Skip to content

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

Closed
dcalhoun wants to merge 28 commits intotrunkfrom
feat/trigger-editor-save-post-action
Closed

feat: trigger editor savePost() lifecycle from native hosts#431
dcalhoun wants to merge 28 commits intotrunkfrom
feat/trigger-editor-save-post-action

Conversation

@dcalhoun
Copy link
Copy Markdown
Member

@dcalhoun dcalhoun commented Apr 7, 2026

What?

Adds a savePost() bridge to GutenbergKit that drives the WordPress core/editor store through its full save lifecycle. Wires it up to a "Save" button in both demo apps, which also persist post content via the WordPress REST API.

Why?

Ref CMM-2024.

Plugins that subscribe to the editor store's save lifecycle (e.g., VideoPress) rely on the isSavingPost() true → false transition to fire side-effect API calls. For example, VideoPress's useSyncMedia hook syncs metadata attributes like rating, privacy, and download settings to /wpcom/v2/videopress/meta only when it observes that transition.

In GutenbergKit today, the native host calls editor.getTitleAndContent() to read content and persists it via its own REST API call. The editor store's savePost() action is never dispatched, so plugins never see the lifecycle transition — and metadata changes silently fail to persist.

This PR exposes a savePost() bridge so the native host can drive the lifecycle before reading content for persistence, restoring plugin compatibility.

How?

Web/JS layer (src/components/editor/use-host-bridge.js):

  • New window.editor.savePost() async method that dispatches editorStore.savePost().
  • The existing filterEndpointsMiddleware swallows the post save HTTP request, so the lifecycle completes without duplicating the native app's persistence.

iOS library (EditorViewController.swift):

  • Public savePost() async throws that uses WKWebView.callAsyncJavaScript to await the JS Promise.

Android library (GutenbergView.kt):

  • Public savePost(callback) that wraps the call in a small IIFE and routes completion through the existing editorDelegate JavaScript interface (Android's WebView.evaluateJavascript cannot natively await Promises).
  • Also fixes a pre-existing bug where GBKitGlobal.Post was missing restBase/restNamespace, causing the api-fetch middleware to let post fetches through and overwrite local edits.

iOS demo app:

  • Adds a "Save" button to the editor toolbar that calls savePost() and then persists via posts.updateCancellation() from wordpress-rs.
  • Requires Automattic/wordpress-rs#1270 to expose PostUpdateParams from the public Swift API.

Android demo app:

  • Adds a PostsListActivity (mirroring iOS's PostsListView) so the user can pick an existing post to edit. Reaches the WordPress REST API through wordpress-rs's WpApiClient.
  • Adds a "Browse" button beneath the Post Type picker in SitePreparationActivity that opens the posts list.
  • Adds a "SAVE" button to the editor toolbar that mirrors the iOS Save flow: savePost() → read content → posts().update().
  • Cleans up the editor's overflow menu to remove permanently-disabled items (Preview, Post Settings, Help, Revisions, block/word count).

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 (e.g., https://your-site/wp-admin/post.php?post=PostID&action=edit)
  9. Inspect the VideoPress block — verify the Rating reflects the value you set in step 5

Additional smoke tests

  • Title or paragraph edits made before tapping Save should also persist (verifies posts.update() REST call works).
  • The Save button should be disabled for new drafts (postId is null) since the demo only supports updating existing posts.

Accessibility Testing Instructions

N/A, no user-facing UI 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.

dcalhoun and others added 13 commits April 7, 2026 06:09
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 "Save" button to the demo app's editor toolbar. Tapping it:

1. Calls EditorViewController.savePost() to trigger the editor store's
   save lifecycle, so plugins (e.g., VideoPress) fire their side-effect
   API calls.
2. Reads the latest title/content via getTitleAndContent() and persists
   the post via WordPressAPI's posts.updateCancellation() call.

The API client is threaded from PostsListView through RunnableEditor
and into EditorView. The Save button is disabled for new drafts
(where postID is nil) since updating requires an existing post ID.

Requires the PostUpdateParams export added in wordpress-rs
feat/export-post-update-params branch.

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>
Updates the wordpress-rs SPM dependency to track the pr-build/1270
branch, which contains the PostUpdateParams export needed by the
demo app's Save button to persist posts via the REST API.

See: Automattic/wordpress-rs#1270

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new PostsListActivity that fetches and displays posts from a
WordPress site so the user can pick one to edit. Mirrors the iOS
PostsListView in functionality.

- GutenbergKitApplication.createApiClient() builds a WpApiClient from
  a stored Account (works for both self-hosted Application Passwords
  and WP.com OAuth flows).
- PostsListActivity uses the client to call posts.listWithEditContext()
  with pagination, then launches EditorActivity with the selected
  post's ID, title, and content pre-filled.
- Registered in AndroidManifest.xml.

Required so the Android demo can exercise the Save button flow added
in a follow-up commit, which needs a real post ID to PUT to the REST
API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wires the Save button in EditorActivity to:

1. Call GutenbergView.savePost() to trigger the editor store's save
   lifecycle so plugins (e.g., VideoPress) fire their side-effect API
   calls.
2. Read the latest title/content via getTitleAndContent() and persist
   the post via WpApiClient's posts().update() call.

Also adds a "Browse" button to SitePreparationActivity (visible only
for authenticated sites) that launches PostsListActivity to pick an
existing post to edit. The selected post's ID is threaded through to
EditorActivity via a new EXTRA_ACCOUNT_ID intent extra so the Save
handler can reconstruct the API client.

The Save button is disabled for new drafts (where postId is null)
since updating requires an existing post ID.

Requires the PostUpdateParams export from wordpress-rs PR
Automattic/wordpress-rs#1270 (already exposed via uniffi for Kotlin).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The api-fetch filterEndpointsMiddleware blocks server fetches for the
post being edited (preventing them from overwriting local edits with
stale or context-mismatched data). The middleware bails out early when
either restBase or restNamespace is missing from window.GBKit.post:

    if ( id === undefined || ! restNamespace || ! restBase ) {
        return next( options );  // lets the request through
    }

iOS already populates these from EditorConfiguration.postType
(PostTypeDetails struct), but Android's GBKitGlobal.Post only had
{ id, type, status, title, content }. As a result, when the editor
mounted with an existing post ID, WordPress core data fetched the
post via GET /wp/v2/posts/{id} unfiltered, and the response (without
context=edit) overwrote the title and content set from the native
host with empty values — causing the editor to briefly show the
loaded post before clearing it.

Adds restBase and restNamespace fields to GBKitGlobal.Post and derives
them from the postType slug ("post" → "posts", "page" → "pages",
custom types pluralized).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Matches the iOS demo's layout where the "Browse" action sits below the
Post Type picker in the Feature Configuration card, rather than in the
top app bar alongside Start.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The toolbar previously showed both "Save" and a non-functional "PUBLISH"
button. Removes the disabled Publish button so Save is the only
top-level action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Change R.string.save to "SAVE" so the toolbar button matches the
  styling of the previous PUBLISH button it replaced.
- Remove the unused R.string.publish (the disabled Publish button was
  removed in an earlier commit).
- Remove the disabled "Save" item from the editor's overflow menu so
  the toolbar Save button is the sole save action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PostListParams.status defaults to publish-only on the server side,
hiding drafts from the demo's posts list. Pass PostStatus.Any
explicitly so all statuses (draft, pending, future, etc.) appear in
the picker, matching the iOS demo's behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removes the placeholder Preview, Revisions, Post Settings, Help, and
the static block/word/character count footer from the editor's more
menu. Only the working "Code Editor / Visual Editor" toggle remains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removes the placeholder Preview, Post Settings, and Help dropdown menu
items from the editor toolbar. Only the working "Code Editor / Visual
Editor" toggle remains. Also drops the now-unused string resources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added the [Type] Enhancement A suggestion for improvement. label Apr 7, 2026
dcalhoun and others added 15 commits April 7, 2026 11:17
- 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>
Ports the iOS `PostTypeDetails` struct to Android as a Kotlin data
class with `post`/`page` companion constants. `EditorConfiguration`,
`EditorPreloadList`, `GBKitGlobal`, and `EditorService` now carry the
full post type details (slug + restBase + restNamespace) instead of
just the slug, matching the iOS API surface 1:1.

This deletes the `restBaseFor()` heuristic in `GBKitGlobal` that
incorrectly pluralized custom post-type slugs, and fixes a related
bug in `EditorPreloadList.buildPostPath()` where the preload key was
hardcoded to `/wp/v2/posts/$id` even when editing a page.

The demo app still threads a string slug internally; a small
`slugToPostTypeDetails()` helper bridges to the new API. Chunk 3
will replace that helper with real REST data from `wordpress-rs`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`RESTAPIRepository.buildPostUrl` was hardcoded to `/wp/v2/posts/$id`,
so opening a `page` (or any non-post type) hit the wrong endpoint and
the WordPress REST API returned `rest_post_invalid_id`. Use the
configuration's `restNamespace`/`restBase` instead, matching iOS
(`RESTAPIRepository.swift:110-120`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the hardcoded `post`/`page` picker with a dynamic list
fetched via `wordpress-rs`'s `postTypes.listWithEditContext()`,
mirroring the iOS `SitePreparationView.loadPostTypes()` flow.

The view-model now stores `postTypes: List<PostTypeDetails>` and
`selectedPostType: PostTypeDetails?`, deletes the
`slugToPostTypeDetails` placeholder, and threads the selected type
straight into `EditorConfiguration` and `PostsListActivity`. The
post-type filter matches iOS: always include `post`/`page`, include
custom types only when `viewable && visibility.showUi`, exclude all
internal built-ins (`Attachment`, `WpBlock`, etc.).

`PostsListActivity` now takes a `PostTypeDetails` extra (Parcelable)
instead of a string slug, and dispatches the endpoint type from
`postType.postType` so the existing `Posts`/`Pages`/`Custom`
`when` keeps working for the standard cases. The picker shows
"Loading post types…" while the list is empty and falls back to
`PostTypeDetails.post` if the REST call fails so the editor can
still launch.

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>
- Move the long save closure body out of `viewModel.saveHandler =`
  and into a `private func persistPost(...)` on `_EditorView`. The
  saveHandler closure is now a one-liner that delegates, mirroring
  how `viewModel.perform` is wired for undo/redo while keeping the
  actual save logic readable as a regular method.
- Add an inline comment to `RunnableEditor` explaining why
  `apiClient` is excluded from `==` and `hash(into:)`: `WordPressAPI`
  isn't `Hashable`/`Equatable` (it owns native Rust state), and two
  editors with the same configuration but different client instances
  should be treated as equal for navigation/identity purposes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…fix imports

- Drop the manual pagination loop in `PostsListViewModel.loadPosts` —
  iOS doesn't paginate either, and a single 20-post page is plenty for
  the demo. Removes ~25 lines and a hidden N-request fan-out on busy
  sites.
- Inline the `PostsListViewModelFactory` as an anonymous object in
  `onCreate`. The standalone factory class was 12 lines of pure
  boilerplate for a one-shot screen with immutable args.
- Move the activity's hardcoded UI strings ("Posts", "Back", "Browse",
  "No posts found", "Error loading posts", "Failed to save post") to
  `strings.xml`. Save error toast messages also use string resources
  with a positional argument for the underlying error.
- Clean up fully-qualified imports introduced earlier in this branch:
  `android.content.Context`, `androidx.compose.ui.platform.LocalContext`,
  and `org.wordpress.gutenberg.model.EditorDependencies`. These now use
  short names with proper imports at the top of each file.
- Refresh `app/detekt-baseline.xml` to absorb the legitimate
  `LongMethod`/`LongParameterList`/`TooGenericExceptionCaught` cases
  added by this branch's earlier commits — they're inherent to the
  Compose composables and the demo's intentionally permissive error
  handling. Three pre-existing `UseCheckOrError` warnings are gone now
  that the catch sites use `error()` instead of `throw IllegalStateException`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After fetching post types from the REST API, prefer `post` over the
first item in the alphabetically-sorted list. Without this the picker
defaulted to `page` (alphabetical first), which was surprising — `post`
is the conventional default and matches what users expect from the
prior hardcoded picker.

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>
The overflow menu cleanup (removing permanently-disabled Preview, Post
Settings, Help, Revisions, and block/word count items) is unrelated to
the savePost lifecycle work this PR targets. Restore the trunk shape on
both demo apps to keep the diff focused.

The disabled "Save" item on Android is intentionally not restored — the
new Save toolbar button this PR adds supersedes it, and showing both a
working and a disabled "Save" would be confusing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dcalhoun
Copy link
Copy Markdown
Member Author

dcalhoun commented Apr 8, 2026

Superseded by #432, #433, and #435. This was split up to make testing and review easier.

@dcalhoun dcalhoun closed this Apr 8, 2026
@dcalhoun dcalhoun deleted the feat/trigger-editor-save-post-action branch April 8, 2026 16:03
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