From aeb7f66fd47f6dc0fc0e7ed1d14fff51c5f44282 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:43:13 -0600 Subject: [PATCH 1/5] fix: normalize data store attributes to plain strings in JS bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WordPress data store's `getEditedPostContent()` may return `{raw, rendered}` objects instead of plain strings because it uses `getEditedEntityRecord` (which preserves the object shape) rather than `getRawEntityRecord` (which extracts `.raw`). Add `normalizeAttribute()` to always extract the raw string before returning values to the native host via `getTitleAndContent()`. Also remove the redundant `getContent()` bridge method and its iOS public API — `getTitleAndContent()` is the single accessor for editor state. --- e2e/editor-page.js | 11 ++ e2e/get-title-and-content.spec.js | 104 ++++++++++++++++++ .../Sources/EditorViewController.swift | 6 - .../EditorViewControllerDelegate.swift | 2 +- .../editor/test/use-host-bridge.test.jsx | 78 ++++++++++++- src/components/editor/use-host-bridge.js | 33 ++++-- 6 files changed, 210 insertions(+), 24 deletions(-) create mode 100644 e2e/get-title-and-content.spec.js diff --git a/e2e/editor-page.js b/e2e/editor-page.js index d93417ef1..2b16da354 100644 --- a/e2e/editor-page.js +++ b/e2e/editor-page.js @@ -187,6 +187,17 @@ export default class EditorPage { }, index ); } + /** + * Call the bridge's `getTitleAndContent()` and return the result. + * + * @return {Promise<{title: string, content: string, changed: boolean}>} The editor state. + */ + async getTitleAndContent() { + return await this.#page.evaluate( () => + window.editor.getTitleAndContent() + ); + } + /** * Retrieve all blocks from the editor via the WP data store. * diff --git a/e2e/get-title-and-content.spec.js b/e2e/get-title-and-content.spec.js new file mode 100644 index 000000000..072ae09ce --- /dev/null +++ b/e2e/get-title-and-content.spec.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { test, expect } from '@playwright/test'; + +/** + * Internal dependencies + */ +import EditorPage from './editor-page'; + +test.describe( 'getTitleAndContent', () => { + test( 'returns correct title and content before any edits', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Initial Title', + content: + '\n
Hello
\n', + }, + } ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( typeof result.content ).toBe( 'string' ); + expect( result.title ).toBe( 'Initial Title' ); + expect( result.content ).toBe( + '\nHello
\n' + ); + expect( result.changed ).toBe( false ); + } ); + + test( 'returns plain strings after editing the title', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Original', + content: '', + }, + } ); + + const titleInput = page.getByRole( 'textbox', { + name: 'Add title', + } ); + await titleInput.click(); + await page.keyboard.press( 'ControlOrMeta+a' ); + await page.keyboard.type( 'Updated Title' ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( result.title ).toBe( 'Updated Title' ); + expect( result.changed ).toBe( true ); + } ); + + test( 'returns plain strings after editing content', async ( { page } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Title', + content: '', + }, + } ); + + await editor.clickBlockAppender(); + await page.keyboard.type( 'New paragraph' ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( typeof result.content ).toBe( 'string' ); + expect( result.title ).toBe( 'Title' ); + expect( result.content ).toContain( 'New paragraph' ); + expect( result.changed ).toBe( true ); + } ); + + test( 'returns plain strings with empty initial state', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup(); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( typeof result.content ).toBe( 'string' ); + expect( result.title ).toBe( '' ); + expect( result.content ).toBe( '' ); + expect( result.changed ).toBe( false ); + } ); +} ); diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index bfd11bc82..d1362e4f8 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -374,12 +374,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.setContent('\(escapedString)');", isCritical: true) } - /// Returns the current editor content. - public func getContent() async throws -> String { - guard isReady else { throw EditorNotReadyError() } - return try await webView.evaluateJavaScript("editor.getContent();") as! String - } - public struct EditorTitleAndContent: Decodable { public let title: String public let content: String diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 979acd61b..e614f7259 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -18,7 +18,7 @@ public protocol EditorViewControllerDelegate: AnyObject { /// Notifies the client about the new edits. /// - /// - note: To get the latest content, call ``EditorViewController/getContent()``. + /// - note: To get the latest content, call ``EditorViewController/getTitleAndContent()``. /// Retrieving the content is a relatively expensive operation and should not /// be performed too frequently during editing. /// diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 4d8301e8d..52efded2b 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -9,11 +9,44 @@ import { renderHook } from '@testing-library/react'; */ import { useHostBridge } from '../use-host-bridge'; -vi.mock( '@wordpress/data' ); -vi.mock( '@wordpress/core-data' ); -vi.mock( '@wordpress/editor' ); +const mockGetEditedPostAttribute = vi.fn(); +const mockGetEditedPostContent = vi.fn(); + +vi.mock( '@wordpress/data', () => ( { + useSelect: ( store ) => { + if ( store?.name === 'core/editor' ) { + return { + getEditedPostAttribute: mockGetEditedPostAttribute, + getEditedPostContent: mockGetEditedPostContent, + }; + } + // block-editor store selectors + return { + getSelectedBlockClientId: vi.fn(), + getBlock: vi.fn(), + getSelectionStart: vi.fn(), + getSelectionEnd: vi.fn(), + }; + }, + useDispatch: () => ( { + editEntityRecord: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + switchEditorMode: vi.fn(), + updateBlock: vi.fn(), + selectionChange: vi.fn(), + } ), +} ) ); +vi.mock( '@wordpress/core-data', () => ( { + store: { name: 'core' }, +} ) ); +vi.mock( '@wordpress/editor', () => ( { + store: { name: 'core/editor' }, +} ) ); vi.mock( '@wordpress/blocks' ); -vi.mock( '@wordpress/block-editor' ); +vi.mock( '@wordpress/block-editor', () => ( { + store: { name: 'core/block-editor' }, +} ) ); const defaultPost = { id: 1, @@ -43,7 +76,6 @@ describe( 'useHostBridge', () => { // Verify all bridge methods exist expect( window.editor.setContent ).toBeTypeOf( 'function' ); expect( window.editor.setTitle ).toBeTypeOf( 'function' ); - expect( window.editor.getContent ).toBeTypeOf( 'function' ); expect( window.editor.getTitleAndContent ).toBeTypeOf( 'function' ); expect( window.editor.undo ).toBeTypeOf( 'function' ); expect( window.editor.redo ).toBeTypeOf( 'function' ); @@ -55,6 +87,41 @@ describe( 'useHostBridge', () => { expect( markBridgeReady ).toHaveBeenCalledTimes( 1 ); } ); + it( 'getTitleAndContent returns plain strings when data store returns objects', () => { + mockGetEditedPostAttribute.mockReturnValue( { + raw: 'Hello World', + rendered: 'Hello World', + } ); + mockGetEditedPostContent.mockReturnValue( { + raw: '\nHello
\n', + rendered: 'Hello
', + protected: false, + } ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.title ).toBe( 'Hello World' ); + expect( result.content ).toBe( + '\nHello
\n' + ); + } ); + + it( 'getTitleAndContent passes through plain strings unchanged', () => { + mockGetEditedPostAttribute.mockReturnValue( 'Plain Title' ); + mockGetEditedPostContent.mockReturnValue( 'Plain Content' ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.title ).toBe( 'Plain Title' ); + expect( result.content ).toBe( 'Plain Content' ); + } ); + it( 'cleans up window.editor methods on unmount', () => { const { unmount } = renderHook( () => useHostBridge( defaultPost, editorRef, markBridgeReady ) @@ -66,7 +133,6 @@ describe( 'useHostBridge', () => { expect( window.editor.setContent ).toBeUndefined(); expect( window.editor.setTitle ).toBeUndefined(); - expect( window.editor.getContent ).toBeUndefined(); expect( window.editor.getTitleAndContent ).toBeUndefined(); expect( window.editor.undo ).toBeUndefined(); expect( window.editor.redo ).toBeUndefined(); diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 250933406..6268af53f 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -51,21 +51,15 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { editContent( { title: decodeURIComponent( title ) } ); }; - window.editor.getContent = ( completeComposition = false ) => { - if ( completeComposition ) { - endComposition( editorRef.current ); - } - - return getEditedPostContent(); - }; - window.editor.getTitleAndContent = ( completeComposition = false ) => { if ( completeComposition ) { endComposition( editorRef.current ); } - const title = getEditedPostAttribute( 'title' ); - const content = getEditedPostContent(); + const title = normalizeAttribute( + getEditedPostAttribute( 'title' ) + ); + const content = normalizeAttribute( getEditedPostContent() ); const changed = title !== postTitleRef.current || content !== postContentRef.current; @@ -184,7 +178,6 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { return () => { delete window.editor.setContent; delete window.editor.setTitle; - delete window.editor.getContent; delete window.editor.getTitleAndContent; delete window.editor.undo; delete window.editor.redo; @@ -211,6 +204,24 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { ] ); } +/** + * Normalizes a WordPress data store attribute to a plain string. + * + * The data store may return either a plain string or a `{ raw, rendered }` + * object depending on internal state (e.g. before vs. after the user edits + * a field). This function always extracts the raw string so the host app + * receives a consistent type. + * + * @param {string|Object} value The value from a data store selector. + * @return {string} The raw string value. + */ +function normalizeAttribute( value ) { + if ( typeof value === 'object' ) { + return value?.raw ?? ''; + } + return value ?? ''; +} + /** * Ends the current text composition on the active element, if it is a * `contenteditable` element. This is used to ensure that the latest composition From c37a9dfb1256200d24bd029b7bd374f0a5806eed Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:14:59 -0600 Subject: [PATCH 2/5] fix: harden normalizeAttribute and extend test coverage - Make normalizeAttribute explicitly handle null/undefined instead of relying on typeof null === 'object' with optional chaining - Coerce non-string primitives to strings via String() - Normalize postTitleRef/postContentRef initialization defensively - Normalize block content in appendTextAtCursor - Add unit tests for edge cases: null, undefined, {raw: null}, {raw: undefined}, arrays, non-string primitives - Add unit tests for changed flag correctness with object values - Add unit tests for appendTextAtCursor (object content, string content, no block selected, unsupported block type) - Add changed flag assertions to E2E object-injection regression tests --- e2e/get-title-and-content.spec.js | 78 ++++++ .../editor/test/use-host-bridge.test.jsx | 243 +++++++++++++++++- src/components/editor/use-host-bridge.js | 19 +- 3 files changed, 327 insertions(+), 13 deletions(-) diff --git a/e2e/get-title-and-content.spec.js b/e2e/get-title-and-content.spec.js index 072ae09ce..8ca9b47bf 100644 --- a/e2e/get-title-and-content.spec.js +++ b/e2e/get-title-and-content.spec.js @@ -101,4 +101,82 @@ test.describe( 'getTitleAndContent', () => { expect( result.content ).toBe( '' ); expect( result.changed ).toBe( false ); } ); + + test( 'returns plain strings when data store title is a {raw, rendered} object', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Initial Title', + content: '', + }, + } ); + + // Inject an object-shaped title edit via editEntityRecord. + // This simulates the Gutenberg data store bug where + // getEditedPostAttribute bypasses getPostRawValue normalization + // for values in the edits layer. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core' ) + .editEntityRecord( 'postType', 'post', 1, { + title: { + raw: 'Object Title', + rendered: 'Object Title', + }, + } ); + } ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.title ).toBe( 'string' ); + expect( result.title ).toBe( 'Object Title' ); + expect( result.changed ).toBe( true ); + + // Second call should report no further changes. + const second = await editor.getTitleAndContent(); + expect( second.changed ).toBe( false ); + } ); + + test( 'returns plain strings when data store content is a {raw, rendered} object', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + id: 1, + type: 'post', + status: 'draft', + title: 'Title', + content: '', + }, + } ); + + await page.evaluate( () => { + window.wp.data + .dispatch( 'core' ) + .editEntityRecord( 'postType', 'post', 1, { + content: { + raw: 'Test
', + rendered: 'Test
', + }, + } ); + } ); + + const result = await editor.getTitleAndContent(); + + expect( typeof result.content ).toBe( 'string' ); + expect( result.content ).toContain( + 'Test
' + ); + expect( result.changed ).toBe( true ); + + // Second call should report no further changes. + const second = await editor.getTitleAndContent(); + expect( second.changed ).toBe( false ); + } ); } ); diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 52efded2b..14ea78196 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -8,9 +8,16 @@ import { renderHook } from '@testing-library/react'; * Internal dependencies */ import { useHostBridge } from '../use-host-bridge'; +import { getBlockType } from '@wordpress/blocks'; const mockGetEditedPostAttribute = vi.fn(); const mockGetEditedPostContent = vi.fn(); +const mockGetSelectedBlockClientId = vi.fn(); +const mockGetBlock = vi.fn(); +const mockGetSelectionStart = vi.fn(); +const mockGetSelectionEnd = vi.fn(); +const mockUpdateBlock = vi.fn(); +const mockSelectionChange = vi.fn(); vi.mock( '@wordpress/data', () => ( { useSelect: ( store ) => { @@ -22,10 +29,10 @@ vi.mock( '@wordpress/data', () => ( { } // block-editor store selectors return { - getSelectedBlockClientId: vi.fn(), - getBlock: vi.fn(), - getSelectionStart: vi.fn(), - getSelectionEnd: vi.fn(), + getSelectedBlockClientId: mockGetSelectedBlockClientId, + getBlock: mockGetBlock, + getSelectionStart: mockGetSelectionStart, + getSelectionEnd: mockGetSelectionEnd, }; }, useDispatch: () => ( { @@ -33,8 +40,8 @@ vi.mock( '@wordpress/data', () => ( { undo: vi.fn(), redo: vi.fn(), switchEditorMode: vi.fn(), - updateBlock: vi.fn(), - selectionChange: vi.fn(), + updateBlock: mockUpdateBlock, + selectionChange: mockSelectionChange, } ), } ) ); vi.mock( '@wordpress/core-data', () => ( { @@ -43,7 +50,28 @@ vi.mock( '@wordpress/core-data', () => ( { vi.mock( '@wordpress/editor', () => ( { store: { name: 'core/editor' }, } ) ); -vi.mock( '@wordpress/blocks' ); +vi.mock( '@wordpress/blocks', () => ( { + parse: vi.fn( () => [] ), + serialize: vi.fn( () => '' ), + getBlockType: vi.fn(), +} ) ); +vi.mock( '@wordpress/rich-text', () => ( { + create: vi.fn( ( { html } ) => ( { + text: html, + formats: [], + replacements: [], + start: 0, + end: html.length, + } ) ), + insert: vi.fn( ( value, text ) => ( { + text: value.text + text, + formats: [], + replacements: [], + start: 0, + end: value.text.length + text.length, + } ) ), + toHTMLString: vi.fn( ( { value } ) => value.text ), +} ) ); vi.mock( '@wordpress/block-editor', () => ( { store: { name: 'core/block-editor' }, } ) ); @@ -122,6 +150,207 @@ describe( 'useHostBridge', () => { expect( result.content ).toBe( 'Plain Content' ); } ); + it( 'getTitleAndContent returns empty strings when data store returns null', () => { + mockGetEditedPostAttribute.mockReturnValue( null ); + mockGetEditedPostContent.mockReturnValue( null ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.title ).toBe( '' ); + expect( result.content ).toBe( '' ); + } ); + + it( 'getTitleAndContent returns empty strings when data store returns undefined', () => { + mockGetEditedPostAttribute.mockReturnValue( undefined ); + mockGetEditedPostContent.mockReturnValue( undefined ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.title ).toBe( '' ); + expect( result.content ).toBe( '' ); + } ); + + it( 'getTitleAndContent returns empty string for object with raw: null', () => { + mockGetEditedPostAttribute.mockReturnValue( { raw: null } ); + mockGetEditedPostContent.mockReturnValue( { + raw: undefined, + } ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.title ).toBe( '' ); + expect( result.content ).toBe( '' ); + } ); + + it( 'getTitleAndContent returns empty string for arrays', () => { + mockGetEditedPostAttribute.mockReturnValue( [ 'unexpected' ] ); + mockGetEditedPostContent.mockReturnValue( [] ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.title ).toBe( '' ); + expect( result.content ).toBe( '' ); + } ); + + it( 'getTitleAndContent coerces non-string primitives to strings', () => { + mockGetEditedPostAttribute.mockReturnValue( 42 ); + mockGetEditedPostContent.mockReturnValue( false ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.title ).toBe( '42' ); + expect( result.content ).toBe( 'false' ); + } ); + + it( 'getTitleAndContent reports changed correctly with object values', () => { + mockGetEditedPostAttribute.mockReturnValue( { + raw: 'Changed Title', + rendered: 'Changed Title', + } ); + mockGetEditedPostContent.mockReturnValue( '' ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const first = window.editor.getTitleAndContent(); + expect( first.changed ).toBe( true ); + expect( first.title ).toBe( 'Changed Title' ); + + const second = window.editor.getTitleAndContent(); + expect( second.changed ).toBe( false ); + } ); + + it( 'getTitleAndContent reports changed: false when values match initial state', () => { + mockGetEditedPostAttribute.mockReturnValue( '' ); + mockGetEditedPostContent.mockReturnValue( '' ); + + renderHook( () => + useHostBridge( defaultPost, 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( { + name: 'core/paragraph', + clientId: 'block-1', + attributes: { + content: { + raw: 'Existing
', + rendered: 'Existing
', + }, + }, + } ); + getBlockType.mockReturnValue( { + attributes: { content: { type: 'string' } }, + } ); + mockGetSelectionStart.mockReturnValue( { + clientId: 'block-1', + attributeKey: 'content', + offset: 8, + } ); + mockGetSelectionEnd.mockReturnValue( { + clientId: 'block-1', + attributeKey: 'content', + offset: 8, + } ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.appendTextAtCursor( ' appended' ); + + expect( result ).toBe( true ); + expect( mockUpdateBlock ).toHaveBeenCalledWith( 'block-1', { + attributes: expect.objectContaining( { + content: expect.any( String ), + } ), + } ); + } ); + + it( 'appendTextAtCursor works with plain string block content', () => { + mockGetSelectedBlockClientId.mockReturnValue( 'block-1' ); + mockGetBlock.mockReturnValue( { + name: 'core/paragraph', + clientId: 'block-1', + attributes: { content: 'Hello' }, + } ); + getBlockType.mockReturnValue( { + attributes: { content: { type: 'string' } }, + } ); + mockGetSelectionStart.mockReturnValue( { + clientId: 'block-1', + attributeKey: 'content', + offset: 5, + } ); + mockGetSelectionEnd.mockReturnValue( { + clientId: 'block-1', + attributeKey: 'content', + offset: 5, + } ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.appendTextAtCursor( ' World' ); + + expect( result ).toBe( true ); + expect( mockUpdateBlock ).toHaveBeenCalledWith( 'block-1', { + attributes: expect.objectContaining( { + content: expect.any( String ), + } ), + } ); + } ); + + it( 'appendTextAtCursor returns false when no block is selected', () => { + mockGetSelectedBlockClientId.mockReturnValue( null ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + expect( window.editor.appendTextAtCursor( 'text' ) ).toBe( false ); + } ); + + it( 'appendTextAtCursor returns false for blocks without content attribute', () => { + mockGetSelectedBlockClientId.mockReturnValue( 'block-1' ); + mockGetBlock.mockReturnValue( { + name: 'core/image', + clientId: 'block-1', + attributes: { url: 'test.jpg' }, + } ); + getBlockType.mockReturnValue( { + attributes: { url: { type: 'string' } }, + } ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + expect( window.editor.appendTextAtCursor( 'text' ) ).toBe( false ); + } ); + it( 'cleans up window.editor methods on unmount', () => { const { unmount } = renderHook( () => useHostBridge( defaultPost, editorRef, markBridgeReady ) diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 6268af53f..7f394ed5b 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -36,10 +36,12 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { [ editEntityRecord, post.id, post.type ] ); - const postTitleRef = useRef( post.title.raw ); + const postTitleRef = useRef( normalizeAttribute( post.title ) ); const postContentRef = useRef( null ); if ( postContentRef.current === null ) { - postContentRef.current = serialize( parse( post.content.raw || '' ) ); + postContentRef.current = serialize( + parse( normalizeAttribute( post.content ) ) + ); } useEffect( () => { @@ -139,7 +141,9 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { return false; } - const blockContent = block.attributes?.content || ''; + const blockContent = normalizeAttribute( + block.attributes?.content + ); const currentValue = create( { html: blockContent } ); const selectionStart = getSelectionStart(); const selectionEnd = getSelectionEnd(); @@ -212,14 +216,17 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { * a field). This function always extracts the raw string so the host app * receives a consistent type. * - * @param {string|Object} value The value from a data store selector. + * @param {string|Object|null|undefined} value The value from a data store selector. * @return {string} The raw string value. */ function normalizeAttribute( value ) { + if ( value === null || value === undefined ) { + return ''; + } if ( typeof value === 'object' ) { - return value?.raw ?? ''; + return value.raw ?? ''; } - return value ?? ''; + return String( value ); } /** From 3181e082baf79fd0f88c48f136b3bd76b18f8f18 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:26:20 -0600 Subject: [PATCH 3/5] fix: restore getContent() as convenience accessor Restore the getContent() bridge method on both JS and iOS. It is used in contexts where the editor has no title field (e.g. comment editors). Both implementations delegate to getTitleAndContent() so normalization happens in one place. --- .../Sources/EditorViewController.swift | 10 ++++++++++ .../editor/test/use-host-bridge.test.jsx | 20 +++++++++++++++++++ src/components/editor/use-host-bridge.js | 10 ++++++++++ 3 files changed, 40 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index d1362e4f8..dbe9fe522 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -380,6 +380,16 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro public let changed: Bool } + /// Returns just the current editor content, without the title. + /// + /// Use this when the editor is used without a title field (e.g. as a + /// comment editor). Delegates to `getTitleAndContent()` internally so + /// the same normalization is applied. + public func getContent() async throws -> String { + let result = try await getTitleAndContent() + return result.content + } + /// Returns the current editor title and content. public func getTitleAndContent() async throws -> EditorTitleAndContent { guard isReady else { throw EditorNotReadyError() } diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 14ea78196..d09b183ee 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -104,6 +104,7 @@ describe( 'useHostBridge', () => { // Verify all bridge methods exist expect( window.editor.setContent ).toBeTypeOf( 'function' ); expect( window.editor.setTitle ).toBeTypeOf( 'function' ); + expect( window.editor.getContent ).toBeTypeOf( 'function' ); expect( window.editor.getTitleAndContent ).toBeTypeOf( 'function' ); expect( window.editor.undo ).toBeTypeOf( 'function' ); expect( window.editor.redo ).toBeTypeOf( 'function' ); @@ -150,6 +151,24 @@ describe( 'useHostBridge', () => { expect( result.content ).toBe( 'Plain Content' ); } ); + it( 'getContent returns normalized content string', () => { + mockGetEditedPostAttribute.mockReturnValue( 'Title' ); + mockGetEditedPostContent.mockReturnValue( { + raw: '\nHello
\n', + rendered: 'Hello
', + } ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getContent(); + expect( typeof result ).toBe( 'string' ); + expect( result ).toBe( + '\nHello
\n' + ); + } ); + it( 'getTitleAndContent returns empty strings when data store returns null', () => { mockGetEditedPostAttribute.mockReturnValue( null ); mockGetEditedPostContent.mockReturnValue( null ); @@ -362,6 +381,7 @@ describe( 'useHostBridge', () => { expect( window.editor.setContent ).toBeUndefined(); expect( window.editor.setTitle ).toBeUndefined(); + expect( window.editor.getContent ).toBeUndefined(); expect( window.editor.getTitleAndContent ).toBeUndefined(); expect( window.editor.undo ).toBeUndefined(); expect( window.editor.redo ).toBeUndefined(); diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 7f394ed5b..7bf6dea7d 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -53,6 +53,15 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { editContent( { title: decodeURIComponent( title ) } ); }; + // Convenience accessor for contexts where only the content is needed + // (e.g. a comment editor with no title field). Delegates to + // getTitleAndContent so normalization happens in one place. + window.editor.getContent = ( completeComposition = false ) => { + const { content } = + window.editor.getTitleAndContent( completeComposition ); + return content; + }; + window.editor.getTitleAndContent = ( completeComposition = false ) => { if ( completeComposition ) { endComposition( editorRef.current ); @@ -182,6 +191,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { return () => { delete window.editor.setContent; delete window.editor.setTitle; + delete window.editor.getContent; delete window.editor.getTitleAndContent; delete window.editor.undo; delete window.editor.redo; From 55ef73a89dd0716275f68e768a2e26d714d072b9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:29:01 -0600 Subject: [PATCH 4/5] test: use project-level mocks for WordPress packages in host bridge tests --- __mocks__/@wordpress/block-editor.js | 2 ++ __mocks__/@wordpress/core-data.js | 2 +- .../editor/test/use-host-bridge.test.jsx | 18 ++++-------------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/__mocks__/@wordpress/block-editor.js b/__mocks__/@wordpress/block-editor.js index 7f90f1824..12d53b270 100644 --- a/__mocks__/@wordpress/block-editor.js +++ b/__mocks__/@wordpress/block-editor.js @@ -1 +1,3 @@ // Intentionally empty — prevents the real module from loading. + +export const store = { name: 'core/block-editor' }; diff --git a/__mocks__/@wordpress/core-data.js b/__mocks__/@wordpress/core-data.js index 7f90f1824..046ff29f8 100644 --- a/__mocks__/@wordpress/core-data.js +++ b/__mocks__/@wordpress/core-data.js @@ -1 +1 @@ -// Intentionally empty — prevents the real module from loading. +export const store = { name: 'core/data' }; diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index d09b183ee..cd6ee49b6 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -44,17 +44,9 @@ vi.mock( '@wordpress/data', () => ( { selectionChange: mockSelectionChange, } ), } ) ); -vi.mock( '@wordpress/core-data', () => ( { - store: { name: 'core' }, -} ) ); -vi.mock( '@wordpress/editor', () => ( { - store: { name: 'core/editor' }, -} ) ); -vi.mock( '@wordpress/blocks', () => ( { - parse: vi.fn( () => [] ), - serialize: vi.fn( () => '' ), - getBlockType: vi.fn(), -} ) ); +vi.mock( '@wordpress/core-data' ); +vi.mock( '@wordpress/editor' ); +vi.mock( '@wordpress/blocks' ); vi.mock( '@wordpress/rich-text', () => ( { create: vi.fn( ( { html } ) => ( { text: html, @@ -72,9 +64,7 @@ vi.mock( '@wordpress/rich-text', () => ( { } ) ), toHTMLString: vi.fn( ( { value } ) => value.text ), } ) ); -vi.mock( '@wordpress/block-editor', () => ( { - store: { name: 'core/block-editor' }, -} ) ); +vi.mock( '@wordpress/block-editor' ); const defaultPost = { id: 1, From acf1a5baa33baaa7a7716be3fda61cce59e2a3e0 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:37:46 -0600 Subject: [PATCH 5/5] fix: use Array.isArray for explicit array handling in normalizeAttribute --- src/components/editor/use-host-bridge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 7bf6dea7d..073aec4f2 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -230,7 +230,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { * @return {string} The raw string value. */ function normalizeAttribute( value ) { - if ( value === null || value === undefined ) { + if ( value === null || value === undefined || Array.isArray( value ) ) { return ''; } if ( typeof value === 'object' ) {