From 6474a751f646c3168d4cf5ba5fbbc0453edc78e2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 6 Apr 2026 16:25:03 -0400 Subject: [PATCH 01/13] feat: add savePost() bridge to trigger editor store save lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new window.editor.savePost() JS bridge method that dispatches the @wordpress/editor store's savePost action, and a corresponding public savePost() method on EditorViewController. This drives the core/editor store through its full save lifecycle, causing isSavingPost() to transition true → false. Plugins that subscribe to this lifecycle (e.g., VideoPress syncing rating/privacy metadata via /wpcom/v2/videopress/meta) fire their side-effect API calls during this transition. The existing filterEndpointsMiddleware in api-fetch.js already swallows the post save HTTP request, so savePost() runs to completion without duplicating the native app's own persistence. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/EditorViewController.swift | 15 +++++++++++++++ src/components/editor/use-host-bridge.js | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index dbe9fe52..3c136dbb 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -415,6 +415,21 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.redo();") } + /// Triggers the editor store's save lifecycle. + /// + /// This drives the WordPress `core/editor` store through its full save flow, + /// causing `isSavingPost()` to transition `true` → `false`. Plugins that + /// subscribe to this lifecycle (e.g., VideoPress syncing metadata) will fire + /// their side-effect API calls. + /// + /// The actual post content is **not** persisted by this method — the host app + /// is responsible for reading content via ``getTitleAndContent()`` and saving + /// it through its own REST API calls. + public func savePost() async throws { + guard isReady else { throw EditorNotReadyError() } + _ = try await webView.callAsyncJavaScript("await editor.savePost();", in: nil, contentWorld: .page) + } + /// Dismisses the topmost modal dialog or menu in the editor public func dismissTopModal() { guard isReady else { return } diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 073aec4f..7d1980a2 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -18,7 +18,8 @@ window.editor = window.editor || {}; export function useHostBridge( post, editorRef, markBridgeReady ) { const { editEntityRecord } = useDispatch( coreStore ); - const { undo, redo, switchEditorMode } = useDispatch( editorStore ); + const { undo, redo, switchEditorMode, savePost } = + useDispatch( editorStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); const { updateBlock, selectionChange } = useDispatch( blockEditorStore ); @@ -93,6 +94,10 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; + window.editor.savePost = async () => { + await savePost(); + }; + window.editor.switchEditorMode = ( mode ) => { // Do not return the `Promise` return value to avoid host errors. switchEditorMode( mode ); @@ -193,6 +198,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { delete window.editor.setTitle; delete window.editor.getContent; delete window.editor.getTitleAndContent; + delete window.editor.savePost; delete window.editor.undo; delete window.editor.redo; delete window.editor.switchEditorMode; @@ -206,6 +212,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { markBridgeReady, getEditedPostAttribute, getEditedPostContent, + savePost, redo, switchEditorMode, undo, From 6b49fb62719a7a578ca1b86835296d5dc78fa4fb Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 6 Apr 2026 19:33:05 -0400 Subject: [PATCH 02/13] feat(android): add savePost() to GutenbergView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a savePost(callback) method to GutenbergView that triggers the editor store's save lifecycle (mirroring the iOS EditorViewController change). This drives the core/editor store through its full save flow, causing isSavingPost() to transition true → false. Plugins that subscribe to this lifecycle (e.g., VideoPress syncing metadata) fire their side-effect API calls during this transition. Unlike iOS's WKWebView.callAsyncJavaScript, Android's WebView cannot await JavaScript Promises, so completion is routed back via a .then() callback on the existing editorDelegate JavaScript interface. A new @JavascriptInterface method (onSavePostComplete) dispatches to per-request callbacks keyed by UUID. The actual post content is not persisted by this method — host apps (like WordPress-Android) are responsible for reading content via getTitleAndContent() and saving it through their own REST API calls. The callback fires only after the editor store's save lifecycle completes, so plugin side-effects have settled before the host retrieves content for persistence. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../org/wordpress/gutenberg/GutenbergView.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 5184c26c..02beefd4 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -737,6 +737,56 @@ class GutenbergView : FrameLayout { } } + private val pendingSaveCallbacks = Collections.synchronizedMap(mutableMapOf()) + + /** + * Triggers the editor store's save lifecycle and invokes [callback] when it completes. + * + * This drives the WordPress `core/editor` store through its full save flow, causing + * `isSavingPost()` to transition `true` → `false`. Plugins that subscribe to this + * lifecycle (e.g., VideoPress syncing metadata via `/wpcom/v2/videopress/meta`) fire + * their side-effect API calls during this transition. + * + * The actual post content is **not** persisted by this method — the host app is + * responsible for reading content via [getTitleAndContent] and saving it through + * its own REST API calls. The callback fires only after the editor store's save + * lifecycle completes, so it is safe to read and persist content at that point. + * + * Note: `window.editor.savePost()` is an async JS function that returns a Promise. + * Android's `WebView.evaluateJavascript` cannot await Promises (unlike iOS's + * `WKWebView.callAsyncJavaScript`), so we dispatch the call and route completion + * back via the `editorDelegate` JavaScript interface. + */ + fun savePost(callback: SavePostCallback) { + if (!isEditorLoaded) { + Log.e("GutenbergView", "You can't save until the editor has loaded") + callback.onComplete(false, "Editor not loaded") + return + } + val requestId = java.util.UUID.randomUUID().toString() + pendingSaveCallbacks[requestId] = callback + handler.post { + webView.evaluateJavascript( + "editor.savePost()" + + ".then(() => editorDelegate.onSavePostComplete('$requestId', true, null))" + + ".catch((e) => editorDelegate.onSavePostComplete('$requestId', false, String(e)));", + null + ) + } + } + + @JavascriptInterface + fun onSavePostComplete(requestId: String, success: Boolean, error: String?) { + val callback = pendingSaveCallbacks.remove(requestId) ?: return + handler.post { + callback.onComplete(success, error) + } + } + + fun interface SavePostCallback { + fun onComplete(success: Boolean, error: String?) + } + fun appendTextAtCursor(text: String) { if (!isEditorLoaded) { Log.e("GutenbergView", "You can't append text until the editor has loaded") From 68a696ced59b2299a6fa076268f5683a107d275c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:17:53 -0400 Subject: [PATCH 03/13] refactor: harden savePost bridge and clarify host contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - js: assign `window.editor.savePost` directly so the Promise reaches native hosts (`callAsyncJavaScript` etc.); add comment explaining the divergence from the `undo`/`redo` wrappers. - ios/android: doc that hosts must still persist content if `savePost` fails — a misbehaving plugin must not block the user from saving. - android: route the request id through `JSONObject.quote()` to keep the JS string interpolation safe even if non-UUID values land here. - android: drain `pendingSaveCallbacks` in `onDetachedFromWindow` so coroutines awaiting `savePost` unblock instead of leaking when the view is torn down mid-save. - tests: extend `use-host-bridge.test.jsx` to cover `savePost` registration and cleanup; teach the `@wordpress/data` mock to return real `vi.fn()` actions so destructured dispatches assign functions. Co-Authored-By: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/data.js | 15 ++++++++++- .../org/wordpress/gutenberg/GutenbergView.kt | 26 +++++++++++++++++-- .../Sources/EditorViewController.swift | 5 ++++ .../editor/test/use-host-bridge.test.jsx | 2 ++ src/components/editor/use-host-bridge.js | 8 +++--- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js index bdfdf749..0cbae4b9 100644 --- a/__mocks__/@wordpress/data.js +++ b/__mocks__/@wordpress/data.js @@ -1,6 +1,19 @@ import { vi } from 'vitest'; -export const useDispatch = vi.fn( () => ( {} ) ); +// Returns mock dispatched actions for stores referenced by editor code +// (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`). +// Returning `vi.fn()` rather than `undefined` lets `useHostBridge` assign +// destructured actions directly (e.g. `window.editor.savePost = savePost`) +// without silently producing `undefined` values. +export const useDispatch = vi.fn( () => ( { + undo: vi.fn(), + redo: vi.fn(), + savePost: vi.fn(), + switchEditorMode: vi.fn(), + editEntityRecord: vi.fn(), + updateBlock: vi.fn(), + selectionChange: vi.fn(), +} ) ); export const useSelect = vi.fn( ( selector ) => { if ( typeof selector === 'function' ) { return selector( () => ( {} ) ); diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 02beefd4..40225ae2 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -752,6 +752,11 @@ class GutenbergView : FrameLayout { * its own REST API calls. The callback fires only after the editor store's save * lifecycle completes, so it is safe to read and persist content at that point. * + * **Important:** if the callback reports `success = false` (for example because a + * third-party plugin subscribed to the lifecycle errored out), hosts should still + * proceed to read and persist content. A misbehaving plugin must not block the + * user from saving their work — log the lifecycle failure and continue. + * * Note: `window.editor.savePost()` is an async JS function that returns a Promise. * Android's `WebView.evaluateJavascript` cannot await Promises (unlike iOS's * `WKWebView.callAsyncJavaScript`), so we dispatch the call and route completion @@ -765,11 +770,15 @@ class GutenbergView : FrameLayout { } val requestId = java.util.UUID.randomUUID().toString() pendingSaveCallbacks[requestId] = callback + // Quote the requestId for safe JS string interpolation. UUIDs are safe + // today, but routing all values through `JSONObject.quote()` ensures we + // never accidentally inject untrusted strings into the JS context. + val quotedRequestId = JSONObject.quote(requestId) handler.post { webView.evaluateJavascript( "editor.savePost()" + - ".then(() => editorDelegate.onSavePostComplete('$requestId', true, null))" + - ".catch((e) => editorDelegate.onSavePostComplete('$requestId', false, String(e)));", + ".then(() => editorDelegate.onSavePostComplete($quotedRequestId, true, null))" + + ".catch((e) => editorDelegate.onSavePostComplete($quotedRequestId, false, String(e)));", null ) } @@ -1075,10 +1084,23 @@ class GutenbergView : FrameLayout { networkRequestListener = null requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null + // Fail any save callbacks still waiting on a JS Promise — without this, + // coroutines awaiting `savePost()` would hang forever (and leak whatever + // they captured) when the view is torn down mid-save. + drainPendingSaveCallbacks("View detached") handler.removeCallbacksAndMessages(null) webView.destroy() } + private fun drainPendingSaveCallbacks(reason: String) { + val pending = synchronized(pendingSaveCallbacks) { + val snapshot = pendingSaveCallbacks.toMap() + pendingSaveCallbacks.clear() + snapshot + } + pending.values.forEach { it.onComplete(false, reason) } + } + // Network Monitoring private fun startNetworkMonitoring() { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 3c136dbb..1416161a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -425,6 +425,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// The actual post content is **not** persisted by this method — the host app /// is responsible for reading content via ``getTitleAndContent()`` and saving /// it through its own REST API calls. + /// + /// > Important: If this call throws (for example, because a third-party plugin + /// > subscribed to the lifecycle errors out), hosts should still proceed to + /// > read and persist content. A misbehaving plugin must not block the user + /// > from saving their work — log the lifecycle failure and continue. public func savePost() async throws { guard isReady else { throw EditorNotReadyError() } _ = try await webView.callAsyncJavaScript("await editor.savePost();", in: nil, contentWorld: .page) diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index cd6ee49b..91b733a5 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -98,6 +98,7 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeTypeOf( 'function' ); expect( window.editor.undo ).toBeTypeOf( 'function' ); expect( window.editor.redo ).toBeTypeOf( 'function' ); + expect( window.editor.savePost ).toBeTypeOf( 'function' ); expect( window.editor.switchEditorMode ).toBeTypeOf( 'function' ); expect( window.editor.dismissTopModal ).toBeTypeOf( 'function' ); expect( window.editor.focus ).toBeTypeOf( 'function' ); @@ -375,6 +376,7 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeUndefined(); expect( window.editor.undo ).toBeUndefined(); expect( window.editor.redo ).toBeUndefined(); + expect( window.editor.savePost ).toBeUndefined(); expect( window.editor.switchEditorMode ).toBeUndefined(); expect( window.editor.dismissTopModal ).toBeUndefined(); expect( window.editor.focus ).toBeUndefined(); diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 7d1980a2..4358b21f 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -94,9 +94,11 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; - window.editor.savePost = async () => { - await savePost(); - }; + // Unlike `undo`/`redo`/`switchEditorMode` above, we intentionally + // expose the underlying Promise here so native hosts can `await` the + // editor store's full save lifecycle (e.g., iOS uses + // `WKWebView.callAsyncJavaScript` to wait for completion). + window.editor.savePost = savePost; window.editor.switchEditorMode = ( mode ) => { // Do not return the `Promise` return value to avoid host errors. From 438fa6c744365f72f48f50a1c3f2b5467123b25e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:08:51 -0400 Subject: [PATCH 04/13] fix: suppress editor save snackbar in host bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core/editor`'s save lifecycle dispatches a "Draft saved." (or "Post updated.") snackbar via `core/notices` with the stable id `editor-save`. Native hosts that drive `savePost()` from the bridge own their own save UI (toasts, alerts), so the editor's web snackbar just competes with the host's feedback. Wrap the bridge's `window.editor.savePost` to remove the `editor-save` notice in a `finally` block, regardless of whether the save succeeds or fails. This is preferable to passing `{ isAutosave: true }` to `savePost()` — that would also suppress the notice, but it reroutes the request to `/wp/v2/{type}/{id}/autosaves` and changes which fields are persisted, defeating the lifecycle hosts depend on. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/test/use-host-bridge.test.jsx | 1 + src/components/editor/use-host-bridge.js | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 91b733a5..a35561be 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -46,6 +46,7 @@ vi.mock( '@wordpress/data', () => ( { } ) ); vi.mock( '@wordpress/core-data' ); vi.mock( '@wordpress/editor' ); +vi.mock( '@wordpress/notices' ); vi.mock( '@wordpress/blocks' ); vi.mock( '@wordpress/rich-text', () => ( { create: vi.fn( ( { html } ) => ( { diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 4358b21f..33534259 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -5,6 +5,7 @@ import { useEffect, useCallback, useRef } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; +import { store as noticesStore } from '@wordpress/notices'; import { parse, serialize, getBlockType } from '@wordpress/blocks'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { insert, create, toHTMLString } from '@wordpress/rich-text'; @@ -20,6 +21,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { const { editEntityRecord } = useDispatch( coreStore ); const { undo, redo, switchEditorMode, savePost } = useDispatch( editorStore ); + const { removeNotice } = useDispatch( noticesStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); const { updateBlock, selectionChange } = useDispatch( blockEditorStore ); @@ -98,7 +100,20 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { // expose the underlying Promise here so native hosts can `await` the // editor store's full save lifecycle (e.g., iOS uses // `WKWebView.callAsyncJavaScript` to wait for completion). - window.editor.savePost = savePost; + // + // We also suppress the "Draft saved." / "Post updated." snackbar that + // `core/editor` dispatches as part of the save lifecycle: native hosts + // own their own UI for save feedback (toasts, alerts, etc.), and the + // editor's web snackbar would just compete with them. The notice has a + // stable id (`editor-save`) which we remove on both success and + // failure paths. + window.editor.savePost = async () => { + try { + return await savePost(); + } finally { + removeNotice( 'editor-save' ); + } + }; window.editor.switchEditorMode = ( mode ) => { // Do not return the `Promise` return value to avoid host errors. @@ -215,6 +230,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { getEditedPostAttribute, getEditedPostContent, savePost, + removeNotice, redo, switchEditorMode, undo, From 25b1f50c6db70fd04c7178147ad3acdd2a84ae05 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:13:42 -0400 Subject: [PATCH 05/13] test: cover savePost snackbar removal in host bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new test cases lock in the snackbar suppression added in the previous commit: - The success path: `await window.editor.savePost()` resolves and `removeNotice('editor-save')` is dispatched. - The failure path: `savePost` rejects, the rejection still propagates to the host, and `removeNotice('editor-save')` is still dispatched (because it's in a `finally`). These guard against two regressions: a future Gutenberg version renaming the `editor-save` notice id (manual QA only would catch), and a future "simplification" of the wrapper that drops the `finally` and lets the snackbar back in. Stabilize the `__mocks__/@wordpress/data.js` mock so `useDispatch()` returns the same shared actions object on every call. Tests need to read the captured mock after the hook runs (e.g. `const { savePost } = useDispatch()`), so the mock fns must be reference-stable — otherwise `mockResolvedValueOnce` and `toHaveBeenCalled` would target a different fn than the hook captured. Co-Authored-By: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/data.js | 19 +++++++-- .../editor/test/use-host-bridge.test.jsx | 40 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js index 0cbae4b9..30d1943a 100644 --- a/__mocks__/@wordpress/data.js +++ b/__mocks__/@wordpress/data.js @@ -1,11 +1,19 @@ import { vi } from 'vitest'; -// Returns mock dispatched actions for stores referenced by editor code -// (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`). +// Stable map of mock dispatched actions for stores referenced by editor +// code (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`, +// and core/notices' `removeNotice`). +// // Returning `vi.fn()` rather than `undefined` lets `useHostBridge` assign // destructured actions directly (e.g. `window.editor.savePost = savePost`) // without silently producing `undefined` values. -export const useDispatch = vi.fn( () => ( { +// +// Returning the *same* object on every call is important for tests that +// need to read the captured mock after the hook runs — e.g. `const +// { savePost } = useDispatch()` in a test must yield the very same +// `vi.fn` the hook destructured, so `mockResolvedValueOnce` and +// `toHaveBeenCalled` work end-to-end. +const dispatchedActions = { undo: vi.fn(), redo: vi.fn(), savePost: vi.fn(), @@ -13,7 +21,10 @@ export const useDispatch = vi.fn( () => ( { editEntityRecord: vi.fn(), updateBlock: vi.fn(), selectionChange: vi.fn(), -} ) ); + removeNotice: vi.fn(), +}; + +export const useDispatch = vi.fn( () => dispatchedActions ); export const useSelect = vi.fn( ( selector ) => { if ( typeof selector === 'function' ) { return selector( () => ( {} ) ); diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index a35561be..fda46fb7 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -4,6 +4,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; + /** * Internal dependencies */ @@ -383,4 +388,39 @@ describe( 'useHostBridge', () => { expect( window.editor.focus ).toBeUndefined(); expect( window.editor.appendTextAtCursor ).toBeUndefined(); } ); + + describe( 'window.editor.savePost', () => { + it( 'removes the editor-save snackbar after a successful save', async () => { + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + // `useDispatch` is mocked to return the same shared actions + // object for every call (see `__mocks__/@wordpress/data.js`), + // so we can grab the action mocks here regardless of which + // store was passed. + const { savePost, removeNotice } = useDispatch(); + savePost.mockResolvedValueOnce( undefined ); + + await window.editor.savePost(); + + expect( savePost ).toHaveBeenCalledTimes( 1 ); + expect( removeNotice ).toHaveBeenCalledWith( 'editor-save' ); + } ); + + it( 'removes the editor-save snackbar even when the save fails', async () => { + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const { savePost, removeNotice } = useDispatch(); + const failure = new Error( 'plugin lifecycle error' ); + savePost.mockRejectedValueOnce( failure ); + + await expect( window.editor.savePost() ).rejects.toThrow( failure ); + + expect( savePost ).toHaveBeenCalledTimes( 1 ); + expect( removeNotice ).toHaveBeenCalledWith( 'editor-save' ); + } ); + } ); } ); From cec8ae996e7f5cdf0965ee33b1cf99d394b1d2fb Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:20:47 -0400 Subject: [PATCH 06/13] test: localize useDispatch mock for savePost coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the savePost snackbar tests to declare their `vi.mock` factory locally with `vi.hoisted` references, instead of digging the mock out of `useDispatch()` at runtime. Tests now hold direct references to `dispatchMocks.savePost` and `dispatchMocks.removeNotice`, which is the idiomatic vitest pattern and removes the hidden invariant from the global mock (the previous commit required `__mocks__/@wordpress/data.js` to return a stable shared object so tests could capture the same fns the hook destructured — that's surprising for anyone reading the global mock). Reverts the global mock to its pre-coverage state (no `removeNotice`, fresh fns per call) since no other test depends on the stable-object behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/data.js | 19 ++---- .../editor/test/use-host-bridge.test.jsx | 62 +++++++++---------- 2 files changed, 34 insertions(+), 47 deletions(-) diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js index 30d1943a..0cbae4b9 100644 --- a/__mocks__/@wordpress/data.js +++ b/__mocks__/@wordpress/data.js @@ -1,19 +1,11 @@ import { vi } from 'vitest'; -// Stable map of mock dispatched actions for stores referenced by editor -// code (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`, -// and core/notices' `removeNotice`). -// +// Returns mock dispatched actions for stores referenced by editor code +// (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`). // Returning `vi.fn()` rather than `undefined` lets `useHostBridge` assign // destructured actions directly (e.g. `window.editor.savePost = savePost`) // without silently producing `undefined` values. -// -// Returning the *same* object on every call is important for tests that -// need to read the captured mock after the hook runs — e.g. `const -// { savePost } = useDispatch()` in a test must yield the very same -// `vi.fn` the hook destructured, so `mockResolvedValueOnce` and -// `toHaveBeenCalled` work end-to-end. -const dispatchedActions = { +export const useDispatch = vi.fn( () => ( { undo: vi.fn(), redo: vi.fn(), savePost: vi.fn(), @@ -21,10 +13,7 @@ const dispatchedActions = { editEntityRecord: vi.fn(), updateBlock: vi.fn(), selectionChange: vi.fn(), - removeNotice: vi.fn(), -}; - -export const useDispatch = vi.fn( () => dispatchedActions ); +} ) ); export const useSelect = vi.fn( ( selector ) => { if ( typeof selector === 'function' ) { return selector( () => ( {} ) ); diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index fda46fb7..f3cac3a4 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -4,11 +4,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; -/** - * WordPress dependencies - */ -import { useDispatch } from '@wordpress/data'; - /** * Internal dependencies */ @@ -21,8 +16,20 @@ const mockGetSelectedBlockClientId = vi.fn(); const mockGetBlock = vi.fn(); const mockGetSelectionStart = vi.fn(); const mockGetSelectionEnd = vi.fn(); -const mockUpdateBlock = vi.fn(); -const mockSelectionChange = vi.fn(); + +// Hoisted so the `vi.mock` factory below can capture references to the +// same spies the tests assert on. `vi.mock` is hoisted above imports, +// so plain top-level `const`s aren't visible to its factory. +const dispatchMocks = vi.hoisted( () => ( { + savePost: vi.fn(), + removeNotice: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + switchEditorMode: vi.fn(), + editEntityRecord: vi.fn(), + updateBlock: vi.fn(), + selectionChange: vi.fn(), +} ) ); vi.mock( '@wordpress/data', () => ( { useSelect: ( store ) => { @@ -40,14 +47,7 @@ vi.mock( '@wordpress/data', () => ( { getSelectionEnd: mockGetSelectionEnd, }; }, - useDispatch: () => ( { - editEntityRecord: vi.fn(), - undo: vi.fn(), - redo: vi.fn(), - switchEditorMode: vi.fn(), - updateBlock: mockUpdateBlock, - selectionChange: mockSelectionChange, - } ), + useDispatch: vi.fn( () => dispatchMocks ), } ) ); vi.mock( '@wordpress/core-data' ); vi.mock( '@wordpress/editor' ); @@ -297,7 +297,7 @@ describe( 'useHostBridge', () => { const result = window.editor.appendTextAtCursor( ' appended' ); expect( result ).toBe( true ); - expect( mockUpdateBlock ).toHaveBeenCalledWith( 'block-1', { + expect( dispatchMocks.updateBlock ).toHaveBeenCalledWith( 'block-1', { attributes: expect.objectContaining( { content: expect.any( String ), } ), @@ -332,7 +332,7 @@ describe( 'useHostBridge', () => { const result = window.editor.appendTextAtCursor( ' World' ); expect( result ).toBe( true ); - expect( mockUpdateBlock ).toHaveBeenCalledWith( 'block-1', { + expect( dispatchMocks.updateBlock ).toHaveBeenCalledWith( 'block-1', { attributes: expect.objectContaining( { content: expect.any( String ), } ), @@ -391,36 +391,34 @@ describe( 'useHostBridge', () => { describe( 'window.editor.savePost', () => { it( 'removes the editor-save snackbar after a successful save', async () => { + dispatchMocks.savePost.mockResolvedValueOnce( undefined ); + renderHook( () => useHostBridge( defaultPost, editorRef, markBridgeReady ) ); - // `useDispatch` is mocked to return the same shared actions - // object for every call (see `__mocks__/@wordpress/data.js`), - // so we can grab the action mocks here regardless of which - // store was passed. - const { savePost, removeNotice } = useDispatch(); - savePost.mockResolvedValueOnce( undefined ); - await window.editor.savePost(); - expect( savePost ).toHaveBeenCalledTimes( 1 ); - expect( removeNotice ).toHaveBeenCalledWith( 'editor-save' ); + expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); + expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( + 'editor-save' + ); } ); it( 'removes the editor-save snackbar even when the save fails', async () => { + const failure = new Error( 'plugin lifecycle error' ); + dispatchMocks.savePost.mockRejectedValueOnce( failure ); + renderHook( () => useHostBridge( defaultPost, editorRef, markBridgeReady ) ); - const { savePost, removeNotice } = useDispatch(); - const failure = new Error( 'plugin lifecycle error' ); - savePost.mockRejectedValueOnce( failure ); - await expect( window.editor.savePost() ).rejects.toThrow( failure ); - expect( savePost ).toHaveBeenCalledTimes( 1 ); - expect( removeNotice ).toHaveBeenCalledWith( 'editor-save' ); + expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); + expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( + 'editor-save' + ); } ); } ); } ); From 238b2dcc6c803385441be2a9dd62436b0bfc0b45 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:23:59 -0400 Subject: [PATCH 07/13] test: revert global useDispatch mock to its trunk shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded action map I added to `__mocks__/@wordpress/data.js` in the chunk 1 commit (`refactor: harden savePost bridge…`) was only needed by the new `savePost` host-bridge tests, which the previous commit moved to a local `vi.mock` factory with its own `vi.hoisted` `dispatchMocks`. No other test file consumes the global mock's action map: `use-host-bridge.test.jsx` now provides its own factory, and `src/utils/editor.test.jsx` uses `vi.mock( import( '@wordpress/data' ), { spy: true } )` which spies on the real module and ignores the manual mock entirely. Restore the file to byte-identical parity with trunk so the global mock stays minimal and surprises nobody. Co-Authored-By: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/data.js | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js index 0cbae4b9..bdfdf749 100644 --- a/__mocks__/@wordpress/data.js +++ b/__mocks__/@wordpress/data.js @@ -1,19 +1,6 @@ import { vi } from 'vitest'; -// Returns mock dispatched actions for stores referenced by editor code -// (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`). -// Returning `vi.fn()` rather than `undefined` lets `useHostBridge` assign -// destructured actions directly (e.g. `window.editor.savePost = savePost`) -// without silently producing `undefined` values. -export const useDispatch = vi.fn( () => ( { - undo: vi.fn(), - redo: vi.fn(), - savePost: vi.fn(), - switchEditorMode: vi.fn(), - editEntityRecord: vi.fn(), - updateBlock: vi.fn(), - selectionChange: vi.fn(), -} ) ); +export const useDispatch = vi.fn( () => ( {} ) ); export const useSelect = vi.fn( ( selector ) => { if ( typeof selector === 'function' ) { return selector( () => ( {} ) ); From d73d5e94c9c90ea7bbab8d4914fe4b44d560ccd9 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:33:19 -0400 Subject: [PATCH 08/13] docs: Reduce comment length --- src/components/editor/use-host-bridge.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 33534259..576772d7 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -96,21 +96,11 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; - // Unlike `undo`/`redo`/`switchEditorMode` above, we intentionally - // expose the underlying Promise here so native hosts can `await` the - // editor store's full save lifecycle (e.g., iOS uses - // `WKWebView.callAsyncJavaScript` to wait for completion). - // - // We also suppress the "Draft saved." / "Post updated." snackbar that - // `core/editor` dispatches as part of the save lifecycle: native hosts - // own their own UI for save feedback (toasts, alerts, etc.), and the - // editor's web snackbar would just compete with them. The notice has a - // stable id (`editor-save`) which we remove on both success and - // failure paths. window.editor.savePost = async () => { try { return await savePost(); } finally { + // Native hosts display their own save feedback, disable the default removeNotice( 'editor-save' ); } }; From 7c0101d6987a640d4ef4871d46be0f38b49a9274 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:59:25 -0400 Subject: [PATCH 09/13] fix(demo): always persist content even when savePost lifecycle fails A misbehaving plugin that throws during the editor store's save lifecycle must not block the user from saving their work. Both demo apps now log the lifecycle failure as a warning and proceed to read and persist the post content via the REST API regardless. Persist failures (the part the user actually cares about) now surface to the UI: an Alert on iOS, a Toast on Android. Lifecycle failures remain log-only since the user has no recourse and the persist still succeeds. Aligns log strings between platforms so cross-platform debugging is uniform: "editor.savePost() completed", "editor.savePost() lifecycle failed; persisting anyway", "Post N persisted via REST API", "Failed to persist post N". Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/gutenbergkit/EditorActivity.kt | 27 +++++++++++++++++ ios/Demo-iOS/Sources/Views/EditorView.swift | 29 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index ab173102..49d59fa2 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -328,8 +328,26 @@ fun EditorScreen( } } +/** + * Suspends until the editor store's save lifecycle completes. + * + * Bridges the [GutenbergView.savePost] callback to a coroutine so the caller + * can sequence post-save work (like persisting content via the REST API). + */ +private suspend fun GutenbergView.savePostAwait(): Boolean = + suspendCancellableCoroutine { continuation -> + savePost { success, _ -> + if (continuation.isActive) continuation.resume(success) + } + } + /** * Reads the latest title/content from the editor and PUTs it to the WordPress REST API. + * + * Triggers [GutenbergView.savePost] first so plugin side-effects (e.g., VideoPress + * syncing metadata) settle before the content is read and persisted. A lifecycle + * failure must NOT block the user from saving their work — the warning is logged + * and persistence proceeds anyway. */ private suspend fun persistPost( context: Context, @@ -338,6 +356,15 @@ private suspend fun persistPost( accountId: ULong, postId: UInt ): String? { + // 1. Trigger the editor store save lifecycle so plugins fire side-effects. + val saveSucceeded = view.savePostAwait() + if (saveSucceeded) { + Log.i("EditorActivity", "editor.savePost() completed — editor store save lifecycle fired") + } else { + Log.w("EditorActivity", "editor.savePost() lifecycle failed; persisting anyway") + } + + // 2. Persist post content via REST API. return try { val titleAndContent = suspendCancellableCoroutine> { cont -> view.getTitleAndContent( diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 23dcf736..1aefc995 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -30,6 +30,18 @@ struct EditorView: View { viewModel: viewModel ) .toolbar { toolbar } + .alert( + "Save failed", + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + ), + presenting: viewModel.errorMessage + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } } @ToolbarContentBuilder @@ -145,8 +157,21 @@ private struct _EditorView: UIViewControllerRepresentable { viewController.isCodeEditorEnabled = viewModel.isCodeEditorEnabled } - /// Persists the post via the REST API. + /// Triggers the editor store save lifecycle, then persists the post via the REST API. + /// + /// A lifecycle failure must **not** block the user from saving their work — the + /// warning is logged and persistence proceeds anyway. Persist failures surface + /// through `viewModel.errorMessage`, which the parent view binds to an `Alert`. private func persistPost(viewController: EditorViewController, viewModel: EditorViewModel) async { + // 1. Trigger the editor store save lifecycle so plugins fire side-effects. + do { + try await viewController.savePost() + print("editor.savePost() completed — editor store save lifecycle fired") + } catch { + print("editor.savePost() lifecycle failed; persisting anyway: \(error)") + } + + // 2. Persist post content via REST API. guard let apiClient, let postID = configuration.postID else { return } do { let titleAndContent = try await viewController.getTitleAndContent() @@ -169,6 +194,7 @@ private struct _EditorView: UIViewControllerRepresentable { print("Post \(postID) persisted via REST API") } catch { print("Failed to persist post \(postID): \(error)") + viewModel.errorMessage = "Failed to save post: \(error.localizedDescription)" } } @@ -272,6 +298,7 @@ private final class EditorViewModel { var isCodeEditorEnabled = false var isSaving = false var isEditorReady = false + var errorMessage: String? var hasPostID = false From 39d3b6380cfddae83b5329d1564a9bbaf16ab6f0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 13:19:32 -0400 Subject: [PATCH 10/13] fix(host-bridge): avoid returning savePost() result to hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `core/editor` store's `savePost()` can resolve with a non-clonable value, which would break `WKWebView.callAsyncJavaScript` on iOS. Await the Promise so hosts can still sequence persistence after the lifecycle settles, but drop the return value — matching the `switchEditorMode` precedent. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/editor/use-host-bridge.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 576772d7..6867b486 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -98,7 +98,10 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { window.editor.savePost = async () => { try { - return await savePost(); + // Await the lifecycle so hosts can sequence persistence after + // plugin side-effects settle, do not return the `Promise` return value + // to avoid host errors. + await savePost(); } finally { // Native hosts display their own save feedback, disable the default removeNotice( 'editor-save' ); From 858f08b8b642885ba356670a32a235cc2b955af5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 13:20:21 -0400 Subject: [PATCH 11/13] fix(android): deliver savePost not-ready callback on handler thread The `isEditorLoaded` early-return invoked the callback synchronously on the caller's thread, while the success/failure paths deliver via `handler.post`. Route the early-return through the handler too so callers see a consistent callback thread regardless of outcome. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 40225ae2..d29a37ce 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -765,7 +765,9 @@ class GutenbergView : FrameLayout { fun savePost(callback: SavePostCallback) { if (!isEditorLoaded) { Log.e("GutenbergView", "You can't save until the editor has loaded") - callback.onComplete(false, "Editor not loaded") + handler.post { + callback.onComplete(false, "Editor not loaded") + } return } val requestId = java.util.UUID.randomUUID().toString() From 64e2627902dd269ad6359e97c7f21bb904c021e1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 13:21:22 -0400 Subject: [PATCH 12/13] fix(android): prefer e.message when stringifying savePost rejections Non-Error rejection values passed through `String(e)` degrade to `[object Object]` in logs, losing the diagnostic. Prefer `e.message` when present and fall back to `String(e)` otherwise. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index d29a37ce..3a5d3d87 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -780,7 +780,7 @@ class GutenbergView : FrameLayout { webView.evaluateJavascript( "editor.savePost()" + ".then(() => editorDelegate.onSavePostComplete($quotedRequestId, true, null))" + - ".catch((e) => editorDelegate.onSavePostComplete($quotedRequestId, false, String(e)));", + ".catch((e) => editorDelegate.onSavePostComplete($quotedRequestId, false, (e && e.message) || String(e)));", null ) } From aba02cbe76aa5381dabce9814551339a7932c351 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 13:25:35 -0400 Subject: [PATCH 13/13] refactor(host-bridge): rename savePost to triggerSaveLifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge method doesn't actually persist content — that's the host app's job. Naming it `savePost` invited hosts to assume persistence and obscured the lifecycle-only contract. Rename across all three layers so the method name reflects what it does. - JS: `window.editor.savePost` → `window.editor.triggerSaveLifecycle` - iOS: `EditorViewController.savePost()` → `triggerSaveLifecycle()` - Android: `GutenbergView.savePost()` → `triggerSaveLifecycle()`, `SavePostCallback` → `SaveLifecycleCallback`, `onSavePostComplete` → `onSaveLifecycleComplete` The `core/editor` store's own `savePost()` action is still invoked internally from the JS bridge — only the host-facing surface is renamed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../org/wordpress/gutenberg/GutenbergView.kt | 44 +++++++++---------- .../example/gutenbergkit/EditorActivity.kt | 24 +++++----- ios/Demo-iOS/Sources/Views/EditorView.swift | 6 +-- .../Sources/EditorViewController.swift | 4 +- .../editor/test/use-host-bridge.test.jsx | 12 ++--- src/components/editor/use-host-bridge.js | 4 +- 6 files changed, 48 insertions(+), 46 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 3a5d3d87..d705ca17 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -737,7 +737,7 @@ class GutenbergView : FrameLayout { } } - private val pendingSaveCallbacks = Collections.synchronizedMap(mutableMapOf()) + private val pendingLifecycleCallbacks = Collections.synchronizedMap(mutableMapOf()) /** * Triggers the editor store's save lifecycle and invokes [callback] when it completes. @@ -757,44 +757,44 @@ class GutenbergView : FrameLayout { * proceed to read and persist content. A misbehaving plugin must not block the * user from saving their work — log the lifecycle failure and continue. * - * Note: `window.editor.savePost()` is an async JS function that returns a Promise. - * Android's `WebView.evaluateJavascript` cannot await Promises (unlike iOS's - * `WKWebView.callAsyncJavaScript`), so we dispatch the call and route completion - * back via the `editorDelegate` JavaScript interface. + * Note: `window.editor.triggerSaveLifecycle()` is an async JS function that returns + * a Promise. Android's `WebView.evaluateJavascript` cannot await Promises (unlike + * iOS's `WKWebView.callAsyncJavaScript`), so we dispatch the call and route + * completion back via the `editorDelegate` JavaScript interface. */ - fun savePost(callback: SavePostCallback) { + fun triggerSaveLifecycle(callback: SaveLifecycleCallback) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't save until the editor has loaded") + Log.e("GutenbergView", "You can't trigger the save lifecycle until the editor has loaded") handler.post { callback.onComplete(false, "Editor not loaded") } return } val requestId = java.util.UUID.randomUUID().toString() - pendingSaveCallbacks[requestId] = callback + pendingLifecycleCallbacks[requestId] = callback // Quote the requestId for safe JS string interpolation. UUIDs are safe // today, but routing all values through `JSONObject.quote()` ensures we // never accidentally inject untrusted strings into the JS context. val quotedRequestId = JSONObject.quote(requestId) handler.post { webView.evaluateJavascript( - "editor.savePost()" + - ".then(() => editorDelegate.onSavePostComplete($quotedRequestId, true, null))" + - ".catch((e) => editorDelegate.onSavePostComplete($quotedRequestId, false, (e && e.message) || String(e)));", + "editor.triggerSaveLifecycle()" + + ".then(() => editorDelegate.onSaveLifecycleComplete($quotedRequestId, true, null))" + + ".catch((e) => editorDelegate.onSaveLifecycleComplete($quotedRequestId, false, (e && e.message) || String(e)));", null ) } } @JavascriptInterface - fun onSavePostComplete(requestId: String, success: Boolean, error: String?) { - val callback = pendingSaveCallbacks.remove(requestId) ?: return + fun onSaveLifecycleComplete(requestId: String, success: Boolean, error: String?) { + val callback = pendingLifecycleCallbacks.remove(requestId) ?: return handler.post { callback.onComplete(success, error) } } - fun interface SavePostCallback { + fun interface SaveLifecycleCallback { fun onComplete(success: Boolean, error: String?) } @@ -1086,18 +1086,18 @@ class GutenbergView : FrameLayout { networkRequestListener = null requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null - // Fail any save callbacks still waiting on a JS Promise — without this, - // coroutines awaiting `savePost()` would hang forever (and leak whatever - // they captured) when the view is torn down mid-save. - drainPendingSaveCallbacks("View detached") + // Fail any lifecycle callbacks still waiting on a JS Promise — without + // this, coroutines awaiting `triggerSaveLifecycle()` would hang forever + // (and leak whatever they captured) when the view is torn down mid-save. + drainPendingLifecycleCallbacks("View detached") handler.removeCallbacksAndMessages(null) webView.destroy() } - private fun drainPendingSaveCallbacks(reason: String) { - val pending = synchronized(pendingSaveCallbacks) { - val snapshot = pendingSaveCallbacks.toMap() - pendingSaveCallbacks.clear() + private fun drainPendingLifecycleCallbacks(reason: String) { + val pending = synchronized(pendingLifecycleCallbacks) { + val snapshot = pendingLifecycleCallbacks.toMap() + pendingLifecycleCallbacks.clear() snapshot } pending.values.forEach { it.onComplete(false, reason) } diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 49d59fa2..ec30d62b 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -331,12 +331,12 @@ fun EditorScreen( /** * Suspends until the editor store's save lifecycle completes. * - * Bridges the [GutenbergView.savePost] callback to a coroutine so the caller - * can sequence post-save work (like persisting content via the REST API). + * Bridges the [GutenbergView.triggerSaveLifecycle] callback to a coroutine so + * the caller can sequence post-save work (like persisting content via the REST API). */ -private suspend fun GutenbergView.savePostAwait(): Boolean = +private suspend fun GutenbergView.triggerSaveLifecycleAwait(): Boolean = suspendCancellableCoroutine { continuation -> - savePost { success, _ -> + triggerSaveLifecycle { success, _ -> if (continuation.isActive) continuation.resume(success) } } @@ -344,10 +344,10 @@ private suspend fun GutenbergView.savePostAwait(): Boolean = /** * Reads the latest title/content from the editor and PUTs it to the WordPress REST API. * - * Triggers [GutenbergView.savePost] first so plugin side-effects (e.g., VideoPress - * syncing metadata) settle before the content is read and persisted. A lifecycle - * failure must NOT block the user from saving their work — the warning is logged - * and persistence proceeds anyway. + * Triggers [GutenbergView.triggerSaveLifecycle] first so plugin side-effects + * (e.g., VideoPress syncing metadata) settle before the content is read and + * persisted. A lifecycle failure must NOT block the user from saving their + * work — the warning is logged and persistence proceeds anyway. */ private suspend fun persistPost( context: Context, @@ -357,11 +357,11 @@ private suspend fun persistPost( postId: UInt ): String? { // 1. Trigger the editor store save lifecycle so plugins fire side-effects. - val saveSucceeded = view.savePostAwait() - if (saveSucceeded) { - Log.i("EditorActivity", "editor.savePost() completed — editor store save lifecycle fired") + val lifecycleSucceeded = view.triggerSaveLifecycleAwait() + if (lifecycleSucceeded) { + Log.i("EditorActivity", "editor.triggerSaveLifecycle() completed — editor store save lifecycle fired") } else { - Log.w("EditorActivity", "editor.savePost() lifecycle failed; persisting anyway") + Log.w("EditorActivity", "editor.triggerSaveLifecycle() failed; persisting anyway") } // 2. Persist post content via REST API. diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 1aefc995..be5e4c13 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -165,10 +165,10 @@ private struct _EditorView: UIViewControllerRepresentable { private func persistPost(viewController: EditorViewController, viewModel: EditorViewModel) async { // 1. Trigger the editor store save lifecycle so plugins fire side-effects. do { - try await viewController.savePost() - print("editor.savePost() completed — editor store save lifecycle fired") + try await viewController.triggerSaveLifecycle() + print("editor.triggerSaveLifecycle() completed — editor store save lifecycle fired") } catch { - print("editor.savePost() lifecycle failed; persisting anyway: \(error)") + print("editor.triggerSaveLifecycle() failed; persisting anyway: \(error)") } // 2. Persist post content via REST API. diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1416161a..42ef0a95 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -430,9 +430,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// > subscribed to the lifecycle errors out), hosts should still proceed to /// > read and persist content. A misbehaving plugin must not block the user /// > from saving their work — log the lifecycle failure and continue. - public func savePost() async throws { + public func triggerSaveLifecycle() async throws { guard isReady else { throw EditorNotReadyError() } - _ = try await webView.callAsyncJavaScript("await editor.savePost();", in: nil, contentWorld: .page) + _ = try await webView.callAsyncJavaScript("await editor.triggerSaveLifecycle();", in: nil, contentWorld: .page) } /// Dismisses the topmost modal dialog or menu in the editor diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index f3cac3a4..0148bcf0 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -104,7 +104,7 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeTypeOf( 'function' ); expect( window.editor.undo ).toBeTypeOf( 'function' ); expect( window.editor.redo ).toBeTypeOf( 'function' ); - expect( window.editor.savePost ).toBeTypeOf( 'function' ); + expect( window.editor.triggerSaveLifecycle ).toBeTypeOf( 'function' ); expect( window.editor.switchEditorMode ).toBeTypeOf( 'function' ); expect( window.editor.dismissTopModal ).toBeTypeOf( 'function' ); expect( window.editor.focus ).toBeTypeOf( 'function' ); @@ -382,14 +382,14 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeUndefined(); expect( window.editor.undo ).toBeUndefined(); expect( window.editor.redo ).toBeUndefined(); - expect( window.editor.savePost ).toBeUndefined(); + expect( window.editor.triggerSaveLifecycle ).toBeUndefined(); expect( window.editor.switchEditorMode ).toBeUndefined(); expect( window.editor.dismissTopModal ).toBeUndefined(); expect( window.editor.focus ).toBeUndefined(); expect( window.editor.appendTextAtCursor ).toBeUndefined(); } ); - describe( 'window.editor.savePost', () => { + describe( 'window.editor.triggerSaveLifecycle', () => { it( 'removes the editor-save snackbar after a successful save', async () => { dispatchMocks.savePost.mockResolvedValueOnce( undefined ); @@ -397,7 +397,7 @@ describe( 'useHostBridge', () => { useHostBridge( defaultPost, editorRef, markBridgeReady ) ); - await window.editor.savePost(); + await window.editor.triggerSaveLifecycle(); expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( @@ -413,7 +413,9 @@ describe( 'useHostBridge', () => { useHostBridge( defaultPost, editorRef, markBridgeReady ) ); - await expect( window.editor.savePost() ).rejects.toThrow( failure ); + await expect( + window.editor.triggerSaveLifecycle() + ).rejects.toThrow( failure ); expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 6867b486..e09dea4f 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -96,7 +96,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; - window.editor.savePost = async () => { + window.editor.triggerSaveLifecycle = async () => { try { // Await the lifecycle so hosts can sequence persistence after // plugin side-effects settle, do not return the `Promise` return value @@ -208,7 +208,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { delete window.editor.setTitle; delete window.editor.getContent; delete window.editor.getTitleAndContent; - delete window.editor.savePost; + delete window.editor.triggerSaveLifecycle; delete window.editor.undo; delete window.editor.redo; delete window.editor.switchEditorMode;