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 a9b0402d1b094e..e64686e02d5581 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php
@@ -119,3 +119,16 @@
+
+
+
+
+
Toggle Text
+
Add New Text
+
Navigate
+
Async Navigate
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js
index 46483aaa2ea53d..1bab3946a3d4b5 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js
@@ -1,5 +1,19 @@
( ( { wp } ) => {
- const { store } = wp.interactivity;
+ const { store, navigate } = wp.interactivity;
+
+ const html = `
+
+
+
+
Toggle Text
+
Add new text
+
Navigate
+
Async Navigate
+
`;
store( {
derived: {
@@ -17,6 +31,25 @@
toggleContextText: ( { context } ) => {
context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1';
},
+ toggleText: ( { context } ) => {
+ context.text = "changed dynamically";
+ },
+ addNewText: ( { context } ) => {
+ context.newText = 'some new text';
+ },
+ navigate: () => {
+ navigate( window.location, {
+ force: true,
+ html,
+ } );
+ },
+ asyncNavigate: async ({ context }) => {
+ await navigate( window.location, {
+ force: true,
+ html,
+ } );
+ context.newText = 'changed from async action';
+ }
},
} );
} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-key/block.json
new file mode 100644
index 00000000000000..0cbdd065e63a1d
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/directive-key",
+ "title": "E2E Interactivity tests - directive key",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "directive-key-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php
new file mode 100644
index 00000000000000..07c6e4e3de161d
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php
@@ -0,0 +1,18 @@
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js
new file mode 100644
index 00000000000000..a155dec99e0aa9
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js
@@ -0,0 +1,23 @@
+( ( { wp } ) => {
+ const { store, navigate } = wp.interactivity;
+
+ const html = `
+ `;
+
+ store( {
+ actions: {
+ navigate: () => {
+ navigate( window.location, {
+ force: true,
+ html,
+ } );
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/block.json b/packages/e2e-tests/plugins/interactive-blocks/router-regions/block.json
new file mode 100644
index 00000000000000..44cc260d87d3f6
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/router-regions",
+ "title": "E2E Interactivity tests - router regions",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "router-regions-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php
new file mode 100644
index 00000000000000..db6e75709f9792
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php
@@ -0,0 +1,89 @@
+
+
+
+ Region 1
+
+
not hydrated
+
content from page
+
+
NaN
+
+
+
Next
+
+
Back
+
+
+
+
+
+
+
+
+ Region 2
+
+
not hydrated
+
content from page
+
+
NaN
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js
new file mode 100644
index 00000000000000..296c77d3ee7b38
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js
@@ -0,0 +1,43 @@
+( ( { wp } ) => {
+ /**
+ * WordPress dependencies
+ */
+ const { store, navigate } = wp.interactivity;
+
+ store( {
+ state: {
+ region1: {
+ text: 'hydrated'
+ },
+ region2: {
+ text: 'hydrated'
+ },
+ counter: {
+ value: 0,
+ },
+ },
+ actions: {
+ router: {
+ navigate: async ( { event: e } ) => {
+ e.preventDefault();
+ await navigate( e.target.href );
+ },
+ back: () => history.back(),
+ },
+ counter: {
+ increment: ( { state, context } ) => {
+ if ( context.counter ) {
+ context.counter.value += 1;
+ } else {
+ state.counter.value += 1;
+ }
+ },
+ init: ( { context } ) => {
+ if ( context.counter ) {
+ context.counter.value = context.counter.initialValue;
+ }
+ }
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index 6bff5ea1f4dea4..63da342d030a56 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -2,6 +2,12 @@
## Unreleased
+### Enhancements
+
+- Support keys using `data-wp-key`. ([#53844](https://github.com/WordPress/gutenberg/pull/53844))
+- Merge new server-side rendered context on client-side navigation. ([#53853](https://github.com/WordPress/gutenberg/pull/53853))
+- Support region-based client-side navigation. ([#53733](https://github.com/WordPress/gutenberg/pull/53733))
+
## 2.1.0 (2023-08-16)
### New Features
diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/2-api-reference.md
index 64a4adf19b3dc0..828de4379c0269 100644
--- a/packages/interactivity/docs/2-api-reference.md
+++ b/packages/interactivity/docs/2-api-reference.md
@@ -22,6 +22,7 @@ DOM elements are connected to data stored in the state & context through directi
- [`wp-on`](#wp-on) ![](https://img.shields.io/badge/EVENT_HANDLERS-afd2e3.svg)
- [`wp-effect`](#wp-effect) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
- [`wp-init`](#wp-init) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
+ - [`wp-key`](#wp-key) ![](https://img.shields.io/badge/TEMPLATING-afd2e3.svg)
- [Values of directives are references to store properties](#values-of-directives-are-references-to-store-properties)
- [The store](#the-store)
- [Elements of the store](#elements-of-the-store)
@@ -449,6 +450,31 @@ store( {
The `wp-init` can return a function. If it does, the returned function will run when the element is removed from the DOM.
+#### `wp-key`
+
+
+The `wp-key` directive assigns a unique key to an element to help the Interactivity API identify it when iterating through arrays of elements. This becomes important if your array elements can move (e.g. due to sorting), get inserted, or get deleted. A well-chosen key value helps the Interactivity API infer what exactly has changed in the array, allowing it to make the correct updates to the DOM.
+
+The key should be a string that uniquely identifies the element among its siblings. Typically it is used on repeated elements like list items. For example:
+
+```html
+
+```
+
+But it can also be used on other elements:
+
+```html
+
+```
+
+When the list is re-rendered, the Interactivity API will match elements by their keys to determine if an item was added/removed/reordered. Elements without keys might be recreated unnecessarily.
+
### Values of directives are references to store properties
The value assigned to a directive is a string pointing to a specific state, selector, action, or effect. *Using a Namespace is highly recommended* to define these elements of the store.
diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js
index 16789bd9da0522..1b7a82be38cfaa 100644
--- a/packages/interactivity/src/directives.js
+++ b/packages/interactivity/src/directives.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { useContext, useMemo, useEffect } from 'preact/hooks';
+import { useContext, useMemo, useEffect, useRef } from 'preact/hooks';
import { deepSignal, peek } from 'deepsignal';
/**
@@ -14,18 +14,16 @@ import { directive } from './hooks';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
-const mergeDeepSignals = ( target, source ) => {
+const mergeDeepSignals = ( target, source, overwrite ) => {
for ( const k in source ) {
- if ( typeof peek( target, k ) === 'undefined' ) {
- target[ `$${ k }` ] = source[ `$${ k }` ];
- } else if (
- isObject( peek( target, k ) ) &&
- isObject( peek( source, k ) )
- ) {
+ if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) {
mergeDeepSignals(
target[ `$${ k }` ].peek(),
- source[ `$${ k }` ].peek()
+ source[ `$${ k }` ].peek(),
+ overwrite
);
+ } else if ( overwrite || typeof peek( target, k ) === 'undefined' ) {
+ target[ `$${ k }` ] = source[ `$${ k }` ];
}
}
};
@@ -36,20 +34,24 @@ export default () => {
'context',
( {
directives: {
- context: { default: context },
+ context: { default: newContext },
},
props: { children },
- context: inherited,
+ context: inheritedContext,
} ) => {
- const { Provider } = inherited;
- const inheritedValue = useContext( inherited );
- const value = useMemo( () => {
- const localValue = deepSignal( context );
- mergeDeepSignals( localValue, inheritedValue );
- return localValue;
- }, [ context, inheritedValue ] );
+ const { Provider } = inheritedContext;
+ const inheritedValue = useContext( inheritedContext );
+ const currentValue = useRef( deepSignal( {} ) );
+ currentValue.current = useMemo( () => {
+ const newValue = deepSignal( newContext );
+ mergeDeepSignals( newValue, inheritedValue );
+ mergeDeepSignals( currentValue.current, newValue, true );
+ return currentValue.current;
+ }, [ newContext, inheritedValue ] );
- return { children } ;
+ return (
+ { children }
+ );
},
{ priority: 5 }
);
diff --git a/packages/interactivity/src/hooks.js b/packages/interactivity/src/hooks.js
index 448060caf2b2e0..d5b019300fed1a 100644
--- a/packages/interactivity/src/hooks.js
+++ b/packages/interactivity/src/hooks.js
@@ -205,6 +205,7 @@ options.vnode = ( vnode ) => {
if ( vnode.props.__directives ) {
const props = vnode.props;
const directives = props.__directives;
+ if ( directives.key ) vnode.key = directives.key.default;
delete props.__directives;
const priorityLevels = getPriorityLevels( directives );
if ( priorityLevels.length > 0 ) {
diff --git a/packages/interactivity/src/hydration.js b/packages/interactivity/src/hydration.js
deleted file mode 100644
index e5a8e5128a1d14..00000000000000
--- a/packages/interactivity/src/hydration.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * External dependencies
- */
-import { hydrate } from 'preact';
-/**
- * Internal dependencies
- */
-import { toVdom, hydratedIslands } from './vdom';
-import { createRootFragment } from './utils';
-import { directivePrefix } from './constants';
-
-export const init = async () => {
- document
- .querySelectorAll( `[data-${ directivePrefix }-interactive]` )
- .forEach( ( node ) => {
- if ( ! hydratedIslands.has( node ) ) {
- const fragment = createRootFragment( node.parentNode, node );
- const vdom = toVdom( node );
- hydrate( vdom, fragment );
- }
- } );
-};
diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js
index a3b942dc482be6..88e81e6f5877c0 100644
--- a/packages/interactivity/src/index.js
+++ b/packages/interactivity/src/index.js
@@ -2,20 +2,17 @@
* Internal dependencies
*/
import registerDirectives from './directives';
-import { init } from './hydration';
+import { init } from './router';
import { rawStore, afterLoads } from './store';
export { store } from './store';
export { directive } from './hooks';
+export { navigate, prefetch } from './router';
export { h as createElement } from 'preact';
export { useEffect, useContext, useMemo } from 'preact/hooks';
export { deepSignal } from 'deepsignal';
-/**
- * Initialize the Interactivity API.
- */
-registerDirectives();
-
document.addEventListener( 'DOMContentLoaded', async () => {
+ registerDirectives();
await init();
afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) );
} );
diff --git a/packages/interactivity/src/router.js b/packages/interactivity/src/router.js
new file mode 100644
index 00000000000000..cc7925e2fc3981
--- /dev/null
+++ b/packages/interactivity/src/router.js
@@ -0,0 +1,127 @@
+/**
+ * External dependencies
+ */
+import { hydrate, render } from 'preact';
+/**
+ * Internal dependencies
+ */
+import { toVdom, hydratedIslands } from './vdom';
+import { createRootFragment } from './utils';
+import { directivePrefix } from './constants';
+
+// The cache of visited and prefetched pages.
+const pages = new Map();
+
+// Keep the same root fragment for each interactive region node.
+const regionRootFragments = new WeakMap();
+const getRegionRootFragment = ( region ) => {
+ if ( ! regionRootFragments.has( region ) ) {
+ regionRootFragments.set(
+ region,
+ createRootFragment( region.parentElement, region )
+ );
+ }
+ return regionRootFragments.get( region );
+};
+
+// Helper to remove domain and hash from the URL. We are only interesting in
+// caching the path and the query.
+const cleanUrl = ( url ) => {
+ const u = new URL( url, window.location );
+ return u.pathname + u.search;
+};
+
+// Fetch a new page and convert it to a static virtual DOM.
+const fetchPage = async ( url, { html } ) => {
+ try {
+ if ( ! html ) {
+ const res = await window.fetch( url );
+ if ( res.status !== 200 ) return false;
+ html = await res.text();
+ }
+ const dom = new window.DOMParser().parseFromString( html, 'text/html' );
+ return regionsToVdom( dom );
+ } catch ( e ) {
+ return false;
+ }
+};
+
+// Return an object with VDOM trees of those HTML regions marked with a
+// `navigation-id` directive.
+const regionsToVdom = ( dom ) => {
+ const regions = {};
+ const attrName = `data-${ directivePrefix }-navigation-id`;
+ dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
+ const id = region.getAttribute( attrName );
+ regions[ id ] = toVdom( region );
+ } );
+
+ return { regions };
+};
+
+// Prefetch a page. We store the promise to avoid triggering a second fetch for
+// a page if a fetching has already started.
+export const prefetch = ( url, options = {} ) => {
+ url = cleanUrl( url );
+ if ( options.force || ! pages.has( url ) ) {
+ pages.set( url, fetchPage( url, options ) );
+ }
+};
+
+// Render all interactive regions contained in the given page.
+const renderRegions = ( page ) => {
+ const attrName = `data-${ directivePrefix }-navigation-id`;
+ document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
+ const id = region.getAttribute( attrName );
+ const fragment = getRegionRootFragment( region );
+ render( page.regions[ id ], fragment );
+ } );
+};
+
+// Navigate to a new page.
+export const navigate = async ( href, options = {} ) => {
+ const url = cleanUrl( href );
+ prefetch( url, options );
+ const page = await pages.get( url );
+ if ( page ) {
+ renderRegions( page );
+ window.history[ options.replace ? 'replaceState' : 'pushState' ](
+ {},
+ '',
+ href
+ );
+ } else {
+ window.location.assign( href );
+ }
+};
+
+// Listen to the back and forward buttons and restore the page if it's in the
+// cache.
+window.addEventListener( 'popstate', async () => {
+ const url = cleanUrl( window.location ); // Remove hash.
+ const page = pages.has( url ) && ( await pages.get( url ) );
+ if ( page ) {
+ renderRegions( page );
+ } else {
+ window.location.reload();
+ }
+} );
+
+// Initialize the router with the initial DOM.
+export const init = async () => {
+ document
+ .querySelectorAll( `[data-${ directivePrefix }-interactive]` )
+ .forEach( ( node ) => {
+ if ( ! hydratedIslands.has( node ) ) {
+ const fragment = getRegionRootFragment( node );
+ const vdom = toVdom( node );
+ hydrate( vdom, fragment );
+ }
+ } );
+
+ // Cache the current regions.
+ pages.set(
+ cleanUrl( window.location ),
+ Promise.resolve( regionsToVdom( document ) )
+ );
+};
diff --git a/test/e2e/specs/interactivity/directive-key.spec.ts b/test/e2e/specs/interactivity/directive-key.spec.ts
new file mode 100644
index 00000000000000..b780100b92a6dc
--- /dev/null
+++ b/test/e2e/specs/interactivity/directive-key.spec.ts
@@ -0,0 +1,34 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'data-wp-key', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-key' );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-key' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should keep the elements when adding items to the start of the array', async ( {
+ page,
+ } ) => {
+ // Add a number to the node so we can check later that it is still there.
+ await page
+ .getByTestId( 'first-item' )
+ .evaluate( ( n ) => ( ( n as any )._id = 123 ) );
+ await page.getByTestId( 'navigate' ).click();
+ const id = await page
+ .getByTestId( 'second-item' )
+ .evaluate( ( n ) => ( n as any )._id );
+ expect( id ).toBe( 123 );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/directives-context.spec.ts b/test/e2e/specs/interactivity/directives-context.spec.ts
index 5c74e8054bf19d..f94784865cb757 100644
--- a/test/e2e/specs/interactivity/directives-context.spec.ts
+++ b/test/e2e/specs/interactivity/directives-context.spec.ts
@@ -162,4 +162,31 @@ test.describe( 'data-wp-context', () => {
await expect( element ).toHaveText( 'Text 1' );
await expect( element ).toHaveAttribute( 'value', 'Text 1' );
} );
+
+ test( 'should replace values on navigation', async ( { page } ) => {
+ const element = page.getByTestId( 'navigation text' );
+ await expect( element ).toHaveText( 'first page' );
+ await page.getByTestId( 'toggle text' ).click();
+ await expect( element ).toHaveText( 'changed dynamically' );
+ await page.getByTestId( 'navigate' ).click();
+ await expect( element ).toHaveText( 'second page' );
+ } );
+
+ test( 'should preserve the previous context values', async ( { page } ) => {
+ const element = page.getByTestId( 'navigation new text' );
+ await expect( element ).toHaveText( '' );
+ await page.getByTestId( 'add new text' ).click();
+ await expect( element ).toHaveText( 'some new text' );
+ await page.getByTestId( 'navigate' ).click();
+ await expect( element ).toHaveText( 'some new text' );
+ } );
+
+ test( 'should maintain the same context reference on async actions', async ( {
+ page,
+ } ) => {
+ const element = page.getByTestId( 'navigation new text' );
+ await expect( element ).toHaveText( '' );
+ await page.getByTestId( 'async navigate' ).click();
+ await expect( element ).toHaveText( 'changed from async action' );
+ } );
} );
diff --git a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts
index 9d83c93650d403..fc0dc4b30d664e 100644
--- a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts
+++ b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts
@@ -3,6 +3,11 @@
*/
import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright';
+type AddPostWithBlockOptions = {
+ alias?: string;
+ attributes?: Record< string, any >;
+};
+
export default class InteractivityUtils {
links: Map< string, string >;
requestUtils: RequestUtils;
@@ -28,15 +33,25 @@ export default class InteractivityUtils {
return url.href;
}
- async addPostWithBlock( blockName: string ) {
+ async addPostWithBlock(
+ name: string,
+ { attributes, alias }: AddPostWithBlockOptions = {}
+ ) {
+ const block = attributes
+ ? `${ name } ${ JSON.stringify( attributes ) }`
+ : name;
+
+ if ( ! alias ) alias = block;
+
const payload = {
- content: ``,
+ content: ``,
status: 'publish' as 'publish',
date_gmt: '2023-01-01T00:00:00',
};
const { link } = await this.requestUtils.createPost( payload );
- this.links.set( blockName, link );
+ this.links.set( alias, link );
+ return this.getLink( alias );
}
async deleteAllPosts() {
diff --git a/test/e2e/specs/interactivity/router-regions.spec.ts b/test/e2e/specs/interactivity/router-regions.spec.ts
new file mode 100644
index 00000000000000..cbe66b7bd1b217
--- /dev/null
+++ b/test/e2e/specs/interactivity/router-regions.spec.ts
@@ -0,0 +1,100 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'Router regions', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ const next = await utils.addPostWithBlock( 'test/router-regions', {
+ alias: 'router regions - page 2',
+ attributes: { page: 2 },
+ } );
+ await utils.addPostWithBlock( 'test/router-regions', {
+ alias: 'router regions - page 1',
+ attributes: { page: 1, next },
+ } );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'router regions - page 1' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should be the only part hydrated', async ( { page } ) => {
+ const region1Text = page.getByTestId( 'region-1-text' );
+ const region2Text = page.getByTestId( 'region-2-text' );
+ const noRegionText1 = page.getByTestId( 'no-region-text-1' );
+ const noRegionText2 = page.getByTestId( 'no-region-text-2' );
+
+ await expect( region1Text ).toHaveText( 'hydrated' );
+ await expect( region2Text ).toHaveText( 'hydrated' );
+ await expect( noRegionText1 ).toHaveText( 'not hydrated' );
+ await expect( noRegionText2 ).toHaveText( 'not hydrated' );
+ } );
+
+ test( 'should update after navigation', async ( { page } ) => {
+ const region1Ssr = page.getByTestId( 'region-1-ssr' );
+ const region2Ssr = page.getByTestId( 'region-2-ssr' );
+
+ await expect( region1Ssr ).toHaveText( 'content from page 1' );
+ await expect( region2Ssr ).toHaveText( 'content from page 1' );
+
+ await page.getByTestId( 'next' ).click();
+
+ await expect( region1Ssr ).toHaveText( 'content from page 2' );
+ await expect( region2Ssr ).toHaveText( 'content from page 2' );
+
+ await page.getByTestId( 'back' ).click();
+
+ await expect( region1Ssr ).toHaveText( 'content from page 1' );
+ await expect( region2Ssr ).toHaveText( 'content from page 1' );
+ } );
+
+ test( 'should preserve state across pages', async ( { page } ) => {
+ const counter = page.getByTestId( 'state-counter' );
+ await expect( counter ).toHaveText( '0' );
+
+ await counter.click( { clickCount: 3, delay: 50 } );
+ await expect( counter ).toHaveText( '3' );
+
+ await page.getByTestId( 'next' ).click();
+ await counter.click( { clickCount: 3, delay: 50 } );
+ await expect( counter ).toHaveText( '6' );
+
+ await page.getByTestId( 'back' ).click();
+ await counter.click( { clickCount: 3, delay: 50 } );
+ await expect( counter ).toHaveText( '9' );
+ } );
+
+ test( 'should preserve context across pages', async ( { page } ) => {
+ const counter = page.getByTestId( 'context-counter' );
+ await expect( counter ).toHaveText( '0' );
+
+ await counter.click( { clickCount: 3, delay: 50 } );
+ await expect( counter ).toHaveText( '3' );
+
+ await page.getByTestId( 'next' ).click();
+ await counter.click( { clickCount: 3, delay: 50 } );
+ await expect( counter ).toHaveText( '6' );
+
+ await page.getByTestId( 'back' ).click();
+ await counter.click( { clickCount: 3, delay: 50 } );
+ await expect( counter ).toHaveText( '9' );
+ } );
+
+ test( 'can be nested', async ( { page } ) => {
+ const nestedRegionSsr = page.getByTestId( 'nested-region-ssr' );
+ await expect( nestedRegionSsr ).toHaveText( 'content from page 1' );
+
+ await page.getByTestId( 'next' ).click();
+ await expect( nestedRegionSsr ).toHaveText( 'content from page 2' );
+
+ await page.getByTestId( 'back' ).click();
+ await expect( nestedRegionSsr ).toHaveText( 'content from page 1' );
+ } );
+} );