From 17199f7d676d3ddead61cc303a3e71cd32eaace1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Oct 2026 15:22:44 -0400 Subject: [PATCH 01/12] fix: seed the post entity like core before the editor renders GBK's post type entity configs shadowed core's, which lack the edit context, pre-persist edits, and revision URLs, and the post's resolution only stayed skipped because its fetch failed. Seed the post into core's entities before rendering, mark it resolved, and fill in the fields the host doesn't send from the preloaded server record. Co-Authored-By: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- src/components/editor/use-editor-setup.js | 44 ------- src/utils/editor.jsx | 2 + src/utils/editor.test.jsx | 13 ++ src/utils/post-type-entities.js | 93 +++++++++++--- src/utils/post-type-entities.test.js | 60 +++++++++ src/utils/prime-post-entity.js | 87 +++++++++++++ src/utils/prime-post-entity.test.js | 144 ++++++++++++++++++++++ 7 files changed, 382 insertions(+), 61 deletions(-) create mode 100644 src/utils/post-type-entities.test.js create mode 100644 src/utils/prime-post-entity.js create mode 100644 src/utils/prime-post-entity.test.js diff --git a/src/components/editor/use-editor-setup.js b/src/components/editor/use-editor-setup.js index af0fa1819..7f7515c65 100644 --- a/src/components/editor/use-editor-setup.js +++ b/src/components/editor/use-editor-setup.js @@ -1,19 +1,11 @@ import { useEffect } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; -import { postTypeEntities } from '../../utils/post-type-entities'; export function useEditorSetup( post ) { - const { addEntities, receiveEntityRecords } = useDispatch( coreStore ); const { setEditedPost, setupEditor } = useDispatch( editorStore ); useEffect( () => { - const entities = buildEntitiesForPostType( post ); - - addEntities( entities ); - receiveEntityRecords( 'postType', post.type, post ); - setupEditor( post, {} ); // Temp, check why this isn't being called in the provider. @@ -22,39 +14,3 @@ export function useEditorSetup( post ) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [] ); } - -/** - * Builds the entity configuration list for the given post type. - * - * If the post type is already in the static list (post, page, etc.), - * returns the static list as-is. Otherwise, dynamically creates an - * entity configuration using the restBase and restNamespace from the post object. - * - * @param {Object} post - The post object with type, restBase, and restNamespace - * @return {Array} Array of entity configurations - */ -function buildEntitiesForPostType( post ) { - const isRegistered = postTypeEntities.some( - ( entity ) => entity.name === post.type - ); - - if ( isRegistered ) { - return postTypeEntities; - } - - const dynamicEntity = { - kind: 'postType', - name: post.type, - baseURL: `/${ post.restNamespace }/${ post.restBase }`, - transientEdits: { - blocks: true, - selection: true, - }, - mergedEdits: { - meta: true, - }, - rawAttributes: [ 'title', 'excerpt', 'content' ], - }; - - return [ ...postTypeEntities, dynamicEntity ]; -} diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index eaa3e10b7..501e2f174 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -7,6 +7,7 @@ import Layout from '../components/layout'; import { unregisterDisallowedBlocks } from './blocks'; import { getGBKit, getPost } from './bridge'; import { getDefaultEditorSettings } from './editor-settings'; +import { primePostEntity } from './prime-post-entity'; /** * Configure editor settings and styles, and render the editor. @@ -41,6 +42,7 @@ export async function initializeEditor( { unregisterDisallowedBlocks( allowedBlockTypes ); const post = await getPost(); + await primePostEntity( post ); createRoot( document.getElementById( 'root' ) ).render( diff --git a/src/utils/editor.test.jsx b/src/utils/editor.test.jsx index 0c76f35e3..f6e9370a4 100644 --- a/src/utils/editor.test.jsx +++ b/src/utils/editor.test.jsx @@ -5,6 +5,7 @@ import { initializeEditor } from './editor'; import { getGBKit, getPost } from './bridge'; import { getDefaultEditorSettings } from './editor-settings'; import { unregisterDisallowedBlocks } from './blocks'; +import { primePostEntity } from './prime-post-entity'; vi.mock( '@wordpress/blocks' ); vi.mock( '@wordpress/editor' ); @@ -13,6 +14,7 @@ vi.mock( '@wordpress/block-library' ); vi.mock( './blocks' ); vi.mock( './bridge' ); vi.mock( './editor-settings' ); +vi.mock( './prime-post-entity', () => ( { primePostEntity: vi.fn() } ) ); vi.mock( '../components/layout', () => ( { default: () => null, } ) ); @@ -145,4 +147,15 @@ describe( 'initializeEditor', () => { expect( registerCoreBlocks ).toHaveBeenCalled(); } ); + + it( 'should seed the post entity', async () => { + await initializeEditor(); + + expect( primePostEntity ).toHaveBeenCalledWith( { + id: 1, + type: 'post', + title: { raw: 'Test Post' }, + content: { raw: '' }, + } ); + } ); } ); diff --git a/src/utils/post-type-entities.js b/src/utils/post-type-entities.js index 29db977ad..da6342fec 100644 --- a/src/utils/post-type-entities.js +++ b/src/utils/post-type-entities.js @@ -1,17 +1,76 @@ -export const postTypeEntities = [ - { name: 'post', baseURL: '/wp/v2/posts' }, - { name: 'page', baseURL: '/wp/v2/pages' }, - { name: 'attachment', baseURL: '/wp/v2/media' }, - { name: 'wp_block', baseURL: '/wp/v2/blocks' }, -].map( ( postTypeEntity ) => ( { - kind: 'postType', - ...postTypeEntity, - transientEdits: { - blocks: true, - selection: true, - }, - mergedEdits: { - meta: true, - }, - rawAttributes: [ 'title', 'excerpt', 'content' ], -} ) ); +import { parse } from '@wordpress/blocks'; + +/** + * Builds an entity configuration for a post type core-data did not load. + * + * Core loads post type entities from the `/wp/v2/types` response, so this is + * only a fallback for a type missing from it. It mirrors the configuration + * core builds in `loadPostTypeEntities`, so saves behave the same either way. + * + * @param {Object} post The post being edited. + * @param {string} post.type The post type. + * @param {string} post.restBase The REST API base path for the post type. + * @param {string} post.restNamespace The REST API namespace for the post type. + * @return {Object} The entity configuration. + */ +export function buildPostTypeEntity( { type, restBase, restNamespace } ) { + const baseURL = `/${ restNamespace }/${ restBase }`; + + return { + kind: 'postType', + name: type, + baseURL, + baseURLParams: { context: 'edit' }, + transientEdits: { + // Core also defines a `write`, which nothing calls. + blocks: { + read: ( record ) => parse( record.content?.raw ?? '' ), + }, + selection: true, + }, + mergedEdits: { meta: true }, + rawAttributes: [ 'title', 'excerpt', 'content' ], + __unstablePrePersist: prePersistPostType, + __unstable_rest_base: restBase, + supportsPagination: true, + getRevisionsUrl: ( parentId, revisionId ) => + `${ baseURL }/${ parentId }/revisions${ + revisionId ? '/' + revisionId : '' + }`, + revisionKey: 'id', + }; +} + +/** + * Applies the edits core makes before saving a post. + * + * Mirrors core-data's `prePersistPostType`, which is not exported, for + * non-template post types. + * + * Exported for testing only. + * + * @param {Object} persistedRecord The persisted post. + * @param {Object} edits The edits being saved. + * @return {Object} The additional edits. + */ +export function prePersistPostType( persistedRecord, edits ) { + const newEdits = {}; + + if ( persistedRecord?.status === 'auto-draft' ) { + // Saving an auto-draft should create a draft by default. + if ( ! edits.status ) { + newEdits.status = 'draft'; + } + + // Fix the auto-draft default title. + if ( + ( ! edits.title || edits.title === 'Auto Draft' ) && + ( ! persistedRecord?.title || + persistedRecord?.title === 'Auto Draft' ) + ) { + newEdits.title = ''; + } + } + + return newEdits; +} diff --git a/src/utils/post-type-entities.test.js b/src/utils/post-type-entities.test.js new file mode 100644 index 000000000..5e5504737 --- /dev/null +++ b/src/utils/post-type-entities.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildPostTypeEntity, prePersistPostType } from './post-type-entities'; + +// Loading the real package in vitest trips on JSON import attributes. +vi.mock( '@wordpress/blocks', () => ( { parse: vi.fn() } ) ); + +describe( 'buildPostTypeEntity', () => { + const entity = buildPostTypeEntity( { + type: 'book', + restBase: 'books', + restNamespace: 'wp/v2', + } ); + + it( "matches core's post type entity configuration", () => { + expect( entity ).toMatchObject( { + kind: 'postType', + name: 'book', + baseURL: '/wp/v2/books', + baseURLParams: { context: 'edit' }, + transientEdits: { selection: true }, + mergedEdits: { meta: true }, + rawAttributes: [ 'title', 'excerpt', 'content' ], + __unstablePrePersist: prePersistPostType, + __unstable_rest_base: 'books', + supportsPagination: true, + revisionKey: 'id', + } ); + } ); + + it( 'builds revision URLs', () => { + expect( entity.getRevisionsUrl( 5 ) ).toBe( + '/wp/v2/books/5/revisions' + ); + expect( entity.getRevisionsUrl( 5, 9 ) ).toBe( + '/wp/v2/books/5/revisions/9' + ); + } ); +} ); + +describe( 'prePersistPostType', () => { + it( 'saves an auto-draft as a draft with an empty title', () => { + expect( prePersistPostType( { status: 'auto-draft' }, {} ) ).toEqual( { + status: 'draft', + title: '', + } ); + } ); + + it( 'keeps the status and title being saved', () => { + expect( + prePersistPostType( + { status: 'auto-draft' }, + { status: 'publish', title: 'Title' } + ) + ).toEqual( {} ); + } ); + + it( 'leaves posts that are not auto-drafts unchanged', () => { + expect( prePersistPostType( { status: 'draft' }, {} ) ).toEqual( {} ); + } ); +} ); diff --git a/src/utils/prime-post-entity.js b/src/utils/prime-post-entity.js new file mode 100644 index 000000000..09ce10631 --- /dev/null +++ b/src/utils/prime-post-entity.js @@ -0,0 +1,87 @@ +import { dispatch, resolveSelect, select } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { getGBKit } from './bridge'; +import { warn } from './logger'; +import { buildPostTypeEntity } from './post-type-entities'; + +/** + * @typedef {import('./bridge').Post} Post + */ + +/** + * Seeds core-data with the post being edited before the editor renders. + * + * The post is received as the persisted record and its `getEntityRecord` + * resolution is marked finished, so core-data never fetches the post and + * replaces the host's content, which may hold edits not yet uploaded. + * + * Adapted from `primePostEntity` in + * https://github.com/wordpress-mobile/GutenbergKit/pull/445. + * + * @param {Post} post The post from the host. + * @return {Promise} Resolves once the post is seeded. + */ +export async function primePostEntity( post ) { + await registerPostTypeEntity( post ); + + const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); + receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); + finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); +} + +/** + * Ensures core-data has an entity configuration for the post's type. + * + * Core builds the configurations from the `/wp/v2/types` response, which hosts + * preload. A fallback is registered only for a type missing from it. + * + * @param {Post} post The post from the host. + * @return {Promise} Resolves once the configuration is registered. + */ +async function registerPostTypeEntity( post ) { + await resolveSelect( coreStore ).getEntitiesConfig( 'postType' ); + + if ( select( coreStore ).getEntityConfig( 'postType', post.type ) ) { + return; + } + + warn( `Post type "${ post.type }" not loaded; using a fallback config` ); + dispatch( coreStore ).addEntities( [ buildPostTypeEntity( post ) ] ); +} + +/** + * Builds the persisted record for the post. + * + * The host sends only a few fields, so the server record the host preloaded + * supplies the rest (dates, author, terms, meta, etc.). The host's title and + * content take precedence over the server's. + * + * @param {Post} post The post from the host. + * @return {Object} The record. + */ +function buildPostRecord( post ) { + const serverRecord = getPreloadedPost( post ); + + if ( ! serverRecord ) { + return post; + } + + return { + ...serverRecord, + title: { ...serverRecord.title, raw: post.title.raw }, + content: { ...serverRecord.content, raw: post.content.raw }, + }; +} + +/** + * Returns the server record the host preloaded for the post, if any. + * + * @param {Post} post The post from the host. + * @return {Object|null} The record, or null when not preloaded. + */ +function getPreloadedPost( post ) { + const { preloadData } = getGBKit(); + const path = `/${ post.restNamespace }/${ post.restBase }/${ post.id }?context=edit`; + + return preloadData?.[ path ]?.body ?? null; +} diff --git a/src/utils/prime-post-entity.test.js b/src/utils/prime-post-entity.test.js new file mode 100644 index 000000000..494e5f507 --- /dev/null +++ b/src/utils/prime-post-entity.test.js @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { dispatch, resolveSelect, select } from '@wordpress/data'; +import { primePostEntity } from './prime-post-entity'; +import { getGBKit } from './bridge'; +import { buildPostTypeEntity } from './post-type-entities'; + +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/data', () => ( { + dispatch: vi.fn(), + resolveSelect: vi.fn(), + select: vi.fn(), +} ) ); +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); +vi.mock( './bridge' ); +vi.mock( './logger' ); +vi.mock( './post-type-entities', () => ( { + buildPostTypeEntity: vi.fn( () => ( { name: 'fallback' } ) ), +} ) ); + +/** + * Builds a post shaped like the one `getPost()` returns. + * + * @param {Object} overrides Fields to override. + * @return {Object} The post. + */ +function buildPost( overrides = {} ) { + return { + id: 1, + type: 'post', + restBase: 'posts', + restNamespace: 'wp/v2', + status: 'draft', + title: { raw: 'Host title' }, + content: { raw: '

Host content

' }, + ...overrides, + }; +} + +describe( 'primePostEntity', () => { + let coreDispatch; + let getEntityConfig; + let getEntitiesConfig; + + beforeEach( () => { + vi.clearAllMocks(); + + coreDispatch = { + addEntities: vi.fn(), + receiveEntityRecords: vi.fn(), + finishResolution: vi.fn(), + }; + getEntityConfig = vi.fn( () => ( { name: 'post' } ) ); + getEntitiesConfig = vi.fn( () => Promise.resolve( [] ) ); + + dispatch.mockReturnValue( coreDispatch ); + select.mockReturnValue( { getEntityConfig } ); + resolveSelect.mockReturnValue( { getEntitiesConfig } ); + getGBKit.mockReturnValue( {} ); + } ); + + it( "loads core's post type entities before seeding", async () => { + await primePostEntity( buildPost() ); + + expect( getEntitiesConfig ).toHaveBeenCalledWith( 'postType' ); + expect( getEntityConfig ).toHaveBeenCalledWith( 'postType', 'post' ); + expect( coreDispatch.addEntities ).not.toHaveBeenCalled(); + } ); + + it( 'registers a fallback entity for a post type core did not load', async () => { + getEntityConfig.mockReturnValue( undefined ); + const post = buildPost( { type: 'book', restBase: 'books' } ); + + await primePostEntity( post ); + + expect( buildPostTypeEntity ).toHaveBeenCalledWith( post ); + expect( coreDispatch.addEntities ).toHaveBeenCalledWith( [ + { name: 'fallback' }, + ] ); + } ); + + it( 'marks the post resolved so core-data never fetches it', async () => { + await primePostEntity( buildPost( { id: 2 } ) ); + + expect( coreDispatch.finishResolution ).toHaveBeenCalledWith( + 'getEntityRecord', + [ 'postType', 'post', 2 ] + ); + } ); + + it( 'seeds the host post when the server record is not preloaded', async () => { + const post = buildPost( { id: -1 } ); + + await primePostEntity( post ); + + expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + post + ); + } ); + + it( 'seeds the preloaded server record with the host title and content', async () => { + getGBKit.mockReturnValue( { + preloadData: { + '/wp/v2/posts/3?context=edit': { + body: { + id: 3, + type: 'post', + status: 'publish', + author: 7, + title: { + raw: 'Server title', + rendered: 'Server title', + }, + content: { + raw: '

Server content

', + rendered: '

Server content

', + protected: false, + }, + }, + }, + }, + } ); + + await primePostEntity( buildPost( { id: 3 } ) ); + + expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + { + id: 3, + type: 'post', + status: 'publish', + author: 7, + title: { raw: 'Host title', rendered: 'Server title' }, + content: { + raw: '

Host content

', + rendered: '

Server content

', + protected: false, + }, + } + ); + } ); +} ); From 07afaded62a45963bcce6a9e28e29a3242ade5a9 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Oct 2026 15:22:47 -0400 Subject: [PATCH 02/12] fix: block post writes instead of filtering post requests Seeding marks the post resolved, so the editor no longer requests it. The old filter also answered writes with an empty response, so a save reported success and cleared edits without reaching the server. Reject writes to the post, including autosaves, and log them until the editor saves posts. Co-Authored-By: Claude Opus 5.5 --- src/utils/api-fetch.js | 67 +++++++++++++++++----------- src/utils/api-fetch.test.js | 89 +++++++++++++++++++------------------ src/utils/bridge.js | 2 +- 3 files changed, 86 insertions(+), 72 deletions(-) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 6f52a6400..48bfacf1e 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -23,8 +23,8 @@ const MEDIA_ATTACHMENT_PATH = /^\/wp\/v2\/media\/(\d+)(\?|$)/; */ export const NATIVE_UPLOAD_CHUNK_SIZE = 4 * 1024 * 1024; -/** Methods safe to repeat because they do not change server state. */ -const RETRYABLE_METHODS = [ 'GET', 'HEAD', 'OPTIONS' ]; +/** Methods that do not change server state, so are safe to repeat. */ +const SAFE_METHODS = [ 'GET', 'HEAD', 'OPTIONS' ]; /** Base delay before each retry; jitter of up to the same amount is added. */ const RETRY_DELAYS_MS = [ 500, 2000 ]; @@ -49,7 +49,7 @@ export function configureApiFetch() { apiFetch.use( corsMiddleware ); apiFetch.use( apiPathModifierMiddleware ); apiFetch.use( tokenAuthMiddleware ); - apiFetch.use( filterEndpointsMiddleware ); + apiFetch.use( blockPostWritesMiddleware ); // `apiFetch.use` unshifts, so the last registration runs first. Core's media // upload middleware is registered after the native one so that it runs // before it, wrapping it: the native middleware handles an upload without @@ -176,42 +176,55 @@ function tokenAuthMiddleware( options, next ) { } /** - * Middleware to filter out requests to specific endpoints. + * Middleware rejecting writes to the post being edited, including autosaves. * - * @type {APIFetchMiddleware} - * - * @todo Properly seed the post entity and remove this middleware. + * The host app saves the post, so a save from the editor, e.g. a plugin calling + * `savePost()`, would race the host's. Rejecting it, rather than resolving an + * empty response, keeps core from treating the post as saved and clearing its + * edits. * - * 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. + * @type {APIFetchMiddleware} * - * This requires hoisting the relevant logic from `useEditorSetup` to occur - * before we render the editor, and invoking `finishResolution`. + * @todo Remove once the editor saves posts. + */ +function blockPostWritesMiddleware( options, next ) { + const method = ( options.method ?? 'GET' ).toUpperCase(); + + if ( SAFE_METHODS.includes( method ) || ! isPostPath( options.path ) ) { + return next( options ); + } + + warn( + `Blocked ${ method } ${ options.path }: the host app saves the post` + ); + return Promise.reject( { + code: 'gutenberg_kit_post_write_blocked', + message: 'The host app saves the post.', + } ); +} + +/** + * Whether a request path targets the post being edited or one of its + * sub-resources, such as autosaves. * - * See: https://github.com/wordpress-mobile/GutenbergKit/commit/c9b4fc9978a3760ba97f3f5d4359c2bc2155bb80 + * @param {string} [path] The request path, e.g. `/wp/v2/posts/1/autosaves`. + * @return {boolean} True for the post's paths. */ -function filterEndpointsMiddleware( options, next ) { +function isPostPath( path ) { const { post } = getGBKit(); - if ( ! post || post.id === undefined ) { - return next( options ); + if ( typeof path !== 'string' || ! post || post.id === undefined ) { + return false; } - // Apply the same fallback contract as `getPost()` so the filter still + // Apply the same fallback contract as `getPost()` so the guard 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 }`; + const postPath = `/${ restNamespace }/${ restBase }/${ post.id }`; + const pathname = path.split( '?' )[ 0 ]; - if ( - options.path === disabledPath || - options.path?.startsWith( `${ disabledPath }?` ) - ) { - return Promise.resolve( [] ); - } - return next( options ); + return pathname === postPath || pathname.startsWith( `${ postPath }/` ); } /** @@ -963,7 +976,7 @@ function isRestIndexPath( path ) { export function withRateLimitRetry( fetchHandler ) { return async ( options ) => { const method = ( options.method ?? 'GET' ).toUpperCase(); - if ( ! RETRYABLE_METHODS.includes( method ) ) { + if ( ! SAFE_METHODS.includes( method ) ) { return fetchHandler( options ); } diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index 2a40b650c..4be3b8beb 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -115,71 +115,72 @@ 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' } ); + describe( 'blockPostWritesMiddleware', () => { + const gbkit = { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + post: { + id: 42, + restBase: 'posts', + restNamespace: 'wp/v2', + }, + }; + it.each( [ + [ 'PUT', '/wp/v2/posts/42' ], + [ 'post', '/wp/v2/posts/42?_locale=user' ], + [ 'DELETE', '/wp/v2/posts/42' ], + [ 'POST', '/wp/v2/posts/42/autosaves' ], + ] )( 'rejects %s %s', async ( method, path ) => { + bridge.getGBKit.mockReturnValue( gbkit ); + + await expect( apiFetch( { path, method } ) ).rejects.toMatchObject( + { code: 'gutenberg_kit_post_write_blocked' } + ); expect( global.fetch ).not.toHaveBeenCalled(); - expect( result ).toEqual( [] ); + expect( logger.warn ).toHaveBeenCalled(); + } ); + + it.each( [ + [ 'GET', '/wp/v2/posts/42' ], + [ 'OPTIONS', '/wp/v2/posts/42' ], + [ 'PUT', '/wp/v2/posts/421' ], + [ 'POST', '/wp/v2/posts' ], + [ 'POST', '/wp/v2/pages/42' ], + ] )( 'lets %s %s through', async ( method, path ) => { + bridge.getGBKit.mockReturnValue( gbkit ); + + await apiFetch( { path, method } ); + + expect( global.fetch ).toHaveBeenCalled(); } ); 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: [], + ...gbkit, 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: {}, + await expect( + apiFetch( { path: '/wp/v2/posts/7', method: 'PUT' } ) + ).rejects.toMatchObject( { + code: 'gutenberg_kit_post_write_blocked', } ); - - try { - await apiFetch( { path: '/wp/v2/posts/99' } ); - } catch { - // Ignore errors from the actual fetch - } - - expect( global.fetch ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); } ); - it( 'lets the request through when no post payload is present', async () => { + it( 'lets writes 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 { - // Ignore errors from the actual fetch - } + await apiFetch( { path: '/wp/v2/posts/99', method: 'PUT' } ); expect( global.fetch ).toHaveBeenCalled(); } ); diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 6422ad599..bb9cae0cd 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -274,7 +274,7 @@ export function onNetworkRequest( requestData ) { /** * Default values used when the native host omits fields from the post payload. - * Centralized here so all consumers (getPost, filterEndpointsMiddleware) agree + * Centralized here so all consumers (getPost, blockPostWritesMiddleware) agree * on the fallback contract. */ export const POST_FALLBACKS = { From 6b2aae47710e0a0781a32306b3a7ef4e014d73f8 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Oct 2026 15:23:33 -0400 Subject: [PATCH 03/12] refactor: remove the redundant setEditedPost call `setupEditor` and `EditorProvider` both set the edited post already. Co-Authored-By: Claude Opus 5.5 --- src/components/editor/use-editor-setup.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/editor/use-editor-setup.js b/src/components/editor/use-editor-setup.js index 7f7515c65..15220622b 100644 --- a/src/components/editor/use-editor-setup.js +++ b/src/components/editor/use-editor-setup.js @@ -3,14 +3,11 @@ import { useDispatch } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; export function useEditorSetup( post ) { - const { setEditedPost, setupEditor } = useDispatch( editorStore ); + const { setupEditor } = useDispatch( editorStore ); useEffect( () => { setupEditor( post, {} ); - // Temp, check why this isn't being called in the provider. - setEditedPost( post.type, post.id ); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [] ); } From 00c0bb447d1b84e0eb3cca715d1f45f6d2566790 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Oct 2026 15:23:34 -0400 Subject: [PATCH 04/12] test: cover post entity seeding and the post write guard Co-Authored-By: Claude Opus 5.5 --- e2e/post-entity-seeding.spec.js | 149 ++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 e2e/post-entity-seeding.spec.js diff --git a/e2e/post-entity-seeding.spec.js b/e2e/post-entity-seeding.spec.js new file mode 100644 index 000000000..6b2936c17 --- /dev/null +++ b/e2e/post-entity-seeding.spec.js @@ -0,0 +1,149 @@ +import { test, expect } from '@playwright/test'; +import EditorPage from './editor-page'; +import { credentials } from './wp-env-fixtures'; +import { fetchJson } from './fetch-json'; + +/** Matches the post's own path, not sub-resources like its autosaves. */ +const POST_PATH = /\/wp\/v2\/posts\/1(?:[?&]|$)/; + +/** Matches the post's path and its sub-resources. */ +const POST_PATHS = /\/wp\/v2\/posts\/1(?:[/?&]|$)/; + +const HOST_POST = { + id: 1, + type: 'post', + status: 'draft', + title: 'Host title', + content: + '\n

Host content

\n', +}; + +test.describe( 'Post entity seeding', () => { + let serverPost; + + test.beforeAll( async () => { + serverPost = await fetchJson( + `${ credentials.siteApiRoot }wp/v2/posts/1?context=edit`, + credentials, + 'post' + ); + } ); + + test( 'seeds the preloaded post without requesting it', async ( { + page, + } ) => { + const postRequests = []; + page.on( 'request', ( request ) => { + if ( POST_PATH.test( decodeURIComponent( request.url() ) ) ) { + postRequests.push( `${ request.method() } ${ request.url() }` ); + } + } ); + + // Stand in for the Android bridge, which receives host events on + // `window.editorDelegate`. + await page.addInitScript( () => { + window.contentChangedCount = 0; + window.editorDelegate = { + onEditorContentChanged: () => window.contentChangedCount++, + }; + } ); + + const editor = new EditorPage( page ); + await editor.setup( { + post: HOST_POST, + preloadData: { + '/wp/v2/posts/1?context=edit': { + body: serverPost, + headers: {}, + }, + }, + } ); + + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'Host title' ); + await expect( page.getByText( 'Host content' ) ).toBeVisible(); + + const record = await page.evaluate( () => + window.wp.data + .select( 'core' ) + .getEntityRecord( 'postType', 'post', 1 ) + ); + expect( record.date ).toBe( serverPost.date ); + expect( record.author ).toBe( serverPost.author ); + expect( record.title.raw ).toBe( 'Host title' ); + + expect( + await page.evaluate( () => + window.wp.data.select( 'core/editor' ).isEditedPostDirty() + ) + ).toBe( false ); + expect( await page.evaluate( () => window.contentChangedCount ) ).toBe( + 0 + ); + expect( postRequests ).toEqual( [] ); + + await page.getByText( 'Host content' ).click(); + await page.keyboard.type( ' edited' ); + + await expect + .poll( () => page.evaluate( () => window.contentChangedCount ) ) + .toBeGreaterThan( 0 ); + } ); + + test( "uses core's post type entity configuration", async ( { page } ) => { + const editor = new EditorPage( page ); + await editor.setup( { post: HOST_POST } ); + + const config = await page.evaluate( () => { + const configs = window.wp.data + .select( 'core' ) + .getEntitiesConfig( 'postType' ) + .filter( ( { name } ) => name === 'post' ); + return configs.map( ( entity ) => ( { + baseURLParams: entity.baseURLParams, + revisionKey: entity.revisionKey, + prePersist: typeof entity.__unstablePrePersist, + } ) ); + } ); + + expect( config ).toEqual( [ + { + baseURLParams: { context: 'edit' }, + revisionKey: 'id', + prePersist: 'function', + }, + ] ); + } ); + + test( 'blocks saving the post from the editor', async ( { page } ) => { + const writeRequests = []; + page.on( 'request', ( request ) => { + if ( + request.method() !== 'GET' && + request.method() !== 'OPTIONS' && + POST_PATHS.test( decodeURIComponent( request.url() ) ) + ) { + writeRequests.push( request.url() ); + } + } ); + + const editor = new EditorPage( page ); + await editor.setup( { post: HOST_POST } ); + + await page.getByText( 'Host content' ).click(); + await page.keyboard.type( ' edited' ); + + const result = await page.evaluate( async () => { + const { dispatch, select } = window.wp.data; + await dispatch( 'core/editor' ).savePost(); + return { + failed: select( 'core/editor' ).didPostSaveRequestFail(), + dirty: select( 'core/editor' ).isEditedPostDirty(), + }; + } ); + + expect( result ).toEqual( { failed: true, dirty: true } ); + expect( writeRequests ).toEqual( [] ); + } ); +} ); From 2a1df376cf873e1848495a97c0c188bd5cf23fc5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 19 Oct 2026 14:02:33 -0400 Subject: [PATCH 05/12] fix: report only featured image changes made in the editor The seeded post takes featured_media from the preloaded server record, so reporting it on load could restore an image the user removed in the host app before uploading. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0131FDhhXuMmm8NytFURswM5 --- .../test/use-sync-featured-image.test.jsx | 61 +++++++++++++++++++ .../editor/use-sync-featured-image.js | 12 +++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/components/editor/test/use-sync-featured-image.test.jsx diff --git a/src/components/editor/test/use-sync-featured-image.test.jsx b/src/components/editor/test/use-sync-featured-image.test.jsx new file mode 100644 index 000000000..89d4d742c --- /dev/null +++ b/src/components/editor/test/use-sync-featured-image.test.jsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useSyncFeaturedImage } from '../use-sync-featured-image'; +import { onEditorFeaturedImageChanged } from '../../../utils/bridge'; + +const mockGetEditedPostAttribute = vi.fn(); + +vi.mock( '@wordpress/data', () => ( { + useSelect: ( mapSelect ) => + mapSelect( () => ( { + getEditedPostAttribute: mockGetEditedPostAttribute, + } ) ), +} ) ); +vi.mock( '@wordpress/editor' ); +vi.mock( '../../../utils/bridge' ); + +describe( 'useSyncFeaturedImage', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'does not report the featured image the post loaded with', () => { + mockGetEditedPostAttribute.mockReturnValue( 123 ); + + renderHook( () => useSyncFeaturedImage() ); + + expect( onEditorFeaturedImageChanged ).not.toHaveBeenCalled(); + } ); + + it( 'reports a featured image set in the editor', () => { + mockGetEditedPostAttribute.mockReturnValue( 123 ); + const { rerender } = renderHook( () => useSyncFeaturedImage() ); + + mockGetEditedPostAttribute.mockReturnValue( 456 ); + rerender(); + + expect( onEditorFeaturedImageChanged ).toHaveBeenCalledWith( 456 ); + } ); + + it( 'reports the initial featured image when set again in the editor', () => { + mockGetEditedPostAttribute.mockReturnValue( 123 ); + const { rerender } = renderHook( () => useSyncFeaturedImage() ); + + mockGetEditedPostAttribute.mockReturnValue( 456 ); + rerender(); + mockGetEditedPostAttribute.mockReturnValue( 123 ); + rerender(); + + expect( onEditorFeaturedImageChanged ).toHaveBeenLastCalledWith( 123 ); + } ); + + it( 'does not report a removed featured image', () => { + mockGetEditedPostAttribute.mockReturnValue( 123 ); + const { rerender } = renderHook( () => useSyncFeaturedImage() ); + + mockGetEditedPostAttribute.mockReturnValue( 0 ); + rerender(); + + expect( onEditorFeaturedImageChanged ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/src/components/editor/use-sync-featured-image.js b/src/components/editor/use-sync-featured-image.js index fbfdb7c29..d666f6bdc 100644 --- a/src/components/editor/use-sync-featured-image.js +++ b/src/components/editor/use-sync-featured-image.js @@ -1,10 +1,13 @@ -import { useEffect } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; import { onEditorFeaturedImageChanged } from '../../utils/bridge'; /** * Unidirectionally synchronizes the featured image with the host. + * + * Only changes made in the editor are reported. The initial value comes from + * the preloaded server record, which may predate the host's own edits. */ export function useSyncFeaturedImage() { const { featuredImageId } = useSelect( ( select ) => { @@ -13,8 +16,15 @@ export function useSyncFeaturedImage() { featuredImageId: getEditedPostAttribute( 'featured_media' ), }; }, [] ); + const lastFeaturedImageId = useRef( featuredImageId ); useEffect( () => { + if ( featuredImageId === lastFeaturedImageId.current ) { + return; + } + + lastFeaturedImageId.current = featuredImageId; + if ( ! featuredImageId ) { return; } From 324690b9a807c032130265593a6d2cf67308651e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 19 Oct 2026 14:17:34 -0400 Subject: [PATCH 06/12] fix: let core build every post type entity configuration The hand-written fallback copied core-data internals that drift on upgrades, and offline mode left attachment and wp_block without any configuration. Instead, add the edited post's type, plus attachment and wp_block in offline mode, to the preloaded /wp/v2/types response that core builds the configurations from. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0131FDhhXuMmm8NytFURswM5 --- e2e/post-entity-seeding.spec.js | 24 ++++++--- src/utils/api-fetch.js | 75 ++++++++++++++++++++++++++- src/utils/api-fetch.test.js | 77 +++++++++++++++++++++++++++- src/utils/post-type-entities.js | 76 --------------------------- src/utils/post-type-entities.test.js | 60 ---------------------- src/utils/prime-post-entity.js | 29 +++-------- src/utils/prime-post-entity.test.js | 20 +++----- 7 files changed, 179 insertions(+), 182 deletions(-) delete mode 100644 src/utils/post-type-entities.js delete mode 100644 src/utils/post-type-entities.test.js diff --git a/e2e/post-entity-seeding.spec.js b/e2e/post-entity-seeding.spec.js index 6b2936c17..7636fbb38 100644 --- a/e2e/post-entity-seeding.spec.js +++ b/e2e/post-entity-seeding.spec.js @@ -91,7 +91,7 @@ test.describe( 'Post entity seeding', () => { .toBeGreaterThan( 0 ); } ); - test( "uses core's post type entity configuration", async ( { page } ) => { + test( "uses core's post type entity configurations", async ( { page } ) => { const editor = new EditorPage( page ); await editor.setup( { post: HOST_POST } ); @@ -99,21 +99,29 @@ test.describe( 'Post entity seeding', () => { const configs = window.wp.data .select( 'core' ) .getEntitiesConfig( 'postType' ) - .filter( ( { name } ) => name === 'post' ); + .filter( ( { name } ) => + [ 'post', 'attachment', 'wp_block' ].includes( name ) + ); return configs.map( ( entity ) => ( { + name: entity.name, + baseURL: entity.baseURL, baseURLParams: entity.baseURLParams, - revisionKey: entity.revisionKey, prePersist: typeof entity.__unstablePrePersist, } ) ); } ); - expect( config ).toEqual( [ - { + expect( config ).toEqual( + [ + [ 'post', '/wp/v2/posts' ], + [ 'attachment', '/wp/v2/media' ], + [ 'wp_block', '/wp/v2/blocks' ], + ].map( ( [ name, baseURL ] ) => ( { + name, + baseURL, baseURLParams: { context: 'edit' }, - revisionKey: 'id', prePersist: 'function', - }, - ] ); + } ) ) + ); } ); test( 'blocks saving the post from the editor', async ( { page } ) => { diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 48bfacf1e..3b2891c7c 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -23,6 +23,9 @@ const MEDIA_ATTACHMENT_PATH = /^\/wp\/v2\/media\/(\d+)(\?|$)/; */ export const NATIVE_UPLOAD_CHUNK_SIZE = 4 * 1024 * 1024; +/** The response core-data builds post type entity configurations from. */ +const POST_TYPES_PATH = '/wp/v2/types?context=view'; + /** Methods that do not change server state, so are safe to repeat. */ const SAFE_METHODS = [ 'GET', 'HEAD', 'OPTIONS' ]; @@ -80,13 +83,55 @@ export function configureApiFetch() { apiFetch.use( transformOEmbedApiResponse ); apiFetch.use( siteIndexMiddleware ); apiFetch.use( - apiFetch.createPreloadingMiddleware( preloadData ?? defaultPreloadData ) + apiFetch.createPreloadingMiddleware( + withEditedPostType( preloadData ?? defaultPreloadData ) + ) ); apiFetch.setFetchHandler( withRateLimitRetry( apiFetch.defaultFetchHandler ) ); } +/** + * Adds the edited post's type to the preloaded post types when missing. + * + * Core-data builds post type entity configurations from this response, so a + * missing type, e.g. a custom post type in offline mode, would leave the post + * without a configuration. + * + * Exported for testing only. + * + * @param {Object} preloadData The preloaded responses, keyed by path. + * @return {Object} The preloaded responses. + */ +export function withEditedPostType( preloadData ) { + const { post } = getGBKit(); + const postTypes = preloadData[ POST_TYPES_PATH ]?.body; + const type = post?.type || POST_FALLBACKS.type; + + if ( ! post || ! postTypes || postTypes[ type ] ) { + return preloadData; + } + + return { + ...preloadData, + [ POST_TYPES_PATH ]: { + ...preloadData[ POST_TYPES_PATH ], + body: { + ...postTypes, + [ type ]: { + name: type, + slug: type, + taxonomies: [], + rest_base: post.restBase || POST_FALLBACKS.restBase, + rest_namespace: + post.restNamespace || POST_FALLBACKS.restNamespace, + }, + }, + }, + }; +} + /** * Middleware setting the CORS mode and remove a specific header causing CORS errors. * @@ -1103,7 +1148,7 @@ async function parseResponse( response, isOk ) { } const defaultPreloadData = { - '/wp/v2/types?context=view': { + [ POST_TYPES_PATH ]: { body: { post: { description: '', @@ -1131,6 +1176,32 @@ const defaultPreloadData = { template_lock: false, _links: {}, }, + attachment: { + description: '', + hierarchical: false, + has_archive: false, + name: 'Media', + slug: 'attachment', + taxonomies: [], + rest_base: 'media', + rest_namespace: 'wp/v2', + template: [], + template_lock: false, + _links: {}, + }, + wp_block: { + description: '', + hierarchical: false, + has_archive: false, + name: 'Patterns', + slug: 'wp_block', + taxonomies: [ 'wp_pattern_category' ], + rest_base: 'blocks', + rest_namespace: 'wp/v2', + template: [], + template_lock: false, + _links: {}, + }, }, }, '/wp/v2/types/post?context=edit': { diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index 4be3b8beb..f16bdcf3d 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -8,7 +8,11 @@ import { vi, } from 'vitest'; import apiFetch from '@wordpress/api-fetch'; -import { configureApiFetch, withRateLimitRetry } from './api-fetch'; +import { + configureApiFetch, + withEditedPostType, + withRateLimitRetry, +} from './api-fetch'; import * as bridge from './bridge'; import * as logger from './logger'; @@ -186,6 +190,77 @@ describe( 'api-fetch credentials handling', () => { } ); } ); + describe( 'withEditedPostType', () => { + const typesPath = '/wp/v2/types?context=view'; + const preloadData = { + [ typesPath ]: { + body: { post: { slug: 'post', rest_base: 'posts' } }, + headers: { Allow: 'GET' }, + }, + '/wp/v2/settings': { body: {} }, + }; + + it( 'adds a post type missing from the preloaded types', () => { + bridge.getGBKit.mockReturnValue( { + post: { + type: 'book', + restBase: 'books', + restNamespace: 'library/v1', + }, + } ); + + const result = withEditedPostType( preloadData ); + + expect( result[ typesPath ] ).toEqual( { + body: { + post: { slug: 'post', rest_base: 'posts' }, + book: { + name: 'book', + slug: 'book', + taxonomies: [], + rest_base: 'books', + rest_namespace: 'library/v1', + }, + }, + headers: { Allow: 'GET' }, + } ); + expect( result[ '/wp/v2/settings' ] ).toBe( + preloadData[ '/wp/v2/settings' ] + ); + expect( preloadData[ typesPath ].body.book ).toBeUndefined(); + } ); + + it( 'falls back to default restBase and restNamespace when omitted from the payload', () => { + bridge.getGBKit.mockReturnValue( { post: { type: 'book' } } ); + + const result = withEditedPostType( preloadData ); + + expect( result[ typesPath ].body.book ).toMatchObject( { + rest_base: 'posts', + rest_namespace: 'wp/v2', + } ); + } ); + + it.each( [ + [ + 'the post type is already preloaded', + { post: { type: 'post' } }, + ], + [ 'no post payload is present', {} ], + ] )( 'leaves the preload data unchanged when %s', ( _, gbkit ) => { + bridge.getGBKit.mockReturnValue( gbkit ); + + expect( withEditedPostType( preloadData ) ).toBe( preloadData ); + } ); + + it( 'leaves the preload data unchanged when post types are not preloaded', () => { + bridge.getGBKit.mockReturnValue( { post: { type: 'book' } } ); + const data = { '/wp/v2/settings': { body: {} } }; + + expect( withEditedPostType( data ) ).toBe( data ); + } ); + } ); + describe( 'siteIndexMiddleware', () => { const indexPath = '/?_fields=name,home,url,image_sizes'; diff --git a/src/utils/post-type-entities.js b/src/utils/post-type-entities.js deleted file mode 100644 index da6342fec..000000000 --- a/src/utils/post-type-entities.js +++ /dev/null @@ -1,76 +0,0 @@ -import { parse } from '@wordpress/blocks'; - -/** - * Builds an entity configuration for a post type core-data did not load. - * - * Core loads post type entities from the `/wp/v2/types` response, so this is - * only a fallback for a type missing from it. It mirrors the configuration - * core builds in `loadPostTypeEntities`, so saves behave the same either way. - * - * @param {Object} post The post being edited. - * @param {string} post.type The post type. - * @param {string} post.restBase The REST API base path for the post type. - * @param {string} post.restNamespace The REST API namespace for the post type. - * @return {Object} The entity configuration. - */ -export function buildPostTypeEntity( { type, restBase, restNamespace } ) { - const baseURL = `/${ restNamespace }/${ restBase }`; - - return { - kind: 'postType', - name: type, - baseURL, - baseURLParams: { context: 'edit' }, - transientEdits: { - // Core also defines a `write`, which nothing calls. - blocks: { - read: ( record ) => parse( record.content?.raw ?? '' ), - }, - selection: true, - }, - mergedEdits: { meta: true }, - rawAttributes: [ 'title', 'excerpt', 'content' ], - __unstablePrePersist: prePersistPostType, - __unstable_rest_base: restBase, - supportsPagination: true, - getRevisionsUrl: ( parentId, revisionId ) => - `${ baseURL }/${ parentId }/revisions${ - revisionId ? '/' + revisionId : '' - }`, - revisionKey: 'id', - }; -} - -/** - * Applies the edits core makes before saving a post. - * - * Mirrors core-data's `prePersistPostType`, which is not exported, for - * non-template post types. - * - * Exported for testing only. - * - * @param {Object} persistedRecord The persisted post. - * @param {Object} edits The edits being saved. - * @return {Object} The additional edits. - */ -export function prePersistPostType( persistedRecord, edits ) { - const newEdits = {}; - - if ( persistedRecord?.status === 'auto-draft' ) { - // Saving an auto-draft should create a draft by default. - if ( ! edits.status ) { - newEdits.status = 'draft'; - } - - // Fix the auto-draft default title. - if ( - ( ! edits.title || edits.title === 'Auto Draft' ) && - ( ! persistedRecord?.title || - persistedRecord?.title === 'Auto Draft' ) - ) { - newEdits.title = ''; - } - } - - return newEdits; -} diff --git a/src/utils/post-type-entities.test.js b/src/utils/post-type-entities.test.js deleted file mode 100644 index 5e5504737..000000000 --- a/src/utils/post-type-entities.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { buildPostTypeEntity, prePersistPostType } from './post-type-entities'; - -// Loading the real package in vitest trips on JSON import attributes. -vi.mock( '@wordpress/blocks', () => ( { parse: vi.fn() } ) ); - -describe( 'buildPostTypeEntity', () => { - const entity = buildPostTypeEntity( { - type: 'book', - restBase: 'books', - restNamespace: 'wp/v2', - } ); - - it( "matches core's post type entity configuration", () => { - expect( entity ).toMatchObject( { - kind: 'postType', - name: 'book', - baseURL: '/wp/v2/books', - baseURLParams: { context: 'edit' }, - transientEdits: { selection: true }, - mergedEdits: { meta: true }, - rawAttributes: [ 'title', 'excerpt', 'content' ], - __unstablePrePersist: prePersistPostType, - __unstable_rest_base: 'books', - supportsPagination: true, - revisionKey: 'id', - } ); - } ); - - it( 'builds revision URLs', () => { - expect( entity.getRevisionsUrl( 5 ) ).toBe( - '/wp/v2/books/5/revisions' - ); - expect( entity.getRevisionsUrl( 5, 9 ) ).toBe( - '/wp/v2/books/5/revisions/9' - ); - } ); -} ); - -describe( 'prePersistPostType', () => { - it( 'saves an auto-draft as a draft with an empty title', () => { - expect( prePersistPostType( { status: 'auto-draft' }, {} ) ).toEqual( { - status: 'draft', - title: '', - } ); - } ); - - it( 'keeps the status and title being saved', () => { - expect( - prePersistPostType( - { status: 'auto-draft' }, - { status: 'publish', title: 'Title' } - ) - ).toEqual( {} ); - } ); - - it( 'leaves posts that are not auto-drafts unchanged', () => { - expect( prePersistPostType( { status: 'draft' }, {} ) ).toEqual( {} ); - } ); -} ); diff --git a/src/utils/prime-post-entity.js b/src/utils/prime-post-entity.js index 09ce10631..32cbf9ab0 100644 --- a/src/utils/prime-post-entity.js +++ b/src/utils/prime-post-entity.js @@ -2,7 +2,6 @@ import { dispatch, resolveSelect, select } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { getGBKit } from './bridge'; import { warn } from './logger'; -import { buildPostTypeEntity } from './post-type-entities'; /** * @typedef {import('./bridge').Post} Post @@ -22,31 +21,17 @@ import { buildPostTypeEntity } from './post-type-entities'; * @return {Promise} Resolves once the post is seeded. */ export async function primePostEntity( post ) { - await registerPostTypeEntity( post ); - - const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); - receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); - finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); -} - -/** - * Ensures core-data has an entity configuration for the post's type. - * - * Core builds the configurations from the `/wp/v2/types` response, which hosts - * preload. A fallback is registered only for a type missing from it. - * - * @param {Post} post The post from the host. - * @return {Promise} Resolves once the configuration is registered. - */ -async function registerPostTypeEntity( post ) { + // Core builds the configurations from the preloaded `/wp/v2/types` + // response, which `withEditedPostType` ensures includes the post's type. await resolveSelect( coreStore ).getEntitiesConfig( 'postType' ); - if ( select( coreStore ).getEntityConfig( 'postType', post.type ) ) { - return; + if ( ! select( coreStore ).getEntityConfig( 'postType', post.type ) ) { + warn( `No entity configuration for post type "${ post.type }"` ); } - warn( `Post type "${ post.type }" not loaded; using a fallback config` ); - dispatch( coreStore ).addEntities( [ buildPostTypeEntity( post ) ] ); + const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); + receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); + finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); } /** diff --git a/src/utils/prime-post-entity.test.js b/src/utils/prime-post-entity.test.js index 494e5f507..6d3944526 100644 --- a/src/utils/prime-post-entity.test.js +++ b/src/utils/prime-post-entity.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { dispatch, resolveSelect, select } from '@wordpress/data'; import { primePostEntity } from './prime-post-entity'; import { getGBKit } from './bridge'; -import { buildPostTypeEntity } from './post-type-entities'; +import { warn } from './logger'; // Loading the real core-data store in vitest trips on its ESM dependencies. vi.mock( '@wordpress/data', () => ( { @@ -13,9 +13,6 @@ vi.mock( '@wordpress/data', () => ( { vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); vi.mock( './bridge' ); vi.mock( './logger' ); -vi.mock( './post-type-entities', () => ( { - buildPostTypeEntity: vi.fn( () => ( { name: 'fallback' } ) ), -} ) ); /** * Builds a post shaped like the one `getPost()` returns. @@ -45,7 +42,6 @@ describe( 'primePostEntity', () => { vi.clearAllMocks(); coreDispatch = { - addEntities: vi.fn(), receiveEntityRecords: vi.fn(), finishResolution: vi.fn(), }; @@ -63,19 +59,17 @@ describe( 'primePostEntity', () => { expect( getEntitiesConfig ).toHaveBeenCalledWith( 'postType' ); expect( getEntityConfig ).toHaveBeenCalledWith( 'postType', 'post' ); - expect( coreDispatch.addEntities ).not.toHaveBeenCalled(); + expect( warn ).not.toHaveBeenCalled(); } ); - it( 'registers a fallback entity for a post type core did not load', async () => { + it( 'warns when core has no configuration for the post type', async () => { getEntityConfig.mockReturnValue( undefined ); - const post = buildPost( { type: 'book', restBase: 'books' } ); - await primePostEntity( post ); + await primePostEntity( buildPost( { type: 'book' } ) ); - expect( buildPostTypeEntity ).toHaveBeenCalledWith( post ); - expect( coreDispatch.addEntities ).toHaveBeenCalledWith( [ - { name: 'fallback' }, - ] ); + expect( warn ).toHaveBeenCalledWith( + expect.stringContaining( '"book"' ) + ); } ); it( 'marks the post resolved so core-data never fetches it', async () => { From 543dddf447297273287c6bffa6c74563276a1a2f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Oct 2026 13:05:27 -0400 Subject: [PATCH 07/12] refactor: share the post reference fallbacks and REST path getPost, withEditedPostType and isPostPath each repeated the restBase and restNamespace fallbacks, and the post's REST path was built twice. Copies that drift would let the write guard miss the post's real path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- src/utils/api-fetch.js | 22 ++++++------- src/utils/bridge.js | 48 +++++++++++++++++++++++------ src/utils/bridge.test.js | 48 +++++++++++++++++++++++++++++ src/utils/prime-post-entity.js | 4 +-- src/utils/prime-post-entity.test.js | 5 ++- 5 files changed, 101 insertions(+), 26 deletions(-) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 3b2891c7c..a735fb79c 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -1,6 +1,6 @@ import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; -import { getGBKit, POST_FALLBACKS } from './bridge'; +import { getGBKit, getPostPath, getPostReference } from './bridge'; import { info, warn, error as logError } from './logger'; import { ensureTrailingSlash, stripTrailingSlash } from './url'; @@ -105,11 +105,12 @@ export function configureApiFetch() { * @return {Object} The preloaded responses. */ export function withEditedPostType( preloadData ) { - const { post } = getGBKit(); const postTypes = preloadData[ POST_TYPES_PATH ]?.body; - const type = post?.type || POST_FALLBACKS.type; + const { type, restBase, restNamespace } = getPostReference( + getGBKit().post + ); - if ( ! post || ! postTypes || postTypes[ type ] ) { + if ( ! postTypes || postTypes[ type ] ) { return preloadData; } @@ -123,9 +124,8 @@ export function withEditedPostType( preloadData ) { name: type, slug: type, taxonomies: [], - rest_base: post.restBase || POST_FALLBACKS.restBase, - rest_namespace: - post.restNamespace || POST_FALLBACKS.restNamespace, + rest_base: restBase, + rest_namespace: restNamespace, }, }, }, @@ -258,15 +258,11 @@ function blockPostWritesMiddleware( options, next ) { function isPostPath( path ) { const { post } = getGBKit(); - if ( typeof path !== 'string' || ! post || post.id === undefined ) { + if ( typeof path !== 'string' || ! post ) { return false; } - // Apply the same fallback contract as `getPost()` so the guard still - // engages on hosts whose payload omits restBase/restNamespace. - const restNamespace = post.restNamespace || POST_FALLBACKS.restNamespace; - const restBase = post.restBase || POST_FALLBACKS.restBase; - const postPath = `/${ restNamespace }/${ restBase }/${ post.id }`; + const postPath = getPostPath( getPostReference( post ) ); const pathname = path.split( '?' )[ 0 ]; return pathname === postPath || pathname.startsWith( `${ postPath }/` ); diff --git a/src/utils/bridge.js b/src/utils/bridge.js index bb9cae0cd..8a8f43ef3 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -274,8 +274,8 @@ export function onNetworkRequest( requestData ) { /** * Default values used when the native host omits fields from the post payload. - * Centralized here so all consumers (getPost, blockPostWritesMiddleware) agree - * on the fallback contract. + * Centralized here so all consumers (getPost, getPostReference) agree on the + * fallback contract. */ export const POST_FALLBACKS = { id: -1, @@ -307,6 +307,40 @@ export function getGBKit() { * @property {string} [status] The status of the post. */ +/** + * @typedef {Object} PostReference + * @property {number} id The ID of the post. + * @property {string} type The type of the post. + * @property {string} restBase The REST API base path for this post type. + * @property {string} restNamespace The REST API namespace for this post type. + */ + +/** + * Returns the identifiers of a post from the host's payload, with + * `POST_FALLBACKS` applied to the fields it omits. + * + * @param {Object} [post] The `post` payload of the GBKit object. + * @return {PostReference} The post's identifiers. + */ +export function getPostReference( post ) { + return { + id: post?.id || POST_FALLBACKS.id, + type: post?.type || POST_FALLBACKS.type, + restBase: post?.restBase || POST_FALLBACKS.restBase, + restNamespace: post?.restNamespace || POST_FALLBACKS.restNamespace, + }; +} + +/** + * Returns the REST path of a post, e.g. `/wp/v2/posts/1`. + * + * @param {PostReference} post The post. + * @return {string} The path. + */ +export function getPostPath( { id, restBase, restNamespace } ) { + return `/${ restNamespace }/${ restBase }/${ id }`; +} + /** * Requests the latest persisted content from the native host. * @@ -358,10 +392,7 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); return { - id: post?.id || POST_FALLBACKS.id, - type: post?.type || POST_FALLBACKS.type, - restBase: post?.restBase || POST_FALLBACKS.restBase, - restNamespace: post?.restNamespace || POST_FALLBACKS.restNamespace, + ...getPostReference( post ), status: post?.status || POST_FALLBACKS.status, title: { raw: hostContent.title }, content: { raw: hostContent.content }, @@ -371,10 +402,7 @@ export async function getPost() { if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); return { - id: post.id || POST_FALLBACKS.id, - type: post.type || POST_FALLBACKS.type, - restBase: post.restBase || POST_FALLBACKS.restBase, - restNamespace: post.restNamespace || POST_FALLBACKS.restNamespace, + ...getPostReference( post ), status: post.status || POST_FALLBACKS.status, title: { raw: decodeURIComponent( post.title ) }, content: { raw: decodeURIComponent( post.content ) }, diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index fe80728b6..86791e3b1 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -3,6 +3,8 @@ import { requestLatestContent, getGBKit, getPost, + getPostPath, + getPostReference, showBlockInserter, onEditorSaveAvailabilityChanged, } from './bridge'; @@ -226,6 +228,52 @@ describe( 'getGBKit', () => { } ); } ); +describe( 'getPostReference', () => { + it( "returns the host's post identifiers", () => { + expect( + getPostReference( { + id: 5, + type: 'book', + restBase: 'books', + restNamespace: 'library/v1', + title: 'Title', + } ) + ).toEqual( { + id: 5, + type: 'book', + restBase: 'books', + restNamespace: 'library/v1', + } ); + } ); + + it( 'falls back for fields the host omits', () => { + expect( getPostReference( { id: 5 } ) ).toEqual( { + id: 5, + type: 'post', + restBase: 'posts', + restNamespace: 'wp/v2', + } ); + expect( getPostReference() ).toEqual( { + id: -1, + type: 'post', + restBase: 'posts', + restNamespace: 'wp/v2', + } ); + } ); +} ); + +describe( 'getPostPath', () => { + it( "returns the post's REST path", () => { + expect( + getPostPath( { + id: 5, + restBase: 'books', + restNamespace: 'library/v1', + } ) + ).toBe( '/library/v1/books/5' ); + } ); +} ); + describe( 'getPost', () => { let originalWindow; diff --git a/src/utils/prime-post-entity.js b/src/utils/prime-post-entity.js index 32cbf9ab0..a23c21bcb 100644 --- a/src/utils/prime-post-entity.js +++ b/src/utils/prime-post-entity.js @@ -1,6 +1,6 @@ import { dispatch, resolveSelect, select } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { getGBKit } from './bridge'; +import { getGBKit, getPostPath } from './bridge'; import { warn } from './logger'; /** @@ -66,7 +66,7 @@ function buildPostRecord( post ) { */ function getPreloadedPost( post ) { const { preloadData } = getGBKit(); - const path = `/${ post.restNamespace }/${ post.restBase }/${ post.id }?context=edit`; + const path = `${ getPostPath( post ) }?context=edit`; return preloadData?.[ path ]?.body ?? null; } diff --git a/src/utils/prime-post-entity.test.js b/src/utils/prime-post-entity.test.js index 6d3944526..dc1cf9606 100644 --- a/src/utils/prime-post-entity.test.js +++ b/src/utils/prime-post-entity.test.js @@ -11,7 +11,10 @@ vi.mock( '@wordpress/data', () => ( { select: vi.fn(), } ) ); vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); -vi.mock( './bridge' ); +vi.mock( './bridge', async ( importOriginal ) => ( { + ...( await importOriginal() ), + getGBKit: vi.fn(), +} ) ); vi.mock( './logger' ); /** From a328a19c67c311ab8b5ee9d89bf399af43c318ac Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Oct 2026 13:43:14 -0400 Subject: [PATCH 08/12] fix: set up the editor before its first render setupEditor ran in an effect after the first render, so hooks saw an empty post on mount and the seeded post as a later change. useSyncFeaturedImage then reported the preloaded server featured image to the host on load, which could overwrite an unsynced one. Core's edit-post sets up before rendering, and EditorProvider then skips its own setup. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- e2e/post-entity-seeding.spec.js | 9 ++++++++- src/components/editor/index.jsx | 2 -- src/components/editor/use-editor-setup.js | 13 ------------- src/utils/editor.jsx | 7 ++++++- src/utils/editor.test.jsx | 21 +++++++++++++++++++++ src/utils/prime-post-entity.js | 7 +++++-- src/utils/prime-post-entity.test.js | 3 ++- 7 files changed, 42 insertions(+), 20 deletions(-) delete mode 100644 src/components/editor/use-editor-setup.js diff --git a/e2e/post-entity-seeding.spec.js b/e2e/post-entity-seeding.spec.js index 7636fbb38..d067fcfe9 100644 --- a/e2e/post-entity-seeding.spec.js +++ b/e2e/post-entity-seeding.spec.js @@ -43,8 +43,11 @@ test.describe( 'Post entity seeding', () => { // `window.editorDelegate`. await page.addInitScript( () => { window.contentChangedCount = 0; + window.featuredImageChanges = []; window.editorDelegate = { onEditorContentChanged: () => window.contentChangedCount++, + onEditorFeaturedImageChanged: ( mediaID ) => + window.featuredImageChanges.push( mediaID ), }; } ); @@ -53,7 +56,8 @@ test.describe( 'Post entity seeding', () => { post: HOST_POST, preloadData: { '/wp/v2/posts/1?context=edit': { - body: serverPost, + // The host may hold a different, unsynced featured image. + body: { ...serverPost, featured_media: 123 }, headers: {}, }, }, @@ -81,6 +85,9 @@ test.describe( 'Post entity seeding', () => { expect( await page.evaluate( () => window.contentChangedCount ) ).toBe( 0 ); + expect( + await page.evaluate( () => window.featuredImageChanges ) + ).toEqual( [] ); expect( postRequests ).toEqual( [] ); await page.getByText( 'Host content' ).click(); diff --git a/src/components/editor/index.jsx b/src/components/editor/index.jsx index 66efd696c..d54e56fe7 100644 --- a/src/components/editor/index.jsx +++ b/src/components/editor/index.jsx @@ -8,7 +8,6 @@ import { useSyncSaveAvailability } from './use-sync-save-availability'; import { useHostBridge } from './use-host-bridge'; import { useEditorReady } from './use-editor-ready'; import { useHostExceptionLogging } from './use-host-exception-logging'; -import { useEditorSetup } from './use-editor-setup'; import { useMediaUpload } from './use-media-upload'; import TextEditor from '../text-editor'; import { useSyncFeaturedImage } from './use-sync-featured-image'; @@ -37,7 +36,6 @@ export default function Editor( { post, children, hideTitle } ) { useSyncHistoryControls(); useSyncSaveAvailability(); useHostExceptionLogging(); - useEditorSetup( post ); useMediaUpload(); useDevModeNotice(); useAtAutocompleter(); diff --git a/src/components/editor/use-editor-setup.js b/src/components/editor/use-editor-setup.js deleted file mode 100644 index 15220622b..000000000 --- a/src/components/editor/use-editor-setup.js +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; -import { store as editorStore } from '@wordpress/editor'; - -export function useEditorSetup( post ) { - const { setupEditor } = useDispatch( editorStore ); - - useEffect( () => { - setupEditor( post, {} ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [] ); -} diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 501e2f174..cc1fba50b 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -42,7 +42,12 @@ export async function initializeEditor( { unregisterDisallowedBlocks( allowedBlockTypes ); const post = await getPost(); - await primePostEntity( post ); + const record = await primePostEntity( post ); + + // Like core's edit-post, set up the editor before the first render, so that + // hooks see the post on mount rather than a later change. `EditorProvider` + // then skips its own setup. + dispatch( editorStore ).setupEditor( record, {} ); createRoot( document.getElementById( 'root' ) ).render( diff --git a/src/utils/editor.test.jsx b/src/utils/editor.test.jsx index f6e9370a4..6ff170c61 100644 --- a/src/utils/editor.test.jsx +++ b/src/utils/editor.test.jsx @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { dispatch } from '@wordpress/data'; +import * as element from '@wordpress/element'; import { registerCoreBlocks } from '@wordpress/block-library'; import { initializeEditor } from './editor'; import { getGBKit, getPost } from './bridge'; @@ -10,6 +11,7 @@ import { primePostEntity } from './prime-post-entity'; vi.mock( '@wordpress/blocks' ); vi.mock( '@wordpress/editor' ); vi.mock( import( '@wordpress/data' ), { spy: true } ); +vi.mock( import( '@wordpress/element' ), { spy: true } ); vi.mock( '@wordpress/block-library' ); vi.mock( './blocks' ); vi.mock( './bridge' ); @@ -34,6 +36,7 @@ describe( 'initializeEditor', () => { mockEditorDispatch = { updateEditorSettings: vi.fn(), + setupEditor: vi.fn(), }; mockDispatch = vi.fn( ( store ) => { @@ -158,4 +161,22 @@ describe( 'initializeEditor', () => { content: { raw: '' }, } ); } ); + + it( 'should set up the editor with the seeded record before rendering', async () => { + const record = { id: 1, type: 'post', featured_media: 123 }; + primePostEntity.mockResolvedValueOnce( record ); + const render = vi.fn(); + vi.spyOn( element, 'createRoot' ).mockReturnValueOnce( { render } ); + mockEditorDispatch.setupEditor.mockImplementation( () => + expect( render ).not.toHaveBeenCalled() + ); + + await initializeEditor(); + + expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( + record, + {} + ); + expect( render ).toHaveBeenCalled(); + } ); } ); diff --git a/src/utils/prime-post-entity.js b/src/utils/prime-post-entity.js index a23c21bcb..200c66c08 100644 --- a/src/utils/prime-post-entity.js +++ b/src/utils/prime-post-entity.js @@ -18,7 +18,7 @@ import { warn } from './logger'; * https://github.com/wordpress-mobile/GutenbergKit/pull/445. * * @param {Post} post The post from the host. - * @return {Promise} Resolves once the post is seeded. + * @return {Promise} The seeded record. */ export async function primePostEntity( post ) { // Core builds the configurations from the preloaded `/wp/v2/types` @@ -29,9 +29,12 @@ export async function primePostEntity( post ) { warn( `No entity configuration for post type "${ post.type }"` ); } + const record = buildPostRecord( post ); const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); - receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); + receiveEntityRecords( 'postType', post.type, record ); finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); + + return record; } /** diff --git a/src/utils/prime-post-entity.test.js b/src/utils/prime-post-entity.test.js index dc1cf9606..cff9b07dd 100644 --- a/src/utils/prime-post-entity.test.js +++ b/src/utils/prime-post-entity.test.js @@ -87,13 +87,14 @@ describe( 'primePostEntity', () => { it( 'seeds the host post when the server record is not preloaded', async () => { const post = buildPost( { id: -1 } ); - await primePostEntity( post ); + const record = await primePostEntity( post ); expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalledWith( 'postType', 'post', post ); + expect( record ).toBe( post ); } ); it( 'seeds the preloaded server record with the host title and content', async () => { From 9460063a68079135b45cef2124f8e62f3a57be67 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Oct 2026 13:45:06 -0400 Subject: [PATCH 09/12] fix: fall back to built-in post types when they fail to load Post type entity configurations now come from /wp/v2/types, which needed no request when they were built in. When a host doesn't preload it and the request fails, e.g. offline, the seeded post is dropped and EditorProvider crashes on load. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- e2e/post-entity-seeding.spec.js | 14 ++++++++++ src/utils/api-fetch.js | 23 ++++++++++++++++ src/utils/api-fetch.test.js | 49 +++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/e2e/post-entity-seeding.spec.js b/e2e/post-entity-seeding.spec.js index d067fcfe9..862280b3c 100644 --- a/e2e/post-entity-seeding.spec.js +++ b/e2e/post-entity-seeding.spec.js @@ -131,6 +131,20 @@ test.describe( 'Post entity seeding', () => { ); } ); + test( 'renders the post when the post types fail to load', async ( { + page, + } ) => { + await page.route( /\/wp\/v2\/types\?/, ( route ) => route.abort() ); + + const editor = new EditorPage( page ); + await editor.setup( { post: HOST_POST, preloadData: {} } ); + + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'Host title' ); + await expect( page.getByText( 'Host content' ) ).toBeVisible(); + } ); + test( 'blocks saving the post from the editor', async ( { page } ) => { const writeRequests = []; page.on( 'request', ( request ) => { diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index a735fb79c..1c9f0747f 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -82,6 +82,7 @@ export function configureApiFetch() { apiFetch.use( mediaPermissionsMiddleware ); apiFetch.use( transformOEmbedApiResponse ); apiFetch.use( siteIndexMiddleware ); + apiFetch.use( postTypesFallbackMiddleware ); apiFetch.use( apiFetch.createPreloadingMiddleware( withEditedPostType( preloadData ?? defaultPreloadData ) @@ -1002,6 +1003,28 @@ function isRestIndexPath( path ) { return pathname === '' || pathname === '/'; } +/** + * Middleware answering a failed post types request with the built-in types. + * + * Core-data builds post type entity configurations from this response, and the + * editor cannot render the post without one, e.g. offline when the host did not + * preload it. + * + * Exported for testing only. + * + * @type {APIFetchMiddleware} + */ +export function postTypesFallbackMiddleware( options, next ) { + if ( options.path !== POST_TYPES_PATH || options.parse === false ) { + return next( options ); + } + + return next( options ).catch( ( err ) => { + warn( 'Failed to load post types; using the built-in ones', err ); + return withEditedPostType( defaultPreloadData )[ POST_TYPES_PATH ].body; + } ); +} + /** * Wraps a fetch handler to retry read-only requests the site rate-limits. * diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index f16bdcf3d..7a76c4063 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -10,6 +10,7 @@ import { import apiFetch from '@wordpress/api-fetch'; import { configureApiFetch, + postTypesFallbackMiddleware, withEditedPostType, withRateLimitRetry, } from './api-fetch'; @@ -261,6 +262,54 @@ describe( 'api-fetch credentials handling', () => { } ); } ); + describe( 'postTypesFallbackMiddleware', () => { + const typesPath = '/wp/v2/types?context=view'; + + it( 'answers a failed post types request with the built-in types', async () => { + bridge.getGBKit.mockReturnValue( { post: { type: 'book' } } ); + const next = vi.fn( () => + Promise.reject( new Error( 'Offline' ) ) + ); + + const types = await postTypesFallbackMiddleware( + { path: typesPath }, + next + ); + + expect( Object.keys( types ) ).toEqual( + expect.arrayContaining( [ + 'post', + 'page', + 'attachment', + 'wp_block', + 'book', + ] ) + ); + expect( logger.warn ).toHaveBeenCalled(); + } ); + + it( 'returns the post types the site sends', async () => { + const types = { post: { slug: 'post' } }; + const next = vi.fn( () => Promise.resolve( types ) ); + + await expect( + postTypesFallbackMiddleware( { path: typesPath }, next ) + ).resolves.toBe( types ); + } ); + + it.each( [ + [ 'other paths', { path: '/wp/v2/types/post?context=edit' } ], + [ 'unparsed requests', { path: typesPath, parse: false } ], + ] )( 'lets failures of %s through', async ( _, options ) => { + const failure = new Error( 'Offline' ); + const next = vi.fn( () => Promise.reject( failure ) ); + + await expect( + postTypesFallbackMiddleware( options, next ) + ).rejects.toBe( failure ); + } ); + } ); + describe( 'siteIndexMiddleware', () => { const indexPath = '/?_fields=name,home,url,image_sizes'; From 537bc108d185bf90683067cf3a431049b9efdc6e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Oct 2026 13:47:19 -0400 Subject: [PATCH 10/12] fix: keep the host's title and content in fetched copies of the post The removed endpoint filter also answered reads of the post. Now only the seeded getEntityRecord lookup is resolved, so a lookup with a query, e.g. _embed, or a list including the post stores the server's copy over the seeded record and replaces the host's title and content, which may hold edits not yet uploaded. The overlay reads the persisted values from the store, so it stays correct once the editor saves posts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- e2e/post-entity-seeding.spec.js | 42 +++++ src/utils/api-fetch-root-url.test.js | 2 + src/utils/api-fetch-upload-middleware.test.js | 3 + src/utils/api-fetch.js | 147 ++++++++++++++++++ src/utils/api-fetch.test.js | 132 ++++++++++++++++ 5 files changed, 326 insertions(+) diff --git a/e2e/post-entity-seeding.spec.js b/e2e/post-entity-seeding.spec.js index 862280b3c..abb5a2847 100644 --- a/e2e/post-entity-seeding.spec.js +++ b/e2e/post-entity-seeding.spec.js @@ -145,6 +145,48 @@ test.describe( 'Post entity seeding', () => { await expect( page.getByText( 'Host content' ) ).toBeVisible(); } ); + test( "keeps the host's title and content when the post is fetched", async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { post: HOST_POST } ); + + const records = await page.evaluate( async () => { + const { resolveSelect, select } = window.wp.data; + const { getEntityRecord, getEntityRecords } = + resolveSelect( 'core' ); + await getEntityRecord( 'postType', 'post', 1, { _embed: true } ); + await getEntityRecords( 'postType', 'post', { include: [ 1 ] } ); + return { + embedded: select( 'core' ).getEntityRecord( + 'postType', + 'post', + 1, + { _embed: true } + ), + listed: select( 'core' ).getEntityRecords( 'postType', 'post', { + include: [ 1 ], + } )[ 0 ], + edited: select( 'core/editor' ).getEditedPostAttribute( + 'title' + ), + dirty: select( 'core/editor' ).isEditedPostDirty(), + }; + } ); + + for ( const record of [ records.embedded, records.listed ] ) { + expect( record.title.raw ).toBe( 'Host title' ); + expect( record.content.raw ).toBe( HOST_POST.content ); + expect( record.title.rendered ).toBe( serverPost.title.rendered ); + } + expect( records.embedded._embedded ).toBeDefined(); + expect( records.edited ).toBe( 'Host title' ); + expect( records.dirty ).toBe( false ); + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'Host title' ); + } ); + test( 'blocks saving the post from the editor', async ( { page } ) => { const writeRequests = []; page.on( 'request', ( request ) => { diff --git a/src/utils/api-fetch-root-url.test.js b/src/utils/api-fetch-root-url.test.js index be94a7c59..46abf1bf2 100644 --- a/src/utils/api-fetch-root-url.test.js +++ b/src/utils/api-fetch-root-url.test.js @@ -10,6 +10,8 @@ vi.mock( './bridge', async ( importOriginal ) => { getGBKit: vi.fn(), }; } ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); /** * The root is captured when the middleware is created, so it is asserted where diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js index 4c66eef65..4f954afcd 100644 --- a/src/utils/api-fetch-upload-middleware.test.js +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -13,6 +13,9 @@ vi.mock( './logger', () => ( { error: vi.fn(), } ) ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); + function makeNext() { return vi.fn( () => Promise.resolve( { passthrough: true } ) ); } diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 1c9f0747f..c60044365 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -1,4 +1,6 @@ import apiFetch from '@wordpress/api-fetch'; +import { store as coreStore } from '@wordpress/core-data'; +import { select } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { getGBKit, getPostPath, getPostReference } from './bridge'; import { info, warn, error as logError } from './logger'; @@ -88,6 +90,8 @@ export function configureApiFetch() { withEditedPostType( preloadData ?? defaultPreloadData ) ) ); + // Registered last so it runs first, covering preloaded responses too. + apiFetch.use( persistedPostOverlayMiddleware ); apiFetch.setFetchHandler( withRateLimitRetry( apiFetch.defaultFetchHandler ) ); @@ -1025,6 +1029,149 @@ export function postTypesFallbackMiddleware( options, next ) { } ); } +/** + * Middleware keeping the persisted title and content of the post being edited + * in fetched copies of it. + * + * The post is seeded with the host's title and content, which may hold edits + * not yet uploaded. Core-data stores a fetched copy over the seeded record, + * e.g. from `getEntityRecord()` with a query or a list including the post, so + * the server's values would replace the host's. Other fields, such as embeds, + * are kept. + * + * Exported for testing only. + * + * @type {APIFetchMiddleware} + */ +export async function persistedPostOverlayMiddleware( options, next ) { + const method = ( options.method ?? 'GET' ).toUpperCase(); + + if ( method !== 'GET' || ! isPostReadPath( options.path ) ) { + return next( options ); + } + + const response = await next( options ); + const persisted = getPersistedPost(); + + if ( ! persisted ) { + return response; + } + + if ( options.parse !== false ) { + return overlayPersistedPost( response, persisted ); + } + + let body; + try { + body = await response.clone().json(); + } catch { + return response; + } + + const overlaid = overlayPersistedPost( body, persisted ); + + if ( overlaid === body ) { + return response; + } + + return new Response( JSON.stringify( overlaid ), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + } ); +} + +/** + * Whether a request path reads the post being edited, alone or in a list of + * its type. + * + * @param {string} [path] The request path, e.g. `/wp/v2/posts/1?_embed=1`. + * @return {boolean} True for the post's path and its type's collection path. + */ +function isPostReadPath( path ) { + if ( typeof path !== 'string' ) { + return false; + } + + const postPath = getPostPath( getPostReference( getGBKit().post ) ); + const collectionPath = postPath.slice( 0, postPath.lastIndexOf( '/' ) ); + const pathname = path.split( '?' )[ 0 ]; + + return pathname === postPath || pathname === collectionPath; +} + +/** + * Returns the persisted record of the post being edited once it is seeded. + * + * Checking the resolution first avoids triggering a fetch of the post. + * + * @return {Object|null} The record, or null before seeding. + */ +function getPersistedPost() { + const { id, type } = getPostReference( getGBKit().post ); + const args = [ 'postType', type, id ]; + const { hasFinishedResolution, getEntityRecord } = select( coreStore ); + + if ( ! hasFinishedResolution( 'getEntityRecord', args ) ) { + return null; + } + + return getEntityRecord( ...args ) ?? null; +} + +/** + * Applies the persisted title and content to copies of the post in a response. + * + * @param {Object|Object[]} body A record or list of records. + * @param {Object} persisted The post's persisted record. + * @return {Object|Object[]} The body, unchanged when it holds no stale copy. + */ +function overlayPersistedPost( body, persisted ) { + if ( ! Array.isArray( body ) ) { + return overlayRecord( body, persisted ); + } + + const records = body.map( ( record ) => + overlayRecord( record, persisted ) + ); + + return records.some( ( record, index ) => record !== body[ index ] ) + ? records + : body; +} + +/** + * Applies the persisted title and content to a record of the post. + * + * Only raw values are replaced; rendered values stay the server's. + * + * @param {Object} record A record from the response. + * @param {Object} persisted The post's persisted record. + * @return {Object} The record, unchanged when it isn't a stale copy. + */ +function overlayRecord( record, persisted ) { + if ( record?.id !== persisted.id ) { + return record; + } + + let result = record; + + for ( const field of [ 'title', 'content' ] ) { + const raw = persisted[ field ]?.raw; + const value = record[ field ]; + + if ( + raw !== undefined && + value?.raw !== undefined && + value.raw !== raw + ) { + result = { ...result, [ field ]: { ...value, raw } }; + } + } + + return result; +} + /** * Wraps a fetch handler to retry read-only requests the site rate-limits. * diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index 7a76c4063..e6dacba6c 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -8,8 +8,10 @@ import { vi, } from 'vitest'; import apiFetch from '@wordpress/api-fetch'; +import { select } from '@wordpress/data'; import { configureApiFetch, + persistedPostOverlayMiddleware, postTypesFallbackMiddleware, withEditedPostType, withRateLimitRetry, @@ -25,6 +27,9 @@ vi.mock( './bridge', async ( importOriginal ) => { }; } ); vi.mock( './logger' ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); +vi.mock( '@wordpress/data', () => ( { select: vi.fn() } ) ); describe( 'api-fetch credentials handling', () => { let originalFetch; @@ -41,6 +46,7 @@ describe( 'api-fetch credentials handling', () => { beforeEach( () => { vi.clearAllMocks(); + select.mockReturnValue( { hasFinishedResolution: () => false } ); originalFetch = global.fetch; global.fetch = vi.fn( () => Promise.resolve( { @@ -310,6 +316,132 @@ describe( 'api-fetch credentials handling', () => { } ); } ); + describe( 'persistedPostOverlayMiddleware', () => { + const persisted = { + id: 42, + title: { raw: 'Host title' }, + content: { raw: '

Host content

' }, + }; + const serverRecord = { + id: 42, + title: { raw: 'Server title', rendered: 'Server title' }, + content: { + raw: '

Server content

', + rendered: '

Server content

', + }, + _embedded: { author: [] }, + }; + const overlaidRecord = { + ...serverRecord, + title: { raw: 'Host title', rendered: 'Server title' }, + content: { + raw: '

Host content

', + rendered: '

Server content

', + }, + }; + let getEntityRecord; + + beforeEach( () => { + bridge.getGBKit.mockReturnValue( { + post: { id: 42, type: 'post' }, + } ); + getEntityRecord = vi.fn( () => persisted ); + select.mockReturnValue( { + hasFinishedResolution: () => true, + getEntityRecord, + } ); + } ); + + it( "keeps the persisted title and content in the post's record", async () => { + const next = vi.fn( () => Promise.resolve( serverRecord ) ); + + await expect( + persistedPostOverlayMiddleware( + { path: '/wp/v2/posts/42?_embed=1' }, + next + ) + ).resolves.toEqual( overlaidRecord ); + expect( getEntityRecord ).toHaveBeenCalledWith( + 'postType', + 'post', + 42 + ); + } ); + + it( 'keeps the persisted title and content in a list including the post', async () => { + const other = { id: 7, title: { raw: 'Other' } }; + const next = vi.fn( () => + Promise.resolve( [ other, serverRecord ] ) + ); + + const records = await persistedPostOverlayMiddleware( + { path: '/wp/v2/posts?include=42' }, + next + ); + + expect( records ).toEqual( [ other, overlaidRecord ] ); + expect( records[ 0 ] ).toBe( other ); + } ); + + it( 'overlays unparsed responses', async () => { + const next = vi.fn( () => + Promise.resolve( + new Response( JSON.stringify( [ serverRecord ] ), { + status: 200, + headers: { 'X-WP-Total': '1' }, + } ) + ) + ); + + const response = await persistedPostOverlayMiddleware( + { path: '/wp/v2/posts?per_page=100', parse: false }, + next + ); + + expect( response.headers.get( 'X-WP-Total' ) ).toBe( '1' ); + await expect( response.json() ).resolves.toEqual( [ + overlaidRecord, + ] ); + } ); + + it( 'returns responses without the post unchanged', async () => { + const records = [ { id: 7, title: { raw: 'Other' } } ]; + const next = vi.fn( () => Promise.resolve( records ) ); + + await expect( + persistedPostOverlayMiddleware( { path: '/wp/v2/posts' }, next ) + ).resolves.toBe( records ); + } ); + + it( 'leaves responses alone before the post is seeded', async () => { + select.mockReturnValue( { + hasFinishedResolution: () => false, + getEntityRecord, + } ); + const next = vi.fn( () => Promise.resolve( serverRecord ) ); + + await expect( + persistedPostOverlayMiddleware( + { path: '/wp/v2/posts/42' }, + next + ) + ).resolves.toBe( serverRecord ); + expect( getEntityRecord ).not.toHaveBeenCalled(); + } ); + + it.each( [ + [ 'sub-resources', { path: '/wp/v2/posts/42/autosaves' } ], + [ 'other posts', { path: '/wp/v2/posts/421' } ], + [ 'writes', { path: '/wp/v2/posts/42', method: 'PUT' } ], + ] )( 'ignores %s', async ( _, options ) => { + const next = vi.fn( () => Promise.resolve( serverRecord ) ); + + await expect( + persistedPostOverlayMiddleware( options, next ) + ).resolves.toBe( serverRecord ); + } ); + } ); + describe( 'siteIndexMiddleware', () => { const indexPath = '/?_fields=name,home,url,image_sizes'; From c10d330c48e73245d1af63e52c0331cb12ee44ca Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Oct 2026 09:58:50 -0400 Subject: [PATCH 11/12] refactor: set up the editor with the stored post record Matches core's edit-post, which reads the record back from core-data rather than using what was passed to it, and leaves the setup to EditorProvider when no record was stored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- src/utils/editor.jsx | 14 +++++++++++--- src/utils/editor.test.jsx | 29 +++++++++++++++++++++++++++-- src/utils/prime-post-entity.js | 7 ++----- src/utils/prime-post-entity.test.js | 3 +-- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index cc1fba50b..1a6c3a09d 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -1,5 +1,6 @@ import { createRoot, StrictMode } from '@wordpress/element'; -import { dispatch } from '@wordpress/data'; +import { dispatch, select } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; import { store as preferencesStore } from '@wordpress/preferences'; import { registerCoreBlocks } from '@wordpress/block-library'; @@ -42,12 +43,19 @@ export async function initializeEditor( { unregisterDisallowedBlocks( allowedBlockTypes ); const post = await getPost(); - const record = await primePostEntity( post ); + await primePostEntity( post ); // Like core's edit-post, set up the editor before the first render, so that // hooks see the post on mount rather than a later change. `EditorProvider` // then skips its own setup. - dispatch( editorStore ).setupEditor( record, {} ); + const record = select( coreStore ).getEntityRecord( + 'postType', + post.type, + post.id + ); + if ( record ) { + dispatch( editorStore ).setupEditor( record, {} ); + } createRoot( document.getElementById( 'root' ) ).render( diff --git a/src/utils/editor.test.jsx b/src/utils/editor.test.jsx index 6ff170c61..a5b4d790f 100644 --- a/src/utils/editor.test.jsx +++ b/src/utils/editor.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { dispatch } from '@wordpress/data'; +import { dispatch, select } from '@wordpress/data'; import * as element from '@wordpress/element'; import { registerCoreBlocks } from '@wordpress/block-library'; import { initializeEditor } from './editor'; @@ -10,6 +10,8 @@ import { primePostEntity } from './prime-post-entity'; vi.mock( '@wordpress/blocks' ); vi.mock( '@wordpress/editor' ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: { name: 'core' } } ) ); vi.mock( import( '@wordpress/data' ), { spy: true } ); vi.mock( import( '@wordpress/element' ), { spy: true } ); vi.mock( '@wordpress/block-library' ); @@ -25,6 +27,7 @@ describe( 'initializeEditor', () => { let mockDispatch; let mockPreferenceDispatch; let mockEditorDispatch; + let mockGetEntityRecord; beforeEach( () => { vi.clearAllMocks(); @@ -51,6 +54,13 @@ describe( 'initializeEditor', () => { dispatch.mockImplementation( mockDispatch ); + mockGetEntityRecord = vi.fn(); + select.mockImplementation( ( store ) => + store.name === 'core' + ? { getEntityRecord: mockGetEntityRecord } + : {} + ); + getGBKit.mockReturnValue( { themeStyles: true, hideTitle: false, @@ -164,7 +174,7 @@ describe( 'initializeEditor', () => { it( 'should set up the editor with the seeded record before rendering', async () => { const record = { id: 1, type: 'post', featured_media: 123 }; - primePostEntity.mockResolvedValueOnce( record ); + mockGetEntityRecord.mockReturnValue( record ); const render = vi.fn(); vi.spyOn( element, 'createRoot' ).mockReturnValueOnce( { render } ); mockEditorDispatch.setupEditor.mockImplementation( () => @@ -173,10 +183,25 @@ describe( 'initializeEditor', () => { await initializeEditor(); + expect( mockGetEntityRecord ).toHaveBeenCalledWith( + 'postType', + 'post', + 1 + ); expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( record, {} ); expect( render ).toHaveBeenCalled(); } ); + + it( 'should leave the setup to the editor when the post was not seeded', async () => { + const render = vi.fn(); + vi.spyOn( element, 'createRoot' ).mockReturnValueOnce( { render } ); + + await initializeEditor(); + + expect( mockEditorDispatch.setupEditor ).not.toHaveBeenCalled(); + expect( render ).toHaveBeenCalled(); + } ); } ); diff --git a/src/utils/prime-post-entity.js b/src/utils/prime-post-entity.js index 200c66c08..a23c21bcb 100644 --- a/src/utils/prime-post-entity.js +++ b/src/utils/prime-post-entity.js @@ -18,7 +18,7 @@ import { warn } from './logger'; * https://github.com/wordpress-mobile/GutenbergKit/pull/445. * * @param {Post} post The post from the host. - * @return {Promise} The seeded record. + * @return {Promise} Resolves once the post is seeded. */ export async function primePostEntity( post ) { // Core builds the configurations from the preloaded `/wp/v2/types` @@ -29,12 +29,9 @@ export async function primePostEntity( post ) { warn( `No entity configuration for post type "${ post.type }"` ); } - const record = buildPostRecord( post ); const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); - receiveEntityRecords( 'postType', post.type, record ); + receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); - - return record; } /** diff --git a/src/utils/prime-post-entity.test.js b/src/utils/prime-post-entity.test.js index cff9b07dd..dc1cf9606 100644 --- a/src/utils/prime-post-entity.test.js +++ b/src/utils/prime-post-entity.test.js @@ -87,14 +87,13 @@ describe( 'primePostEntity', () => { it( 'seeds the host post when the server record is not preloaded', async () => { const post = buildPost( { id: -1 } ); - const record = await primePostEntity( post ); + await primePostEntity( post ); expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalledWith( 'postType', 'post', post ); - expect( record ).toBe( post ); } ); it( 'seeds the preloaded server record with the host title and content', async () => { From a4a81e33f4bf188c9b07a563cfb90a2e9e5b23a9 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 30 Oct 2026 07:12:28 -0400 Subject: [PATCH 12/12] test: stub core-data in the media upload test suites These suites arrived from trunk after api-fetch began importing the core-data store, which fails to load in vitest. Co-Authored-By: Claude Opus 5.5 --- src/utils/api-fetch-post-process.test.js | 3 +++ src/utils/api-fetch-upload-scheme.test.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/utils/api-fetch-post-process.test.js b/src/utils/api-fetch-post-process.test.js index 90fa021fd..da2e94595 100644 --- a/src/utils/api-fetch-post-process.test.js +++ b/src/utils/api-fetch-post-process.test.js @@ -27,6 +27,9 @@ vi.mock( './logger', () => ( { debug: vi.fn(), } ) ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); + const SITE_API_ROOT = 'https://example.com/wp-json/'; /** diff --git a/src/utils/api-fetch-upload-scheme.test.js b/src/utils/api-fetch-upload-scheme.test.js index d4932ce18..4942fa47f 100644 --- a/src/utils/api-fetch-upload-scheme.test.js +++ b/src/utils/api-fetch-upload-scheme.test.js @@ -15,6 +15,9 @@ vi.mock( './logger', () => ( { error: vi.fn(), } ) ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); + const BASE = 'gbk-upload://upload'; const SESSION = '0f8fad5b-d9cb-469f-a165-70867728950e';