diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php index 0b8e6e1012d1a4..d1a7aa9211f105 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php @@ -15,6 +15,13 @@ array( 'clientNavigationDisabled' => true ) ); } + +if ( isset( $attributes['data'] ) ) { + wp_interactivity_state( + 'router', + array( 'data' => $attributes['data'] ) + ); +} ?>
NaN + NaN NaN - $link ) { - $i = $key += 1; - echo <<link $i - link $i with hash + +
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js index 1e137969936a09..b2d4ad0dc1ddeb 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js @@ -6,14 +6,23 @@ import { store } from '@wordpress/interactivity'; const { state } = store( 'router', { state: { status: 'idle', - navigations: 0, + navigations: { + pending: 0, + count: 0, + }, timeout: 10000, + data: { + get getterProp() { + return `value from getter (${ state.data.prop1 })`; + } + } }, actions: { *navigate( e ) { e.preventDefault(); - state.navigations += 1; + state.navigations.count += 1; + state.navigations.pending += 1; state.status = 'busy'; const force = e.target.dataset.forceNavigation === 'true'; @@ -24,9 +33,9 @@ const { state } = store( 'router', { ); yield actions.navigate( e.target.href, { force, timeout } ); - state.navigations -= 1; + state.navigations.pending -= 1; - if ( state.navigations === 0 ) { + if ( state.navigations.pending === 0 ) { state.status = 'idle'; } }, diff --git a/packages/interactivity-router/CHANGELOG.md b/packages/interactivity-router/CHANGELOG.md index 72a9dd459a688c..799425e4cd9d51 100644 --- a/packages/interactivity-router/CHANGELOG.md +++ b/packages/interactivity-router/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- Fix navigate() issues related to initial state merges. ([#57134](https://github.com/WordPress/gutenberg/pull/57134)) + ## 1.2.0 (2024-02-21) ## 1.1.0 (2024-02-09) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 724a2660df41dc..03d399338167ce 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -3,10 +3,18 @@ */ import { store, privateApis, getConfig } from '@wordpress/interactivity'; -const { directivePrefix, getRegionRootFragment, initialVdom, toVdom, render } = - privateApis( - 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' - ); +const { + directivePrefix, + getRegionRootFragment, + initialVdom, + toVdom, + render, + parseInitialData, + populateInitialData, + batch, +} = privateApis( + 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' +); // The cache of visited and prefetched pages. const pages = new Map(); @@ -45,20 +53,24 @@ const regionsToVdom = ( dom, { vdom } = {} ) => { : toVdom( region ); } ); const title = dom.querySelector( 'title' )?.innerText; - return { regions, title }; + const initialData = parseInitialData( dom ); + return { regions, title, initialData }; }; // Render all interactive regions contained in the given page. const renderRegions = ( page ) => { - const attrName = `data-${ directivePrefix }-router-region`; - document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => { - const id = region.getAttribute( attrName ); - const fragment = getRegionRootFragment( region ); - render( page.regions[ id ], fragment ); + batch( () => { + populateInitialData( page.initialData ); + const attrName = `data-${ directivePrefix }-router-region`; + document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => { + const id = region.getAttribute( attrName ); + const fragment = getRegionRootFragment( region ); + render( page.regions[ id ], fragment ); + } ); + if ( page.title ) { + document.title = page.title; + } } ); - if ( page.title ) { - document.title = page.title; - } }; /** @@ -176,7 +188,11 @@ export const { state, actions } = store( 'core/router', { // out, and let the newer execution to update the HTML. if ( navigatingTo !== href ) return; - if ( page ) { + if ( + page && + ! page.initialData?.config?.[ 'core/router' ] + ?.clientNavigationDisabled + ) { renderRegions( page ); window.history[ options.replace ? 'replaceState' : 'pushState' diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 8e48ead8429d3b..1e81760b8d05c1 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- Prevent passing state proxies as receivers to deepSignal proxy handlers. ([#57134](https://github.com/WordPress/gutenberg/pull/57134)) + ## 5.1.0 (2024-02-21) ### Bug Fixes diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 2a98900dfe379e..3c91e919d91bdc 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -2,6 +2,7 @@ * External dependencies */ import { h, cloneElement, render } from 'preact'; +import { batch } from '@preact/signals'; import { deepSignal } from 'deepsignal'; /** @@ -12,6 +13,7 @@ import { init, getRegionRootFragment, initialVdom } from './init'; import { directivePrefix } from './constants'; import { toVdom } from './vdom'; import { directive, getNamespace } from './hooks'; +import { parseInitialData, populateInitialData } from './store'; export { store, getConfig } from './store'; export { getContext, getElement } from './hooks'; @@ -43,6 +45,9 @@ export const privateApis = ( lock ): any => { cloneElement, render, deepSignal, + parseInitialData, + populateInitialData, + batch, }; } diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index bbdb167054e3b3..ca65bbbc6aa180 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -26,29 +26,20 @@ const deepMerge = ( target: any, source: any ) => { if ( typeof getter === 'function' ) { Object.defineProperty( target, key, { get: getter } ); } else if ( isObject( source[ key ] ) ) { - if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } ); + if ( ! target[ key ] ) target[ key ] = {}; deepMerge( target[ key ], source[ key ] ); } else { - Object.assign( target, { [ key ]: source[ key ] } ); + try { + target[ key ] = source[ key ]; + } catch ( e ) { + // Assignemnts fail for properties that are only getters. + // When that's the case, the assignment is simply ignored. + } } } } }; -const parseInitialData = () => { - const storeTag = document.querySelector( - `script[type="application/json"]#wp-interactivity-data` - ); - if ( storeTag?.textContent ) { - try { - return JSON.parse( storeTag.textContent ); - } catch ( e ) { - // Do nothing. - } - } - return {}; -}; - export const stores = new Map(); const rawStores = new Map(); const storeLocks = new Map(); @@ -100,13 +91,13 @@ const handlers = { } } - const result = Reflect.get( target, key, receiver ); + const result = Reflect.get( target, key ); // Check if the proxy is the store root and no key with that name exist. In // that case, return an empty object for the requested key. if ( typeof result === 'undefined' && receiver === stores.get( ns ) ) { const obj = {}; - Reflect.set( target, key, obj, receiver ); + Reflect.set( target, key, obj ); return proxify( obj, ns ); } @@ -163,6 +154,10 @@ const handlers = { return result; }, + // Prevents passing the current proxy as the receiver to the deepSignal. + set( target: any, key: string, value: any ) { + return Reflect.set( target, key, value ); + }, }; /** @@ -310,15 +305,36 @@ export function store( return stores.get( namespace ); } +export const parseInitialData = ( dom = document ) => { + const storeTag = dom.querySelector( + `script[type="application/json"]#wp-interactivity-data` + ); + if ( storeTag?.textContent ) { + try { + return JSON.parse( storeTag.textContent ); + } catch ( e ) { + // Do nothing. + } + } + return {}; +}; + +export const populateInitialData = ( data?: { + state?: Record< string, unknown >; + config?: Record< string, unknown >; +} ) => { + if ( isObject( data?.state ) ) { + Object.entries( data.state ).forEach( ( [ namespace, state ] ) => { + store( namespace, { state }, { lock: universalUnlock } ); + } ); + } + if ( isObject( data?.config ) ) { + Object.entries( data.config ).forEach( ( [ namespace, config ] ) => { + storeConfigs.set( namespace, config ); + } ); + } +}; + // Parse and populate the initial state and config. const data = parseInitialData(); -if ( isObject( data?.state ) ) { - Object.entries( data.state ).forEach( ( [ namespace, state ] ) => { - store( namespace, { state }, { lock: universalUnlock } ); - } ); -} -if ( isObject( data?.config ) ) { - Object.entries( data.config ).forEach( ( [ namespace, config ] ) => { - storeConfigs.set( namespace, config ); - } ); -} +populateInitialData( data ); diff --git a/test/e2e/specs/interactivity/router-navigate.spec.ts b/test/e2e/specs/interactivity/router-navigate.spec.ts index fafa31341f463e..872fe9ea7ea52e 100644 --- a/test/e2e/specs/interactivity/router-navigate.spec.ts +++ b/test/e2e/specs/interactivity/router-navigate.spec.ts @@ -12,18 +12,37 @@ test.describe( 'Router navigate', () => { } ); const link1 = await utils.addPostWithBlock( 'test/router-navigate', { alias: 'router navigate - link 1', - attributes: { title: 'Link 1' }, - } ); - await utils.addPostWithBlock( 'test/router-navigate', { - alias: 'router navigate - main', - attributes: { title: 'Main', links: [ link1, link2 ] }, + attributes: { + title: 'Link 1', + data: { + getterProp: 'value from link1', + prop1: 'link 1', + prop3: 'link 1', + }, + }, } ); - await utils.addPostWithBlock( 'test/router-navigate', { + const link3 = await utils.addPostWithBlock( 'test/router-navigate', { alias: 'router navigate - disabled', attributes: { title: 'Main (navigation disabled)', links: [ link1, link2 ], disableNavigation: true, + data: { + getterProp: 'value from main (navigation disabled)', + prop1: 'main (navigation disabled)', + }, + }, + } ); + await utils.addPostWithBlock( 'test/router-navigate', { + alias: 'router navigate - main', + attributes: { + title: 'Main', + links: [ link1, link2, link3 ], + data: { + getterProp: 'value from main', + prop1: 'main', + prop2: 'main', + }, }, } ); } ); @@ -44,7 +63,7 @@ test.describe( 'Router navigate', () => { const link1 = utils.getLink( 'router navigate - link 1' ); const link2 = utils.getLink( 'router navigate - link 2' ); - const navigations = page.getByTestId( 'router navigations' ); + const navigations = page.getByTestId( 'router navigations pending' ); const status = page.getByTestId( 'router status' ); const title = page.getByTestId( 'title' ); @@ -89,7 +108,7 @@ test.describe( 'Router navigate', () => { } ) => { const link1 = utils.getLink( 'router navigate - link 1' ); - const navigations = page.getByTestId( 'router navigations' ); + const navigations = page.getByTestId( 'router navigations pending' ); const status = page.getByTestId( 'router status' ); const title = page.getByTestId( 'title' ); @@ -175,12 +194,12 @@ test.describe( 'Router navigate', () => { } ) => { await page.goto( utils.getLink( 'router navigate - disabled' ) ); - const navigations = page.getByTestId( 'router navigations' ); + const count = page.getByTestId( 'router navigations count' ); const status = page.getByTestId( 'router status' ); const title = page.getByTestId( 'title' ); // Check some elements to ensure the page has hydrated. - await expect( navigations ).toHaveText( '0' ); + await expect( count ).toHaveText( '0' ); await expect( status ).toHaveText( 'idle' ); await page.getByTestId( 'link 1' ).click(); @@ -189,6 +208,90 @@ test.describe( 'Router navigate', () => { await expect( title ).toHaveText( 'Link 1' ); // Check that client-navigations count has not increased. - await expect( navigations ).toHaveText( '0' ); + await expect( count ).toHaveText( '0' ); + } ); + + test( 'should overwrite the state with the one serialized in the new page', async ( { + page, + } ) => { + const prop1 = page.getByTestId( 'prop1' ); + const prop2 = page.getByTestId( 'prop2' ); + const prop3 = page.getByTestId( 'prop3' ); + + await expect( prop1 ).toHaveText( 'main' ); + await expect( prop2 ).toHaveText( 'main' ); + await expect( prop3 ).toBeEmpty(); + + await page.getByTestId( 'link 1' ).click(); + + // New values for existing properties should change. + // Old values not overwritten should remain the same. + // New properties should appear. + await expect( prop1 ).toHaveText( 'link 1' ); + await expect( prop2 ).toHaveText( 'main' ); + await expect( prop3 ).toHaveText( 'link 1' ); + + await page.goBack(); + + // New added properties are preserved. + await expect( prop1 ).toHaveText( 'main' ); + await expect( prop2 ).toHaveText( 'main' ); + await expect( prop3 ).toHaveText( 'link 1' ); + } ); + + test( 'should not try to overwrite getters with values from the initial data', async ( { + page, + } ) => { + const title = page.getByTestId( 'title' ); + const getter = page.getByTestId( 'getterProp' ); + + // Title should start in 'Main' and the getter prop should be the one + // returned once hydrated. + await expect( title ).toHaveText( 'Main' ); + await expect( getter ).toHaveText( 'value from getter (main)' ); + + await page.getByTestId( 'link 1' ).click(); + + // Title should have changed. If not, that means there was an error + // during render. The getter should return the correct value. + await expect( title ).toHaveText( 'Link 1' ); + await expect( getter ).toHaveText( 'value from getter (link 1)' ); + + // Same behavior navigating back and forward. + await page.goBack(); + await expect( title ).toHaveText( 'Main' ); + await expect( getter ).toHaveText( 'value from getter (main)' ); + + await page.goForward(); + await expect( title ).toHaveText( 'Link 1' ); + await expect( getter ).toHaveText( 'value from getter (link 1)' ); + } ); + + test( 'should force a page reload when navigating to a page with `clientNavigationDisabled`', async ( { + page, + } ) => { + const count = page.getByTestId( 'router navigations count' ); + const title = page.getByTestId( 'title' ); + + // Check the cound to ensure the page has hydrated. + await expect( count ).toHaveText( '0' ); + + // Navigate to a page without clientNavigationDisabled. + await page.getByTestId( 'link 1' ).click(); + + // Check the page has updated and the navigation count has increased. + await expect( title ).toHaveText( 'Link 1' ); + await expect( count ).toHaveText( '1' ); + + await page.goBack(); + await expect( title ).toHaveText( 'Main' ); + await expect( count ).toHaveText( '1' ); + + // Navigate to a page with clientNavigationDisabled. + await page.getByTestId( 'link 3' ).click(); + + // Check the page has updated and the navigation count is zero. + await expect( title ).toHaveText( 'Main (navigation disabled)' ); + await expect( count ).toHaveText( '0' ); } ); } );