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..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,6 +737,67 @@ class GutenbergView : FrameLayout { } } + private val pendingLifecycleCallbacks = 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. + * + * **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.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 triggerSaveLifecycle(callback: SaveLifecycleCallback) { + if (!isEditorLoaded) { + 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() + 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.triggerSaveLifecycle()" + + ".then(() => editorDelegate.onSaveLifecycleComplete($quotedRequestId, true, null))" + + ".catch((e) => editorDelegate.onSaveLifecycleComplete($quotedRequestId, false, (e && e.message) || String(e)));", + null + ) + } + } + + @JavascriptInterface + fun onSaveLifecycleComplete(requestId: String, success: Boolean, error: String?) { + val callback = pendingLifecycleCallbacks.remove(requestId) ?: return + handler.post { + callback.onComplete(success, error) + } + } + + fun interface SaveLifecycleCallback { + 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") @@ -1025,10 +1086,23 @@ class GutenbergView : FrameLayout { networkRequestListener = null requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null + // 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 drainPendingLifecycleCallbacks(reason: String) { + val pending = synchronized(pendingLifecycleCallbacks) { + val snapshot = pendingLifecycleCallbacks.toMap() + pendingLifecycleCallbacks.clear() + snapshot + } + pending.values.forEach { it.onComplete(false, reason) } + } + // Network Monitoring private fun startNetworkMonitoring() { 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..ec30d62b 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.triggerSaveLifecycle] callback to a coroutine so + * the caller can sequence post-save work (like persisting content via the REST API). + */ +private suspend fun GutenbergView.triggerSaveLifecycleAwait(): Boolean = + suspendCancellableCoroutine { continuation -> + triggerSaveLifecycle { 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.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, @@ -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 lifecycleSucceeded = view.triggerSaveLifecycleAwait() + if (lifecycleSucceeded) { + Log.i("EditorActivity", "editor.triggerSaveLifecycle() completed — editor store save lifecycle fired") + } else { + Log.w("EditorActivity", "editor.triggerSaveLifecycle() 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..be5e4c13 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.triggerSaveLifecycle() + print("editor.triggerSaveLifecycle() completed — editor store save lifecycle fired") + } catch { + print("editor.triggerSaveLifecycle() 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 diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index dbe9fe52..42ef0a95 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -415,6 +415,26 @@ 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. + /// + /// > 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 triggerSaveLifecycle() async throws { + guard isReady else { throw EditorNotReadyError() } + _ = try await webView.callAsyncJavaScript("await editor.triggerSaveLifecycle();", 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/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index cd6ee49b..0148bcf0 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -16,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 ) => { @@ -35,17 +47,11 @@ 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' ); +vi.mock( '@wordpress/notices' ); vi.mock( '@wordpress/blocks' ); vi.mock( '@wordpress/rich-text', () => ( { create: vi.fn( ( { html } ) => ( { @@ -98,6 +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.triggerSaveLifecycle ).toBeTypeOf( 'function' ); expect( window.editor.switchEditorMode ).toBeTypeOf( 'function' ); expect( window.editor.dismissTopModal ).toBeTypeOf( 'function' ); expect( window.editor.focus ).toBeTypeOf( 'function' ); @@ -290,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 ), } ), @@ -325,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 ), } ), @@ -375,9 +382,45 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeUndefined(); expect( window.editor.undo ).toBeUndefined(); expect( window.editor.redo ).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.triggerSaveLifecycle', () => { + it( 'removes the editor-save snackbar after a successful save', async () => { + dispatchMocks.savePost.mockResolvedValueOnce( undefined ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + await window.editor.triggerSaveLifecycle(); + + 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 ) + ); + + await expect( + window.editor.triggerSaveLifecycle() + ).rejects.toThrow( failure ); + + expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); + expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( + 'editor-save' + ); + } ); + } ); } ); diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 073aec4f..e09dea4f 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'; @@ -18,7 +19,9 @@ 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 { removeNotice } = useDispatch( noticesStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); const { updateBlock, selectionChange } = useDispatch( blockEditorStore ); @@ -93,6 +96,18 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; + 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 + // to avoid host errors. + await savePost(); + } finally { + // Native hosts display their own save feedback, disable the default + removeNotice( 'editor-save' ); + } + }; + window.editor.switchEditorMode = ( mode ) => { // Do not return the `Promise` return value to avoid host errors. switchEditorMode( mode ); @@ -193,6 +208,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { delete window.editor.setTitle; delete window.editor.getContent; delete window.editor.getTitleAndContent; + delete window.editor.triggerSaveLifecycle; delete window.editor.undo; delete window.editor.redo; delete window.editor.switchEditorMode; @@ -206,6 +222,8 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { markBridgeReady, getEditedPostAttribute, getEditedPostContent, + savePost, + removeNotice, redo, switchEditorMode, undo,