From b7930f0aee81517cceae4e9445fa81e0a6fc4884 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:01:31 -0400 Subject: [PATCH 1/4] fix(android): include restBase and restNamespace in GBKit post payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../wordpress/gutenberg/model/GBKitGlobal.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index fdc6568f5..cc1288d2c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -74,6 +74,10 @@ data class GBKitGlobal( val id: Int, // TODO: Instead of the `-1` trick, this should just be `null` for new posts /** The post type (e.g., `post`, `page`). */ val type: String, + /** The REST API base path for this post type (e.g., `posts`, `pages`). */ + val restBase: String, + /** The REST API namespace (e.g., `wp/v2`). */ + val restNamespace: String, /** The post status (e.g., `draft`, `publish`, `pending`). */ val status: String, /** The post title (URL-encoded). */ @@ -110,6 +114,8 @@ data class GBKitGlobal( post = Post( id = postId ?: -1, type = configuration.postType, + restBase = restBaseFor(configuration.postType), + restNamespace = "wp/v2", status = configuration.postStatus, title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() @@ -126,6 +132,18 @@ data class GBKitGlobal( } ) } + + /** + * Maps a post type slug to its WordPress REST API base path. + * + * Defaults to pluralizing the slug for unknown types (e.g., `product` → `products`), + * which matches the WordPress convention for most post types. + */ + private fun restBaseFor(postType: String): String = when (postType) { + "post" -> "posts" + "page" -> "pages" + else -> if (postType.endsWith("s")) postType else "${postType}s" + } } /** From 9f94bef710efb6e2705f7b82e4271b84a9d92070 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 17:51:43 -0400 Subject: [PATCH 2/4] test(android): assert restBase and restNamespace in GBKitGlobal payload Adds coverage for the restBase/restNamespace fields recently added to GBKitGlobal.Post, including the heuristic pluralization for custom post types. --- .../gutenberg/model/GBKitGlobalTest.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index a755ca100..b49068af3 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -117,6 +117,29 @@ class GBKitGlobalTest { assertEquals(-1, globalWithout.post.id) } + @Test + fun `populates restBase and restNamespace for post type`() { + val configuration = makeConfiguration(postType = "post") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals("posts", global.post.restBase) + assertEquals("wp/v2", global.post.restNamespace) + } + + @Test + fun `populates restBase for page post type`() { + val configuration = makeConfiguration(postType = "page") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals("pages", global.post.restBase) + assertEquals("wp/v2", global.post.restNamespace) + } + + @Test + fun `pluralizes custom post type slugs for restBase`() { + val configuration = makeConfiguration(postType = "product") + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals("products", global.post.restBase) + } + @Test fun `maps zero postID to negative one`() { val configuration = makeConfiguration(postId = 0u) From dbf4d1272eff4001699f049b78055df3e70f394c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 17:51:50 -0400 Subject: [PATCH 3/4] fix(api-fetch): apply post fallbacks in filterEndpointsMiddleware The middleware previously bailed out when window.GBKit.post was missing restBase or restNamespace, letting the editor's GET /wp/v2/posts/{id} request through unfiltered. The response (without context=edit) then clobbered the host-provided title and content. Apply the same fallback contract that getPost() already uses ('posts' / 'wp/v2'), centralized in a new POST_FALLBACKS export so both consumers stay in sync. Hosts that omit those fields now get the filter applied with sensible defaults instead of silently slipping past. --- src/utils/api-fetch.js | 11 ++++-- src/utils/api-fetch.test.js | 78 ++++++++++++++++++++++++++++++++++++- src/utils/bridge.js | 39 ++++++++++++------- 3 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 76f891825..711f921e6 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -7,7 +7,7 @@ import { getQueryArg } from '@wordpress/url'; /** * Internal dependencies */ -import { getGBKit } from './bridge'; +import { getGBKit, POST_FALLBACKS } from './bridge'; /** * @typedef {import('@wordpress/api-fetch').APIFetchMiddleware} APIFetchMiddleware @@ -114,13 +114,16 @@ function tokenAuthMiddleware( options, next ) { */ function filterEndpointsMiddleware( options, next ) { const { post } = getGBKit(); - const { id, restNamespace, restBase } = post ?? {}; - if ( id === undefined || ! restNamespace || ! restBase ) { + if ( ! post || post.id === undefined ) { return next( options ); } - const disabledPath = `/${ restNamespace }/${ restBase }/${ id }`; + // Apply the same fallback contract as `getPost()` so the filter still + // engages on hosts whose payload omits restBase/restNamespace. + const restNamespace = post.restNamespace || POST_FALLBACKS.restNamespace; + const restBase = post.restBase || POST_FALLBACKS.restBase; + const disabledPath = `/${ restNamespace }/${ restBase }/${ post.id }`; if ( options.path === disabledPath || diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index 48460ea64..f340f9277 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -22,7 +22,13 @@ import apiFetch from '@wordpress/api-fetch'; import { configureApiFetch } from './api-fetch'; import * as bridge from './bridge'; -vi.mock( './bridge' ); +vi.mock( './bridge', async ( importOriginal ) => { + const actual = await importOriginal(); + return { + ...actual, + getGBKit: vi.fn(), + }; +} ); describe( 'api-fetch credentials handling', () => { let originalFetch; @@ -118,6 +124,76 @@ describe( 'api-fetch credentials handling', () => { expect( options.headers.Authorization ).toBe( 'Bearer override-token' ); } ); + describe( 'filterEndpointsMiddleware', () => { + it( 'filters the post endpoint when restBase and restNamespace are provided', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + post: { + id: 42, + restBase: 'posts', + restNamespace: 'wp/v2', + }, + } ); + + const result = await apiFetch( { path: '/wp/v2/posts/42' } ); + + expect( global.fetch ).not.toHaveBeenCalled(); + expect( result ).toEqual( [] ); + } ); + + it( 'falls back to default restBase and restNamespace when omitted from the payload', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + post: { + id: 7, + // restBase and restNamespace intentionally omitted + }, + } ); + + const result = await apiFetch( { path: '/wp/v2/posts/7' } ); + + expect( global.fetch ).not.toHaveBeenCalled(); + expect( result ).toEqual( [] ); + } ); + + it( 'lets the request through when post id is undefined', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + post: {}, + } ); + + try { + await apiFetch( { path: '/wp/v2/posts/99' } ); + } catch ( error ) { + // Ignore errors from the actual fetch + } + + expect( global.fetch ).toHaveBeenCalled(); + } ); + + it( 'lets the request through when no post payload is present', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + } ); + + try { + await apiFetch( { path: '/wp/v2/posts/99' } ); + } catch ( error ) { + // Ignore errors from the actual fetch + } + + expect( global.fetch ).toHaveBeenCalled(); + } ); + } ); + it( 'should preserve other headers when adding Authorization', async () => { bridge.getGBKit.mockReturnValue( { siteApiRoot: 'https://example.com/wp-json/', diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 7b4933a62..fbc2b8005 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -215,6 +215,19 @@ export function onNetworkRequest( requestData ) { * @property {boolean} [enableNetworkLogging] Enables logging of all network requests/responses to the native host via onNetworkRequest bridge method. */ +/** + * Default values used when the native host omits fields from the post payload. + * Centralized here so all consumers (getPost, filterEndpointsMiddleware) agree + * on the fallback contract. + */ +export const POST_FALLBACKS = { + id: -1, + type: 'post', + restBase: 'posts', + restNamespace: 'wp/v2', + status: 'draft', +}; + /** * Retrieves the native-host-provided GBKit object from localStorage or returns * an empty object if not found. @@ -300,11 +313,11 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); return { - id: post?.id || -1, - type: post?.type || 'post', - restBase: post?.restBase || 'posts', - restNamespace: post?.restNamespace || 'wp/v2', - status: post?.status || 'draft', + id: post?.id || POST_FALLBACKS.id, + type: post?.type || POST_FALLBACKS.type, + restBase: post?.restBase || POST_FALLBACKS.restBase, + restNamespace: post?.restNamespace || POST_FALLBACKS.restNamespace, + status: post?.status || POST_FALLBACKS.status, title: { raw: hostContent.title }, content: { raw: hostContent.content }, }; @@ -313,11 +326,11 @@ export async function getPost() { if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); return { - id: post.id || -1, - type: post.type || 'post', - restBase: post.restBase || 'posts', - restNamespace: post.restNamespace || 'wp/v2', - status: post.status || 'draft', + id: post.id || POST_FALLBACKS.id, + type: post.type || POST_FALLBACKS.type, + restBase: post.restBase || POST_FALLBACKS.restBase, + restNamespace: post.restNamespace || POST_FALLBACKS.restNamespace, + status: post.status || POST_FALLBACKS.status, title: { raw: decodeURIComponent( post.title ) }, content: { raw: decodeURIComponent( post.content ) }, }; @@ -325,11 +338,7 @@ export async function getPost() { // Fallback to default empty post return { - id: -1, - type: 'post', - restBase: 'posts', - restNamespace: 'wp/v2', - status: 'draft', + ...POST_FALLBACKS, title: { raw: '' }, content: { raw: '' }, }; From 944fbfce704642cfe725dc69c03b3cf6d5018854 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 09:50:09 -0400 Subject: [PATCH 4/4] docs(api-fetch): note follow-up for filterEndpointsMiddleware Captures a @todo explaining that this middleware could likely be removed in favor of properly seeding entity content into the store on initialization, and links to the historical commit that added it. --- src/utils/api-fetch.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 711f921e6..5373b3b13 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -111,6 +111,18 @@ function tokenAuthMiddleware( options, next ) { * Middleware to filter out requests to specific endpoints. * * @type {APIFetchMiddleware} + * + * @todo Properly seed the post entity and remove this middleware. + * + * This was added to prevent re-fetching entity content provided by the native + * host app, which can lead to content loss. However, we can likely avoid the + * need for this middleware by ensuring we properly seed the entity content into + * the store on initialization. + * + * This requires hoisting the relevant logic from `useEditorSetup` to occur + * before we render the editor, and invoking `finishResolution`. + * + * See: https://github.com/wordpress-mobile/GutenbergKit/commit/c9b4fc9978a3760ba97f3f5d4359c2bc2155bb80 */ function filterEndpointsMiddleware( options, next ) { const { post } = getGBKit();