From 49c228b9872489e8a566b6fbfc289d2cc5bc5210 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 9 Sep 2024 09:41:58 +0200 Subject: [PATCH 01/12] Expose client and server context from provider --- .../directive-priorities/view.js | 4 +- packages/interactivity/src/directives.tsx | 43 +++++++++++++------ packages/interactivity/src/hooks.tsx | 6 ++- packages/interactivity/src/proxies/state.ts | 35 ++++++++++++--- packages/interactivity/src/scopes.ts | 1 + 5 files changed, 67 insertions(+), 22 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js index 5a46908f77d87..77f2f25c5f9a4 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js @@ -41,13 +41,13 @@ directive( 'test-context', ( { context: { Provider }, props: { children } } ) => { executionProof( 'context' ); - const value = { + const client = { [ namespace ]: proxifyState( namespace, { attribute: 'from context', text: 'from context', } ), }; - return h( Provider, { value }, children ); + return h( Provider, { value: { client } }, children ); }, { priority: 8 } ); diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index cde39d830499a..340880954683d 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -142,14 +142,19 @@ export default () => { const defaultEntry = context.find( ( { suffix } ) => suffix === 'default' ); - const inheritedValue = useContext( inheritedContext ); + const { client: inheritedClient, server: inheritedServer } = + useContext( inheritedContext ); const ns = defaultEntry!.namespace; - const currentValue = useRef( proxifyState( ns, {} ) ); + const client = useRef( proxifyState( ns, {} ) ); + const server = useRef( proxifyState( ns, {}, { readOnly: true } ) ); // No change should be made if `defaultEntry` does not exist. const contextStack = useMemo( () => { - const result = { ...inheritedValue }; + const result = { + client: { ...inheritedClient }, + server: { ...inheritedServer }, + }; if ( defaultEntry ) { const { namespace, value } = defaultEntry; // Check that the value is a JSON object. Send a console warning if not. @@ -159,17 +164,22 @@ export default () => { ); } deepMerge( - currentValue.current, + client.current, deepClone( value ) as object, false ); - result[ namespace ] = proxifyContext( - currentValue.current, - inheritedValue[ namespace ] + deepMerge( server.current, deepClone( value ) as object ); + result.client[ namespace ] = proxifyContext( + client.current, + inheritedClient[ namespace ] + ); + result.server[ namespace ] = proxifyContext( + server.current, + inheritedServer[ namespace ] ); } return result; - }, [ defaultEntry, inheritedValue ] ); + }, [ defaultEntry, inheritedClient, inheritedServer ] ); return createElement( Provider, { value: contextStack }, children ); }, @@ -563,17 +573,24 @@ export default () => { suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); const itemContext = proxifyContext( proxifyState( namespace, {} ), - inheritedValue[ namespace ] + inheritedValue.client[ namespace ] ); const mergedContext = { - ...inheritedValue, - [ namespace ]: itemContext, + client: { + ...inheritedValue.client, + [ namespace ]: itemContext, + }, + server: { ...inheritedValue.server }, }; // Set the item after proxifying the context. - mergedContext[ namespace ][ itemProp ] = item; + mergedContext.client[ namespace ][ itemProp ] = item; - const scope = { ...getScope(), context: mergedContext }; + const scope = { + ...getScope(), + context: mergedContext.client, + serverContext: mergedContext.server, + }; const key = eachKey ? getEvaluate( { scope } )( eachKey[ 0 ] ) : item; diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 215da8afef9b5..44dc2645da2c8 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -93,7 +93,7 @@ interface DirectivesProps { } // Main context. -const context = createContext< any >( {} ); +const context = createContext< any >( { client: {}, server: {} } ); // WordPress Directives. const directiveCallbacks: Record< string, DirectiveCallback > = {}; @@ -253,7 +253,9 @@ const Directives = ( { // element ref, state and props. const scope = useRef< Scope >( {} as Scope ).current; scope.evaluate = useCallback( getEvaluate( { scope } ), [] ); - scope.context = useContext( context ); + const { client, server } = useContext( context ); + scope.context = client; + scope.serverContext = server; /* eslint-disable react-hooks/rules-of-hooks */ scope.ref = previousScope?.ref || useRef( null ); /* eslint-enable react-hooks/rules-of-hooks */ diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index ec49c4b27c4ad..c91d8f6ab90a5 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -46,6 +46,8 @@ const proxyToProps: WeakMap< export const hasPropSignal = ( proxy: object, key: string ) => proxyToProps.has( proxy ) && proxyToProps.get( proxy )!.has( key ); +const readOnlyProxies = new WeakSet(); + /** * Returns the {@link PropSignal | `PropSignal`} instance associated with the * specified prop in the passed proxy. @@ -77,8 +79,11 @@ const getPropSignal = ( if ( get ) { prop.setGetter( get ); } else { + const readOnly = readOnlyProxies.has( proxy ); prop.setValue( - shouldProxy( value ) ? proxifyState( ns, value ) : value + shouldProxy( value ) + ? proxifyState( ns, value, { readOnly } ) + : value ); } } @@ -148,6 +153,9 @@ const stateHandlers: ProxyHandler< object > = { value: unknown, receiver: object ): boolean { + if ( readOnlyProxies.has( receiver ) ) { + return false; + } setNamespace( getNamespaceFromProxy( receiver ) ); try { return Reflect.set( target, key, value, receiver ); @@ -161,6 +169,10 @@ const stateHandlers: ProxyHandler< object > = { key: string, desc: PropertyDescriptor ): boolean { + if ( readOnlyProxies.has( getProxyFromObject( target )! ) ) { + return false; + } + const isNew = ! ( key in target ); const result = Reflect.defineProperty( target, key, desc ); @@ -199,6 +211,10 @@ const stateHandlers: ProxyHandler< object > = { }, deleteProperty( target: object, key: string ): boolean { + if ( readOnlyProxies.has( getProxyFromObject( target )! ) ) { + return false; + } + const result = Reflect.deleteProperty( target, key ); if ( result ) { @@ -230,8 +246,10 @@ const stateHandlers: ProxyHandler< object > = { * Returns the proxy associated with the given state object, creating it if it * does not exist. * - * @param namespace The namespace that will be associated to this proxy. - * @param obj The object to proxify. + * @param namespace The namespace that will be associated to this proxy. + * @param obj The object to proxify. + * @param options Options. + * @param options.readOnly Read-only. * * @throws Error if the object cannot be proxified. Use {@link shouldProxy} to * check if a proxy can be created for a specific object. @@ -240,8 +258,15 @@ const stateHandlers: ProxyHandler< object > = { */ export const proxifyState = < T extends object >( namespace: string, - obj: T -): T => createProxy( namespace, obj, stateHandlers ) as T; + obj: T, + options?: { readOnly?: boolean } +): T => { + const proxy = createProxy( namespace, obj, stateHandlers ) as T; + if ( options?.readOnly ) { + readOnlyProxies.add( proxy ); + } + return proxy; +}; /** * Reads the value of the specified property without subscribing to it. diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts index 2e78755ec4bbe..8d5485c360bab 100644 --- a/packages/interactivity/src/scopes.ts +++ b/packages/interactivity/src/scopes.ts @@ -12,6 +12,7 @@ import type { Evaluate } from './hooks'; export interface Scope { evaluate: Evaluate; context: object; + serverContext: object; ref: RefObject< HTMLElement >; attributes: createElement.JSX.HTMLAttributes; } From d81f58dd436ac900c7dd8b357df57f208a957c77 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 9 Sep 2024 14:10:18 +0200 Subject: [PATCH 02/12] Create `getServerContext` --- packages/interactivity/src/scopes.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts index 8d5485c360bab..fe00836866188 100644 --- a/packages/interactivity/src/scopes.ts +++ b/packages/interactivity/src/scopes.ts @@ -97,3 +97,24 @@ export const getElement = () => { attributes: deepImmutable( attributes ), } ); }; + +/** + * TODO: write tsdoc comment. + * + * @param namespace Store namespace. By default, the namespace where the calling + * function exists is used. + * @return The context content. + */ +export const getServerContext = < T extends object >( + namespace?: string +): T => { + const scope = getScope(); + if ( globalThis.SCRIPT_DEBUG ) { + if ( ! scope ) { + throw Error( + 'Cannot call `getServerContext()` when there is no scope. If you are using an async function, please consider using a generator instead. If you are using some sort of async callbacks, like `setTimeout`, please wrap the callback with `withScope(callback)`.' + ); + } + } + return scope.serverContext[ namespace || getNamespace() ]; +}; From 7e6f530a72dd9117946dd7e7fa9ab5c29f01d13d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Sep 2024 13:48:06 +0200 Subject: [PATCH 03/12] Add simple test for server context --- .../interactive-blocks/directive-context/render.php | 4 +++- .../interactive-blocks/directive-context/view.js | 11 +++++++++-- packages/interactivity/src/index.ts | 2 +- .../e2e/specs/interactivity/directive-context.spec.ts | 7 +++++++ 4 files changed, 20 insertions(+), 4 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 76635e37a2608..04c05ac7fd18c 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -151,7 +151,7 @@
@@ -159,6 +159,8 @@
+
+
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 7515ad5ad9226..202aa8cd7f7a5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store, getContext } from '@wordpress/interactivity'; +import { store, getContext, getServerContext } from '@wordpress/interactivity'; store( 'directive-context', { state: { @@ -55,7 +55,7 @@ const html = `
@@ -63,6 +63,7 @@ const html = `
+
@@ -99,6 +100,12 @@ const { actions } = store( 'directive-context-navigate', { ctx.newText = 'changed from async action'; }, }, + callbacks: { + updateServerText() { + const ctx = getContext(); + ctx.serverText = getServerContext().serverText; + }, + }, } ); store( 'directive-context-watch', { diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 336c2a97226db..cd34ce3f12a17 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -17,7 +17,7 @@ import { parseServerData, populateServerData } from './store'; import { proxifyState } from './proxies'; export { store, getConfig } from './store'; -export { getContext, getElement } from './scopes'; +export { getContext, getServerContext, getElement } from './scopes'; export { withScope, useWatch, diff --git a/test/e2e/specs/interactivity/directive-context.spec.ts b/test/e2e/specs/interactivity/directive-context.spec.ts index 0a27fe258d5a8..d33430770590a 100644 --- a/test/e2e/specs/interactivity/directive-context.spec.ts +++ b/test/e2e/specs/interactivity/directive-context.spec.ts @@ -395,4 +395,11 @@ test.describe( 'data-wp-context', () => { await expect( childProp ).toHaveText( 'fromChildNs' ); await expect( parentProp ).toHaveText( 'fromParentNs' ); } ); + + test( 'should update server context on navigation', async ( { page } ) => { + const element = page.getByTestId( 'navigation server text' ); + await expect( element ).toHaveText( 'first page' ); + await page.getByTestId( 'navigate' ).click(); + await expect( element ).toHaveText( 'second page' ); + } ); } ); From dfe0aef9fa68a0e317c2804a7a4d6e452c43689a Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Sep 2024 14:05:15 +0200 Subject: [PATCH 04/12] Implement `getServerState` --- packages/interactivity/src/index.ts | 2 +- packages/interactivity/src/store.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index cd34ce3f12a17..9d013e4e744ed 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -16,7 +16,7 @@ import { getNamespace } from './namespaces'; import { parseServerData, populateServerData } from './store'; import { proxifyState } from './proxies'; -export { store, getConfig } from './store'; +export { store, getConfig, getServerState } from './store'; export { getContext, getServerContext, getElement } from './scopes'; export { withScope, diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index c74764b902e19..f7fc75c60b8c9 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -12,6 +12,7 @@ export const stores = new Map(); const rawStores = new Map(); const storeLocks = new Map(); const storeConfigs = new Map(); +const serverStates = new Map(); /** * Get the defined config for the store with the passed namespace. @@ -22,6 +23,20 @@ const storeConfigs = new Map(); export const getConfig = ( namespace?: string ) => storeConfigs.get( namespace || getNamespace() ) || {}; +/** + * Get the server state for the store with the passed namespace. + * + * @param namespace Store's namespace from which to retrieve the server state. + * @return The server state for the given namespace. + */ +export const getServerState = ( namespace?: string ) => { + const ns = namespace || getNamespace(); + if ( ! serverStates.has( ns ) ) { + serverStates.set( ns, proxifyState( ns, {}, { readOnly: true } ) ); + } + return serverStates.get( ns ); +}; + interface StoreOptions { /** * Property to block/unblock private store namespaces. @@ -187,6 +202,7 @@ export const populateServerData = ( data?: { Object.entries( data!.state ).forEach( ( [ namespace, state ] ) => { const st = store< any >( namespace, {}, { lock: universalUnlock } ); deepMerge( st.state, state, false ); + deepMerge( getServerState( namespace ), state ); } ); } if ( isPlainObject( data?.config ) ) { From 73c77a3294d6fe0d0a6ca52f7eb618534de40b66 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Sep 2024 14:57:19 +0200 Subject: [PATCH 05/12] Add tests for read-only state proxies --- .../src/proxies/test/state-proxy.ts | 199 +++++++++++++++++- 1 file changed, 198 insertions(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 92500189fc830..4b0d2b0a708c3 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -9,7 +9,7 @@ import { effect } from '@preact/signals'; /** * Internal dependencies */ -import { proxifyState, peek } from '../'; +import { proxifyState, peek, deepMerge } from '../'; import { setScope, resetScope, getContext, getElement } from '../../scopes'; import { setNamespace, resetNamespace } from '../../namespaces'; @@ -1265,5 +1265,202 @@ describe( 'Interactivity API', () => { expect( x ).toBe( undefined ); } ); } ); + + describe( 'read-only', () => { + it( "should not allow modifying a prop's value", () => { + const readOnlyState = proxifyState( + 'test', + { prop: 'value', nested: { prop: 'value' } }, + { readOnly: true } + ); + + expect( () => { + readOnlyState.prop = 'new value'; + } ).toThrow(); + expect( () => { + readOnlyState.nested.prop = 'new value'; + } ).toThrow(); + } ); + + it( 'should not allow modifying a prop descriptor', () => { + const readOnlyState = proxifyState( + 'test', + { prop: 'value', nested: { prop: 'value' } }, + { readOnly: true } + ); + + expect( () => { + Object.defineProperty( readOnlyState, 'prop', { + get: () => 'value from getter', + writable: true, + enumerable: false, + } ); + } ).toThrow(); + expect( () => { + Object.defineProperty( readOnlyState.nested, 'prop', { + get: () => 'value from getter', + writable: true, + enumerable: false, + } ); + } ).toThrow(); + } ); + + it( 'should not allow adding new props', () => { + const readOnlyState = proxifyState< any >( + 'test', + { prop: 'value', nested: { prop: 'value' } }, + { readOnly: true } + ); + + expect( () => { + readOnlyState.newProp = 'value'; + } ).toThrow(); + expect( () => { + readOnlyState.nested.newProp = 'value'; + } ).toThrow(); + } ); + + it( 'should not allow removing props', () => { + const readOnlyState = proxifyState< any >( + 'test', + { prop: 'value', nested: { prop: 'value' } }, + { readOnly: true } + ); + + expect( () => { + delete readOnlyState.prop; + } ).toThrow(); + expect( () => { + delete readOnlyState.nested.prop; + } ).toThrow(); + } ); + + it( 'should not allow adding items to an array', () => { + const readOnlyState = proxifyState( + 'test', + { array: [ 1, 2, 3 ], nested: { array: [ 1, 2, 3 ] } }, + { readOnly: true } + ); + + expect( () => readOnlyState.array.push( 4 ) ).toThrow(); + expect( () => readOnlyState.nested.array.push( 4 ) ).toThrow(); + } ); + + it( 'should not allow removing items from an array', () => { + const readOnlyState = proxifyState( + 'test', + { array: [ 1, 2, 3 ], nested: { array: [ 1, 2, 3 ] } }, + { readOnly: true } + ); + + expect( () => readOnlyState.array.pop() ).toThrow(); + expect( () => readOnlyState.nested.array.pop() ).toThrow(); + } ); + + it( 'should allow subscribing to prop changes', () => { + const readOnlyState = proxifyState( + 'test', + { + prop: 'value', + nested: { prop: 'value' }, + }, + { readOnly: true } + ); + + const spy1 = jest.fn( () => readOnlyState.prop ); + const spy2 = jest.fn( () => readOnlyState.nested.prop ); + + effect( spy1 ); + effect( spy2 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 'value' ); + expect( spy2 ).toHaveLastReturnedWith( 'value' ); + + deepMerge( readOnlyState, { prop: 'new value' } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 'new value' ); + expect( spy2 ).toHaveLastReturnedWith( 'value' ); + + deepMerge( readOnlyState, { nested: { prop: 'new value' } } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy1 ).toHaveLastReturnedWith( 'new value' ); + expect( spy2 ).toHaveLastReturnedWith( 'new value' ); + } ); + + it( 'should allow subscribing to new props', () => { + const readOnlyState = proxifyState< any >( + 'test', + { + prop: 'value', + nested: { prop: 'value' }, + }, + { readOnly: true } + ); + + const spy1 = jest.fn( () => readOnlyState.newProp ); + const spy2 = jest.fn( () => readOnlyState.nested.newProp ); + + effect( spy1 ); + effect( spy2 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( undefined ); + expect( spy2 ).toHaveLastReturnedWith( undefined ); + + deepMerge( readOnlyState, { newProp: 'value' } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 'value' ); + expect( spy2 ).toHaveLastReturnedWith( undefined ); + + deepMerge( readOnlyState, { nested: { newProp: 'value' } } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy1 ).toHaveLastReturnedWith( 'value' ); + expect( spy2 ).toHaveLastReturnedWith( 'value' ); + } ); + + it( 'should allow subscribing to array changes', () => { + const readOnlyState = proxifyState< any >( + 'test', + { + array: [ 1, 2, 3 ], + nested: { array: [ 1, 2, 3 ] }, + }, + { readOnly: true } + ); + + const spy1 = jest.fn( () => readOnlyState.array[ 0 ] ); + const spy2 = jest.fn( () => readOnlyState.nested.array[ 0 ] ); + + effect( spy1 ); + effect( spy2 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 1 ); + expect( spy2 ).toHaveLastReturnedWith( 1 ); + + deepMerge( readOnlyState, { array: [ 4, 5, 6 ] } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 4 ); + expect( spy2 ).toHaveLastReturnedWith( 1 ); + + deepMerge( readOnlyState, { nested: { array: [] } } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy1 ).toHaveLastReturnedWith( 4 ); + expect( spy2 ).toHaveLastReturnedWith( undefined ); + } ); + } ); } ); } ); From e7ca8e5e587c5b7ae9e702fe6fdffa25e9278adc Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Sep 2024 22:16:15 +0200 Subject: [PATCH 06/12] Add e2e tests for `getServerState()` --- .../get-server-state/block.json | 15 +++ .../get-server-state/render.php | 46 +++++++ .../get-server-state/view.asset.php | 9 ++ .../get-server-state/view.js | 28 +++++ .../interactivity/get-sever-state.spec.ts | 119 ++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js create mode 100644 test/e2e/specs/interactivity/get-sever-state.spec.ts diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json new file mode 100644 index 0000000000000..abf76eb9beddc --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/get-server-state", + "title": "E2E Interactivity tests - getServerState", + "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/get-server-state/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php new file mode 100644 index 0000000000000..0e876aa9eaf5b --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php @@ -0,0 +1,46 @@ + + +
+
+
+
+
+ + + + + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php new file mode 100644 index 0000000000000..bdaec8d1b67a9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php @@ -0,0 +1,9 @@ + array( + '@wordpress/interactivity', + array( + 'id' => '@wordpress/interactivity-router', + 'import' => 'dynamic', + ), + ), +); diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js new file mode 100644 index 0000000000000..4a6ea4c8f4b80 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { store, getServerState } from '@wordpress/interactivity'; + +const { state } = store( 'test/get-server-state', { + actions: { + *navigate( e ) { + e.preventDefault(); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.navigate( e.target.href ); + }, + tryToUpdateServerState() { + getServerState().prop = 'updated from client'; + }, + }, + callbacks: { + updateState() { + const { prop, newProp, nested } = getServerState(); + state.prop = prop; + state.newProp = newProp; + state.nested.prop = nested.prop; + state.nested.newProp = nested.newProp; + }, + }, +} ); diff --git a/test/e2e/specs/interactivity/get-sever-state.spec.ts b/test/e2e/specs/interactivity/get-sever-state.spec.ts new file mode 100644 index 0000000000000..5e63a9061ead9 --- /dev/null +++ b/test/e2e/specs/interactivity/get-sever-state.spec.ts @@ -0,0 +1,119 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'getServerState()', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + const link1 = await utils.addPostWithBlock( 'test/get-server-state', { + alias: 'getServerState() - link 1', + attributes: { + state: { + prop: 'link 1', + newProp: 'link 1', + nested: { + prop: 'link 1', + newProp: 'link 1', + }, + }, + }, + } ); + const link2 = await utils.addPostWithBlock( 'test/get-server-state', { + alias: 'getServerState() - link 2', + attributes: { + state: { + prop: 'link 2', + newProp: 'link 2', + nested: { + prop: 'link 2', + newProp: 'link 2', + }, + }, + }, + } ); + await utils.addPostWithBlock( 'test/get-server-state', { + alias: 'getServerState() - main', + attributes: { + title: 'Main', + links: [ link1, link2 ], + state: { + prop: 'main', + nested: { + prop: 'main', + }, + }, + }, + } ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'getServerState() - main' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should update existing state props on navigation', async ( { + page, + } ) => { + const prop = page.getByTestId( 'prop' ); + const nestedProp = page.getByTestId( 'nested.prop' ); + + await expect( prop ).toHaveText( 'main' ); + await expect( nestedProp ).toHaveText( 'main' ); + + await page.getByTestId( 'link 1' ).click(); + + await expect( prop ).toHaveText( 'link 1' ); + await expect( nestedProp ).toHaveText( 'link 1' ); + + await page.goBack(); + await expect( prop ).toHaveText( 'main' ); + await expect( nestedProp ).toHaveText( 'main' ); + + await page.getByTestId( 'link 2' ).click(); + + await expect( prop ).toHaveText( 'link 2' ); + await expect( nestedProp ).toHaveText( 'link 2' ); + } ); + + test( 'should add new state props and keep them on navigation', async ( { + page, + } ) => { + const newProp = page.getByTestId( 'newProp' ); + const nestedNewProp = page.getByTestId( 'nested.newProp' ); + + await expect( newProp ).toBeEmpty(); + await expect( nestedNewProp ).toBeEmpty(); + + await page.getByTestId( 'link 1' ).click(); + + await expect( newProp ).toHaveText( 'link 1' ); + await expect( nestedNewProp ).toHaveText( 'link 1' ); + + await page.goBack(); + await expect( newProp ).toHaveText( 'link 1' ); + await expect( nestedNewProp ).toHaveText( 'link 1' ); + + await page.getByTestId( 'link 2' ).click(); + + await expect( newProp ).toHaveText( 'link 2' ); + await expect( nestedNewProp ).toHaveText( 'link 2' ); + } ); + + test( 'should not be modifiable', async ( { page } ) => { + const prop = page.getByTestId( 'prop' ); + + await expect( prop ).toHaveText( 'main' ); + + await page.getByTestId( 'tryToUpdateServerState' ).click(); + await expect( prop ).not.toHaveText( 'updated from client' ); + await expect( prop ).toHaveText( 'main' ); + + await page.getByTestId( 'link 1' ).click(); + await expect( prop ).toHaveText( 'link 1' ); + } ); +} ); From 580aba64e3750d932f70d246a87129ee350015a9 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Sep 2024 22:41:00 +0200 Subject: [PATCH 07/12] Avoid PHPCS UndefinedVariable error --- .../plugins/interactive-blocks/get-server-state/render.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php index 0e876aa9eaf5b..aaf70892568c0 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php @@ -3,6 +3,8 @@ * HTML for testing the getServerState() function. * * @package gutenberg-test-interactive-blocks + * + * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable */ if ( isset( $attributes['state'] ) ) { From ace3233e88dc39745b35ed688011cb00616bf320 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 18 Sep 2024 14:29:05 +0200 Subject: [PATCH 08/12] Add e2e tests for `getServerContext` WIP --- .../get-server-context/block.json | 15 ++ .../get-server-context/render.php | 55 +++++ .../get-server-context/view.asset.php | 9 + .../get-server-context/view.js | 28 +++ .../interactivity/get-sever-context.spec.ts | 191 ++++++++++++++++++ 5 files changed, 298 insertions(+) create mode 100644 packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js create mode 100644 test/e2e/specs/interactivity/get-sever-context.spec.ts diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json new file mode 100644 index 0000000000000..c635846328b9e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/get-server-context", + "title": "E2E Interactivity tests - getServerContext", + "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/get-server-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php new file mode 100644 index 0000000000000..b361dbc3fb0d3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php @@ -0,0 +1,55 @@ + + + + +
+> +
+
+
+
+
+
+
+ > +
+
+
+
+
+
+ + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php new file mode 100644 index 0000000000000..bdaec8d1b67a9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php @@ -0,0 +1,9 @@ + array( + '@wordpress/interactivity', + array( + 'id' => '@wordpress/interactivity-router', + 'import' => 'dynamic', + ), + ), +); diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js new file mode 100644 index 0000000000000..48ed051e92baf --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { store, getServerContext } from '@wordpress/interactivity'; + +const { state } = store( 'test/get-server-state', { + actions: { + *navigate( e ) { + e.preventDefault(); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.navigate( e.target.href ); + }, + tryToUpdateServerState() { + getServerContext().prop = 'updated from client'; + }, + }, + callbacks: { + updateState() { + const { prop, newProp, nested } = getServerContext(); + state.prop = prop; + state.newProp = newProp; + state.nested.prop = nested.prop; + state.nested.newProp = nested.newProp; + }, + }, +} ); diff --git a/test/e2e/specs/interactivity/get-sever-context.spec.ts b/test/e2e/specs/interactivity/get-sever-context.spec.ts new file mode 100644 index 0000000000000..e13b3659fdd1a --- /dev/null +++ b/test/e2e/specs/interactivity/get-sever-context.spec.ts @@ -0,0 +1,191 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'getServerContext()', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + const parent = { + prop: 'parent', + nested: { + prop: 'parent', + }, + inherited: { + prop: 'parent', + }, + }; + + const parentModified = { + prop: 'parentModified', + nested: { + prop: 'parentModified', + }, + inherited: { + prop: 'parentModified', + }, + }; + + const parentNewProps = { + prop: 'parent', + newProp: 'parent', + nested: { + prop: 'parent', + newProp: 'parent', + }, + inherited: { + prop: 'parent', + newProp: 'parent', + }, + }; + + const child = { + prop: 'child', + nested: { + prop: 'child', + }, + }; + + const childModified = { + prop: 'childModified', + nested: { + prop: 'childModified', + }, + }; + + const childNewProps = { + prop: 'child', + newProp: 'child', + nested: { + prop: 'child', + newProp: 'child', + }, + }; + + await utils.activatePlugins(); + const link1 = await utils.addPostWithBlock( 'test/get-server-context', { + alias: 'getServerContext() - modified', + attributes: { + parentContext: parentModified, + childContext: childModified, + }, + } ); + const link2 = await utils.addPostWithBlock( 'test/get-server-context', { + alias: 'getServerContext() - new props', + attributes: { + parentContext: parentNewProps, + childContext: childNewProps, + }, + } ); + await utils.addPostWithBlock( 'test/get-server-context', { + alias: 'getServerContext() - main', + attributes: { + links: { modified: link1, newProps: link2 }, + parentContext: parent, + childContext: child, + }, + } ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'getServerContext() - main' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should update modified props on navigation', async ( { page } ) => { + const parentProp = page.getByTestId( 'parent.prop' ); + const parentNestedProp = page.getByTestId( 'parent.nested.prop' ); + const parentInheritedProp = page.getByTestId( 'parent.inherited.prop' ); + const childProp = page.getByTestId( 'child.prop' ); + const childNestedProp = page.getByTestId( 'child.nested.prop' ); + const childInheritedProp = page.getByTestId( 'child.inherited.prop' ); + + await expect( parentProp ).toHaveText( 'parent' ); + await expect( parentNestedProp ).toHaveText( 'parent' ); + await expect( parentInheritedProp ).toHaveText( 'parent' ); + await expect( childProp ).toHaveText( 'child' ); + await expect( childNestedProp ).toHaveText( 'child' ); + await expect( childInheritedProp ).toHaveText( 'parent' ); + + await page.getByTestId( 'modified' ).click(); + + await expect( parentProp ).toHaveText( 'parentModified' ); + await expect( parentNestedProp ).toHaveText( 'parentModified' ); + await expect( parentInheritedProp ).toHaveText( 'parentModified' ); + await expect( childProp ).toHaveText( 'childModified' ); + await expect( childNestedProp ).toHaveText( 'childModified' ); + await expect( childInheritedProp ).toHaveText( 'parentModified' ); + + await page.goBack(); + + await expect( parentProp ).toHaveText( 'parent' ); + await expect( parentNestedProp ).toHaveText( 'parent' ); + await expect( parentInheritedProp ).toHaveText( 'parent' ); + await expect( childProp ).toHaveText( 'child' ); + await expect( childNestedProp ).toHaveText( 'child' ); + await expect( childInheritedProp ).toHaveText( 'parent' ); + } ); + + test( 'should add new props on navigation', async ( { page } ) => { + const parentProp = page.getByTestId( 'parent.newProp' ); + const parentNestedProp = page.getByTestId( 'parent.nested.newProp' ); + const parentInheritedProp = page.getByTestId( + 'parent.inherited.newProp' + ); + const childProp = page.getByTestId( 'child.newProp' ); + const childNestedProp = page.getByTestId( 'child.nested.newProp' ); + const childInheritedProp = page.getByTestId( + 'child.inherited.newProp' + ); + + await expect( parentProp ).toBeEmpty(); + await expect( parentNestedProp ).toBeEmpty(); + await expect( parentInheritedProp ).toBeEmpty(); + await expect( childProp ).toBeEmpty(); + await expect( childNestedProp ).toBeEmpty(); + await expect( childInheritedProp ).toBeEmpty(); + + await page.getByTestId( 'newProps' ).click(); + + await expect( parentProp ).toHaveText( 'parent' ); + await expect( parentNestedProp ).toHaveText( 'parent' ); + await expect( parentInheritedProp ).toHaveText( 'parent' ); + await expect( childProp ).toHaveText( 'child' ); + await expect( childNestedProp ).toHaveText( 'child' ); + await expect( childInheritedProp ).toHaveText( 'parent' ); + } ); + + test( 'should keep new props on navigation', async ( { page } ) => { + const parentProp = page.getByTestId( 'parent.newProp' ); + const parentNestedProp = page.getByTestId( 'parent.nested.newProp' ); + const parentInheritedProp = page.getByTestId( + 'parent.inherited.newProp' + ); + const childProp = page.getByTestId( 'child.newProp' ); + const childNestedProp = page.getByTestId( 'child.nested.newProp' ); + const childInheritedProp = page.getByTestId( + 'child.inherited.newProp' + ); + + await page.getByTestId( 'newProps' ).click(); + + await expect( parentProp ).toHaveText( 'parent' ); + await expect( parentNestedProp ).toHaveText( 'parent' ); + await expect( parentInheritedProp ).toHaveText( 'parent' ); + await expect( childProp ).toHaveText( 'child' ); + await expect( childNestedProp ).toHaveText( 'child' ); + await expect( childInheritedProp ).toHaveText( 'parent' ); + + await page.goBack(); + + await expect( parentProp ).toHaveText( 'parent' ); + await expect( parentNestedProp ).toHaveText( 'parent' ); + await expect( parentInheritedProp ).toHaveText( 'parent' ); + await expect( childProp ).toHaveText( 'child' ); + await expect( childNestedProp ).toHaveText( 'child' ); + await expect( childInheritedProp ).toHaveText( 'parent' ); + } ); +} ); From 9ce5b3dfdc26e6ab609415125c7105fb34b6418d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 18 Sep 2024 19:51:17 +0200 Subject: [PATCH 09/12] Finish e2e tests for `getServerContext()` --- .../get-server-context/render.php | 26 ++-- .../get-server-context/view.js | 38 ++++-- .../interactivity/get-sever-context.spec.ts | 115 +++++++----------- 3 files changed, 84 insertions(+), 95 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php index b361dbc3fb0d3..a71ced20dc46a 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php @@ -28,28 +28,24 @@ data-wp-watch="callbacks.updateServerContextParent" > -
-
-
-
-
-
> -
-
-
-
-
-
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js index 48ed051e92baf..83f016e2eac16 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js @@ -1,9 +1,9 @@ /** * WordPress dependencies */ -import { store, getServerContext } from '@wordpress/interactivity'; +import { store, getContext, getServerContext } from '@wordpress/interactivity'; -const { state } = store( 'test/get-server-state', { +store( 'test/get-server-context', { actions: { *navigate( e ) { e.preventDefault(); @@ -12,17 +12,35 @@ const { state } = store( 'test/get-server-state', { ); yield actions.navigate( e.target.href ); }, - tryToUpdateServerState() { - getServerContext().prop = 'updated from client'; + attemptModification() { + try { + getServerContext().prop = 'updated from client'; + getContext().result = 'unexpectedly modified ❌'; + } catch ( e ) { + getContext().result = 'not modified ✅'; + } }, }, callbacks: { - updateState() { - const { prop, newProp, nested } = getServerContext(); - state.prop = prop; - state.newProp = newProp; - state.nested.prop = nested.prop; - state.nested.newProp = nested.newProp; + updateServerContextParent() { + const ctx = getContext(); + const { prop, newProp, nested, inherited } = getServerContext(); + ctx.prop = prop; + ctx.newProp = newProp; + ctx.nested.prop = nested.prop; + ctx.nested.newProp = nested.newProp; + ctx.inherited.prop = inherited.prop; + ctx.inherited.newProp = inherited.newProp; + }, + updateServerContextChild() { + const ctx = getContext(); + const { prop, newProp, nested, inherited } = getServerContext(); + ctx.prop = prop; + ctx.newProp = newProp; + ctx.nested.prop = nested.prop; + ctx.nested.newProp = nested.newProp; + ctx.inherited.prop = inherited.prop; + ctx.inherited.newProp = inherited.newProp; }, }, } ); diff --git a/test/e2e/specs/interactivity/get-sever-context.spec.ts b/test/e2e/specs/interactivity/get-sever-context.spec.ts index e13b3659fdd1a..d7bc4075f9760 100644 --- a/test/e2e/specs/interactivity/get-sever-context.spec.ts +++ b/test/e2e/specs/interactivity/get-sever-context.spec.ts @@ -96,96 +96,71 @@ test.describe( 'getServerContext()', () => { } ); test( 'should update modified props on navigation', async ( { page } ) => { - const parentProp = page.getByTestId( 'parent.prop' ); - const parentNestedProp = page.getByTestId( 'parent.nested.prop' ); - const parentInheritedProp = page.getByTestId( 'parent.inherited.prop' ); - const childProp = page.getByTestId( 'child.prop' ); - const childNestedProp = page.getByTestId( 'child.nested.prop' ); - const childInheritedProp = page.getByTestId( 'child.inherited.prop' ); - - await expect( parentProp ).toHaveText( 'parent' ); - await expect( parentNestedProp ).toHaveText( 'parent' ); - await expect( parentInheritedProp ).toHaveText( 'parent' ); - await expect( childProp ).toHaveText( 'child' ); - await expect( childNestedProp ).toHaveText( 'child' ); - await expect( childInheritedProp ).toHaveText( 'parent' ); + const prop = page.getByTestId( 'prop' ); + const nestedProp = page.getByTestId( 'nested.prop' ); + const inheritedProp = page.getByTestId( 'inherited.prop' ); + + await expect( prop ).toHaveText( 'child' ); + await expect( nestedProp ).toHaveText( 'child' ); + await expect( inheritedProp ).toHaveText( 'parent' ); await page.getByTestId( 'modified' ).click(); - await expect( parentProp ).toHaveText( 'parentModified' ); - await expect( parentNestedProp ).toHaveText( 'parentModified' ); - await expect( parentInheritedProp ).toHaveText( 'parentModified' ); - await expect( childProp ).toHaveText( 'childModified' ); - await expect( childNestedProp ).toHaveText( 'childModified' ); - await expect( childInheritedProp ).toHaveText( 'parentModified' ); + await expect( prop ).toHaveText( 'childModified' ); + await expect( nestedProp ).toHaveText( 'childModified' ); + await expect( inheritedProp ).toHaveText( 'parentModified' ); await page.goBack(); - await expect( parentProp ).toHaveText( 'parent' ); - await expect( parentNestedProp ).toHaveText( 'parent' ); - await expect( parentInheritedProp ).toHaveText( 'parent' ); - await expect( childProp ).toHaveText( 'child' ); - await expect( childNestedProp ).toHaveText( 'child' ); - await expect( childInheritedProp ).toHaveText( 'parent' ); + await expect( prop ).toHaveText( 'child' ); + await expect( nestedProp ).toHaveText( 'child' ); + await expect( inheritedProp ).toHaveText( 'parent' ); } ); test( 'should add new props on navigation', async ( { page } ) => { - const parentProp = page.getByTestId( 'parent.newProp' ); - const parentNestedProp = page.getByTestId( 'parent.nested.newProp' ); - const parentInheritedProp = page.getByTestId( - 'parent.inherited.newProp' - ); - const childProp = page.getByTestId( 'child.newProp' ); - const childNestedProp = page.getByTestId( 'child.nested.newProp' ); - const childInheritedProp = page.getByTestId( - 'child.inherited.newProp' - ); - - await expect( parentProp ).toBeEmpty(); - await expect( parentNestedProp ).toBeEmpty(); - await expect( parentInheritedProp ).toBeEmpty(); - await expect( childProp ).toBeEmpty(); - await expect( childNestedProp ).toBeEmpty(); - await expect( childInheritedProp ).toBeEmpty(); + const newProp = page.getByTestId( 'newProp' ); + const nestedNewProp = page.getByTestId( 'nested.newProp' ); + const inheritedNewProp = page.getByTestId( 'inherited.newProp' ); + + await expect( newProp ).toBeEmpty(); + await expect( nestedNewProp ).toBeEmpty(); + await expect( inheritedNewProp ).toBeEmpty(); await page.getByTestId( 'newProps' ).click(); - await expect( parentProp ).toHaveText( 'parent' ); - await expect( parentNestedProp ).toHaveText( 'parent' ); - await expect( parentInheritedProp ).toHaveText( 'parent' ); - await expect( childProp ).toHaveText( 'child' ); - await expect( childNestedProp ).toHaveText( 'child' ); - await expect( childInheritedProp ).toHaveText( 'parent' ); + await expect( newProp ).toHaveText( 'child' ); + await expect( nestedNewProp ).toHaveText( 'child' ); + await expect( inheritedNewProp ).toHaveText( 'parent' ); } ); test( 'should keep new props on navigation', async ( { page } ) => { - const parentProp = page.getByTestId( 'parent.newProp' ); - const parentNestedProp = page.getByTestId( 'parent.nested.newProp' ); - const parentInheritedProp = page.getByTestId( - 'parent.inherited.newProp' - ); - const childProp = page.getByTestId( 'child.newProp' ); - const childNestedProp = page.getByTestId( 'child.nested.newProp' ); - const childInheritedProp = page.getByTestId( - 'child.inherited.newProp' - ); + const newProp = page.getByTestId( 'newProp' ); + const nestedNewProp = page.getByTestId( 'nested.newProp' ); + const inheritedNewProp = page.getByTestId( 'inherited.newProp' ); await page.getByTestId( 'newProps' ).click(); - await expect( parentProp ).toHaveText( 'parent' ); - await expect( parentNestedProp ).toHaveText( 'parent' ); - await expect( parentInheritedProp ).toHaveText( 'parent' ); - await expect( childProp ).toHaveText( 'child' ); - await expect( childNestedProp ).toHaveText( 'child' ); - await expect( childInheritedProp ).toHaveText( 'parent' ); + await expect( newProp ).toHaveText( 'child' ); + await expect( nestedNewProp ).toHaveText( 'child' ); + await expect( inheritedNewProp ).toHaveText( 'parent' ); await page.goBack(); - await expect( parentProp ).toHaveText( 'parent' ); - await expect( parentNestedProp ).toHaveText( 'parent' ); - await expect( parentInheritedProp ).toHaveText( 'parent' ); - await expect( childProp ).toHaveText( 'child' ); - await expect( childNestedProp ).toHaveText( 'child' ); - await expect( childInheritedProp ).toHaveText( 'parent' ); + await expect( newProp ).toHaveText( 'child' ); + await expect( nestedNewProp ).toHaveText( 'child' ); + await expect( inheritedNewProp ).toHaveText( 'parent' ); + } ); + + test( 'should prevent any manual modifications', async ( { page } ) => { + const prop = page.getByTestId( 'prop' ); + const button = page.getByTestId( 'tryToModifyServerContext' ); + + await expect( prop ).toHaveText( 'child' ); + await expect( button ).toHaveText( 'modify' ); + + await button.click(); + + await expect( prop ).toHaveText( 'child' ); + await expect( button ).toHaveText( 'not modified ✅' ); } ); } ); From 05e70f50b08c63217ab97490f6dc2045f168766f Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 18 Sep 2024 19:56:12 +0200 Subject: [PATCH 10/12] Update `getServerState()` tests --- .../interactive-blocks/get-server-state/render.php | 8 +++++--- .../interactive-blocks/get-server-state/view.js | 11 ++++++++--- test/e2e/specs/interactivity/get-sever-state.spec.ts | 12 ++++++------ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php index aaf70892568c0..abc4efd8272d5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php @@ -22,10 +22,12 @@
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js index 4a6ea4c8f4b80..db2992ec4a586 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store, getServerState } from '@wordpress/interactivity'; +import { store, getServerState, getContext } from '@wordpress/interactivity'; const { state } = store( 'test/get-server-state', { actions: { @@ -12,8 +12,13 @@ const { state } = store( 'test/get-server-state', { ); yield actions.navigate( e.target.href ); }, - tryToUpdateServerState() { - getServerState().prop = 'updated from client'; + attemptModification() { + try { + getServerState().prop = 'updated from client'; + getContext().result = 'unexpectedly modified ❌'; + } catch ( e ) { + getContext().result = 'not modified ✅'; + } }, }, callbacks: { diff --git a/test/e2e/specs/interactivity/get-sever-state.spec.ts b/test/e2e/specs/interactivity/get-sever-state.spec.ts index 5e63a9061ead9..16406c1d82446 100644 --- a/test/e2e/specs/interactivity/get-sever-state.spec.ts +++ b/test/e2e/specs/interactivity/get-sever-state.spec.ts @@ -104,16 +104,16 @@ test.describe( 'getServerState()', () => { await expect( nestedNewProp ).toHaveText( 'link 2' ); } ); - test( 'should not be modifiable', async ( { page } ) => { + test( 'should prevent any manual modifications', async ( { page } ) => { const prop = page.getByTestId( 'prop' ); + const button = page.getByTestId( 'tryToModifyServerState' ); await expect( prop ).toHaveText( 'main' ); + await expect( button ).toHaveText( 'modify' ); - await page.getByTestId( 'tryToUpdateServerState' ).click(); - await expect( prop ).not.toHaveText( 'updated from client' ); - await expect( prop ).toHaveText( 'main' ); + await button.click(); - await page.getByTestId( 'link 1' ).click(); - await expect( prop ).toHaveText( 'link 1' ); + await expect( prop ).toHaveText( 'main' ); + await expect( button ).toHaveText( 'not modified ✅' ); } ); } ); From 3d232f6b7136401bb1774671662b6b9b159bd3ad Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 18 Sep 2024 19:59:21 +0200 Subject: [PATCH 11/12] Revert "Add simple test for server context" This reverts commit 7e6f530a72dd9117946dd7e7fa9ab5c29f01d13d. --- .../interactive-blocks/directive-context/render.php | 4 +--- .../interactive-blocks/directive-context/view.js | 11 ++--------- .../e2e/specs/interactivity/directive-context.spec.ts | 7 ------- 3 files changed, 3 insertions(+), 19 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 04c05ac7fd18c..76635e37a2608 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -151,7 +151,7 @@
@@ -159,8 +159,6 @@
-
-
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 202aa8cd7f7a5..7515ad5ad9226 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store, getContext, getServerContext } from '@wordpress/interactivity'; +import { store, getContext } from '@wordpress/interactivity'; store( 'directive-context', { state: { @@ -55,7 +55,7 @@ const html = `
@@ -63,7 +63,6 @@ const html = `
-
@@ -100,12 +99,6 @@ const { actions } = store( 'directive-context-navigate', { ctx.newText = 'changed from async action'; }, }, - callbacks: { - updateServerText() { - const ctx = getContext(); - ctx.serverText = getServerContext().serverText; - }, - }, } ); store( 'directive-context-watch', { diff --git a/test/e2e/specs/interactivity/directive-context.spec.ts b/test/e2e/specs/interactivity/directive-context.spec.ts index d33430770590a..0a27fe258d5a8 100644 --- a/test/e2e/specs/interactivity/directive-context.spec.ts +++ b/test/e2e/specs/interactivity/directive-context.spec.ts @@ -395,11 +395,4 @@ test.describe( 'data-wp-context', () => { await expect( childProp ).toHaveText( 'fromChildNs' ); await expect( parentProp ).toHaveText( 'fromParentNs' ); } ); - - test( 'should update server context on navigation', async ( { page } ) => { - const element = page.getByTestId( 'navigation server text' ); - await expect( element ).toHaveText( 'first page' ); - await page.getByTestId( 'navigate' ).click(); - await expect( element ).toHaveText( 'second page' ); - } ); } ); From d09ad9f5805093436f9c88af2b1358bd513b3824 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 18 Sep 2024 20:50:04 +0200 Subject: [PATCH 12/12] Update TSDocs --- packages/interactivity/src/scopes.ts | 26 ++++++++++++++++++++++++-- packages/interactivity/src/store.ts | 21 ++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts index fe00836866188..722305f6bee11 100644 --- a/packages/interactivity/src/scopes.ts +++ b/packages/interactivity/src/scopes.ts @@ -99,11 +99,33 @@ export const getElement = () => { }; /** - * TODO: write tsdoc comment. + * Retrieves the part of the inherited context defined and updated from the + * server. + * + * The object returned is read-only, and includes the context defined in PHP + * with `wp_interactivity_data_wp_context()`, including the corresponding + * inherited properties. When `actions.navigate()` is called, this object is + * updated to reflect the changes in the new visited page, without affecting the + * context returned by `getContext()`. Directives can subscribe to those changes + * to update the context if needed. + * + * @example + * ```js + * store('...', { + * callbacks: { + * updateServerContext() { + * const context = getContext(); + * const serverContext = getServerContext(); + * // Override some property with the new value that came from the server. + * context.overridableProp = serverContext.overridableProp; + * }, + * }, + * }); + * ``` * * @param namespace Store namespace. By default, the namespace where the calling * function exists is used. - * @return The context content. + * @return The server context content. */ export const getServerContext = < T extends object >( namespace?: string diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index f7fc75c60b8c9..b1ad07459c62c 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -24,7 +24,26 @@ export const getConfig = ( namespace?: string ) => storeConfigs.get( namespace || getNamespace() ) || {}; /** - * Get the server state for the store with the passed namespace. + * Get the part of the state defined and updated from the server. + * + * The object returned is read-only, and includes the state defined in PHP with + * `wp_interactivity_state()`. When using `actions.navigate()`, this object is + * updated to reflect the changes in its properites, without affecting the state + * returned by `store()`. Directives can subscribe to those changes to update + * the state if needed. + * + * @example + * ```js + * const { state } = store('myStore', { + * callbacks: { + * updateServerState() { + * const serverState = getServerState(); + * // Override some property with the new value that came from the server. + * state.overridableProp = serverState.overridableProp; + * }, + * }, + * }); + * ``` * * @param namespace Store's namespace from which to retrieve the server state. * @return The server state for the given namespace.