From 98e1373f83ea032a4969e36ebbbf3ccea6458c5d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 11 Aug 2023 10:16:49 +0200 Subject: [PATCH] Allow passing optional `afterLoad` callbacks to `store` calls (#53363) * Add `afterLoad` callbacks * Add tests for `afterLoad` callbacks * Add changelog * Update changelog link * Add docs for `afterLoad` * Move store options to the end --- .../store-afterload/block.json | 14 +++++ .../store-afterload/render.php | 41 +++++++++++++ .../store-afterload/view.js | 60 +++++++++++++++++++ packages/interactivity/CHANGELOG.md | 4 ++ .../interactivity/docs/2-api-reference.md | 26 ++++++++ packages/interactivity/src/index.js | 2 + packages/interactivity/src/store.js | 24 ++++++-- .../interactivity/store-afterload.spec.ts | 40 +++++++++++++ 8 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js create mode 100644 test/e2e/specs/interactivity/store-afterload.spec.ts diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json new file mode 100644 index 0000000000000..0c00bbbb514be --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/store-afterload", + "title": "E2E Interactivity tests - store afterload", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "store-afterload-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php new file mode 100644 index 0000000000000..950ba923428bf --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php @@ -0,0 +1,41 @@ + +
+

Store statuses

+

waiting

+

waiting

+

waiting

+

waiting

+ +

afterLoad executions

+

All stores ready: + + >waiting +

+

vDOM ready: + + >waiting +

+

afterLoad exec times: + + >0 +

+

sharedAfterLoad exec times: + + >0 +

+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js new file mode 100644 index 0000000000000..361a56dc62283 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js @@ -0,0 +1,60 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + const afterLoad = ({ state }) => { + // Check the state is correctly initialized. + const { status1, status2, status3, status4 } = state; + state.allStoresReady = + [ status1, status2, status3, status4 ] + .every( ( t ) => t === 'ready' ) + .toString(); + + // Check the HTML has been processed as well. + const selector = '[data-store-status]'; + state.vdomReady = + document.querySelector( selector ) && + Array.from( + document.querySelectorAll( selector ) + ).every( ( el ) => el.textContent === 'ready' ).toString(); + + // Increment exec times everytime this function runs. + state.execTimes.afterLoad += 1; + } + + const sharedAfterLoad = ({ state }) => { + // Increment exec times everytime this function runs. + state.execTimes.sharedAfterLoad += 1; + } + + // Case 1: without afterload callback + store( { + state: { status1: 'ready' }, + } ); + + // Case 2: non-shared afterload callback + store( { + state: { + status2: 'ready', + allStoresReady: false, + vdomReady: false, + execTimes: { afterLoad: 0 }, + }, + }, { afterLoad } ); + + // Case 3: shared afterload callback + store( { + state: { + status3: 'ready', + execTimes: { sharedAfterLoad: 0 }, + }, + }, { afterLoad: sharedAfterLoad } ); + store( { + state: { + status4: 'ready', + execTimes: { sharedAfterLoad: 0 }, + }, + }, { afterLoad: sharedAfterLoad } ); +} )( window ); diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 540beadc4db66..83b3e1196e544 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- Allow passing optional `afterLoad` callbacks to `store` calls. ([#53363](https://github.com/WordPress/gutenberg/pull/53363)) + ### Bug Fix - Add support for underscores and leading dashes in the suffix part of the directive. ([#53337](https://github.com/WordPress/gutenberg/pull/53337)) diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/2-api-reference.md index 249e92f76c093..920b27f4727d5 100644 --- a/packages/interactivity/docs/2-api-reference.md +++ b/packages/interactivity/docs/2-api-reference.md @@ -657,5 +657,31 @@ wp_store( ); ``` +### Store options + +The `store` function accepts an object as a second argument with the following optional properties: + +#### `afterLoad` + +Callback to be executed after the Interactivity API has been set up and the store is ready. It receives the global store as argument. + +```js +// view.js +store( + { + state: { + cart: [], + }, + }, + { + afterLoad: async ( { state } ) => { + // Let's consider `clientId` is added + // during server-side rendering. + state.cart = await getCartData( state.clientId ); + }, + } +); +``` + diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js index 9d1b6b695b66b..a3b942dc482be 100644 --- a/packages/interactivity/src/index.js +++ b/packages/interactivity/src/index.js @@ -3,6 +3,7 @@ */ import registerDirectives from './directives'; import { init } from './hydration'; +import { rawStore, afterLoads } from './store'; export { store } from './store'; export { directive } from './hooks'; export { h as createElement } from 'preact'; @@ -16,4 +17,5 @@ registerDirectives(); document.addEventListener( 'DOMContentLoaded', async () => { await init(); + afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) ); } ); diff --git a/packages/interactivity/src/store.js b/packages/interactivity/src/store.js index f13bba040c8c9..e0c5f8b3fae77 100644 --- a/packages/interactivity/src/store.js +++ b/packages/interactivity/src/store.js @@ -35,9 +35,25 @@ const getSerializedState = () => { return {}; }; +export const afterLoads = new Set(); + const rawState = getSerializedState(); export const rawStore = { state: deepSignal( rawState ) }; +/** + * @typedef StoreProps Properties object passed to `store`. + * @property {Object} state State to be added to the global store. All the + * properties included here become reactive. + */ + +/** + * @typedef StoreOptions Options object. + * @property {(store:any) => void} [afterLoad] Callback to be executed after the + * Interactivity API has been set up + * and the store is ready. It + * receives the store as argument. + */ + /** * Extends the Interactivity API global store with the passed properties. * @@ -76,11 +92,11 @@ export const rawStore = { state: deepSignal( rawState ) }; * * ``` * - * @param {Object} properties Properties to be added to the global store. - * @param {Object} [properties.state] State to be added to the global store. All - * the properties included here become reactive. + * @param {StoreProps} properties Properties to be added to the global store. + * @param {StoreOptions} [options] Options passed to the `store` call. */ -export const store = ( { state, ...block } ) => { +export const store = ( { state, ...block }, { afterLoad } = {} ) => { deepMerge( rawStore, block ); deepMerge( rawState, state ); + if ( afterLoad ) afterLoads.add( afterLoad ); }; diff --git a/test/e2e/specs/interactivity/store-afterload.spec.ts b/test/e2e/specs/interactivity/store-afterload.spec.ts new file mode 100644 index 0000000000000..388e80177b033 --- /dev/null +++ b/test/e2e/specs/interactivity/store-afterload.spec.ts @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'store afterLoad callbacks', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/store-afterload' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/store-afterload' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'run after the vdom and store are ready', async ( { page } ) => { + const allStoresReady = page.getByTestId( 'all-stores-ready' ); + const vdomReady = page.getByTestId( 'vdom-ready' ); + + await expect( allStoresReady ).toHaveText( 'true' ); + await expect( vdomReady ).toHaveText( 'true' ); + } ); + + test( 'run once even if shared between several store calls', async ( { + page, + } ) => { + const afterLoadTimes = page.getByTestId( 'after-load-exec-times' ); + const sharedAfterLoadTimes = page.getByTestId( + 'shared-after-load-exec-times' + ); + + await expect( afterLoadTimes ).toHaveText( '1' ); + await expect( sharedAfterLoadTimes ).toHaveText( '1' ); + } ); +} );