feat: trigger editor savePost() lifecycle from native hosts#431
Closed
feat: trigger editor savePost() lifecycle from native hosts#431
Conversation
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>
- 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>
This reverts commit 285a742.
Member
Author
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?
Adds a
savePost()bridge to GutenbergKit that drives the WordPresscore/editorstore 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 → falsetransition to fire side-effect API calls. For example, VideoPress'suseSyncMediahook syncs metadata attributes like rating, privacy, and download settings to/wpcom/v2/videopress/metaonly 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'ssavePost()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):window.editor.savePost()async method that dispatcheseditorStore.savePost().filterEndpointsMiddlewareswallows the post save HTTP request, so the lifecycle completes without duplicating the native app's persistence.iOS library (
EditorViewController.swift):savePost() async throwsthat usesWKWebView.callAsyncJavaScriptto await the JS Promise.Android library (
GutenbergView.kt):savePost(callback)that wraps the call in a small IIFE and routes completion through the existingeditorDelegateJavaScript interface (Android'sWebView.evaluateJavascriptcannot natively await Promises).GBKitGlobal.Postwas missingrestBase/restNamespace, causing the api-fetch middleware to let post fetches through and overwrite local edits.iOS demo app:
savePost()and then persists viaposts.updateCancellation()fromwordpress-rs.PostUpdateParamsfrom the public Swift API.Android demo app:
PostsListActivity(mirroring iOS'sPostsListView) so the user can pick an existing post to edit. Reaches the WordPress REST API throughwordpress-rs'sWpApiClient.SitePreparationActivitythat opens the posts list.savePost()→ read content →posts().update().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:
Additional smoke tests
posts.update()REST call works).Accessibility Testing Instructions
N/A, no user-facing UI 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. ↩