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 @@
+
+
-
-
- 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();
+ } );
} );