Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,67 @@ class GutenbergView : FrameLayout {
}
}

private val pendingLifecycleCallbacks = Collections.synchronizedMap(mutableMapOf<String, SaveLifecycleCallback>())

/**
* 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")
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Pair<CharSequence, CharSequence>> { cont ->
view.getTitleAndContent(
Expand Down
29 changes: 28 additions & 1 deletion ios/Demo-iOS/Sources/Views/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)"
}
}

Expand Down Expand Up @@ -272,6 +298,7 @@ private final class EditorViewModel {
var isCodeEditorEnabled = false
var isSaving = false
var isEditorReady = false
var errorMessage: String?

var hasPostID = false

Expand Down
20 changes: 20 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
67 changes: 55 additions & 12 deletions src/components/editor/test/use-host-bridge.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) => {
Expand All @@ -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 } ) => ( {
Expand Down Expand Up @@ -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' );
Expand Down Expand Up @@ -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 ),
} ),
Expand Down Expand Up @@ -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 ),
} ),
Expand Down Expand Up @@ -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'
);
} );
} );
} );
20 changes: 19 additions & 1 deletion src/components/editor/use-host-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 );
Expand Down Expand Up @@ -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();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that invoking savePost() may mean we cannot remove the filterEndpointsMiddleware as referenced in #432 (comment) and #434.

It may still be worthwhile to properly seed the content, but I believe invoking savePost() triggers a PUT request for the same endpoint. There may also be defense in depth by retaining the middleware.

} 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 );
Expand Down Expand Up @@ -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;
Expand All @@ -206,6 +222,8 @@ export function useHostBridge( post, editorRef, markBridgeReady ) {
markBridgeReady,
getEditedPostAttribute,
getEditedPostContent,
savePost,
removeNotice,
redo,
switchEditorMode,
undo,
Expand Down
Loading