diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 9ea269fa269ad..6e8fbc6b74e08 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- Refactor internal context proxies implementation ([#64713](https://github.com/WordPress/gutenberg/pull/64713)). + ### Bug Fixes - Prevent calling `proxifyContext` over an already-proxified context inside `wp-context` ([#65090](https://github.com/WordPress/gutenberg/pull/65090)). diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index b41d50722e376..cde39d830499a 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -6,10 +6,6 @@ */ import { h as createElement, type RefObject } from 'preact'; import { useContext, useMemo, useRef } from 'preact/hooks'; -/** - * Internal dependencies - */ -import { proxifyState, peek } from './proxies'; /** * Internal dependencies @@ -24,131 +20,7 @@ import { } from './utils'; import { directive, getEvaluate, type DirectiveEntry } from './hooks'; import { getScope } from './scopes'; - -// Assigned objects should be ignored during proxification. -const contextAssignedObjects = new WeakMap(); - -// Store the context proxy and fallback for each object in the context. -const contextObjectToProxy = new WeakMap(); -const contextProxyToObject = new WeakMap(); -const contextObjectToFallback = new WeakMap(); - -const descriptor = Reflect.getOwnPropertyDescriptor; - -/** - * Wrap a context object with a proxy to reproduce the context stack. The proxy - * uses the passed `inherited` context as a fallback to look up for properties - * that don't exist in the given context. Also, updated properties are modified - * where they are defined, or added to the main context when they don't exist. - * - * By default, all plain objects inside the context are wrapped, unless it is - * listed in the `ignore` option. - * - * @param current Current context. - * @param inherited Inherited context, used as fallback. - * - * @return The wrapped context object. - */ -const proxifyContext = ( current: object, inherited: object = {} ): object => { - // Update the fallback object reference when it changes. - contextObjectToFallback.set( current, inherited ); - if ( ! contextObjectToProxy.has( current ) ) { - const proxy = new Proxy( current, { - get: ( target: object, k: string ) => { - const fallback = contextObjectToFallback.get( current ); - // Always subscribe to prop changes in the current context. - const currentProp = target[ k ]; - - // Return the inherited prop when missing in target. - if ( ! ( k in target ) && k in fallback ) { - return fallback[ k ]; - } - - // Proxify plain objects that were not directly assigned. - if ( - k in target && - ! contextAssignedObjects.get( target )?.has( k ) && - isPlainObject( currentProp ) - ) { - return proxifyContext( currentProp ); - } - - // Return the stored proxy for `currentProp` when it exists. - if ( contextObjectToProxy.has( currentProp ) ) { - return contextObjectToProxy.get( currentProp ); - } - - /* - * For other cases, return the value from target, also - * subscribing to changes in the parent context when the current - * prop is not defined. - */ - return k in target ? currentProp : fallback[ k ]; - }, - set: ( target, k, value ) => { - const fallback = contextObjectToFallback.get( current ); - const obj = - k in target || ! ( k in fallback ) ? target : fallback; - - /* - * Assigned object values should not be proxified so they point - * to the original object and don't inherit unexpected - * properties. - */ - if ( value && typeof value === 'object' ) { - if ( ! contextAssignedObjects.has( obj ) ) { - contextAssignedObjects.set( obj, new Set() ); - } - contextAssignedObjects.get( obj ).add( k ); - } - - /* - * When the value is a proxy, it's because it comes from the - * context, so the inner value is assigned instead. - */ - if ( contextProxyToObject.has( value ) ) { - const innerValue = contextProxyToObject.get( value ); - obj[ k ] = innerValue; - } else { - obj[ k ] = value; - } - - return true; - }, - ownKeys: ( target ) => [ - ...new Set( [ - ...Object.keys( contextObjectToFallback.get( current ) ), - ...Object.keys( target ), - ] ), - ], - getOwnPropertyDescriptor: ( target, k ) => - descriptor( target, k ) || - descriptor( contextObjectToFallback.get( current ), k ), - } ); - contextObjectToProxy.set( current, proxy ); - contextProxyToObject.set( proxy, current ); - } - return contextObjectToProxy.get( current ); -}; - -/** - * Recursively update values within a context object. - * - * @param target A context instance. - * @param source Object with properties to update in `target`. - */ -const updateContext = ( target: any, source: any ) => { - for ( const k in source ) { - if ( - isPlainObject( peek( target, k ) ) && - isPlainObject( source[ k ] ) - ) { - updateContext( peek( target, k ) as object, source[ k ] ); - } else if ( ! ( k in target ) ) { - target[ k ] = source[ k ]; - } - } -}; +import { proxifyState, proxifyContext, deepMerge } from './proxies'; /** * Recursively clone the passed object. @@ -286,9 +158,10 @@ export default () => { `The value of data-wp-context in "${ namespace }" store must be a valid stringified JSON object.` ); } - updateContext( + deepMerge( currentValue.current, - deepClone( value ) as object + deepClone( value ) as object, + false ); result[ namespace ] = proxifyContext( currentValue.current, diff --git a/packages/interactivity/src/proxies/context.ts b/packages/interactivity/src/proxies/context.ts new file mode 100644 index 0000000000000..64517c91a6940 --- /dev/null +++ b/packages/interactivity/src/proxies/context.ts @@ -0,0 +1,69 @@ +const contextObjectToProxy = new WeakMap(); +const contextObjectToFallback = new WeakMap(); +const contextProxies = new WeakSet(); + +const descriptor = Reflect.getOwnPropertyDescriptor; + +// TODO: Use the proxy registry to avoid multiple proxies on the same object. +const contextHandlers: ProxyHandler< object > = { + get: ( target, key ) => { + const fallback = contextObjectToFallback.get( target ); + // Always subscribe to prop changes in the current context. + const currentProp = target[ key ]; + + /* + * Return the value from `target` if it exists, or from `fallback` + * otherwise. This way, in the case the property doesn't exist either in + * `target` or `fallback`, it also subscribes to changes in the parent + * context. + */ + return key in target ? currentProp : fallback[ key ]; + }, + set: ( target, key, value ) => { + const fallback = contextObjectToFallback.get( target ); + + // If the property exists in the current context, modify it. Otherwise, + // add it to the current context. + const obj = key in target || ! ( key in fallback ) ? target : fallback; + obj[ key ] = value; + + return true; + }, + ownKeys: ( target ) => [ + ...new Set( [ + ...Object.keys( contextObjectToFallback.get( target ) ), + ...Object.keys( target ), + ] ), + ], + getOwnPropertyDescriptor: ( target, key ) => + descriptor( target, key ) || + descriptor( contextObjectToFallback.get( target ), key ), +}; + +/** + * Wrap a context object with a proxy to reproduce the context stack. The proxy + * uses the passed `inherited` context as a fallback to look up for properties + * that don't exist in the given context. Also, updated properties are modified + * where they are defined, or added to the main context when they don't exist. + * + * @param current Current context. + * @param inherited Inherited context, used as fallback. + * + * @return The wrapped context object. + */ +export const proxifyContext = ( + current: object, + inherited: object = {} +): object => { + if ( contextProxies.has( current ) ) { + throw Error( 'This object cannot be proxified.' ); + } + // Update the fallback object reference when it changes. + contextObjectToFallback.set( current, inherited ); + if ( ! contextObjectToProxy.has( current ) ) { + const proxy = new Proxy( current, contextHandlers ); + contextObjectToProxy.set( current, proxy ); + contextProxies.add( proxy ); + } + return contextObjectToProxy.get( current ); +}; diff --git a/packages/interactivity/src/proxies/index.ts b/packages/interactivity/src/proxies/index.ts index 1a495de6b469f..168e6467fc701 100644 --- a/packages/interactivity/src/proxies/index.ts +++ b/packages/interactivity/src/proxies/index.ts @@ -3,3 +3,4 @@ */ export { proxifyState, peek, deepMerge } from './state'; export { proxifyStore } from './store'; +export { proxifyContext } from './context'; diff --git a/packages/interactivity/src/proxies/test/context-proxy.ts b/packages/interactivity/src/proxies/test/context-proxy.ts new file mode 100644 index 0000000000000..306b3e4a8aa94 --- /dev/null +++ b/packages/interactivity/src/proxies/test/context-proxy.ts @@ -0,0 +1,291 @@ +/** + * External dependencies + */ +import { effect } from '@preact/signals'; + +/** + * Internal dependencies + */ +import { proxifyContext, proxifyState } from '../'; + +describe( 'Interactivity API', () => { + describe( 'context proxy', () => { + describe( 'get', () => { + it( 'should inherit props from its fallback', () => { + const fallback: any = proxifyContext( { a: 1 }, {} ); + const context: any = proxifyContext( { b: 2 }, fallback ); + + expect( context.a ).toBe( 1 ); + expect( context.b ).toBe( 2 ); + } ); + + it( "should inherit props from its fallback's fallback", () => { + const fallback2: any = proxifyContext( { a: 1 }, {} ); + const fallback1: any = proxifyContext( { b: 2 }, fallback2 ); + const context: any = proxifyContext( { c: 3 }, fallback1 ); + + expect( context.a ).toBe( 1 ); + expect( context.b ).toBe( 2 ); + expect( context.c ).toBe( 3 ); + } ); + + it( 'should list all inherited props', () => { + const fallback2: any = proxifyContext( { a: 1 }, {} ); + const fallback1: any = proxifyContext( { b: 2 }, fallback2 ); + const context: any = proxifyContext( { c: 3 }, fallback1 ); + + expect( Object.entries( context ) ).toEqual( [ + [ 'a', 1 ], + [ 'b', 2 ], + [ 'c', 3 ], + ] ); + } ); + + it( 'should shadow properties defined in its fallback', () => { + const fallback: any = proxifyContext( + { prop: 'fallback' }, + {} + ); + const context: any = proxifyContext( + { prop: 'context' }, + fallback + ); + + expect( context.prop ).toBe( 'context' ); + } ); + + it( 'should not inherit properties from nested objects', () => { + const fallback: any = proxifyContext( { obj: { a: 1 } }, {} ); + const context: any = proxifyContext( + { obj: { b: 2 } }, + fallback + ); + + expect( 'a' in context.obj ).toBe( false ); + expect( context.obj.b ).toBe( 2 ); + } ); + + it( 'should work with the proxified state', () => { + const state = proxifyState( 'test', { a: 1 } ); + const fallback: any = proxifyContext( state, {} ); + const context: any = proxifyContext( state, fallback ); + + expect( context.a ).toBe( 1 ); + } ); + } ); + + describe( 'set', () => { + it( 'should modify props defined in it', () => { + const fallback: any = proxifyContext( + { prop: 'fallback' }, + {} + ); + const context: any = proxifyContext( + { prop: 'context' }, + fallback + ); + + context.prop = 'modified'; + + expect( context.prop ).toBe( 'modified' ); + expect( fallback.prop ).toBe( 'fallback' ); + } ); + + it( 'should modify props inherited from its fallback', () => { + const fallback: any = proxifyContext( + { prop: 'fallback' }, + {} + ); + const context: any = proxifyContext( {}, fallback ); + + context.prop = 'modified'; + + expect( context.prop ).toBe( 'modified' ); + expect( fallback.prop ).toBe( 'modified' ); + } ); + + it( 'should see changes in inherited props', () => { + const fallback: any = proxifyContext( + { prop: 'fallback' }, + {} + ); + const context: any = proxifyContext( {}, fallback ); + + fallback.prop = 'modified'; + + expect( context.prop ).toBe( 'modified' ); + expect( fallback.prop ).toBe( 'modified' ); + } ); + + it( 'should create non-inherited props in itself', () => { + const fallback: any = proxifyContext( {}, {} ); + const context: any = proxifyContext( {}, fallback ); + + context.prop = 'modified'; + + expect( context.prop ).toBe( 'modified' ); + expect( fallback.prop ).toBeUndefined(); + } ); + + it( 'should work with the proxified state', () => { + const state = proxifyState( 'test', { a: 1 } ); + const fallback: any = proxifyContext( state, {} ); + const context: any = proxifyContext( {}, fallback ); + + context.a = 2; + + expect( context.a ).toBe( 2 ); + expect( state.a ).toBe( 2 ); + } ); + } ); + + describe( 'computations', () => { + it( 'should subscribe to changes in the current context', () => { + const fallback: any = proxifyContext( + proxifyState( 'test', { fromFallback: 'fallback' } ), + {} + ); + const context: any = proxifyContext( + proxifyState( 'test', { fromContext: 'context' } ), + fallback + ); + + const spy = jest.fn( () => context.fromContext ); + effect( spy ); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( context.fromContext ).toBe( 'context' ); + + context.fromContext = 'modified'; + + expect( spy ).toHaveBeenCalledTimes( 2 ); + expect( context.fromContext ).toBe( 'modified' ); + } ); + + it( 'should subscribe to changes in inherited values', () => { + const fallback: any = proxifyContext( + proxifyState( 'test', { fromFallback: 'fallback' } ), + {} + ); + const context: any = proxifyContext( + proxifyState( 'test', { fromContext: 'context' } ), + fallback + ); + + const spy = jest.fn( () => context.fromFallback ); + effect( spy ); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( context.fromFallback ).toBe( 'fallback' ); + + fallback.fromFallback = 'modified'; + + expect( spy ).toHaveBeenCalledTimes( 2 ); + expect( context.fromFallback ).toBe( 'modified' ); + } ); + + it( 'should subscribe to undefined props added to the context', () => { + const fallback: any = proxifyContext( + proxifyState( 'test', {} ), + {} + ); + const context: any = proxifyContext( + proxifyState( 'test', {} ), + fallback + ); + + const spy = jest.fn( () => context.fromContext ); + effect( spy ); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( context.fromContext ).toBeUndefined(); + + context.fromContext = 'added'; + + expect( spy ).toHaveBeenCalledTimes( 2 ); + expect( context.fromContext ).toBe( 'added' ); + } ); + + it( 'should subscribe to undefined props added to the fallback', () => { + const fallback: any = proxifyContext( + proxifyState( 'test', {} ), + {} + ); + const context: any = proxifyContext( + proxifyState( 'test', {} ), + fallback + ); + + const spy = jest.fn( () => context.fromFallback ); + effect( spy ); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( context.fromFallback ).toBeUndefined(); + + fallback.fromFallback = 'added'; + + expect( spy ).toHaveBeenCalledTimes( 2 ); + expect( context.fromFallback ).toBe( 'added' ); + } ); + + it( 'should subscribe to shadowed props', () => { + const fallbackState: any = proxifyState( 'test', {} ); + const fallback: any = proxifyContext( fallbackState, {} ); + + const contextState: any = proxifyState( 'test', {} ); + const context: any = proxifyContext( contextState, fallback ); + + const spy = jest.fn( () => context.prop ); + effect( spy ); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( context.prop ).toBeUndefined(); + + fallbackState.prop = 'fromFallback'; + + expect( spy ).toHaveBeenCalledTimes( 2 ); + expect( context.prop ).toBe( 'fromFallback' ); + + contextState.prop = 'fromContext'; + + expect( spy ).toHaveBeenCalledTimes( 3 ); + expect( context.prop ).toBe( 'fromContext' ); + } ); + + it( 'should subscribe to any changes in listed props', () => { + const fallback: any = proxifyContext( + proxifyState( 'test', {} ), + {} + ); + const context: any = proxifyContext( + proxifyState( 'test', {} ), + fallback + ); + + const spy = jest.fn( () => Object.keys( context ) ); + effect( spy ); + + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( Object.keys( context ) ).toEqual( [] ); + + context.fromContext = 'added'; + fallback.fromFallback = 'added'; + + expect( spy ).toHaveBeenCalledTimes( 3 ); + expect( Object.keys( context ).sort() ).toEqual( [ + 'fromContext', + 'fromFallback', + ] ); + } ); + } ); + + describe( 'proxifyContext', () => { + it( 'should throw when trying to re-proxify a proxy object', () => { + const context = proxifyContext( {}, {} ); + expect( () => proxifyContext( context, {} ) ).toThrow( + 'This object cannot be proxified.' + ); + } ); + } ); + } ); +} );