From ef261630a5acd19f86b088bcf277d92b51639323 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 2 Sep 2026 11:02:08 -0400 Subject: [PATCH 1/2] fix: resolve the REST index locally on namespaced sites Gutenberg's `root`/`__unstableBase` entity requests the REST API index (`/`) during editor initialization. On namespaced sites the path has no segments for the namespace middleware to rewrite, so the request targets the API host's root, which serves no index and fails. Resolve the entity locally with the site URL the host already provides. The remaining fields are either unavailable on namespaced sites or read from the `site` entity by the blocks that use them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017jPW5y3FrRywuHHn3gAHgu --- src/utils/api-fetch.js | 47 +++++++++++++++++++++++++ src/utils/api-fetch.test.js | 68 +++++++++++++++++++++++++++++++++++++ src/utils/bridge.js | 1 + 3 files changed, 116 insertions(+) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 5f8885e4d..2de7c94d3 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -34,6 +34,7 @@ export function configureApiFetch() { apiFetch.use( nativeMediaUploadMiddleware ); apiFetch.use( mediaUploadMiddleware ); apiFetch.use( transformOEmbedApiResponse ); + apiFetch.use( siteIndexMiddleware ); apiFetch.use( apiFetch.createPreloadingMiddleware( preloadData ?? defaultPreloadData ) ); @@ -460,6 +461,52 @@ function transformOEmbedApiResponse( options, next ) { return next( options, next ); } +/** + * Middleware resolving the REST API index locally on namespaced sites. + * + * Gutenberg's `root`/`__unstableBase` entity fetches the REST API index (`/`) + * during editor initialization. On a namespaced site that path has no segments + * for `apiPathModifierMiddleware` to insert the namespace into, so the request + * targets the API host's root, which serves no index. Rather than let the + * request fail, resolve the entity with the fields the host already provides. + * + * Consumers tolerate the remaining fields being absent: the site blocks read + * the `site` entity when the user can edit settings, and client-side media + * processing treats missing image sizes as none. + * + * Runs after the preloading middleware so a host-supplied index entry takes + * precedence. `apiFetch.use()` prepends, so this is registered immediately + * before it. + * + * @type {APIFetchMiddleware} + */ +function siteIndexMiddleware( options, next ) { + const { siteApiNamespace = [], siteURL } = getGBKit(); + const isNamespacedSite = siteApiNamespace.length > 0; + const isGet = ! options.method || options.method.toUpperCase() === 'GET'; + + if ( ! isNamespacedSite || ! isGet || ! isRestIndexPath( options.path ) ) { + return next( options ); + } + + const home = siteURL?.replace( /\/+$/, '' ); + return Promise.resolve( home ? { home, url: home } : {} ); +} + +/** + * Whether a request path targets the REST API index. + * + * @param {string} [path] The request path, e.g. `/?_fields=name`. + * @return {boolean} True for `/` with or without a query string. + */ +function isRestIndexPath( path ) { + if ( typeof path !== 'string' ) { + return false; + } + const pathname = path.split( '?' )[ 0 ]; + return pathname === '' || pathname === '/'; +} + const defaultPreloadData = { '/wp/v2/types?context=view': { body: { diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index f340f9277..2e04d66d2 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -194,6 +194,74 @@ describe( 'api-fetch credentials handling', () => { } ); } ); + describe( 'siteIndexMiddleware', () => { + const indexPath = '/?_fields=name,home,url,image_sizes'; + + it( 'resolves the REST index locally on namespaced sites', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://public-api.example.com/', + siteApiNamespace: [ 'sites/123/' ], + namespaceExcludedPaths: [], + siteURL: 'https://example.com/', + } ); + + const result = await apiFetch( { path: indexPath } ); + + expect( global.fetch ).not.toHaveBeenCalled(); + expect( result ).toEqual( { + home: 'https://example.com', + url: 'https://example.com', + } ); + } ); + + it( 'resolves an empty record when the site URL is unknown', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://public-api.example.com/', + siteApiNamespace: [ 'sites/123/' ], + namespaceExcludedPaths: [], + } ); + + const result = await apiFetch( { path: '/' } ); + + expect( global.fetch ).not.toHaveBeenCalled(); + expect( result ).toEqual( {} ); + } ); + + it( 'requests the REST index from sites without a namespace', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [], + namespaceExcludedPaths: [], + siteURL: 'https://example.com/', + } ); + + await apiFetch( { path: indexPath } ); + + expect( global.fetch ).toHaveBeenCalled(); + const [ url ] = global.fetch.mock.calls[ 0 ]; + expect( url ).toMatch( + /^https:\/\/example\.com\/wp-json\/\?_fields=/ + ); + } ); + + it( 'lets non-index requests through on namespaced sites', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://public-api.example.com/', + siteApiNamespace: [ 'sites/123/' ], + namespaceExcludedPaths: [], + siteURL: 'https://example.com/', + } ); + + try { + await apiFetch( { path: '/wp/v2/posts' } ); + } 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 f04b50830..45f84174d 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -225,6 +225,7 @@ export function onNetworkRequest( requestData ) { * @typedef GBKitConfig * * @property {boolean} [themeStyles] Controls if theme styles are applied to the editor. + * @property {string} [siteURL] The site's home URL. * @property {string} [siteApiRoot] The root URL of the site's API. * @property {string[]} [siteApiNamespace] The namespace of the site's API; if multiple namespaces are provided, the first one is used as the default. * @property {string[]} [namespaceExcludedPaths] The paths that should not be namespaced. From 6e6404cd8872476c54ca6ac2e965a7478c1f5bc0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 4 Sep 2026 09:42:41 -0400 Subject: [PATCH 2/2] fix: omit url from the locally resolved REST index The host supplies a single site URL, which maps to home. The WordPress address has no accurate source on installs where the two differ, and no editor consumer reads it, so leave it unset rather than duplicate home. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017jPW5y3FrRywuHHn3gAHgu --- src/utils/api-fetch.js | 6 ++++-- src/utils/api-fetch.test.js | 5 +---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 2de7c94d3..df9f9bb07 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -468,7 +468,9 @@ function transformOEmbedApiResponse( options, next ) { * during editor initialization. On a namespaced site that path has no segments * for `apiPathModifierMiddleware` to insert the namespace into, so the request * targets the API host's root, which serves no index. Rather than let the - * request fail, resolve the entity with the fields the host already provides. + * request fail, resolve the entity with `home` from the host's site URL. The + * host supplies a single URL, so `url`, the WordPress address, has no accurate + * source and is left unset. * * Consumers tolerate the remaining fields being absent: the site blocks read * the `site` entity when the user can edit settings, and client-side media @@ -490,7 +492,7 @@ function siteIndexMiddleware( options, next ) { } const home = siteURL?.replace( /\/+$/, '' ); - return Promise.resolve( home ? { home, url: home } : {} ); + return Promise.resolve( home ? { home } : {} ); } /** diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index 2e04d66d2..d75339bfe 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -208,10 +208,7 @@ describe( 'api-fetch credentials handling', () => { const result = await apiFetch( { path: indexPath } ); expect( global.fetch ).not.toHaveBeenCalled(); - expect( result ).toEqual( { - home: 'https://example.com', - url: 'https://example.com', - } ); + expect( result ).toEqual( { home: 'https://example.com' } ); } ); it( 'resolves an empty record when the site URL is unknown', async () => {