diff --git a/e2e/post-entity-seeding.spec.js b/e2e/post-entity-seeding.spec.js new file mode 100644 index 000000000..abb5a2847 --- /dev/null +++ b/e2e/post-entity-seeding.spec.js @@ -0,0 +1,220 @@ +import { test, expect } from '@playwright/test'; +import EditorPage from './editor-page'; +import { credentials } from './wp-env-fixtures'; +import { fetchJson } from './fetch-json'; + +/** Matches the post's own path, not sub-resources like its autosaves. */ +const POST_PATH = /\/wp\/v2\/posts\/1(?:[?&]|$)/; + +/** Matches the post's path and its sub-resources. */ +const POST_PATHS = /\/wp\/v2\/posts\/1(?:[/?&]|$)/; + +const HOST_POST = { + id: 1, + type: 'post', + status: 'draft', + title: 'Host title', + content: + '\n
Host content
\n', +}; + +test.describe( 'Post entity seeding', () => { + let serverPost; + + test.beforeAll( async () => { + serverPost = await fetchJson( + `${ credentials.siteApiRoot }wp/v2/posts/1?context=edit`, + credentials, + 'post' + ); + } ); + + test( 'seeds the preloaded post without requesting it', async ( { + page, + } ) => { + const postRequests = []; + page.on( 'request', ( request ) => { + if ( POST_PATH.test( decodeURIComponent( request.url() ) ) ) { + postRequests.push( `${ request.method() } ${ request.url() }` ); + } + } ); + + // Stand in for the Android bridge, which receives host events on + // `window.editorDelegate`. + await page.addInitScript( () => { + window.contentChangedCount = 0; + window.featuredImageChanges = []; + window.editorDelegate = { + onEditorContentChanged: () => window.contentChangedCount++, + onEditorFeaturedImageChanged: ( mediaID ) => + window.featuredImageChanges.push( mediaID ), + }; + } ); + + const editor = new EditorPage( page ); + await editor.setup( { + post: HOST_POST, + preloadData: { + '/wp/v2/posts/1?context=edit': { + // The host may hold a different, unsynced featured image. + body: { ...serverPost, featured_media: 123 }, + headers: {}, + }, + }, + } ); + + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'Host title' ); + await expect( page.getByText( 'Host content' ) ).toBeVisible(); + + const record = await page.evaluate( () => + window.wp.data + .select( 'core' ) + .getEntityRecord( 'postType', 'post', 1 ) + ); + expect( record.date ).toBe( serverPost.date ); + expect( record.author ).toBe( serverPost.author ); + expect( record.title.raw ).toBe( 'Host title' ); + + expect( + await page.evaluate( () => + window.wp.data.select( 'core/editor' ).isEditedPostDirty() + ) + ).toBe( false ); + expect( await page.evaluate( () => window.contentChangedCount ) ).toBe( + 0 + ); + expect( + await page.evaluate( () => window.featuredImageChanges ) + ).toEqual( [] ); + expect( postRequests ).toEqual( [] ); + + await page.getByText( 'Host content' ).click(); + await page.keyboard.type( ' edited' ); + + await expect + .poll( () => page.evaluate( () => window.contentChangedCount ) ) + .toBeGreaterThan( 0 ); + } ); + + test( "uses core's post type entity configurations", async ( { page } ) => { + const editor = new EditorPage( page ); + await editor.setup( { post: HOST_POST } ); + + const config = await page.evaluate( () => { + const configs = window.wp.data + .select( 'core' ) + .getEntitiesConfig( 'postType' ) + .filter( ( { name } ) => + [ 'post', 'attachment', 'wp_block' ].includes( name ) + ); + return configs.map( ( entity ) => ( { + name: entity.name, + baseURL: entity.baseURL, + baseURLParams: entity.baseURLParams, + prePersist: typeof entity.__unstablePrePersist, + } ) ); + } ); + + expect( config ).toEqual( + [ + [ 'post', '/wp/v2/posts' ], + [ 'attachment', '/wp/v2/media' ], + [ 'wp_block', '/wp/v2/blocks' ], + ].map( ( [ name, baseURL ] ) => ( { + name, + baseURL, + baseURLParams: { context: 'edit' }, + prePersist: 'function', + } ) ) + ); + } ); + + test( 'renders the post when the post types fail to load', async ( { + page, + } ) => { + await page.route( /\/wp\/v2\/types\?/, ( route ) => route.abort() ); + + const editor = new EditorPage( page ); + await editor.setup( { post: HOST_POST, preloadData: {} } ); + + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'Host title' ); + await expect( page.getByText( 'Host content' ) ).toBeVisible(); + } ); + + test( "keeps the host's title and content when the post is fetched", async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { post: HOST_POST } ); + + const records = await page.evaluate( async () => { + const { resolveSelect, select } = window.wp.data; + const { getEntityRecord, getEntityRecords } = + resolveSelect( 'core' ); + await getEntityRecord( 'postType', 'post', 1, { _embed: true } ); + await getEntityRecords( 'postType', 'post', { include: [ 1 ] } ); + return { + embedded: select( 'core' ).getEntityRecord( + 'postType', + 'post', + 1, + { _embed: true } + ), + listed: select( 'core' ).getEntityRecords( 'postType', 'post', { + include: [ 1 ], + } )[ 0 ], + edited: select( 'core/editor' ).getEditedPostAttribute( + 'title' + ), + dirty: select( 'core/editor' ).isEditedPostDirty(), + }; + } ); + + for ( const record of [ records.embedded, records.listed ] ) { + expect( record.title.raw ).toBe( 'Host title' ); + expect( record.content.raw ).toBe( HOST_POST.content ); + expect( record.title.rendered ).toBe( serverPost.title.rendered ); + } + expect( records.embedded._embedded ).toBeDefined(); + expect( records.edited ).toBe( 'Host title' ); + expect( records.dirty ).toBe( false ); + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'Host title' ); + } ); + + test( 'blocks saving the post from the editor', async ( { page } ) => { + const writeRequests = []; + page.on( 'request', ( request ) => { + if ( + request.method() !== 'GET' && + request.method() !== 'OPTIONS' && + POST_PATHS.test( decodeURIComponent( request.url() ) ) + ) { + writeRequests.push( request.url() ); + } + } ); + + const editor = new EditorPage( page ); + await editor.setup( { post: HOST_POST } ); + + await page.getByText( 'Host content' ).click(); + await page.keyboard.type( ' edited' ); + + const result = await page.evaluate( async () => { + const { dispatch, select } = window.wp.data; + await dispatch( 'core/editor' ).savePost(); + return { + failed: select( 'core/editor' ).didPostSaveRequestFail(), + dirty: select( 'core/editor' ).isEditedPostDirty(), + }; + } ); + + expect( result ).toEqual( { failed: true, dirty: true } ); + expect( writeRequests ).toEqual( [] ); + } ); +} ); diff --git a/src/components/editor/index.jsx b/src/components/editor/index.jsx index 66efd696c..d54e56fe7 100644 --- a/src/components/editor/index.jsx +++ b/src/components/editor/index.jsx @@ -8,7 +8,6 @@ import { useSyncSaveAvailability } from './use-sync-save-availability'; import { useHostBridge } from './use-host-bridge'; import { useEditorReady } from './use-editor-ready'; import { useHostExceptionLogging } from './use-host-exception-logging'; -import { useEditorSetup } from './use-editor-setup'; import { useMediaUpload } from './use-media-upload'; import TextEditor from '../text-editor'; import { useSyncFeaturedImage } from './use-sync-featured-image'; @@ -37,7 +36,6 @@ export default function Editor( { post, children, hideTitle } ) { useSyncHistoryControls(); useSyncSaveAvailability(); useHostExceptionLogging(); - useEditorSetup( post ); useMediaUpload(); useDevModeNotice(); useAtAutocompleter(); diff --git a/src/components/editor/test/use-sync-featured-image.test.jsx b/src/components/editor/test/use-sync-featured-image.test.jsx new file mode 100644 index 000000000..89d4d742c --- /dev/null +++ b/src/components/editor/test/use-sync-featured-image.test.jsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useSyncFeaturedImage } from '../use-sync-featured-image'; +import { onEditorFeaturedImageChanged } from '../../../utils/bridge'; + +const mockGetEditedPostAttribute = vi.fn(); + +vi.mock( '@wordpress/data', () => ( { + useSelect: ( mapSelect ) => + mapSelect( () => ( { + getEditedPostAttribute: mockGetEditedPostAttribute, + } ) ), +} ) ); +vi.mock( '@wordpress/editor' ); +vi.mock( '../../../utils/bridge' ); + +describe( 'useSyncFeaturedImage', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'does not report the featured image the post loaded with', () => { + mockGetEditedPostAttribute.mockReturnValue( 123 ); + + renderHook( () => useSyncFeaturedImage() ); + + expect( onEditorFeaturedImageChanged ).not.toHaveBeenCalled(); + } ); + + it( 'reports a featured image set in the editor', () => { + mockGetEditedPostAttribute.mockReturnValue( 123 ); + const { rerender } = renderHook( () => useSyncFeaturedImage() ); + + mockGetEditedPostAttribute.mockReturnValue( 456 ); + rerender(); + + expect( onEditorFeaturedImageChanged ).toHaveBeenCalledWith( 456 ); + } ); + + it( 'reports the initial featured image when set again in the editor', () => { + mockGetEditedPostAttribute.mockReturnValue( 123 ); + const { rerender } = renderHook( () => useSyncFeaturedImage() ); + + mockGetEditedPostAttribute.mockReturnValue( 456 ); + rerender(); + mockGetEditedPostAttribute.mockReturnValue( 123 ); + rerender(); + + expect( onEditorFeaturedImageChanged ).toHaveBeenLastCalledWith( 123 ); + } ); + + it( 'does not report a removed featured image', () => { + mockGetEditedPostAttribute.mockReturnValue( 123 ); + const { rerender } = renderHook( () => useSyncFeaturedImage() ); + + mockGetEditedPostAttribute.mockReturnValue( 0 ); + rerender(); + + expect( onEditorFeaturedImageChanged ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/src/components/editor/use-editor-setup.js b/src/components/editor/use-editor-setup.js deleted file mode 100644 index af0fa1819..000000000 --- a/src/components/editor/use-editor-setup.js +++ /dev/null @@ -1,60 +0,0 @@ -import { useEffect } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; -import { store as editorStore } from '@wordpress/editor'; -import { postTypeEntities } from '../../utils/post-type-entities'; - -export function useEditorSetup( post ) { - const { addEntities, receiveEntityRecords } = useDispatch( coreStore ); - const { setEditedPost, setupEditor } = useDispatch( editorStore ); - - useEffect( () => { - const entities = buildEntitiesForPostType( post ); - - addEntities( entities ); - receiveEntityRecords( 'postType', post.type, post ); - - setupEditor( post, {} ); - - // Temp, check why this isn't being called in the provider. - setEditedPost( post.type, post.id ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [] ); -} - -/** - * Builds the entity configuration list for the given post type. - * - * If the post type is already in the static list (post, page, etc.), - * returns the static list as-is. Otherwise, dynamically creates an - * entity configuration using the restBase and restNamespace from the post object. - * - * @param {Object} post - The post object with type, restBase, and restNamespace - * @return {Array} Array of entity configurations - */ -function buildEntitiesForPostType( post ) { - const isRegistered = postTypeEntities.some( - ( entity ) => entity.name === post.type - ); - - if ( isRegistered ) { - return postTypeEntities; - } - - const dynamicEntity = { - kind: 'postType', - name: post.type, - baseURL: `/${ post.restNamespace }/${ post.restBase }`, - transientEdits: { - blocks: true, - selection: true, - }, - mergedEdits: { - meta: true, - }, - rawAttributes: [ 'title', 'excerpt', 'content' ], - }; - - return [ ...postTypeEntities, dynamicEntity ]; -} diff --git a/src/components/editor/use-sync-featured-image.js b/src/components/editor/use-sync-featured-image.js index fbfdb7c29..d666f6bdc 100644 --- a/src/components/editor/use-sync-featured-image.js +++ b/src/components/editor/use-sync-featured-image.js @@ -1,10 +1,13 @@ -import { useEffect } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; import { onEditorFeaturedImageChanged } from '../../utils/bridge'; /** * Unidirectionally synchronizes the featured image with the host. + * + * Only changes made in the editor are reported. The initial value comes from + * the preloaded server record, which may predate the host's own edits. */ export function useSyncFeaturedImage() { const { featuredImageId } = useSelect( ( select ) => { @@ -13,8 +16,15 @@ export function useSyncFeaturedImage() { featuredImageId: getEditedPostAttribute( 'featured_media' ), }; }, [] ); + const lastFeaturedImageId = useRef( featuredImageId ); useEffect( () => { + if ( featuredImageId === lastFeaturedImageId.current ) { + return; + } + + lastFeaturedImageId.current = featuredImageId; + if ( ! featuredImageId ) { return; } diff --git a/src/utils/api-fetch-post-process.test.js b/src/utils/api-fetch-post-process.test.js index 90fa021fd..da2e94595 100644 --- a/src/utils/api-fetch-post-process.test.js +++ b/src/utils/api-fetch-post-process.test.js @@ -27,6 +27,9 @@ vi.mock( './logger', () => ( { debug: vi.fn(), } ) ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); + const SITE_API_ROOT = 'https://example.com/wp-json/'; /** diff --git a/src/utils/api-fetch-root-url.test.js b/src/utils/api-fetch-root-url.test.js index be94a7c59..46abf1bf2 100644 --- a/src/utils/api-fetch-root-url.test.js +++ b/src/utils/api-fetch-root-url.test.js @@ -10,6 +10,8 @@ vi.mock( './bridge', async ( importOriginal ) => { getGBKit: vi.fn(), }; } ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); /** * The root is captured when the middleware is created, so it is asserted where diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js index 4c66eef65..4f954afcd 100644 --- a/src/utils/api-fetch-upload-middleware.test.js +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -13,6 +13,9 @@ vi.mock( './logger', () => ( { error: vi.fn(), } ) ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); + function makeNext() { return vi.fn( () => Promise.resolve( { passthrough: true } ) ); } diff --git a/src/utils/api-fetch-upload-scheme.test.js b/src/utils/api-fetch-upload-scheme.test.js index d4932ce18..4942fa47f 100644 --- a/src/utils/api-fetch-upload-scheme.test.js +++ b/src/utils/api-fetch-upload-scheme.test.js @@ -15,6 +15,9 @@ vi.mock( './logger', () => ( { error: vi.fn(), } ) ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); + const BASE = 'gbk-upload://upload'; const SESSION = '0f8fad5b-d9cb-469f-a165-70867728950e'; diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 6f52a6400..c60044365 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -1,6 +1,8 @@ import apiFetch from '@wordpress/api-fetch'; +import { store as coreStore } from '@wordpress/core-data'; +import { select } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import { getGBKit, POST_FALLBACKS } from './bridge'; +import { getGBKit, getPostPath, getPostReference } from './bridge'; import { info, warn, error as logError } from './logger'; import { ensureTrailingSlash, stripTrailingSlash } from './url'; @@ -23,8 +25,11 @@ const MEDIA_ATTACHMENT_PATH = /^\/wp\/v2\/media\/(\d+)(\?|$)/; */ export const NATIVE_UPLOAD_CHUNK_SIZE = 4 * 1024 * 1024; -/** Methods safe to repeat because they do not change server state. */ -const RETRYABLE_METHODS = [ 'GET', 'HEAD', 'OPTIONS' ]; +/** The response core-data builds post type entity configurations from. */ +const POST_TYPES_PATH = '/wp/v2/types?context=view'; + +/** Methods that do not change server state, so are safe to repeat. */ +const SAFE_METHODS = [ 'GET', 'HEAD', 'OPTIONS' ]; /** Base delay before each retry; jitter of up to the same amount is added. */ const RETRY_DELAYS_MS = [ 500, 2000 ]; @@ -49,7 +54,7 @@ export function configureApiFetch() { apiFetch.use( corsMiddleware ); apiFetch.use( apiPathModifierMiddleware ); apiFetch.use( tokenAuthMiddleware ); - apiFetch.use( filterEndpointsMiddleware ); + apiFetch.use( blockPostWritesMiddleware ); // `apiFetch.use` unshifts, so the last registration runs first. Core's media // upload middleware is registered after the native one so that it runs // before it, wrapping it: the native middleware handles an upload without @@ -79,14 +84,59 @@ export function configureApiFetch() { apiFetch.use( mediaPermissionsMiddleware ); apiFetch.use( transformOEmbedApiResponse ); apiFetch.use( siteIndexMiddleware ); + apiFetch.use( postTypesFallbackMiddleware ); apiFetch.use( - apiFetch.createPreloadingMiddleware( preloadData ?? defaultPreloadData ) + apiFetch.createPreloadingMiddleware( + withEditedPostType( preloadData ?? defaultPreloadData ) + ) ); + // Registered last so it runs first, covering preloaded responses too. + apiFetch.use( persistedPostOverlayMiddleware ); apiFetch.setFetchHandler( withRateLimitRetry( apiFetch.defaultFetchHandler ) ); } +/** + * Adds the edited post's type to the preloaded post types when missing. + * + * Core-data builds post type entity configurations from this response, so a + * missing type, e.g. a custom post type in offline mode, would leave the post + * without a configuration. + * + * Exported for testing only. + * + * @param {Object} preloadData The preloaded responses, keyed by path. + * @return {Object} The preloaded responses. + */ +export function withEditedPostType( preloadData ) { + const postTypes = preloadData[ POST_TYPES_PATH ]?.body; + const { type, restBase, restNamespace } = getPostReference( + getGBKit().post + ); + + if ( ! postTypes || postTypes[ type ] ) { + return preloadData; + } + + return { + ...preloadData, + [ POST_TYPES_PATH ]: { + ...preloadData[ POST_TYPES_PATH ], + body: { + ...postTypes, + [ type ]: { + name: type, + slug: type, + taxonomies: [], + rest_base: restBase, + rest_namespace: restNamespace, + }, + }, + }, + }; +} + /** * Middleware setting the CORS mode and remove a specific header causing CORS errors. * @@ -176,42 +226,51 @@ function tokenAuthMiddleware( options, next ) { } /** - * Middleware to filter out requests to specific endpoints. - * - * @type {APIFetchMiddleware} - * - * @todo Properly seed the post entity and remove this middleware. + * Middleware rejecting writes to the post being edited, including autosaves. * - * This was added to prevent re-fetching entity content provided by the native - * host app, which can lead to content loss. However, we can likely avoid the - * need for this middleware by ensuring we properly seed the entity content into - * the store on initialization. + * The host app saves the post, so a save from the editor, e.g. a plugin calling + * `savePost()`, would race the host's. Rejecting it, rather than resolving an + * empty response, keeps core from treating the post as saved and clearing its + * edits. * - * This requires hoisting the relevant logic from `useEditorSetup` to occur - * before we render the editor, and invoking `finishResolution`. + * @type {APIFetchMiddleware} * - * See: https://github.com/wordpress-mobile/GutenbergKit/commit/c9b4fc9978a3760ba97f3f5d4359c2bc2155bb80 + * @todo Remove once the editor saves posts. */ -function filterEndpointsMiddleware( options, next ) { - const { post } = getGBKit(); +function blockPostWritesMiddleware( options, next ) { + const method = ( options.method ?? 'GET' ).toUpperCase(); - if ( ! post || post.id === undefined ) { + if ( SAFE_METHODS.includes( method ) || ! isPostPath( options.path ) ) { return next( options ); } - // Apply the same fallback contract as `getPost()` so the filter still - // engages on hosts whose payload omits restBase/restNamespace. - const restNamespace = post.restNamespace || POST_FALLBACKS.restNamespace; - const restBase = post.restBase || POST_FALLBACKS.restBase; - const disabledPath = `/${ restNamespace }/${ restBase }/${ post.id }`; + warn( + `Blocked ${ method } ${ options.path }: the host app saves the post` + ); + return Promise.reject( { + code: 'gutenberg_kit_post_write_blocked', + message: 'The host app saves the post.', + } ); +} - if ( - options.path === disabledPath || - options.path?.startsWith( `${ disabledPath }?` ) - ) { - return Promise.resolve( [] ); +/** + * Whether a request path targets the post being edited or one of its + * sub-resources, such as autosaves. + * + * @param {string} [path] The request path, e.g. `/wp/v2/posts/1/autosaves`. + * @return {boolean} True for the post's paths. + */ +function isPostPath( path ) { + const { post } = getGBKit(); + + if ( typeof path !== 'string' || ! post ) { + return false; } - return next( options ); + + const postPath = getPostPath( getPostReference( post ) ); + const pathname = path.split( '?' )[ 0 ]; + + return pathname === postPath || pathname.startsWith( `${ postPath }/` ); } /** @@ -948,6 +1007,171 @@ function isRestIndexPath( path ) { return pathname === '' || pathname === '/'; } +/** + * Middleware answering a failed post types request with the built-in types. + * + * Core-data builds post type entity configurations from this response, and the + * editor cannot render the post without one, e.g. offline when the host did not + * preload it. + * + * Exported for testing only. + * + * @type {APIFetchMiddleware} + */ +export function postTypesFallbackMiddleware( options, next ) { + if ( options.path !== POST_TYPES_PATH || options.parse === false ) { + return next( options ); + } + + return next( options ).catch( ( err ) => { + warn( 'Failed to load post types; using the built-in ones', err ); + return withEditedPostType( defaultPreloadData )[ POST_TYPES_PATH ].body; + } ); +} + +/** + * Middleware keeping the persisted title and content of the post being edited + * in fetched copies of it. + * + * The post is seeded with the host's title and content, which may hold edits + * not yet uploaded. Core-data stores a fetched copy over the seeded record, + * e.g. from `getEntityRecord()` with a query or a list including the post, so + * the server's values would replace the host's. Other fields, such as embeds, + * are kept. + * + * Exported for testing only. + * + * @type {APIFetchMiddleware} + */ +export async function persistedPostOverlayMiddleware( options, next ) { + const method = ( options.method ?? 'GET' ).toUpperCase(); + + if ( method !== 'GET' || ! isPostReadPath( options.path ) ) { + return next( options ); + } + + const response = await next( options ); + const persisted = getPersistedPost(); + + if ( ! persisted ) { + return response; + } + + if ( options.parse !== false ) { + return overlayPersistedPost( response, persisted ); + } + + let body; + try { + body = await response.clone().json(); + } catch { + return response; + } + + const overlaid = overlayPersistedPost( body, persisted ); + + if ( overlaid === body ) { + return response; + } + + return new Response( JSON.stringify( overlaid ), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + } ); +} + +/** + * Whether a request path reads the post being edited, alone or in a list of + * its type. + * + * @param {string} [path] The request path, e.g. `/wp/v2/posts/1?_embed=1`. + * @return {boolean} True for the post's path and its type's collection path. + */ +function isPostReadPath( path ) { + if ( typeof path !== 'string' ) { + return false; + } + + const postPath = getPostPath( getPostReference( getGBKit().post ) ); + const collectionPath = postPath.slice( 0, postPath.lastIndexOf( '/' ) ); + const pathname = path.split( '?' )[ 0 ]; + + return pathname === postPath || pathname === collectionPath; +} + +/** + * Returns the persisted record of the post being edited once it is seeded. + * + * Checking the resolution first avoids triggering a fetch of the post. + * + * @return {Object|null} The record, or null before seeding. + */ +function getPersistedPost() { + const { id, type } = getPostReference( getGBKit().post ); + const args = [ 'postType', type, id ]; + const { hasFinishedResolution, getEntityRecord } = select( coreStore ); + + if ( ! hasFinishedResolution( 'getEntityRecord', args ) ) { + return null; + } + + return getEntityRecord( ...args ) ?? null; +} + +/** + * Applies the persisted title and content to copies of the post in a response. + * + * @param {Object|Object[]} body A record or list of records. + * @param {Object} persisted The post's persisted record. + * @return {Object|Object[]} The body, unchanged when it holds no stale copy. + */ +function overlayPersistedPost( body, persisted ) { + if ( ! Array.isArray( body ) ) { + return overlayRecord( body, persisted ); + } + + const records = body.map( ( record ) => + overlayRecord( record, persisted ) + ); + + return records.some( ( record, index ) => record !== body[ index ] ) + ? records + : body; +} + +/** + * Applies the persisted title and content to a record of the post. + * + * Only raw values are replaced; rendered values stay the server's. + * + * @param {Object} record A record from the response. + * @param {Object} persisted The post's persisted record. + * @return {Object} The record, unchanged when it isn't a stale copy. + */ +function overlayRecord( record, persisted ) { + if ( record?.id !== persisted.id ) { + return record; + } + + let result = record; + + for ( const field of [ 'title', 'content' ] ) { + const raw = persisted[ field ]?.raw; + const value = record[ field ]; + + if ( + raw !== undefined && + value?.raw !== undefined && + value.raw !== raw + ) { + result = { ...result, [ field ]: { ...value, raw } }; + } + } + + return result; +} + /** * Wraps a fetch handler to retry read-only requests the site rate-limits. * @@ -963,7 +1187,7 @@ function isRestIndexPath( path ) { export function withRateLimitRetry( fetchHandler ) { return async ( options ) => { const method = ( options.method ?? 'GET' ).toUpperCase(); - if ( ! RETRYABLE_METHODS.includes( method ) ) { + if ( ! SAFE_METHODS.includes( method ) ) { return fetchHandler( options ); } @@ -1090,7 +1314,7 @@ async function parseResponse( response, isOk ) { } const defaultPreloadData = { - '/wp/v2/types?context=view': { + [ POST_TYPES_PATH ]: { body: { post: { description: '', @@ -1118,6 +1342,32 @@ const defaultPreloadData = { template_lock: false, _links: {}, }, + attachment: { + description: '', + hierarchical: false, + has_archive: false, + name: 'Media', + slug: 'attachment', + taxonomies: [], + rest_base: 'media', + rest_namespace: 'wp/v2', + template: [], + template_lock: false, + _links: {}, + }, + wp_block: { + description: '', + hierarchical: false, + has_archive: false, + name: 'Patterns', + slug: 'wp_block', + taxonomies: [ 'wp_pattern_category' ], + rest_base: 'blocks', + rest_namespace: 'wp/v2', + template: [], + template_lock: false, + _links: {}, + }, }, }, '/wp/v2/types/post?context=edit': { diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index 2a40b650c..e6dacba6c 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -8,7 +8,14 @@ import { vi, } from 'vitest'; import apiFetch from '@wordpress/api-fetch'; -import { configureApiFetch, withRateLimitRetry } from './api-fetch'; +import { select } from '@wordpress/data'; +import { + configureApiFetch, + persistedPostOverlayMiddleware, + postTypesFallbackMiddleware, + withEditedPostType, + withRateLimitRetry, +} from './api-fetch'; import * as bridge from './bridge'; import * as logger from './logger'; @@ -20,6 +27,9 @@ vi.mock( './bridge', async ( importOriginal ) => { }; } ); vi.mock( './logger' ); +// Loading the real core-data store in vitest trips on its ESM dependencies. +vi.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); +vi.mock( '@wordpress/data', () => ( { select: vi.fn() } ) ); describe( 'api-fetch credentials handling', () => { let originalFetch; @@ -36,6 +46,7 @@ describe( 'api-fetch credentials handling', () => { beforeEach( () => { vi.clearAllMocks(); + select.mockReturnValue( { hasFinishedResolution: () => false } ); originalFetch = global.fetch; global.fetch = vi.fn( () => Promise.resolve( { @@ -115,73 +126,319 @@ describe( 'api-fetch credentials handling', () => { expect( options.headers.Authorization ).toBe( 'Bearer override-token' ); } ); - describe( 'filterEndpointsMiddleware', () => { - it( 'filters the post endpoint when restBase and restNamespace are provided', async () => { - bridge.getGBKit.mockReturnValue( { - siteApiRoot: 'https://example.com/wp-json/', - siteApiNamespace: [ 'wp/v2' ], - namespaceExcludedPaths: [], - post: { - id: 42, - restBase: 'posts', - restNamespace: 'wp/v2', - }, - } ); - - const result = await apiFetch( { path: '/wp/v2/posts/42' } ); + describe( 'blockPostWritesMiddleware', () => { + const gbkit = { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [ 'wp/v2' ], + namespaceExcludedPaths: [], + post: { + id: 42, + restBase: 'posts', + restNamespace: 'wp/v2', + }, + }; + it.each( [ + [ 'PUT', '/wp/v2/posts/42' ], + [ 'post', '/wp/v2/posts/42?_locale=user' ], + [ 'DELETE', '/wp/v2/posts/42' ], + [ 'POST', '/wp/v2/posts/42/autosaves' ], + ] )( 'rejects %s %s', async ( method, path ) => { + bridge.getGBKit.mockReturnValue( gbkit ); + + await expect( apiFetch( { path, method } ) ).rejects.toMatchObject( + { code: 'gutenberg_kit_post_write_blocked' } + ); expect( global.fetch ).not.toHaveBeenCalled(); - expect( result ).toEqual( [] ); + expect( logger.warn ).toHaveBeenCalled(); + } ); + + it.each( [ + [ 'GET', '/wp/v2/posts/42' ], + [ 'OPTIONS', '/wp/v2/posts/42' ], + [ 'PUT', '/wp/v2/posts/421' ], + [ 'POST', '/wp/v2/posts' ], + [ 'POST', '/wp/v2/pages/42' ], + ] )( 'lets %s %s through', async ( method, path ) => { + bridge.getGBKit.mockReturnValue( gbkit ); + + await apiFetch( { path, method } ); + + expect( global.fetch ).toHaveBeenCalled(); } ); it( 'falls back to default restBase and restNamespace when omitted from the payload', async () => { bridge.getGBKit.mockReturnValue( { - siteApiRoot: 'https://example.com/wp-json/', - siteApiNamespace: [ 'wp/v2' ], - namespaceExcludedPaths: [], + ...gbkit, post: { id: 7, // restBase and restNamespace intentionally omitted }, } ); - const result = await apiFetch( { path: '/wp/v2/posts/7' } ); - + await expect( + apiFetch( { path: '/wp/v2/posts/7', method: 'PUT' } ) + ).rejects.toMatchObject( { + code: 'gutenberg_kit_post_write_blocked', + } ); expect( global.fetch ).not.toHaveBeenCalled(); - expect( result ).toEqual( [] ); } ); - it( 'lets the request through when post id is undefined', async () => { + it( 'lets writes through when no post payload is present', async () => { bridge.getGBKit.mockReturnValue( { siteApiRoot: 'https://example.com/wp-json/', siteApiNamespace: [ 'wp/v2' ], namespaceExcludedPaths: [], - post: {}, } ); - try { - await apiFetch( { path: '/wp/v2/posts/99' } ); - } catch { - // Ignore errors from the actual fetch - } + await apiFetch( { path: '/wp/v2/posts/99', method: 'PUT' } ); expect( global.fetch ).toHaveBeenCalled(); } ); + } ); - it( 'lets the request through when no post payload is present', async () => { + describe( 'withEditedPostType', () => { + const typesPath = '/wp/v2/types?context=view'; + const preloadData = { + [ typesPath ]: { + body: { post: { slug: 'post', rest_base: 'posts' } }, + headers: { Allow: 'GET' }, + }, + '/wp/v2/settings': { body: {} }, + }; + + it( 'adds a post type missing from the preloaded types', () => { bridge.getGBKit.mockReturnValue( { - siteApiRoot: 'https://example.com/wp-json/', - siteApiNamespace: [ 'wp/v2' ], - namespaceExcludedPaths: [], + post: { + type: 'book', + restBase: 'books', + restNamespace: 'library/v1', + }, } ); - try { - await apiFetch( { path: '/wp/v2/posts/99' } ); - } catch { - // Ignore errors from the actual fetch - } + const result = withEditedPostType( preloadData ); + + expect( result[ typesPath ] ).toEqual( { + body: { + post: { slug: 'post', rest_base: 'posts' }, + book: { + name: 'book', + slug: 'book', + taxonomies: [], + rest_base: 'books', + rest_namespace: 'library/v1', + }, + }, + headers: { Allow: 'GET' }, + } ); + expect( result[ '/wp/v2/settings' ] ).toBe( + preloadData[ '/wp/v2/settings' ] + ); + expect( preloadData[ typesPath ].body.book ).toBeUndefined(); + } ); - expect( global.fetch ).toHaveBeenCalled(); + it( 'falls back to default restBase and restNamespace when omitted from the payload', () => { + bridge.getGBKit.mockReturnValue( { post: { type: 'book' } } ); + + const result = withEditedPostType( preloadData ); + + expect( result[ typesPath ].body.book ).toMatchObject( { + rest_base: 'posts', + rest_namespace: 'wp/v2', + } ); + } ); + + it.each( [ + [ + 'the post type is already preloaded', + { post: { type: 'post' } }, + ], + [ 'no post payload is present', {} ], + ] )( 'leaves the preload data unchanged when %s', ( _, gbkit ) => { + bridge.getGBKit.mockReturnValue( gbkit ); + + expect( withEditedPostType( preloadData ) ).toBe( preloadData ); + } ); + + it( 'leaves the preload data unchanged when post types are not preloaded', () => { + bridge.getGBKit.mockReturnValue( { post: { type: 'book' } } ); + const data = { '/wp/v2/settings': { body: {} } }; + + expect( withEditedPostType( data ) ).toBe( data ); + } ); + } ); + + describe( 'postTypesFallbackMiddleware', () => { + const typesPath = '/wp/v2/types?context=view'; + + it( 'answers a failed post types request with the built-in types', async () => { + bridge.getGBKit.mockReturnValue( { post: { type: 'book' } } ); + const next = vi.fn( () => + Promise.reject( new Error( 'Offline' ) ) + ); + + const types = await postTypesFallbackMiddleware( + { path: typesPath }, + next + ); + + expect( Object.keys( types ) ).toEqual( + expect.arrayContaining( [ + 'post', + 'page', + 'attachment', + 'wp_block', + 'book', + ] ) + ); + expect( logger.warn ).toHaveBeenCalled(); + } ); + + it( 'returns the post types the site sends', async () => { + const types = { post: { slug: 'post' } }; + const next = vi.fn( () => Promise.resolve( types ) ); + + await expect( + postTypesFallbackMiddleware( { path: typesPath }, next ) + ).resolves.toBe( types ); + } ); + + it.each( [ + [ 'other paths', { path: '/wp/v2/types/post?context=edit' } ], + [ 'unparsed requests', { path: typesPath, parse: false } ], + ] )( 'lets failures of %s through', async ( _, options ) => { + const failure = new Error( 'Offline' ); + const next = vi.fn( () => Promise.reject( failure ) ); + + await expect( + postTypesFallbackMiddleware( options, next ) + ).rejects.toBe( failure ); + } ); + } ); + + describe( 'persistedPostOverlayMiddleware', () => { + const persisted = { + id: 42, + title: { raw: 'Host title' }, + content: { raw: 'Host content
' }, + }; + const serverRecord = { + id: 42, + title: { raw: 'Server title', rendered: 'Server title' }, + content: { + raw: 'Server content
', + rendered: 'Server content
', + }, + _embedded: { author: [] }, + }; + const overlaidRecord = { + ...serverRecord, + title: { raw: 'Host title', rendered: 'Server title' }, + content: { + raw: 'Host content
', + rendered: 'Server content
', + }, + }; + let getEntityRecord; + + beforeEach( () => { + bridge.getGBKit.mockReturnValue( { + post: { id: 42, type: 'post' }, + } ); + getEntityRecord = vi.fn( () => persisted ); + select.mockReturnValue( { + hasFinishedResolution: () => true, + getEntityRecord, + } ); + } ); + + it( "keeps the persisted title and content in the post's record", async () => { + const next = vi.fn( () => Promise.resolve( serverRecord ) ); + + await expect( + persistedPostOverlayMiddleware( + { path: '/wp/v2/posts/42?_embed=1' }, + next + ) + ).resolves.toEqual( overlaidRecord ); + expect( getEntityRecord ).toHaveBeenCalledWith( + 'postType', + 'post', + 42 + ); + } ); + + it( 'keeps the persisted title and content in a list including the post', async () => { + const other = { id: 7, title: { raw: 'Other' } }; + const next = vi.fn( () => + Promise.resolve( [ other, serverRecord ] ) + ); + + const records = await persistedPostOverlayMiddleware( + { path: '/wp/v2/posts?include=42' }, + next + ); + + expect( records ).toEqual( [ other, overlaidRecord ] ); + expect( records[ 0 ] ).toBe( other ); + } ); + + it( 'overlays unparsed responses', async () => { + const next = vi.fn( () => + Promise.resolve( + new Response( JSON.stringify( [ serverRecord ] ), { + status: 200, + headers: { 'X-WP-Total': '1' }, + } ) + ) + ); + + const response = await persistedPostOverlayMiddleware( + { path: '/wp/v2/posts?per_page=100', parse: false }, + next + ); + + expect( response.headers.get( 'X-WP-Total' ) ).toBe( '1' ); + await expect( response.json() ).resolves.toEqual( [ + overlaidRecord, + ] ); + } ); + + it( 'returns responses without the post unchanged', async () => { + const records = [ { id: 7, title: { raw: 'Other' } } ]; + const next = vi.fn( () => Promise.resolve( records ) ); + + await expect( + persistedPostOverlayMiddleware( { path: '/wp/v2/posts' }, next ) + ).resolves.toBe( records ); + } ); + + it( 'leaves responses alone before the post is seeded', async () => { + select.mockReturnValue( { + hasFinishedResolution: () => false, + getEntityRecord, + } ); + const next = vi.fn( () => Promise.resolve( serverRecord ) ); + + await expect( + persistedPostOverlayMiddleware( + { path: '/wp/v2/posts/42' }, + next + ) + ).resolves.toBe( serverRecord ); + expect( getEntityRecord ).not.toHaveBeenCalled(); + } ); + + it.each( [ + [ 'sub-resources', { path: '/wp/v2/posts/42/autosaves' } ], + [ 'other posts', { path: '/wp/v2/posts/421' } ], + [ 'writes', { path: '/wp/v2/posts/42', method: 'PUT' } ], + ] )( 'ignores %s', async ( _, options ) => { + const next = vi.fn( () => Promise.resolve( serverRecord ) ); + + await expect( + persistedPostOverlayMiddleware( options, next ) + ).resolves.toBe( serverRecord ); } ); } ); diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 6422ad599..8a8f43ef3 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -274,8 +274,8 @@ export function onNetworkRequest( requestData ) { /** * Default values used when the native host omits fields from the post payload. - * Centralized here so all consumers (getPost, filterEndpointsMiddleware) agree - * on the fallback contract. + * Centralized here so all consumers (getPost, getPostReference) agree on the + * fallback contract. */ export const POST_FALLBACKS = { id: -1, @@ -307,6 +307,40 @@ export function getGBKit() { * @property {string} [status] The status of the post. */ +/** + * @typedef {Object} PostReference + * @property {number} id The ID of the post. + * @property {string} type The type of the post. + * @property {string} restBase The REST API base path for this post type. + * @property {string} restNamespace The REST API namespace for this post type. + */ + +/** + * Returns the identifiers of a post from the host's payload, with + * `POST_FALLBACKS` applied to the fields it omits. + * + * @param {Object} [post] The `post` payload of the GBKit object. + * @return {PostReference} The post's identifiers. + */ +export function getPostReference( post ) { + return { + id: post?.id || POST_FALLBACKS.id, + type: post?.type || POST_FALLBACKS.type, + restBase: post?.restBase || POST_FALLBACKS.restBase, + restNamespace: post?.restNamespace || POST_FALLBACKS.restNamespace, + }; +} + +/** + * Returns the REST path of a post, e.g. `/wp/v2/posts/1`. + * + * @param {PostReference} post The post. + * @return {string} The path. + */ +export function getPostPath( { id, restBase, restNamespace } ) { + return `/${ restNamespace }/${ restBase }/${ id }`; +} + /** * Requests the latest persisted content from the native host. * @@ -358,10 +392,7 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); return { - id: post?.id || POST_FALLBACKS.id, - type: post?.type || POST_FALLBACKS.type, - restBase: post?.restBase || POST_FALLBACKS.restBase, - restNamespace: post?.restNamespace || POST_FALLBACKS.restNamespace, + ...getPostReference( post ), status: post?.status || POST_FALLBACKS.status, title: { raw: hostContent.title }, content: { raw: hostContent.content }, @@ -371,10 +402,7 @@ export async function getPost() { if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); return { - id: post.id || POST_FALLBACKS.id, - type: post.type || POST_FALLBACKS.type, - restBase: post.restBase || POST_FALLBACKS.restBase, - restNamespace: post.restNamespace || POST_FALLBACKS.restNamespace, + ...getPostReference( post ), status: post.status || POST_FALLBACKS.status, title: { raw: decodeURIComponent( post.title ) }, content: { raw: decodeURIComponent( post.content ) }, diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index fe80728b6..86791e3b1 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -3,6 +3,8 @@ import { requestLatestContent, getGBKit, getPost, + getPostPath, + getPostReference, showBlockInserter, onEditorSaveAvailabilityChanged, } from './bridge'; @@ -226,6 +228,52 @@ describe( 'getGBKit', () => { } ); } ); +describe( 'getPostReference', () => { + it( "returns the host's post identifiers", () => { + expect( + getPostReference( { + id: 5, + type: 'book', + restBase: 'books', + restNamespace: 'library/v1', + title: 'Title', + } ) + ).toEqual( { + id: 5, + type: 'book', + restBase: 'books', + restNamespace: 'library/v1', + } ); + } ); + + it( 'falls back for fields the host omits', () => { + expect( getPostReference( { id: 5 } ) ).toEqual( { + id: 5, + type: 'post', + restBase: 'posts', + restNamespace: 'wp/v2', + } ); + expect( getPostReference() ).toEqual( { + id: -1, + type: 'post', + restBase: 'posts', + restNamespace: 'wp/v2', + } ); + } ); +} ); + +describe( 'getPostPath', () => { + it( "returns the post's REST path", () => { + expect( + getPostPath( { + id: 5, + restBase: 'books', + restNamespace: 'library/v1', + } ) + ).toBe( '/library/v1/books/5' ); + } ); +} ); + describe( 'getPost', () => { let originalWindow; diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index eaa3e10b7..1a6c3a09d 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -1,5 +1,6 @@ import { createRoot, StrictMode } from '@wordpress/element'; -import { dispatch } from '@wordpress/data'; +import { dispatch, 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'; import { registerCoreBlocks } from '@wordpress/block-library'; @@ -7,6 +8,7 @@ import Layout from '../components/layout'; import { unregisterDisallowedBlocks } from './blocks'; import { getGBKit, getPost } from './bridge'; import { getDefaultEditorSettings } from './editor-settings'; +import { primePostEntity } from './prime-post-entity'; /** * Configure editor settings and styles, and render the editor. @@ -41,6 +43,19 @@ export async function initializeEditor( { unregisterDisallowedBlocks( allowedBlockTypes ); const post = await getPost(); + await primePostEntity( post ); + + // 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 + ); + if ( record ) { + dispatch( editorStore ).setupEditor( record, {} ); + } createRoot( document.getElementById( 'root' ) ).render(Host content
' }, + ...overrides, + }; +} + +describe( 'primePostEntity', () => { + let coreDispatch; + let getEntityConfig; + let getEntitiesConfig; + + beforeEach( () => { + vi.clearAllMocks(); + + coreDispatch = { + receiveEntityRecords: vi.fn(), + finishResolution: vi.fn(), + }; + getEntityConfig = vi.fn( () => ( { name: 'post' } ) ); + getEntitiesConfig = vi.fn( () => Promise.resolve( [] ) ); + + dispatch.mockReturnValue( coreDispatch ); + select.mockReturnValue( { getEntityConfig } ); + resolveSelect.mockReturnValue( { getEntitiesConfig } ); + getGBKit.mockReturnValue( {} ); + } ); + + it( "loads core's post type entities before seeding", async () => { + await primePostEntity( buildPost() ); + + expect( getEntitiesConfig ).toHaveBeenCalledWith( 'postType' ); + expect( getEntityConfig ).toHaveBeenCalledWith( 'postType', 'post' ); + expect( warn ).not.toHaveBeenCalled(); + } ); + + it( 'warns when core has no configuration for the post type', async () => { + getEntityConfig.mockReturnValue( undefined ); + + await primePostEntity( buildPost( { type: 'book' } ) ); + + expect( warn ).toHaveBeenCalledWith( + expect.stringContaining( '"book"' ) + ); + } ); + + it( 'marks the post resolved so core-data never fetches it', async () => { + await primePostEntity( buildPost( { id: 2 } ) ); + + expect( coreDispatch.finishResolution ).toHaveBeenCalledWith( + 'getEntityRecord', + [ 'postType', 'post', 2 ] + ); + } ); + + it( 'seeds the host post when the server record is not preloaded', async () => { + const post = buildPost( { id: -1 } ); + + await primePostEntity( post ); + + expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + post + ); + } ); + + it( 'seeds the preloaded server record with the host title and content', async () => { + getGBKit.mockReturnValue( { + preloadData: { + '/wp/v2/posts/3?context=edit': { + body: { + id: 3, + type: 'post', + status: 'publish', + author: 7, + title: { + raw: 'Server title', + rendered: 'Server title', + }, + content: { + raw: 'Server content
', + rendered: 'Server content
', + protected: false, + }, + }, + }, + }, + } ); + + await primePostEntity( buildPost( { id: 3 } ) ); + + expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + { + id: 3, + type: 'post', + status: 'publish', + author: 7, + title: { raw: 'Host title', rendered: 'Server title' }, + content: { + raw: 'Host content
', + rendered: 'Server content
', + protected: false, + }, + } + ); + } ); +} );