From 3cf83bf43ae53f4590e60762f04806070fcbdc51 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 16 Aug 2023 14:52:29 +0200 Subject: [PATCH 1/8] Add router with region-based client-side navigation --- packages/interactivity/src/hydration.js | 22 ---- packages/interactivity/src/index.js | 9 +- packages/interactivity/src/router.js | 127 ++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 28 deletions(-) delete mode 100644 packages/interactivity/src/hydration.js create mode 100644 packages/interactivity/src/router.js 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 ) ) + ); +}; From 3cc2e452d4f6f0325de6a0d3d341493a18278183 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 16 Aug 2023 15:05:45 +0200 Subject: [PATCH 2/8] Add changelog --- packages/interactivity/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 83b3e1196e544c..83f58772c11dc3 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -4,6 +4,7 @@ ### New Features +- Support region-based client-side navigation. ([#53733](https://github.com/WordPress/gutenberg/pull/53733)) - Allow passing optional `afterLoad` callbacks to `store` calls. ([#53363](https://github.com/WordPress/gutenberg/pull/53363)) ### Bug Fix From a325563a58f5d5a4258fc190c79fd0b8a2172acf Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 22 Aug 2023 11:57:59 +0200 Subject: [PATCH 3/8] Interactivity API: merge new server-side rendered context on client-side navigation (#53853) * Add failing test * Fix the test * Add changelog * Fix lint error --- .../directive-context/render.php | 12 +++++++++ .../directive-context/view.js | 27 ++++++++++++++++++- packages/interactivity/CHANGELOG.md | 4 +++ packages/interactivity/src/directives.js | 26 ++++++++++-------- .../interactivity/directives-context.spec.ts | 18 +++++++++++++ 5 files changed, 75 insertions(+), 12 deletions(-) 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..0133b251cfc06e 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,15 @@ + +
+
+
+ + + +
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..f159d5a73caea5 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,18 @@ ( ( { wp } ) => { - const { store } = wp.interactivity; + const { store, navigate } = wp.interactivity; + + const html = ` +
+
+
+ + + +
`; store( { derived: { @@ -17,6 +30,18 @@ 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, + } ); + } }, } ); } )( window ); diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index b521bb704085d8..33fbe2017ef0ca 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- Merge new server-side rendered context on client-side navigation. ([#53853](https://github.com/WordPress/gutenberg/pull/53853)) + ## 2.1.0 (2023-08-16) ### New Features diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 16789bd9da0522..61fd5ab26f9590 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'; /** @@ -36,20 +36,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, currentValue.current ); + mergeDeepSignals( newValue, inheritedValue ); + return newValue; + }, [ newContext, inheritedValue ] ); - return { children }; + return ( + { children } + ); }, { priority: 5 } ); diff --git a/test/e2e/specs/interactivity/directives-context.spec.ts b/test/e2e/specs/interactivity/directives-context.spec.ts index 5c74e8054bf19d..01e28049a57ffb 100644 --- a/test/e2e/specs/interactivity/directives-context.spec.ts +++ b/test/e2e/specs/interactivity/directives-context.spec.ts @@ -162,4 +162,22 @@ 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' ); + } ); } ); From a26cba09667cff9dc290c1accada6b831c525e59 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 22 Aug 2023 12:00:43 +0200 Subject: [PATCH 4/8] Fix changelog placement --- packages/interactivity/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 33fbe2017ef0ca..5c0b1a4392c97d 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -5,12 +5,12 @@ ### Enhancements - 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 -- Support region-based client-side navigation. ([#53733](https://github.com/WordPress/gutenberg/pull/53733)) - Allow passing optional `afterLoad` callbacks to `store` calls. ([#53363](https://github.com/WordPress/gutenberg/pull/53363)) ### Bug Fix From d7c1850399017f4223fcdb528ac04ff42ad72061 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 22 Aug 2023 12:04:12 +0200 Subject: [PATCH 5/8] 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 --- .../directive-key/block.json | 14 ++++++++ .../directive-key/render.php | 18 ++++++++++ .../interactive-blocks/directive-key/view.js | 23 +++++++++++++ packages/interactivity/CHANGELOG.md | 1 + .../interactivity/docs/2-api-reference.md | 26 ++++++++++++++ packages/interactivity/src/hooks.js | 1 + .../specs/interactivity/directive-key.spec.ts | 34 +++++++++++++++++++ 7 files changed, 117 insertions(+) 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 test/e2e/specs/interactivity/directive-key.spec.ts 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 @@ + + +
+
    +
  • 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 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 = ` +
+
    +
  • 1
  • +
  • 2
  • +
  • 3
  • +
+
`; + + store( { + actions: { + navigate: () => { + navigate( window.location, { + force: true, + html, + } ); + }, + }, + } ); +} )( window ); diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 5c0b1a4392c97d..63da342d030a56 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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)) diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/2-api-reference.md index 920b27f4727d56..36811ae61bc4d7 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 +
    +
  • Item 1
  • +
  • Item 2
  • +
+``` + +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/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/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 ); + } ); +} ); From c57465c77913aa19459d900e19d56c961cfdd975 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 22 Aug 2023 16:17:25 +0200 Subject: [PATCH 6/8] Interactivity API: Fix non stable context reference on client side navigation (#53876) * Add failing test * Fix the test --- .../directive-context/render.php | 3 ++- .../directive-context/view.js | 8 ++++++++ packages/interactivity/src/directives.js | 18 ++++++++---------- .../interactivity/directives-context.spec.ts | 9 +++++++++ 4 files changed, 27 insertions(+), 11 deletions(-) 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 0133b251cfc06e..e64686e02d5581 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -128,6 +128,7 @@
- + + 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 f159d5a73caea5..1bab3946a3d4b5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -12,6 +12,7 @@ + `; store( { @@ -41,6 +42,13 @@ force: true, html, } ); + }, + asyncNavigate: async ({ context }) => { + await navigate( window.location, { + force: true, + html, + } ); + context.newText = 'changed from async action'; } }, } ); diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 61fd5ab26f9590..1b7a82be38cfaa 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -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 }` ]; } } }; @@ -46,9 +44,9 @@ export default () => { const currentValue = useRef( deepSignal( {} ) ); currentValue.current = useMemo( () => { const newValue = deepSignal( newContext ); - mergeDeepSignals( newValue, currentValue.current ); mergeDeepSignals( newValue, inheritedValue ); - return newValue; + mergeDeepSignals( currentValue.current, newValue, true ); + return currentValue.current; }, [ newContext, inheritedValue ] ); return ( diff --git a/test/e2e/specs/interactivity/directives-context.spec.ts b/test/e2e/specs/interactivity/directives-context.spec.ts index 01e28049a57ffb..f94784865cb757 100644 --- a/test/e2e/specs/interactivity/directives-context.spec.ts +++ b/test/e2e/specs/interactivity/directives-context.spec.ts @@ -180,4 +180,13 @@ test.describe( 'data-wp-context', () => { 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' ); + } ); } ); From 5bb12f50b877c76ca0e190672b7f060a041fa3f4 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 23 Aug 2023 13:55:28 +0200 Subject: [PATCH 7/8] Refactor addPostWithBlock util --- .../fixtures/interactivity-utils.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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() { From 913b372a007c83c80578909b36a8187b2755ffd8 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 23 Aug 2023 13:55:48 +0200 Subject: [PATCH 8/8] Add tests for router regions --- .../router-regions/block.json | 14 +++ .../router-regions/render.php | 89 ++++++++++++++++ .../interactive-blocks/router-regions/view.js | 43 ++++++++ .../interactivity/router-regions.spec.ts | 100 ++++++++++++++++++ 4 files changed, 246 insertions(+) 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 create mode 100644 test/e2e/specs/interactivity/router-regions.spec.ts 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

+ + + + + 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 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/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' ); + } ); +} );