From 640150aedcaac50493b9614c4be28bcc60a01c5c Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Mon, 28 Aug 2023 10:36:11 +0200 Subject: [PATCH] Interactivity API: router with region-based client-side navigation (#53733) * Add router with region-based client-side navigation * Add changelog * Interactivity API: merge new server-side rendered context on client-side navigation (#53853) * Add failing test * Fix the test * Add changelog * Fix lint error * Fix changelog placement * Interactivity API: Support for the `data-wp-key` directive (#53844) * Add failing test * Fix test using key * Replace key with data-wp-key * Refactor test a bit * Add changelog * Add docs * Remove unnecessary paragraph * Fix lint error * Interactivity API: Fix non stable context reference on client side navigation (#53876) * Add failing test * Fix the test * Refactor addPostWithBlock util * Add tests for router regions --------- Co-authored-by: David Arenas --- .../directive-context/render.php | 13 ++ .../directive-context/view.js | 35 ++++- .../directive-key/block.json | 14 ++ .../directive-key/render.php | 18 +++ .../interactive-blocks/directive-key/view.js | 23 ++++ .../router-regions/block.json | 14 ++ .../router-regions/render.php | 89 ++++++++++++ .../interactive-blocks/router-regions/view.js | 43 ++++++ packages/interactivity/CHANGELOG.md | 6 + .../interactivity/docs/2-api-reference.md | 26 ++++ packages/interactivity/src/directives.js | 40 +++--- packages/interactivity/src/hooks.js | 1 + packages/interactivity/src/hydration.js | 22 --- packages/interactivity/src/index.js | 9 +- packages/interactivity/src/router.js | 127 ++++++++++++++++++ .../specs/interactivity/directive-key.spec.ts | 34 +++++ .../interactivity/directives-context.spec.ts | 27 ++++ .../fixtures/interactivity-utils.ts | 21 ++- .../interactivity/router-regions.spec.ts | 100 ++++++++++++++ 19 files changed, 611 insertions(+), 51 deletions(-) create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-key/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/router-regions/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js delete mode 100644 packages/interactivity/src/hydration.js create mode 100644 packages/interactivity/src/router.js create mode 100644 test/e2e/specs/interactivity/directive-key.spec.ts create mode 100644 test/e2e/specs/interactivity/router-regions.spec.ts 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 a9b0402d1b094..e64686e02d558 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 @@ + +
+
+
+ + + + +
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 46483aaa2ea53..1bab3946a3d4b 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 = ` +
+
+
+ + + + +
`; 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 0000000000000..0cbdd065e63a1 --- /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 0000000000000..07c6e4e3de161 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php @@ -0,0 +1,18 @@ + + +
+
    +
  • 2
  • +
  • 3
  • +
+ +
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 0000000000000..a155dec99e0aa --- /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 = ` +
+
    +
  • 1
  • +
  • 2
  • +
  • 3
  • +
+
`; + + 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 0000000000000..44cc260d87d3f --- /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 0000000000000..db6e75709f979 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php @@ -0,0 +1,89 @@ + + +
+

Region 1

+
+

not hydrated

+

content from page

+ + + + + Next + + Back + +
+
+ +
+

not hydrated

+
+ + +
+

Region 2

+
+

not hydrated

+

content from page

+ + + +
+
+

not hydrated

+
+ +
+

Nested region

+
+

content from page

+
+
+
+
+
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 0000000000000..296c77d3ee7b3 --- /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 6bff5ea1f4dea..63da342d030a5 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 64a4adf19b3dc..828de4379c026 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 +
+ Previous page + Next page +
+``` + +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 16789bd9da052..1b7a82be38cfa 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 448060caf2b2e..d5b019300fed1 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 e5a8e5128a1d1..0000000000000 --- 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 a3b942dc482be..88e81e6f5877c 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 0000000000000..cc7925e2fc398 --- /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 0000000000000..b780100b92a6d --- /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 5c74e8054bf19..f94784865cb75 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 9d83c93650d40..fc0dc4b30d664 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 0000000000000..cbe66b7bd1b21 --- /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' ); + } ); +} );