diff --git a/package-lock.json b/package-lock.json index 6680025d768be..8bf2400660a2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53728,7 +53728,6 @@ "license": "GPL-2.0-or-later", "dependencies": { "@preact/signals": "^1.2.2", - "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "engines": { @@ -53763,31 +53762,6 @@ "preact": "10.x" } }, - "packages/interactivity/node_modules/deepsignal": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.4.0.tgz", - "integrity": "sha512-x0XUMT48s+xQRLc2fPFfxnYLCJ46vffw47OQ5NcHFzacOjfW5eA0NrEmI0bhQHL6MgUHkBVT4TIiWTVwzTEwpg==", - "peerDependencies": { - "@preact/signals": "^1.1.4", - "@preact/signals-core": "^1.5.1", - "@preact/signals-react": "^1.3.8 || ^2.0.0", - "preact": "^10.16.0" - }, - "peerDependenciesMeta": { - "@preact/signals": { - "optional": true - }, - "@preact/signals-core": { - "optional": true - }, - "@preact/signals-react": { - "optional": true - }, - "preact": { - "optional": true - } - } - }, "packages/interactivity/node_modules/preact": { "version": "10.19.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz", @@ -68270,7 +68244,6 @@ "version": "file:packages/interactivity", "requires": { "@preact/signals": "^1.2.2", - "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "dependencies": { @@ -68282,11 +68255,6 @@ "@preact/signals-core": "^1.4.0" } }, - "deepsignal": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.4.0.tgz", - "integrity": "sha512-x0XUMT48s+xQRLc2fPFfxnYLCJ46vffw47OQ5NcHFzacOjfW5eA0NrEmI0bhQHL6MgUHkBVT4TIiWTVwzTEwpg==" - }, "preact": { "version": "10.19.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz", diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php index 27fd6c7d17293..47eb351d837e7 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php @@ -220,14 +220,14 @@
beta
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js index e19821a2a2aff..6ceef82864d9d 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js @@ -169,14 +169,14 @@ const html = `alpha
@@ -187,6 +187,12 @@ const html = ` `; store( 'directive-each', { + state: { + get list() { + const ctx = getContext(); + return Object.keys( ctx ).sort(); + }, + }, actions: { *navigate() { const { actions } = yield import( 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 c6cdf31b4909c..5a46908f77d87 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js @@ -8,7 +8,7 @@ import { privateApis, } from '@wordpress/interactivity'; -const { directive, deepSignal, h } = privateApis( +const { directive, proxifyState, h } = privateApis( 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' ); @@ -41,12 +41,12 @@ directive( 'test-context', ( { context: { Provider }, props: { children } } ) => { executionProof( 'context' ); - const value = deepSignal( { - [ namespace ]: { + const value = { + [ namespace ]: proxifyState( namespace, { attribute: 'from context', text: 'from context', - }, - } ); + } ), + }; return h( Provider, { value }, children ); }, { priority: 8 } diff --git a/packages/interactivity-router/src/index.ts b/packages/interactivity-router/src/index.ts index 79b67eeb98e65..c6e1087b038a5 100644 --- a/packages/interactivity-router/src/index.ts +++ b/packages/interactivity-router/src/index.ts @@ -14,8 +14,8 @@ const { initialVdom, toVdom, render, - parseInitialData, - populateInitialData, + parseServerData, + populateServerData, batch, } = privateApis( 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' @@ -103,7 +103,7 @@ const regionsToVdom: RegionsToVdom = async ( dom, { vdom } = {} ) => { } ); } const title = dom.querySelector( 'title' )?.innerText; - const initialData = parseInitialData( dom ); + const initialData = parseServerData( dom ); return { regions, head, title, initialData }; }; @@ -119,7 +119,7 @@ const renderRegions = ( page: Page ) => { } } if ( navigationMode === 'regionBased' ) { - populateInitialData( page.initialData ); + populateServerData( page.initialData ); const attrName = `data-${ directivePrefix }-router-region`; document .querySelectorAll( `[${ attrName }]` ) diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index 835063ccc7699..332254684bdc9 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -28,7 +28,6 @@ "types": "build-types", "dependencies": { "@preact/signals": "^1.2.2", - "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "publishConfig": { diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 60ddf13375a8a..357fe203399be 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -6,14 +6,24 @@ */ import { h as createElement, type RefObject } from 'preact'; import { useContext, useMemo, useRef } from 'preact/hooks'; -import { deepSignal, peek, type DeepSignal } from 'deepsignal'; +/** + * Internal dependencies + */ +import { proxifyState, peek } from './proxies'; /** * Internal dependencies */ -import { useWatch, useInit, kebabToCamelCase, warn, splitTask } from './utils'; -import type { DirectiveEntry } from './hooks'; -import { directive, getScope, getEvaluate } from './hooks'; +import { + useWatch, + useInit, + kebabToCamelCase, + warn, + splitTask, + isPlainObject, +} from './utils'; +import { directive, getEvaluate, type DirectiveEntry } from './hooks'; +import { getScope } from './scopes'; // Assigned objects should be ignored during proxification. const contextAssignedObjects = new WeakMap(); @@ -23,9 +33,6 @@ const contextObjectToProxy = new WeakMap(); const contextProxyToObject = new WeakMap(); const contextObjectToFallback = new WeakMap(); -const isPlainObject = ( item: unknown ): boolean => - Boolean( item && typeof item === 'object' && item.constructor === Object ); - const descriptor = Reflect.getOwnPropertyDescriptor; /** @@ -47,7 +54,7 @@ const proxifyContext = ( current: object, inherited: object = {} ): object => { contextObjectToFallback.set( current, inherited ); if ( ! contextObjectToProxy.has( current ) ) { const proxy = new Proxy( current, { - get: ( target: DeepSignal< any >, k ) => { + get: ( target: object, k: string ) => { const fallback = contextObjectToFallback.get( current ); // Always subscribe to prop changes in the current context. const currentProp = target[ k ]; @@ -61,9 +68,9 @@ const proxifyContext = ( current: object, inherited: object = {} ): object => { if ( k in target && ! contextAssignedObjects.get( target )?.has( k ) && - isPlainObject( peek( target, k ) ) + isPlainObject( currentProp ) ) { - return proxifyContext( currentProp, fallback[ k ] ); + return proxifyContext( currentProp ); } // Return the stored proxy for `currentProp` when it exists. @@ -125,22 +132,19 @@ const proxifyContext = ( current: object, inherited: object = {} ): object => { }; /** - * Recursively update values within a deepSignal object. + * Recursively update values within a context object. * - * @param target A deepSignal instance. + * @param target A context instance. * @param source Object with properties to update in `target`. */ -const updateSignals = ( - target: DeepSignal< any >, - source: DeepSignal< any > -) => { +const updateContext = ( target: any, source: any ) => { for ( const k in source ) { if ( isPlainObject( peek( target, k ) ) && - isPlainObject( peek( source, k ) ) + isPlainObject( source[ k ] ) ) { - updateSignals( target[ `$${ k }` ].peek(), source[ k ] ); - } else { + updateContext( peek( target, k ) as object, source[ k ] ); + } else if ( ! ( k in target ) ) { target[ k ] = source[ k ]; } } @@ -257,18 +261,21 @@ export default () => { // data-wp-context directive( 'context', - // @ts-ignore-next-line ( { directives: { context }, props: { children }, context: inheritedContext, } ) => { const { Provider } = inheritedContext; - const inheritedValue = useContext( inheritedContext ); - const currentValue = useRef( deepSignal( {} ) ); const defaultEntry = context.find( ( { suffix } ) => suffix === 'default' ); + const inheritedValue = useContext( inheritedContext ); + + const ns = defaultEntry!.namespace; + const currentValue = useRef( { + [ ns ]: proxifyState( ns, {} ), + } ); // No change should be made if `defaultEntry` does not exist. const contextStack = useMemo( () => { @@ -280,11 +287,16 @@ export default () => { `The value of data-wp-context in "${ namespace }" store must be a valid stringified JSON object.` ); } - updateSignals( currentValue.current, { - [ namespace ]: deepClone( value ), - } ); + updateContext( + currentValue.current[ namespace ], + deepClone( value ) as object + ); + currentValue.current[ namespace ] = proxifyContext( + currentValue.current[ namespace ], + inheritedValue[ namespace ] + ); } - return proxifyContext( currentValue.current, inheritedValue ); + return currentValue.current; }, [ defaultEntry, inheritedValue ] ); return createElement( Provider, { value: contextStack }, children ); @@ -677,11 +689,14 @@ export default () => { return list.map( ( item ) => { const itemProp = suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); - const itemContext = deepSignal( { [ namespace ]: {} } ); - const mergedContext = proxifyContext( - itemContext, - inheritedValue + const itemContext = proxifyContext( + proxifyState( namespace, {} ), + inheritedValue[ namespace ] ); + const mergedContext = { + ...inheritedValue, + [ namespace ]: itemContext, + }; // Set the item after proxifying the context. mergedContext[ namespace ][ itemProp ] = item; diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 9af6fb00d6aba..215da8afef9b5 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -12,13 +12,14 @@ import { type ComponentChildren, } from 'preact'; import { useRef, useCallback, useContext } from 'preact/hooks'; -import type { VNode, Context, RefObject } from 'preact'; +import type { VNode, Context } from 'preact'; /** * Internal dependencies */ import { store, stores, universalUnlock } from './store'; import { warn } from './utils'; +import { getScope, setScope, resetScope, type Scope } from './scopes'; export interface DirectiveEntry { value: string | object; namespace: string; @@ -56,7 +57,7 @@ interface DirectiveArgs { } interface DirectiveCallback { - ( args: DirectiveArgs ): VNode | null | void; + ( args: DirectiveArgs ): VNode< any > | null | void; } interface DirectiveOptions { @@ -69,14 +70,7 @@ interface DirectiveOptions { priority?: number; } -interface Scope { - evaluate: Evaluate; - context: object; - ref: RefObject< HTMLElement >; - attributes: createElement.JSX.HTMLAttributes; -} - -interface Evaluate { +export interface Evaluate { ( entry: DirectiveEntry, ...args: any[] ): any; } @@ -101,85 +95,6 @@ interface DirectivesProps { // Main context. const context = createContext< any >( {} ); -// Wrap the element props to prevent modifications. -const immutableMap = new WeakMap(); -const immutableError = () => { - throw new Error( - 'Please use `data-wp-bind` to modify the attributes of an element.' - ); -}; -const immutableHandlers: ProxyHandler< object > = { - get( target, key, receiver ) { - const value = Reflect.get( target, key, receiver ); - return !! value && typeof value === 'object' - ? deepImmutable( value ) - : value; - }, - set: immutableError, - deleteProperty: immutableError, -}; -const deepImmutable = < T extends object = {} >( target: T ): T => { - if ( ! immutableMap.has( target ) ) { - immutableMap.set( target, new Proxy( target, immutableHandlers ) ); - } - return immutableMap.get( target ); -}; - -// Store stacks for the current scope and the default namespaces and export APIs -// to interact with them. -const scopeStack: Scope[] = []; -const namespaceStack: string[] = []; - -/** - * Retrieves the context inherited by the element evaluating a function from the - * store. The returned value depends on the element and the namespace where the - * function calling `getContext()` exists. - * - * @param namespace Store namespace. By default, the namespace where the calling - * function exists is used. - * @return The context content. - */ -export const getContext = < T extends object >( namespace?: string ): T => - getScope()?.context[ namespace || getNamespace() ]; - -/** - * Retrieves a representation of the element where a function from the store - * is being evalutated. Such representation is read-only, and contains a - * reference to the DOM element, its props and a local reactive state. - * - * @return Element representation. - */ -export const getElement = () => { - if ( ! getScope() ) { - throw Error( - 'Cannot call `getElement()` outside getters and actions used by directives.' - ); - } - const { ref, attributes } = getScope(); - return Object.freeze( { - ref: ref.current, - attributes: deepImmutable( attributes ), - } ); -}; - -export const getScope = () => scopeStack.slice( -1 )[ 0 ]; - -export const setScope = ( scope: Scope ) => { - scopeStack.push( scope ); -}; -export const resetScope = () => { - scopeStack.pop(); -}; - -export const getNamespace = () => namespaceStack.slice( -1 )[ 0 ]; - -export const setNamespace = ( namespace: string ) => { - namespaceStack.push( namespace ); -}; -export const resetNamespace = () => { - namespaceStack.pop(); -}; - // WordPress Directives. const directiveCallbacks: Record< string, DirectiveCallback > = {}; const directivePriorities: Record< string, number > = {}; diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index a43534509bb5a..336c2a97226db 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -3,7 +3,6 @@ */ import { h, cloneElement, render } from 'preact'; import { batch } from '@preact/signals'; -import { deepSignal } from 'deepsignal'; /** * Internal dependencies @@ -12,11 +11,13 @@ import registerDirectives from './directives'; import { init, getRegionRootFragment, initialVdom } from './init'; import { directivePrefix } from './constants'; import { toVdom } from './vdom'; -import { directive, getNamespace } from './hooks'; -import { parseInitialData, populateInitialData } from './store'; +import { directive } from './hooks'; +import { getNamespace } from './namespaces'; +import { parseServerData, populateServerData } from './store'; +import { proxifyState } from './proxies'; export { store, getConfig } from './store'; -export { getContext, getElement } from './hooks'; +export { getContext, getElement } from './scopes'; export { withScope, useWatch, @@ -45,9 +46,9 @@ export const privateApis = ( lock ): any => { h, cloneElement, render, - deepSignal, - parseInitialData, - populateInitialData, + proxifyState, + parseServerData, + populateServerData, batch, }; } @@ -55,7 +56,5 @@ export const privateApis = ( lock ): any => { throw new Error( 'Forbidden access.' ); }; -document.addEventListener( 'DOMContentLoaded', async () => { - registerDirectives(); - await init(); -} ); +registerDirectives(); +init(); diff --git a/packages/interactivity/src/namespaces.ts b/packages/interactivity/src/namespaces.ts new file mode 100644 index 0000000000000..9103f3c76e67b --- /dev/null +++ b/packages/interactivity/src/namespaces.ts @@ -0,0 +1,10 @@ +const namespaceStack: string[] = []; + +export const getNamespace = () => namespaceStack.slice( -1 )[ 0 ]; + +export const setNamespace = ( namespace: string ) => { + namespaceStack.push( namespace ); +}; +export const resetNamespace = () => { + namespaceStack.pop(); +}; diff --git a/packages/interactivity/src/proxies/index.ts b/packages/interactivity/src/proxies/index.ts new file mode 100644 index 0000000000000..d64fb59fa6bcc --- /dev/null +++ b/packages/interactivity/src/proxies/index.ts @@ -0,0 +1,5 @@ +/** + * Internal dependencies + */ +export { proxifyState, peek } from './state'; +export { proxifyStore } from './store'; diff --git a/packages/interactivity/src/proxies/registry.ts b/packages/interactivity/src/proxies/registry.ts new file mode 100644 index 0000000000000..767a3730dbae2 --- /dev/null +++ b/packages/interactivity/src/proxies/registry.ts @@ -0,0 +1,82 @@ +/** + * Proxies for each object. + */ +const objToProxy = new WeakMap< object, object >(); + +/** + * Namespaces for each created proxy. + */ +const proxyToNs = new WeakMap< object, string >(); + +/** + * Object types that can be proxied. + */ +const supported = new Set( [ Object, Array ] ); + +/** + * Returns a proxy to the passed object with the given handlers, assigning the + * specified namespace to it. If a proxy for the passed object was created + * before, that proxy is returned. + * + * @param namespace The namespace that will be associated to this proxy. + * @param obj The object to proxify. + * @param handlers Handlers that the proxy will use. + * + * @throws Error if the object cannot be proxified. Use {@link shouldProxy} to + * check if a proxy can be created for a specific object. + * + * @return The created proxy. + */ +export const createProxy = < T extends object >( + namespace: string, + obj: T, + handlers: ProxyHandler< T > +): T => { + if ( ! shouldProxy( obj ) ) { + throw Error( 'This object cannot be proxified.' ); + } + if ( ! objToProxy.has( obj ) ) { + const proxy = new Proxy( obj, handlers ); + objToProxy.set( obj, proxy ); + proxyToNs.set( proxy, namespace ); + } + return objToProxy.get( obj ) as T; +}; + +/** + * Returns the proxy for the given object. If there is no associated proxy, the + * function returns `undefined`. + * + * @param obj Object from which to know the proxy. + * @return Associated proxy or `undefined`. + */ +export const getProxyFromObject = < T extends object >( obj: T ): T => + objToProxy.get( obj ) as T; + +/** + * Gets the namespace associated with the given proxy. + * + * Proxies have a namespace assigned upon creation. See {@link createProxy}. + * + * @param proxy Proxy. + * @return Namespace. + */ +export const getNamespaceFromProxy = ( proxy: object ): string => + proxyToNs.get( proxy )!; + +/** + * Checks if a given object can be proxied. + * + * @param candidate Object to know whether it can be proxied. + * @return True if the passed instance can be proxied. + */ +export const shouldProxy = ( + candidate: any +): candidate is Object | Array< unknown > => { + if ( typeof candidate !== 'object' || candidate === null ) { + return false; + } + return ( + ! proxyToNs.has( candidate ) && supported.has( candidate.constructor ) + ); +}; diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts new file mode 100644 index 0000000000000..6a3f41c149e13 --- /dev/null +++ b/packages/interactivity/src/proxies/signals.ts @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { + computed, + signal, + batch, + type Signal, + type ReadonlySignal, +} from '@preact/signals'; + +/** + * Internal dependencies + */ +import { getNamespaceFromProxy } from './registry'; +import { getScope } from '../scopes'; +import { setNamespace, resetNamespace } from '../namespaces'; +import { withScope } from '../utils'; + +/** + * Identifier for property computeds not associated to any scope. + */ +const NO_SCOPE = Symbol(); + +/** + * Structure that manages reactivity for a property in a state object. It uses + * signals to keep track of property value or getter modifications. + */ +export class PropSignal { + /** + * Proxy that holds the property this PropSignal is associated with. + */ + private owner: object; + + /** + * Relation of computeds by scope. These computeds are read-only signals + * that depend on whether the property is a value or a getter and, + * therefore, can return different values depending on the scope in which + * the getter is accessed. + */ + private computedsByScope: WeakMap< WeakKey, ReadonlySignal >; + + /** + * Signal with the value assigned to the related property. + */ + private valueSignal?: Signal; + + /** + * Signal with the getter assigned to the related property. + */ + private getterSignal?: Signal< ( () => any ) | undefined >; + + /** + * Structure that manages reactivity for a property in a state object, using + * signals to keep track of property value or getter modifications. + * + * @param owner Proxy that holds the property this instance is associated + * with. + */ + constructor( owner: object ) { + this.owner = owner; + this.computedsByScope = new WeakMap(); + } + + /** + * Changes the internal value. If a getter was set before, it is set to + * `undefined`. + * + * @param value New value. + */ + public setValue( value: unknown ) { + this.update( { value } ); + } + + /** + * Changes the internal getter. If a value was set before, it is set to + * `undefined`. + * + * @param getter New getter. + */ + public setGetter( getter: () => any ) { + this.update( { get: getter } ); + } + + /** + * Returns the computed that holds the result of evaluating the prop in the + * current scope. + * + * These computeds are read-only signals that depend on whether the property + * is a value or a getter and, therefore, can return different values + * depending on the scope in which the getter is accessed. + * + * @return Computed that depends on the scope. + */ + public getComputed(): ReadonlySignal { + const scope = getScope() || NO_SCOPE; + + if ( ! this.valueSignal && ! this.getterSignal ) { + this.update( {} ); + } + + if ( ! this.computedsByScope.has( scope ) ) { + const callback = () => { + const getter = this.getterSignal?.value; + return getter + ? getter.call( this.owner ) + : this.valueSignal?.value; + }; + + setNamespace( getNamespaceFromProxy( this.owner ) ); + this.computedsByScope.set( + scope, + computed( withScope( callback ) ) + ); + resetNamespace(); + } + + return this.computedsByScope.get( scope )!; + } + + /** + * Update the internal signals for the value and the getter of the + * corresponding prop. + * + * @param param0 + * @param param0.get New getter. + * @param param0.value New value. + */ + private update( { get, value }: { get?: () => any; value?: unknown } ) { + if ( ! this.valueSignal ) { + this.valueSignal = signal( value ); + this.getterSignal = signal( get ); + } else if ( + value !== this.valueSignal.peek() || + get !== this.getterSignal!.peek() + ) { + batch( () => { + this.valueSignal!.value = value; + this.getterSignal!.value = get; + } ); + } + } +} diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts new file mode 100644 index 0000000000000..0978fa2ccd026 --- /dev/null +++ b/packages/interactivity/src/proxies/state.ts @@ -0,0 +1,250 @@ +/** + * External dependencies + */ +import { signal, type Signal } from '@preact/signals'; + +/** + * Internal dependencies + */ +import { + createProxy, + getProxyFromObject, + getNamespaceFromProxy, + shouldProxy, +} from './registry'; +import { PropSignal } from './signals'; +import { setNamespace, resetNamespace } from '../namespaces'; + +/** + * Set of built-in symbols. + */ +const wellKnownSymbols = new Set( + Object.getOwnPropertyNames( Symbol ) + .map( ( key ) => Symbol[ key ] ) + .filter( ( value ) => typeof value === 'symbol' ) +); + +/** + * Relates each proxy with a map of {@link PropSignal} instances, representing + * the proxy's accessed properties. + */ +const proxyToProps: WeakMap< + object, + Map< string | symbol, PropSignal > +> = new WeakMap(); + +/** + * Returns the {@link PropSignal | `PropSignal`} instance associated with the + * specified prop in the passed proxy. + * + * The `PropSignal` instance is generated if it doesn't exist yet, using the + * `initial` parameter to initialize the internal signals. + * + * @param proxy Proxy of a state object or array. + * @param key The property key. + * @param initial Initial data for the `PropSignal` instance. + * @return The `PropSignal` instance. + */ +const getPropSignal = ( + proxy: object, + key: string | number | symbol, + initial?: PropertyDescriptor +) => { + if ( ! proxyToProps.has( proxy ) ) { + proxyToProps.set( proxy, new Map() ); + } + key = typeof key === 'number' ? `${ key }` : key; + const props = proxyToProps.get( proxy )!; + if ( ! props.has( key ) ) { + const ns = getNamespaceFromProxy( proxy ); + const prop = new PropSignal( proxy ); + props.set( key, prop ); + if ( initial ) { + const { get, value } = initial; + if ( get ) { + prop.setGetter( get ); + } else { + prop.setValue( + shouldProxy( value ) ? proxifyState( ns, value ) : value + ); + } + } + } + return props.get( key )!; +}; + +/** + * Relates each proxied object (i.e., the original object) with a signal that + * tracks changes in the number of properties. + */ +const objToIterable = new WeakMap< object, Signal< number > >(); + +/** + * When this flag is `true`, it avoids any signal subscription, overriding state + * props' "reactive" behavior. + */ +let peeking = false; + +/** + * Handlers for reactive objects and arrays in the state. + */ +const stateHandlers: ProxyHandler< object > = { + get( target: object, key: string | symbol, receiver: object ): any { + /* + * The property should not be reactive for the following cases: + * 1. While using the `peek` function to read the property. + * 2. The property exists but comes from the Object or Array prototypes. + * 3. The property key is a known symbol. + */ + if ( + peeking || + ( ! target.hasOwnProperty( key ) && key in target ) || + ( typeof key === 'symbol' && wellKnownSymbols.has( key ) ) + ) { + return Reflect.get( target, key, receiver ); + } + + // At this point, the property should be reactive. + const desc = Object.getOwnPropertyDescriptor( target, key ); + const prop = getPropSignal( receiver, key, desc ); + const result = prop.getComputed().value; + + /* + * Check if the property is a synchronous function. If it is, set the + * default namespace. Synchronous functions always run in the proper scope, + * which is set by the Directives component. + */ + if ( typeof result === 'function' ) { + const ns = getNamespaceFromProxy( receiver ); + return ( ...args: unknown[] ) => { + setNamespace( ns ); + try { + return result.call( receiver, ...args ); + } finally { + resetNamespace(); + } + }; + } + + return result; + }, + + set( + target: object, + key: string, + value: unknown, + receiver: object + ): boolean { + setNamespace( getNamespaceFromProxy( receiver ) ); + try { + return Reflect.set( target, key, value, receiver ); + } finally { + resetNamespace(); + } + }, + + defineProperty( + target: object, + key: string, + desc: PropertyDescriptor + ): boolean { + const isNew = ! ( key in target ); + const result = Reflect.defineProperty( target, key, desc ); + + if ( result ) { + const receiver = getProxyFromObject( target ); + const prop = getPropSignal( receiver, key ); + const { get, value } = desc; + if ( get ) { + prop.setGetter( get ); + } else { + const ns = getNamespaceFromProxy( receiver ); + prop.setValue( + shouldProxy( value ) ? proxifyState( ns, value ) : value + ); + } + + if ( isNew && objToIterable.has( target ) ) { + objToIterable.get( target )!.value++; + } + + /* + * Modify the `length` property value only if the related + * `PropSignal` exists, which means that there are subscriptions to + * this property. + */ + if ( + Array.isArray( target ) && + proxyToProps.get( receiver )?.has( 'length' ) + ) { + const length = getPropSignal( receiver, 'length' ); + length.setValue( target.length ); + } + } + + return result; + }, + + deleteProperty( target: object, key: string ): boolean { + const result = Reflect.deleteProperty( target, key ); + + if ( result ) { + const prop = getPropSignal( getProxyFromObject( target ), key ); + prop.setValue( undefined ); + + if ( objToIterable.has( target ) ) { + objToIterable.get( target )!.value++; + } + } + + return result; + }, + + ownKeys( target: object ): ( string | symbol )[] { + if ( ! objToIterable.has( target ) ) { + objToIterable.set( target, signal( 0 ) ); + } + /* + *This subscribes to the signal while preventing the minifier from + * deleting this line in production. + */ + ( objToIterable as any )._ = objToIterable.get( target )!.value; + return Reflect.ownKeys( target ); + }, +}; + +/** + * 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. + * + * @throws Error if the object cannot be proxified. Use {@link shouldProxy} to + * check if a proxy can be created for a specific object. + * + * @return The associated proxy. + */ +export const proxifyState = < T extends object >( + namespace: string, + obj: T +): T => createProxy( namespace, obj, stateHandlers ) as T; + +/** + * Reads the value of the specified property without subscribing to it. + * + * @param obj The object to read the property from. + * @param key The property key. + * @return The property value. + */ +export const peek = < T extends object, K extends keyof T >( + obj: T, + key: K +): T[ K ] => { + peeking = true; + try { + return obj[ key ]; + } finally { + peeking = false; + } +}; diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts new file mode 100644 index 0000000000000..506b8c3b097ba --- /dev/null +++ b/packages/interactivity/src/proxies/store.ts @@ -0,0 +1,79 @@ +/** + * Internal dependencies + */ +import { createProxy, getNamespaceFromProxy, shouldProxy } from './registry'; +/** + * External dependencies + */ +import { setNamespace, resetNamespace } from '../namespaces'; +import { withScope, isPlainObject } from '../utils'; + +/** + * Identifies the store proxies handling the root objects of each store. + */ +const storeRoots = new WeakSet(); + +/** + * Handlers for store proxies. + */ +const storeHandlers: ProxyHandler< object > = { + get: ( target: any, key: string | symbol, receiver: any ) => { + const result = Reflect.get( target, key ); + const ns = getNamespaceFromProxy( receiver ); + + /* + * Check if the proxy is the store root and no key with that name exist. In + * that case, return an empty object for the requested key. + */ + if ( typeof result === 'undefined' && storeRoots.has( receiver ) ) { + const obj = {}; + Reflect.set( target, key, obj ); + return proxifyStore( ns, obj, false ); + } + + /* + * Check if the property is a function. If it is, add the store + * namespace to the stack and wrap the function with the current scope. + * The `withScope` util handles both synchronous functions and generator + * functions. + */ + if ( typeof result === 'function' ) { + setNamespace( ns ); + const scoped = withScope( result ); + resetNamespace(); + return scoped; + } + + // Check if the property is an object. If it is, proxyify it. + if ( isPlainObject( result ) && shouldProxy( result ) ) { + return proxifyStore( ns, result, false ); + } + + return result; + }, +}; + +/** + * Returns the proxy associated with the given store 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 isRoot Whether the passed object is the store root object. + * @throws Error if the object cannot be proxified. Use {@link shouldProxy} to + * check if a proxy can be created for a specific object. + * + * @return The associated proxy. + */ +export const proxifyStore = < T extends object >( + namespace: string, + obj: T, + isRoot = true +): T => { + const proxy = createProxy( namespace, obj, storeHandlers ); + if ( proxy && isRoot ) { + storeRoots.add( proxy ); + } + return proxy as T; +}; diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts new file mode 100644 index 0000000000000..92500189fc830 --- /dev/null +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -0,0 +1,1269 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +/** + * External dependencies + */ +import { effect } from '@preact/signals'; +/** + * Internal dependencies + */ +import { proxifyState, peek } from '../'; +import { setScope, resetScope, getContext, getElement } from '../../scopes'; +import { setNamespace, resetNamespace } from '../../namespaces'; + +type State = { + a?: number; + nested: { b?: number }; + array: ( number | State[ 'nested' ] )[]; +}; + +const withScopeAndNs = ( scope, ns, callback ) => () => { + setScope( scope ); + setNamespace( ns ); + try { + return callback(); + } finally { + resetNamespace(); + resetScope(); + } +}; + +describe( 'Interactivity API', () => { + describe( 'state proxy', () => { + let nested = { b: 2 }; + let array = [ 3, nested ]; + let raw: State = { a: 1, nested, array }; + let state = proxifyState( 'test', raw ); + + const window = globalThis as any; + + beforeEach( () => { + nested = { b: 2 }; + array = [ 3, nested ]; + raw = { a: 1, nested, array }; + state = proxifyState( 'test', raw ); + } ); + + describe( 'get', () => { + it( 'should return plain objects/arrays', () => { + expect( state.nested ).toEqual( { b: 2 } ); + expect( state.array ).toEqual( [ 3, { b: 2 } ] ); + expect( state.array[ 1 ] ).toEqual( { b: 2 } ); + } ); + + it( 'should return plain primitives', () => { + expect( state.a ).toBe( 1 ); + expect( state.nested.b ).toBe( 2 ); + expect( state.array[ 0 ] ).toBe( 3 ); + expect( + typeof state.array[ 1 ] === 'object' && state.array[ 1 ].b + ).toBe( 2 ); + expect( state.array.length ).toBe( 2 ); + } ); + + it( 'should support reading from getters', () => { + const state = proxifyState( 'test', { + counter: 1, + get double() { + return state.counter * 2; + }, + } ); + expect( state.double ).toBe( 2 ); + state.counter = 2; + expect( state.double ).toBe( 4 ); + } ); + + it( 'should support getters returning other parts of the state', () => { + const state = proxifyState( 'test', { + switch: 'a', + a: { data: 'a' }, + b: { data: 'b' }, + get aOrB() { + return state.switch === 'a' ? state.a : state.b; + }, + } ); + expect( state.aOrB.data ).toBe( 'a' ); + state.switch = 'b'; + expect( state.aOrB.data ).toBe( 'b' ); + } ); + + it( 'should support getters using ownKeys traps', () => { + const state = proxifyState( 'test', { + x: { + a: 1, + b: 2, + }, + get y() { + return Object.values( state.x ); + }, + } ); + + expect( state.y ).toEqual( [ 1, 2 ] ); + } ); + + it( 'should support getters accessing the scope', () => { + const state = proxifyState( 'test', { + get y() { + const ctx = getContext< { value: string } >(); + return ctx.value; + }, + } ); + + const scope = { context: { test: { value: 'from context' } } }; + try { + setScope( scope as any ); + expect( state.y ).toBe( 'from context' ); + } finally { + resetScope(); + } + } ); + + it( 'should use its namespace by default inside getters', () => { + const state = proxifyState( 'test/right', { + get value() { + const ctx = getContext< { value: string } >(); + return ctx.value; + }, + } ); + + const scope = { + context: { + 'test/right': { value: 'OK' }, + 'test/other': { value: 'Wrong' }, + }, + }; + + try { + setScope( scope as any ); + setNamespace( 'test/other' ); + expect( state.value ).toBe( 'OK' ); + } finally { + resetNamespace(); + resetScope(); + } + } ); + + it( 'should work with normal functions', () => { + const state = proxifyState( 'test', { + value: 1, + isBigger: ( newValue: number ): boolean => + state.value < newValue, + sum( newValue: number ): number { + return state.value + newValue; + }, + replace: ( newValue: number ): void => { + state.value = newValue; + }, + } ); + expect( state.isBigger( 2 ) ).toBe( true ); + expect( state.sum( 2 ) ).toBe( 3 ); + expect( state.value ).toBe( 1 ); + state.replace( 2 ); + expect( state.value ).toBe( 2 ); + } ); + + it( 'should work with normal functions accessing the scope', () => { + const state = proxifyState( 'test', { + sumContextValue( newValue: number ): number { + const ctx = getContext< { value: number } >(); + return ctx.value + newValue; + }, + } ); + + const scope = { context: { test: { value: 1 } } }; + try { + setScope( scope as any ); + expect( state.sumContextValue( 2 ) ).toBe( 3 ); + } finally { + resetScope(); + } + } ); + + it( 'should allow using `this` inside functions', () => { + const state = proxifyState( 'test', { + value: 1, + sum( newValue: number ): number { + return this.value + newValue; + }, + } ); + expect( state.sum( 2 ) ).toBe( 3 ); + } ); + } ); + + describe( 'set', () => { + it( 'should update like plain objects/arrays', () => { + expect( state.a ).toBe( 1 ); + expect( state.nested.b ).toBe( 2 ); + state.a = 2; + state.nested.b = 3; + expect( state.a ).toBe( 2 ); + expect( state.nested.b ).toBe( 3 ); + } ); + + it( 'should support setting values with setters', () => { + const state = proxifyState( 'test', { + counter: 1, + get double() { + return state.counter * 2; + }, + set double( val ) { + state.counter = val / 2; + }, + } ); + expect( state.counter ).toBe( 1 ); + state.double = 4; + expect( state.counter ).toBe( 2 ); + } ); + + it( 'should update array length', () => { + expect( state.array.length ).toBe( 2 ); + state.array.push( 4 ); + expect( state.array.length ).toBe( 3 ); + state.array.splice( 1, 2 ); + expect( state.array.length ).toBe( 1 ); + } ); + + it( 'should support setting getters on the fly', () => { + const state = proxifyState< { + counter: number; + double?: number; + } >( 'test', { + counter: 1, + } ); + Object.defineProperty( state, 'double', { + get() { + return state.counter * 2; + }, + } ); + expect( state.double ).toBe( 2 ); + state.counter = 2; + expect( state.double ).toBe( 4 ); + } ); + + it( 'should support getter modification', () => { + const state = proxifyState< { + counter: number; + double: number; + } >( 'test', { + counter: 1, + get double() { + return state.counter * 2; + }, + } ); + + const scope = { + context: { test: { counter: 2 } }, + }; + + expect( state.double ).toBe( 2 ); + + Object.defineProperty( state, 'double', { + get() { + const ctx = getContext< { counter: number } >(); + return ctx.counter * 2; + }, + } ); + + try { + setScope( scope as any ); + expect( state.double ).toBe( 4 ); + } finally { + resetScope(); + } + } ); + + it( 'should copy object like plain JavaScript', () => { + const state = proxifyState< { + a?: { id: number; nested: { id: number } }; + b: { id: number; nested: { id: number } }; + } >( 'test', { + b: { id: 1, nested: { id: 1 } }, + } ); + + state.a = state.b; + + expect( state.a.id ).toBe( 1 ); + expect( state.b.id ).toBe( 1 ); + expect( state.a.nested.id ).toBe( 1 ); + expect( state.b.nested.id ).toBe( 1 ); + + state.a.id = 2; + state.a.nested.id = 2; + expect( state.a.id ).toBe( 2 ); + expect( state.b.id ).toBe( 2 ); + expect( state.a.nested.id ).toBe( 2 ); + expect( state.b.nested.id ).toBe( 2 ); + + state.b.id = 3; + state.b.nested.id = 3; + expect( state.b.id ).toBe( 3 ); + expect( state.a.id ).toBe( 3 ); + expect( state.a.nested.id ).toBe( 3 ); + expect( state.b.nested.id ).toBe( 3 ); + + state.a.id = 4; + state.a.nested.id = 4; + expect( state.a.id ).toBe( 4 ); + expect( state.b.id ).toBe( 4 ); + expect( state.a.nested.id ).toBe( 4 ); + expect( state.b.nested.id ).toBe( 4 ); + } ); + + it( 'should be able to reset values with Object.assign', () => { + const initialNested = { ...nested }; + const initialState = { ...raw, nested: initialNested }; + state.a = 2; + state.nested.b = 3; + Object.assign( state, initialState ); + expect( state.a ).toBe( 1 ); + expect( state.nested.b ).toBe( 2 ); + } ); + + it( 'should keep assigned object references internally', () => { + const obj = {}; + state.nested = obj; + expect( raw.nested ).toBe( obj ); + } ); + + it( 'should keep object references across namespaces', () => { + const raw1 = { obj: {} }; + const raw2 = { obj: {} }; + const state1 = proxifyState( 'test-1', raw1 ); + const state2 = proxifyState( 'test-2', raw2 ); + state2.obj = state1.obj; + expect( state2.obj ).toBe( state1.obj ); + expect( raw2.obj ).toBe( state1.obj ); + } ); + + it( 'should use its namespace by default inside setters', () => { + const state = proxifyState( 'test/right', { + set counter( val: number ) { + const ctx = getContext< { counter: number } >(); + ctx.counter = val; + }, + } ); + + const scope = { + context: { + 'test/other': { counter: 0 }, + 'test/right': { counter: 0 }, + }, + }; + + try { + setScope( scope as any ); + setNamespace( 'test/other' ); + state.counter = 4; + expect( scope.context[ 'test/right' ].counter ).toBe( 4 ); + } finally { + resetNamespace(); + resetScope(); + } + } ); + } ); + + describe( 'computations', () => { + it( 'should subscribe to values mutated with setters', () => { + const state = proxifyState( 'test', { + counter: 1, + get double() { + return state.counter * 2; + }, + set double( val ) { + state.counter = val / 2; + }, + } ); + let counter = 0; + let double = 0; + + effect( () => { + counter = state.counter; + double = state.double; + } ); + + expect( counter ).toBe( 1 ); + expect( double ).toBe( 2 ); + state.double = 4; + expect( counter ).toBe( 2 ); + expect( double ).toBe( 4 ); + } ); + + it( 'should subscribe to changes when an item is removed from the array', () => { + const state = proxifyState( 'test', [ 0, 0, 0 ] ); + let sum = 0; + + effect( () => { + sum = 0; + sum = state.reduce( ( sum ) => sum + 1, 0 ); + } ); + + expect( sum ).toBe( 3 ); + state.splice( 2, 1 ); + expect( sum ).toBe( 2 ); + } ); + + it( 'should subscribe to changes to for..in loops', () => { + const raw: Record< string, number > = { a: 0, b: 0 }; + const state = proxifyState( 'test', raw ); + let sum = 0; + + effect( () => { + sum = 0; + for ( const _ in state ) { + sum += 1; + } + } ); + + expect( sum ).toBe( 2 ); + + state.c = 0; + expect( sum ).toBe( 3 ); + + delete state.c; + expect( sum ).toBe( 2 ); + + state.c = 0; + expect( sum ).toBe( 3 ); + } ); + + it( 'should subscribe to changes for Object.getOwnPropertyNames()', () => { + const raw: Record< string, number > = { a: 1, b: 2 }; + const state = proxifyState( 'test', raw ); + let sum = 0; + + effect( () => { + sum = 0; + const keys = Object.getOwnPropertyNames( state ); + for ( const _ of keys ) { + sum += 1; + } + } ); + + expect( sum ).toBe( 2 ); + + state.c = 0; + expect( sum ).toBe( 3 ); + + delete state.a; + expect( sum ).toBe( 2 ); + } ); + + it( 'should subscribe to changes to Object.keys/values/entries()', () => { + const raw: Record< string, number > = { a: 1, b: 2 }; + const state = proxifyState( 'test', raw ); + let keys = 0; + let values = 0; + let entries = 0; + + effect( () => { + keys = 0; + Object.keys( state ).forEach( () => ( keys += 1 ) ); + } ); + + effect( () => { + values = 0; + Object.values( state ).forEach( () => ( values += 1 ) ); + } ); + + effect( () => { + entries = 0; + Object.entries( state ).forEach( () => ( entries += 1 ) ); + } ); + + expect( keys ).toBe( 2 ); + expect( values ).toBe( 2 ); + expect( entries ).toBe( 2 ); + + state.c = 0; + expect( keys ).toBe( 3 ); + expect( values ).toBe( 3 ); + expect( entries ).toBe( 3 ); + + delete state.a; + expect( keys ).toBe( 2 ); + expect( values ).toBe( 2 ); + expect( entries ).toBe( 2 ); + } ); + + it( 'should subscribe to changes to for..of loops', () => { + const state = proxifyState( 'test', [ 0, 0 ] ); + let sum = 0; + + effect( () => { + sum = 0; + for ( const _ of state ) { + sum += 1; + } + } ); + + expect( sum ).toBe( 2 ); + + state.push( 0 ); + expect( sum ).toBe( 3 ); + + state.splice( 0, 1 ); + expect( sum ).toBe( 2 ); + } ); + + it( 'should subscribe to implicit changes in length', () => { + const state = proxifyState( 'test', [ 'foo', 'bar' ] ); + let x = ''; + + effect( () => { + x = state.join( ' ' ); + } ); + + expect( x ).toBe( 'foo bar' ); + + state.push( 'baz' ); + expect( x ).toBe( 'foo bar baz' ); + + state.splice( 0, 1 ); + expect( x ).toBe( 'bar baz' ); + } ); + + it( 'should subscribe to changes when deleting properties', () => { + let x, y; + + effect( () => { + x = state.a; + } ); + + effect( () => { + y = state.nested.b; + } ); + + expect( x ).toBe( 1 ); + delete state.a; + expect( x ).toBe( undefined ); + + expect( y ).toBe( 2 ); + delete state.nested.b; + expect( y ).toBe( undefined ); + } ); + + it( 'should subscribe to changes when mutating objects', () => { + let x, y; + + const state = proxifyState< { + a?: { id: number; nested: { id: number } }; + b: { id: number; nested: { id: number } }[]; + } >( 'test', { + b: [ + { id: 1, nested: { id: 1 } }, + { id: 2, nested: { id: 2 } }, + ], + } ); + + effect( () => { + x = state.a?.id; + } ); + + effect( () => { + y = state.a?.nested.id; + } ); + + expect( x ).toBe( undefined ); + expect( y ).toBe( undefined ); + + state.a = state.b[ 0 ]; + + expect( x ).toBe( 1 ); + expect( y ).toBe( 1 ); + + state.a = state.b[ 1 ]; + expect( x ).toBe( 2 ); + expect( y ).toBe( 2 ); + + state.a = undefined; + expect( x ).toBe( undefined ); + expect( y ).toBe( undefined ); + + state.a = state.b[ 1 ]; + expect( x ).toBe( 2 ); + expect( y ).toBe( 2 ); + } ); + + it( 'should trigger effects after mutations happen', () => { + let x; + effect( () => { + x = state.a; + } ); + expect( x ).toBe( 1 ); + state.a = 11; + expect( x ).toBe( 11 ); + } ); + + it( 'should subscribe corretcly from getters', () => { + let x; + const state = proxifyState( 'test', { + counter: 1, + get double() { + return state.counter * 2; + }, + } ); + effect( () => ( x = state.double ) ); + expect( x ).toBe( 2 ); + state.counter = 2; + expect( x ).toBe( 4 ); + } ); + + it( 'should subscribe corretcly from getters returning other parts of the state', () => { + let data; + const state = proxifyState( 'test', { + switch: 'a', + a: { data: 'a' }, + b: { data: 'b' }, + get aOrB() { + return state.switch === 'a' ? state.a : state.b; + }, + } ); + effect( () => ( data = state.aOrB.data ) ); + expect( data ).toBe( 'a' ); + state.switch = 'b'; + expect( data ).toBe( 'b' ); + } ); + + it( 'should subscribe to changes', () => { + const spy1 = jest.fn( () => state.a ); + const spy2 = jest.fn( () => state.nested ); + const spy3 = jest.fn( () => state.nested.b ); + const spy4 = jest.fn( () => state.array[ 0 ] ); + const spy5 = jest.fn( + () => + typeof state.array[ 1 ] === 'object' && + state.array[ 1 ].b + ); + + effect( spy1 ); + effect( spy2 ); + effect( spy3 ); + effect( spy4 ); + effect( spy5 ); + + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy3 ).toHaveBeenCalledTimes( 1 ); + expect( spy4 ).toHaveBeenCalledTimes( 1 ); + expect( spy5 ).toHaveBeenCalledTimes( 1 ); + + state.a = 11; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy3 ).toHaveBeenCalledTimes( 1 ); + expect( spy4 ).toHaveBeenCalledTimes( 1 ); + expect( spy5 ).toHaveBeenCalledTimes( 1 ); + + state.nested.b = 22; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy3 ).toHaveBeenCalledTimes( 2 ); + expect( spy4 ).toHaveBeenCalledTimes( 1 ); + expect( spy5 ).toHaveBeenCalledTimes( 2 ); // nested also exists array[1] + + state.nested = { b: 222 }; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 1 ); + expect( spy5 ).toHaveBeenCalledTimes( 2 ); // now state.nested has a different reference + + state.array[ 0 ] = 33; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 2 ); + + if ( typeof state.array[ 1 ] === 'object' ) { + state.array[ 1 ].b = 2222; + } + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 3 ); + + state.array[ 1 ] = { b: 22222 }; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); + + state.array.push( 4 ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); + + state.array[ 3 ] = 5; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); + + state.array = [ 333, { b: 222222 } ]; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 3 ); + expect( spy5 ).toHaveBeenCalledTimes( 5 ); + } ); + + it( 'should subscribe to array length', () => { + const array = [ 1 ]; + const state = proxifyState( 'test', { array } ); + const spy1 = jest.fn( () => state.array.length ); + const spy2 = jest.fn( () => + state.array.map( ( i: number ) => i ) + ); + + effect( spy1 ); + effect( spy2 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + + state.array.push( 2 ); + expect( state.array.length ).toBe( 2 ); + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + + state.array[ 2 ] = 3; + expect( state.array.length ).toBe( 3 ); + expect( spy1 ).toHaveBeenCalledTimes( 3 ); + expect( spy2 ).toHaveBeenCalledTimes( 3 ); + + state.array = state.array.filter( ( i: number ) => i <= 2 ); + expect( state.array.length ).toBe( 2 ); + expect( spy1 ).toHaveBeenCalledTimes( 4 ); + expect( spy2 ).toHaveBeenCalledTimes( 4 ); + } ); + + it( 'should be able to reset values with Object.assign and still react to changes', () => { + const initialNested = { ...nested }; + const initialState = { ...raw, nested: initialNested }; + let a, b; + + effect( () => { + a = state.a; + } ); + effect( () => { + b = state.nested.b; + } ); + + state.a = 2; + state.nested.b = 3; + + expect( a ).toBe( 2 ); + expect( b ).toBe( 3 ); + + Object.assign( state, initialState ); + + expect( a ).toBe( 1 ); + expect( b ).toBe( 2 ); + } ); + + it( 'should keep subscribed to properties that become getters', () => { + const state = proxifyState( 'test', { + number: 1, + } ); + + let number = 0; + + effect( () => { + number = state.number; + } ); + + expect( number ).toBe( 1 ); + state.number = 2; + expect( number ).toBe( 2 ); + Object.defineProperty( state, 'number', { + get: () => 3, + configurable: true, + } ); + expect( number ).toBe( 3 ); + } ); + + it( 'should keep subscribed to modified getters', () => { + const state = proxifyState< { + counter: number; + double: number; + } >( 'test', { + counter: 1, + get double() { + return state.counter * 2; + }, + } ); + + const scope = { + context: { test: { counter: 2 } }, + }; + + let double = 0; + + effect( + withScopeAndNs( scope, 'test', () => { + double = state.double; + } ) + ); + + expect( double ).toBe( 2 ); + + Object.defineProperty( state, 'double', { + get() { + const ctx = getContext< { counter: number } >(); + return ctx.counter * 2; + }, + } ); + + expect( double ).toBe( 4 ); + } ); + + it( 'should react to changes in props inside getters', () => { + const state = proxifyState( 'test', { + number: 1, + otherNumber: 3, + } ); + + let number = 0; + + effect( () => { + number = state.number; + } ); + + expect( number ).toBe( 1 ); + state.number = 2; + expect( number ).toBe( 2 ); + Object.defineProperty( state, 'number', { + get: () => state.otherNumber, + configurable: true, + } ); + expect( number ).toBe( 3 ); + state.otherNumber = 4; + expect( number ).toBe( 4 ); + } ); + + it( 'should react to changes in props inside getters if they become getters', () => { + const state = proxifyState( 'test', { + number: 1, + otherNumber: 3, + } ); + + let number = 0; + + effect( () => { + number = state.number; + } ); + + expect( number ).toBe( 1 ); + state.number = 2; + expect( number ).toBe( 2 ); + Object.defineProperty( state, 'number', { + get: () => state.otherNumber, + configurable: true, + } ); + expect( number ).toBe( 3 ); + state.otherNumber = 4; + expect( number ).toBe( 4 ); + Object.defineProperty( state, 'otherNumber', { + get: () => 5, + configurable: true, + } ); + expect( number ).toBe( 5 ); + } ); + + it( 'should allow getters to use `this`', () => { + const state = proxifyState( 'test', { + number: 1, + otherNumber: 3, + } ); + + let number = 0; + + effect( () => { + number = state.number; + } ); + + expect( number ).toBe( 1 ); + state.number = 2; + expect( number ).toBe( 2 ); + Object.defineProperty( state, 'number', { + get() { + return this.otherNumber; + }, + configurable: true, + } ); + expect( number ).toBe( 3 ); + state.otherNumber = 4; + expect( number ).toBe( 4 ); + } ); + + it( 'should support different scopes for the same getter', () => { + const state = proxifyState( 'test', { + number: 1, + get numWithTag() { + let tag = 'No scope'; + try { + tag = getContext< any >().tag; + } catch ( e ) {} + return `${ tag }: ${ this.number }`; + }, + } ); + + const scopeA = { + context: { test: { tag: 'A' } }, + }; + const scopeB = { + context: { test: { tag: 'B' } }, + }; + + let resultA = ''; + let resultB = ''; + let resultNoScope = ''; + + effect( + withScopeAndNs( scopeA, 'test', () => { + resultA = state.numWithTag; + } ) + ); + effect( + withScopeAndNs( scopeB, 'test', () => { + resultB = state.numWithTag; + } ) + ); + effect( () => { + resultNoScope = state.numWithTag; + } ); + + expect( resultA ).toBe( 'A: 1' ); + expect( resultB ).toBe( 'B: 1' ); + expect( resultNoScope ).toBe( 'No scope: 1' ); + state.number = 2; + expect( resultA ).toBe( 'A: 2' ); + expect( resultB ).toBe( 'B: 2' ); + expect( resultNoScope ).toBe( 'No scope: 2' ); + } ); + + it( 'should throw an error in getters that require a scope', () => { + const state = proxifyState( 'test', { + number: 1, + get sumValueFromContext() { + const ctx = getContext(); + return ctx + ? this.number + ( ctx as any ).value + : this.number; + }, + get sumValueFromElement() { + const element = getElement(); + return element + ? this.number + element.attributes.value + : this.number; + }, + } ); + + expect( () => state.sumValueFromContext ).toThrow(); + expect( () => state.sumValueFromElement ).toThrow(); + } ); + + it( 'should react to changes in props inside functions', () => { + const state = proxifyState( 'test', { + number: 1, + otherNumber: 3, + sum( value: number ) { + return state.number + state.otherNumber + value; + }, + } ); + + let result = 0; + + effect( () => { + result = state.sum( 2 ); + } ); + + expect( result ).toBe( 6 ); + state.number = 2; + expect( result ).toBe( 7 ); + state.otherNumber = 4; + expect( result ).toBe( 8 ); + } ); + } ); + + describe( 'peek', () => { + it( 'should return correct values when using peek()', () => { + expect( peek( state, 'a' ) ).toBe( 1 ); + expect( peek( state.nested, 'b' ) ).toBe( 2 ); + expect( peek( state.array, 0 ) ).toBe( 3 ); + const nested = peek( state, 'array' )[ 1 ]; + expect( typeof nested === 'object' && nested.b ).toBe( 2 ); + expect( peek( state.array, 'length' ) ).toBe( 2 ); + } ); + + it( 'should not subscribe to changes when peeking', () => { + const spy1 = jest.fn( () => peek( state, 'a' ) ); + const spy2 = jest.fn( () => peek( state, 'nested' ) ); + const spy3 = jest.fn( () => peek( state, 'nested' ).b ); + const spy4 = jest.fn( () => peek( state, 'array' )[ 0 ] ); + const spy5 = jest.fn( () => { + const nested = peek( state, 'array' )[ 1 ]; + return typeof nested === 'object' && nested.b; + } ); + const spy6 = jest.fn( () => peek( state, 'array' ).length ); + + effect( spy1 ); + effect( spy2 ); + effect( spy3 ); + effect( spy4 ); + effect( spy5 ); + effect( spy6 ); + + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy3 ).toHaveBeenCalledTimes( 1 ); + expect( spy4 ).toHaveBeenCalledTimes( 1 ); + expect( spy5 ).toHaveBeenCalledTimes( 1 ); + expect( spy6 ).toHaveBeenCalledTimes( 1 ); + + state.a = 11; + state.nested.b = 22; + state.nested = { b: 222 }; + state.array[ 0 ] = 33; + if ( typeof state.array[ 1 ] === 'object' ) { + state.array[ 1 ].b = 2222; + } + state.array.push( 4 ); + + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy3 ).toHaveBeenCalledTimes( 1 ); + expect( spy4 ).toHaveBeenCalledTimes( 1 ); + expect( spy5 ).toHaveBeenCalledTimes( 1 ); + expect( spy6 ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should subscribe to some changes but not other when peeking inside an object', () => { + const spy1 = jest.fn( () => peek( state.nested, 'b' ) ); + effect( spy1 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + state.nested.b = 22; + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + state.nested = { b: 222 }; + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + state.nested.b = 2222; + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should support returning peek from getters', () => { + const state = proxifyState( 'test', { + counter: 1, + get double() { + return state.counter * 2; + }, + } ); + expect( peek( state, 'double' ) ).toBe( 2 ); + state.counter = 2; + expect( peek( state, 'double' ) ).toBe( 4 ); + } ); + + it( 'should support peeking getters accessing the scope', () => { + const state = proxifyState( 'test', { + get double() { + const { counter } = getContext< { counter: number } >(); + return counter * 2; + }, + } ); + + const context = proxifyState( 'test', { counter: 1 } ); + const scope = { context: { test: context } }; + const peekStateDouble = withScopeAndNs( scope, 'test', () => + peek( state, 'double' ) + ); + + const spy = jest.fn( peekStateDouble ); + effect( spy ); + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( peekStateDouble() ).toBe( 2 ); + + context.counter = 2; + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( peekStateDouble() ).toBe( 4 ); + } ); + + it( 'should support peeking getters accessing other namespaces', () => { + const state2 = proxifyState( 'test2', { + get counter() { + const { counter } = getContext< { counter: number } >(); + return counter; + }, + } ); + const context2 = proxifyState( 'test-2', { counter: 1 } ); + + const state1 = proxifyState( 'test1', { + get double() { + return state2.counter * 2; + }, + } ); + + const peekStateDouble = withScopeAndNs( + { context: { test2: context2 } }, + 'test2', + () => peek( state1, 'double' ) + ); + + const spy = jest.fn( peekStateDouble ); + effect( spy ); + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( peekStateDouble() ).toBe( 2 ); + + context2.counter = 2; + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( peekStateDouble() ).toBe( 4 ); + } ); + } ); + + describe( 'refs', () => { + it( 'should preserve object references', () => { + expect( state.nested ).toBe( state.array[ 1 ] ); + + state.nested.b = 22; + + expect( state.nested ).toBe( state.array[ 1 ] ); + expect( state.nested.b ).toBe( 22 ); + expect( + typeof state.array[ 1 ] === 'object' && state.array[ 1 ].b + ).toBe( 22 ); + + state.nested = { b: 222 }; + + expect( state.nested ).not.toBe( state.array[ 1 ] ); + expect( state.nested.b ).toBe( 222 ); + expect( + typeof state.array[ 1 ] === 'object' && state.array[ 1 ].b + ).toBe( 22 ); + } ); + + it( 'should return the same proxy if initialized more than once', () => { + const raw = {}; + const state1 = proxifyState( 'test', raw ); + const state2 = proxifyState( 'test', raw ); + expect( state1 ).toBe( state2 ); + } ); + + it( 'should throw when trying to re-proxify a state object', () => { + const state = proxifyState( 'test', {} ); + expect( () => proxifyState( 'test', state ) ).toThrow(); + } ); + } ); + + describe( 'unsupported data structures', () => { + it( 'should throw when trying to proxify a class instance', () => { + class MyClass {} + const obj = new MyClass(); + expect( () => proxifyState( 'test', obj ) ).toThrow(); + } ); + + it( 'should not wrap a class instance', () => { + class MyClass {} + const obj = new MyClass(); + const state = proxifyState( 'test', { obj } ); + expect( state.obj ).toBe( obj ); + } ); + + it( 'should not wrap built-ins in proxies', () => { + window.MyClass = class MyClass {}; + const obj = new window.MyClass(); + const state = proxifyState( 'test', { obj } ); + expect( state.obj ).toBe( obj ); + } ); + + it( 'should not wrap elements in proxies', () => { + const el = window.document.createElement( 'div' ); + const state = proxifyState( 'test', { el } ); + expect( state.el ).toBe( el ); + } ); + + it( 'should wrap global objects', () => { + window.obj = { b: 2 }; + const state = proxifyState( 'test', window.obj ); + expect( state ).not.toBe( window.obj ); + expect( state ).toStrictEqual( { b: 2 } ); + } ); + + it( 'should not wrap dates', () => { + const date = new Date(); + const state = proxifyState( 'test', { date } ); + expect( state.date ).toBe( date ); + } ); + + it( 'should not wrap regular expressions', () => { + const regex = new RegExp( '' ); + const state = proxifyState( 'test', { regex } ); + expect( state.regex ).toBe( regex ); + } ); + + it( 'should not wrap Map', () => { + const map = new Map(); + const state = proxifyState( 'test', { map } ); + expect( state.map ).toBe( map ); + } ); + + it( 'should not wrap Set', () => { + const set = new Set(); + const state = proxifyState( 'test', { set } ); + expect( state.set ).toBe( set ); + } ); + } ); + + describe( 'symbols', () => { + it( 'should observe symbols', () => { + const key = Symbol( 'key' ); + let x; + const store = proxifyState< { [ key: symbol ]: any } >( + 'test', + {} + ); + effect( () => ( x = store[ key ] ) ); + + expect( store[ key ] ).toBe( undefined ); + expect( x ).toBe( undefined ); + + store[ key ] = true; + + expect( store[ key ] ).toBe( true ); + expect( x ).toBe( true ); + } ); + + it( 'should not observe well-known symbols', () => { + const key = Symbol.isConcatSpreadable; + let x; + const state = proxifyState< { [ key: symbol ]: any } >( + 'test', + {} + ); + effect( () => ( x = state[ key ] ) ); + + expect( state[ key ] ).toBe( undefined ); + expect( x ).toBe( undefined ); + + state[ key ] = true; + expect( state[ key ] ).toBe( true ); + expect( x ).toBe( undefined ); + } ); + } ); + } ); +} ); diff --git a/packages/interactivity/src/proxies/test/store-proxy.ts b/packages/interactivity/src/proxies/test/store-proxy.ts new file mode 100644 index 0000000000000..225621072929d --- /dev/null +++ b/packages/interactivity/src/proxies/test/store-proxy.ts @@ -0,0 +1,123 @@ +/** + * Internal dependencies + */ +import { proxifyStore, proxifyState } from '../'; +import { setScope, resetScope, getContext } from '../../scopes'; +import { setNamespace, resetNamespace } from '../../namespaces'; + +describe( 'Interactivity API', () => { + describe( 'store proxy', () => { + describe( 'get', () => { + it( 'should initialize properties at the top level if they do not exist', () => { + const store = proxifyStore< any >( 'test', {} ); + expect( store.state.props ).toBeUndefined(); + expect( store.state ).toEqual( {} ); + } ); + + it( 'should wrap sync functions with the store namespace and current scope', () => { + let result = ''; + + const syncFunc = () => { + const ctx = getContext< { value: string } >(); + result = ctx.value; + }; + + const storeTest = proxifyStore( 'test', { + callbacks: { + syncFunc, + nested: { syncFunc }, + }, + } ); + + const scope = { + context: { + test: { value: 'test' }, + }, + }; + + setNamespace( 'other-namespace' ); + setScope( scope as any ); + + storeTest.callbacks.syncFunc(); + expect( result ).toBe( 'test' ); + storeTest.callbacks.nested.syncFunc(); + expect( result ).toBe( 'test' ); + + resetScope(); + resetNamespace(); + } ); + + it( 'should wrap generators into async functions', async () => { + const asyncFunc = function* () { + const data = yield Promise.resolve( 'data' ); + const ctx = getContext< { value: string } >(); + return `${ data } from ${ ctx.value }`; + }; + + const storeTest = proxifyStore( 'test', { + callbacks: { asyncFunc, nested: { asyncFunc } }, + } ); + + const scope = { + context: { + test: { value: 'test' }, + }, + }; + + setNamespace( 'other-namespace' ); + setScope( scope as any ); + const promise1 = storeTest.callbacks.asyncFunc(); + const promise2 = storeTest.callbacks.nested.asyncFunc(); + resetScope(); + resetNamespace(); + + expect( await promise1 ).toBe( 'data from test' ); + expect( await promise2 ).toBe( 'data from test' ); + } ); + + it( 'should allow async functions to call functions from other stores', async () => { + const asyncFunc = function* () { + const data = yield Promise.resolve( 'data' ); + const ctx = getContext< { value: string } >(); + return `${ data } from ${ ctx.value }`; + }; + + const storeTest1 = proxifyStore( 'test1', { + callbacks: { asyncFunc }, + } ); + + const storeTest2 = proxifyStore( 'test2', { + callbacks: { + *asyncFunc() { + const result = + yield storeTest1.callbacks.asyncFunc(); + return result; + }, + }, + } ); + + const scope = { + context: { + test1: { value: 'test1' }, + test2: { value: 'test2' }, + }, + }; + + setNamespace( 'other-namespace' ); + setScope( scope as any ); + const promise = storeTest2.callbacks.asyncFunc(); + resetScope(); + resetNamespace(); + + expect( await promise ).toBe( 'data from test1' ); + } ); + + it( 'should not wrap other proxified objects with a store proxy', () => { + const state = proxifyState( 'test', {} ); + const store = proxifyStore( 'test', { state } ); + + expect( store.state ).toBe( state ); + } ); + } ); + } ); +} ); diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts new file mode 100644 index 0000000000000..2e78755ec4bbe --- /dev/null +++ b/packages/interactivity/src/scopes.ts @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import type { h as createElement, RefObject } from 'preact'; + +/** + * Internal dependencies + */ +import { getNamespace } from './namespaces'; +import type { Evaluate } from './hooks'; + +export interface Scope { + evaluate: Evaluate; + context: object; + ref: RefObject< HTMLElement >; + attributes: createElement.JSX.HTMLAttributes; +} + +// Store stacks for the current scope and the default namespaces and export APIs +// to interact with them. +const scopeStack: Scope[] = []; + +export const getScope = () => scopeStack.slice( -1 )[ 0 ]; + +export const setScope = ( scope: Scope ) => { + scopeStack.push( scope ); +}; +export const resetScope = () => { + scopeStack.pop(); +}; + +// Wrap the element props to prevent modifications. +const immutableMap = new WeakMap(); +const immutableError = () => { + throw new Error( + 'Please use `data-wp-bind` to modify the attributes of an element.' + ); +}; +const immutableHandlers: ProxyHandler< object > = { + get( target, key, receiver ) { + const value = Reflect.get( target, key, receiver ); + return !! value && typeof value === 'object' + ? deepImmutable( value ) + : value; + }, + set: immutableError, + deleteProperty: immutableError, +}; +const deepImmutable = < T extends object = {} >( target: T ): T => { + if ( ! immutableMap.has( target ) ) { + immutableMap.set( target, new Proxy( target, immutableHandlers ) ); + } + return immutableMap.get( target ); +}; + +/** + * Retrieves the context inherited by the element evaluating a function from the + * store. The returned value depends on the element and the namespace where the + * function calling `getContext()` exists. + * + * @param namespace Store namespace. By default, the namespace where the calling + * function exists is used. + * @return The context content. + */ +export const getContext = < T extends object >( namespace?: string ): T => { + const scope = getScope(); + if ( globalThis.SCRIPT_DEBUG ) { + if ( ! scope ) { + throw Error( + 'Cannot call `getContext()` 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.context[ namespace || getNamespace() ]; +}; + +/** + * Retrieves a representation of the element where a function from the store + * is being evalutated. Such representation is read-only, and contains a + * reference to the DOM element, its props and a local reactive state. + * + * @return Element representation. + */ +export const getElement = () => { + const scope = getScope(); + if ( globalThis.SCRIPT_DEBUG ) { + if ( ! scope ) { + throw Error( + 'Cannot call `getElement()` 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)`.' + ); + } + } + const { ref, attributes } = scope; + return Object.freeze( { + ref: ref.current, + attributes: deepImmutable( attributes ), + } ); +}; diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 281a6c266021e..25fa64eb6e160 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -1,175 +1,18 @@ /** - * External dependencies + * Internal dependencies */ -import { deepSignal } from 'deepsignal'; -import { computed } from '@preact/signals'; - +import { proxifyState, proxifyStore } from './proxies'; /** - * Internal dependencies + * External dependencies */ -import { - getScope, - setScope, - resetScope, - getNamespace, - setNamespace, - resetNamespace, -} from './hooks'; -const isObject = ( item: unknown ): item is Record< string, unknown > => - Boolean( item && typeof item === 'object' && item.constructor === Object ); - -const deepMerge = ( target: any, source: any ) => { - if ( isObject( target ) && isObject( source ) ) { - for ( const key in source ) { - const getter = Object.getOwnPropertyDescriptor( source, key )?.get; - if ( typeof getter === 'function' ) { - Object.defineProperty( target, key, { get: getter } ); - } else if ( isObject( source[ key ] ) ) { - if ( ! target[ key ] ) { - target[ key ] = {}; - } - deepMerge( target[ key ], source[ key ] ); - } else { - try { - target[ key ] = source[ key ]; - } catch ( e ) { - // Assignemnts fail for properties that are only getters. - // When that's the case, the assignment is simply ignored. - } - } - } - } -}; +import { getNamespace } from './namespaces'; +import { deepMerge, isPlainObject } from './utils'; export const stores = new Map(); const rawStores = new Map(); const storeLocks = new Map(); const storeConfigs = new Map(); -const objToProxy = new WeakMap(); -const proxyToNs = new WeakMap(); -const scopeToGetters = new WeakMap(); - -const proxify = ( obj: any, ns: string ) => { - if ( ! objToProxy.has( obj ) ) { - const proxy = new Proxy( obj, handlers ); - objToProxy.set( obj, proxy ); - proxyToNs.set( proxy, ns ); - } - return objToProxy.get( obj ); -}; - -const handlers = { - get: ( target: any, key: string | symbol, receiver: any ) => { - const ns = proxyToNs.get( receiver ); - - // Check if the property is a getter and we are inside an scope. If that is - // the case, we clone the getter to avoid overwriting the scoped - // dependencies of the computed each time that getter runs. - const getter = Object.getOwnPropertyDescriptor( target, key )?.get; - if ( getter ) { - const scope = getScope(); - if ( scope ) { - const getters = - scopeToGetters.get( scope ) || - scopeToGetters.set( scope, new Map() ).get( scope ); - if ( ! getters.has( getter ) ) { - getters.set( - getter, - computed( () => { - setNamespace( ns ); - setScope( scope ); - try { - return getter.call( target ); - } finally { - resetScope(); - resetNamespace(); - } - } ) - ); - } - return getters.get( getter ).value; - } - } - - const result = Reflect.get( target, key ); - - // Check if the proxy is the store root and no key with that name exist. In - // that case, return an empty object for the requested key. - if ( typeof result === 'undefined' && receiver === stores.get( ns ) ) { - const obj = {}; - Reflect.set( target, key, obj ); - return proxify( obj, ns ); - } - - // Check if the property is a generator. If it is, we turn it into an - // asynchronous function where we restore the default namespace and scope - // each time it awaits/yields. - if ( result?.constructor?.name === 'GeneratorFunction' ) { - return async ( ...args: unknown[] ) => { - const scope = getScope(); - const gen: Generator< any > = result( ...args ); - - let value: unknown; - let it: IteratorResult< any >; - - while ( true ) { - setNamespace( ns ); - setScope( scope ); - try { - it = gen.next( value ); - } finally { - resetScope(); - resetNamespace(); - } - - try { - value = await it.value; - } catch ( e ) { - setNamespace( ns ); - setScope( scope ); - gen.throw( e ); - } finally { - resetScope(); - resetNamespace(); - } - - if ( it.done ) { - break; - } - } - - return value; - }; - } - - // Check if the property is a synchronous function. If it is, set the - // default namespace. Synchronous functions always run in the proper scope, - // which is set by the Directives component. - if ( typeof result === 'function' ) { - return ( ...args: unknown[] ) => { - setNamespace( ns ); - try { - return result( ...args ); - } finally { - resetNamespace(); - } - }; - } - - // Check if the property is an object. If it is, proxyify it. - if ( isObject( result ) ) { - return proxify( result, ns ); - } - - return result; - }, - // Prevents passing the current proxy as the receiver to the deepSignal. - set( target: any, key: string, value: any ) { - return Reflect.set( target, key, value ); - }, -}; - /** * Get the defined config for the store with the passed namespace. * @@ -280,13 +123,15 @@ export function store( storeLocks.set( namespace, lock ); } const rawStore = { - state: deepSignal( isObject( state ) ? state : {} ), + state: proxifyState( + namespace, + isPlainObject( state ) ? state : {} + ), ...block, }; - const proxiedStore = new Proxy( rawStore, handlers ); + const proxifiedStore = proxifyStore( namespace, rawStore ); rawStores.set( namespace, rawStore ); - stores.set( namespace, proxiedStore ); - proxyToNs.set( proxiedStore, namespace ); + stores.set( namespace, proxifiedStore ); } else { // Lock the store if it wasn't locked yet and the passed lock is // different from the universal unlock. If no lock is given, the store @@ -318,7 +163,7 @@ export function store( return stores.get( namespace ); } -export const parseInitialData = ( dom = document ) => { +export const parseServerData = ( dom = document ) => { const jsonDataScriptTag = // Preferred Script Module data passing form dom.getElementById( @@ -334,16 +179,17 @@ export const parseInitialData = ( dom = document ) => { return {}; }; -export const populateInitialData = ( data?: { +export const populateServerData = ( data?: { state?: Record< string, unknown >; config?: Record< string, unknown >; } ) => { - if ( isObject( data?.state ) ) { + if ( isPlainObject( data?.state ) ) { Object.entries( data!.state ).forEach( ( [ namespace, state ] ) => { - store( namespace, { state }, { lock: universalUnlock } ); + const st = store< any >( namespace, {}, { lock: universalUnlock } ); + deepMerge( st.state, state, false ); } ); } - if ( isObject( data?.config ) ) { + if ( isPlainObject( data?.config ) ) { Object.entries( data!.config ).forEach( ( [ namespace, config ] ) => { storeConfigs.set( namespace, config ); } ); @@ -351,5 +197,5 @@ export const populateInitialData = ( data?: { }; // Parse and populate the initial state and config. -const data = parseInitialData(); -populateInitialData( data ); +const data = parseServerData(); +populateServerData( data ); diff --git a/packages/interactivity/src/test/utils.ts b/packages/interactivity/src/test/utils.ts index ff564fa7c4c25..2ea0d2f04fadf 100644 --- a/packages/interactivity/src/test/utils.ts +++ b/packages/interactivity/src/test/utils.ts @@ -1,26 +1,321 @@ /** * Internal dependencies */ -import { kebabToCamelCase } from '../utils'; - -describe( 'kebabToCamelCase', () => { - it( 'should work exactly as the PHP version', async () => { - expect( kebabToCamelCase( '' ) ).toBe( '' ); - expect( kebabToCamelCase( 'item' ) ).toBe( 'item' ); - expect( kebabToCamelCase( 'my-item' ) ).toBe( 'myItem' ); - expect( kebabToCamelCase( 'my_item' ) ).toBe( 'my_item' ); - expect( kebabToCamelCase( 'My-iTem' ) ).toBe( 'myItem' ); - expect( kebabToCamelCase( 'my-item-with-multiple-hyphens' ) ).toBe( - 'myItemWithMultipleHyphens' - ); - expect( kebabToCamelCase( 'my-item-with--double-hyphens' ) ).toBe( - 'myItemWith-DoubleHyphens' - ); - expect( kebabToCamelCase( 'my-item-with_under-score' ) ).toBe( - 'myItemWith_underScore' - ); - expect( kebabToCamelCase( '-my-item' ) ).toBe( 'myItem' ); - expect( kebabToCamelCase( 'my-item-' ) ).toBe( 'myItem' ); - expect( kebabToCamelCase( '-my-item-' ) ).toBe( 'myItem' ); +import { deepMerge, kebabToCamelCase } from '../utils'; + +describe( 'Interactivity API', () => { + describe( 'deepMerge', () => { + it( 'should merge two plain objects', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + const result = {}; + deepMerge( result, target ); + deepMerge( result, source ); + expect( result ).toEqual( { a: 1, b: 3, c: 4 } ); + } ); + + it( 'should handle nested objects', () => { + const target = { a: { x: 1 }, b: 2 }; + const source = { a: { y: 2 }, c: 3 }; + const result = {}; + deepMerge( result, target ); + deepMerge( result, source ); + expect( result ).toEqual( { a: { x: 1, y: 2 }, b: 2, c: 3 } ); + } ); + + it( 'should not override existing properties when override is false', () => { + const target = { a: 1, b: { x: 10 } }; + const source = { a: 2, b: { y: 20 }, c: 3 }; + const result = {}; + deepMerge( result, target ); + deepMerge( result, source, false ); + expect( result ).toEqual( { a: 1, b: { x: 10, y: 20 }, c: 3 } ); + } ); + + it( 'should handle getters', () => { + const target = { + get a() { + return 1; + }, + b: 1, + }; + const source = { + a: 2, + get b() { + return 2; + }, + }; + const result: Record< string, any > = {}; + deepMerge( result, target ); + deepMerge( result, source ); + expect( result.a ).toBe( 2 ); + expect( result.b ).toBe( 2 ); + expect( + Object.getOwnPropertyDescriptor( result, 'a' )?.get + ).toBeUndefined(); + expect( + Object.getOwnPropertyDescriptor( result, 'b' )?.get + ).toBeDefined(); + } ); + + it( 'should not execute getters when performing the deep merge', () => { + let targetExecuted = false; + let sourceExecuted = false; + const target = { + get a() { + targetExecuted = true; + return 1; + }, + }; + const source = { + get b() { + sourceExecuted = true; + return 2; + }, + }; + const result: Record< string, any > = {}; + deepMerge( result, target ); + deepMerge( result, source ); + expect( targetExecuted ).toBe( false ); + expect( sourceExecuted ).toBe( false ); + } ); + + https: it( 'should handle setters', () => { + let targetValue = 1; + const target = { + get a() { + return targetValue; + }, + set a( value ) { + targetValue = value; + }, + b: 1, + }; + let sourceValue = 2; + const source = { + a: 3, + get b() { + return 2; + }, + set b( value ) { + sourceValue = value; + }, + }; + + const result: Record< string, any > = {}; + deepMerge( result, target ); + + result.a = 5; + expect( targetValue ).toBe( 5 ); + expect( result.a ).toBe( 5 ); + + deepMerge( result, source ); + + result.a = 6; + expect( targetValue ).toBe( 5 ); + + result.b = 7; + expect( sourceValue ).toBe( 7 ); + + expect( result.a ).toBe( 6 ); + expect( result.b ).toBe( 2 ); + expect( + Object.getOwnPropertyDescriptor( result, 'a' )?.set + ).toBeUndefined(); + expect( + Object.getOwnPropertyDescriptor( result, 'b' )?.set + ).toBeDefined(); + } ); + + it( 'should handle setters when overwrite is false', () => { + let targetValue = 1; + const target = { + get a() { + return targetValue; + }, + set a( value ) { + targetValue = value; + }, + b: 1, + }; + let sourceValue = 2; + const source = { + a: 3, + get b() { + return 2; + }, + set b( value ) { + sourceValue = value; + }, + }; + + const result: Record< string, any > = {}; + deepMerge( result, target, false ); + deepMerge( result, source, false ); + + result.a = 6; + expect( targetValue ).toBe( 6 ); + + result.b = 7; + expect( sourceValue ).toBe( 2 ); + + expect( result.a ).toBe( 6 ); + expect( result.b ).toBe( 7 ); + expect( + Object.getOwnPropertyDescriptor( result, 'a' )?.set + ).toBeDefined(); + expect( + Object.getOwnPropertyDescriptor( result, 'b' )?.set + ).toBeUndefined(); + } ); + + it( 'should handle getters and setters together', () => { + let targetValue = 1; + const target = { + get a() { + return targetValue; + }, + set a( value ) { + targetValue = value; + }, + b: 1, + }; + let sourceValue = 2; + const source = { + get a() { + return 3; + }, + set a( value ) { + sourceValue = value; + }, + }; + const result: Record< string, any > = {}; + deepMerge( result, target ); + deepMerge( result, source ); + + // Test if setters and getters are copied correctly + result.a = 5; + expect( targetValue ).toBe( 1 ); // Should not change + expect( sourceValue ).toBe( 5 ); // Should change + expect( result.a ).toBe( 3 ); // Should return the getter's value + + expect( + Object.getOwnPropertyDescriptor( result, 'a' )?.get + ).toBeDefined(); + expect( + Object.getOwnPropertyDescriptor( result, 'a' )?.set + ).toBeDefined(); + } ); + + it( 'should handle getters when overwrite is false', () => { + const target = { + get a() { + return 1; + }, + b: 1, + }; + const source = { + a: 2, + get b() { + return 2; + }, + }; + const result: Record< string, any > = {}; + deepMerge( result, target, false ); + deepMerge( result, source, false ); + expect( result.a ).toBe( 1 ); + expect( result.b ).toBe( 1 ); + expect( + Object.getOwnPropertyDescriptor( result, 'a' )?.get + ).toBeDefined(); + expect( + Object.getOwnPropertyDescriptor( result, 'b' )?.get + ).toBeUndefined(); + } ); + + it( 'should ignore non-plain objects', () => { + const target = { a: 1 }; + const source = new Date(); + const result = { ...target }; + deepMerge( result, source ); + expect( result ).toEqual( { a: 1 } ); + } ); + + it( 'should handle arrays', () => { + const target = { a: [ 1, 2 ] }; + const source = { a: [ 3, 4 ] }; + const result = {}; + deepMerge( result, target ); + deepMerge( result, source ); + expect( result ).toEqual( { a: [ 3, 4 ] } ); + } ); + + it( 'should handle arrays when overwrite is false', () => { + const target = { a: [ 1, 2 ] }; + const source = { a: [ 3, 4 ] }; + const result = {}; + deepMerge( result, target, false ); + deepMerge( result, source, false ); + expect( result ).toEqual( { a: [ 1, 2 ] } ); + } ); + + it( 'should handle null values', () => { + const target = { a: 1, b: null }; + const source = { b: 2, c: null }; + const result = {}; + deepMerge( result, target ); + deepMerge( result, source ); + expect( result ).toEqual( { a: 1, b: 2, c: null } ); + } ); + + it( 'should handle undefined values', () => { + const target = { a: 1, b: undefined }; + const source = { b: 2, c: undefined }; + const result = {}; + deepMerge( result, target ); + deepMerge( result, source ); + expect( result ).toEqual( { a: 1, b: 2, c: undefined } ); + } ); + + it( 'should handle undefined values when overwrite is false', () => { + const target = { a: 1, b: undefined }; + const source = { b: 2, c: undefined }; + const result = {}; + deepMerge( result, target, false ); + deepMerge( result, source, false ); + expect( result ).toEqual( { a: 1, b: undefined, c: undefined } ); + } ); + + it( 'should handle deleted values when overwrite is false', () => { + const target = { a: 1 }; + const source = { a: 2 }; + const result: Record< string, any > = {}; + deepMerge( result, target, false ); + delete result.a; + deepMerge( result, source, false ); + expect( result ).toEqual( { a: 2 } ); + } ); + } ); + + describe( 'kebabToCamelCase', () => { + it( 'should work exactly as the PHP version', async () => { + expect( kebabToCamelCase( '' ) ).toBe( '' ); + expect( kebabToCamelCase( 'item' ) ).toBe( 'item' ); + expect( kebabToCamelCase( 'my-item' ) ).toBe( 'myItem' ); + expect( kebabToCamelCase( 'my_item' ) ).toBe( 'my_item' ); + expect( kebabToCamelCase( 'My-iTem' ) ).toBe( 'myItem' ); + expect( kebabToCamelCase( 'my-item-with-multiple-hyphens' ) ).toBe( + 'myItemWithMultipleHyphens' + ); + expect( kebabToCamelCase( 'my-item-with--double-hyphens' ) ).toBe( + 'myItemWith-DoubleHyphens' + ); + expect( kebabToCamelCase( 'my-item-with_under-score' ) ).toBe( + 'myItemWith_underScore' + ); + expect( kebabToCamelCase( '-my-item' ) ).toBe( 'myItem' ); + expect( kebabToCamelCase( 'my-item-' ) ).toBe( 'myItem' ); + expect( kebabToCamelCase( '-my-item-' ) ).toBe( 'myItem' ); + } ); } ); } ); diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index 3c880d43f1706..ee0cf1ba084dc 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -14,14 +14,8 @@ import { effect } from '@preact/signals'; /** * Internal dependencies */ -import { - getScope, - setScope, - resetScope, - getNamespace, - setNamespace, - resetNamespace, -} from './hooks'; +import { getScope, setScope, resetScope } from './scopes'; +import { getNamespace, setNamespace, resetNamespace } from './namespaces'; interface Flusher { readonly flush: () => void; @@ -145,18 +139,26 @@ export function withScope( func: ( ...args: unknown[] ) => unknown ) { try { it = gen.next( value ); } finally { - resetNamespace(); resetScope(); + resetNamespace(); } + try { value = await it.value; } catch ( e ) { + setNamespace( ns ); + setScope( scope ); gen.throw( e ); + } finally { + resetScope(); + resetNamespace(); } + if ( it.done ) { break; } } + return value; }; } @@ -346,3 +348,50 @@ export const warn = ( message: string ): void => { logged.add( message ); } }; + +/** + * Checks if the passed `candidate` is a plain object with just the `Object` + * prototype. + * + * @param candidate The item to check. + * @return Whether `candidate` is a plain object. + */ +export const isPlainObject = ( + candidate: unknown +): candidate is Record< string, unknown > => + Boolean( + candidate && + typeof candidate === 'object' && + candidate.constructor === Object + ); + +export const deepMerge = ( + target: any, + source: any, + override: boolean = true +) => { + if ( isPlainObject( target ) && isPlainObject( source ) ) { + for ( const key in source ) { + const desc = Object.getOwnPropertyDescriptor( source, key ); + if ( + typeof desc?.get === 'function' || + typeof desc?.set === 'function' + ) { + if ( override || ! ( key in target ) ) { + Object.defineProperty( target, key, { + ...desc, + configurable: true, + enumerable: true, + } ); + } + } else if ( isPlainObject( source[ key ] ) ) { + if ( ! target[ key ] ) { + target[ key ] = {}; + } + deepMerge( target[ key ], source[ key ], override ); + } else if ( override || ! ( key in target ) ) { + Object.defineProperty( target, key, desc! ); + } + } + } +}; diff --git a/test/e2e/specs/interactivity/deferred-store.spec.ts b/test/e2e/specs/interactivity/deferred-store.spec.ts index b6a7853c40dcd..0ddbcb0a60d2f 100644 --- a/test/e2e/specs/interactivity/deferred-store.spec.ts +++ b/test/e2e/specs/interactivity/deferred-store.spec.ts @@ -27,9 +27,7 @@ test.describe( 'deferred store', () => { 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 ( { + test( 'Ensure that a state getter can be subscribed to before it is initialized', async ( { page, } ) => { const resultInput = page.getByTestId( 'result-getter' ); diff --git a/test/e2e/specs/interactivity/directive-context.spec.ts b/test/e2e/specs/interactivity/directive-context.spec.ts index 41afa274155a2..e806049ff55e7 100644 --- a/test/e2e/specs/interactivity/directive-context.spec.ts +++ b/test/e2e/specs/interactivity/directive-context.spec.ts @@ -38,7 +38,7 @@ test.describe( 'data-wp-context', () => { } ); } ); - test( 'is correctly extended', async ( { page } ) => { + test( 'is correctly extended (shallow)', async ( { page } ) => { const childContext = await parseContent( page.getByTestId( 'child context' ) ); @@ -47,12 +47,12 @@ test.describe( 'data-wp-context', () => { prop1: 'parent', prop2: 'child', prop3: 'child', - obj: { prop4: 'parent', prop5: 'child', prop6: 'child' }, + obj: { prop5: 'child', prop6: 'child' }, array: [ 4, 5, 6 ], } ); } ); - test( 'changes in inherited properties are reflected (child)', async ( { + test( "changes in inherited properties are reflected and don't leak down (child)", async ( { page, } ) => { await page.getByTestId( 'child prop1' ).click(); @@ -70,10 +70,10 @@ test.describe( 'data-wp-context', () => { ); expect( parentContext.prop1 ).toBe( 'modifiedFromChild' ); - expect( parentContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + expect( parentContext.obj.prop4 ).toBe( 'parent' ); } ); - test( 'changes in inherited properties are reflected (parent)', async ( { + test( "changes in inherited properties are reflected and don't leak up (parent)", async ( { page, } ) => { await page.getByTestId( 'parent prop1' ).click(); @@ -84,7 +84,7 @@ test.describe( 'data-wp-context', () => { ); expect( childContext.prop1 ).toBe( 'modifiedFromParent' ); - expect( childContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + expect( childContext.obj.prop4 ).toBeUndefined(); const parentContext = await parseContent( page.getByTestId( 'parent context' ) @@ -170,7 +170,7 @@ test.describe( 'data-wp-context', () => { expect( childContext.array ).toMatchObject( [ 4, 5, 6 ] ); } ); - test( 'overwritten objects updates inherited values', async ( { + test( "overwritten objects don't inherit values (shallow)", async ( { page, } ) => { await page.getByTestId( 'parent replace' ).click(); @@ -182,7 +182,7 @@ test.describe( 'data-wp-context', () => { expect( childContext.obj.prop4 ).toBeUndefined(); expect( childContext.obj.prop5 ).toBe( 'child' ); expect( childContext.obj.prop6 ).toBe( 'child' ); - expect( childContext.obj.overwritten ).toBe( true ); + expect( childContext.obj.overwritten ).toBeUndefined(); const parentContext = await parseContent( page.getByTestId( 'parent context' ) @@ -230,13 +230,13 @@ test.describe( 'data-wp-context', () => { await expect( element ).toHaveAttribute( 'value', 'Text 1' ); } ); - test( 'should replace values on navigation', async ( { page } ) => { + test( 'should preserve 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' ); + await expect( element ).toHaveText( 'changed dynamically' ); } ); test( 'should preserve the previous context values', async ( { page } ) => { @@ -248,16 +248,16 @@ test.describe( 'data-wp-context', () => { await expect( element ).toHaveText( 'some new text' ); } ); - test( 'should update values when navigating back or forward', async ( { + test( 'should preserve values when navigating back or forward', async ( { page, } ) => { const element = page.getByTestId( 'navigation text' ); await page.getByTestId( 'navigate' ).click(); - await expect( element ).toHaveText( 'second page' ); + await expect( element ).toHaveText( 'first page' ); await page.goBack(); await expect( element ).toHaveText( 'first page' ); await page.goForward(); - await expect( element ).toHaveText( 'second page' ); + await expect( element ).toHaveText( 'first page' ); } ); test( 'should inherit values on navigation', async ( { page } ) => { @@ -270,15 +270,14 @@ test.describe( 'data-wp-context', () => { await page.getByTestId( 'add text2' ).click(); await expect( text2 ).toHaveText( 'some new text' ); await page.getByTestId( 'navigate' ).click(); - await expect( text ).toHaveText( 'second page' ); - await expect( text2 ).toHaveText( 'second page' ); + await expect( text ).toHaveText( 'changed dynamically' ); + await expect( text2 ).toHaveText( 'some new text' ); await page.goBack(); - await expect( text ).toHaveText( 'first page' ); - // text2 maintains its value as it is not defined in the first page. - await expect( text2 ).toHaveText( 'second page' ); + await expect( text ).toHaveText( 'changed dynamically' ); + await expect( text2 ).toHaveText( 'some new text' ); await page.goForward(); - await expect( text ).toHaveText( 'second page' ); - await expect( text2 ).toHaveText( 'second page' ); + await expect( text ).toHaveText( 'changed dynamically' ); + await expect( text2 ).toHaveText( 'some new text' ); } ); test( 'should maintain the same context reference on async actions', async ( { @@ -289,11 +288,14 @@ test.describe( 'data-wp-context', () => { await page.getByTestId( 'async navigate' ).click(); await expect( element ).toHaveText( 'changed from async action' ); } ); + test( 'should bail out if the context is not a default directive', async ( { page, } ) => { - // This test is to ensure that the context directive is only applied to the default directive - // and not to any other directive. + /* + * This test is to ensure that the context directive is only applied to the + * default directive and not to any other directive. + */ const defaultElement = page.getByTestId( 'default suffix context' ); await expect( defaultElement ).toHaveText( 'default' ); const element = page.getByTestId( 'non-default suffix context' ); @@ -363,7 +365,7 @@ test.describe( 'data-wp-context', () => { page.getByTestId( 'child context' ) ); - expect( childContextBefore.obj2.prop4 ).toBe( 'parent' ); + expect( childContextBefore.obj2.prop4 ).toBeUndefined(); expect( childContextBefore.obj2.prop5 ).toBe( 'child' ); expect( childContextBefore.obj2.prop6 ).toBe( 'child' ); @@ -376,6 +378,6 @@ test.describe( 'data-wp-context', () => { expect( childContextAfter.obj2.prop4 ).toBeUndefined(); expect( childContextAfter.obj2.prop5 ).toBe( 'child' ); expect( childContextAfter.obj2.prop6 ).toBe( 'child' ); - expect( childContextAfter.obj2.overwritten ).toBe( true ); + expect( childContextAfter.obj2.overwritten ).toBeUndefined(); } ); } ); diff --git a/test/e2e/specs/interactivity/directive-each.spec.ts b/test/e2e/specs/interactivity/directive-each.spec.ts index 9dbd1e47a2ef1..511b38e7ddbb8 100644 --- a/test/e2e/specs/interactivity/directive-each.spec.ts +++ b/test/e2e/specs/interactivity/directive-each.spec.ts @@ -314,19 +314,14 @@ test.describe( 'data-wp-each', () => { } ) ); - await expect( elements ).toHaveText( [ 'beta', 'gamma', 'delta' ] ); + await expect( elements ).toHaveText( [ 'b', 'c', 'd' ] ); await page .getByTestId( 'navigation-updated list' ) .getByTestId( 'navigate' ) .click(); - await expect( elements ).toHaveText( [ - 'alpha', - 'beta', - 'gamma', - 'delta', - ] ); + await expect( elements ).toHaveText( [ 'a', 'b', 'c', 'd' ] ); // Get the tags. They should not have disappeared or changed, // except for the newly created element. diff --git a/test/e2e/specs/interactivity/router-navigate.spec.ts b/test/e2e/specs/interactivity/router-navigate.spec.ts index 872fe9ea7ea52..d1ac30783ee2b 100644 --- a/test/e2e/specs/interactivity/router-navigate.spec.ts +++ b/test/e2e/specs/interactivity/router-navigate.spec.ts @@ -6,10 +6,6 @@ import { test, expect } from './fixtures'; test.describe( 'Router navigate', () => { test.beforeAll( async ( { interactivityUtils: utils } ) => { await utils.activatePlugins(); - const link2 = await utils.addPostWithBlock( 'test/router-navigate', { - alias: 'router navigate - link 2', - attributes: { title: 'Link 2' }, - } ); const link1 = await utils.addPostWithBlock( 'test/router-navigate', { alias: 'router navigate - link 1', attributes: { @@ -21,6 +17,10 @@ test.describe( 'Router navigate', () => { }, }, } ); + const link2 = await utils.addPostWithBlock( 'test/router-navigate', { + alias: 'router navigate - link 2', + attributes: { title: 'Link 2' }, + } ); const link3 = await utils.addPostWithBlock( 'test/router-navigate', { alias: 'router navigate - disabled', attributes: { @@ -211,29 +211,26 @@ test.describe( 'Router navigate', () => { await expect( count ).toHaveText( '0' ); } ); - test( 'should overwrite the state with the one serialized in the new page', async ( { + test( 'should merge the state with the one serialized in the new page', async ( { page, } ) => { const prop1 = page.getByTestId( 'prop1' ); const prop2 = page.getByTestId( 'prop2' ); const prop3 = page.getByTestId( 'prop3' ); + const title = page.getByTestId( 'title' ); await expect( prop1 ).toHaveText( 'main' ); await expect( prop2 ).toHaveText( 'main' ); await expect( prop3 ).toBeEmpty(); await page.getByTestId( 'link 1' ).click(); - - // New values for existing properties should change. - // Old values not overwritten should remain the same. - // New properties should appear. - await expect( prop1 ).toHaveText( 'link 1' ); + await expect( title ).toHaveText( 'Link 1' ); + await expect( prop1 ).toHaveText( 'main' ); await expect( prop2 ).toHaveText( 'main' ); await expect( prop3 ).toHaveText( 'link 1' ); await page.goBack(); - - // New added properties are preserved. + await expect( title ).toHaveText( 'Main' ); await expect( prop1 ).toHaveText( 'main' ); await expect( prop2 ).toHaveText( 'main' ); await expect( prop3 ).toHaveText( 'link 1' ); @@ -245,26 +242,20 @@ test.describe( 'Router navigate', () => { const title = page.getByTestId( 'title' ); const getter = page.getByTestId( 'getterProp' ); - // Title should start in 'Main' and the getter prop should be the one - // returned once hydrated. await expect( title ).toHaveText( 'Main' ); await expect( getter ).toHaveText( 'value from getter (main)' ); await page.getByTestId( 'link 1' ).click(); - - // Title should have changed. If not, that means there was an error - // during render. The getter should return the correct value. await expect( title ).toHaveText( 'Link 1' ); - await expect( getter ).toHaveText( 'value from getter (link 1)' ); + await expect( getter ).toHaveText( 'value from getter (main)' ); - // Same behavior navigating back and forward. await page.goBack(); await expect( title ).toHaveText( 'Main' ); await expect( getter ).toHaveText( 'value from getter (main)' ); await page.goForward(); await expect( title ).toHaveText( 'Link 1' ); - await expect( getter ).toHaveText( 'value from getter (link 1)' ); + await expect( getter ).toHaveText( 'value from getter (main)' ); } ); test( 'should force a page reload when navigating to a page with `clientNavigationDisabled`', async ( { diff --git a/test/unit/jest.config.js b/test/unit/jest.config.js index 4a19d5bb37f31..4a3bb647a7879 100644 --- a/test/unit/jest.config.js +++ b/test/unit/jest.config.js @@ -40,7 +40,7 @@ module.exports = { '^.+\\.[jt]sx?$': '