From ea3e8af7ba9ffd1259ffab5d0cab8f9f938d82fc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 13 Mar 2024 19:04:09 +0100 Subject: [PATCH 1/6] Add deferred store tests --- .../deferred-store/block.json | 15 ++++++++ .../deferred-store/render.php | 15 ++++++++ .../deferred-store/view.asset.php | 1 + .../interactive-blocks/deferred-store/view.js | 20 +++++++++++ .../interactivity/deferred-store.spec.ts | 36 +++++++++++++++++++ 5 files changed, 87 insertions(+) create mode 100644 packages/e2e-tests/plugins/interactive-blocks/deferred-store/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/deferred-store/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/deferred-store/view.asset.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/deferred-store/view.js create mode 100644 test/e2e/specs/interactivity/deferred-store.spec.ts diff --git a/packages/e2e-tests/plugins/interactive-blocks/deferred-store/block.json b/packages/e2e-tests/plugins/interactive-blocks/deferred-store/block.json new file mode 100644 index 0000000000000..088572086f000 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/deferred-store/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/deferred-store", + "title": "E2E Interactivity tests - deferred store", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScriptModule": "file:./view.js", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/deferred-store/render.php b/packages/e2e-tests/plugins/interactive-blocks/deferred-store/render.php new file mode 100644 index 0000000000000..0d8c2cbeb5125 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/deferred-store/render.php @@ -0,0 +1,15 @@ + + +
'!dlrow ,olleH' ) ); ?> +> + + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/deferred-store/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/deferred-store/view.asset.php new file mode 100644 index 0000000000000..db23afdf657a1 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/deferred-store/view.asset.php @@ -0,0 +1 @@ + array( '@wordpress/interactivity' ) ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/deferred-store/view.js b/packages/e2e-tests/plugins/interactive-blocks/deferred-store/view.js new file mode 100644 index 0000000000000..8474757a0bd8b --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/deferred-store/view.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { store, getContext } from '@wordpress/interactivity'; + +document.addEventListener( 'DOMContentLoaded', () => { + setTimeout( () => { + store( 'test/deferred-store', { + state: { + reversedText() { + return [ ...getContext().text ].reverse().join( '' ); + }, + + get reversedTextGetter() { + return [ ...getContext().text ].reverse().join( '' ); + }, + }, + } ); + }, 50 ); +} ); diff --git a/test/e2e/specs/interactivity/deferred-store.spec.ts b/test/e2e/specs/interactivity/deferred-store.spec.ts new file mode 100644 index 0000000000000..4521322e61dfc --- /dev/null +++ b/test/e2e/specs/interactivity/deferred-store.spec.ts @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'deferred store', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/deferred-store' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/deferred-store' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'Ensure that a store can be subscribed to before it is initialized', async ( { + page, + } ) => { + const resultInput = page.getByTestId( 'result' ); + await expect( resultInput ).toHaveText( '' ); + await expect( resultInput ).toHaveText( 'Hello, world!' ); + } ); + + // There is a known issue for deferred getters right now. + // eslint-disable-next-line playwright/no-skipped-test + test.skip( 'Ensure that a state getter can be subscribed to before it is initialized', async ( { + page, + } ) => { + const resultInput = page.getByTestId( 'result-getter' ); + await expect( resultInput ).toHaveText( '' ); + await expect( resultInput ).toHaveText( 'Hello, world!' ); + } ); +} ); From 165c9859be6f95c59eb1010a37b8205b0bfe9513 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 13 Mar 2024 19:48:17 +0100 Subject: [PATCH 2/6] 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 cb3f2fc97dac1..c8e3432e862c8 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -4,6 +4,7 @@ ### Bug Fixes +- Ensure that stores are available for subscription before hydration. ([#59842](https://github.com/WordPress/gutenberg/pull/59842)) - Ensure scope is restored when catching exceptions thrown in async generator actions. ([#59708](https://github.com/WordPress/gutenberg/pull/59708)) ## 5.2.0 (2024-03-06) From 12762625c8a413fe94cf8624b05ad4bb5da617ec Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 14 Mar 2024 09:11:28 +0100 Subject: [PATCH 3/6] Export universalUnlock from store --- packages/interactivity/src/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 055e6e3cc99ff..2cd8ddd9e3fb2 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -202,7 +202,7 @@ interface StoreOptions { lock?: boolean | string; } -const universalUnlock = +export const universalUnlock = 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; /** From 17953d95b07cdc140d5cbad98bb585fcd572cde1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 13 Mar 2024 18:52:11 +0100 Subject: [PATCH 4/6] Ensure stores exist for vdom namespaces --- packages/interactivity/src/init.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/interactivity/src/init.js b/packages/interactivity/src/init.js index fb510a9c00fce..7575f8827676d 100644 --- a/packages/interactivity/src/init.js +++ b/packages/interactivity/src/init.js @@ -8,6 +8,7 @@ import { hydrate } from 'preact'; import { toVdom, hydratedIslands } from './vdom'; import { createRootFragment } from './utils'; import { directivePrefix } from './constants'; +import { store, stores, universalUnlock } from './store'; // Keep the same root fragment for each interactive region node. const regionRootFragments = new WeakMap(); @@ -33,10 +34,29 @@ export const initialVdom = new WeakMap(); // Initialize the router with the initial DOM. export const init = async () => { + /** @type { NodeListOf} */ const nodes = document.querySelectorAll( `[data-${ directivePrefix }-interactive]` ); + for ( const node of nodes ) { + // Before initializing the vdom, make sure a store exists for the namespace. + // This ensures that directives can subscribe to the store even if it has + // not yet been created on the client so that directives can be updated when + // stores are later created. + let namespace = /** @type {HTMLElement} */ ( node ).dataset[ + `${ directivePrefix }Interactive` + ]; + try { + namespace = JSON.parse( namespace ).namespace; + } catch {} + if ( ! stores.has( namespace ) ) { + store( namespace, undefined, { + lock: universalUnlock, + } ); + } + } + for ( const node of nodes ) { if ( ! hydratedIslands.has( node ) ) { await yieldToMain(); From 813274c7a5471bfacbf75b83953572d0c1ecf00c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 14 Mar 2024 09:13:53 +0100 Subject: [PATCH 5/6] Revert "Ensure stores exist for vdom namespaces" This reverts commit 17953d95b07cdc140d5cbad98bb585fcd572cde1. --- packages/interactivity/src/init.js | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/packages/interactivity/src/init.js b/packages/interactivity/src/init.js index 7575f8827676d..fb510a9c00fce 100644 --- a/packages/interactivity/src/init.js +++ b/packages/interactivity/src/init.js @@ -8,7 +8,6 @@ import { hydrate } from 'preact'; import { toVdom, hydratedIslands } from './vdom'; import { createRootFragment } from './utils'; import { directivePrefix } from './constants'; -import { store, stores, universalUnlock } from './store'; // Keep the same root fragment for each interactive region node. const regionRootFragments = new WeakMap(); @@ -34,29 +33,10 @@ export const initialVdom = new WeakMap(); // Initialize the router with the initial DOM. export const init = async () => { - /** @type { NodeListOf} */ const nodes = document.querySelectorAll( `[data-${ directivePrefix }-interactive]` ); - for ( const node of nodes ) { - // Before initializing the vdom, make sure a store exists for the namespace. - // This ensures that directives can subscribe to the store even if it has - // not yet been created on the client so that directives can be updated when - // stores are later created. - let namespace = /** @type {HTMLElement} */ ( node ).dataset[ - `${ directivePrefix }Interactive` - ]; - try { - namespace = JSON.parse( namespace ).namespace; - } catch {} - if ( ! stores.has( namespace ) ) { - store( namespace, undefined, { - lock: universalUnlock, - } ); - } - } - for ( const node of nodes ) { if ( ! hydratedIslands.has( node ) ) { await yieldToMain(); From 9395e196125a6355164440397a3f5cc8cccbde12 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 14 Mar 2024 09:09:09 +0100 Subject: [PATCH 6/6] Ensure stores exist in "resolve" --- packages/interactivity/src/hooks.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 0b869dee4fda8..00c3c0d6d1729 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -15,7 +15,7 @@ import type { VNode, Context, RefObject } from 'preact'; /** * Internal dependencies */ -import { stores } from './store'; +import { store, stores, universalUnlock } from './store'; interface DirectiveEntry { value: string | Object; namespace: string; @@ -259,8 +259,14 @@ export const directive = ( // Resolve the path to some property of the store object. const resolve = ( path, namespace ) => { + let resolvedStore = stores.get( namespace ); + if ( typeof resolvedStore === 'undefined' ) { + resolvedStore = store( namespace, undefined, { + lock: universalUnlock, + } ); + } let current = { - ...stores.get( namespace ), + ...resolvedStore, context: getScope().context[ namespace ], }; path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) );