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/2] 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/2] 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 ); } /**