From 88387df68e5137f65e03969df3f73d48672b5fe8 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Oct 2026 15:29:32 -0400 Subject: [PATCH 1/8] feat: seed new posts as auto-drafts WP Admin seeds new posts as auto-drafts, which core relies on to treat a post as new, e.g. `isEditedPostNew()`. Posts without a server ID were seeded with the host's status, usually `draft`. Co-Authored-By: Claude Opus 5.5 --- src/utils/bridge.js | 30 ++++++++++++++++++++++++++---- src/utils/bridge.test.js | 24 ++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 8a8f43ef3..6e758c0b3 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -391,9 +391,10 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); + const reference = getPostReference( post ); return { - ...getPostReference( post ), - status: post?.status || POST_FALLBACKS.status, + ...reference, + status: getPostStatus( reference.id, post?.status ), title: { raw: hostContent.title }, content: { raw: hostContent.content }, }; @@ -401,9 +402,10 @@ export async function getPost() { if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); + const reference = getPostReference( post ); return { - ...getPostReference( post ), - status: post.status || POST_FALLBACKS.status, + ...reference, + status: getPostStatus( reference.id, post.status ), title: { raw: decodeURIComponent( post.title ) }, content: { raw: decodeURIComponent( post.content ) }, }; @@ -412,11 +414,31 @@ export async function getPost() { // Fallback to default empty post return { ...POST_FALLBACKS, + status: getPostStatus( POST_FALLBACKS.id ), title: { raw: '' }, content: { raw: '' }, }; } +/** + * Returns the status to seed the post with. + * + * A post the server doesn't have yet is an auto-draft, as WP Admin seeds new + * posts, so core treats it as new. The status stays local: the editor never + * saves the post, and no bridge message sends it to the host. + * + * @param {number} id The post ID. + * @param {string} [status] The status the host sent. + * @return {string} The status. + */ +function getPostStatus( id, status ) { + if ( id === POST_FALLBACKS.id ) { + return 'auto-draft'; + } + + return status || POST_FALLBACKS.status; +} + /** * Logs an error to the host app. * diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index 86791e3b1..dcb52995e 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -397,6 +397,25 @@ describe( 'getPost', () => { expect( result.id ).toBe( -1 ); } ); + + it( 'should seed a post without a server ID as an auto-draft', async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi + .fn() + .mockResolvedValue( { title: '', content: '' } ), + }, + }, + }; + window.GBKit = { + post: { id: -1, type: 'post', status: 'draft' }, + }; + + const result = await getPost(); + + expect( result.status ).toBe( 'auto-draft' ); + } ); } ); describe( 'fallback to GBKit', () => { @@ -470,6 +489,7 @@ describe( 'getPost', () => { const result = await getPost(); expect( result.id ).toBe( -1 ); + expect( result.status ).toBe( 'auto-draft' ); } ); } ); @@ -482,7 +502,7 @@ describe( 'getPost', () => { expect( result ).toEqual( { id: -1, type: 'post', - status: 'draft', + status: 'auto-draft', title: { raw: '' }, content: { raw: '' }, restBase: 'posts', @@ -510,7 +530,7 @@ describe( 'getPost', () => { expect( result ).toEqual( { id: -1, type: 'post', - status: 'draft', + status: 'auto-draft', title: { raw: 'Title' }, content: { raw: 'Content' }, restBase: 'posts', From da4fd9da5a3cee9c9bf29edd7024ae900383c449 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Oct 2026 15:29:35 -0400 Subject: [PATCH 2/8] feat: apply the post type template to empty new posts Core applies a post type's block template to new posts, but GBK never passed it to `setupEditor`. Load the post type before rendering and pass its template for new posts without content: unlike WP Admin's auto-drafts, a new post can hold a local draft, which core would replace. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + e2e/new-post-template.spec.js | 60 +++++++++++++++++++ src/utils/editor.jsx | 32 ++++++++-- src/utils/editor.test.jsx | 46 +++++++++++++- src/utils/prime-post-entity.js | 40 +++++++++++-- src/utils/prime-post-entity.test.js | 18 +++++- .../gutenbergkit-templated-post-type.php | 19 ++++++ 7 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 e2e/new-post-template.spec.js create mode 100644 wp-env/mu-plugins/gutenbergkit-templated-post-type.php diff --git a/.gitignore b/.gitignore index fef1eeb8a..baa328e60 100644 --- a/.gitignore +++ b/.gitignore @@ -217,6 +217,7 @@ wp-env/mu-plugins/* !wp-env/mu-plugins/gutenbergkit-cors.php !wp-env/mu-plugins/gutenbergkit-jetpack-blocks.php !wp-env/mu-plugins/gutenbergkit-media-failure.php +!wp-env/mu-plugins/gutenbergkit-templated-post-type.php # Claude .claude/settings.local.json diff --git a/e2e/new-post-template.spec.js b/e2e/new-post-template.spec.js new file mode 100644 index 000000000..f8c3e243b --- /dev/null +++ b/e2e/new-post-template.spec.js @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; +import EditorPage from './editor-page'; + +/** A new post of the type `wp-env` registers with a block template. */ +const NEW_TEMPLATED_POST = { + id: -1, + type: 'gbk_templated', + restBase: 'gbk-templated', + restNamespace: 'wp/v2', + status: 'draft', + title: '', + content: '', +}; + +test.describe( 'New post template', () => { + test( 'applies the post type template to an empty new post', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + // An empty preload makes the editor request the post types, as hosts + // preload the site's real ones. + await editor.setup( { post: NEW_TEMPLATED_POST, preloadData: {} } ); + + const blocks = await editor.getBlocks(); + expect( blocks.map( ( { name } ) => name ) ).toEqual( [ + 'core/heading', + 'core/paragraph', + ] ); + expect( blocks[ 0 ].attributes.content ).toBe( 'Template heading' ); + + const state = await page.evaluate( () => { + const editorSelect = window.wp.data.select( 'core/editor' ); + return { + isNew: editorSelect.isEditedPostNew(), + status: editorSelect.getEditedPostAttribute( 'status' ), + }; + } ); + expect( state ).toEqual( { isNew: true, status: 'auto-draft' } ); + } ); + + test( 'keeps the content of a new post that has some', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + ...NEW_TEMPLATED_POST, + content: + '\n

Local draft

\n', + }, + preloadData: {}, + } ); + + const blocks = await editor.getBlocks(); + expect( blocks.map( ( { name } ) => name ) ).toEqual( [ + 'core/paragraph', + ] ); + await expect( page.getByText( 'Local draft' ) ).toBeVisible(); + } ); +} ); diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 1a6c3a09d..74f6a4472 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -48,13 +48,14 @@ export async function initializeEditor( { // 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. - const record = select( coreStore ).getEntityRecord( - 'postType', - post.type, - post.id - ); + const { getEntityRecord, getPostType } = select( coreStore ); + const record = getEntityRecord( 'postType', post.type, post.id ); if ( record ) { - dispatch( editorStore ).setupEditor( record, {} ); + dispatch( editorStore ).setupEditor( + record, + {}, + getNewPostTemplate( record, getPostType( post.type )?.template ) + ); } createRoot( document.getElementById( 'root' ) ).render( @@ -67,3 +68,22 @@ export async function initializeEditor( { ); } + +/** + * Returns the post type's template to apply to the post, if any. + * + * Core applies the template to every new post, replacing blocks that don't + * match it. Unlike WP Admin's auto-drafts, a new post can hold content from the + * host, such as a local draft, so the template applies only when it's empty. + * + * @param {Object} post The post. + * @param {Array} [template] The post type's block template. + * @return {Array|undefined} The template, or undefined to leave the post as is. + */ +function getNewPostTemplate( post, template ) { + if ( ! template?.length || post.content?.raw?.trim() ) { + return undefined; + } + + return template; +} diff --git a/src/utils/editor.test.jsx b/src/utils/editor.test.jsx index a5b4d790f..20c151c03 100644 --- a/src/utils/editor.test.jsx +++ b/src/utils/editor.test.jsx @@ -28,6 +28,7 @@ describe( 'initializeEditor', () => { let mockPreferenceDispatch; let mockEditorDispatch; let mockGetEntityRecord; + let mockGetPostType; beforeEach( () => { vi.clearAllMocks(); @@ -55,9 +56,13 @@ describe( 'initializeEditor', () => { dispatch.mockImplementation( mockDispatch ); mockGetEntityRecord = vi.fn(); + mockGetPostType = vi.fn(); select.mockImplementation( ( store ) => store.name === 'core' - ? { getEntityRecord: mockGetEntityRecord } + ? { + getEntityRecord: mockGetEntityRecord, + getPostType: mockGetPostType, + } : {} ); @@ -190,11 +195,48 @@ describe( 'initializeEditor', () => { ); expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( record, - {} + {}, + undefined ); expect( render ).toHaveBeenCalled(); } ); + it( "should set up an empty post with its type's template", async () => { + const record = { id: -1, type: 'post', content: { raw: '' } }; + const template = [ [ 'core/heading' ] ]; + mockGetEntityRecord.mockReturnValue( record ); + mockGetPostType.mockReturnValue( { template } ); + + await initializeEditor(); + + expect( mockGetPostType ).toHaveBeenCalledWith( 'post' ); + expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( + record, + {}, + template + ); + } ); + + it( "should not apply the type's template to a post with content", async () => { + const record = { + id: -1, + type: 'post', + content: { + raw: '

Draft

', + }, + }; + mockGetEntityRecord.mockReturnValue( record ); + mockGetPostType.mockReturnValue( { template: [ [ 'core/heading' ] ] } ); + + await initializeEditor(); + + expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( + record, + {}, + undefined + ); + } ); + 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 } ); diff --git a/src/utils/prime-post-entity.js b/src/utils/prime-post-entity.js index a23c21bcb..15965065e 100644 --- a/src/utils/prime-post-entity.js +++ b/src/utils/prime-post-entity.js @@ -21,17 +21,47 @@ import { warn } from './logger'; * @return {Promise} Resolves once the post is seeded. */ export async function primePostEntity( post ) { - // Core builds the configurations from the preloaded `/wp/v2/types` - // response, which `withEditedPostType` ensures includes the post's type. + await Promise.all( [ + resolvePostTypeEntity( post ), + resolvePostType( post ), + ] ); + + const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); + receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); + finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); +} + +/** + * Resolves core-data's entity configurations for post types. + * + * Core builds the configurations from the preloaded `/wp/v2/types` response, + * which `withEditedPostType` ensures includes the post's type. + * + * @param {Post} post The post from the host. + * @return {Promise} Resolves once the configurations are resolved. + */ +async function resolvePostTypeEntity( post ) { await resolveSelect( coreStore ).getEntitiesConfig( 'postType' ); if ( ! select( coreStore ).getEntityConfig( 'postType', post.type ) ) { warn( `No entity configuration for post type "${ post.type }"` ); } +} - const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); - receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); - finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); +/** + * Loads the post's type, whose template the editor setup applies to new posts. + * + * A failure is logged rather than thrown, as the editor works without it. + * + * @param {Post} post The post from the host. + * @return {Promise} Resolves once the post type is loaded or failed. + */ +async function resolvePostType( post ) { + try { + await resolveSelect( coreStore ).getPostType( post.type ); + } catch ( err ) { + warn( `Failed to load post type "${ post.type }"`, err ); + } } /** diff --git a/src/utils/prime-post-entity.test.js b/src/utils/prime-post-entity.test.js index dc1cf9606..aedb656c6 100644 --- a/src/utils/prime-post-entity.test.js +++ b/src/utils/prime-post-entity.test.js @@ -40,6 +40,7 @@ describe( 'primePostEntity', () => { let coreDispatch; let getEntityConfig; let getEntitiesConfig; + let getPostType; beforeEach( () => { vi.clearAllMocks(); @@ -50,13 +51,28 @@ describe( 'primePostEntity', () => { }; getEntityConfig = vi.fn( () => ( { name: 'post' } ) ); getEntitiesConfig = vi.fn( () => Promise.resolve( [] ) ); + getPostType = vi.fn( () => Promise.resolve( {} ) ); dispatch.mockReturnValue( coreDispatch ); select.mockReturnValue( { getEntityConfig } ); - resolveSelect.mockReturnValue( { getEntitiesConfig } ); + resolveSelect.mockReturnValue( { getEntitiesConfig, getPostType } ); getGBKit.mockReturnValue( {} ); } ); + it( 'loads the post type before seeding', async () => { + await primePostEntity( buildPost() ); + + expect( getPostType ).toHaveBeenCalledWith( 'post' ); + } ); + + it( 'seeds the post when the post type fails to load', async () => { + getPostType.mockRejectedValue( new Error( 'Offline' ) ); + + await primePostEntity( buildPost() ); + + expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalled(); + } ); + it( "loads core's post type entities before seeding", async () => { await primePostEntity( buildPost() ); diff --git a/wp-env/mu-plugins/gutenbergkit-templated-post-type.php b/wp-env/mu-plugins/gutenbergkit-templated-post-type.php new file mode 100644 index 000000000..cda6b37c3 --- /dev/null +++ b/wp-env/mu-plugins/gutenbergkit-templated-post-type.php @@ -0,0 +1,19 @@ + 'Templated Posts', + 'public' => true, + 'show_in_rest' => true, + 'rest_base' => 'gbk-templated', + 'supports' => array( 'title', 'editor' ), + 'template' => array( + array( 'core/heading', array( 'content' => 'Template heading' ) ), + array( 'core/paragraph', array( 'placeholder' => 'Template paragraph' ) ), + ), + ) ); +} ); From 5c6625cb54add50ac9ac6f08e796a9edd918a1e2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Oct 2026 10:36:51 -0400 Subject: [PATCH 3/8] task: clarify the templated test post type The gbk_templated slug showed as "Gbk_Templated" in the demo apps, and the template's heading text looked like a placeholder beside the paragraph's real placeholder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- e2e/new-post-template.spec.js | 11 ++++++++--- .../gutenbergkit-templated-post-type.php | 14 +++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/e2e/new-post-template.spec.js b/e2e/new-post-template.spec.js index f8c3e243b..af5286c3b 100644 --- a/e2e/new-post-template.spec.js +++ b/e2e/new-post-template.spec.js @@ -4,8 +4,8 @@ import EditorPage from './editor-page'; /** A new post of the type `wp-env` registers with a block template. */ const NEW_TEMPLATED_POST = { id: -1, - type: 'gbk_templated', - restBase: 'gbk-templated', + type: 'templated_post', + restBase: 'templated-posts', restNamespace: 'wp/v2', status: 'draft', title: '', @@ -26,7 +26,12 @@ test.describe( 'New post template', () => { 'core/heading', 'core/paragraph', ] ); - expect( blocks[ 0 ].attributes.content ).toBe( 'Template heading' ); + expect( blocks[ 0 ].attributes.content ).toBe( + 'Prefilled heading from the template' + ); + expect( blocks[ 1 ].attributes.placeholder ).toBe( + 'Placeholder paragraph from the template' + ); const state = await page.evaluate( () => { const editorSelect = window.wp.data.select( 'core/editor' ); diff --git a/wp-env/mu-plugins/gutenbergkit-templated-post-type.php b/wp-env/mu-plugins/gutenbergkit-templated-post-type.php index cda6b37c3..66e2bf962 100644 --- a/wp-env/mu-plugins/gutenbergkit-templated-post-type.php +++ b/wp-env/mu-plugins/gutenbergkit-templated-post-type.php @@ -5,15 +5,19 @@ */ add_action( 'init', function () { - register_post_type( 'gbk_templated', array( - 'label' => 'Templated Posts', + register_post_type( 'templated_post', array( + 'labels' => array( + 'name' => 'Templated Posts', + 'singular_name' => 'Templated Post', + ), + 'description' => 'For testing that new posts apply the post type template.', 'public' => true, 'show_in_rest' => true, - 'rest_base' => 'gbk-templated', + 'rest_base' => 'templated-posts', 'supports' => array( 'title', 'editor' ), 'template' => array( - array( 'core/heading', array( 'content' => 'Template heading' ) ), - array( 'core/paragraph', array( 'placeholder' => 'Template paragraph' ) ), + array( 'core/heading', array( 'content' => 'Prefilled heading from the template' ) ), + array( 'core/paragraph', array( 'placeholder' => 'Placeholder paragraph from the template' ) ), ), ) ); } ); From e977ba99a049a20965e732bcdc6192be6a1c667c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Oct 2026 13:20:53 -0400 Subject: [PATCH 4/8] fix: seed only empty new posts as auto-drafts Core blanks the title of auto-drafts and applies the post type's template to them, so seeding every post without a server ID as one erased local drafts' titles, re-applied deleted templates, and focused the title on reopen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- e2e/new-post-template.spec.js | 13 ++++++++ src/utils/bridge.js | 31 +++++++++++-------- src/utils/bridge.test.js | 58 ++++++++++++++++++++++++++++++++++- src/utils/editor.jsx | 27 ++++++++-------- src/utils/editor.test.jsx | 36 ++++++++++++++++++---- 5 files changed, 134 insertions(+), 31 deletions(-) diff --git a/e2e/new-post-template.spec.js b/e2e/new-post-template.spec.js index af5286c3b..10daf106e 100644 --- a/e2e/new-post-template.spec.js +++ b/e2e/new-post-template.spec.js @@ -62,4 +62,17 @@ test.describe( 'New post template', () => { ] ); await expect( page.getByText( 'Local draft' ) ).toBeVisible(); } ); + + test( 'keeps the title of a new post that has one', async ( { page } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { ...NEW_TEMPLATED_POST, title: 'Trip notes' }, + preloadData: {}, + } ); + + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'Trip notes' ); + expect( await editor.getBlocks() ).toEqual( [] ); + } ); } ); diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 6e758c0b3..62e0952dd 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -392,29 +392,32 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); const reference = getPostReference( post ); + const { title, content } = hostContent; return { ...reference, - status: getPostStatus( reference.id, post?.status ), - title: { raw: hostContent.title }, - content: { raw: hostContent.content }, + status: getPostStatus( reference.id, post?.status, title, content ), + title: { raw: title }, + content: { raw: content }, }; } if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); const reference = getPostReference( post ); + const title = decodeURIComponent( post.title ); + const content = decodeURIComponent( post.content ); return { ...reference, - status: getPostStatus( reference.id, post.status ), - title: { raw: decodeURIComponent( post.title ) }, - content: { raw: decodeURIComponent( post.content ) }, + status: getPostStatus( reference.id, post.status, title, content ), + title: { raw: title }, + content: { raw: content }, }; } // Fallback to default empty post return { ...POST_FALLBACKS, - status: getPostStatus( POST_FALLBACKS.id ), + status: 'auto-draft', title: { raw: '' }, content: { raw: '' }, }; @@ -423,16 +426,20 @@ export async function getPost() { /** * Returns the status to seed the post with. * - * A post the server doesn't have yet is an auto-draft, as WP Admin seeds new - * posts, so core treats it as new. The status stays local: the editor never - * saves the post, and no bridge message sends it to the host. + * An empty post the server doesn't have yet is an auto-draft, as WP Admin seeds + * new posts, so core treats it as new. A local draft keeps the host's status, + * as core blanks the title of auto-drafts and applies the post type's template + * to them. The status stays local: the editor never saves the post, and no + * bridge message sends it to the host. * * @param {number} id The post ID. * @param {string} [status] The status the host sent. + * @param {string} title The post title. + * @param {string} content The post content. * @return {string} The status. */ -function getPostStatus( id, status ) { - if ( id === POST_FALLBACKS.id ) { +function getPostStatus( id, status, title, content ) { + if ( id === POST_FALLBACKS.id && ! title?.trim() && ! content?.trim() ) { return 'auto-draft'; } diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index dcb52995e..ead172178 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -416,6 +416,46 @@ describe( 'getPost', () => { expect( result.status ).toBe( 'auto-draft' ); } ); + + it( "should keep the host's status for a local draft with a title", async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( { + title: 'Trip notes', + content: '', + } ), + }, + }, + }; + window.GBKit = { + post: { id: -1, type: 'post', status: 'draft' }, + }; + + const result = await getPost(); + + expect( result.status ).toBe( 'draft' ); + } ); + + it( "should keep the host's status for a local draft with content", async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( { + title: '', + content: '

Day 1

', + } ), + }, + }, + }; + window.GBKit = { + post: { id: -1, type: 'post', status: 'draft' }, + }; + + const result = await getPost(); + + expect( result.status ).toBe( 'draft' ); + } ); } ); describe( 'fallback to GBKit', () => { @@ -489,6 +529,22 @@ describe( 'getPost', () => { const result = await getPost(); expect( result.id ).toBe( -1 ); + expect( result.status ).toBe( 'draft' ); + } ); + + it( 'should seed an empty post without a server ID as an auto-draft', async () => { + window.GBKit = { + post: { + id: -1, + type: 'post', + status: 'draft', + title: '', + content: '', + }, + }; + + const result = await getPost(); + expect( result.status ).toBe( 'auto-draft' ); } ); } ); @@ -530,7 +586,7 @@ describe( 'getPost', () => { expect( result ).toEqual( { id: -1, type: 'post', - status: 'auto-draft', + status: 'draft', title: { raw: 'Title' }, content: { raw: 'Content' }, restBase: 'posts', diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 74f6a4472..1532c69e3 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -48,13 +48,16 @@ export async function initializeEditor( { // 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. - const { getEntityRecord, getPostType } = select( coreStore ); - const record = getEntityRecord( 'postType', post.type, post.id ); + const record = select( coreStore ).getEntityRecord( + 'postType', + post.type, + post.id + ); if ( record ) { dispatch( editorStore ).setupEditor( record, {}, - getNewPostTemplate( record, getPostType( post.type )?.template ) + getNewPostTemplate( record ) ); } @@ -70,20 +73,20 @@ export async function initializeEditor( { } /** - * Returns the post type's template to apply to the post, if any. + * Returns the post type's template to apply to a new post, if any. * - * Core applies the template to every new post, replacing blocks that don't - * match it. Unlike WP Admin's auto-drafts, a new post can hold content from the - * host, such as a local draft, so the template applies only when it's empty. + * Only empty new posts are auto-drafts (see `getPost`), so a local draft keeps + * its blocks. Post types without a template have an empty one, which core would + * still apply as an edit. * - * @param {Object} post The post. - * @param {Array} [template] The post type's block template. + * @param {Object} post The post. * @return {Array|undefined} The template, or undefined to leave the post as is. */ -function getNewPostTemplate( post, template ) { - if ( ! template?.length || post.content?.raw?.trim() ) { +function getNewPostTemplate( post ) { + if ( post.status !== 'auto-draft' ) { return undefined; } - return template; + const template = select( coreStore ).getPostType( post.type )?.template; + return template?.length ? template : undefined; } diff --git a/src/utils/editor.test.jsx b/src/utils/editor.test.jsx index 20c151c03..083761162 100644 --- a/src/utils/editor.test.jsx +++ b/src/utils/editor.test.jsx @@ -201,8 +201,13 @@ describe( 'initializeEditor', () => { expect( render ).toHaveBeenCalled(); } ); - it( "should set up an empty post with its type's template", async () => { - const record = { id: -1, type: 'post', content: { raw: '' } }; + it( "should set up a new post with its type's template", async () => { + const record = { + id: -1, + type: 'post', + status: 'auto-draft', + content: { raw: '' }, + }; const template = [ [ 'core/heading' ] ]; mockGetEntityRecord.mockReturnValue( record ); mockGetPostType.mockReturnValue( { template } ); @@ -217,13 +222,13 @@ describe( 'initializeEditor', () => { ); } ); - it( "should not apply the type's template to a post with content", async () => { + it( "should not apply the type's template to a local draft", async () => { const record = { id: -1, type: 'post', - content: { - raw: '

Draft

', - }, + status: 'draft', + title: { raw: 'Trip notes' }, + content: { raw: '' }, }; mockGetEntityRecord.mockReturnValue( record ); mockGetPostType.mockReturnValue( { template: [ [ 'core/heading' ] ] } ); @@ -237,6 +242,25 @@ describe( 'initializeEditor', () => { ); } ); + it( "should not apply a post type's empty template", async () => { + const record = { + id: -1, + type: 'post', + status: 'auto-draft', + content: { raw: '' }, + }; + mockGetEntityRecord.mockReturnValue( record ); + mockGetPostType.mockReturnValue( { template: [] } ); + + await initializeEditor(); + + expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( + record, + {}, + undefined + ); + } ); + 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 } ); From 147823b8619c7dfff6d1401119d0bd369b9d5435 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Oct 2026 13:22:05 -0400 Subject: [PATCH 5/8] fix: keep an applied template from counting as a host change The host compared the editor's content with what it sent, so a new post's template looked like an edit, and hosts prompted to save or uploaded posts the user never touched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- e2e/new-post-template.spec.js | 13 ++++++ .../editor/test/use-host-bridge.test.jsx | 43 ++++++++++++++++++- src/components/editor/use-host-bridge.js | 6 ++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/e2e/new-post-template.spec.js b/e2e/new-post-template.spec.js index 10daf106e..3fe7f74d8 100644 --- a/e2e/new-post-template.spec.js +++ b/e2e/new-post-template.spec.js @@ -43,6 +43,19 @@ test.describe( 'New post template', () => { expect( state ).toEqual( { isNew: true, status: 'auto-draft' } ); } ); + test( 'does not report an untouched template as a change', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { post: NEW_TEMPLATED_POST, preloadData: {} } ); + + const { content, changed } = await page.evaluate( () => + window.editor.getTitleAndContent() + ); + expect( content ).toContain( 'Prefilled heading from the template' ); + expect( changed ).toBe( false ); + } ); + test( 'keeps the content of a new post that has some', async ( { page, } ) => { diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 2511278f7..9713f5371 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; -import { getBlockType } from '@wordpress/blocks'; +import { getBlockType, parse, serialize } from '@wordpress/blocks'; import { useHostBridge } from '../use-host-bridge'; const mockGetEditedPostAttribute = vi.fn(); @@ -250,6 +250,47 @@ describe( 'useHostBridge', () => { expect( result.changed ).toBe( false ); } ); + it( 'getTitleAndContent reports changed: false for an untouched template', () => { + const template = + '\n

From the template

\n'; + parse.mockImplementationOnce( ( content ) => content ); + serialize.mockImplementationOnce( ( blocks ) => blocks ); + mockGetEditedPostAttribute.mockReturnValue( '' ); + mockGetEditedPostContent.mockReturnValue( template ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result ).toEqual( { + title: '', + content: template, + changed: false, + } ); + } ); + + it( "getTitleAndContent starts from the host's content when the editor has none", () => { + const content = + '\n

Host

\n'; + parse.mockImplementationOnce( ( value ) => value ); + serialize.mockImplementationOnce( ( blocks ) => blocks ); + mockGetEditedPostAttribute.mockReturnValue( '' ); + mockGetEditedPostContent.mockReturnValueOnce( '' ); + mockGetEditedPostContent.mockReturnValue( content ); + + renderHook( () => + useHostBridge( + { ...defaultPost, content: { raw: content } }, + editorRef, + markBridgeReady + ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.changed ).toBe( false ); + } ); + it( 'appendTextAtCursor normalizes object-shaped block content', () => { mockGetSelectedBlockClientId.mockReturnValue( 'block-1' ); mockGetBlock.mockReturnValue( { diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index a72f15ab6..9f8f7bd46 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -32,8 +32,12 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { const postTitleRef = useRef( normalizeAttribute( post.title ) ); const postContentRef = useRef( null ); if ( postContentRef.current === null ) { + // Start from the content the editor set up, which includes the template + // applied to a new post, so an untouched post doesn't report a change. postContentRef.current = serialize( - parse( normalizeAttribute( post.content ) ) + parse( + normalizeAttribute( getEditedPostContent() || post.content ) + ) ); } From da8e5e8c084a288ca0350cbd3b27a17a622198d1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Oct 2026 13:22:26 -0400 Subject: [PATCH 6/8] perf: wait for the post type before rendering only new posts Hosts may not preload the post type, such as WordPress-Android for pages, so every other post's first render waited on a network request for a template it never uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- src/utils/prime-post-entity.js | 6 ++++-- src/utils/prime-post-entity.test.js | 13 ++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/utils/prime-post-entity.js b/src/utils/prime-post-entity.js index 15965065e..6416b0644 100644 --- a/src/utils/prime-post-entity.js +++ b/src/utils/prime-post-entity.js @@ -23,7 +23,7 @@ import { warn } from './logger'; export async function primePostEntity( post ) { await Promise.all( [ resolvePostTypeEntity( post ), - resolvePostType( post ), + post.status === 'auto-draft' && resolvePostType( post ), ] ); const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); @@ -51,7 +51,9 @@ async function resolvePostTypeEntity( post ) { /** * Loads the post's type, whose template the editor setup applies to new posts. * - * A failure is logged rather than thrown, as the editor works without it. + * Only new posts wait for it, as hosts may not preload the type and other posts + * don't need it to render. A failure is logged rather than thrown, as the + * editor works without it. * * @param {Post} post The post from the host. * @return {Promise} Resolves once the post type is loaded or failed. diff --git a/src/utils/prime-post-entity.test.js b/src/utils/prime-post-entity.test.js index aedb656c6..b3e624463 100644 --- a/src/utils/prime-post-entity.test.js +++ b/src/utils/prime-post-entity.test.js @@ -59,16 +59,23 @@ describe( 'primePostEntity', () => { getGBKit.mockReturnValue( {} ); } ); - it( 'loads the post type before seeding', async () => { - await primePostEntity( buildPost() ); + it( 'loads the post type of a new post before seeding', async () => { + await primePostEntity( buildPost( { status: 'auto-draft' } ) ); expect( getPostType ).toHaveBeenCalledWith( 'post' ); } ); + it( 'seeds other posts without waiting for the post type', async () => { + await primePostEntity( buildPost() ); + + expect( getPostType ).not.toHaveBeenCalled(); + expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalled(); + } ); + it( 'seeds the post when the post type fails to load', async () => { getPostType.mockRejectedValue( new Error( 'Offline' ) ); - await primePostEntity( buildPost() ); + await primePostEntity( buildPost( { status: 'auto-draft' } ) ); expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalled(); } ); From 91d549308109c2a67c3a8c960509138ca9427868 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Oct 2026 13:23:43 -0400 Subject: [PATCH 7/8] feat: lock post type templates as WP Admin does WP Admin adds the post type's template and template_lock to the editor settings, so a locked template's blocks can't be removed, moved, or added to. GBK applied the template without the lock. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- e2e/new-post-template.spec.js | 27 ++++++++++++++++++ src/utils/editor.jsx | 35 ++++++++++++++++++++++- src/utils/editor.test.jsx | 54 ++++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/e2e/new-post-template.spec.js b/e2e/new-post-template.spec.js index 3fe7f74d8..337a692d7 100644 --- a/e2e/new-post-template.spec.js +++ b/e2e/new-post-template.spec.js @@ -56,6 +56,33 @@ test.describe( 'New post template', () => { expect( changed ).toBe( false ); } ); + test( "locks the post type's template", async ( { page } ) => { + await page.route( + /\/wp\/v2\/types\/templated_post\b/, + async ( route ) => { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill( { + response, + json: { ...body, template_lock: 'all' }, + } ); + } + ); + + const editor = new EditorPage( page ); + await editor.setup( { post: NEW_TEMPLATED_POST, preloadData: {} } ); + + await expect + .poll( () => + page.evaluate( () => + window.wp.data + .select( 'core/block-editor' ) + .getTemplateLock() + ) + ) + .toBe( 'all' ); + } ); + test( 'keeps the content of a new post that has some', async ( { page, } ) => { diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 1532c69e3..5f8590e79 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -1,5 +1,5 @@ import { createRoot, StrictMode } from '@wordpress/element'; -import { dispatch, select } from '@wordpress/data'; +import { dispatch, resolveSelect, 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'; @@ -8,6 +8,7 @@ import Layout from '../components/layout'; import { unregisterDisallowedBlocks } from './blocks'; import { getGBKit, getPost } from './bridge'; import { getDefaultEditorSettings } from './editor-settings'; +import { warn } from './logger'; import { primePostEntity } from './prime-post-entity'; /** @@ -61,6 +62,8 @@ export async function initializeEditor( { ); } + applyPostTypeTemplateSettings( post.type ); + createRoot( document.getElementById( 'root' ) ).render( } Resolves once the settings are updated or skipped. + */ +async function applyPostTypeTemplateSettings( postType ) { + let type; + try { + type = await resolveSelect( coreStore ).getPostType( postType ); + } catch ( err ) { + warn( `Failed to load post type "${ postType }"`, err ); + return; + } + + if ( ! type?.template?.length ) { + return; + } + + dispatch( editorStore ).updateEditorSettings( { + template: type.template, + templateLock: type.template_lock || false, + } ); +} diff --git a/src/utils/editor.test.jsx b/src/utils/editor.test.jsx index 083761162..37ece3a63 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, select } from '@wordpress/data'; +import { dispatch, resolveSelect, select } from '@wordpress/data'; import * as element from '@wordpress/element'; import { registerCoreBlocks } from '@wordpress/block-library'; import { initializeEditor } from './editor'; @@ -18,6 +18,7 @@ vi.mock( '@wordpress/block-library' ); vi.mock( './blocks' ); vi.mock( './bridge' ); vi.mock( './editor-settings' ); +vi.mock( './logger' ); vi.mock( './prime-post-entity', () => ( { primePostEntity: vi.fn() } ) ); vi.mock( '../components/layout', () => ( { default: () => null, @@ -29,6 +30,7 @@ describe( 'initializeEditor', () => { let mockEditorDispatch; let mockGetEntityRecord; let mockGetPostType; + let mockResolvePostType; beforeEach( () => { vi.clearAllMocks(); @@ -66,6 +68,11 @@ describe( 'initializeEditor', () => { : {} ); + mockResolvePostType = vi.fn().mockResolvedValue( undefined ); + resolveSelect.mockImplementation( () => ( { + getPostType: mockResolvePostType, + } ) ); + getGBKit.mockReturnValue( { themeStyles: true, hideTitle: false, @@ -261,6 +268,51 @@ describe( 'initializeEditor', () => { ); } ); + it( "should lock the post type's template as WP Admin does", async () => { + const template = [ [ 'core/heading' ] ]; + mockResolvePostType.mockResolvedValue( { + template, + template_lock: 'all', + } ); + + await initializeEditor(); + + expect( mockResolvePostType ).toHaveBeenCalledWith( 'post' ); + await vi.waitFor( () => + expect( + mockEditorDispatch.updateEditorSettings + ).toHaveBeenCalledWith( { template, templateLock: 'all' } ) + ); + } ); + + it( 'should leave a post type without a template unlocked', async () => { + mockResolvePostType.mockResolvedValue( { + template: [], + template_lock: false, + } ); + + await initializeEditor(); + await vi.waitFor( () => + expect( mockResolvePostType ).toHaveBeenCalled() + ); + + expect( + mockEditorDispatch.updateEditorSettings + ).not.toHaveBeenCalledWith( + expect.objectContaining( { templateLock: expect.anything() } ) + ); + } ); + + it( 'should render when the post type fails to load', async () => { + const render = vi.fn(); + vi.spyOn( element, 'createRoot' ).mockReturnValueOnce( { render } ); + mockResolvePostType.mockRejectedValue( new Error( 'Offline' ) ); + + await initializeEditor(); + + 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 } ); From a4f76caa12f3479cad3b0b3133e0fa69e41a1d51 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 28 Oct 2026 21:09:52 -0400 Subject: [PATCH 8/8] docs: clarify which posts get the post type template The comment implied only GBK's seeded auto-drafts reach this check, but a status the host sends passes through unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- src/utils/editor.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 5f8590e79..8abb8d3a7 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -78,9 +78,9 @@ export async function initializeEditor( { /** * Returns the post type's template to apply to a new post, if any. * - * Only empty new posts are auto-drafts (see `getPost`), so a local draft keeps - * its blocks. Post types without a template have an empty one, which core would - * still apply as an edit. + * Only auto-drafts get the template, as in core. GBK seeds that status only for + * empty new posts (see `getPost`), so a local draft keeps its blocks. Post types + * without a template have an empty one, which core would still apply as an edit. * * @param {Object} post The post. * @return {Array|undefined} The template, or undefined to leave the post as is.