feat: trigger editor savePost() lifecycle from native hosts#435
Open
feat: trigger editor savePost() lifecycle from native hosts#435
Conversation
dcalhoun
commented
Apr 8, 2026
| // 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(); |
Member
Author
There was a problem hiding this comment.
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.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
4f05fd6 to
aba02cb
Compare
Member
Author
|
Rebased now that #433 merged. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What?
Note
This stacks atop #433. The ability to edit existing posts is required for testing the
savePost()lifecycle.Adds a
savePost()lifecycle bridge so native hosts can trigger the WordPresscore/editorstore's save flow without persisting content via the editor.Why?
Ref CMM-2024.
Plugins like VideoPress need to fire side-effect API calls (e.g., metadata syncing) when the editor's save lifecycle runs. Given the native host app, not the web editor, manages saving, those side-effects never ran.
Exposing the lifecycle as a bridge method that hosts can call allows host-driven saves to trigger the same
core/editorstore flow that plugins subscribe to, so plugin side-effects fire as expected.The bridge is lifecycle-only: it does not persist post content. Hosts remain responsible for reading content via
getTitleAndContent()and PUTting it through their own REST API calls. The existingfilterEndpointsMiddleware(hardened in #432) already swallows the editor's own post-save HTTP request, so callingsavePost()does not cause a duplicate REST round-trip.How?
dispatch('core/editor').savePost()and removes theeditor-savenotice in afinallyblock. The call is added to the host bridge's exposedwindow.editorAPI and cleaned up on hook unmount.callAsyncJavaScriptso the Promise returned byeditor.savePost()is awaited natively. A documented contract clarifies that lifecycle ≠ persistence and that plugin failures must not block saves.evaluateJavascript, so the bridge dispatches the call with a UUID request ID, captures completion ineditorDelegate.onSavePostComplete(requestId, success, error), and routes it back to the per-request callback.onDetachedFromWindow()drains any pending callbacks withsuccess = falseso coroutines awaiting the bridge don't hang on view teardown.Testing Instructions
This PR is best tested by editing a post that contains a VideoPress block, since VideoPress is the canonical plugin that depends on the save lifecycle for metadata sync.
Prerequisites:
A WordPress.com (or Jetpack-connected) site with VideoPress enabled
An existing post on that site containing at least one VideoPress block
The site added to the demo app via OAuth1 (WP.com) or Application Passwords (self-hosted)
Temporarily allow the VideoPress block type:
Steps:
Accessibility Testing Instructions
N/A, no navigation changes.
Screenshots or screencast
gbk-save-post-lifecycle.mov
Footnotes
Certain VideoPress requests fail with when using the dev server due to CORS, use
make buildinstead. ↩