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/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..8ca9b47bf --- /dev/null +++ b/e2e/get-title-and-content.spec.js @@ -0,0 +1,182 @@ +/** + * 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( + '\n

Hello

\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 ); + } ); + + 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/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index bfd11bc82..dbe9fe522 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -374,18 +374,22 @@ 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 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/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..cd6ee49b6 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -8,11 +8,62 @@ import { renderHook } from '@testing-library/react'; * Internal dependencies */ import { useHostBridge } from '../use-host-bridge'; +import { getBlockType } from '@wordpress/blocks'; -vi.mock( '@wordpress/data' ); +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 ) => { + if ( store?.name === 'core/editor' ) { + return { + getEditedPostAttribute: mockGetEditedPostAttribute, + getEditedPostContent: mockGetEditedPostContent, + }; + } + // block-editor store selectors + return { + getSelectedBlockClientId: mockGetSelectedBlockClientId, + getBlock: mockGetBlock, + getSelectionStart: mockGetSelectionStart, + getSelectionEnd: mockGetSelectionEnd, + }; + }, + useDispatch: () => ( { + editEntityRecord: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + switchEditorMode: vi.fn(), + updateBlock: mockUpdateBlock, + selectionChange: mockSelectionChange, + } ), +} ) ); vi.mock( '@wordpress/core-data' ); vi.mock( '@wordpress/editor' ); vi.mock( '@wordpress/blocks' ); +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' ); const defaultPost = { @@ -55,6 +106,260 @@ 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: '\n

Hello

\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( + '\n

Hello

\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( 'getContent returns normalized content string', () => { + mockGetEditedPostAttribute.mockReturnValue( 'Title' ); + mockGetEditedPostContent.mockReturnValue( { + raw: '\n

Hello

\n', + rendered: '

Hello

', + } ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getContent(); + expect( typeof result ).toBe( 'string' ); + expect( result ).toBe( + '\n

Hello

\n' + ); + } ); + + 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 250933406..073aec4f2 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( () => { @@ -51,12 +53,13 @@ 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 ) => { - if ( completeComposition ) { - endComposition( editorRef.current ); - } - - return getEditedPostContent(); + const { content } = + window.editor.getTitleAndContent( completeComposition ); + return content; }; window.editor.getTitleAndContent = ( completeComposition = false ) => { @@ -64,8 +67,10 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { 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; @@ -145,7 +150,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(); @@ -211,6 +218,27 @@ 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|null|undefined} value The value from a data store selector. + * @return {string} The raw string value. + */ +function normalizeAttribute( value ) { + if ( value === null || value === undefined || Array.isArray( value ) ) { + return ''; + } + if ( typeof value === 'object' ) { + return value.raw ?? ''; + } + return String( 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