diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php index 76635e37a26086..5fae2a9e66c9d9 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -166,6 +166,14 @@ + +
+
Async Navigate
`; -const { actions } = store( 'directive-context-navigate', { +const { actions, state } = store( 'directive-context-navigate', { + state: { + get navigationCount() { + const { __navigationCount } = state; + return isNaN( __navigationCount ) ? 0 : __navigationCount; + }, + __navigationCount: NaN, + }, actions: { toggleText() { const ctx = getContext(); @@ -99,6 +106,15 @@ const { actions } = store( 'directive-context-navigate', { ctx.newText = 'changed from async action'; }, }, + callbacks: { + updateNavigationCount() { + const { state: routerState } = store( 'core/router' ); + if ( routerState.url && isNaN( state.__navigationCount ) ) { + state.__navigationCount = 0; + } + state.__navigationCount++; + }, + }, } ); store( 'directive-context-watch', { diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php index f2f29425095279..3b9f8429c18823 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php @@ -110,18 +110,54 @@
-
-

- content from page -

-
-
-

+ +

+

content from page

+ + + +
+

+ content from page +

+ +
+
+

+ content from page +

+
+
{ } }; +/** + * Clones the content of the router region vDOM passed as argument. + * + * The function creates a new VNode instance removing all priority levels up to + * the one containing the router-region directive, which should have evaluated + * in advance. + * + * @param vdom A router region's VNode. + * @return The VNode for the passed router region's content. + */ +const cloneRouterRegionContent = ( vdom: any ) => { + if ( ! vdom ) { + return vdom; + } + const allPriorityLevels: string[][] = vdom.props.priorityLevels; + const routerRegionLevel = allPriorityLevels.findIndex( ( level ) => + level.includes( 'router-region' ) + ); + const priorityLevels = + routerRegionLevel !== -1 + ? allPriorityLevels.slice( routerRegionLevel + 1 ) + : allPriorityLevels; + + return priorityLevels.length > 0 + ? cloneElement( vdom, { + ...vdom.props, + priorityLevels, + } ) + : vdom.props.element; +}; + +/** + * IDs of router regions with an `attachTo` property pointing to the same parent + * element. + */ +const regionsToAttachByParent = new WeakMap< Element, string[] >(); + +/** + * Map of root fragments by parent element, used to render router regions with + * the `attachTo` property. Those elements with the same parent are rendered + * together in the corresponding root fragment. + */ +const rootFragmentsByParent = new WeakMap< Element, any >(); + /** * Fetches and prepares a page from a given URL. * @@ -146,9 +192,15 @@ const preparePage: PreparePage = async ( url, dom, { vdom } = {} ) => { const regionsToAttach = {}; dom.querySelectorAll( regionsSelector ).forEach( ( region ) => { const { id, attachTo } = parseRegionAttribute( region ); - regions[ id ] = vdom?.has( region ) - ? vdom.get( region ) - : toVdom( region ); + + if ( region.parentElement.closest( `[${ regionAttr }]` ) ) { + regions[ id ] = undefined; + } else { + regions[ id ] = vdom?.has( region ) + ? vdom.get( region ) + : toVdom( region ); + } + if ( attachTo ) { regionsToAttach[ id ] = attachTo; } @@ -187,32 +239,61 @@ const renderPage = ( page: Page ) => { const regionsToAttach = { ...page.regionsToAttach }; batch( () => { + // Update server data. populateServerData( page.initialData ); - document.querySelectorAll( regionsSelector ).forEach( ( region ) => { - const { id } = parseRegionAttribute( region ); - const fragment = getRegionRootFragment( region ); - render( page.regions[ id ], fragment ); - // If this is an attached region, remove it from the list. - delete regionsToAttach[ id ]; + + // Reset all router regions before setting the actual values. + ( routerRegions as Map< string, any > ).forEach( ( signal ) => { + signal.value = null; } ); - // Render unattached regions. + //Init regions with attachTo that don't exist yet. + const parentsToUpdate = new Set< Element >(); for ( const id in regionsToAttach ) { const parent = document.querySelector( regionsToAttach[ id ] ); + if ( ! regionsToAttachByParent.has( parent ) ) { + regionsToAttachByParent.set( parent, [] ); + } + const regions = regionsToAttachByParent.get( parent ); + if ( ! regions.includes( id ) ) { + regions.push( id ); + parentsToUpdate.add( parent ); + } + } - // Get the type from the vnode. If wrapped with Directives, get the - // original type from `props.type`. - const { props, type } = page.regions[ id ]; - const elementType = typeof type === 'function' ? props.type : type; - - // Create an element with the obtained type where the region will be - // rendered. The type should match the one of the root vnode. - const region = document.createElement( elementType ); - parent.appendChild( region ); - - const fragment = getRegionRootFragment( region ); - render( page.regions[ id ], fragment ); + //Update all existing regions. + for ( const id in page.regions ) { + if ( routerRegions.has( id ) ) { + routerRegions.get( id ).value = cloneRouterRegionContent( + page.regions[ id ] + ); + } } + + // Render regions attached to the same parent in the same fragment. + parentsToUpdate.forEach( ( parent ) => { + const ids = regionsToAttachByParent.get( parent ); + const vdoms = ids.map( ( id ) => page.regions[ id ] ); + + if ( ! rootFragmentsByParent.has( parent ) ) { + const regions = vdoms.map( ( { props, type } ) => { + const elementType = + typeof type === 'function' ? props.type : type; + + // Create an element with the obtained type where the region will be + // rendered. The type should match the one of the root vnode. + const region = document.createElement( elementType ); + parent.appendChild( region ); + return region; + } ); + rootFragmentsByParent.set( + parent, + getRegionRootFragment( regions ) + ); + } + const fragment = rootFragmentsByParent.get( parent ); + render( vdoms, fragment ); + } ); } ); if ( page.title ) { diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index bd71507ef420fc..dc7a55bfb2d44d 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -4,8 +4,14 @@ /** * External dependencies */ -import { h as createElement, type VNode, type RefObject } from 'preact'; +import { + h as createElement, + cloneElement, + type VNode, + type RefObject, +} from 'preact'; import { useContext, useMemo, useRef } from 'preact/hooks'; +import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies @@ -199,6 +205,20 @@ const getGlobalAsyncEventDirective = ( }; }; +/** + * Relates each router region with its current vDOM content. Used by the + * `router-region` directive. + * + * Keys are router region IDs, and values are signals with the corresponding + * VNode rendered inside. If the value is `null`, that means the regions should + * not be rendered. If the value is `undefined`, the region is already contained + * inside another router region and does not need to change its children. + */ +export const routerRegions = new Map< + string, + Signal< VNode | null | undefined > +>(); + export default () => { // data-wp-context directive( @@ -718,4 +738,34 @@ export default () => { ); directive( 'each-child', () => null, { priority: 1 } ); + + directive( + 'router-region', + ( { directives: { 'router-region': routerRegion } } ) => { + const entry = routerRegion.find( isDefaultDirectiveSuffix ); + if ( ! entry ) { + return; + } + + const regionId = + typeof entry.value === 'string' + ? entry.value + : ( entry.value as any ).id; + + if ( ! routerRegions.has( regionId ) ) { + routerRegions.set( regionId, signal() ); + } + + // Get the content of this router region. + const vdom = routerRegions.get( regionId )!.value; + + if ( vdom && typeof vdom.type !== 'string' ) { + // The scope needs to be injected. + const previousScope = getScope(); + return cloneElement( vdom, { previousScope } ); + } + return vdom; + }, + { priority: 1 } + ); }; diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 8c8132cef26f94..5dd179794f3c13 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -7,7 +7,7 @@ import { batch } from '@preact/signals'; /** * Internal dependencies */ -import registerDirectives from './directives'; +import registerDirectives, { routerRegions } from './directives'; import { init, getRegionRootFragment, initialVdom } from './init'; import { directivePrefix } from './constants'; import { toVdom } from './vdom'; @@ -57,6 +57,7 @@ export const privateApis = ( lock ): any => { parseServerData, populateServerData, batch, + routerRegions, }; } diff --git a/packages/interactivity/src/init.ts b/packages/interactivity/src/init.ts index f91d695cf0b9f3..ff79a733450fb7 100644 --- a/packages/interactivity/src/init.ts +++ b/packages/interactivity/src/init.ts @@ -11,14 +11,17 @@ import { directivePrefix } from './constants'; // Keep the same root fragment for each interactive region node. const regionRootFragments = new WeakMap(); -export const getRegionRootFragment = ( region: Element ): ContainerNode => { +export const getRegionRootFragment = ( + regions: Element | Element[] +): ContainerNode => { + const region = Array.isArray( regions ) ? regions[ 0 ] : regions; if ( ! region.parentElement ) { throw Error( 'The passed region should be an element with a parent.' ); } if ( ! regionRootFragments.has( region ) ) { regionRootFragments.set( region, - createRootFragment( region.parentElement, region ) + createRootFragment( region.parentElement, regions ) ); } return regionRootFragments.get( region ); diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index 271c3649c31eb4..4b44504874133a 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -333,6 +333,9 @@ export const createRootFragment = ( removeChild( c: Node ) { parent.removeChild( c ); }, + contains( c: Node ) { + parent.contains( c ); + }, } ); }; diff --git a/test/e2e/specs/interactivity/directive-context.spec.ts b/test/e2e/specs/interactivity/directive-context.spec.ts index 0a27fe258d5a8d..b1e21b60710d8e 100644 --- a/test/e2e/specs/interactivity/directive-context.spec.ts +++ b/test/e2e/specs/interactivity/directive-context.spec.ts @@ -252,17 +252,24 @@ test.describe( 'data-wp-context', () => { page, } ) => { const element = page.getByTestId( 'navigation text' ); + const navCount = page.getByTestId( 'navigation count' ); + await expect( navCount ).toHaveText( '0' ); await page.getByTestId( 'navigate' ).click(); + await expect( navCount ).toHaveText( '1' ); await expect( element ).toHaveText( 'first page' ); await page.goBack(); + await expect( navCount ).toHaveText( '2' ); await expect( element ).toHaveText( 'first page' ); await page.goForward(); + await expect( navCount ).toHaveText( '3' ); await expect( element ).toHaveText( 'first page' ); } ); test( 'should inherit values on navigation', async ( { page } ) => { const text = page.getByTestId( 'navigation inherited text' ); const text2 = page.getByTestId( 'navigation inherited text2' ); + const navCount = page.getByTestId( 'navigation count' ); + await expect( navCount ).toHaveText( '0' ); await expect( text ).toHaveText( 'first page' ); await expect( text2 ).toBeEmpty(); await page.getByTestId( 'toggle text' ).click(); @@ -270,12 +277,15 @@ test.describe( 'data-wp-context', () => { await page.getByTestId( 'add text2' ).click(); await expect( text2 ).toHaveText( 'some new text' ); await page.getByTestId( 'navigate' ).click(); + await expect( navCount ).toHaveText( '1' ); await expect( text ).toHaveText( 'changed dynamically' ); await expect( text2 ).toHaveText( 'some new text' ); await page.goBack(); + await expect( navCount ).toHaveText( '2' ); await expect( text ).toHaveText( 'changed dynamically' ); await expect( text2 ).toHaveText( 'some new text' ); await page.goForward(); + await expect( navCount ).toHaveText( '3' ); await expect( text ).toHaveText( 'changed dynamically' ); await expect( text2 ).toHaveText( 'some new text' ); } ); diff --git a/test/e2e/specs/interactivity/router-regions.spec.ts b/test/e2e/specs/interactivity/router-regions.spec.ts index ecce42160b48b3..130547518b72b1 100644 --- a/test/e2e/specs/interactivity/router-regions.spec.ts +++ b/test/e2e/specs/interactivity/router-regions.spec.ts @@ -41,6 +41,14 @@ test.describe( 'Router regions', () => { attachTo: '#regions-with-attach-to', }, }; + const region7 = { + type: 'section', + data: { id: 'region7', attachTo: 'body' }, + }; + const region8 = { + type: 'section', + data: { id: 'region8', attachTo: '#regions-with-attach-to' }, + }; const pageAttachTo2 = await utils.addPostWithBlock( 'test/router-regions', @@ -48,7 +56,14 @@ test.describe( 'Router regions', () => { alias: 'router regions - page 2', attributes: { page: 'attachTo2', - regionsWithAttachTo: [ region3, region4, region5, region6 ], + regionsWithAttachTo: [ + region3, + region4, + region5, + region6, + region7, + region8, + ], counter: 10, }, } @@ -171,14 +186,37 @@ test.describe( 'Router regions', () => { ); } ); - test( 'should not take into account regions that are not in the topmost `data-wp-interactive`.', async ( { + test( 'should be updated when placed inside a `data-wp-interactive` element.', async ( { page, } ) => { - const invalidRegionText1 = page.getByTestId( 'invalid-region-text-1' ); - const invalidRegionText2 = page.getByTestId( 'invalid-region-text-2' ); - - await expect( invalidRegionText1 ).toHaveText( 'content from page 1' ); - await expect( invalidRegionText2 ).toHaveText( 'content from page 1' ); + const [ + validInsideInteractive, + validInsideRouterRegion, + invalidOutsideInteractive, + ] = [ + page.getByTestId( 'valid-inside-interactive' ), + page.getByTestId( 'valid-inside-router-region' ), + page.getByTestId( 'invalid-outside-interactive' ), + ]; + + const [ validRegionText1, validRegionText2, invalidRegionText3 ] = [ + validInsideInteractive.getByTestId( 'text-1' ), + validInsideRouterRegion.getByTestId( 'text-2' ), + invalidOutsideInteractive.getByTestId( 'text-3' ), + ]; + const [ counter1, counter2 ] = [ + page.getByTestId( 'valid-inside-interactive-counter' ), + page.getByTestId( 'valid-inside-router-region-counter' ), + ]; + + await expect( validRegionText1 ).toHaveText( 'content from page 1' ); + await expect( validRegionText2 ).toHaveText( 'content from page 1' ); + await expect( invalidRegionText3 ).toHaveText( 'content from page 1' ); + + await counter1.click(); + await counter2.click(); + await expect( counter1 ).toHaveText( '1' ); + await expect( counter2 ).toHaveText( '1' ); await page.getByTestId( 'next' ).click(); // Waits until the navigation finishes so it doesn't read the text from @@ -186,8 +224,14 @@ test.describe( 'Router regions', () => { await expect( page ).toHaveTitle( 'router regions – page 2 – gutenberg' ); - await expect( invalidRegionText1 ).toHaveText( 'content from page 1' ); - await expect( invalidRegionText2 ).toHaveText( 'content from page 1' ); + await expect( validRegionText1 ).toHaveText( 'content from page 2' ); + await expect( validRegionText2 ).toHaveText( 'content from page 2' ); + await expect( invalidRegionText3 ).toHaveText( 'content from page 1' ); + + await counter1.click(); + await counter2.click(); + await expect( counter1 ).toHaveText( '2' ); + await expect( counter2 ).toHaveText( '2' ); await page.getByTestId( 'back' ).click(); // Waits until the navigation finishes so it doesn't read the text from @@ -195,8 +239,14 @@ test.describe( 'Router regions', () => { await expect( page ).toHaveTitle( 'router regions – page 1 – gutenberg' ); - await expect( invalidRegionText1 ).toHaveText( 'content from page 1' ); - await expect( invalidRegionText2 ).toHaveText( 'content from page 1' ); + await expect( validRegionText1 ).toHaveText( 'content from page 1' ); + await expect( validRegionText2 ).toHaveText( 'content from page 1' ); + await expect( invalidRegionText3 ).toHaveText( 'content from page 1' ); + + await counter1.click(); + await counter2.click(); + await expect( counter1 ).toHaveText( '3' ); + await expect( counter2 ).toHaveText( '3' ); } ); test( 'should support router regions with the `attachTo` property.', async ( { @@ -335,4 +385,96 @@ test.describe( 'Router regions', () => { // Region 6 has an init directive that should have been executed again. await expect( initCount ).toHaveText( '2' ); } ); + + test( 'should support multiple regions with the `attachTo` property added at different times', async ( { + page, + interactivityUtils: utils, + } ) => { + await page.goto( + utils.getLink( 'router regions - page 1 - attachTo' ) + ); + + const region7 = page.locator( 'body' ).getByTestId( 'region7' ); + const region8 = page + .locator( '#regions-with-attach-to' ) + .getByTestId( 'region8' ); + + // The text of this element is used to check a navigation is completed. + const region1Ssr = page.getByTestId( 'region-1-ssr' ); + + await expect( region1Ssr ).toHaveText( 'content from page 1' ); + + // Regions with `attachTo` should initially be hidden. + await expect( region7 ).toBeHidden(); + await expect( region8 ).toBeHidden(); + + const regions: Record< string, Locator > = { + region7, + region8, + }; + + // Navigate to "Page attachTo 1". + await page.getByTestId( 'next' ).click(); + + await expect( region1Ssr ).toHaveText( 'content from page attachTo1' ); + + // Regions with `attachTo` should be hidden in "Page attachTo 1". + await expect( region7 ).toBeHidden(); + await expect( region8 ).toBeHidden(); + + // Navigate to "Page attachTo 2". + await page.getByTestId( 'next' ).click(); + await expect( region1Ssr ).toHaveText( 'content from page attachTo2' ); + + // Check that regions remains hydrated and interactive. + for ( const regionId in regions ) { + const region = regions[ regionId ]; + await expect( region ).toBeVisible(); + + const text = region.getByTestId( 'text' ); + const clientCounter = region.getByTestId( 'client-counter' ); + const serverCounter = region.getByTestId( 'server-counter' ); + + await expect( text ).toHaveText( regionId ); + await expect( clientCounter ).toHaveText( '10' ); + await expect( serverCounter ).toHaveText( '10' ); + + await clientCounter.click( { clickCount: 3, delay: 50 } ); + await expect( clientCounter ).toHaveText( '13' ); + } + + // Navigate back to "Page attachTo 1". + await page.goBack(); + + await expect( region1Ssr ).toHaveText( 'content from page attachTo1' ); + + // Regions with `attachTo` should be hidden in "Page attachTo 1". + await expect( region7 ).toBeHidden(); + await expect( region8 ).toBeHidden(); + + // Navigate back to the initial page. + await page.goBack(); + + await expect( region1Ssr ).toHaveText( 'content from page 1' ); + + // Regions should be unmounted. + await expect( region7 ).toBeHidden(); + await expect( region8 ).toBeHidden(); + + await page.goForward(); + + await expect( region1Ssr ).toHaveText( 'content from page attachTo1' ); + + // Regions should still be unmounted. + await expect( region7 ).toBeHidden(); + await expect( region8 ).toBeHidden(); + + await page.goForward(); + + await expect( region1Ssr ).toHaveText( 'content from page attachTo2' ); + + // Regions should still be unmounted. + await expect( region7 ).toBeVisible(); + await expect( region8 ).toBeVisible(); + } ); } );