From af8b9f2a14f77328729fbcd5b08995d670a4aa2d Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Wed, 24 Jun 2026 12:47:46 +0300 Subject: [PATCH] fix(ai-content-planner): skip empty paragraph insertion when post type has a block template When a post type registers a block template, insertFirstParagraph would race against Gutenberg's template application: the effect fired while blocks was still [] (before the template blocks landed), inserting a stray core/paragraph at position 0. The fix reads the template from select('core').getPostType(), which is populated from the REST API preload cache synchronously before React renders, unlike getSettings().template which is written by BlockEditorProvider inside a useEffect (one tick too late). Co-Authored-By: Claude Sonnet 4.6 --- .../js/src/ai-content-planner/initialize.js | 11 +- .../ai-content-planner/initialize.test.js | 192 ++++++++++-------- 2 files changed, 109 insertions(+), 94 deletions(-) diff --git a/packages/js/src/ai-content-planner/initialize.js b/packages/js/src/ai-content-planner/initialize.js index ae4a1241a05..18106711154 100644 --- a/packages/js/src/ai-content-planner/initialize.js +++ b/packages/js/src/ai-content-planner/initialize.js @@ -58,14 +58,17 @@ export function insertFirstParagraph( blocks, insertBlock, isBannerRendered ) { export const ContentPlannerEditorPlugin = () => { const hasInserted = useRef( false ); - const { isNewPost, postType, blocks, minPostsMet, isBannerRendered } = useSelect( select => { + const { isNewPost, postType, blocks, minPostsMet, isBannerRendered, hasBlockTemplate } = useSelect( select => { const coreEditor = select( "core/editor" ); + const currentPostType = coreEditor.getCurrentPostType(); + const postTypeObject = select( "core" ).getPostType( currentPostType ); return { isNewPost: coreEditor.isEditedPostNew(), - postType: coreEditor.getCurrentPostType(), + postType: currentPostType, blocks: select( "core/block-editor" ).getBlocks(), minPostsMet: select( CONTENT_PLANNER_STORE ).selectIsMinPostsMet(), isBannerRendered: select( CONTENT_PLANNER_STORE ).selectIsBannerRendered(), + hasBlockTemplate: Array.isArray( postTypeObject?.template ) && postTypeObject.template.length > 0, }; }, [] ); const hasAiStore = useSelect( select => isObject( select( STORE_NAME_AI ) ), [] ); @@ -76,12 +79,12 @@ export const ContentPlannerEditorPlugin = () => { useYoastMetaSync(); useEffect( () => { - if ( hasInserted.current || ! isNewPost || postType !== "post" || ! minPostsMet ) { + if ( hasInserted.current || ! isNewPost || postType !== "post" || ! minPostsMet || hasBlockTemplate ) { return; } hasInserted.current = insertFirstParagraph( blocks, insertBlock, isBannerRendered ); - }, [ blocks, isNewPost, postType, insertBlock, minPostsMet ] ); + }, [ blocks, isNewPost, postType, insertBlock, minPostsMet, hasBlockTemplate ] ); if ( ! hasAiStore ) { return null; diff --git a/packages/js/tests/ai-content-planner/initialize.test.js b/packages/js/tests/ai-content-planner/initialize.test.js index 376e8829e3c..e9e0136d92a 100644 --- a/packages/js/tests/ai-content-planner/initialize.test.js +++ b/packages/js/tests/ai-content-planner/initialize.test.js @@ -1,48 +1,36 @@ import { render } from "../test-utils"; -import { ContentPlannerEditorPlugin, registerInlineBanner } from "../../src/ai-content-planner/initialize"; +import { ContentPlannerEditorPlugin, insertFirstParagraph, registerInlineBanner } from "../../src/ai-content-planner/initialize"; import { addFilter } from "@wordpress/hooks"; const mockSelectHasAiGeneratorConsent = jest.fn( () => false ); const mockSelectIsMinPostsMet = jest.fn( () => false ); const mockSelectIsBannerRendered = jest.fn( () => false ); +const buildMockSelect = ( { postTypeTemplate = null } = {} ) => { + const storeMap = { + "yoast-seo/ai-generator": { selectHasAiGeneratorConsent: mockSelectHasAiGeneratorConsent }, + "yoast-seo/content-planner": { + selectIsMinPostsMet: mockSelectIsMinPostsMet, + selectIsBannerRendered: mockSelectIsBannerRendered, + }, + "core/editor": { + isEditedPostNew: () => false, + getCurrentPostType: () => "post", + getEditedPostAttribute: () => "", + }, + "core/block-editor": { getBlocks: () => [] }, + core: { getPostType: () => ( { template: postTypeTemplate } ) }, + "yoast-seo/editor": { getSnippetEditorTemplates: () => ( { title: "", description: "" } ) }, + }; + return ( storeName ) => storeMap[ storeName ] ?? {}; +}; + jest.mock( "@wordpress/data", () => ( { dispatch: jest.fn( () => ( { updateData: jest.fn(), setFocusKeyword: jest.fn(), } ) ), - useSelect: jest.fn( ( mapSelect ) => { - const mockSelect = ( storeName ) => { - if ( storeName === "yoast-seo/ai-generator" ) { - return { - selectHasAiGeneratorConsent: mockSelectHasAiGeneratorConsent, - }; - } - if ( storeName === "yoast-seo/content-planner" ) { - return { - selectIsMinPostsMet: mockSelectIsMinPostsMet, - selectIsBannerRendered: mockSelectIsBannerRendered, - }; - } - if ( storeName === "core/editor" ) { - return { - isEditedPostNew: () => false, - getCurrentPostType: () => "post", - getEditedPostAttribute: () => "", - }; - } - if ( storeName === "core/block-editor" ) { - return { - getBlocks: () => [], - }; - } - if ( storeName === "yoast-seo/editor" ) { - return { getSnippetEditorTemplates: () => ( { title: "", description: "" } ) }; - } - return {}; - }; - return mapSelect( mockSelect ); - } ), + useSelect: jest.fn( ( mapSelect ) => mapSelect( buildMockSelect() ) ), useDispatch: jest.fn( () => ( { insertBlock: jest.fn(), updateData: jest.fn(), @@ -107,35 +95,15 @@ describe( "ContentPlannerEditorPlugin", () => { test( "renders null when the AI generator store is not registered", () => { const { useSelect } = require( "@wordpress/data" ); - useSelect.mockImplementation( ( mapSelect ) => { - const mockSelect = ( storeName ) => { - if ( storeName === "yoast-seo/ai-generator" ) { - // Unregistered store returns undefined. - return undefined; - } - if ( storeName === "yoast-seo/content-planner" ) { - return { - selectIsMinPostsMet: () => false, - selectIsBannerRendered: () => false, - }; - } - if ( storeName === "core/editor" ) { - return { - isEditedPostNew: () => false, - getCurrentPostType: () => "post", - getEditedPostAttribute: () => "", - }; - } - if ( storeName === "core/block-editor" ) { - return { getBlocks: () => [] }; - } - if ( storeName === "yoast-seo/editor" ) { - return { getSnippetEditorTemplates: () => ( { title: "", description: "" } ) }; - } - return {}; - }; - return mapSelect( mockSelect ); - } ); + useSelect.mockImplementation( ( mapSelect ) => mapSelect( buildMockSelect() ) ); + + // Override only the ai-generator store to simulate it being absent. + useSelect.mockImplementation( ( mapSelect ) => mapSelect( ( storeName ) => { + if ( storeName === "yoast-seo/ai-generator" ) { + return undefined; + } + return buildMockSelect()( storeName ); + } ) ); const { container } = render( ); expect( container.firstChild ).toBeNull(); @@ -145,34 +113,7 @@ describe( "ContentPlannerEditorPlugin", () => { mockSelectHasAiGeneratorConsent.mockReturnValue( true ); const { useSelect } = require( "@wordpress/data" ); - useSelect.mockImplementation( ( mapSelect ) => { - const mockSelect = ( storeName ) => { - if ( storeName === "yoast-seo/ai-generator" ) { - return { selectHasAiGeneratorConsent: mockSelectHasAiGeneratorConsent }; - } - if ( storeName === "yoast-seo/content-planner" ) { - return { - selectIsMinPostsMet: () => false, - selectIsBannerRendered: () => false, - }; - } - if ( storeName === "core/editor" ) { - return { - isEditedPostNew: () => false, - getCurrentPostType: () => "post", - getEditedPostAttribute: () => "", - }; - } - if ( storeName === "core/block-editor" ) { - return { getBlocks: () => [] }; - } - if ( storeName === "yoast-seo/editor" ) { - return { getSnippetEditorTemplates: () => ( { title: "", description: "" } ) }; - } - return {}; - }; - return mapSelect( mockSelect ); - } ); + useSelect.mockImplementation( ( mapSelect ) => mapSelect( buildMockSelect() ) ); const { getByTestId } = render( ); expect( getByTestId( "app" ).dataset.hasConsent ).toBe( "true" ); @@ -188,6 +129,77 @@ describe( "ContentPlannerEditorPlugin", () => { } ); } ); +describe( "insertFirstParagraph", () => { + const mockInsertBlock = jest.fn(); + + beforeEach( () => { + mockInsertBlock.mockClear(); + } ); + + test( "returns true immediately when the banner is already rendered", () => { + const result = insertFirstParagraph( [], mockInsertBlock, true ); + + expect( result ).toBe( true ); + expect( mockInsertBlock ).not.toHaveBeenCalled(); + } ); + + test( "inserts a paragraph and returns false when the canvas is empty", () => { + const result = insertFirstParagraph( [], mockInsertBlock, false ); + + expect( mockInsertBlock ).toHaveBeenCalledTimes( 1 ); + expect( result ).toBe( false ); + } ); + + test( "returns true without inserting when the canvas already has a paragraph", () => { + const blocks = [ { name: "core/paragraph" } ]; + + const result = insertFirstParagraph( blocks, mockInsertBlock, false ); + + expect( mockInsertBlock ).not.toHaveBeenCalled(); + expect( result ).toBe( true ); + } ); + + test( "returns false without inserting when blocks exist but none is a paragraph", () => { + const blocks = [ { name: "core/heading" } ]; + + const result = insertFirstParagraph( blocks, mockInsertBlock, false ); + + expect( mockInsertBlock ).not.toHaveBeenCalled(); + expect( result ).toBe( false ); + } ); +} ); + +describe( "ContentPlannerEditorPlugin — block template guard", () => { + const mockInsertBlock = jest.fn(); + + beforeEach( () => { + mockInsertBlock.mockClear(); + } ); + + test( "does not insert a paragraph when the post type has a block template", () => { + const { useSelect, useDispatch } = require( "@wordpress/data" ); + + mockSelectIsMinPostsMet.mockReturnValue( true ); + useDispatch.mockImplementation( () => ( { insertBlock: mockInsertBlock, updateData: jest.fn(), setFocusKeyword: jest.fn() } ) ); + useSelect.mockImplementation( ( mapSelect ) => mapSelect( ( storeName ) => { + if ( storeName === "core/editor" ) { + return { + isEditedPostNew: () => true, + getCurrentPostType: () => "post", + getEditedPostAttribute: () => "", + }; + } + return buildMockSelect( { postTypeTemplate: [ [ "core/group", {} ] ] } )( storeName ); + } ) ); + + render( ); + + expect( mockInsertBlock ).not.toHaveBeenCalled(); + + mockSelectIsMinPostsMet.mockReturnValue( false ); + } ); +} ); + describe( "registerInlineBanner", () => { beforeEach( () => { addFilter.mockClear();