Skip to content

feat: trigger editor savePost() lifecycle from native hosts#435

Open
dcalhoun wants to merge 13 commits intotrunkfrom
feat/save-post-lifecycle-bridge
Open

feat: trigger editor savePost() lifecycle from native hosts#435
dcalhoun wants to merge 13 commits intotrunkfrom
feat/save-post-lifecycle-bridge

Conversation

@dcalhoun
Copy link
Copy Markdown
Member

@dcalhoun dcalhoun commented Apr 8, 2026

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 WordPress core/editor store'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/editor store 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 existing filterEndpointsMiddleware (hardened in #432) already swallows the editor's own post-save HTTP request, so calling savePost() does not cause a duplicate REST round-trip.

How?

  • JS dispatches dispatch('core/editor').savePost() and removes the editor-save notice in a finally block. The call is added to the host bridge's exposed window.editor API and cleaned up on hook unmount.
  • iOS uses callAsyncJavaScript so the Promise returned by editor.savePost() is awaited natively. A documented contract clarifies that lifecycle ≠ persistence and that plugin failures must not block saves.
  • Android can't await Promises through evaluateJavascript, so the bridge dispatches the call with a UUID request ID, captures completion in editorDelegate.onSavePostComplete(requestId, success, error), and routes it back to the per-request callback. onDetachedFromWindow() drains any pending callbacks with success = false so 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:

    diff --git a/src/utils/blocks.js b/src/utils/blocks.js
    index 52a39f97..fd47f2ab 100644
    --- a/src/utils/blocks.js
    +++ b/src/utils/blocks.js
    @@ -17,7 +17,10 @@ export function unregisterDisallowedBlocks( allowedBlockTypes ) {
    
      const unregisteredBlocks = [];
      getBlockTypes().forEach( ( block ) => {
    -		if ( ! allowedBlockTypes.includes( block.name ) ) {
    +		if (
    +			! allowedBlockTypes.includes( block.name ) &&
    +			block.name !== 'videopress/video'
    +		) {
          unregisterBlockType( block.name );
          unregisteredBlocks.push( block.name );
        }
    

Steps:

  1. Open the demo app (iOS or Android) and authenticate with the test site
  2. From the site preparation screen, select Post as the Post Type and tap Browse
  3. From the posts list, tap the post containing the VideoPress block
  4. In the editor, tap the VideoPress block to select it, then open the block inspector
  5. Change the Rating from its current value (e.g., G → PG-13)
  6. Tap the Save button in the top-right toolbar
  7. Wait for the save to complete (the button briefly disables)
  8. Open the same post in the WordPress site's web editor
  9. Inspect the VideoPress block — verify the Rating reflects the value you set in step 5

Accessibility Testing Instructions

N/A, no navigation changes.

Screenshots or screencast

gbk-save-post-lifecycle.mov

Footnotes

  1. Certain VideoPress requests fail with when using the dev server due to CORS, use make build instead.

@github-actions github-actions bot added the [Type] Enhancement A suggestion for improvement. label Apr 8, 2026
@dcalhoun dcalhoun marked this pull request as ready for review April 8, 2026 17:54
@dcalhoun dcalhoun requested a review from jkmassel April 8, 2026 17:54
// 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.

Base automatically changed from feat/demo-edit-existing-posts to trunk April 8, 2026 20:33
dcalhoun and others added 13 commits April 8, 2026 16:34
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>
@dcalhoun dcalhoun force-pushed the feat/save-post-lifecycle-bridge branch from 4f05fd6 to aba02cb Compare April 8, 2026 20:37
@dcalhoun
Copy link
Copy Markdown
Member Author

dcalhoun commented Apr 8, 2026

Rebased now that #433 merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant