From 181841a86745f72bc59f15aaf7f2063830f387bf Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 01/91] WIP --- package-lock.json | 56 ++--- packages/interactivity/package.json | 2 +- packages/interactivity/src/store/handlers.ts | 128 +++++++++++ packages/interactivity/src/store/index.ts | 0 .../interactivity/src/store/properties.ts | 90 ++++++++ packages/interactivity/src/store/proxies.ts | 30 +++ .../src/store/test/state-handlers.ts | 217 ++++++++++++++++++ 7 files changed, 479 insertions(+), 44 deletions(-) create mode 100644 packages/interactivity/src/store/handlers.ts create mode 100644 packages/interactivity/src/store/index.ts create mode 100644 packages/interactivity/src/store/properties.ts create mode 100644 packages/interactivity/src/store/proxies.ts create mode 100644 packages/interactivity/src/store/test/state-handlers.ts diff --git a/package-lock.json b/package-lock.json index 6680025d768bef..cd8f4b0fb6e594 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7142,15 +7142,6 @@ "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", "dev": true }, - "node_modules/@preact/signals-core": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.4.0.tgz", - "integrity": "sha512-5iYoZBhELLIhUQceZI7sDTQWPb+xcVSn2qk8T/aNl/VMh+A4AiPX9YRSh4XO7fZ6pncrVxl1Iln82poVqYVbbw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/@puppeteer/browsers": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", @@ -53728,7 +53719,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@preact/signals": "^1.2.2", - "deepsignal": "^1.4.0", + "@preact/signals-core": "1.6.1", "preact": "^10.19.3" }, "engines": { @@ -53763,29 +53754,13 @@ "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/signals-core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.6.1.tgz", + "integrity": "sha512-KXEEmJoKDlo0Igju/cj9YvKIgyaWFDgnprShQjzimUd5VynAAdTWMshawEOjUVeKbsI0aR58V6WOQp+DNcKApw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" } }, "packages/interactivity/node_modules/preact": { @@ -60123,11 +60098,6 @@ "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", "dev": true }, - "@preact/signals-core": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.4.0.tgz", - "integrity": "sha512-5iYoZBhELLIhUQceZI7sDTQWPb+xcVSn2qk8T/aNl/VMh+A4AiPX9YRSh4XO7fZ6pncrVxl1Iln82poVqYVbbw==" - }, "@puppeteer/browsers": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", @@ -68270,7 +68240,7 @@ "version": "file:packages/interactivity", "requires": { "@preact/signals": "^1.2.2", - "deepsignal": "^1.4.0", + "@preact/signals-core": "1.6.1", "preact": "^10.19.3" }, "dependencies": { @@ -68282,10 +68252,10 @@ "@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/signals-core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.6.1.tgz", + "integrity": "sha512-KXEEmJoKDlo0Igju/cj9YvKIgyaWFDgnprShQjzimUd5VynAAdTWMshawEOjUVeKbsI0aR58V6WOQp+DNcKApw==" }, "preact": { "version": "10.19.3", diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index 835063ccc76992..4fbe614e3d9c81 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -28,7 +28,7 @@ "types": "build-types", "dependencies": { "@preact/signals": "^1.2.2", - "deepsignal": "^1.4.0", + "@preact/signals-core": "1.6.1", "preact": "^10.19.3" }, "publishConfig": { diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts new file mode 100644 index 00000000000000..e840a232a1314e --- /dev/null +++ b/packages/interactivity/src/store/handlers.ts @@ -0,0 +1,128 @@ +/** + * External dependencies + */ +import { signal } from '@preact/signals'; + +/** + * Internal dependencies + */ +import { proxify, getProxyNs, shouldProxy } from './proxies'; +import { getProperty } from './properties'; +import { resetNamespace, setNamespace } from '../hooks'; +import { withScope } from '../utils'; + +const descriptor = Object.getOwnPropertyDescriptor; +const objToIterable = new WeakMap(); + +export const stateHandlers: ProxyHandler< object > = { + get( target: object, key: string, receiver: object ): any { + const ns = getProxyNs( receiver ); + + /* + * First, we get a reference of the property we want to access. The + * property object is automatically instanciated if needed. + */ + const prop = getProperty( target, key ); + + /* + * When the value is a getter, it updates the internal getter value. + * This change triggers the signal only when the getter value changes. + */ + const getter = descriptor( target, key )?.get; + if ( getter && ns ) { + prop.updateGetter( getter ); + setNamespace( ns ); + const result = prop.accessor( withScope ).value; + resetNamespace(); + return result; + } + + /* + * When it is not a getter, we get the actual value an apply different + * logic depending on the type of value. As before, the internal signal + * is updated, which only triggers a re-render when the value changes. + */ + const value = Reflect.get( target, key, receiver ); + prop.updateSignal( + shouldProxy( value ) && ns + ? proxify( value, stateHandlers, ns ) + : value + ); + + return prop.accessor().value; + }, + + set( + target: object, + key: string, + value: unknown, + receiver: object + ): boolean { + if ( typeof descriptor( target, key )?.set === 'function' ) { + return Reflect.set( target, key, value, receiver ); + } + + const ns = getProxyNs( receiver ); + if ( shouldProxy( value ) && ns ) { + value = proxify( value, stateHandlers, ns ); + } + + const result = Reflect.set( target, key, value, receiver ); + + if ( result ) { + const isNew = ! ( key in target ); + + if ( isNew && objToIterable.has( target ) ) { + objToIterable.get( target ).value++; + } + + if ( Array.isArray( target ) ) { + const length = getProperty( target, 'length' ); + length.updateSignal( target.length ); + } + } + + return result; + }, + + defineProperty( + target: object, + key: string, + desc: PropertyDescriptor + ): boolean { + const prop = getProperty( target, key ); + const result = Reflect.defineProperty( target, key, desc ); + + if ( result ) { + if ( desc.get ) { + prop.updateGetter( desc.get ); + } else if ( desc.value ) { + prop.updateSignal( desc.value ); + } + } + return result; + }, + + deleteProperty( target: object, key: string ): boolean { + const result = Reflect.deleteProperty( target, key ); + + if ( result ) { + const prop = getProperty( target, key ); + prop.updateSignal( 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 ) ); + } + ( objToIterable as any )._ = objToIterable.get( target ).value; + return Reflect.ownKeys( target ); + }, +}; diff --git a/packages/interactivity/src/store/index.ts b/packages/interactivity/src/store/index.ts new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/interactivity/src/store/properties.ts b/packages/interactivity/src/store/properties.ts new file mode 100644 index 00000000000000..f6968d151e9b3e --- /dev/null +++ b/packages/interactivity/src/store/properties.ts @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { + computed, + signal, + batch, + type Signal, + type ReadonlySignal, +} from '@preact/signals-core'; + +/** + * Internal dependencies + */ +import { getProxy } from './proxies'; +import { getScope } from '../hooks'; + +const DEFAULT_SCOPE = {}; +const objToProps: WeakMap< object, Map< string, Property > > = new WeakMap(); + +export const getProperty = ( target: object, key: string ) => { + if ( ! objToProps.has( target ) ) { + objToProps.set( target, new Map() ); + } + const props = objToProps.get( target )!; + if ( ! props.has( key ) ) { + props.set( key, new Property( target ) ); + } + return props.get( key )!; +}; + +class Property { + private owner: object; + private accessors: WeakMap< object, ReadonlySignal >; + private signal?: Signal; + private getter?: Signal< ( () => any ) | undefined >; + + constructor( owner: object ) { + this.owner = owner; + this.accessors = new WeakMap(); + } + + updateSignal( value: unknown | undefined ) { + if ( ! this.signal ) { + this.signal = signal( value ); + this.getter = signal( undefined ); + } else if ( value !== this.signal.peek() ) { + batch( () => { + this.signal!.value = value; + this.getter!.value = undefined; + } ); + } + } + + updateGetter( getter: () => any | undefined ) { + if ( ! this.getter ) { + this.signal = signal( undefined ); + this.getter = signal( getter ); + } else if ( getter !== this.getter.peek() ) { + batch( () => { + this.signal!.value = undefined; + this.getter!.value = getter; + } ); + } + } + + accessor( + wrapper?: < G extends () => any >( getter: G ) => G + ): ReadonlySignal { + const scope = getScope() || DEFAULT_SCOPE; + + if ( ! this.accessors.has( scope ) ) { + this.accessors.set( + scope, + computed( () => { + const getter = this.getter?.value; + if ( getter ) { + return wrapper + ? wrapper( () => + getter.call( getProxy( this.owner ) ) + )() + : getter.call( this.owner ); + } + return this.signal?.value; + } ) + ); + } + return this.accessors.get( scope )!; + } +} diff --git a/packages/interactivity/src/store/proxies.ts b/packages/interactivity/src/store/proxies.ts new file mode 100644 index 00000000000000..34e2b37d90469d --- /dev/null +++ b/packages/interactivity/src/store/proxies.ts @@ -0,0 +1,30 @@ +const objToProxy = new WeakMap< object, object >(); +const proxyToNs = new WeakMap< object, string >(); +const ignore = new WeakSet< object >(); + +export const proxify = < T extends object >( + obj: T, + handlers: ProxyHandler< T >, + namespace: string +): T => { + if ( ! objToProxy.has( obj ) ) { + const proxy = new Proxy( obj, handlers ); + ignore.add( proxy ); + objToProxy.set( obj, proxy ); + proxyToNs.set( proxy, namespace ); + } + return objToProxy.get( obj ) as T; +}; + +export const getProxyNs = ( proxy: object ) => proxyToNs.get( proxy ); +export const getProxy = < T extends object >( obj: T ) => + objToProxy.get( obj ) as T; + +export const shouldProxy = ( val: any ): val is Object | Array< unknown > => { + if ( typeof val !== 'object' || val === null ) { + return false; + } + return ! ignore.has( val ) && supported.has( val.constructor ); +}; + +const supported = new Set( [ Object, Array ] ); diff --git a/packages/interactivity/src/store/test/state-handlers.ts b/packages/interactivity/src/store/test/state-handlers.ts new file mode 100644 index 00000000000000..4469535de8bacb --- /dev/null +++ b/packages/interactivity/src/store/test/state-handlers.ts @@ -0,0 +1,217 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable jest/no-identical-title */ +/* eslint-disable @typescript-eslint/no-shadow */ +/** + * External dependencies + */ +import { Signal, effect, signal } from '@preact/signals-core'; +/** + * Internal dependencies + */ +import { proxify } from '../proxies'; +import { stateHandlers } from '../handlers'; + +type State = { + a?: number; + nested: { b?: number }; + array: ( number | State[ 'nested' ] )[]; +}; + +const proxifyState = < T extends object >( obj: T ) => + proxify( obj, stateHandlers, 'test' ) as T; + +describe( 'interactivity api handlers', () => { + let nested = { b: 2 }; + let array = [ 3, nested ]; + let state: State = { a: 1, nested, array }; + let store = proxifyState( state ); + + const window = globalThis as any; + + beforeEach( () => { + nested = { b: 2 }; + array = [ 3, nested ]; + state = { a: 1, nested, array }; + store = proxifyState( state ); + } ); + + describe( 'get - plain', () => { + it( 'should return plain objects/arrays', () => { + expect( store.nested ).equals( { b: 2 } ); + expect( store.array ).equals( [ 3, { b: 2 } ] ); + expect( store.array[ 1 ] ).equals( { b: 2 } ); + } ); + + it( 'should return plain primitives', () => { + expect( store.a ).toBe( 1 ); + expect( store.nested.b ).toBe( 2 ); + expect( store.array[ 0 ] ).toBe( 3 ); + expect( + typeof store.array[ 1 ] === 'object' && store.array[ 1 ].b + ).toBe( 2 ); + expect( store.array.length ).toBe( 2 ); + } ); + + it( 'should support reading from getters', () => { + const store = proxifyState( { + counter: 1, + get double() { + return store.counter * 2; + }, + } ); + expect( store.double ).toBe( 2 ); + store.counter = 2; + expect( store.double ).toBe( 4 ); + } ); + + it( 'should support getters returning other parts of the state', () => { + const store = proxifyState( { + switch: 'a', + a: { data: 'a' }, + b: { data: 'b' }, + get aOrB() { + return store.switch === 'a' ? store.a : store.b; + }, + } ); + expect( store.aOrB.data ).toBe( 'a' ); + store.switch = 'b'; + expect( store.aOrB.data ).toBe( 'b' ); + } ); + + it( 'should support getters using ownKeys traps', () => { + const state = proxifyState( { + x: { + a: 1, + b: 2, + }, + get y() { + return Object.values( state.x ); + }, + } ); + + expect( state.y ).equals( [ 1, 2 ] ); + } ); + + it( 'should work with normal functions', () => { + const store = proxifyState( { + value: 1, + isBigger: ( newValue: number ): boolean => + store.value < newValue, + sum( newValue: number ): number { + return store.value + newValue; + }, + replace: ( newValue: number ): void => { + store.value = newValue; + }, + } ); + expect( store.isBigger( 2 ) ).toBe( true ); + expect( store.sum( 2 ) ).toBe( 3 ); + expect( store.value ).toBe( 1 ); + store.replace( 2 ); + expect( store.value ).toBe( 2 ); + } ); + } ); + + describe( 'set', () => { + it( 'should update like plain objects/arrays', () => { + expect( store.a ).toBe( 1 ); + expect( store.nested.b ).toBe( 2 ); + store.a = 2; + store.nested.b = 3; + expect( store.a ).toBe( 2 ); + expect( store.nested.b ).toBe( 3 ); + } ); + + it( 'should support setting values with setters', () => { + const store = proxifyState( { + counter: 1, + get double() { + return store.counter * 2; + }, + set double( val ) { + store.counter = val / 2; + }, + } ); + expect( store.counter ).toBe( 1 ); + store.double = 4; + expect( store.counter ).toBe( 2 ); + } ); + + it( 'should update array length', () => { + expect( store.array.length ).toBe( 2 ); + store.array.push( 4 ); + expect( store.array.length ).toBe( 3 ); + store.array.splice( 1, 2 ); + expect( store.array.length ).toBe( 1 ); + } ); + + it( 'should update when mutations happen', () => { + expect( store.a ).toBe( 1 ); + store.a = 11; + expect( store.a ).toBe( 11 ); + } ); + + it( 'should support setting getters on the fly', () => { + const store = proxifyState< { counter: number; double?: number } >( + { + counter: 1, + } + ); + Object.defineProperty( store, 'double', { + get() { + return store.counter * 2; + }, + } ); + expect( store.double ).toBe( 2 ); + store.counter = 2; + expect( store.double ).toBe( 4 ); + } ); + + it( 'should copy object like plain JavaScript', () => { + const store = proxifyState< { + a?: { id: number; nested: { id: number } }; + b: { id: number; nested: { id: number } }; + } >( { + b: { id: 1, nested: { id: 1 } }, + } ); + + store.a = store.b; + + expect( store.a.id ).toBe( 1 ); + expect( store.b.id ).toBe( 1 ); + expect( store.a.nested.id ).toBe( 1 ); + expect( store.b.nested.id ).toBe( 1 ); + + store.a.id = 2; + store.a.nested.id = 2; + expect( store.a.id ).toBe( 2 ); + expect( store.b.id ).toBe( 2 ); + expect( store.a.nested.id ).toBe( 2 ); + expect( store.b.nested.id ).toBe( 2 ); + + store.b.id = 3; + store.b.nested.id = 3; + expect( store.b.id ).toBe( 3 ); + expect( store.a.id ).toBe( 3 ); + expect( store.a.nested.id ).toBe( 3 ); + expect( store.b.nested.id ).toBe( 3 ); + + store.a.id = 4; + store.a.nested.id = 4; + expect( store.a.id ).toBe( 4 ); + expect( store.b.id ).toBe( 4 ); + expect( store.a.nested.id ).toBe( 4 ); + expect( store.b.nested.id ).toBe( 4 ); + } ); + + it( 'should be able to reset values with Object.assign', () => { + const initialNested = { ...nested }; + const initialState = { ...state, nested: initialNested }; + store.a = 2; + store.nested.b = 3; + Object.assign( store, initialState ); + expect( store.a ).toBe( 1 ); + expect( store.nested.b ).toBe( 2 ); + } ); + } ); +} ); From e78df315f1d7c1f491b66ab9fe76232cf03bb73b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 02/91] More WIP --- package-lock.json | 32 +++++++++++++++++ packages/interactivity/package.json | 1 + packages/interactivity/src/store/handlers.ts | 31 ++++++++-------- .../interactivity/src/store/properties.ts | 36 +++++++++---------- 4 files changed, 63 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd8f4b0fb6e594..c7421dfddd89c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23244,6 +23244,31 @@ "node": ">=16.0.0" } }, + "node_modules/deepsignal": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.5.0.tgz", + "integrity": "sha512-bFywDpBUUWMs576H2dgLFLLFuQ/UWXbzHfKD98MZTfGsl7+twIzvz4ihCNrRrZ/Emz3kqJaNIAp5eBWUEWhnAw==", + "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 + } + } + }, "node_modules/default-browser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", @@ -53720,6 +53745,7 @@ "dependencies": { "@preact/signals": "^1.2.2", "@preact/signals-core": "1.6.1", + "deepsignal": "1.5.0", "preact": "^10.19.3" }, "engines": { @@ -68241,6 +68267,7 @@ "requires": { "@preact/signals": "^1.2.2", "@preact/signals-core": "1.6.1", + "deepsignal": "1.5.0", "preact": "^10.19.3" }, "dependencies": { @@ -73777,6 +73804,11 @@ "integrity": "sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==", "dev": true }, + "deepsignal": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.5.0.tgz", + "integrity": "sha512-bFywDpBUUWMs576H2dgLFLLFuQ/UWXbzHfKD98MZTfGsl7+twIzvz4ihCNrRrZ/Emz3kqJaNIAp5eBWUEWhnAw==" + }, "default-browser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index 4fbe614e3d9c81..53b4ef2b6afdad 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -29,6 +29,7 @@ "dependencies": { "@preact/signals": "^1.2.2", "@preact/signals-core": "1.6.1", + "deepsignal": "1.5.0", "preact": "^10.19.3" }, "publishConfig": { diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts index e840a232a1314e..5bcf304ca1f05a 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/store/handlers.ts @@ -30,11 +30,11 @@ export const stateHandlers: ProxyHandler< object > = { */ const getter = descriptor( target, key )?.get; if ( getter && ns ) { - prop.updateGetter( getter ); + prop.update( { get: getter } ); setNamespace( ns ); - const result = prop.accessor( withScope ).value; + const value = prop.get( withScope ).value; resetNamespace(); - return result; + return value; } /* @@ -43,13 +43,14 @@ export const stateHandlers: ProxyHandler< object > = { * is updated, which only triggers a re-render when the value changes. */ const value = Reflect.get( target, key, receiver ); - prop.updateSignal( - shouldProxy( value ) && ns - ? proxify( value, stateHandlers, ns ) - : value - ); - - return prop.accessor().value; + prop.update( { + value: + shouldProxy( value ) && ns + ? proxify( value, stateHandlers, ns ) + : value, + } ); + + return prop.get().value; }, set( @@ -78,7 +79,7 @@ export const stateHandlers: ProxyHandler< object > = { if ( Array.isArray( target ) ) { const length = getProperty( target, 'length' ); - length.updateSignal( target.length ); + length.update( { value: target.length } ); } } @@ -94,11 +95,7 @@ export const stateHandlers: ProxyHandler< object > = { const result = Reflect.defineProperty( target, key, desc ); if ( result ) { - if ( desc.get ) { - prop.updateGetter( desc.get ); - } else if ( desc.value ) { - prop.updateSignal( desc.value ); - } + prop.update( desc ); } return result; }, @@ -108,7 +105,7 @@ export const stateHandlers: ProxyHandler< object > = { if ( result ) { const prop = getProperty( target, key ); - prop.updateSignal( undefined ); + prop.update( {} ); if ( objToIterable.has( target ) ) { objToIterable.get( target ).value++; diff --git a/packages/interactivity/src/store/properties.ts b/packages/interactivity/src/store/properties.ts index f6968d151e9b3e..6cfee98e8ced24 100644 --- a/packages/interactivity/src/store/properties.ts +++ b/packages/interactivity/src/store/properties.ts @@ -15,7 +15,7 @@ import { import { getProxy } from './proxies'; import { getScope } from '../hooks'; -const DEFAULT_SCOPE = {}; +const DEFAULT_SCOPE = Symbol(); const objToProps: WeakMap< object, Map< string, Property > > = new WeakMap(); export const getProperty = ( target: object, key: string ) => { @@ -40,33 +40,28 @@ class Property { this.accessors = new WeakMap(); } - updateSignal( value: unknown | undefined ) { + public update( { + get, + value, + }: { + get?: () => any; + value?: unknown; + } ): void { if ( ! this.signal ) { this.signal = signal( value ); - this.getter = signal( undefined ); - } else if ( value !== this.signal.peek() ) { + this.getter = signal( get ); + } else if ( + value !== this.signal.peek() || + get !== this.getter?.peek() + ) { batch( () => { this.signal!.value = value; - this.getter!.value = undefined; + this.getter!.value = get; } ); } } - updateGetter( getter: () => any | undefined ) { - if ( ! this.getter ) { - this.signal = signal( undefined ); - this.getter = signal( getter ); - } else if ( getter !== this.getter.peek() ) { - batch( () => { - this.signal!.value = undefined; - this.getter!.value = getter; - } ); - } - } - - accessor( - wrapper?: < G extends () => any >( getter: G ) => G - ): ReadonlySignal { + get( wrapper?: < G extends () => any >( getter: G ) => G ): ReadonlySignal { const scope = getScope() || DEFAULT_SCOPE; if ( ! this.accessors.has( scope ) ) { @@ -85,6 +80,7 @@ class Property { } ) ); } + return this.accessors.get( scope )!; } } From 80c1da92bccf418359787d69f5b0dd3baf94f27a Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 03/91] Restore previous packages --- package-lock.json | 88 ++++++++++++++--------------- packages/interactivity/package.json | 3 +- 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index c7421dfddd89c0..6680025d768bef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7142,6 +7142,15 @@ "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", "dev": true }, + "node_modules/@preact/signals-core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.4.0.tgz", + "integrity": "sha512-5iYoZBhELLIhUQceZI7sDTQWPb+xcVSn2qk8T/aNl/VMh+A4AiPX9YRSh4XO7fZ6pncrVxl1Iln82poVqYVbbw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@puppeteer/browsers": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", @@ -23244,31 +23253,6 @@ "node": ">=16.0.0" } }, - "node_modules/deepsignal": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.5.0.tgz", - "integrity": "sha512-bFywDpBUUWMs576H2dgLFLLFuQ/UWXbzHfKD98MZTfGsl7+twIzvz4ihCNrRrZ/Emz3kqJaNIAp5eBWUEWhnAw==", - "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 - } - } - }, "node_modules/default-browser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", @@ -53744,8 +53728,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@preact/signals": "^1.2.2", - "@preact/signals-core": "1.6.1", - "deepsignal": "1.5.0", + "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "engines": { @@ -53780,13 +53763,29 @@ "preact": "10.x" } }, - "packages/interactivity/node_modules/@preact/signals-core": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.6.1.tgz", - "integrity": "sha512-KXEEmJoKDlo0Igju/cj9YvKIgyaWFDgnprShQjzimUd5VynAAdTWMshawEOjUVeKbsI0aR58V6WOQp+DNcKApw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" + "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": { @@ -60124,6 +60123,11 @@ "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", "dev": true }, + "@preact/signals-core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.4.0.tgz", + "integrity": "sha512-5iYoZBhELLIhUQceZI7sDTQWPb+xcVSn2qk8T/aNl/VMh+A4AiPX9YRSh4XO7fZ6pncrVxl1Iln82poVqYVbbw==" + }, "@puppeteer/browsers": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", @@ -68266,8 +68270,7 @@ "version": "file:packages/interactivity", "requires": { "@preact/signals": "^1.2.2", - "@preact/signals-core": "1.6.1", - "deepsignal": "1.5.0", + "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "dependencies": { @@ -68279,10 +68282,10 @@ "@preact/signals-core": "^1.4.0" } }, - "@preact/signals-core": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.6.1.tgz", - "integrity": "sha512-KXEEmJoKDlo0Igju/cj9YvKIgyaWFDgnprShQjzimUd5VynAAdTWMshawEOjUVeKbsI0aR58V6WOQp+DNcKApw==" + "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", @@ -73804,11 +73807,6 @@ "integrity": "sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==", "dev": true }, - "deepsignal": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.5.0.tgz", - "integrity": "sha512-bFywDpBUUWMs576H2dgLFLLFuQ/UWXbzHfKD98MZTfGsl7+twIzvz4ihCNrRrZ/Emz3kqJaNIAp5eBWUEWhnAw==" - }, "default-browser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index 53b4ef2b6afdad..835063ccc76992 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -28,8 +28,7 @@ "types": "build-types", "dependencies": { "@preact/signals": "^1.2.2", - "@preact/signals-core": "1.6.1", - "deepsignal": "1.5.0", + "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "publishConfig": { From 852bc992edec3d5c611c10000b779172ff397877 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 04/91] Fix current tests --- packages/interactivity/src/store/test/state-handlers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/interactivity/src/store/test/state-handlers.ts b/packages/interactivity/src/store/test/state-handlers.ts index 4469535de8bacb..96c525a601e3bd 100644 --- a/packages/interactivity/src/store/test/state-handlers.ts +++ b/packages/interactivity/src/store/test/state-handlers.ts @@ -37,9 +37,9 @@ describe( 'interactivity api handlers', () => { describe( 'get - plain', () => { it( 'should return plain objects/arrays', () => { - expect( store.nested ).equals( { b: 2 } ); - expect( store.array ).equals( [ 3, { b: 2 } ] ); - expect( store.array[ 1 ] ).equals( { b: 2 } ); + expect( store.nested ).toEqual( { b: 2 } ); + expect( store.array ).toEqual( [ 3, { b: 2 } ] ); + expect( store.array[ 1 ] ).toEqual( { b: 2 } ); } ); it( 'should return plain primitives', () => { @@ -89,7 +89,7 @@ describe( 'interactivity api handlers', () => { }, } ); - expect( state.y ).equals( [ 1, 2 ] ); + expect( state.y ).toEqual( [ 1, 2 ] ); } ); it( 'should work with normal functions', () => { From 0688cff7af42b88f9cadbe2c805d0078fc0e3341 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 05/91] Add computation tests --- .../src/store/test/state-handlers.ts | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) diff --git a/packages/interactivity/src/store/test/state-handlers.ts b/packages/interactivity/src/store/test/state-handlers.ts index 96c525a601e3bd..eefae0007be4bc 100644 --- a/packages/interactivity/src/store/test/state-handlers.ts +++ b/packages/interactivity/src/store/test/state-handlers.ts @@ -214,4 +214,514 @@ describe( 'interactivity api handlers', () => { expect( store.nested.b ).toBe( 2 ); } ); } ); + + describe( 'computations', () => { + it( 'should subscribe to values mutated with setters', () => { + const store = proxifyState( { + counter: 1, + get double() { + return store.counter * 2; + }, + set double( val ) { + store.counter = val / 2; + }, + } ); + let counter = 0; + let double = 0; + + effect( () => { + counter = store.counter; + double = store.double; + } ); + + expect( counter ).toBe( 1 ); + expect( double ).toBe( 2 ); + store.double = 4; + expect( counter ).toBe( 2 ); + expect( double ).toBe( 4 ); + } ); + + it( 'should subscribe to changes when an item is removed from the array', () => { + const store = proxifyState( [ 0, 0, 0 ] ); + let sum = 0; + + effect( () => { + sum = 0; + sum = store.reduce( ( sum ) => sum + 1, 0 ); + } ); + + expect( sum ).toBe( 3 ); + store.splice( 2, 1 ); + expect( sum ).toBe( 2 ); + } ); + + it( 'should subscribe to changes to for..in loops', () => { + const state: Record< string, number > = { a: 0, b: 0 }; + const store = proxifyState( state ); + let sum = 0; + + effect( () => { + sum = 0; + for ( const _ in store ) { + sum += 1; + } + } ); + + expect( sum ).toBe( 2 ); + + store.c = 0; + expect( sum ).toBe( 3 ); + + delete store.c; + expect( sum ).toBe( 2 ); + + store.c = 0; + expect( sum ).toBe( 3 ); + } ); + + it( 'should subscribe to changes for Object.getOwnPropertyNames()', () => { + const state: Record< string, number > = { a: 1, b: 2 }; + const store = proxifyState( state ); + let sum = 0; + + effect( () => { + sum = 0; + const keys = Object.getOwnPropertyNames( store ); + for ( const _ of keys ) { + sum += 1; + } + } ); + + expect( sum ).toBe( 2 ); + + store.c = 0; + expect( sum ).toBe( 3 ); + + delete store.a; + expect( sum ).toBe( 2 ); + } ); + + it( 'should subscribe to changes to Object.keys/values/entries()', () => { + const state: Record< string, number > = { a: 1, b: 2 }; + const store = proxifyState( state ); + let keys = 0; + let values = 0; + let entries = 0; + + effect( () => { + keys = 0; + Object.keys( store ).forEach( () => ( keys += 1 ) ); + } ); + + effect( () => { + values = 0; + Object.values( store ).forEach( () => ( values += 1 ) ); + } ); + + effect( () => { + entries = 0; + Object.entries( store ).forEach( () => ( entries += 1 ) ); + } ); + + expect( keys ).toBe( 2 ); + expect( values ).toBe( 2 ); + expect( entries ).toBe( 2 ); + + store.c = 0; + expect( keys ).toBe( 3 ); + expect( values ).toBe( 3 ); + expect( entries ).toBe( 3 ); + + delete store.a; + expect( keys ).toBe( 2 ); + expect( values ).toBe( 2 ); + expect( entries ).toBe( 2 ); + } ); + + it( 'should subscribe to changes to for..of loops', () => { + const store = proxifyState( [ 0, 0 ] ); + let sum = 0; + + effect( () => { + sum = 0; + for ( const _ of store ) { + sum += 1; + } + } ); + + expect( sum ).toBe( 2 ); + + store.push( 0 ); + expect( sum ).toBe( 3 ); + + store.splice( 0, 1 ); + expect( sum ).toBe( 2 ); + } ); + + it( 'should subscribe to implicit changes in length', () => { + const store = proxifyState( [ 'foo', 'bar' ] ); + let x = ''; + + effect( () => { + x = store.join( ' ' ); + } ); + + expect( x ).toBe( 'foo bar' ); + + store.push( 'baz' ); + expect( x ).toBe( 'foo bar baz' ); + + store.splice( 0, 1 ); + expect( x ).toBe( 'bar baz' ); + } ); + + it( 'should subscribe to changes when deleting properties', () => { + let x, y; + + effect( () => { + x = store.a; + } ); + + effect( () => { + y = store.nested.b; + } ); + + expect( x ).toBe( 1 ); + delete store.a; + expect( x ).toBe( undefined ); + + expect( y ).toBe( 2 ); + delete store.nested.b; + expect( y ).toBe( undefined ); + } ); + + it( 'should subscribe to changes when mutating objects', () => { + let x, y; + + const store = proxifyState< { + a?: { id: number; nested: { id: number } }; + b: { id: number; nested: { id: number } }[]; + } >( { + b: [ + { id: 1, nested: { id: 1 } }, + { id: 2, nested: { id: 2 } }, + ], + } ); + + effect( () => { + x = store.a?.id; + } ); + + effect( () => { + y = store.a?.nested.id; + } ); + + expect( x ).toBe( undefined ); + expect( y ).toBe( undefined ); + + store.a = store.b[ 0 ]; + + expect( x ).toBe( 1 ); + expect( y ).toBe( 1 ); + + store.a = store.b[ 1 ]; + expect( x ).toBe( 2 ); + expect( y ).toBe( 2 ); + + store.a = undefined; + expect( x ).toBe( undefined ); + expect( y ).toBe( undefined ); + + store.a = store.b[ 1 ]; + expect( x ).toBe( 2 ); + expect( y ).toBe( 2 ); + } ); + + it( 'should trigger effects after mutations happen', () => { + let x; + effect( () => { + x = store.a; + } ); + expect( x ).toBe( 1 ); + store.a = 11; + expect( x ).toBe( 11 ); + } ); + + it( 'should subscribe corretcly from getters', () => { + let x; + const store = proxifyState( { + counter: 1, + get double() { + return store.counter * 2; + }, + } ); + effect( () => ( x = store.double ) ); + expect( x ).toBe( 2 ); + store.counter = 2; + expect( x ).toBe( 4 ); + } ); + + it( 'should subscribe corretcly from getters returning other parts of the store', () => { + let data; + const store = proxifyState( { + switch: 'a', + a: { data: 'a' }, + b: { data: 'b' }, + get aOrB() { + return store.switch === 'a' ? store.a : store.b; + }, + } ); + effect( () => ( data = store.aOrB.data ) ); + expect( data ).toBe( 'a' ); + store.switch = 'b'; + expect( data ).toBe( 'b' ); + } ); + + it( 'should subscribe to changes', () => { + const spy1 = jest.fn( () => store.a ); + const spy2 = jest.fn( () => store.nested ); + const spy3 = jest.fn( () => store.nested.b ); + const spy4 = jest.fn( () => store.array[ 0 ] ); + const spy5 = jest.fn( + () => typeof store.array[ 1 ] === 'object' && store.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 ); + + store.a = 11; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy3 ).toHaveBeenCalledTimes( 1 ); + expect( spy4 ).toHaveBeenCalledTimes( 1 ); + expect( spy5 ).toHaveBeenCalledTimes( 1 ); + + store.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] + + store.nested = { b: 222 }; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 1 ); + expect( spy5 ).toHaveBeenCalledTimes( 2 ); // now store.nested has a different reference + + store.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 store.array[ 1 ] === 'object' ) { + store.array[ 1 ].b = 2222; + } + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 3 ); + + store.array[ 1 ] = { b: 22222 }; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); + + store.array.push( 4 ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); + + store.array[ 3 ] = 5; + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); + + store.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 store = proxifyState( { array } ); + const spy1 = jest.fn( () => store.array.length ); + const spy2 = jest.fn( () => store.array.map( ( i: number ) => i ) ); + + effect( spy1 ); + effect( spy2 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + + store.array.push( 2 ); + expect( store.array.length ).toBe( 2 ); + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + + store.array[ 2 ] = 3; + expect( store.array.length ).toBe( 3 ); + expect( spy1 ).toHaveBeenCalledTimes( 3 ); + expect( spy2 ).toHaveBeenCalledTimes( 3 ); + + store.array = store.array.filter( ( i: number ) => i <= 2 ); + expect( store.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 = { ...state, nested: initialNested }; + let a, b; + + effect( () => { + a = store.a; + } ); + effect( () => { + b = store.nested.b; + } ); + + store.a = 2; + store.nested.b = 3; + + expect( a ).toBe( 2 ); + expect( b ).toBe( 3 ); + + Object.assign( store, initialState ); + + expect( a ).toBe( 1 ); + expect( b ).toBe( 2 ); + } ); + + it( 'should keep subscribed to properties even when replaced by getters', () => { + const store = proxifyState( { + number: 1, + } ); + + let number = 0; + + effect( () => { + number = store.number; + } ); + + expect( number ).toBe( 1 ); + store.number = 2; + expect( number ).toBe( 2 ); + Object.defineProperty( store, 'number', { + get: () => 3, + configurable: true, + } ); + expect( number ).toBe( 3 ); + } ); + + it( 'should react to changes in getter subscriptions', () => { + const store = proxifyState( { + number: 1, + otherNumber: 3, + } ); + + let number = 0; + + effect( () => { + number = store.number; + } ); + + expect( number ).toBe( 1 ); + store.number = 2; + expect( number ).toBe( 2 ); + Object.defineProperty( store, 'number', { + get: () => store.otherNumber, + configurable: true, + } ); + expect( number ).toBe( 3 ); + store.otherNumber = 4; + expect( number ).toBe( 4 ); + } ); + + it( 'should react to changes in getter subscriptions even if they become getters', () => { + const store = proxifyState( { + number: 1, + otherNumber: 3, + } ); + + let number = 0; + + effect( () => { + number = store.number; + } ); + + expect( number ).toBe( 1 ); + store.number = 2; + expect( number ).toBe( 2 ); + Object.defineProperty( store, 'number', { + get: () => store.otherNumber, + configurable: true, + } ); + expect( number ).toBe( 3 ); + store.otherNumber = 4; + expect( number ).toBe( 4 ); + Object.defineProperty( store, 'otherNumber', { + get: () => 5, + configurable: true, + } ); + expect( number ).toBe( 5 ); + } ); + + it( 'should allow getters to use `this`', () => { + const store = proxifyState( { + number: 1, + otherNumber: 3, + } ); + + let number = 0; + + effect( () => { + number = store.number; + } ); + + expect( number ).toBe( 1 ); + store.number = 2; + expect( number ).toBe( 2 ); + Object.defineProperty( store, 'number', { + get() { + return this.otherNumber; + }, + configurable: true, + } ); + expect( number ).toBe( 3 ); + store.otherNumber = 4; + expect( number ).toBe( 4 ); + } ); + } ); } ); From 4dec7586f6f644804abb63475db3a1aa11f71df0 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 06/91] Fix getter call --- packages/interactivity/src/store/properties.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/interactivity/src/store/properties.ts b/packages/interactivity/src/store/properties.ts index 6cfee98e8ced24..caf2caf73d159e 100644 --- a/packages/interactivity/src/store/properties.ts +++ b/packages/interactivity/src/store/properties.ts @@ -70,11 +70,10 @@ class Property { computed( () => { const getter = this.getter?.value; if ( getter ) { + const proxy = getProxy( this.owner ); return wrapper - ? wrapper( () => - getter.call( getProxy( this.owner ) ) - )() - : getter.call( this.owner ); + ? wrapper( () => getter.call( proxy ) )() + : getter.call( proxy ); } return this.signal?.value; } ) From dace30237bdc9657118f2a9fe37d4ec2d0006a50 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 07/91] Fix for..in on new properties --- packages/interactivity/src/store/handlers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts index 5bcf304ca1f05a..f7be8be9727dca 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/store/handlers.ts @@ -68,11 +68,10 @@ export const stateHandlers: ProxyHandler< object > = { value = proxify( value, stateHandlers, ns ); } + const isNew = ! ( key in target ); const result = Reflect.set( target, key, value, receiver ); if ( result ) { - const isNew = ! ( key in target ); - if ( isNew && objToIterable.has( target ) ) { objToIterable.get( target ).value++; } From 20923cb13b6828c76d196922a55de14c3bfb2e1b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 08/91] Refactor code a little --- packages/interactivity/src/store/handlers.ts | 32 +++++++--- .../interactivity/src/store/properties.ts | 61 ++++++++----------- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts index f7be8be9727dca..bd366e5b3cc5d8 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/store/handlers.ts @@ -1,18 +1,32 @@ /** * External dependencies */ -import { signal } from '@preact/signals'; +import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { proxify, getProxyNs, shouldProxy } from './proxies'; -import { getProperty } from './properties'; +import { proxify, getProxy, getProxyNs, shouldProxy } from './proxies'; +import { Property } from './properties'; import { resetNamespace, setNamespace } from '../hooks'; import { withScope } from '../utils'; +const proxyToProps: WeakMap< object, Map< string, Property > > = new WeakMap(); +const objToIterable = new WeakMap< object, Signal< number > >(); + +const getProperty = ( target: object, key: string ) => { + const proxy = getProxy( target ); + if ( ! proxyToProps.has( proxy ) ) { + proxyToProps.set( proxy, new Map() ); + } + const props = proxyToProps.get( proxy )!; + if ( ! props.has( key ) ) { + props.set( key, new Property( proxy ) ); + } + return props.get( key )!; +}; + const descriptor = Object.getOwnPropertyDescriptor; -const objToIterable = new WeakMap(); export const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { @@ -32,7 +46,7 @@ export const stateHandlers: ProxyHandler< object > = { if ( getter && ns ) { prop.update( { get: getter } ); setNamespace( ns ); - const value = prop.get( withScope ).value; + const value = prop.getComputed( withScope ).value; resetNamespace(); return value; } @@ -50,7 +64,7 @@ export const stateHandlers: ProxyHandler< object > = { : value, } ); - return prop.get().value; + return prop.getComputed().value; }, set( @@ -73,7 +87,7 @@ export const stateHandlers: ProxyHandler< object > = { if ( result ) { if ( isNew && objToIterable.has( target ) ) { - objToIterable.get( target ).value++; + objToIterable.get( target )!.value++; } if ( Array.isArray( target ) ) { @@ -107,7 +121,7 @@ export const stateHandlers: ProxyHandler< object > = { prop.update( {} ); if ( objToIterable.has( target ) ) { - objToIterable.get( target ).value++; + objToIterable.get( target )!.value++; } } @@ -118,7 +132,7 @@ export const stateHandlers: ProxyHandler< object > = { if ( ! objToIterable.has( target ) ) { objToIterable.set( target, signal( 0 ) ); } - ( objToIterable as any )._ = objToIterable.get( target ).value; + ( objToIterable as any )._ = objToIterable.get( target )!.value; return Reflect.ownKeys( target ); }, }; diff --git a/packages/interactivity/src/store/properties.ts b/packages/interactivity/src/store/properties.ts index caf2caf73d159e..b16272ea150c76 100644 --- a/packages/interactivity/src/store/properties.ts +++ b/packages/interactivity/src/store/properties.ts @@ -12,32 +12,19 @@ import { /** * Internal dependencies */ -import { getProxy } from './proxies'; import { getScope } from '../hooks'; const DEFAULT_SCOPE = Symbol(); -const objToProps: WeakMap< object, Map< string, Property > > = new WeakMap(); -export const getProperty = ( target: object, key: string ) => { - if ( ! objToProps.has( target ) ) { - objToProps.set( target, new Map() ); - } - const props = objToProps.get( target )!; - if ( ! props.has( key ) ) { - props.set( key, new Property( target ) ); - } - return props.get( key )!; -}; - -class Property { +export class Property { private owner: object; - private accessors: WeakMap< object, ReadonlySignal >; - private signal?: Signal; - private getter?: Signal< ( () => any ) | undefined >; + private computedsByScope: WeakMap< WeakKey, ReadonlySignal >; + private valueSignal?: Signal; + private getterSignal?: Signal< ( () => any ) | undefined >; constructor( owner: object ) { this.owner = owner; - this.accessors = new WeakMap(); + this.computedsByScope = new WeakMap(); } public update( { @@ -47,39 +34,45 @@ class Property { get?: () => any; value?: unknown; } ): void { - if ( ! this.signal ) { - this.signal = signal( value ); - this.getter = signal( get ); + if ( ! this.valueSignal ) { + this.valueSignal = signal( value ); + this.getterSignal = signal( get ); } else if ( - value !== this.signal.peek() || - get !== this.getter?.peek() + value !== this.valueSignal.peek() || + get !== this.getterSignal?.peek() ) { batch( () => { - this.signal!.value = value; - this.getter!.value = get; + this.valueSignal!.value = value; + this.getterSignal!.value = get; } ); } } - get( wrapper?: < G extends () => any >( getter: G ) => G ): ReadonlySignal { + public getComputed( + wrapper?: < G extends () => any >( getter: G ) => G + ): ReadonlySignal { const scope = getScope() || DEFAULT_SCOPE; - if ( ! this.accessors.has( scope ) ) { - this.accessors.set( + if ( ! this.valueSignal && ! this.getterSignal ) { + this.update( {} ); + } + + if ( ! this.computedsByScope.has( scope ) ) { + this.computedsByScope.set( scope, computed( () => { - const getter = this.getter?.value; + const getter = this.getterSignal?.value; if ( getter ) { - const proxy = getProxy( this.owner ); + const owner = this.owner; return wrapper - ? wrapper( () => getter.call( proxy ) )() - : getter.call( proxy ); + ? wrapper( () => getter.call( owner ) )() + : getter.call( owner ); } - return this.signal?.value; + return this.valueSignal?.value; } ) ); } - return this.accessors.get( scope )!; + return this.computedsByScope.get( scope )!; } } From 38c4ca88a9246412a18a89f3c491c43d747dd416 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 09/91] Add tests for getters with scope --- .../interactivity/src/store/properties.ts | 18 +++--- .../src/store/test/state-handlers.ts | 57 ++++++++++++++++++- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/interactivity/src/store/properties.ts b/packages/interactivity/src/store/properties.ts index b16272ea150c76..0fd9de8d46c598 100644 --- a/packages/interactivity/src/store/properties.ts +++ b/packages/interactivity/src/store/properties.ts @@ -58,18 +58,16 @@ export class Property { } if ( ! this.computedsByScope.has( scope ) ) { + const callback = () => { + const getter = this.getterSignal?.value; + return getter + ? getter.call( this.owner ) + : this.valueSignal?.value; + }; + this.computedsByScope.set( scope, - computed( () => { - const getter = this.getterSignal?.value; - if ( getter ) { - const owner = this.owner; - return wrapper - ? wrapper( () => getter.call( owner ) )() - : getter.call( owner ); - } - return this.valueSignal?.value; - } ) + computed( wrapper ? wrapper( callback ) : callback ) ); } diff --git a/packages/interactivity/src/store/test/state-handlers.ts b/packages/interactivity/src/store/test/state-handlers.ts index eefae0007be4bc..73060a92d29253 100644 --- a/packages/interactivity/src/store/test/state-handlers.ts +++ b/packages/interactivity/src/store/test/state-handlers.ts @@ -4,12 +4,19 @@ /** * External dependencies */ -import { Signal, effect, signal } from '@preact/signals-core'; +import { effect } from '@preact/signals-core'; /** * Internal dependencies */ import { proxify } from '../proxies'; import { stateHandlers } from '../handlers'; +import { + setScope, + resetScope, + setNamespace, + resetNamespace, + getContext, +} from '../../hooks'; type State = { a?: number; @@ -17,6 +24,15 @@ type State = { array: ( number | State[ 'nested' ] )[]; }; +const withScopeAndNs = ( scope, ns, callback ) => () => { + setScope( scope ); + setNamespace( ns ); + const result = callback(); + resetNamespace(); + resetScope(); + return result; +}; + const proxifyState = < T extends object >( obj: T ) => proxify( obj, stateHandlers, 'test' ) as T; @@ -723,5 +739,44 @@ describe( 'interactivity api handlers', () => { store.otherNumber = 4; expect( number ).toBe( 4 ); } ); + + it( 'should support different scopes for getters', () => { + const store = proxifyState( { + number: 1, + get sum() { + const ctx = getContext(); + return ctx + ? this.number + ( ctx as any ).value + : this.number; + }, + } ); + + const scopeA = { + context: { test: { value: 10 } }, + }; + const scopeB = { + context: { test: { value: 20 } }, + }; + + let resultA = 0; + let resultB = 0; + + effect( + withScopeAndNs( scopeA, 'test', () => { + resultA = store.sum; + } ) + ); + effect( + withScopeAndNs( scopeB, 'test', () => { + resultB = store.sum; + } ) + ); + + expect( resultA ).toBe( 11 ); + expect( resultB ).toBe( 21 ); + store.number = 2; + expect( resultA ).toBe( 12 ); + expect( resultB ).toBe( 22 ); + } ); } ); } ); From 98163be379b0d024db85fc3b9fdd26ac0c837f85 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 10/91] Move namespaces to properties --- packages/interactivity/src/store/handlers.ts | 31 +++++++++---------- .../interactivity/src/store/properties.ts | 10 ++++-- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts index bd366e5b3cc5d8..2db853cd33d6a1 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/store/handlers.ts @@ -6,9 +6,8 @@ import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { proxify, getProxy, getProxyNs, shouldProxy } from './proxies'; +import { proxify, getProxy, shouldProxy } from './proxies'; import { Property } from './properties'; -import { resetNamespace, setNamespace } from '../hooks'; import { withScope } from '../utils'; const proxyToProps: WeakMap< object, Map< string, Property > > = new WeakMap(); @@ -30,8 +29,6 @@ const descriptor = Object.getOwnPropertyDescriptor; export const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { - const ns = getProxyNs( receiver ); - /* * First, we get a reference of the property we want to access. The * property object is automatically instanciated if needed. @@ -43,11 +40,9 @@ export const stateHandlers: ProxyHandler< object > = { * This change triggers the signal only when the getter value changes. */ const getter = descriptor( target, key )?.get; - if ( getter && ns ) { + if ( getter ) { prop.update( { get: getter } ); - setNamespace( ns ); const value = prop.getComputed( withScope ).value; - resetNamespace(); return value; } @@ -58,10 +53,9 @@ export const stateHandlers: ProxyHandler< object > = { */ const value = Reflect.get( target, key, receiver ); prop.update( { - value: - shouldProxy( value ) && ns - ? proxify( value, stateHandlers, ns ) - : value, + value: shouldProxy( value ) + ? proxify( value, stateHandlers, prop.namespace ) + : value, } ); return prop.getComputed().value; @@ -77,11 +71,6 @@ export const stateHandlers: ProxyHandler< object > = { return Reflect.set( target, key, value, receiver ); } - const ns = getProxyNs( receiver ); - if ( shouldProxy( value ) && ns ) { - value = proxify( value, stateHandlers, ns ); - } - const isNew = ! ( key in target ); const result = Reflect.set( target, key, value, receiver ); @@ -105,7 +94,15 @@ export const stateHandlers: ProxyHandler< object > = { desc: PropertyDescriptor ): boolean { const prop = getProperty( target, key ); - const result = Reflect.defineProperty( target, key, desc ); + let { value } = desc; + if ( value && shouldProxy( value ) ) { + value = proxify( value, stateHandlers, prop.namespace ); + } + const result = Reflect.defineProperty( + target, + key, + value ? { ...desc, value } : desc + ); if ( result ) { prop.update( desc ); diff --git a/packages/interactivity/src/store/properties.ts b/packages/interactivity/src/store/properties.ts index 0fd9de8d46c598..09f07f393265fc 100644 --- a/packages/interactivity/src/store/properties.ts +++ b/packages/interactivity/src/store/properties.ts @@ -12,11 +12,13 @@ import { /** * Internal dependencies */ -import { getScope } from '../hooks'; +import { getScope, setNamespace, resetNamespace } from '../hooks'; +import { getProxyNs } from './proxies'; const DEFAULT_SCOPE = Symbol(); export class Property { + public readonly namespace: string; private owner: object; private computedsByScope: WeakMap< WeakKey, ReadonlySignal >; private valueSignal?: Signal; @@ -24,6 +26,7 @@ export class Property { constructor( owner: object ) { this.owner = owner; + this.namespace = getProxyNs( owner )!; this.computedsByScope = new WeakMap(); } @@ -33,7 +36,7 @@ export class Property { }: { get?: () => any; value?: unknown; - } ): void { + } ): Property { if ( ! this.valueSignal ) { this.valueSignal = signal( value ); this.getterSignal = signal( get ); @@ -46,6 +49,7 @@ export class Property { this.getterSignal!.value = get; } ); } + return this; } public getComputed( @@ -65,10 +69,12 @@ export class Property { : this.valueSignal?.value; }; + setNamespace( this.namespace ); this.computedsByScope.set( scope, computed( wrapper ? wrapper( callback ) : callback ) ); + resetNamespace(); } return this.computedsByScope.get( scope )!; From 55cca00732e290eb40286b2eb5b10a8f2b7c8e69 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 11/91] Fix a problem with object references --- packages/interactivity/src/store/handlers.ts | 20 +++++++++---------- .../src/store/test/state-handlers.ts | 6 ++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts index 2db853cd33d6a1..ccbb41383a472e 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/store/handlers.ts @@ -93,19 +93,17 @@ export const stateHandlers: ProxyHandler< object > = { key: string, desc: PropertyDescriptor ): boolean { - const prop = getProperty( target, key ); - let { value } = desc; - if ( value && shouldProxy( value ) ) { - value = proxify( value, stateHandlers, prop.namespace ); - } - const result = Reflect.defineProperty( - target, - key, - value ? { ...desc, value } : desc - ); + const result = Reflect.defineProperty( target, key, desc ); if ( result ) { - prop.update( desc ); + const prop = getProperty( target, key ); + const { value, get } = desc; + prop.update( { + value: shouldProxy( value ) + ? proxify( value, stateHandlers, prop.namespace ) + : value, + get, + } ); } return result; }, diff --git a/packages/interactivity/src/store/test/state-handlers.ts b/packages/interactivity/src/store/test/state-handlers.ts index 73060a92d29253..5250b25d3a0e04 100644 --- a/packages/interactivity/src/store/test/state-handlers.ts +++ b/packages/interactivity/src/store/test/state-handlers.ts @@ -229,6 +229,12 @@ describe( 'interactivity api handlers', () => { expect( store.a ).toBe( 1 ); expect( store.nested.b ).toBe( 2 ); } ); + + it( 'should keep assigned object references internally', () => { + const obj = {}; + store.nested = obj; + expect( state.nested ).toBe( obj ); + } ); } ); describe( 'computations', () => { From 8711d382cd6b9b0920e7080fce8448f065ce6c7e Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:02 +0200 Subject: [PATCH 12/91] Add store handlers --- packages/interactivity/src/store/handlers.ts | 40 +++++++++++++++++++- packages/interactivity/src/store/proxies.ts | 2 +- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts index ccbb41383a472e..146ed7c2fbe707 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/store/handlers.ts @@ -6,9 +6,11 @@ import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { proxify, getProxy, shouldProxy } from './proxies'; +import { proxify, getProxy, getProxyNs, shouldProxy } from './proxies'; import { Property } from './properties'; import { withScope } from '../utils'; +import { setNamespace, resetNamespace } from '../hooks'; +import { stores } from '../store'; const proxyToProps: WeakMap< object, Map< string, Property > > = new WeakMap(); const objToIterable = new WeakMap< object, Signal< number > >(); @@ -27,6 +29,9 @@ const getProperty = ( target: object, key: string ) => { const descriptor = Object.getOwnPropertyDescriptor; +const isObject = ( item: unknown ): item is Record< string, unknown > => + Boolean( item && typeof item === 'object' && item.constructor === Object ); + export const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { /* @@ -131,3 +136,36 @@ export const stateHandlers: ProxyHandler< object > = { return Reflect.ownKeys( target ); }, }; + +export const storeHandlers: ProxyHandler< object > = { + get: ( target: any, key: string | symbol, receiver: any ) => { + const result = Reflect.get( target, key ); + const ns = getProxyNs( 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' && receiver === stores.get( ns ) ) { + const obj = {}; + Reflect.set( target, key, obj ); + return proxify( obj, storeHandlers, ns ); + } + + // 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 ( isObject( result ) && shouldProxy( result ) ) { + return proxify( result, storeHandlers, ns ); + } + + return result; + }, +}; diff --git a/packages/interactivity/src/store/proxies.ts b/packages/interactivity/src/store/proxies.ts index 34e2b37d90469d..f51302329d7e68 100644 --- a/packages/interactivity/src/store/proxies.ts +++ b/packages/interactivity/src/store/proxies.ts @@ -16,7 +16,7 @@ export const proxify = < T extends object >( return objToProxy.get( obj ) as T; }; -export const getProxyNs = ( proxy: object ) => proxyToNs.get( proxy ); +export const getProxyNs = ( proxy: object ): string => proxyToNs.get( proxy )!; export const getProxy = < T extends object >( obj: T ) => objToProxy.get( obj ) as T; From 349a257d1ea23a56858715fa485170dfbd04538f Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 13/91] Add signals-core dependency --- package-lock.json | 2 ++ packages/interactivity/package.json | 1 + 2 files changed, 3 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6680025d768bef..4e81914e5879a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53728,6 +53728,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@preact/signals": "^1.2.2", + "@preact/signals-core": "^1.4.0", "deepsignal": "^1.4.0", "preact": "^10.19.3" }, @@ -68270,6 +68271,7 @@ "version": "file:packages/interactivity", "requires": { "@preact/signals": "^1.2.2", + "@preact/signals-core": "^1.4.0", "deepsignal": "^1.4.0", "preact": "^10.19.3" }, diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index 835063ccc76992..a49e362637751f 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -28,6 +28,7 @@ "types": "build-types", "dependencies": { "@preact/signals": "^1.2.2", + "@preact/signals-core": "^1.4.0", "deepsignal": "^1.4.0", "preact": "^10.19.3" }, From fc7a39babcded63ac64324249acc26a7e23eb9c1 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 14/91] Rename Property to PropSignal --- packages/interactivity/src/store/handlers.ts | 19 +++++++++++-------- .../src/store/{properties.ts => signals.ts} | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) rename packages/interactivity/src/store/{properties.ts => signals.ts} (97%) diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts index 146ed7c2fbe707..25d1fdb07db9c6 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/store/handlers.ts @@ -7,22 +7,25 @@ import { signal, type Signal } from '@preact/signals'; * Internal dependencies */ import { proxify, getProxy, getProxyNs, shouldProxy } from './proxies'; -import { Property } from './properties'; +import { PropSignal } from './signals'; import { withScope } from '../utils'; import { setNamespace, resetNamespace } from '../hooks'; import { stores } from '../store'; -const proxyToProps: WeakMap< object, Map< string, Property > > = new WeakMap(); +const proxyToProps: WeakMap< + object, + Map< string, PropSignal > +> = new WeakMap(); const objToIterable = new WeakMap< object, Signal< number > >(); -const getProperty = ( target: object, key: string ) => { +const getPropSignal = ( target: object, key: string ) => { const proxy = getProxy( target ); if ( ! proxyToProps.has( proxy ) ) { proxyToProps.set( proxy, new Map() ); } const props = proxyToProps.get( proxy )!; if ( ! props.has( key ) ) { - props.set( key, new Property( proxy ) ); + props.set( key, new PropSignal( proxy ) ); } return props.get( key )!; }; @@ -38,7 +41,7 @@ export const stateHandlers: ProxyHandler< object > = { * First, we get a reference of the property we want to access. The * property object is automatically instanciated if needed. */ - const prop = getProperty( target, key ); + const prop = getPropSignal( target, key ); /* * When the value is a getter, it updates the internal getter value. @@ -85,7 +88,7 @@ export const stateHandlers: ProxyHandler< object > = { } if ( Array.isArray( target ) ) { - const length = getProperty( target, 'length' ); + const length = getPropSignal( target, 'length' ); length.update( { value: target.length } ); } } @@ -101,7 +104,7 @@ export const stateHandlers: ProxyHandler< object > = { const result = Reflect.defineProperty( target, key, desc ); if ( result ) { - const prop = getProperty( target, key ); + const prop = getPropSignal( target, key ); const { value, get } = desc; prop.update( { value: shouldProxy( value ) @@ -117,7 +120,7 @@ export const stateHandlers: ProxyHandler< object > = { const result = Reflect.deleteProperty( target, key ); if ( result ) { - const prop = getProperty( target, key ); + const prop = getPropSignal( target, key ); prop.update( {} ); if ( objToIterable.has( target ) ) { diff --git a/packages/interactivity/src/store/properties.ts b/packages/interactivity/src/store/signals.ts similarity index 97% rename from packages/interactivity/src/store/properties.ts rename to packages/interactivity/src/store/signals.ts index 09f07f393265fc..6a33d3c233687a 100644 --- a/packages/interactivity/src/store/properties.ts +++ b/packages/interactivity/src/store/signals.ts @@ -17,7 +17,7 @@ import { getProxyNs } from './proxies'; const DEFAULT_SCOPE = Symbol(); -export class Property { +export class PropSignal { public readonly namespace: string; private owner: object; private computedsByScope: WeakMap< WeakKey, ReadonlySignal >; @@ -36,7 +36,7 @@ export class Property { }: { get?: () => any; value?: unknown; - } ): Property { + } ): PropSignal { if ( ! this.valueSignal ) { this.valueSignal = signal( value ); this.getterSignal = signal( get ); From 0df62196ea5d7989f6902ea0f408124a341a2836 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 15/91] Move withScope inside PropSignal logic --- packages/interactivity/src/store/handlers.ts | 31 +++++++++----------- packages/interactivity/src/store/signals.ts | 7 ++--- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/store/handlers.ts index 25d1fdb07db9c6..8005854f6557cf 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/store/handlers.ts @@ -43,29 +43,26 @@ export const stateHandlers: ProxyHandler< object > = { */ const prop = getPropSignal( target, key ); + const getter = descriptor( target, key )?.get; + /* - * When the value is a getter, it updates the internal getter value. - * This change triggers the signal only when the getter value changes. + * When the value is a getter, it updates the internal getter value. If + * not, we get the actual value an wrap it with a proxy if needed. + * + * These updates only triggers a re-render when either the getter or the + * value has changed. */ - const getter = descriptor( target, key )?.get; if ( getter ) { prop.update( { get: getter } ); - const value = prop.getComputed( withScope ).value; - return value; + } else { + const value = Reflect.get( target, key, receiver ); + prop.update( { + value: shouldProxy( value ) + ? proxify( value, stateHandlers, prop.namespace ) + : value, + } ); } - /* - * When it is not a getter, we get the actual value an apply different - * logic depending on the type of value. As before, the internal signal - * is updated, which only triggers a re-render when the value changes. - */ - const value = Reflect.get( target, key, receiver ); - prop.update( { - value: shouldProxy( value ) - ? proxify( value, stateHandlers, prop.namespace ) - : value, - } ); - return prop.getComputed().value; }, diff --git a/packages/interactivity/src/store/signals.ts b/packages/interactivity/src/store/signals.ts index 6a33d3c233687a..ebcb171fde008d 100644 --- a/packages/interactivity/src/store/signals.ts +++ b/packages/interactivity/src/store/signals.ts @@ -14,6 +14,7 @@ import { */ import { getScope, setNamespace, resetNamespace } from '../hooks'; import { getProxyNs } from './proxies'; +import { withScope } from '../utils'; const DEFAULT_SCOPE = Symbol(); @@ -52,9 +53,7 @@ export class PropSignal { return this; } - public getComputed( - wrapper?: < G extends () => any >( getter: G ) => G - ): ReadonlySignal { + public getComputed(): ReadonlySignal { const scope = getScope() || DEFAULT_SCOPE; if ( ! this.valueSignal && ! this.getterSignal ) { @@ -72,7 +71,7 @@ export class PropSignal { setNamespace( this.namespace ); this.computedsByScope.set( scope, - computed( wrapper ? wrapper( callback ) : callback ) + computed( withScope( callback ) ) ); resetNamespace(); } From 61b1da5b095733e15c76e22acfaf652d9f2426ef Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 16/91] Create proxies folder --- .../src/{store => proxies}/handlers.ts | 2 +- .../{store/proxies.ts => proxies/index.ts} | 17 +- .../src/{store => proxies}/signals.ts | 2 +- .../{store => proxies}/test/state-handlers.ts | 5 +- packages/interactivity/src/store.ts | 151 ++---------------- packages/interactivity/src/store/index.ts | 0 6 files changed, 29 insertions(+), 148 deletions(-) rename packages/interactivity/src/{store => proxies}/handlers.ts (98%) rename packages/interactivity/src/{store/proxies.ts => proxies/index.ts} (72%) rename packages/interactivity/src/{store => proxies}/signals.ts (97%) rename packages/interactivity/src/{store => proxies}/test/state-handlers.ts (99%) delete mode 100644 packages/interactivity/src/store/index.ts diff --git a/packages/interactivity/src/store/handlers.ts b/packages/interactivity/src/proxies/handlers.ts similarity index 98% rename from packages/interactivity/src/store/handlers.ts rename to packages/interactivity/src/proxies/handlers.ts index 8005854f6557cf..be3f5fb0597e90 100644 --- a/packages/interactivity/src/store/handlers.ts +++ b/packages/interactivity/src/proxies/handlers.ts @@ -6,7 +6,7 @@ import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { proxify, getProxy, getProxyNs, shouldProxy } from './proxies'; +import { proxify, getProxy, getProxyNs, shouldProxy } from './'; import { PropSignal } from './signals'; import { withScope } from '../utils'; import { setNamespace, resetNamespace } from '../hooks'; diff --git a/packages/interactivity/src/store/proxies.ts b/packages/interactivity/src/proxies/index.ts similarity index 72% rename from packages/interactivity/src/store/proxies.ts rename to packages/interactivity/src/proxies/index.ts index f51302329d7e68..3d7e2a2b358ec4 100644 --- a/packages/interactivity/src/store/proxies.ts +++ b/packages/interactivity/src/proxies/index.ts @@ -1,7 +1,14 @@ +/** + * Internal dependencies + */ +import { stateHandlers, storeHandlers } from './handlers'; + const objToProxy = new WeakMap< object, object >(); const proxyToNs = new WeakMap< object, string >(); const ignore = new WeakSet< object >(); +const supported = new Set( [ Object, Array ] ); + export const proxify = < T extends object >( obj: T, handlers: ProxyHandler< T >, @@ -27,4 +34,12 @@ export const shouldProxy = ( val: any ): val is Object | Array< unknown > => { return ! ignore.has( val ) && supported.has( val.constructor ); }; -const supported = new Set( [ Object, Array ] ); +export const getStateProxy = < T extends object >( + obj: T, + namespace: string +) => proxify( obj, stateHandlers, namespace ); + +export const getStoreProxy = < T extends object >( + obj: T, + namespace: string +) => proxify( obj, storeHandlers, namespace ); diff --git a/packages/interactivity/src/store/signals.ts b/packages/interactivity/src/proxies/signals.ts similarity index 97% rename from packages/interactivity/src/store/signals.ts rename to packages/interactivity/src/proxies/signals.ts index ebcb171fde008d..4f04087bfb85d9 100644 --- a/packages/interactivity/src/store/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -12,8 +12,8 @@ import { /** * Internal dependencies */ +import { getProxyNs } from './'; import { getScope, setNamespace, resetNamespace } from '../hooks'; -import { getProxyNs } from './proxies'; import { withScope } from '../utils'; const DEFAULT_SCOPE = Symbol(); diff --git a/packages/interactivity/src/store/test/state-handlers.ts b/packages/interactivity/src/proxies/test/state-handlers.ts similarity index 99% rename from packages/interactivity/src/store/test/state-handlers.ts rename to packages/interactivity/src/proxies/test/state-handlers.ts index 5250b25d3a0e04..d92564aa461147 100644 --- a/packages/interactivity/src/store/test/state-handlers.ts +++ b/packages/interactivity/src/proxies/test/state-handlers.ts @@ -8,8 +8,7 @@ import { effect } from '@preact/signals-core'; /** * Internal dependencies */ -import { proxify } from '../proxies'; -import { stateHandlers } from '../handlers'; +import { getStateProxy } from '../'; import { setScope, resetScope, @@ -34,7 +33,7 @@ const withScopeAndNs = ( scope, ns, callback ) => () => { }; const proxifyState = < T extends object >( obj: T ) => - proxify( obj, stateHandlers, 'test' ) as T; + getStateProxy( obj, 'test' ) as T; describe( 'interactivity api handlers', () => { let nested = { b: 2 }; diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 281a6c266021e1..ed3ad3bf4726b5 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -1,20 +1,9 @@ -/** - * External dependencies - */ -import { deepSignal } from 'deepsignal'; -import { computed } from '@preact/signals'; - /** * Internal dependencies */ -import { - getScope, - setScope, - resetScope, - getNamespace, - setNamespace, - resetNamespace, -} from './hooks'; +import { getStateProxy, getStoreProxy } from './proxies'; +import { getNamespace } from './hooks'; + const isObject = ( item: unknown ): item is Record< string, unknown > => Boolean( item && typeof item === 'object' && item.constructor === Object ); @@ -23,7 +12,10 @@ const deepMerge = ( target: any, source: any ) => { for ( const key in source ) { const getter = Object.getOwnPropertyDescriptor( source, key )?.get; if ( typeof getter === 'function' ) { - Object.defineProperty( target, key, { get: getter } ); + Object.defineProperty( target, key, { + get: getter, + configurable: true, + } ); } else if ( isObject( source[ key ] ) ) { if ( ! target[ key ] ) { target[ key ] = {}; @@ -46,130 +38,6 @@ 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 +148,12 @@ export function store( storeLocks.set( namespace, lock ); } const rawStore = { - state: deepSignal( isObject( state ) ? state : {} ), + state: getStateProxy( isObject( state ) ? state : {}, namespace ), ...block, }; - const proxiedStore = new Proxy( rawStore, handlers ); + const proxiedStore = getStoreProxy( rawStore, namespace ); rawStores.set( namespace, rawStore ); stores.set( namespace, proxiedStore ); - proxyToNs.set( proxiedStore, namespace ); } 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 diff --git a/packages/interactivity/src/store/index.ts b/packages/interactivity/src/store/index.ts deleted file mode 100644 index e69de29bb2d1d6..00000000000000 From 3401a2b8c70ca7dda7c30a80479b418a9a01433b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 17/91] Attempt to make the context work --- packages/interactivity/src/directives.tsx | 43 +++++++++++-------- .../interactivity/src/proxies/handlers.ts | 27 +++--------- packages/interactivity/src/proxies/index.ts | 23 ++++++++++ 3 files changed, 53 insertions(+), 40 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 60ddf13375a8a1..05dc99f56f2b15 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -6,7 +6,10 @@ */ 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 { getStateProxy, peek } from './proxies'; /** * Internal dependencies @@ -47,7 +50,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 ]; @@ -127,21 +130,18 @@ const proxifyContext = ( current: object, inherited: object = {} ): object => { /** * Recursively update values within a deepSignal object. * - * @param target A deepSignal instance. - * @param source Object with properties to update in `target`. + * @param proxy A deepSignal instance. + * @param source Object with properties to update in `proxy`. */ -const updateSignals = ( - target: DeepSignal< any >, - source: DeepSignal< any > -) => { +const updateSignals = ( proxy: object, source: object ) => { for ( const k in source ) { if ( - isPlainObject( peek( target, k ) ) && - isPlainObject( peek( source, k ) ) + isPlainObject( peek( proxy, k ) ) && + isPlainObject( source[ k ] ) ) { - updateSignals( target[ `$${ k }` ].peek(), source[ k ] ); + updateSignals( peek( proxy, k ) as object, source[ k ] ); } else { - target[ k ] = source[ k ]; + proxy[ k ] = source[ k ]; } } }; @@ -264,11 +264,15 @@ export default () => { 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 ]: getStateProxy( {}, ns ), + } ); // No change should be made if `defaultEntry` does not exist. const contextStack = useMemo( () => { @@ -280,9 +284,10 @@ export default () => { `The value of data-wp-context in "${ namespace }" store must be a valid stringified JSON object.` ); } - updateSignals( currentValue.current, { - [ namespace ]: deepClone( value ), - } ); + updateSignals( + currentValue.current[ namespace ], + deepClone( value ) as object + ); } return proxifyContext( currentValue.current, inheritedValue ); }, [ defaultEntry, inheritedValue ] ); @@ -677,7 +682,9 @@ export default () => { return list.map( ( item ) => { const itemProp = suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); - const itemContext = deepSignal( { [ namespace ]: {} } ); + const itemContext = { + [ namespace ]: getStateProxy( {}, namespace ), + }; const mergedContext = proxifyContext( itemContext, inheritedValue diff --git a/packages/interactivity/src/proxies/handlers.ts b/packages/interactivity/src/proxies/handlers.ts index be3f5fb0597e90..9bd0c085ef9d31 100644 --- a/packages/interactivity/src/proxies/handlers.ts +++ b/packages/interactivity/src/proxies/handlers.ts @@ -6,30 +6,13 @@ import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { proxify, getProxy, getProxyNs, shouldProxy } from './'; -import { PropSignal } from './signals'; +import { proxify, getProxy, getProxyNs, shouldProxy, getPropSignal } from './'; import { withScope } from '../utils'; import { setNamespace, resetNamespace } from '../hooks'; import { stores } from '../store'; -const proxyToProps: WeakMap< - object, - Map< string, PropSignal > -> = new WeakMap(); const objToIterable = new WeakMap< object, Signal< number > >(); -const getPropSignal = ( target: object, key: string ) => { - const proxy = getProxy( target ); - if ( ! proxyToProps.has( proxy ) ) { - proxyToProps.set( proxy, new Map() ); - } - const props = proxyToProps.get( proxy )!; - if ( ! props.has( key ) ) { - props.set( key, new PropSignal( proxy ) ); - } - return props.get( key )!; -}; - const descriptor = Object.getOwnPropertyDescriptor; const isObject = ( item: unknown ): item is Record< string, unknown > => @@ -41,7 +24,7 @@ export const stateHandlers: ProxyHandler< object > = { * First, we get a reference of the property we want to access. The * property object is automatically instanciated if needed. */ - const prop = getPropSignal( target, key ); + const prop = getPropSignal( receiver, key ); const getter = descriptor( target, key )?.get; @@ -85,7 +68,7 @@ export const stateHandlers: ProxyHandler< object > = { } if ( Array.isArray( target ) ) { - const length = getPropSignal( target, 'length' ); + const length = getPropSignal( receiver, 'length' ); length.update( { value: target.length } ); } } @@ -101,7 +84,7 @@ export const stateHandlers: ProxyHandler< object > = { const result = Reflect.defineProperty( target, key, desc ); if ( result ) { - const prop = getPropSignal( target, key ); + const prop = getPropSignal( getProxy( target ), key ); const { value, get } = desc; prop.update( { value: shouldProxy( value ) @@ -117,7 +100,7 @@ export const stateHandlers: ProxyHandler< object > = { const result = Reflect.deleteProperty( target, key ); if ( result ) { - const prop = getPropSignal( target, key ); + const prop = getPropSignal( getProxy( target ), key ); prop.update( {} ); if ( objToIterable.has( target ) ) { diff --git a/packages/interactivity/src/proxies/index.ts b/packages/interactivity/src/proxies/index.ts index 3d7e2a2b358ec4..38cf44436a65a0 100644 --- a/packages/interactivity/src/proxies/index.ts +++ b/packages/interactivity/src/proxies/index.ts @@ -2,6 +2,7 @@ * Internal dependencies */ import { stateHandlers, storeHandlers } from './handlers'; +import { PropSignal } from './signals'; const objToProxy = new WeakMap< object, object >(); const proxyToNs = new WeakMap< object, string >(); @@ -9,6 +10,22 @@ const ignore = new WeakSet< object >(); const supported = new Set( [ Object, Array ] ); +const proxyToProps: WeakMap< + object, + Map< string, PropSignal > +> = new WeakMap(); + +export const getPropSignal = ( proxy: object, key: string ) => { + if ( ! proxyToProps.has( proxy ) ) { + proxyToProps.set( proxy, new Map() ); + } + const props = proxyToProps.get( proxy )!; + if ( ! props.has( key ) ) { + props.set( key, new PropSignal( proxy ) ); + } + return props.get( key )!; +}; + export const proxify = < T extends object >( obj: T, handlers: ProxyHandler< T >, @@ -43,3 +60,9 @@ export const getStoreProxy = < T extends object >( obj: T, namespace: string ) => proxify( obj, storeHandlers, namespace ); + +export const peek = ( obj: object, key: string ): unknown => { + const prop = getPropSignal( obj, key ); + // TODO: what about the scope? + return prop.getComputed().peek(); +}; From 5a546ebafa4df1d31f3455b402a63ff51f0cfd85 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 18/91] Reorganize code --- packages/interactivity/src/proxies/index.ts | 67 +----------- .../interactivity/src/proxies/registry.ts | 28 +++++ packages/interactivity/src/proxies/signals.ts | 50 +++++---- .../src/proxies/{handlers.ts => state.ts} | 101 ++++++++---------- packages/interactivity/src/proxies/store.ts | 48 +++++++++ .../{state-handlers.ts => state-proxy.ts} | 0 6 files changed, 153 insertions(+), 141 deletions(-) create mode 100644 packages/interactivity/src/proxies/registry.ts rename packages/interactivity/src/proxies/{handlers.ts => state.ts} (56%) create mode 100644 packages/interactivity/src/proxies/store.ts rename packages/interactivity/src/proxies/test/{state-handlers.ts => state-proxy.ts} (100%) diff --git a/packages/interactivity/src/proxies/index.ts b/packages/interactivity/src/proxies/index.ts index 38cf44436a65a0..619f1bebcdec0b 100644 --- a/packages/interactivity/src/proxies/index.ts +++ b/packages/interactivity/src/proxies/index.ts @@ -1,68 +1,5 @@ /** * Internal dependencies */ -import { stateHandlers, storeHandlers } from './handlers'; -import { PropSignal } from './signals'; - -const objToProxy = new WeakMap< object, object >(); -const proxyToNs = new WeakMap< object, string >(); -const ignore = new WeakSet< object >(); - -const supported = new Set( [ Object, Array ] ); - -const proxyToProps: WeakMap< - object, - Map< string, PropSignal > -> = new WeakMap(); - -export const getPropSignal = ( proxy: object, key: string ) => { - if ( ! proxyToProps.has( proxy ) ) { - proxyToProps.set( proxy, new Map() ); - } - const props = proxyToProps.get( proxy )!; - if ( ! props.has( key ) ) { - props.set( key, new PropSignal( proxy ) ); - } - return props.get( key )!; -}; - -export const proxify = < T extends object >( - obj: T, - handlers: ProxyHandler< T >, - namespace: string -): T => { - if ( ! objToProxy.has( obj ) ) { - const proxy = new Proxy( obj, handlers ); - ignore.add( proxy ); - objToProxy.set( obj, proxy ); - proxyToNs.set( proxy, namespace ); - } - return objToProxy.get( obj ) as T; -}; - -export const getProxyNs = ( proxy: object ): string => proxyToNs.get( proxy )!; -export const getProxy = < T extends object >( obj: T ) => - objToProxy.get( obj ) as T; - -export const shouldProxy = ( val: any ): val is Object | Array< unknown > => { - if ( typeof val !== 'object' || val === null ) { - return false; - } - return ! ignore.has( val ) && supported.has( val.constructor ); -}; - -export const getStateProxy = < T extends object >( - obj: T, - namespace: string -) => proxify( obj, stateHandlers, namespace ); - -export const getStoreProxy = < T extends object >( - obj: T, - namespace: string -) => proxify( obj, storeHandlers, namespace ); - -export const peek = ( obj: object, key: string ): unknown => { - const prop = getPropSignal( obj, key ); - // TODO: what about the scope? - return prop.getComputed().peek(); -}; +export { getStateProxy, peek } from './state'; +export { getStoreProxy } from './store'; diff --git a/packages/interactivity/src/proxies/registry.ts b/packages/interactivity/src/proxies/registry.ts new file mode 100644 index 00000000000000..ef208b327f5149 --- /dev/null +++ b/packages/interactivity/src/proxies/registry.ts @@ -0,0 +1,28 @@ +const objToProxy = new WeakMap< object, object >(); +const proxyToNs = new WeakMap< object, string >(); +const ignore = new WeakSet< object >(); + +const supported = new Set( [ Object, Array ] ); + +export const getProxy = < T extends object >( + obj: T, + handlers?: ProxyHandler< T >, + namespace?: string +): T => { + if ( ! objToProxy.has( obj ) && handlers && namespace ) { + const proxy = new Proxy( obj, handlers ); + ignore.add( proxy ); + objToProxy.set( obj, proxy ); + proxyToNs.set( proxy, namespace ); + } + return objToProxy.get( obj ) as T; +}; + +export const getProxyNs = ( proxy: object ): string => proxyToNs.get( proxy )!; + +export const shouldProxy = ( val: any ): val is Object | Array< unknown > => { + if ( typeof val !== 'object' || val === null ) { + return false; + } + return ! ignore.has( val ) && supported.has( val.constructor ); +}; diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index 4f04087bfb85d9..88b04a8c1c91d2 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -12,7 +12,7 @@ import { /** * Internal dependencies */ -import { getProxyNs } from './'; +import { getProxyNs } from './registry'; import { getScope, setNamespace, resetNamespace } from '../hooks'; import { withScope } from '../utils'; @@ -31,26 +31,12 @@ export class PropSignal { this.computedsByScope = new WeakMap(); } - public update( { - get, - value, - }: { - get?: () => any; - value?: unknown; - } ): PropSignal { - 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; - } ); - } - return this; + public setValue( value: unknown ): PropSignal { + return this.update( { value } ); + } + + public setGetter( getter: () => any ): PropSignal { + return this.update( { get: getter } ); } public getComputed(): ReadonlySignal { @@ -78,4 +64,26 @@ export class PropSignal { return this.computedsByScope.get( scope )!; } + + private update( { + get, + value, + }: { + get?: () => any; + value?: unknown; + } ): PropSignal { + 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; + } ); + } + return this; + } } diff --git a/packages/interactivity/src/proxies/handlers.ts b/packages/interactivity/src/proxies/state.ts similarity index 56% rename from packages/interactivity/src/proxies/handlers.ts rename to packages/interactivity/src/proxies/state.ts index 9bd0c085ef9d31..85f6cf0c60df66 100644 --- a/packages/interactivity/src/proxies/handlers.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -6,17 +6,38 @@ import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { proxify, getProxy, getProxyNs, shouldProxy, getPropSignal } from './'; -import { withScope } from '../utils'; -import { setNamespace, resetNamespace } from '../hooks'; -import { stores } from '../store'; +import { getProxy, shouldProxy } from './registry'; +import { PropSignal } from './signals'; + +const proxyToProps: WeakMap< + object, + Map< string, PropSignal > +> = new WeakMap(); + +export const getPropSignal = ( proxy: object, key: string ) => { + if ( ! proxyToProps.has( proxy ) ) { + proxyToProps.set( proxy, new Map() ); + } + const props = proxyToProps.get( proxy )!; + if ( ! props.has( key ) ) { + props.set( key, new PropSignal( proxy ) ); + } + return props.get( key )!; +}; -const objToIterable = new WeakMap< object, Signal< number > >(); +export const peek = ( obj: object, key: string ): unknown => { + const prop = getPropSignal( obj, key ); + // TODO: what about the scope? + return prop.getComputed().peek(); +}; +const objToIterable = new WeakMap< object, Signal< number > >(); const descriptor = Object.getOwnPropertyDescriptor; -const isObject = ( item: unknown ): item is Record< string, unknown > => - Boolean( item && typeof item === 'object' && item.constructor === Object ); +export const getStateProxy = < T extends object >( + obj: T, + namespace: string +) => getProxy( obj, stateHandlers, namespace ); export const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { @@ -36,14 +57,14 @@ export const stateHandlers: ProxyHandler< object > = { * value has changed. */ if ( getter ) { - prop.update( { get: getter } ); + prop.setGetter( getter ); } else { const value = Reflect.get( target, key, receiver ); - prop.update( { - value: shouldProxy( value ) - ? proxify( value, stateHandlers, prop.namespace ) - : value, - } ); + prop.setValue( + shouldProxy( value ) + ? getStateProxy( value, prop.namespace ) + : value + ); } return prop.getComputed().value; @@ -69,7 +90,7 @@ export const stateHandlers: ProxyHandler< object > = { if ( Array.isArray( target ) ) { const length = getPropSignal( receiver, 'length' ); - length.update( { value: target.length } ); + length.setValue( target.length ); } } @@ -85,13 +106,16 @@ export const stateHandlers: ProxyHandler< object > = { if ( result ) { const prop = getPropSignal( getProxy( target ), key ); - const { value, get } = desc; - prop.update( { - value: shouldProxy( value ) - ? proxify( value, stateHandlers, prop.namespace ) - : value, - get, - } ); + const { get, value } = desc; + if ( get ) { + prop.setGetter( desc.get! ); + } else { + prop.setValue( + shouldProxy( value ) + ? getStateProxy( value, prop.namespace ) + : value + ); + } } return result; }, @@ -101,7 +125,7 @@ export const stateHandlers: ProxyHandler< object > = { if ( result ) { const prop = getPropSignal( getProxy( target ), key ); - prop.update( {} ); + prop.setValue( undefined ); if ( objToIterable.has( target ) ) { objToIterable.get( target )!.value++; @@ -119,36 +143,3 @@ export const stateHandlers: ProxyHandler< object > = { return Reflect.ownKeys( target ); }, }; - -export const storeHandlers: ProxyHandler< object > = { - get: ( target: any, key: string | symbol, receiver: any ) => { - const result = Reflect.get( target, key ); - const ns = getProxyNs( 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' && receiver === stores.get( ns ) ) { - const obj = {}; - Reflect.set( target, key, obj ); - return proxify( obj, storeHandlers, ns ); - } - - // 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 ( isObject( result ) && shouldProxy( result ) ) { - return proxify( result, storeHandlers, ns ); - } - - return result; - }, -}; diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts new file mode 100644 index 00000000000000..02545f7eb559fc --- /dev/null +++ b/packages/interactivity/src/proxies/store.ts @@ -0,0 +1,48 @@ +/** + * Internal dependencies + */ +import { getProxy, getProxyNs, shouldProxy } from './registry'; +import { setNamespace, resetNamespace } from '../hooks'; +import { stores } from '../store'; +import { withScope } from '../utils'; + +const isObject = ( item: unknown ): item is Record< string, unknown > => + Boolean( item && typeof item === 'object' && item.constructor === Object ); + +export const getStoreProxy = < T extends object >( + obj: T, + namespace: string +) => getProxy( obj, storeHandlers, namespace ); + +export const storeHandlers: ProxyHandler< object > = { + get: ( target: any, key: string | symbol, receiver: any ) => { + const result = Reflect.get( target, key ); + const ns = getProxyNs( 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' && receiver === stores.get( ns ) ) { + const obj = {}; + Reflect.set( target, key, obj ); + return getStoreProxy( obj, ns ); + } + + // 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 ( isObject( result ) && shouldProxy( result ) ) { + return getStoreProxy( result, ns ); + } + + return result; + }, +}; diff --git a/packages/interactivity/src/proxies/test/state-handlers.ts b/packages/interactivity/src/proxies/test/state-proxy.ts similarity index 100% rename from packages/interactivity/src/proxies/test/state-handlers.ts rename to packages/interactivity/src/proxies/test/state-proxy.ts From d375418b629a714c22533facfc35ca68719967a3 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 19/91] Make peek() return only the value inside signalValue --- packages/interactivity/src/proxies/signals.ts | 4 ++++ packages/interactivity/src/proxies/state.ts | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index 88b04a8c1c91d2..5c53a945d1ed07 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -65,6 +65,10 @@ export class PropSignal { return this.computedsByScope.get( scope )!; } + public peekValueSignal(): unknown { + return this.valueSignal?.peek(); + } + private update( { get, value, diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 85f6cf0c60df66..68baf5c2afbf9a 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -27,8 +27,9 @@ export const getPropSignal = ( proxy: object, key: string ) => { export const peek = ( obj: object, key: string ): unknown => { const prop = getPropSignal( obj, key ); - // TODO: what about the scope? - return prop.getComputed().peek(); + // TODO: it currently returns the value of the internal `valueSignal`, + // getters are not considered yet. + return prop.peekValueSignal(); }; const objToIterable = new WeakMap< object, Signal< number > >(); From 075c4f96853dfa0723b55bb41073a60428dda551 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 20/91] A bit of refactoring --- packages/interactivity/src/proxies/state.ts | 87 +++++++++------------ packages/interactivity/src/proxies/store.ts | 12 +-- 2 files changed, 42 insertions(+), 57 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 68baf5c2afbf9a..bde45859cf9b13 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -14,33 +14,10 @@ const proxyToProps: WeakMap< Map< string, PropSignal > > = new WeakMap(); -export const getPropSignal = ( proxy: object, key: string ) => { - if ( ! proxyToProps.has( proxy ) ) { - proxyToProps.set( proxy, new Map() ); - } - const props = proxyToProps.get( proxy )!; - if ( ! props.has( key ) ) { - props.set( key, new PropSignal( proxy ) ); - } - return props.get( key )!; -}; - -export const peek = ( obj: object, key: string ): unknown => { - const prop = getPropSignal( obj, key ); - // TODO: it currently returns the value of the internal `valueSignal`, - // getters are not considered yet. - return prop.peekValueSignal(); -}; - const objToIterable = new WeakMap< object, Signal< number > >(); const descriptor = Object.getOwnPropertyDescriptor; -export const getStateProxy = < T extends object >( - obj: T, - namespace: string -) => getProxy( obj, stateHandlers, namespace ); - -export const stateHandlers: ProxyHandler< object > = { +const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { /* * First, we get a reference of the property we want to access. The @@ -71,38 +48,12 @@ export const stateHandlers: ProxyHandler< object > = { return prop.getComputed().value; }, - set( - target: object, - key: string, - value: unknown, - receiver: object - ): boolean { - if ( typeof descriptor( target, key )?.set === 'function' ) { - return Reflect.set( target, key, value, receiver ); - } - - const isNew = ! ( key in target ); - const result = Reflect.set( target, key, value, receiver ); - - if ( result ) { - if ( isNew && objToIterable.has( target ) ) { - objToIterable.get( target )!.value++; - } - - if ( Array.isArray( target ) ) { - const length = getPropSignal( receiver, 'length' ); - length.setValue( target.length ); - } - } - - return result; - }, - defineProperty( target: object, key: string, desc: PropertyDescriptor ): boolean { + const isNew = ! ( key in target ); const result = Reflect.defineProperty( target, key, desc ); if ( result ) { @@ -117,7 +68,18 @@ export const stateHandlers: ProxyHandler< object > = { : value ); } + + if ( isNew && objToIterable.has( target ) ) { + objToIterable.get( target )!.value++; + } + + if ( Array.isArray( target ) ) { + const receiver = getProxy( target ); + const length = getPropSignal( receiver, 'length' ); + length.setValue( target.length ); + } } + return result; }, @@ -144,3 +106,26 @@ export const stateHandlers: ProxyHandler< object > = { return Reflect.ownKeys( target ); }, }; + +export const getStateProxy = < T extends object >( + obj: T, + namespace: string +) => getProxy( obj, stateHandlers, namespace ); + +export const peek = ( obj: object, key: string ): unknown => { + const prop = getPropSignal( obj, key ); + // TODO: it currently returns the value of the internal `valueSignal`, + // getters are not considered yet. + return prop.peekValueSignal(); +}; + +export const getPropSignal = ( proxy: object, key: string ) => { + if ( ! proxyToProps.has( proxy ) ) { + proxyToProps.set( proxy, new Map() ); + } + const props = proxyToProps.get( proxy )!; + if ( ! props.has( key ) ) { + props.set( key, new PropSignal( proxy ) ); + } + return props.get( key )!; +}; diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index 02545f7eb559fc..331dc05bc52fc3 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -9,12 +9,7 @@ import { withScope } from '../utils'; const isObject = ( item: unknown ): item is Record< string, unknown > => Boolean( item && typeof item === 'object' && item.constructor === Object ); -export const getStoreProxy = < T extends object >( - obj: T, - namespace: string -) => getProxy( obj, storeHandlers, namespace ); - -export const storeHandlers: ProxyHandler< object > = { +const storeHandlers: ProxyHandler< object > = { get: ( target: any, key: string | symbol, receiver: any ) => { const result = Reflect.get( target, key ); const ns = getProxyNs( receiver ); @@ -46,3 +41,8 @@ export const storeHandlers: ProxyHandler< object > = { return result; }, }; + +export const getStoreProxy = < T extends object >( + obj: T, + namespace: string +) => getProxy( obj, storeHandlers, namespace ); From b9f5ac8229021312425375b703d2f0b42fdef1dc Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 21/91] Return the computed value with `peek()` --- packages/interactivity/src/proxies/state.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index bde45859cf9b13..e05a19a197dbb3 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -114,9 +114,7 @@ export const getStateProxy = < T extends object >( export const peek = ( obj: object, key: string ): unknown => { const prop = getPropSignal( obj, key ); - // TODO: it currently returns the value of the internal `valueSignal`, - // getters are not considered yet. - return prop.peekValueSignal(); + return prop.getComputed().peek(); }; export const getPropSignal = ( proxy: object, key: string ) => { From a9a5fb03a219ec844cfa13325d1b171d0c6a4b38 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 22/91] Rename DEFAULT_SCOPE to NO_SCOPE --- packages/interactivity/src/proxies/signals.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index 5c53a945d1ed07..a1d405270c209a 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -16,7 +16,7 @@ import { getProxyNs } from './registry'; import { getScope, setNamespace, resetNamespace } from '../hooks'; import { withScope } from '../utils'; -const DEFAULT_SCOPE = Symbol(); +const NO_SCOPE = Symbol(); export class PropSignal { public readonly namespace: string; @@ -40,7 +40,7 @@ export class PropSignal { } public getComputed(): ReadonlySignal { - const scope = getScope() || DEFAULT_SCOPE; + const scope = getScope() || NO_SCOPE; if ( ! this.valueSignal && ! this.getterSignal ) { this.update( {} ); From c9a854c1dfc0f9fb7daaec8d7dc89fc45c37a79b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 23/91] Remove deepsignal --- package-lock.json | 32 ------------------- .../directive-priorities/view.js | 17 ++++++---- packages/interactivity/package.json | 1 - packages/interactivity/src/index.ts | 4 +-- 4 files changed, 12 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e81914e5879a8..97dffa37e0b412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53729,7 +53729,6 @@ "dependencies": { "@preact/signals": "^1.2.2", "@preact/signals-core": "^1.4.0", - "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "engines": { @@ -53764,31 +53763,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", @@ -68272,7 +68246,6 @@ "requires": { "@preact/signals": "^1.2.2", "@preact/signals-core": "^1.4.0", - "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "dependencies": { @@ -68284,11 +68257,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-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js index c6cdf31b4909c8..02982e34f8f13d 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, getStateProxy, 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,15 @@ directive( 'test-context', ( { context: { Provider }, props: { children } } ) => { executionProof( 'context' ); - const value = deepSignal( { - [ namespace ]: { - attribute: 'from context', - text: 'from context', - }, - } ); + const value = { + [ namespace ]: getStateProxy( + { + attribute: 'from context', + text: 'from context', + }, + namespace + ), + } ; return h( Provider, { value }, children ); }, { priority: 8 } diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index a49e362637751f..2f8b227c1cd45b 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -29,7 +29,6 @@ "dependencies": { "@preact/signals": "^1.2.2", "@preact/signals-core": "^1.4.0", - "deepsignal": "^1.4.0", "preact": "^10.19.3" }, "publishConfig": { diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index a43534509bb5ac..7643d08bca20a0 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 @@ -14,6 +13,7 @@ import { directivePrefix } from './constants'; import { toVdom } from './vdom'; import { directive, getNamespace } from './hooks'; import { parseInitialData, populateInitialData } from './store'; +import { getStateProxy } from './proxies'; export { store, getConfig } from './store'; export { getContext, getElement } from './hooks'; @@ -45,7 +45,7 @@ export const privateApis = ( lock ): any => { h, cloneElement, render, - deepSignal, + getStateProxy, parseInitialData, populateInitialData, batch, From fc131dc1e255b7335771b7f11693ba271afc9c69 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 24/91] Handle functions inside state --- packages/interactivity/src/proxies/state.ts | 34 +++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index e05a19a197dbb3..efb161f37a2a54 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -8,6 +8,7 @@ import { signal, type Signal } from '@preact/signals'; */ import { getProxy, shouldProxy } from './registry'; import { PropSignal } from './signals'; +import { setNamespace, resetNamespace } from '../hooks'; const proxyToProps: WeakMap< object, @@ -19,14 +20,22 @@ const descriptor = Object.getOwnPropertyDescriptor; const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { + const desc = descriptor( target, key ); + + /* + * This property comes from the Object prototype and should not + * be processed. + */ + if ( ! desc && key in target ) { + return Reflect.get( target, key, receiver ); + } + /* * First, we get a reference of the property we want to access. The * property object is automatically instanciated if needed. */ const prop = getPropSignal( receiver, key ); - const getter = descriptor( target, key )?.get; - /* * When the value is a getter, it updates the internal getter value. If * not, we get the actual value an wrap it with a proxy if needed. @@ -34,6 +43,7 @@ const stateHandlers: ProxyHandler< object > = { * These updates only triggers a re-render when either the getter or the * value has changed. */ + const getter = desc?.get; if ( getter ) { prop.setGetter( getter ); } else { @@ -45,7 +55,25 @@ const stateHandlers: ProxyHandler< object > = { ); } - return prop.getComputed().value; + 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' ) { + return ( ...args: unknown[] ) => { + setNamespace( prop.namespace ); + try { + return result( ...args ); + } finally { + resetNamespace(); + } + }; + } + + return result; }, defineProperty( From fe0b6bbd53982624b8dc3a6444e64e3b2926fec4 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 25/91] Fix withScope in this PR --- packages/interactivity/src/utils.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index 3c880d43f17065..a78aa798a0958a 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -145,18 +145,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; }; } From e371add32dc812e5b092edfb9eeec6746cb54105 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 26/91] Fix context proxification --- packages/interactivity/src/directives.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 05dc99f56f2b15..e78efe24f39c66 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -64,7 +64,7 @@ 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 ] ); } From fe1920dd39cb33146991ab4f8606a8de184f7de2 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 27/91] Move store root logic to store proxy handlers --- packages/interactivity/src/proxies/store.ts | 16 ++++++++++++---- packages/interactivity/src/store.ts | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index 331dc05bc52fc3..70f6a6ce1c3b2f 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -3,12 +3,13 @@ */ import { getProxy, getProxyNs, shouldProxy } from './registry'; import { setNamespace, resetNamespace } from '../hooks'; -import { stores } from '../store'; import { withScope } from '../utils'; const isObject = ( item: unknown ): item is Record< string, unknown > => Boolean( item && typeof item === 'object' && item.constructor === Object ); +const storeRoots = new WeakSet(); + const storeHandlers: ProxyHandler< object > = { get: ( target: any, key: string | symbol, receiver: any ) => { const result = Reflect.get( target, key ); @@ -16,7 +17,7 @@ const storeHandlers: ProxyHandler< object > = { // 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 ) ) { + if ( typeof result === 'undefined' && storeRoots.has( receiver ) ) { const obj = {}; Reflect.set( target, key, obj ); return getStoreProxy( obj, ns ); @@ -44,5 +45,12 @@ const storeHandlers: ProxyHandler< object > = { export const getStoreProxy = < T extends object >( obj: T, - namespace: string -) => getProxy( obj, storeHandlers, namespace ); + namespace: string, + isRoot = false +) => { + const proxy = getProxy( obj, storeHandlers, namespace ); + if ( isRoot ) { + storeRoots.add( proxy ); + } + return proxy; +}; diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index ed3ad3bf4726b5..0661cb3b23db9b 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -151,7 +151,7 @@ export function store( state: getStateProxy( isObject( state ) ? state : {}, namespace ), ...block, }; - const proxiedStore = getStoreProxy( rawStore, namespace ); + const proxiedStore = getStoreProxy( rawStore, namespace, true ); rawStores.set( namespace, rawStore ); stores.set( namespace, proxiedStore ); } else { From b471609018b6d677a663515e3f66c570215f2b05 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 28/91] Move store initialization inside init --- packages/interactivity/src/init.ts | 5 +++++ packages/interactivity/src/store.ts | 4 ---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/interactivity/src/init.ts b/packages/interactivity/src/init.ts index ddf6785d4dfdf4..6e04de897cc7ce 100644 --- a/packages/interactivity/src/init.ts +++ b/packages/interactivity/src/init.ts @@ -8,6 +8,7 @@ import { hydrate, type ContainerNode, type ComponentChild } from 'preact'; import { toVdom, hydratedIslands } from './vdom'; import { createRootFragment, splitTask } from './utils'; import { directivePrefix } from './constants'; +import { parseInitialData, populateInitialData } from './store'; // Keep the same root fragment for each interactive region node. const regionRootFragments = new WeakMap(); @@ -29,6 +30,10 @@ export const initialVdom = new WeakMap< Element, ComponentChild[] >(); // Initialize the router with the initial DOM. export const init = async () => { + // Parse and populate the initial state and config. + const data = parseInitialData(); + populateInitialData( data ); + const nodes = document.querySelectorAll( `[data-${ directivePrefix }-interactive]` ); diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 0661cb3b23db9b..637d9d1b39c9ff 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -216,7 +216,3 @@ export const populateInitialData = ( data?: { } ); } }; - -// Parse and populate the initial state and config. -const data = parseInitialData(); -populateInitialData( data ); From 164d92e08d952d9bc93f781751ee946f2c495c98 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 29/91] Add TODO comment inside `peek()` --- packages/interactivity/src/proxies/state.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index efb161f37a2a54..e0f675dbf1cc95 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -142,6 +142,7 @@ export const getStateProxy = < T extends object >( export const peek = ( obj: object, key: string ): unknown => { const prop = getPropSignal( obj, key ); + // TODO: handle values for properties that have not been accessed yet. return prop.getComputed().peek(); }; From c06f6bffea12a24212797927908597692967b5aa Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 30/91] Fix store root assignments --- packages/interactivity/src/proxies/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index 70f6a6ce1c3b2f..5febd75ab9b578 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -49,7 +49,7 @@ export const getStoreProxy = < T extends object >( isRoot = false ) => { const proxy = getProxy( obj, storeHandlers, namespace ); - if ( isRoot ) { + if ( proxy && isRoot ) { storeRoots.add( proxy ); } return proxy; From 747c82206a7efe86415ee5c7ff5e4676190bdb1d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 28 Jun 2024 13:37:03 +0200 Subject: [PATCH 31/91] Fix lint error --- .../plugins/interactive-blocks/directive-priorities/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 02982e34f8f13d..3377a06c9ccd89 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js @@ -49,7 +49,7 @@ directive( }, namespace ), - } ; + }; return h( Provider, { value }, children ); }, { priority: 8 } From 7938dd84d24239f80fe0408c63034e6b21512337 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 1 Jul 2024 17:47:09 +0200 Subject: [PATCH 32/91] Enable skipped test for non-initialized getters --- test/e2e/specs/interactivity/deferred-store.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/e2e/specs/interactivity/deferred-store.spec.ts b/test/e2e/specs/interactivity/deferred-store.spec.ts index b6a7853c40dcd4..0ddbcb0a60d2f4 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' ); From c238ede3405256f3f422a3e3782142492cab66e8 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 2 Jul 2024 17:13:47 +0200 Subject: [PATCH 33/91] Rename get(State|Store)Proxy to proxify(State|Store) --- .../interactive-blocks/directive-priorities/view.js | 4 ++-- packages/interactivity/src/directives.tsx | 6 +++--- packages/interactivity/src/index.ts | 4 ++-- packages/interactivity/src/proxies/index.ts | 4 ++-- packages/interactivity/src/proxies/state.ts | 10 ++++------ packages/interactivity/src/proxies/store.ts | 6 +++--- packages/interactivity/src/proxies/test/state-proxy.ts | 4 ++-- packages/interactivity/src/store.ts | 6 +++--- 8 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js index 3377a06c9ccd89..3d3bea1b964cf0 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, getStateProxy, 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.' ); @@ -42,7 +42,7 @@ directive( ( { context: { Provider }, props: { children } } ) => { executionProof( 'context' ); const value = { - [ namespace ]: getStateProxy( + [ namespace ]: proxifyState( { attribute: 'from context', text: 'from context', diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index e78efe24f39c66..7899650cd618a0 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -9,7 +9,7 @@ import { useContext, useMemo, useRef } from 'preact/hooks'; /** * Internal dependencies */ -import { getStateProxy, peek } from './proxies'; +import { proxifyState, peek } from './proxies'; /** * Internal dependencies @@ -271,7 +271,7 @@ export default () => { const ns = defaultEntry!.namespace; const currentValue = useRef( { - [ ns ]: getStateProxy( {}, ns ), + [ ns ]: proxifyState( {}, ns ), } ); // No change should be made if `defaultEntry` does not exist. @@ -683,7 +683,7 @@ export default () => { const itemProp = suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); const itemContext = { - [ namespace ]: getStateProxy( {}, namespace ), + [ namespace ]: proxifyState( {}, namespace ), }; const mergedContext = proxifyContext( itemContext, diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 7643d08bca20a0..ef98c0abbd0cfa 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -13,7 +13,7 @@ import { directivePrefix } from './constants'; import { toVdom } from './vdom'; import { directive, getNamespace } from './hooks'; import { parseInitialData, populateInitialData } from './store'; -import { getStateProxy } from './proxies'; +import { proxifyState } from './proxies'; export { store, getConfig } from './store'; export { getContext, getElement } from './hooks'; @@ -45,7 +45,7 @@ export const privateApis = ( lock ): any => { h, cloneElement, render, - getStateProxy, + proxifyState, parseInitialData, populateInitialData, batch, diff --git a/packages/interactivity/src/proxies/index.ts b/packages/interactivity/src/proxies/index.ts index 619f1bebcdec0b..d64fb59fa6bccf 100644 --- a/packages/interactivity/src/proxies/index.ts +++ b/packages/interactivity/src/proxies/index.ts @@ -1,5 +1,5 @@ /** * Internal dependencies */ -export { getStateProxy, peek } from './state'; -export { getStoreProxy } from './store'; +export { proxifyState, peek } from './state'; +export { proxifyStore } from './store'; diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index e0f675dbf1cc95..57ef088459d622 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -50,7 +50,7 @@ const stateHandlers: ProxyHandler< object > = { const value = Reflect.get( target, key, receiver ); prop.setValue( shouldProxy( value ) - ? getStateProxy( value, prop.namespace ) + ? proxifyState( value, prop.namespace ) : value ); } @@ -92,7 +92,7 @@ const stateHandlers: ProxyHandler< object > = { } else { prop.setValue( shouldProxy( value ) - ? getStateProxy( value, prop.namespace ) + ? proxifyState( value, prop.namespace ) : value ); } @@ -135,10 +135,8 @@ const stateHandlers: ProxyHandler< object > = { }, }; -export const getStateProxy = < T extends object >( - obj: T, - namespace: string -) => getProxy( obj, stateHandlers, namespace ); +export const proxifyState = < T extends object >( obj: T, namespace: string ) => + getProxy( obj, stateHandlers, namespace ); export const peek = ( obj: object, key: string ): unknown => { const prop = getPropSignal( obj, key ); diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index 5febd75ab9b578..40a13350e4f8fb 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -20,7 +20,7 @@ const storeHandlers: ProxyHandler< object > = { if ( typeof result === 'undefined' && storeRoots.has( receiver ) ) { const obj = {}; Reflect.set( target, key, obj ); - return getStoreProxy( obj, ns ); + return proxifyStore( obj, ns ); } // Check if the property is a function. If it is, add the store @@ -36,14 +36,14 @@ const storeHandlers: ProxyHandler< object > = { // Check if the property is an object. If it is, proxyify it. if ( isObject( result ) && shouldProxy( result ) ) { - return getStoreProxy( result, ns ); + return proxifyStore( result, ns ); } return result; }, }; -export const getStoreProxy = < T extends object >( +export const proxifyStore = < T extends object >( obj: T, namespace: string, isRoot = false diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index d92564aa461147..c6363175b05834 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -8,7 +8,7 @@ import { effect } from '@preact/signals-core'; /** * Internal dependencies */ -import { getStateProxy } from '../'; +import { proxifyState as _proxifyState } from '../'; import { setScope, resetScope, @@ -33,7 +33,7 @@ const withScopeAndNs = ( scope, ns, callback ) => () => { }; const proxifyState = < T extends object >( obj: T ) => - getStateProxy( obj, 'test' ) as T; + _proxifyState( obj, 'test' ) as T; describe( 'interactivity api handlers', () => { let nested = { b: 2 }; diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 637d9d1b39c9ff..0ffb9bb8fd66e4 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { getStateProxy, getStoreProxy } from './proxies'; +import { proxifyState, proxifyStore } from './proxies'; import { getNamespace } from './hooks'; const isObject = ( item: unknown ): item is Record< string, unknown > => @@ -148,10 +148,10 @@ export function store( storeLocks.set( namespace, lock ); } const rawStore = { - state: getStateProxy( isObject( state ) ? state : {}, namespace ), + state: proxifyState( isObject( state ) ? state : {}, namespace ), ...block, }; - const proxiedStore = getStoreProxy( rawStore, namespace, true ); + const proxiedStore = proxifyStore( rawStore, namespace, true ); rawStores.set( namespace, rawStore ); stores.set( namespace, proxiedStore ); } else { From 3cf82a1fadfbd84c951847e9c24ca3c7d145812b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 11:03:16 +0200 Subject: [PATCH 34/91] Make `getContext` throw when there is no scope --- packages/interactivity/src/hooks.tsx | 11 ++- .../src/proxies/test/state-proxy.ts | 67 +++++++++++++------ 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 9af6fb00d6aba5..b2da02d6a15bac 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -139,8 +139,15 @@ const namespaceStack: string[] = []; * function exists is used. * @return The context content. */ -export const getContext = < T extends object >( namespace?: string ): T => - getScope()?.context[ namespace || getNamespace() ]; +export const getContext = < T extends object >( namespace?: string ): T => { + const scope = getScope(); + if ( ! scope ) { + throw Error( + 'Cannot call `getContext()` outside getters and actions used by directives.' + ); + } + return scope.context[ namespace || getNamespace() ]; +}; /** * Retrieves a representation of the element where a function from the store diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index c6363175b05834..76ba3a105f66e1 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -15,6 +15,7 @@ import { setNamespace, resetNamespace, getContext, + getElement, } from '../../hooks'; type State = { @@ -41,8 +42,6 @@ describe( 'interactivity api handlers', () => { let state: State = { a: 1, nested, array }; let store = proxifyState( state ); - const window = globalThis as any; - beforeEach( () => { nested = { b: 2 }; array = [ 3, nested ]; @@ -645,7 +644,7 @@ describe( 'interactivity api handlers', () => { expect( b ).toBe( 2 ); } ); - it( 'should keep subscribed to properties even when replaced by getters', () => { + it( 'should keep subscribed to properties that become getters', () => { const store = proxifyState( { number: 1, } ); @@ -690,7 +689,7 @@ describe( 'interactivity api handlers', () => { expect( number ).toBe( 4 ); } ); - it( 'should react to changes in getter subscriptions even if they become getters', () => { + it( 'should react to changes in getter subscriptions if they become getters', () => { const store = proxifyState( { number: 1, otherNumber: 3, @@ -745,43 +744,71 @@ describe( 'interactivity api handlers', () => { expect( number ).toBe( 4 ); } ); - it( 'should support different scopes for getters', () => { + it( 'should support different scopes for the same getter', () => { const store = proxifyState( { number: 1, - get sum() { - const ctx = getContext(); - return ctx - ? this.number + ( ctx as any ).value - : this.number; + get numWithTag() { + let tag = 'No scope'; + try { + tag = getContext< any >().tag; + } catch ( e ) {} + return `${ tag }: ${ this.number }`; }, } ); const scopeA = { - context: { test: { value: 10 } }, + context: { test: { tag: 'A' } }, }; const scopeB = { - context: { test: { value: 20 } }, + context: { test: { tag: 'B' } }, }; - let resultA = 0; - let resultB = 0; + let resultA = ''; + let resultB = ''; + let resultNoScope = ''; effect( withScopeAndNs( scopeA, 'test', () => { - resultA = store.sum; + resultA = store.numWithTag; } ) ); effect( withScopeAndNs( scopeB, 'test', () => { - resultB = store.sum; + resultB = store.numWithTag; } ) ); + effect( () => { + resultNoScope = store.numWithTag; + } ); - expect( resultA ).toBe( 11 ); - expect( resultB ).toBe( 21 ); + expect( resultA ).toBe( 'A: 1' ); + expect( resultB ).toBe( 'B: 1' ); + expect( resultNoScope ).toBe( 'No scope: 1' ); store.number = 2; - expect( resultA ).toBe( 12 ); - expect( resultB ).toBe( 22 ); + 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 an scope', () => { + const store = proxifyState( { + 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( () => store.sumValueFromContext ).toThrow(); + expect( () => store.sumValueFromElement ).toThrow(); } ); } ); } ); From 99f6e87390deb32a3801c8b00c155ce76197a702 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 11:21:02 +0200 Subject: [PATCH 35/91] Fix `proxifyState` types --- packages/interactivity/src/proxies/state.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 57ef088459d622..66b77dca71a309 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -135,8 +135,10 @@ const stateHandlers: ProxyHandler< object > = { }, }; -export const proxifyState = < T extends object >( obj: T, namespace: string ) => - getProxy( obj, stateHandlers, namespace ); +export const proxifyState = < T extends object >( + obj: T, + namespace: string +): T => getProxy( obj, stateHandlers, namespace ) as T; export const peek = ( obj: object, key: string ): unknown => { const prop = getPropSignal( obj, key ); From 821532e4f386d7ae61cf621fd079e37f014538a1 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 11:22:00 +0200 Subject: [PATCH 36/91] Rename some variables in tests --- .../src/proxies/test/state-proxy.ts | 437 +++++++++--------- 1 file changed, 219 insertions(+), 218 deletions(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 76ba3a105f66e1..c1f68c6b8d8f85 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -8,7 +8,7 @@ import { effect } from '@preact/signals-core'; /** * Internal dependencies */ -import { proxifyState as _proxifyState } from '../'; +import { proxifyState } from '../'; import { setScope, resetScope, @@ -33,280 +33,281 @@ const withScopeAndNs = ( scope, ns, callback ) => () => { return result; }; -const proxifyState = < T extends object >( obj: T ) => - _proxifyState( obj, 'test' ) as T; +const proxifyStateTest = < T extends object >( obj: T ) => + proxifyState( obj, 'test' ) as T; describe( 'interactivity api handlers', () => { let nested = { b: 2 }; let array = [ 3, nested ]; - let state: State = { a: 1, nested, array }; - let store = proxifyState( state ); + let raw: State = { a: 1, nested, array }; + let state = proxifyStateTest( raw ); beforeEach( () => { nested = { b: 2 }; array = [ 3, nested ]; - state = { a: 1, nested, array }; - store = proxifyState( state ); + raw = { a: 1, nested, array }; + state = proxifyStateTest( raw ); } ); describe( 'get - plain', () => { it( 'should return plain objects/arrays', () => { - expect( store.nested ).toEqual( { b: 2 } ); - expect( store.array ).toEqual( [ 3, { b: 2 } ] ); - expect( store.array[ 1 ] ).toEqual( { b: 2 } ); + 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( store.a ).toBe( 1 ); - expect( store.nested.b ).toBe( 2 ); - expect( store.array[ 0 ] ).toBe( 3 ); + expect( state.a ).toBe( 1 ); + expect( state.nested.b ).toBe( 2 ); + expect( state.array[ 0 ] ).toBe( 3 ); expect( - typeof store.array[ 1 ] === 'object' && store.array[ 1 ].b + typeof state.array[ 1 ] === 'object' && state.array[ 1 ].b ).toBe( 2 ); - expect( store.array.length ).toBe( 2 ); + expect( state.array.length ).toBe( 2 ); } ); it( 'should support reading from getters', () => { - const store = proxifyState( { + const state = proxifyStateTest( { counter: 1, get double() { - return store.counter * 2; + return state.counter * 2; }, } ); - expect( store.double ).toBe( 2 ); - store.counter = 2; - expect( store.double ).toBe( 4 ); + expect( state.double ).toBe( 2 ); + state.counter = 2; + expect( state.double ).toBe( 4 ); } ); - it( 'should support getters returning other parts of the state', () => { - const store = proxifyState( { + it( 'should support getters returning other parts of the raw', () => { + const state = proxifyStateTest( { switch: 'a', a: { data: 'a' }, b: { data: 'b' }, get aOrB() { - return store.switch === 'a' ? store.a : store.b; + return state.switch === 'a' ? state.a : state.b; }, } ); - expect( store.aOrB.data ).toBe( 'a' ); - store.switch = 'b'; - expect( store.aOrB.data ).toBe( '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( { + const raw = proxifyStateTest( { x: { a: 1, b: 2, }, get y() { - return Object.values( state.x ); + return Object.values( raw.x ); }, } ); - expect( state.y ).toEqual( [ 1, 2 ] ); + expect( raw.y ).toEqual( [ 1, 2 ] ); } ); it( 'should work with normal functions', () => { - const store = proxifyState( { + const state = proxifyStateTest( { value: 1, isBigger: ( newValue: number ): boolean => - store.value < newValue, + state.value < newValue, sum( newValue: number ): number { - return store.value + newValue; + return state.value + newValue; }, replace: ( newValue: number ): void => { - store.value = newValue; + state.value = newValue; }, } ); - expect( store.isBigger( 2 ) ).toBe( true ); - expect( store.sum( 2 ) ).toBe( 3 ); - expect( store.value ).toBe( 1 ); - store.replace( 2 ); - expect( store.value ).toBe( 2 ); + 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 ); } ); } ); describe( 'set', () => { it( 'should update like plain objects/arrays', () => { - expect( store.a ).toBe( 1 ); - expect( store.nested.b ).toBe( 2 ); - store.a = 2; - store.nested.b = 3; - expect( store.a ).toBe( 2 ); - expect( store.nested.b ).toBe( 3 ); + 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 store = proxifyState( { + const state = proxifyStateTest( { counter: 1, get double() { - return store.counter * 2; + return state.counter * 2; }, set double( val ) { - store.counter = val / 2; + state.counter = val / 2; }, } ); - expect( store.counter ).toBe( 1 ); - store.double = 4; - expect( store.counter ).toBe( 2 ); + expect( state.counter ).toBe( 1 ); + state.double = 4; + expect( state.counter ).toBe( 2 ); } ); it( 'should update array length', () => { - expect( store.array.length ).toBe( 2 ); - store.array.push( 4 ); - expect( store.array.length ).toBe( 3 ); - store.array.splice( 1, 2 ); - expect( store.array.length ).toBe( 1 ); + 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 update when mutations happen', () => { - expect( store.a ).toBe( 1 ); - store.a = 11; - expect( store.a ).toBe( 11 ); + expect( state.a ).toBe( 1 ); + state.a = 11; + expect( state.a ).toBe( 11 ); } ); it( 'should support setting getters on the fly', () => { - const store = proxifyState< { counter: number; double?: number } >( - { - counter: 1, - } - ); - Object.defineProperty( store, 'double', { + const state = proxifyStateTest< { + counter: number; + double?: number; + } >( { + counter: 1, + } ); + Object.defineProperty( state, 'double', { get() { - return store.counter * 2; + return state.counter * 2; }, } ); - expect( store.double ).toBe( 2 ); - store.counter = 2; - expect( store.double ).toBe( 4 ); + expect( state.double ).toBe( 2 ); + state.counter = 2; + expect( state.double ).toBe( 4 ); } ); it( 'should copy object like plain JavaScript', () => { - const store = proxifyState< { + const state = proxifyStateTest< { a?: { id: number; nested: { id: number } }; b: { id: number; nested: { id: number } }; } >( { b: { id: 1, nested: { id: 1 } }, } ); - store.a = store.b; + state.a = state.b; - expect( store.a.id ).toBe( 1 ); - expect( store.b.id ).toBe( 1 ); - expect( store.a.nested.id ).toBe( 1 ); - expect( store.b.nested.id ).toBe( 1 ); + 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 ); - store.a.id = 2; - store.a.nested.id = 2; - expect( store.a.id ).toBe( 2 ); - expect( store.b.id ).toBe( 2 ); - expect( store.a.nested.id ).toBe( 2 ); - expect( store.b.nested.id ).toBe( 2 ); + 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 ); - store.b.id = 3; - store.b.nested.id = 3; - expect( store.b.id ).toBe( 3 ); - expect( store.a.id ).toBe( 3 ); - expect( store.a.nested.id ).toBe( 3 ); - expect( store.b.nested.id ).toBe( 3 ); + 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 ); - store.a.id = 4; - store.a.nested.id = 4; - expect( store.a.id ).toBe( 4 ); - expect( store.b.id ).toBe( 4 ); - expect( store.a.nested.id ).toBe( 4 ); - expect( store.b.nested.id ).toBe( 4 ); + 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 = { ...state, nested: initialNested }; - store.a = 2; - store.nested.b = 3; - Object.assign( store, initialState ); - expect( store.a ).toBe( 1 ); - expect( store.nested.b ).toBe( 2 ); + 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 = {}; - store.nested = obj; - expect( state.nested ).toBe( obj ); + state.nested = obj; + expect( raw.nested ).toBe( obj ); } ); } ); describe( 'computations', () => { it( 'should subscribe to values mutated with setters', () => { - const store = proxifyState( { + const state = proxifyStateTest( { counter: 1, get double() { - return store.counter * 2; + return state.counter * 2; }, set double( val ) { - store.counter = val / 2; + state.counter = val / 2; }, } ); let counter = 0; let double = 0; effect( () => { - counter = store.counter; - double = store.double; + counter = state.counter; + double = state.double; } ); expect( counter ).toBe( 1 ); expect( double ).toBe( 2 ); - store.double = 4; + 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 store = proxifyState( [ 0, 0, 0 ] ); + const state = proxifyStateTest( [ 0, 0, 0 ] ); let sum = 0; effect( () => { sum = 0; - sum = store.reduce( ( sum ) => sum + 1, 0 ); + sum = state.reduce( ( sum ) => sum + 1, 0 ); } ); expect( sum ).toBe( 3 ); - store.splice( 2, 1 ); + state.splice( 2, 1 ); expect( sum ).toBe( 2 ); } ); it( 'should subscribe to changes to for..in loops', () => { - const state: Record< string, number > = { a: 0, b: 0 }; - const store = proxifyState( state ); + const raw: Record< string, number > = { a: 0, b: 0 }; + const state = proxifyStateTest( raw ); let sum = 0; effect( () => { sum = 0; - for ( const _ in store ) { + for ( const _ in state ) { sum += 1; } } ); expect( sum ).toBe( 2 ); - store.c = 0; + state.c = 0; expect( sum ).toBe( 3 ); - delete store.c; + delete state.c; expect( sum ).toBe( 2 ); - store.c = 0; + state.c = 0; expect( sum ).toBe( 3 ); } ); it( 'should subscribe to changes for Object.getOwnPropertyNames()', () => { - const state: Record< string, number > = { a: 1, b: 2 }; - const store = proxifyState( state ); + const raw: Record< string, number > = { a: 1, b: 2 }; + const state = proxifyStateTest( raw ); let sum = 0; effect( () => { sum = 0; - const keys = Object.getOwnPropertyNames( store ); + const keys = Object.getOwnPropertyNames( state ); for ( const _ of keys ) { sum += 1; } @@ -314,84 +315,84 @@ describe( 'interactivity api handlers', () => { expect( sum ).toBe( 2 ); - store.c = 0; + state.c = 0; expect( sum ).toBe( 3 ); - delete store.a; + delete state.a; expect( sum ).toBe( 2 ); } ); it( 'should subscribe to changes to Object.keys/values/entries()', () => { - const state: Record< string, number > = { a: 1, b: 2 }; - const store = proxifyState( state ); + const raw: Record< string, number > = { a: 1, b: 2 }; + const state = proxifyStateTest( raw ); let keys = 0; let values = 0; let entries = 0; effect( () => { keys = 0; - Object.keys( store ).forEach( () => ( keys += 1 ) ); + Object.keys( state ).forEach( () => ( keys += 1 ) ); } ); effect( () => { values = 0; - Object.values( store ).forEach( () => ( values += 1 ) ); + Object.values( state ).forEach( () => ( values += 1 ) ); } ); effect( () => { entries = 0; - Object.entries( store ).forEach( () => ( entries += 1 ) ); + Object.entries( state ).forEach( () => ( entries += 1 ) ); } ); expect( keys ).toBe( 2 ); expect( values ).toBe( 2 ); expect( entries ).toBe( 2 ); - store.c = 0; + state.c = 0; expect( keys ).toBe( 3 ); expect( values ).toBe( 3 ); expect( entries ).toBe( 3 ); - delete store.a; + 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 store = proxifyState( [ 0, 0 ] ); + const state = proxifyStateTest( [ 0, 0 ] ); let sum = 0; effect( () => { sum = 0; - for ( const _ of store ) { + for ( const _ of state ) { sum += 1; } } ); expect( sum ).toBe( 2 ); - store.push( 0 ); + state.push( 0 ); expect( sum ).toBe( 3 ); - store.splice( 0, 1 ); + state.splice( 0, 1 ); expect( sum ).toBe( 2 ); } ); it( 'should subscribe to implicit changes in length', () => { - const store = proxifyState( [ 'foo', 'bar' ] ); + const state = proxifyStateTest( [ 'foo', 'bar' ] ); let x = ''; effect( () => { - x = store.join( ' ' ); + x = state.join( ' ' ); } ); expect( x ).toBe( 'foo bar' ); - store.push( 'baz' ); + state.push( 'baz' ); expect( x ).toBe( 'foo bar baz' ); - store.splice( 0, 1 ); + state.splice( 0, 1 ); expect( x ).toBe( 'bar baz' ); } ); @@ -399,26 +400,26 @@ describe( 'interactivity api handlers', () => { let x, y; effect( () => { - x = store.a; + x = state.a; } ); effect( () => { - y = store.nested.b; + y = state.nested.b; } ); expect( x ).toBe( 1 ); - delete store.a; + delete state.a; expect( x ).toBe( undefined ); expect( y ).toBe( 2 ); - delete store.nested.b; + delete state.nested.b; expect( y ).toBe( undefined ); } ); it( 'should subscribe to changes when mutating objects', () => { let x, y; - const store = proxifyState< { + const state = proxifyStateTest< { a?: { id: number; nested: { id: number } }; b: { id: number; nested: { id: number } }[]; } >( { @@ -429,30 +430,30 @@ describe( 'interactivity api handlers', () => { } ); effect( () => { - x = store.a?.id; + x = state.a?.id; } ); effect( () => { - y = store.a?.nested.id; + y = state.a?.nested.id; } ); expect( x ).toBe( undefined ); expect( y ).toBe( undefined ); - store.a = store.b[ 0 ]; + state.a = state.b[ 0 ]; expect( x ).toBe( 1 ); expect( y ).toBe( 1 ); - store.a = store.b[ 1 ]; + state.a = state.b[ 1 ]; expect( x ).toBe( 2 ); expect( y ).toBe( 2 ); - store.a = undefined; + state.a = undefined; expect( x ).toBe( undefined ); expect( y ).toBe( undefined ); - store.a = store.b[ 1 ]; + state.a = state.b[ 1 ]; expect( x ).toBe( 2 ); expect( y ).toBe( 2 ); } ); @@ -460,50 +461,50 @@ describe( 'interactivity api handlers', () => { it( 'should trigger effects after mutations happen', () => { let x; effect( () => { - x = store.a; + x = state.a; } ); expect( x ).toBe( 1 ); - store.a = 11; + state.a = 11; expect( x ).toBe( 11 ); } ); it( 'should subscribe corretcly from getters', () => { let x; - const store = proxifyState( { + const state = proxifyStateTest( { counter: 1, get double() { - return store.counter * 2; + return state.counter * 2; }, } ); - effect( () => ( x = store.double ) ); + effect( () => ( x = state.double ) ); expect( x ).toBe( 2 ); - store.counter = 2; + state.counter = 2; expect( x ).toBe( 4 ); } ); - it( 'should subscribe corretcly from getters returning other parts of the store', () => { + it( 'should subscribe corretcly from getters returning other parts of the state', () => { let data; - const store = proxifyState( { + const state = proxifyStateTest( { switch: 'a', a: { data: 'a' }, b: { data: 'b' }, get aOrB() { - return store.switch === 'a' ? store.a : store.b; + return state.switch === 'a' ? state.a : state.b; }, } ); - effect( () => ( data = store.aOrB.data ) ); + effect( () => ( data = state.aOrB.data ) ); expect( data ).toBe( 'a' ); - store.switch = 'b'; + state.switch = 'b'; expect( data ).toBe( 'b' ); } ); it( 'should subscribe to changes', () => { - const spy1 = jest.fn( () => store.a ); - const spy2 = jest.fn( () => store.nested ); - const spy3 = jest.fn( () => store.nested.b ); - const spy4 = jest.fn( () => store.array[ 0 ] ); + 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 store.array[ 1 ] === 'object' && store.array[ 1 ].b + () => typeof state.array[ 1 ] === 'object' && state.array[ 1 ].b ); effect( spy1 ); @@ -518,7 +519,7 @@ describe( 'interactivity api handlers', () => { expect( spy4 ).toHaveBeenCalledTimes( 1 ); expect( spy5 ).toHaveBeenCalledTimes( 1 ); - store.a = 11; + state.a = 11; expect( spy1 ).toHaveBeenCalledTimes( 2 ); expect( spy2 ).toHaveBeenCalledTimes( 1 ); @@ -526,7 +527,7 @@ describe( 'interactivity api handlers', () => { expect( spy4 ).toHaveBeenCalledTimes( 1 ); expect( spy5 ).toHaveBeenCalledTimes( 1 ); - store.nested.b = 22; + state.nested.b = 22; expect( spy1 ).toHaveBeenCalledTimes( 2 ); expect( spy2 ).toHaveBeenCalledTimes( 1 ); @@ -534,15 +535,15 @@ describe( 'interactivity api handlers', () => { expect( spy4 ).toHaveBeenCalledTimes( 1 ); expect( spy5 ).toHaveBeenCalledTimes( 2 ); // nested also exists array[1] - store.nested = { b: 222 }; + 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 store.nested has a different reference + expect( spy5 ).toHaveBeenCalledTimes( 2 ); // now state.nested has a different reference - store.array[ 0 ] = 33; + state.array[ 0 ] = 33; expect( spy1 ).toHaveBeenCalledTimes( 2 ); expect( spy2 ).toHaveBeenCalledTimes( 2 ); @@ -550,8 +551,8 @@ describe( 'interactivity api handlers', () => { expect( spy4 ).toHaveBeenCalledTimes( 2 ); expect( spy5 ).toHaveBeenCalledTimes( 2 ); - if ( typeof store.array[ 1 ] === 'object' ) { - store.array[ 1 ].b = 2222; + if ( typeof state.array[ 1 ] === 'object' ) { + state.array[ 1 ].b = 2222; } expect( spy1 ).toHaveBeenCalledTimes( 2 ); @@ -560,7 +561,7 @@ describe( 'interactivity api handlers', () => { expect( spy4 ).toHaveBeenCalledTimes( 2 ); expect( spy5 ).toHaveBeenCalledTimes( 3 ); - store.array[ 1 ] = { b: 22222 }; + state.array[ 1 ] = { b: 22222 }; expect( spy1 ).toHaveBeenCalledTimes( 2 ); expect( spy2 ).toHaveBeenCalledTimes( 2 ); @@ -568,7 +569,7 @@ describe( 'interactivity api handlers', () => { expect( spy4 ).toHaveBeenCalledTimes( 2 ); expect( spy5 ).toHaveBeenCalledTimes( 4 ); - store.array.push( 4 ); + state.array.push( 4 ); expect( spy1 ).toHaveBeenCalledTimes( 2 ); expect( spy2 ).toHaveBeenCalledTimes( 2 ); @@ -576,7 +577,7 @@ describe( 'interactivity api handlers', () => { expect( spy4 ).toHaveBeenCalledTimes( 2 ); expect( spy5 ).toHaveBeenCalledTimes( 4 ); - store.array[ 3 ] = 5; + state.array[ 3 ] = 5; expect( spy1 ).toHaveBeenCalledTimes( 2 ); expect( spy2 ).toHaveBeenCalledTimes( 2 ); @@ -584,7 +585,7 @@ describe( 'interactivity api handlers', () => { expect( spy4 ).toHaveBeenCalledTimes( 2 ); expect( spy5 ).toHaveBeenCalledTimes( 4 ); - store.array = [ 333, { b: 222222 } ]; + state.array = [ 333, { b: 222222 } ]; expect( spy1 ).toHaveBeenCalledTimes( 2 ); expect( spy2 ).toHaveBeenCalledTimes( 2 ); @@ -595,70 +596,70 @@ describe( 'interactivity api handlers', () => { it( 'should subscribe to array length', () => { const array = [ 1 ]; - const store = proxifyState( { array } ); - const spy1 = jest.fn( () => store.array.length ); - const spy2 = jest.fn( () => store.array.map( ( i: number ) => i ) ); + const state = proxifyStateTest( { 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 ); - store.array.push( 2 ); - expect( store.array.length ).toBe( 2 ); + state.array.push( 2 ); + expect( state.array.length ).toBe( 2 ); expect( spy1 ).toHaveBeenCalledTimes( 2 ); expect( spy2 ).toHaveBeenCalledTimes( 2 ); - store.array[ 2 ] = 3; - expect( store.array.length ).toBe( 3 ); + state.array[ 2 ] = 3; + expect( state.array.length ).toBe( 3 ); expect( spy1 ).toHaveBeenCalledTimes( 3 ); expect( spy2 ).toHaveBeenCalledTimes( 3 ); - store.array = store.array.filter( ( i: number ) => i <= 2 ); - expect( store.array.length ).toBe( 2 ); + 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 = { ...state, nested: initialNested }; + const initialState = { ...raw, nested: initialNested }; let a, b; effect( () => { - a = store.a; + a = state.a; } ); effect( () => { - b = store.nested.b; + b = state.nested.b; } ); - store.a = 2; - store.nested.b = 3; + state.a = 2; + state.nested.b = 3; expect( a ).toBe( 2 ); expect( b ).toBe( 3 ); - Object.assign( store, initialState ); + Object.assign( state, initialState ); expect( a ).toBe( 1 ); expect( b ).toBe( 2 ); } ); it( 'should keep subscribed to properties that become getters', () => { - const store = proxifyState( { + const state = proxifyStateTest( { number: 1, } ); let number = 0; effect( () => { - number = store.number; + number = state.number; } ); expect( number ).toBe( 1 ); - store.number = 2; + state.number = 2; expect( number ).toBe( 2 ); - Object.defineProperty( store, 'number', { + Object.defineProperty( state, 'number', { get: () => 3, configurable: true, } ); @@ -666,7 +667,7 @@ describe( 'interactivity api handlers', () => { } ); it( 'should react to changes in getter subscriptions', () => { - const store = proxifyState( { + const state = proxifyStateTest( { number: 1, otherNumber: 3, } ); @@ -674,23 +675,23 @@ describe( 'interactivity api handlers', () => { let number = 0; effect( () => { - number = store.number; + number = state.number; } ); expect( number ).toBe( 1 ); - store.number = 2; + state.number = 2; expect( number ).toBe( 2 ); - Object.defineProperty( store, 'number', { - get: () => store.otherNumber, + Object.defineProperty( state, 'number', { + get: () => state.otherNumber, configurable: true, } ); expect( number ).toBe( 3 ); - store.otherNumber = 4; + state.otherNumber = 4; expect( number ).toBe( 4 ); } ); it( 'should react to changes in getter subscriptions if they become getters', () => { - const store = proxifyState( { + const state = proxifyStateTest( { number: 1, otherNumber: 3, } ); @@ -698,20 +699,20 @@ describe( 'interactivity api handlers', () => { let number = 0; effect( () => { - number = store.number; + number = state.number; } ); expect( number ).toBe( 1 ); - store.number = 2; + state.number = 2; expect( number ).toBe( 2 ); - Object.defineProperty( store, 'number', { - get: () => store.otherNumber, + Object.defineProperty( state, 'number', { + get: () => state.otherNumber, configurable: true, } ); expect( number ).toBe( 3 ); - store.otherNumber = 4; + state.otherNumber = 4; expect( number ).toBe( 4 ); - Object.defineProperty( store, 'otherNumber', { + Object.defineProperty( state, 'otherNumber', { get: () => 5, configurable: true, } ); @@ -719,7 +720,7 @@ describe( 'interactivity api handlers', () => { } ); it( 'should allow getters to use `this`', () => { - const store = proxifyState( { + const state = proxifyStateTest( { number: 1, otherNumber: 3, } ); @@ -727,25 +728,25 @@ describe( 'interactivity api handlers', () => { let number = 0; effect( () => { - number = store.number; + number = state.number; } ); expect( number ).toBe( 1 ); - store.number = 2; + state.number = 2; expect( number ).toBe( 2 ); - Object.defineProperty( store, 'number', { + Object.defineProperty( state, 'number', { get() { return this.otherNumber; }, configurable: true, } ); expect( number ).toBe( 3 ); - store.otherNumber = 4; + state.otherNumber = 4; expect( number ).toBe( 4 ); } ); it( 'should support different scopes for the same getter', () => { - const store = proxifyState( { + const state = proxifyStateTest( { number: 1, get numWithTag() { let tag = 'No scope'; @@ -769,29 +770,29 @@ describe( 'interactivity api handlers', () => { effect( withScopeAndNs( scopeA, 'test', () => { - resultA = store.numWithTag; + resultA = state.numWithTag; } ) ); effect( withScopeAndNs( scopeB, 'test', () => { - resultB = store.numWithTag; + resultB = state.numWithTag; } ) ); effect( () => { - resultNoScope = store.numWithTag; + resultNoScope = state.numWithTag; } ); expect( resultA ).toBe( 'A: 1' ); expect( resultB ).toBe( 'B: 1' ); expect( resultNoScope ).toBe( 'No scope: 1' ); - store.number = 2; + 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 an scope', () => { - const store = proxifyState( { + const state = proxifyStateTest( { number: 1, get sumValueFromContext() { const ctx = getContext(); @@ -807,8 +808,8 @@ describe( 'interactivity api handlers', () => { }, } ); - expect( () => store.sumValueFromContext ).toThrow(); - expect( () => store.sumValueFromElement ).toThrow(); + expect( () => state.sumValueFromContext ).toThrow(); + expect( () => state.sumValueFromElement ).toThrow(); } ); } ); } ); From 2dc4548f45c221089e3ecddc2c9d2db911147a40 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 11:25:46 +0200 Subject: [PATCH 37/91] Add test for object reference keeping --- packages/interactivity/src/proxies/test/state-proxy.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index c1f68c6b8d8f85..c5016f3fde3415 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -234,6 +234,16 @@ describe( 'interactivity api handlers', () => { state.nested = obj; expect( raw.nested ).toBe( obj ); } ); + + it( 'should keep object references across namespaces', () => { + const raw1 = { obj: {} }; + const raw2 = { obj: {} }; + const state1 = proxifyState( raw1, 'test-1' ); + const state2 = proxifyState( raw2, 'test-2' ); + state2.obj = state1.obj; + expect( state2.obj ).toBe( state1.obj ); + expect( raw2.obj ).toBe( state1.obj ); + } ); } ); describe( 'computations', () => { From c720d0fc0cdfc33641c7caa1adf9e0a04532fc74 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 12:05:33 +0200 Subject: [PATCH 38/91] Rename test suite for state proxy --- packages/interactivity/src/proxies/test/state-proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index c5016f3fde3415..eeb0dbe9fef60a 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -36,7 +36,7 @@ const withScopeAndNs = ( scope, ns, callback ) => () => { const proxifyStateTest = < T extends object >( obj: T ) => proxifyState( obj, 'test' ) as T; -describe( 'interactivity api handlers', () => { +describe( 'interactivity api - state proxy', () => { let nested = { b: 2 }; let array = [ 3, nested ]; let raw: State = { a: 1, nested, array }; From 8de7b838ada1df95e3b95de223b1542a500d7400 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 13:25:45 +0200 Subject: [PATCH 39/91] Add tests for getters and functions with scope --- .../src/proxies/test/state-proxy.ts | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index eeb0dbe9fef60a..a0ef5093f8b270 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -27,10 +27,12 @@ type State = { const withScopeAndNs = ( scope, ns, callback ) => () => { setScope( scope ); setNamespace( ns ); - const result = callback(); - resetNamespace(); - resetScope(); - return result; + try { + return callback(); + } finally { + resetNamespace(); + resetScope(); + } }; const proxifyStateTest = < T extends object >( obj: T ) => @@ -93,17 +95,36 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support getters using ownKeys traps', () => { - const raw = proxifyStateTest( { + const state = proxifyStateTest( { x: { a: 1, b: 2, }, get y() { - return Object.values( raw.x ); + return Object.values( state.x ); + }, + } ); + + expect( state.y ).toEqual( [ 1, 2 ] ); + } ); + + it( 'should support getters accessing the scope', () => { + const state = proxifyStateTest( { + get y() { + const ctx = getContext< { value: string } >(); + return ctx.value; }, } ); - expect( raw.y ).toEqual( [ 1, 2 ] ); + const scope = { context: { test: { value: 'from context' } } }; + try { + setScope( scope as any ); + setNamespace( 'test' ); + expect( state.y ).toBe( 'from context' ); + } finally { + resetNamespace(); + resetScope(); + } } ); it( 'should work with normal functions', () => { @@ -124,6 +145,25 @@ describe( 'interactivity api - state proxy', () => { state.replace( 2 ); expect( state.value ).toBe( 2 ); } ); + + it( 'should work with normal functions accessing the scope', () => { + const state = proxifyStateTest( { + sumContextValue( newValue: number ): number { + const ctx = getContext< { value: number } >(); + return ctx.value + newValue; + }, + } ); + + const scope = { context: { test: { value: 1 } } }; + try { + setScope( scope as any ); + setNamespace( 'test' ); + expect( state.sumContextValue( 2 ) ).toBe( 3 ); + } finally { + resetNamespace(); + resetScope(); + } + } ); } ); describe( 'set', () => { From 4bac1a854f3c6a8eba4b27950152b86053efbffd Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 13:42:42 +0200 Subject: [PATCH 40/91] Add tests for prop subscription inside functions --- .../src/proxies/test/state-proxy.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index a0ef5093f8b270..d866a6d9e66da6 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -861,5 +861,27 @@ describe( 'interactivity api - state proxy', () => { expect( () => state.sumValueFromContext ).toThrow(); expect( () => state.sumValueFromElement ).toThrow(); } ); + + it( 'should react to changes in props inside functions', () => { + const state = proxifyStateTest( { + 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 ); + } ); } ); } ); From 5e508c1c7ad4de51ccc1ba799a62b44386067cc8 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 13:49:13 +0200 Subject: [PATCH 41/91] Allow functions to use `this` --- packages/interactivity/src/proxies/state.ts | 2 +- .../interactivity/src/proxies/test/state-proxy.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 66b77dca71a309..ea20308550a532 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -66,7 +66,7 @@ const stateHandlers: ProxyHandler< object > = { return ( ...args: unknown[] ) => { setNamespace( prop.namespace ); try { - return result( ...args ); + return result.call( receiver, ...args ); } finally { resetNamespace(); } diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index d866a6d9e66da6..bffa7e5254f61b 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -164,6 +164,16 @@ describe( 'interactivity api - state proxy', () => { resetScope(); } } ); + + it( 'should allow using `this` inside functions', () => { + const state = proxifyStateTest( { + value: 1, + sum( newValue: number ): number { + return this.value + newValue; + }, + } ); + expect( state.sum( 2 ) ).toBe( 3 ); + } ); } ); describe( 'set', () => { @@ -716,7 +726,7 @@ describe( 'interactivity api - state proxy', () => { expect( number ).toBe( 3 ); } ); - it( 'should react to changes in getter subscriptions', () => { + it( 'should react to changes in props inside getters', () => { const state = proxifyStateTest( { number: 1, otherNumber: 3, @@ -740,7 +750,7 @@ describe( 'interactivity api - state proxy', () => { expect( number ).toBe( 4 ); } ); - it( 'should react to changes in getter subscriptions if they become getters', () => { + it( 'should react to changes in props inside getters if they become getters', () => { const state = proxifyStateTest( { number: 1, otherNumber: 3, From a4840a0cd21fce4638114e3277312bc10452e269 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 14:07:15 +0200 Subject: [PATCH 42/91] Minor fixes --- packages/interactivity/src/proxies/test/state-proxy.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index bffa7e5254f61b..a2a14002d360ec 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -1,5 +1,4 @@ /* eslint-disable eslint-comments/disable-enable-pair */ -/* eslint-disable jest/no-identical-title */ /* eslint-disable @typescript-eslint/no-shadow */ /** * External dependencies @@ -80,7 +79,7 @@ describe( 'interactivity api - state proxy', () => { expect( state.double ).toBe( 4 ); } ); - it( 'should support getters returning other parts of the raw', () => { + it( 'should support getters returning other parts of the state', () => { const state = proxifyStateTest( { switch: 'a', a: { data: 'a' }, From d17f4bd12f7d788fffdaf3f1f3ab2a682af1131e Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 3 Jul 2024 17:50:51 +0200 Subject: [PATCH 43/91] Add tests to store proxies --- packages/interactivity/src/proxies/store.ts | 4 +- .../src/proxies/test/store-proxy.ts | 138 ++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 packages/interactivity/src/proxies/test/store-proxy.ts diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index 40a13350e4f8fb..f027ce420ee51c 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -47,10 +47,10 @@ export const proxifyStore = < T extends object >( obj: T, namespace: string, isRoot = false -) => { +): T => { const proxy = getProxy( obj, storeHandlers, namespace ); if ( proxy && isRoot ) { storeRoots.add( proxy ); } - return proxy; + return proxy as T; }; 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 00000000000000..face27f6f25d8c --- /dev/null +++ b/packages/interactivity/src/proxies/test/store-proxy.ts @@ -0,0 +1,138 @@ +/** + * Internal dependencies + */ +import { proxifyStore, proxifyState } from '../'; +import { + setScope, + resetScope, + setNamespace, + resetNamespace, + getContext, +} from '../../hooks'; + +describe( 'interactivity api - store proxy', () => { + describe( 'get', () => { + it( 'should initialize properties at the top level if they do not exist', () => { + const store = proxifyStore< any >( {}, 'test', true ); + 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( + { + callbacks: { + syncFunc, + nested: { syncFunc }, + }, + }, + 'test', + true + ); + + 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( + { callbacks: { asyncFunc, nested: { asyncFunc } } }, + 'test', + true + ); + + 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( + { callbacks: { asyncFunc } }, + 'test1', + true + ); + + const storeTest2 = proxifyStore( + { + callbacks: { + *asyncFunc() { + const result = + yield storeTest1.callbacks.asyncFunc(); + return result; + }, + }, + }, + 'test2', + true + ); + + 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( { state }, 'test' ); + + expect( store.state ).toBe( state ); + } ); + } ); +} ); From d64eee3973b0cc7ffc09d2d29b99becf17421f8d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 Jul 2024 12:38:48 +0200 Subject: [PATCH 44/91] Throw an error when an object cannot be proxified --- packages/interactivity/src/proxies/registry.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/interactivity/src/proxies/registry.ts b/packages/interactivity/src/proxies/registry.ts index ef208b327f5149..058dcf51ac76b5 100644 --- a/packages/interactivity/src/proxies/registry.ts +++ b/packages/interactivity/src/proxies/registry.ts @@ -9,6 +9,9 @@ export const getProxy = < T extends object >( handlers?: ProxyHandler< T >, namespace?: string ): T => { + if ( ! shouldProxy( obj ) ) { + throw Error( 'This object can be proxified.' ); + } if ( ! objToProxy.has( obj ) && handlers && namespace ) { const proxy = new Proxy( obj, handlers ); ignore.add( proxy ); From 22f5244b5d5f0514ff5c4dad823cbc9f6c081996 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 Jul 2024 12:41:25 +0200 Subject: [PATCH 45/91] Change peek implementation --- packages/interactivity/src/directives.tsx | 2 +- packages/interactivity/src/proxies/state.ts | 35 +++++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 7899650cd618a0..df7c669c00950f 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -133,7 +133,7 @@ const proxifyContext = ( current: object, inherited: object = {} ): object => { * @param proxy A deepSignal instance. * @param source Object with properties to update in `proxy`. */ -const updateSignals = ( proxy: object, source: object ) => { +const updateSignals = ( proxy: any, source: any ) => { for ( const k in source ) { if ( isPlainObject( peek( proxy, k ) ) && diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index ea20308550a532..f8fc6826f2ff4c 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -12,21 +12,24 @@ import { setNamespace, resetNamespace } from '../hooks'; const proxyToProps: WeakMap< object, - Map< string, PropSignal > + Map< string | symbol, PropSignal > > = new WeakMap(); const objToIterable = new WeakMap< object, Signal< number > >(); const descriptor = Object.getOwnPropertyDescriptor; +let peeking = false; + const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { const desc = descriptor( target, key ); + const isPropFromObjectPrototype = ! desc && key in target; /* - * This property comes from the Object prototype and should not - * be processed. + * If peeking, or the property comes from the Object prototype, then it + * should not be processed. */ - if ( ! desc && key in target ) { + if ( peeking || isPropFromObjectPrototype ) { return Reflect.get( target, key, receiver ); } @@ -55,6 +58,10 @@ const stateHandlers: ProxyHandler< object > = { ); } + if ( peeking ) { + return prop.getComputed().peek(); + } + const result = prop.getComputed().value; /* @@ -140,16 +147,26 @@ export const proxifyState = < T extends object >( namespace: string ): T => getProxy( obj, stateHandlers, namespace ) as T; -export const peek = ( obj: object, key: string ): unknown => { - const prop = getPropSignal( obj, key ); - // TODO: handle values for properties that have not been accessed yet. - return prop.getComputed().peek(); +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; + } }; -export const getPropSignal = ( proxy: object, key: string ) => { +export const getPropSignal = ( + proxy: object, + key: string | number | symbol +) => { if ( ! proxyToProps.has( proxy ) ) { proxyToProps.set( proxy, new Map() ); } + key = typeof key === 'number' ? `${ key }` : key; const props = proxyToProps.get( proxy )!; if ( ! props.has( key ) ) { props.set( key, new PropSignal( proxy ) ); From 82622aaf2bd09b319e62593e8c8d887ee5797b00 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 Jul 2024 12:41:55 +0200 Subject: [PATCH 46/91] Add tests for peek and unsupported structures --- .../src/proxies/test/state-proxy.ts | 174 +++++++++++++++++- 1 file changed, 173 insertions(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index a2a14002d360ec..392739d4d52279 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -7,7 +7,7 @@ import { effect } from '@preact/signals-core'; /** * Internal dependencies */ -import { proxifyState } from '../'; +import { proxifyState, peek } from '../'; import { setScope, resetScope, @@ -43,6 +43,8 @@ describe( 'interactivity api - state proxy', () => { let raw: State = { a: 1, nested, array }; let state = proxifyStateTest( raw ); + const window = globalThis as any; + beforeEach( () => { nested = { b: 2 }; array = [ 3, nested ]; @@ -893,4 +895,174 @@ describe( 'interactivity api - state proxy', () => { 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 = proxifyStateTest( { + counter: 1, + get double() { + return state.counter * 2; + }, + } ); + expect( peek( state, 'double' ) ).toBe( 2 ); + state.counter = 2; + expect( peek( state, 'double' ) ).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 = proxifyStateTest( raw ); + const state2 = proxifyStateTest( raw ); + expect( state1 ).toBe( state2 ); + } ); + + it( 'should return the same proxy when trying to re-proxify a state object', () => { + const state = proxifyStateTest( {} ); + expect( () => proxifyStateTest( state ) ).toThrow(); + } ); + } ); + + describe( 'unsupported data structures', () => { + it( 'should throw when trying to proxify a class instance', () => { + class MyClass {} + const obj = new MyClass(); + expect( () => proxifyStateTest( obj ) ).toThrow(); + } ); + + it( 'should not wrap a class instance', () => { + class MyClass {} + const obj = new MyClass(); + const state = proxifyStateTest( { 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 = proxifyStateTest( { obj } ); + expect( state.obj ).toBe( obj ); + } ); + + it( 'should not wrap elements in proxies', () => { + const el = window.document.createElement( 'div' ); + const state = proxifyStateTest( { el } ); + expect( state.el ).toBe( el ); + } ); + + it( 'should wrap global objects', () => { + window.obj = { b: 2 }; + const state = proxifyStateTest( window.obj ); + expect( state ).not.toBe( window.obj ); + expect( state ).toStrictEqual( { b: 2 } ); + } ); + + it( 'should not wrap dates', () => { + const date = new Date(); + const state = proxifyStateTest( { date } ); + expect( state.date ).toBe( date ); + } ); + + it( 'should not wrap regular expressions', () => { + const regex = new RegExp( '' ); + const state = proxifyStateTest( { regex } ); + expect( state.regex ).toBe( regex ); + } ); + + it( 'should not wrap Map', () => { + const map = new Map(); + const state = proxifyStateTest( { map } ); + expect( state.map ).toBe( map ); + } ); + + it( 'should not wrap Set', () => { + const set = new Set(); + const state = proxifyStateTest( { set } ); + expect( state.set ).toBe( set ); + } ); + } ); } ); From 43dcfec822db9a6acd2f11597457dbe5c3f59dfe Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 Jul 2024 13:10:57 +0200 Subject: [PATCH 47/91] Test peeking getters that access scope or other namespaces --- .../src/proxies/test/state-proxy.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 392739d4d52279..49288409e2b4ad 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -971,6 +971,69 @@ describe( 'interactivity api - state proxy', () => { state.counter = 2; expect( peek( state, 'double' ) ).toBe( 4 ); } ); + + it( 'should support peeking getters accessing the scope', () => { + const state = proxifyStateTest( { + get double() { + const { counter } = getContext< { counter: number } >(); + return counter * 2; + }, + } ); + + const context = proxifyStateTest( { 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( + { + get counter() { + const { counter } = getContext< { counter: number } >(); + return counter; + }, + }, + 'test2' + ); + const context2 = proxifyState( { counter: 1 }, 'test2' ); + + const state1 = proxifyState( + { + get double() { + return state2.counter * 2; + }, + }, + 'test1' + ); + + 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', () => { From e2b2e61879fbb65c3925d2723b0ed46a29b82892 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 Jul 2024 13:45:41 +0200 Subject: [PATCH 48/91] Ignore well-known symbols --- packages/interactivity/src/proxies/state.ts | 33 ++++++++++++++++--- .../src/proxies/test/state-proxy.ts | 31 +++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index f8fc6826f2ff4c..53c28cf768619f 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -10,12 +10,34 @@ import { getProxy, shouldProxy } from './registry'; import { PropSignal } from './signals'; import { setNamespace, resetNamespace } from '../hooks'; +type WellKnownSymbols = + | 'asyncIterator' + | 'hasInstance' + | 'isConcatSpreadable' + | 'iterator' + | 'match' + | 'matchAll' + | 'replace' + | 'search' + | 'species' + | 'split' + | 'toPrimitive' + | 'toStringTag' + | 'unscopables'; + const proxyToProps: WeakMap< object, Map< string | symbol, PropSignal > > = new WeakMap(); const objToIterable = new WeakMap< object, Signal< number > >(); + +const wellKnownSymbols = new Set( + Object.getOwnPropertyNames( Symbol ) + .map( ( key ) => Symbol[ key as WellKnownSymbols ] ) + .filter( ( value ) => typeof value === 'symbol' ) +); + const descriptor = Object.getOwnPropertyDescriptor; let peeking = false; @@ -23,13 +45,16 @@ let peeking = false; const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { const desc = descriptor( target, key ); - const isPropFromObjectPrototype = ! desc && key in target; /* - * If peeking, or the property comes from the Object prototype, then it - * should not be processed. + * If peeking, the property comes from the Object prototype, or the key + * is a well-known symbol, then it should not be processed. */ - if ( peeking || isPropFromObjectPrototype ) { + if ( + peeking || + ( ! desc && key in Object.prototype ) || + ( typeof key === 'symbol' && wellKnownSymbols.has( key ) ) + ) { return Reflect.get( target, key, receiver ); } diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 49288409e2b4ad..1dd1906523dc60 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -1128,4 +1128,35 @@ describe( 'interactivity api - state proxy', () => { expect( state.set ).toBe( set ); } ); } ); + + describe( 'symbols', () => { + it( 'should observe symbols', () => { + const key = Symbol( 'key' ); + let x; + const store = proxifyStateTest< { [ key: symbol ]: any } >( {} ); + 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 = proxifyStateTest< { [ key: symbol ]: any } >( {} ); + 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 ); + } ); + } ); } ); From 0bd105021d36ddc7f9bc1f37236059c34a5c86bf Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 Jul 2024 13:48:46 +0200 Subject: [PATCH 49/91] Minor comment format fix --- packages/interactivity/src/proxies/test/state-proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 1dd1906523dc60..581e21110f5165 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -1,5 +1,6 @@ /* eslint-disable eslint-comments/disable-enable-pair */ /* eslint-disable @typescript-eslint/no-shadow */ + /** * External dependencies */ From 53c3939896390083cd94240288994207857a640f Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 Jul 2024 17:03:20 +0200 Subject: [PATCH 50/91] Move namespace arg to first position in proxify functions --- .../directive-priorities/view.js | 11 +- packages/interactivity/src/directives.tsx | 4 +- .../interactivity/src/proxies/registry.ts | 9 +- packages/interactivity/src/proxies/state.ts | 12 +- packages/interactivity/src/proxies/store.ts | 14 +- .../src/proxies/test/state-proxy.ts | 141 +++++++++--------- .../src/proxies/test/store-proxy.ts | 55 +++---- packages/interactivity/src/store.ts | 4 +- 8 files changed, 117 insertions(+), 133 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js index 3d3bea1b964cf0..5a46908f77d87b 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js @@ -42,13 +42,10 @@ directive( ( { context: { Provider }, props: { children } } ) => { executionProof( 'context' ); const value = { - [ namespace ]: proxifyState( - { - attribute: 'from context', - text: 'from context', - }, - namespace - ), + [ namespace ]: proxifyState( namespace, { + attribute: 'from context', + text: 'from context', + } ), }; return h( Provider, { value }, children ); }, diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index df7c669c00950f..419fb42e7d7c7a 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -271,7 +271,7 @@ export default () => { const ns = defaultEntry!.namespace; const currentValue = useRef( { - [ ns ]: proxifyState( {}, ns ), + [ ns ]: proxifyState( ns, {} ), } ); // No change should be made if `defaultEntry` does not exist. @@ -683,7 +683,7 @@ export default () => { const itemProp = suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); const itemContext = { - [ namespace ]: proxifyState( {}, namespace ), + [ namespace ]: proxifyState( namespace, {} ), }; const mergedContext = proxifyContext( itemContext, diff --git a/packages/interactivity/src/proxies/registry.ts b/packages/interactivity/src/proxies/registry.ts index 058dcf51ac76b5..fc48f2a99c4f39 100644 --- a/packages/interactivity/src/proxies/registry.ts +++ b/packages/interactivity/src/proxies/registry.ts @@ -4,10 +4,10 @@ const ignore = new WeakSet< object >(); const supported = new Set( [ Object, Array ] ); -export const getProxy = < T extends object >( +export const createProxy = < T extends object >( + namespace: string, obj: T, - handlers?: ProxyHandler< T >, - namespace?: string + handlers: ProxyHandler< T > ): T => { if ( ! shouldProxy( obj ) ) { throw Error( 'This object can be proxified.' ); @@ -21,6 +21,9 @@ export const getProxy = < T extends object >( return objToProxy.get( obj ) as T; }; +export const getProxy = < T extends object >( obj: T ): T => + objToProxy.get( obj ) as T; + export const getProxyNs = ( proxy: object ): string => proxyToNs.get( proxy )!; export const shouldProxy = ( val: any ): val is Object | Array< unknown > => { diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 53c28cf768619f..3349ac512e45a7 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -6,7 +6,7 @@ import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { getProxy, shouldProxy } from './registry'; +import { createProxy, getProxy, shouldProxy } from './registry'; import { PropSignal } from './signals'; import { setNamespace, resetNamespace } from '../hooks'; @@ -78,7 +78,7 @@ const stateHandlers: ProxyHandler< object > = { const value = Reflect.get( target, key, receiver ); prop.setValue( shouldProxy( value ) - ? proxifyState( value, prop.namespace ) + ? proxifyState( prop.namespace, value ) : value ); } @@ -124,7 +124,7 @@ const stateHandlers: ProxyHandler< object > = { } else { prop.setValue( shouldProxy( value ) - ? proxifyState( value, prop.namespace ) + ? proxifyState( prop.namespace, value ) : value ); } @@ -168,9 +168,9 @@ const stateHandlers: ProxyHandler< object > = { }; export const proxifyState = < T extends object >( - obj: T, - namespace: string -): T => getProxy( obj, stateHandlers, namespace ) as T; + namespace: string, + obj: T +): T => createProxy( namespace, obj, stateHandlers ) as T; export const peek = < T extends object, K extends keyof T >( obj: T, diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index f027ce420ee51c..f5f795960731a5 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { getProxy, getProxyNs, shouldProxy } from './registry'; +import { createProxy, getProxyNs, shouldProxy } from './registry'; import { setNamespace, resetNamespace } from '../hooks'; import { withScope } from '../utils'; @@ -20,7 +20,7 @@ const storeHandlers: ProxyHandler< object > = { if ( typeof result === 'undefined' && storeRoots.has( receiver ) ) { const obj = {}; Reflect.set( target, key, obj ); - return proxifyStore( obj, ns ); + return proxifyStore( ns, obj, true ); } // Check if the property is a function. If it is, add the store @@ -36,7 +36,7 @@ const storeHandlers: ProxyHandler< object > = { // Check if the property is an object. If it is, proxyify it. if ( isObject( result ) && shouldProxy( result ) ) { - return proxifyStore( result, ns ); + return proxifyStore( ns, result, true ); } return result; @@ -44,12 +44,12 @@ const storeHandlers: ProxyHandler< object > = { }; export const proxifyStore = < T extends object >( - obj: T, namespace: string, - isRoot = false + obj: T, + isNotRoot = false ): T => { - const proxy = getProxy( obj, storeHandlers, namespace ); - if ( proxy && isRoot ) { + const proxy = createProxy( namespace, obj, storeHandlers ); + if ( proxy && ! isNotRoot ) { 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 index 581e21110f5165..6c5f43ce7865d7 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -35,14 +35,11 @@ const withScopeAndNs = ( scope, ns, callback ) => () => { } }; -const proxifyStateTest = < T extends object >( obj: T ) => - proxifyState( obj, 'test' ) as T; - describe( 'interactivity api - state proxy', () => { let nested = { b: 2 }; let array = [ 3, nested ]; let raw: State = { a: 1, nested, array }; - let state = proxifyStateTest( raw ); + let state = proxifyState( 'test', raw ); const window = globalThis as any; @@ -50,7 +47,7 @@ describe( 'interactivity api - state proxy', () => { nested = { b: 2 }; array = [ 3, nested ]; raw = { a: 1, nested, array }; - state = proxifyStateTest( raw ); + state = proxifyState( 'test', raw ); } ); describe( 'get - plain', () => { @@ -71,7 +68,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support reading from getters', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { counter: 1, get double() { return state.counter * 2; @@ -83,7 +80,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support getters returning other parts of the state', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { switch: 'a', a: { data: 'a' }, b: { data: 'b' }, @@ -97,7 +94,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support getters using ownKeys traps', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { x: { a: 1, b: 2, @@ -111,7 +108,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support getters accessing the scope', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { get y() { const ctx = getContext< { value: string } >(); return ctx.value; @@ -130,7 +127,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should work with normal functions', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { value: 1, isBigger: ( newValue: number ): boolean => state.value < newValue, @@ -149,7 +146,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should work with normal functions accessing the scope', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { sumContextValue( newValue: number ): number { const ctx = getContext< { value: number } >(); return ctx.value + newValue; @@ -168,7 +165,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should allow using `this` inside functions', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { value: 1, sum( newValue: number ): number { return this.value + newValue; @@ -189,7 +186,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support setting values with setters', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { counter: 1, get double() { return state.counter * 2; @@ -218,10 +215,10 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support setting getters on the fly', () => { - const state = proxifyStateTest< { + const state = proxifyState< { counter: number; double?: number; - } >( { + } >( 'test', { counter: 1, } ); Object.defineProperty( state, 'double', { @@ -235,10 +232,10 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should copy object like plain JavaScript', () => { - const state = proxifyStateTest< { + const state = proxifyState< { a?: { id: number; nested: { id: number } }; b: { id: number; nested: { id: number } }; - } >( { + } >( 'test', { b: { id: 1, nested: { id: 1 } }, } ); @@ -290,8 +287,8 @@ describe( 'interactivity api - state proxy', () => { it( 'should keep object references across namespaces', () => { const raw1 = { obj: {} }; const raw2 = { obj: {} }; - const state1 = proxifyState( raw1, 'test-1' ); - const state2 = proxifyState( raw2, 'test-2' ); + 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 ); @@ -300,7 +297,7 @@ describe( 'interactivity api - state proxy', () => { describe( 'computations', () => { it( 'should subscribe to values mutated with setters', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { counter: 1, get double() { return state.counter * 2; @@ -325,7 +322,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should subscribe to changes when an item is removed from the array', () => { - const state = proxifyStateTest( [ 0, 0, 0 ] ); + const state = proxifyState( 'test', [ 0, 0, 0 ] ); let sum = 0; effect( () => { @@ -340,7 +337,7 @@ describe( 'interactivity api - state proxy', () => { it( 'should subscribe to changes to for..in loops', () => { const raw: Record< string, number > = { a: 0, b: 0 }; - const state = proxifyStateTest( raw ); + const state = proxifyState( 'test', raw ); let sum = 0; effect( () => { @@ -364,7 +361,7 @@ describe( 'interactivity api - state proxy', () => { it( 'should subscribe to changes for Object.getOwnPropertyNames()', () => { const raw: Record< string, number > = { a: 1, b: 2 }; - const state = proxifyStateTest( raw ); + const state = proxifyState( 'test', raw ); let sum = 0; effect( () => { @@ -386,7 +383,7 @@ describe( 'interactivity api - state proxy', () => { it( 'should subscribe to changes to Object.keys/values/entries()', () => { const raw: Record< string, number > = { a: 1, b: 2 }; - const state = proxifyStateTest( raw ); + const state = proxifyState( 'test', raw ); let keys = 0; let values = 0; let entries = 0; @@ -422,7 +419,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should subscribe to changes to for..of loops', () => { - const state = proxifyStateTest( [ 0, 0 ] ); + const state = proxifyState( 'test', [ 0, 0 ] ); let sum = 0; effect( () => { @@ -442,7 +439,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should subscribe to implicit changes in length', () => { - const state = proxifyStateTest( [ 'foo', 'bar' ] ); + const state = proxifyState( 'test', [ 'foo', 'bar' ] ); let x = ''; effect( () => { @@ -481,10 +478,10 @@ describe( 'interactivity api - state proxy', () => { it( 'should subscribe to changes when mutating objects', () => { let x, y; - const state = proxifyStateTest< { + 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 } }, @@ -532,7 +529,7 @@ describe( 'interactivity api - state proxy', () => { it( 'should subscribe corretcly from getters', () => { let x; - const state = proxifyStateTest( { + const state = proxifyState( 'test', { counter: 1, get double() { return state.counter * 2; @@ -546,7 +543,7 @@ describe( 'interactivity api - state proxy', () => { it( 'should subscribe corretcly from getters returning other parts of the state', () => { let data; - const state = proxifyStateTest( { + const state = proxifyState( 'test', { switch: 'a', a: { data: 'a' }, b: { data: 'b' }, @@ -658,7 +655,7 @@ describe( 'interactivity api - state proxy', () => { it( 'should subscribe to array length', () => { const array = [ 1 ]; - const state = proxifyStateTest( { array } ); + const state = proxifyState( 'test', { array } ); const spy1 = jest.fn( () => state.array.length ); const spy2 = jest.fn( () => state.array.map( ( i: number ) => i ) ); @@ -708,7 +705,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should keep subscribed to properties that become getters', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { number: 1, } ); @@ -729,7 +726,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should react to changes in props inside getters', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { number: 1, otherNumber: 3, } ); @@ -753,7 +750,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should react to changes in props inside getters if they become getters', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { number: 1, otherNumber: 3, } ); @@ -782,7 +779,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should allow getters to use `this`', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { number: 1, otherNumber: 3, } ); @@ -808,7 +805,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support different scopes for the same getter', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { number: 1, get numWithTag() { let tag = 'No scope'; @@ -854,7 +851,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should throw an error in getters that require an scope', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { number: 1, get sumValueFromContext() { const ctx = getContext(); @@ -875,7 +872,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should react to changes in props inside functions', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { number: 1, otherNumber: 3, sum( value: number ) { @@ -962,7 +959,7 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support returning peek from getters', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { counter: 1, get double() { return state.counter * 2; @@ -974,14 +971,14 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support peeking getters accessing the scope', () => { - const state = proxifyStateTest( { + const state = proxifyState( 'test', { get double() { const { counter } = getContext< { counter: number } >(); return counter * 2; }, } ); - const context = proxifyStateTest( { counter: 1 } ); + const context = proxifyState( 'test', { counter: 1 } ); const scope = { context: { test: context } }; const peekStateDouble = withScopeAndNs( scope, 'test', () => peek( state, 'double' ) @@ -999,25 +996,19 @@ describe( 'interactivity api - state proxy', () => { } ); it( 'should support peeking getters accessing other namespaces', () => { - const state2 = proxifyState( - { - get counter() { - const { counter } = getContext< { counter: number } >(); - return counter; - }, + const state2 = proxifyState( 'test2', { + get counter() { + const { counter } = getContext< { counter: number } >(); + return counter; }, - 'test2' - ); - const context2 = proxifyState( { counter: 1 }, 'test2' ); + } ); + const context2 = proxifyState( 'test-2', { counter: 1 } ); - const state1 = proxifyState( - { - get double() { - return state2.counter * 2; - }, + const state1 = proxifyState( 'test1', { + get double() { + return state2.counter * 2; }, - 'test1' - ); + } ); const peekStateDouble = withScopeAndNs( { context: { test2: context2 } }, @@ -1060,14 +1051,14 @@ describe( 'interactivity api - state proxy', () => { it( 'should return the same proxy if initialized more than once', () => { const raw = {}; - const state1 = proxifyStateTest( raw ); - const state2 = proxifyStateTest( raw ); + const state1 = proxifyState( 'test', raw ); + const state2 = proxifyState( 'test', raw ); expect( state1 ).toBe( state2 ); } ); it( 'should return the same proxy when trying to re-proxify a state object', () => { - const state = proxifyStateTest( {} ); - expect( () => proxifyStateTest( state ) ).toThrow(); + const state = proxifyState( 'test', {} ); + expect( () => proxifyState( 'test', state ) ).toThrow(); } ); } ); @@ -1075,57 +1066,57 @@ describe( 'interactivity api - state proxy', () => { it( 'should throw when trying to proxify a class instance', () => { class MyClass {} const obj = new MyClass(); - expect( () => proxifyStateTest( obj ) ).toThrow(); + expect( () => proxifyState( 'test', obj ) ).toThrow(); } ); it( 'should not wrap a class instance', () => { class MyClass {} const obj = new MyClass(); - const state = proxifyStateTest( { obj } ); + 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 = proxifyStateTest( { obj } ); + 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 = proxifyStateTest( { el } ); + const state = proxifyState( 'test', { el } ); expect( state.el ).toBe( el ); } ); it( 'should wrap global objects', () => { window.obj = { b: 2 }; - const state = proxifyStateTest( window.obj ); + 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 = proxifyStateTest( { date } ); + const state = proxifyState( 'test', { date } ); expect( state.date ).toBe( date ); } ); it( 'should not wrap regular expressions', () => { const regex = new RegExp( '' ); - const state = proxifyStateTest( { regex } ); + const state = proxifyState( 'test', { regex } ); expect( state.regex ).toBe( regex ); } ); it( 'should not wrap Map', () => { const map = new Map(); - const state = proxifyStateTest( { map } ); + const state = proxifyState( 'test', { map } ); expect( state.map ).toBe( map ); } ); it( 'should not wrap Set', () => { const set = new Set(); - const state = proxifyStateTest( { set } ); + const state = proxifyState( 'test', { set } ); expect( state.set ).toBe( set ); } ); } ); @@ -1134,7 +1125,10 @@ describe( 'interactivity api - state proxy', () => { it( 'should observe symbols', () => { const key = Symbol( 'key' ); let x; - const store = proxifyStateTest< { [ key: symbol ]: any } >( {} ); + const store = proxifyState< { [ key: symbol ]: any } >( + 'test', + {} + ); effect( () => ( x = store[ key ] ) ); expect( store[ key ] ).toBe( undefined ); @@ -1149,7 +1143,10 @@ describe( 'interactivity api - state proxy', () => { it( 'should not observe well-known symbols', () => { const key = Symbol.isConcatSpreadable; let x; - const state = proxifyStateTest< { [ key: symbol ]: any } >( {} ); + const state = proxifyState< { [ key: symbol ]: any } >( + 'test', + {} + ); effect( () => ( x = state[ key ] ) ); expect( state[ key ] ).toBe( undefined ); diff --git a/packages/interactivity/src/proxies/test/store-proxy.ts b/packages/interactivity/src/proxies/test/store-proxy.ts index face27f6f25d8c..d6922d0c4d3c98 100644 --- a/packages/interactivity/src/proxies/test/store-proxy.ts +++ b/packages/interactivity/src/proxies/test/store-proxy.ts @@ -13,7 +13,7 @@ import { describe( 'interactivity api - store proxy', () => { describe( 'get', () => { it( 'should initialize properties at the top level if they do not exist', () => { - const store = proxifyStore< any >( {}, 'test', true ); + const store = proxifyStore< any >( 'test', {} ); expect( store.state.props ).toBeUndefined(); expect( store.state ).toEqual( {} ); } ); @@ -26,16 +26,12 @@ describe( 'interactivity api - store proxy', () => { result = ctx.value; }; - const storeTest = proxifyStore( - { - callbacks: { - syncFunc, - nested: { syncFunc }, - }, + const storeTest = proxifyStore( 'test', { + callbacks: { + syncFunc, + nested: { syncFunc }, }, - 'test', - true - ); + } ); const scope = { context: { @@ -62,11 +58,9 @@ describe( 'interactivity api - store proxy', () => { return `${ data } from ${ ctx.value }`; }; - const storeTest = proxifyStore( - { callbacks: { asyncFunc, nested: { asyncFunc } } }, - 'test', - true - ); + const storeTest = proxifyStore( 'test', { + callbacks: { asyncFunc, nested: { asyncFunc } }, + } ); const scope = { context: { @@ -92,25 +86,18 @@ describe( 'interactivity api - store proxy', () => { return `${ data } from ${ ctx.value }`; }; - const storeTest1 = proxifyStore( - { callbacks: { asyncFunc } }, - 'test1', - true - ); - - const storeTest2 = proxifyStore( - { - callbacks: { - *asyncFunc() { - const result = - yield storeTest1.callbacks.asyncFunc(); - return result; - }, + const storeTest1 = proxifyStore( 'test1', { + callbacks: { asyncFunc }, + } ); + + const storeTest2 = proxifyStore( 'test2', { + callbacks: { + *asyncFunc() { + const result = yield storeTest1.callbacks.asyncFunc(); + return result; }, }, - 'test2', - true - ); + } ); const scope = { context: { @@ -129,8 +116,8 @@ describe( 'interactivity api - store proxy', () => { } ); it( 'should not wrap other proxified objects with a store proxy', () => { - const state = proxifyState( {}, 'test' ); - const store = proxifyStore( { state }, 'test' ); + const state = proxifyState( 'test', {} ); + const store = proxifyStore( 'test', { state } ); expect( store.state ).toBe( state ); } ); diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 0ffb9bb8fd66e4..91b0f9535f4ec3 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -148,10 +148,10 @@ export function store( storeLocks.set( namespace, lock ); } const rawStore = { - state: proxifyState( isObject( state ) ? state : {}, namespace ), + state: proxifyState( namespace, isObject( state ) ? state : {} ), ...block, }; - const proxiedStore = proxifyStore( rawStore, namespace, true ); + const proxiedStore = proxifyStore( namespace, rawStore ); rawStores.set( namespace, rawStore ); stores.set( namespace, proxiedStore ); } else { From e294bba34b75502b4a8b7ad5d7c4ce9bb1abed51 Mon Sep 17 00:00:00 2001 From: Michal Czaplinski Date: Tue, 9 Jul 2024 16:54:46 +0100 Subject: [PATCH 51/91] chore: Update jest.config.js to stop ignoring deepsignal because we removed it as dependency --- test/unit/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/jest.config.js b/test/unit/jest.config.js index 4a19d5bb37f316..4a3bb647a7879f 100644 --- a/test/unit/jest.config.js +++ b/test/unit/jest.config.js @@ -40,7 +40,7 @@ module.exports = { '^.+\\.[jt]sx?$': '/test/unit/scripts/babel-transformer.js', }, transformIgnorePatterns: [ - '/node_modules/(?!(docker-compose|yaml|preact|@preact|deepsignal)/)', + '/node_modules/(?!(docker-compose|yaml|preact|@preact)/)', '\\.pnp\\.[^\\/]+$', ], snapshotSerializers: [ From c0c633e7760af3b6e3d53f0c770af72f80c82a65 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 16 Jul 2024 12:46:39 +0200 Subject: [PATCH 52/91] Add comments to proxy registry --- .../interactivity/src/proxies/registry.ts | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/interactivity/src/proxies/registry.ts b/packages/interactivity/src/proxies/registry.ts index fc48f2a99c4f39..a9963eae4d3156 100644 --- a/packages/interactivity/src/proxies/registry.ts +++ b/packages/interactivity/src/proxies/registry.ts @@ -1,34 +1,79 @@ +/** + * Proxies for each object. + */ const objToProxy = new WeakMap< object, object >(); + +/** + * Namespaces for each created proxy. + */ const proxyToNs = new WeakMap< object, string >(); -const ignore = new WeakSet< object >(); +/** + * 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 can be proxified.' ); + throw Error( 'This object cannot be proxified.' ); } - if ( ! objToProxy.has( obj ) && handlers && namespace ) { + if ( ! objToProxy.has( obj ) ) { const proxy = new Proxy( obj, handlers ); - ignore.add( proxy ); 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 getProxy = < T extends object >( obj: T ): T => objToProxy.get( obj ) as T; +/** + * Gets the namespace associated with the given proxy. + * + * @param proxy Proxy. + * @return Namespace. + */ export const getProxyNs = ( proxy: object ): string => proxyToNs.get( proxy )!; -export const shouldProxy = ( val: any ): val is Object | Array< unknown > => { - if ( typeof val !== 'object' || val === null ) { +/** + * 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 ! ignore.has( val ) && supported.has( val.constructor ); + return ( + ! proxyToNs.has( candidate ) && supported.has( candidate.constructor ) + ); }; From 1e1aa6a398f6751c9177a750132109acce47334b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 16 Jul 2024 16:28:10 +0200 Subject: [PATCH 53/91] Remove namespace from PropSignal --- packages/interactivity/src/proxies/signals.ts | 4 +--- packages/interactivity/src/proxies/state.ts | 19 +++++++++---------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index a1d405270c209a..25428419f7bc06 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -19,7 +19,6 @@ import { withScope } from '../utils'; const NO_SCOPE = Symbol(); export class PropSignal { - public readonly namespace: string; private owner: object; private computedsByScope: WeakMap< WeakKey, ReadonlySignal >; private valueSignal?: Signal; @@ -27,7 +26,6 @@ export class PropSignal { constructor( owner: object ) { this.owner = owner; - this.namespace = getProxyNs( owner )!; this.computedsByScope = new WeakMap(); } @@ -54,7 +52,7 @@ export class PropSignal { : this.valueSignal?.value; }; - setNamespace( this.namespace ); + setNamespace( getProxyNs( this.owner ) ); this.computedsByScope.set( scope, computed( withScope( callback ) ) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 3349ac512e45a7..f558a621d71cc3 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -6,7 +6,7 @@ import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { createProxy, getProxy, shouldProxy } from './registry'; +import { createProxy, getProxy, getProxyNs, shouldProxy } from './registry'; import { PropSignal } from './signals'; import { setNamespace, resetNamespace } from '../hooks'; @@ -64,6 +64,8 @@ const stateHandlers: ProxyHandler< object > = { */ const prop = getPropSignal( receiver, key ); + const ns = getProxyNs( receiver ); + /* * When the value is a getter, it updates the internal getter value. If * not, we get the actual value an wrap it with a proxy if needed. @@ -77,9 +79,7 @@ const stateHandlers: ProxyHandler< object > = { } else { const value = Reflect.get( target, key, receiver ); prop.setValue( - shouldProxy( value ) - ? proxifyState( prop.namespace, value ) - : value + shouldProxy( value ) ? proxifyState( ns, value ) : value ); } @@ -96,7 +96,7 @@ const stateHandlers: ProxyHandler< object > = { */ if ( typeof result === 'function' ) { return ( ...args: unknown[] ) => { - setNamespace( prop.namespace ); + setNamespace( ns ); try { return result.call( receiver, ...args ); } finally { @@ -117,15 +117,15 @@ const stateHandlers: ProxyHandler< object > = { const result = Reflect.defineProperty( target, key, desc ); if ( result ) { - const prop = getPropSignal( getProxy( target ), key ); + const receiver = getProxy( target ); + const prop = getPropSignal( receiver, key ); const { get, value } = desc; if ( get ) { prop.setGetter( desc.get! ); } else { + const ns = getProxyNs( receiver ); prop.setValue( - shouldProxy( value ) - ? proxifyState( prop.namespace, value ) - : value + shouldProxy( value ) ? proxifyState( ns, value ) : value ); } @@ -134,7 +134,6 @@ const stateHandlers: ProxyHandler< object > = { } if ( Array.isArray( target ) ) { - const receiver = getProxy( target ); const length = getPropSignal( receiver, 'length' ); length.setValue( target.length ); } From 56487547dcbd39d402f09415ee6c608a67d2afb2 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 16 Jul 2024 21:06:57 +0200 Subject: [PATCH 54/91] Remove unused `peekValueSignal` method --- packages/interactivity/src/proxies/signals.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index 25428419f7bc06..696b19eefc9ce4 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -63,10 +63,6 @@ export class PropSignal { return this.computedsByScope.get( scope )!; } - public peekValueSignal(): unknown { - return this.valueSignal?.peek(); - } - private update( { get, value, From d2f849f5b22e8b90178e3f05c786cbee4d595852 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 17 Jul 2024 10:08:08 +0200 Subject: [PATCH 55/91] Simplify PropSignal methods --- packages/interactivity/src/proxies/signals.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index 696b19eefc9ce4..2eb0cbf3803b7c 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -29,12 +29,12 @@ export class PropSignal { this.computedsByScope = new WeakMap(); } - public setValue( value: unknown ): PropSignal { - return this.update( { value } ); + public setValue( value: unknown ) { + this.update( { value } ); } - public setGetter( getter: () => any ): PropSignal { - return this.update( { get: getter } ); + public setGetter( getter: () => any ) { + this.update( { get: getter } ); } public getComputed(): ReadonlySignal { @@ -63,13 +63,7 @@ export class PropSignal { return this.computedsByScope.get( scope )!; } - private update( { - get, - value, - }: { - get?: () => any; - value?: unknown; - } ): PropSignal { + private update( { get, value }: { get?: () => any; value?: unknown } ) { if ( ! this.valueSignal ) { this.valueSignal = signal( value ); this.getterSignal = signal( get ); From 573ce9d8e1607d96a4f04c962e0487e76e937fec Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 17 Jul 2024 10:32:25 +0200 Subject: [PATCH 56/91] Add TSDocs to PropSignal --- packages/interactivity/src/proxies/signals.ts | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index 2eb0cbf3803b7c..d765da917031f4 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -16,27 +16,81 @@ import { getProxyNs } from './registry'; import { getScope, setNamespace, resetNamespace } from '../hooks'; 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; @@ -63,6 +117,14 @@ export class PropSignal { 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 ); @@ -76,6 +138,5 @@ export class PropSignal { this.getterSignal!.value = get; } ); } - return this; } } From 0950fa8403429a9de84bf17afcc3f81eb49a6f54 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 17 Jul 2024 10:40:00 +0200 Subject: [PATCH 57/91] Disable unused vars lint rule in state-proxy tests --- packages/interactivity/src/proxies/test/state-proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 6c5f43ce7865d7..da8c35f31836b4 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -1,5 +1,6 @@ /* eslint-disable eslint-comments/disable-enable-pair */ /* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-unused-vars */ /** * External dependencies From 236e389092f730529db50053e13f7c559d3fc000 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 17 Jul 2024 17:38:55 +0200 Subject: [PATCH 58/91] Remove descriptor alias --- packages/interactivity/src/proxies/state.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index f558a621d71cc3..b9c34a78dff6a3 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -38,13 +38,11 @@ const wellKnownSymbols = new Set( .filter( ( value ) => typeof value === 'symbol' ) ); -const descriptor = Object.getOwnPropertyDescriptor; - let peeking = false; const stateHandlers: ProxyHandler< object > = { get( target: object, key: string, receiver: object ): any { - const desc = descriptor( target, key ); + const desc = Object.getOwnPropertyDescriptor( target, key ); /* * If peeking, the property comes from the Object prototype, or the key From 8bcb5764bcd688019cefea6c91c536b08ae63db1 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 19 Jul 2024 10:40:58 +0200 Subject: [PATCH 59/91] Expand `getProxyNs` docs --- packages/interactivity/src/proxies/registry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/interactivity/src/proxies/registry.ts b/packages/interactivity/src/proxies/registry.ts index a9963eae4d3156..63a438e895870a 100644 --- a/packages/interactivity/src/proxies/registry.ts +++ b/packages/interactivity/src/proxies/registry.ts @@ -56,6 +56,8 @@ export const getProxy = < T extends object >( obj: T ): T => /** * Gets the namespace associated with the given proxy. * + * Proxies have a namespace assigned upon creation. See {@link createProxy}. + * * @param proxy Proxy. * @return Namespace. */ From 1b7d2dff6c3dafdc6170b9f11e5ce14e56956208 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 19 Jul 2024 11:23:50 +0200 Subject: [PATCH 60/91] Add more comments in `state` --- packages/interactivity/src/proxies/state.ts | 27 ++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index b9c34a78dff6a3..b57a43e8b58f7c 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -25,19 +25,34 @@ type WellKnownSymbols = | 'toStringTag' | 'unscopables'; +/** + * Set of built-in symbols. + */ +const wellKnownSymbols = new Set( + Object.getOwnPropertyNames( Symbol ) + .map( ( key ) => Symbol[ key as WellKnownSymbols ] ) + .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(); +/** + * 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 > >(); -const wellKnownSymbols = new Set( - Object.getOwnPropertyNames( Symbol ) - .map( ( key ) => Symbol[ key as WellKnownSymbols ] ) - .filter( ( value ) => typeof value === 'symbol' ) -); - +/** + * When this flag is `true`, it avoids any signal subscription, overriding state + * props' "reactive" behavior. + */ let peeking = false; const stateHandlers: ProxyHandler< object > = { From 4554fb43f5f9fbd5e84b5803ce62196f5c3273c1 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 23 Jul 2024 11:51:18 +0200 Subject: [PATCH 61/91] Refactor state functions and add tsdocs --- packages/interactivity/src/proxies/state.ts | 134 +++++++++++--------- 1 file changed, 73 insertions(+), 61 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index b57a43e8b58f7c..c7a764c87b0836 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -10,27 +10,12 @@ import { createProxy, getProxy, getProxyNs, shouldProxy } from './registry'; import { PropSignal } from './signals'; import { setNamespace, resetNamespace } from '../hooks'; -type WellKnownSymbols = - | 'asyncIterator' - | 'hasInstance' - | 'isConcatSpreadable' - | 'iterator' - | 'match' - | 'matchAll' - | 'replace' - | 'search' - | 'species' - | 'split' - | 'toPrimitive' - | 'toStringTag' - | 'unscopables'; - /** * Set of built-in symbols. */ const wellKnownSymbols = new Set( Object.getOwnPropertyNames( Symbol ) - .map( ( key ) => Symbol[ key as WellKnownSymbols ] ) + .map( ( key ) => Symbol[ key ] ) .filter( ( value ) => typeof value === 'symbol' ) ); @@ -43,6 +28,46 @@ const proxyToProps: WeakMap< 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 = getProxyNs( 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. @@ -55,46 +80,28 @@ const objToIterable = new WeakMap< object, Signal< number > >(); */ let peeking = false; +/** + * Handlers for reactive objects and arrays in the state. + */ const stateHandlers: ProxyHandler< object > = { - get( target: object, key: string, receiver: object ): any { - const desc = Object.getOwnPropertyDescriptor( target, key ); - + get( target: object, key: string | symbol, receiver: object ): any { /* - * If peeking, the property comes from the Object prototype, or the key - * is a well-known symbol, then it should not be processed. + * 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 || - ( ! desc && key in Object.prototype ) || + ( ! target.hasOwnProperty( key ) && key in target ) || ( typeof key === 'symbol' && wellKnownSymbols.has( key ) ) ) { return Reflect.get( target, key, receiver ); } - /* - * First, we get a reference of the property we want to access. The - * property object is automatically instanciated if needed. - */ - const prop = getPropSignal( receiver, key ); - - const ns = getProxyNs( receiver ); - - /* - * When the value is a getter, it updates the internal getter value. If - * not, we get the actual value an wrap it with a proxy if needed. - * - * These updates only triggers a re-render when either the getter or the - * value has changed. - */ - const getter = desc?.get; - if ( getter ) { - prop.setGetter( getter ); - } else { - const value = Reflect.get( target, key, receiver ); - prop.setValue( - shouldProxy( value ) ? proxifyState( ns, value ) : value - ); - } + // At this point, the property should be reactive. + const desc = Object.getOwnPropertyDescriptor( target, key ); + const prop = getPropSignal( receiver, key, desc ); if ( peeking ) { return prop.getComputed().peek(); @@ -108,6 +115,7 @@ const stateHandlers: ProxyHandler< object > = { * which is set by the Directives component. */ if ( typeof result === 'function' ) { + const ns = getProxyNs( receiver ); return ( ...args: unknown[] ) => { setNamespace( ns ); try { @@ -179,11 +187,30 @@ const stateHandlers: ProxyHandler< object > = { }, }; +/** + * Returns the proxy associated to the given state object, creating it if 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 @@ -195,18 +222,3 @@ export const peek = < T extends object, K extends keyof T >( peeking = false; } }; - -export const getPropSignal = ( - proxy: object, - key: string | number | symbol -) => { - if ( ! proxyToProps.has( proxy ) ) { - proxyToProps.set( proxy, new Map() ); - } - key = typeof key === 'number' ? `${ key }` : key; - const props = proxyToProps.get( proxy )!; - if ( ! props.has( key ) ) { - props.set( key, new PropSignal( proxy ) ); - } - return props.get( key )!; -}; From 5cce756d0422b68f2c6135b13bbfb33c751e81bb Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 23 Jul 2024 11:53:42 +0200 Subject: [PATCH 62/91] Fix some grammar issues --- packages/interactivity/src/proxies/state.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index c7a764c87b0836..38fcdc439c0555 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -188,8 +188,8 @@ const stateHandlers: ProxyHandler< object > = { }; /** - * Returns the proxy associated to the given state object, creating it if does - * not exist. + * 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. From 86a5b5a14ece1cd403c19ee05865d69456f2d567 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 23 Jul 2024 19:51:39 +0200 Subject: [PATCH 63/91] Fix default namespace for setters --- packages/interactivity/src/proxies/state.ts | 14 ++++++++++ .../src/proxies/test/state-proxy.ts | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 38fcdc439c0555..8253c769ba0f40 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -129,6 +129,20 @@ const stateHandlers: ProxyHandler< object > = { return result; }, + set( + target: object, + key: string, + value: unknown, + receiver: object + ): boolean { + setNamespace( getProxyNs( receiver ) ); + try { + return Reflect.set( target, key, value, receiver ); + } finally { + resetNamespace(); + } + }, + defineProperty( target: object, key: string, diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index da8c35f31836b4..5ec8f7db440e5f 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -294,6 +294,32 @@ describe( 'interactivity api - state proxy', () => { 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', () => { From cebfd6ea35e52a503eda231a4f5ec45bc3dbb6b5 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 24 Jul 2024 10:05:26 +0200 Subject: [PATCH 64/91] Replace `isNotRoot` with `isRoot` --- packages/interactivity/src/proxies/store.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index f5f795960731a5..6efa2c5e222e5f 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -20,7 +20,7 @@ const storeHandlers: ProxyHandler< object > = { if ( typeof result === 'undefined' && storeRoots.has( receiver ) ) { const obj = {}; Reflect.set( target, key, obj ); - return proxifyStore( ns, obj, true ); + return proxifyStore( ns, obj, false ); } // Check if the property is a function. If it is, add the store @@ -36,7 +36,7 @@ const storeHandlers: ProxyHandler< object > = { // Check if the property is an object. If it is, proxyify it. if ( isObject( result ) && shouldProxy( result ) ) { - return proxifyStore( ns, result, true ); + return proxifyStore( ns, result, false ); } return result; @@ -46,10 +46,10 @@ const storeHandlers: ProxyHandler< object > = { export const proxifyStore = < T extends object >( namespace: string, obj: T, - isNotRoot = false + isRoot = true ): T => { const proxy = createProxy( namespace, obj, storeHandlers ); - if ( proxy && ! isNotRoot ) { + if ( proxy && isRoot ) { storeRoots.add( proxy ); } return proxy as T; From 0e2b095bb0f63e19e5aa98b1b820e759a4cf6ea7 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 24 Jul 2024 10:16:58 +0200 Subject: [PATCH 65/91] Update comments in store.ts --- packages/interactivity/src/proxies/store.ts | 52 +++++++++++++++++---- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index 6efa2c5e222e5f..29af482ee60a09 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -5,28 +5,51 @@ import { createProxy, getProxyNs, shouldProxy } from './registry'; import { setNamespace, resetNamespace } from '../hooks'; import { withScope } from '../utils'; -const isObject = ( item: unknown ): item is Record< string, unknown > => - Boolean( item && typeof item === 'object' && item.constructor === Object ); +/** + * Checks if the passed `candidate` is an object with just the `Object` + * prototype. + * + * @param candidate The item to check. + * @return Whether `candidate` is an object. + */ +const isObject = ( + candidate: unknown +): candidate is Record< string, unknown > => + Boolean( + candidate && + typeof candidate === 'object' && + candidate.constructor === Object + ); +/** + * 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 = getProxyNs( 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. + /* + * 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. + /* + * 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 ); @@ -43,6 +66,19 @@ const storeHandlers: ProxyHandler< object > = { }, }; +/** + * 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, From a12732ca4c262403ba265a3b95e9d171b9d193d5 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 24 Jul 2024 11:16:55 +0200 Subject: [PATCH 66/91] Move `isPlainObject` to utils --- packages/interactivity/src/directives.tsx | 15 +++++++++------ packages/interactivity/src/proxies/store.ts | 20 ++------------------ packages/interactivity/src/utils.ts | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 419fb42e7d7c7a..9470158138232b 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -14,9 +14,15 @@ 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, getScope, getEvaluate, type DirectiveEntry } from './hooks'; // Assigned objects should be ignored during proxification. const contextAssignedObjects = new WeakMap(); @@ -26,9 +32,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; /** diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index 29af482ee60a09..b889b94e6c40a0 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -3,23 +3,7 @@ */ import { createProxy, getProxyNs, shouldProxy } from './registry'; import { setNamespace, resetNamespace } from '../hooks'; -import { withScope } from '../utils'; - -/** - * Checks if the passed `candidate` is an object with just the `Object` - * prototype. - * - * @param candidate The item to check. - * @return Whether `candidate` is an object. - */ -const isObject = ( - candidate: unknown -): candidate is Record< string, unknown > => - Boolean( - candidate && - typeof candidate === 'object' && - candidate.constructor === Object - ); +import { withScope, isPlainObject } from '../utils'; /** * Identifies the store proxies handling the root objects of each store. @@ -58,7 +42,7 @@ const storeHandlers: ProxyHandler< object > = { } // Check if the property is an object. If it is, proxyify it. - if ( isObject( result ) && shouldProxy( result ) ) { + if ( isPlainObject( result ) && shouldProxy( result ) ) { return proxifyStore( ns, result, false ); } diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index a78aa798a0958a..2df2cf9edd9693 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -354,3 +354,19 @@ 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 + ); From 0667028eb558a2dc26647a5016805abb9e6a4512 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 24 Jul 2024 11:20:32 +0200 Subject: [PATCH 67/91] Remove remaining deepSignal references --- packages/interactivity/src/directives.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 9470158138232b..8cfd1fb5dec9cb 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -131,20 +131,20 @@ const proxifyContext = ( current: object, inherited: object = {} ): object => { }; /** - * Recursively update values within a deepSignal object. + * Recursively update values within a context object. * - * @param proxy A deepSignal instance. - * @param source Object with properties to update in `proxy`. + * @param target A context instance. + * @param source Object with properties to update in `target`. */ -const updateSignals = ( proxy: any, source: any ) => { +const updateContext = ( target: any, source: any ) => { for ( const k in source ) { if ( - isPlainObject( peek( proxy, k ) ) && + isPlainObject( peek( target, k ) ) && isPlainObject( source[ k ] ) ) { - updateSignals( peek( proxy, k ) as object, source[ k ] ); + updateContext( peek( target, k ) as object, source[ k ] ); } else { - proxy[ k ] = source[ k ]; + target[ k ] = source[ k ]; } } }; @@ -287,7 +287,7 @@ export default () => { `The value of data-wp-context in "${ namespace }" store must be a valid stringified JSON object.` ); } - updateSignals( + updateContext( currentValue.current[ namespace ], deepClone( value ) as object ); From 9a52b65ece61a0fe0317c1ad46b4f3160c4ad1c2 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 26 Jul 2024 13:35:53 +0200 Subject: [PATCH 68/91] Delete unnecessary @ts-ignore-next-line --- packages/interactivity/src/directives.tsx | 1 - packages/interactivity/src/hooks.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 8cfd1fb5dec9cb..37021b04d62deb 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -260,7 +260,6 @@ export default () => { // data-wp-context directive( 'context', - // @ts-ignore-next-line ( { directives: { context }, props: { children }, diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index b2da02d6a15bac..b9648407d4d8b1 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -56,7 +56,7 @@ interface DirectiveArgs { } interface DirectiveCallback { - ( args: DirectiveArgs ): VNode | null | void; + ( args: DirectiveArgs ): VNode< any > | null | void; } interface DirectiveOptions { From b21a3ce5c170995c9ce59964564dfacaf4492c18 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 29 Jul 2024 11:11:40 +0200 Subject: [PATCH 69/91] Use `isPlainObject` from utils inside store --- packages/interactivity/src/store.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 91b0f9535f4ec3..80082764c65290 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -3,12 +3,10 @@ */ import { proxifyState, proxifyStore } from './proxies'; import { getNamespace } from './hooks'; - -const isObject = ( item: unknown ): item is Record< string, unknown > => - Boolean( item && typeof item === 'object' && item.constructor === Object ); +import { isPlainObject } from './utils'; const deepMerge = ( target: any, source: any ) => { - if ( isObject( target ) && isObject( source ) ) { + if ( isPlainObject( target ) && isPlainObject( source ) ) { for ( const key in source ) { const getter = Object.getOwnPropertyDescriptor( source, key )?.get; if ( typeof getter === 'function' ) { @@ -16,7 +14,7 @@ const deepMerge = ( target: any, source: any ) => { get: getter, configurable: true, } ); - } else if ( isObject( source[ key ] ) ) { + } else if ( isPlainObject( source[ key ] ) ) { if ( ! target[ key ] ) { target[ key ] = {}; } @@ -148,7 +146,10 @@ export function store( storeLocks.set( namespace, lock ); } const rawStore = { - state: proxifyState( namespace, isObject( state ) ? state : {} ), + state: proxifyState( + namespace, + isPlainObject( state ) ? state : {} + ), ...block, }; const proxiedStore = proxifyStore( namespace, rawStore ); @@ -205,12 +206,12 @@ export const populateInitialData = ( 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 } ); } ); } - if ( isObject( data?.config ) ) { + if ( isPlainObject( data?.config ) ) { Object.entries( data!.config ).forEach( ( [ namespace, config ] ) => { storeConfigs.set( namespace, config ); } ); From e8c5458bbbf65c8b68bc16c668b264315d127462 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 29 Jul 2024 14:14:03 +0200 Subject: [PATCH 70/91] Move scopes and namespaces to separate files --- packages/interactivity/src/directives.tsx | 3 +- packages/interactivity/src/hooks.tsx | 98 +------------------ packages/interactivity/src/index.ts | 5 +- packages/interactivity/src/init.ts | 5 - packages/interactivity/src/namespaces.ts | 10 ++ packages/interactivity/src/proxies/signals.ts | 3 +- packages/interactivity/src/proxies/state.ts | 2 +- packages/interactivity/src/proxies/store.ts | 5 +- .../src/proxies/test/state-proxy.ts | 10 +- .../src/proxies/test/store-proxy.ts | 9 +- packages/interactivity/src/scopes.ts | 93 ++++++++++++++++++ packages/interactivity/src/store.ts | 9 +- packages/interactivity/src/utils.ts | 10 +- 13 files changed, 132 insertions(+), 130 deletions(-) create mode 100644 packages/interactivity/src/namespaces.ts create mode 100644 packages/interactivity/src/scopes.ts diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 37021b04d62deb..b128b6afbab9ed 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -22,7 +22,8 @@ import { splitTask, isPlainObject, } from './utils'; -import { directive, getScope, getEvaluate, type DirectiveEntry } from './hooks'; +import { directive, getEvaluate, type DirectiveEntry } from './hooks'; +import { getScope } from './scopes'; // Assigned objects should be ignored during proxification. const contextAssignedObjects = new WeakMap(); diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index b9648407d4d8b1..215da8afef9b5b 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; @@ -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,92 +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 => { - const scope = getScope(); - if ( ! scope ) { - throw Error( - 'Cannot call `getContext()` outside getters and actions used by directives.' - ); - } - 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 = () => { - 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 ef98c0abbd0cfa..6f0ee729350d0b 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -11,12 +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 { directive } from './hooks'; +import { getNamespace } from './namespaces'; import { parseInitialData, populateInitialData } from './store'; import { proxifyState } from './proxies'; export { store, getConfig } from './store'; -export { getContext, getElement } from './hooks'; +export { getContext, getElement } from './scopes'; export { withScope, useWatch, diff --git a/packages/interactivity/src/init.ts b/packages/interactivity/src/init.ts index 6e04de897cc7ce..ddf6785d4dfdf4 100644 --- a/packages/interactivity/src/init.ts +++ b/packages/interactivity/src/init.ts @@ -8,7 +8,6 @@ import { hydrate, type ContainerNode, type ComponentChild } from 'preact'; import { toVdom, hydratedIslands } from './vdom'; import { createRootFragment, splitTask } from './utils'; import { directivePrefix } from './constants'; -import { parseInitialData, populateInitialData } from './store'; // Keep the same root fragment for each interactive region node. const regionRootFragments = new WeakMap(); @@ -30,10 +29,6 @@ export const initialVdom = new WeakMap< Element, ComponentChild[] >(); // Initialize the router with the initial DOM. export const init = async () => { - // Parse and populate the initial state and config. - const data = parseInitialData(); - populateInitialData( data ); - const nodes = document.querySelectorAll( `[data-${ directivePrefix }-interactive]` ); diff --git a/packages/interactivity/src/namespaces.ts b/packages/interactivity/src/namespaces.ts new file mode 100644 index 00000000000000..9103f3c76e67bb --- /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/signals.ts b/packages/interactivity/src/proxies/signals.ts index d765da917031f4..01a4e907566765 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -13,7 +13,8 @@ import { * Internal dependencies */ import { getProxyNs } from './registry'; -import { getScope, setNamespace, resetNamespace } from '../hooks'; +import { getScope } from '../scopes'; +import { setNamespace, resetNamespace } from '../namespaces'; import { withScope } from '../utils'; /** diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 8253c769ba0f40..123c81f48b3ae5 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -8,7 +8,7 @@ import { signal, type Signal } from '@preact/signals'; */ import { createProxy, getProxy, getProxyNs, shouldProxy } from './registry'; import { PropSignal } from './signals'; -import { setNamespace, resetNamespace } from '../hooks'; +import { setNamespace, resetNamespace } from '../namespaces'; /** * Set of built-in symbols. diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index b889b94e6c40a0..2d1e83696f6748 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -2,7 +2,10 @@ * Internal dependencies */ import { createProxy, getProxyNs, shouldProxy } from './registry'; -import { setNamespace, resetNamespace } from '../hooks'; +/** + * External dependencies + */ +import { setNamespace, resetNamespace } from '../namespaces'; import { withScope, isPlainObject } from '../utils'; /** diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 5ec8f7db440e5f..4c05a042533a3f 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -10,14 +10,8 @@ import { effect } from '@preact/signals-core'; * Internal dependencies */ import { proxifyState, peek } from '../'; -import { - setScope, - resetScope, - setNamespace, - resetNamespace, - getContext, - getElement, -} from '../../hooks'; +import { setScope, resetScope, getContext, getElement } from '../../scopes'; +import { setNamespace, resetNamespace } from '../../namespaces'; type State = { a?: number; diff --git a/packages/interactivity/src/proxies/test/store-proxy.ts b/packages/interactivity/src/proxies/test/store-proxy.ts index d6922d0c4d3c98..2f8f1465e4d12c 100644 --- a/packages/interactivity/src/proxies/test/store-proxy.ts +++ b/packages/interactivity/src/proxies/test/store-proxy.ts @@ -2,13 +2,8 @@ * Internal dependencies */ import { proxifyStore, proxifyState } from '../'; -import { - setScope, - resetScope, - setNamespace, - resetNamespace, - getContext, -} from '../../hooks'; +import { setScope, resetScope, getContext } from '../../scopes'; +import { setNamespace, resetNamespace } from '../../namespaces'; describe( 'interactivity api - store proxy', () => { describe( 'get', () => { diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts new file mode 100644 index 00000000000000..12602b0c730f3e --- /dev/null +++ b/packages/interactivity/src/scopes.ts @@ -0,0 +1,93 @@ +/** + * 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 ( ! scope ) { + throw Error( + 'Cannot call `getContext()` outside getters and actions used by directives.' + ); + } + 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 = () => { + 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 ), + } ); +}; diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 80082764c65290..7927ff61179be4 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -2,7 +2,10 @@ * Internal dependencies */ import { proxifyState, proxifyStore } from './proxies'; -import { getNamespace } from './hooks'; +/** + * External dependencies + */ +import { getNamespace } from './namespaces'; import { isPlainObject } from './utils'; const deepMerge = ( target: any, source: any ) => { @@ -217,3 +220,7 @@ export const populateInitialData = ( data?: { } ); } }; + +// Parse and populate the initial state and config. +const data = parseInitialData(); +populateInitialData( data ); diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index 2df2cf9edd9693..c5eb91681294f2 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; From 6d1d88741bd2c69f48a64253601c5f95ec62ae24 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 29 Jul 2024 16:38:34 +0200 Subject: [PATCH 71/91] Call `init` outside `DOMContentLoaded` --- packages/interactivity/src/index.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 6f0ee729350d0b..ee9a7b1c21a988 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -56,7 +56,5 @@ export const privateApis = ( lock ): any => { throw new Error( 'Forbidden access.' ); }; -document.addEventListener( 'DOMContentLoaded', async () => { - registerDirectives(); - await init(); -} ); +registerDirectives(); +init(); From 4dccb4598df982b33db8f051ef3d4cad7be5b91d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 30 Jul 2024 16:41:09 +0200 Subject: [PATCH 72/91] Replace `signals-core` imports with `signals` --- package-lock.json | 2 -- packages/interactivity/package.json | 1 - packages/interactivity/src/proxies/signals.ts | 2 +- packages/interactivity/src/proxies/test/state-proxy.ts | 2 +- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 97dffa37e0b412..8bf2400660a2a0 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", - "@preact/signals-core": "^1.4.0", "preact": "^10.19.3" }, "engines": { @@ -68245,7 +68244,6 @@ "version": "file:packages/interactivity", "requires": { "@preact/signals": "^1.2.2", - "@preact/signals-core": "^1.4.0", "preact": "^10.19.3" }, "dependencies": { diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index 2f8b227c1cd45b..332254684bdc9b 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -28,7 +28,6 @@ "types": "build-types", "dependencies": { "@preact/signals": "^1.2.2", - "@preact/signals-core": "^1.4.0", "preact": "^10.19.3" }, "publishConfig": { diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index 01a4e907566765..497376073d0331 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -7,7 +7,7 @@ import { batch, type Signal, type ReadonlySignal, -} from '@preact/signals-core'; +} from '@preact/signals'; /** * Internal dependencies diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 4c05a042533a3f..799ea9dd9c8f66 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -5,7 +5,7 @@ /** * External dependencies */ -import { effect } from '@preact/signals-core'; +import { effect } from '@preact/signals'; /** * Internal dependencies */ From fee316d2503d99b2bfced9d7f9fa35626c078395 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 30 Jul 2024 16:43:13 +0200 Subject: [PATCH 73/91] Rename `proxiedStore` to `proxifiedStore` Co-authored-by: Luis Herranz --- packages/interactivity/src/store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 7927ff61179be4..d2696d44f75e1d 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -155,9 +155,9 @@ export function store( ), ...block, }; - const proxiedStore = proxifyStore( namespace, rawStore ); + const proxifiedStore = proxifyStore( namespace, rawStore ); rawStores.set( namespace, rawStore ); - stores.set( namespace, proxiedStore ); + 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 From 52373b160fb7e70c7a72689d8a33a3b40f8d7f20 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 10:20:12 +0200 Subject: [PATCH 74/91] Remove unnecessary `peek()` call --- packages/interactivity/src/proxies/state.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 123c81f48b3ae5..46f48d2b5981a0 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -102,11 +102,6 @@ const stateHandlers: ProxyHandler< object > = { // At this point, the property should be reactive. const desc = Object.getOwnPropertyDescriptor( target, key ); const prop = getPropSignal( receiver, key, desc ); - - if ( peeking ) { - return prop.getComputed().peek(); - } - const result = prop.getComputed().value; /* From 55a9d01fb99d9cd4c0878021d6d57d7dba144ab8 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 10:46:25 +0200 Subject: [PATCH 75/91] Throw more descriptive errors from getContext and getElement --- packages/interactivity/src/scopes.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts index 12602b0c730f3e..2e78755ec4bbe6 100644 --- a/packages/interactivity/src/scopes.ts +++ b/packages/interactivity/src/scopes.ts @@ -64,10 +64,12 @@ const deepImmutable = < T extends object = {} >( target: T ): T => { */ export const getContext = < T extends object >( namespace?: string ): T => { const scope = getScope(); - if ( ! scope ) { - throw Error( - 'Cannot call `getContext()` outside getters and actions used by directives.' - ); + 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() ]; }; @@ -80,12 +82,15 @@ export const getContext = < T extends object >( namespace?: string ): T => { * @return Element representation. */ export const getElement = () => { - if ( ! getScope() ) { - throw Error( - 'Cannot call `getElement()` outside getters and actions used by directives.' - ); + 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 } = getScope(); + const { ref, attributes } = scope; return Object.freeze( { ref: ref.current, attributes: deepImmutable( attributes ), From ff9c9010aa63a86bdb18770069f0dce14a433589 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 11:07:29 +0200 Subject: [PATCH 76/91] Use more descriptive name for proxy functions --- .../interactivity/src/proxies/registry.ts | 5 +++-- packages/interactivity/src/proxies/signals.ts | 4 ++-- packages/interactivity/src/proxies/state.ts | 19 ++++++++++++------- packages/interactivity/src/proxies/store.ts | 4 ++-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/interactivity/src/proxies/registry.ts b/packages/interactivity/src/proxies/registry.ts index 63a438e895870a..767a3730dbae2d 100644 --- a/packages/interactivity/src/proxies/registry.ts +++ b/packages/interactivity/src/proxies/registry.ts @@ -50,7 +50,7 @@ export const createProxy = < T extends object >( * @param obj Object from which to know the proxy. * @return Associated proxy or `undefined`. */ -export const getProxy = < T extends object >( obj: T ): T => +export const getProxyFromObject = < T extends object >( obj: T ): T => objToProxy.get( obj ) as T; /** @@ -61,7 +61,8 @@ export const getProxy = < T extends object >( obj: T ): T => * @param proxy Proxy. * @return Namespace. */ -export const getProxyNs = ( proxy: object ): string => proxyToNs.get( proxy )!; +export const getNamespaceFromProxy = ( proxy: object ): string => + proxyToNs.get( proxy )!; /** * Checks if a given object can be proxied. diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index 497376073d0331..e650d4c9f487a7 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -12,7 +12,7 @@ import { /** * Internal dependencies */ -import { getProxyNs } from './registry'; +import { getNamespaceFromProxy } from './registry'; import { getScope } from '../scopes'; import { setNamespace, resetNamespace } from '../namespaces'; import { withScope } from '../utils'; @@ -107,7 +107,7 @@ export class PropSignal { : this.valueSignal?.value; }; - setNamespace( getProxyNs( this.owner ) ); + setNamespace( getNamespaceFromProxy( this.owner ) ); this.computedsByScope.set( scope, computed( withScope( callback ) ) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 46f48d2b5981a0..55829c6bc7e538 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -6,7 +6,12 @@ import { signal, type Signal } from '@preact/signals'; /** * Internal dependencies */ -import { createProxy, getProxy, getProxyNs, shouldProxy } from './registry'; +import { + createProxy, + getProxyFromObject, + getNamespaceFromProxy, + shouldProxy, +} from './registry'; import { PropSignal } from './signals'; import { setNamespace, resetNamespace } from '../namespaces'; @@ -51,7 +56,7 @@ const getPropSignal = ( key = typeof key === 'number' ? `${ key }` : key; const props = proxyToProps.get( proxy )!; if ( ! props.has( key ) ) { - const ns = getProxyNs( proxy ); + const ns = getNamespaceFromProxy( proxy ); const prop = new PropSignal( proxy ); props.set( key, prop ); if ( initial ) { @@ -110,7 +115,7 @@ const stateHandlers: ProxyHandler< object > = { * which is set by the Directives component. */ if ( typeof result === 'function' ) { - const ns = getProxyNs( receiver ); + const ns = getNamespaceFromProxy( receiver ); return ( ...args: unknown[] ) => { setNamespace( ns ); try { @@ -130,7 +135,7 @@ const stateHandlers: ProxyHandler< object > = { value: unknown, receiver: object ): boolean { - setNamespace( getProxyNs( receiver ) ); + setNamespace( getNamespaceFromProxy( receiver ) ); try { return Reflect.set( target, key, value, receiver ); } finally { @@ -147,13 +152,13 @@ const stateHandlers: ProxyHandler< object > = { const result = Reflect.defineProperty( target, key, desc ); if ( result ) { - const receiver = getProxy( target ); + const receiver = getProxyFromObject( target ); const prop = getPropSignal( receiver, key ); const { get, value } = desc; if ( get ) { prop.setGetter( desc.get! ); } else { - const ns = getProxyNs( receiver ); + const ns = getNamespaceFromProxy( receiver ); prop.setValue( shouldProxy( value ) ? proxifyState( ns, value ) : value ); @@ -176,7 +181,7 @@ const stateHandlers: ProxyHandler< object > = { const result = Reflect.deleteProperty( target, key ); if ( result ) { - const prop = getPropSignal( getProxy( target ), key ); + const prop = getPropSignal( getProxyFromObject( target ), key ); prop.setValue( undefined ); if ( objToIterable.has( target ) ) { diff --git a/packages/interactivity/src/proxies/store.ts b/packages/interactivity/src/proxies/store.ts index 2d1e83696f6748..506b8c3b097bae 100644 --- a/packages/interactivity/src/proxies/store.ts +++ b/packages/interactivity/src/proxies/store.ts @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { createProxy, getProxyNs, shouldProxy } from './registry'; +import { createProxy, getNamespaceFromProxy, shouldProxy } from './registry'; /** * External dependencies */ @@ -19,7 +19,7 @@ const storeRoots = new WeakSet(); const storeHandlers: ProxyHandler< object > = { get: ( target: any, key: string | symbol, receiver: any ) => { const result = Reflect.get( target, key ); - const ns = getProxyNs( receiver ); + const ns = getNamespaceFromProxy( receiver ); /* * Check if the proxy is the store root and no key with that name exist. In From 68de3b647b735e8a3d54c1482f4014cb12af5e6e Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 11:38:07 +0200 Subject: [PATCH 77/91] Use destructured `get` inside `setGetter` call --- packages/interactivity/src/proxies/state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 55829c6bc7e538..58e8a9e33dda95 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -156,7 +156,7 @@ const stateHandlers: ProxyHandler< object > = { const prop = getPropSignal( receiver, key ); const { get, value } = desc; if ( get ) { - prop.setGetter( desc.get! ); + prop.setGetter( get ); } else { const ns = getNamespaceFromProxy( receiver ); prop.setValue( From 1009a4ff37d117eb9f84cb6b0e7dbd47b19a565f Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 12:18:26 +0200 Subject: [PATCH 78/91] Add comment to explain `objToIterable` signals subscription Co-authored-by: Luis Herranz --- packages/interactivity/src/proxies/state.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 58e8a9e33dda95..adc1356d0f3464 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -196,6 +196,7 @@ const stateHandlers: ProxyHandler< object > = { 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 ); }, From 2e5c9268edde640aaccf2d1dbee3c997ef5d0629 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 12:21:04 +0200 Subject: [PATCH 79/91] Change line comment to block comment --- packages/interactivity/src/proxies/state.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index adc1356d0f3464..7b9c0bf4ea09cf 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -196,7 +196,10 @@ const stateHandlers: ProxyHandler< object > = { if ( ! objToIterable.has( target ) ) { objToIterable.set( target, signal( 0 ) ); } - // This subscribes to the signal while preventing the minifier from deleting this line in production. + /* + *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 ); }, From 2aaf90961dc4a863975122cea848574682b9f63e Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 12:23:30 +0200 Subject: [PATCH 80/91] Replace `?` with `!` operator --- packages/interactivity/src/proxies/signals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/signals.ts b/packages/interactivity/src/proxies/signals.ts index e650d4c9f487a7..6a3f41c149e134 100644 --- a/packages/interactivity/src/proxies/signals.ts +++ b/packages/interactivity/src/proxies/signals.ts @@ -132,7 +132,7 @@ export class PropSignal { this.getterSignal = signal( get ); } else if ( value !== this.valueSignal.peek() || - get !== this.getterSignal?.peek() + get !== this.getterSignal!.peek() ) { batch( () => { this.valueSignal!.value = value; From 1ff465d615ba82e8e55fda19631fb169f2baeca0 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 13:16:28 +0200 Subject: [PATCH 81/91] Wrap Interactivity API tests with appropriate `describe` --- .../src/proxies/test/state-proxy.ts | 1976 +++++++++-------- .../src/proxies/test/store-proxy.ts | 183 +- packages/interactivity/src/test/utils.ts | 40 +- 3 files changed, 1105 insertions(+), 1094 deletions(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 799ea9dd9c8f66..6b084f3bbc9d0a 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -30,1152 +30,1158 @@ const withScopeAndNs = ( scope, ns, callback ) => () => { } }; -describe( 'interactivity api - 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 - plain', () => { - 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 } ); +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 ); } ); - 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; - }, + 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 } ); } ); - 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; - }, + 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 ); } ); - 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 ); - }, + 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 ); } ); - 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; - }, + 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' ); } ); - const scope = { context: { test: { value: 'from context' } } }; - try { - setScope( scope as any ); - setNamespace( 'test' ); - expect( state.y ).toBe( 'from context' ); - } 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; - }, + 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 ] ); } ); - 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; - }, + 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 ); + setNamespace( 'test' ); + expect( state.y ).toBe( 'from context' ); + } finally { + resetNamespace(); + resetScope(); + } } ); - const scope = { context: { test: { value: 1 } } }; - try { - setScope( scope as any ); - setNamespace( 'test' ); - expect( state.sumContextValue( 2 ) ).toBe( 3 ); - } finally { - resetNamespace(); - resetScope(); - } - } ); - - it( 'should allow using `this` inside functions', () => { - const state = proxifyState( 'test', { - value: 1, - sum( newValue: number ): number { - return this.value + newValue; - }, + 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 ); } ); - 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; - }, + 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 ); + setNamespace( 'test' ); + expect( state.sumContextValue( 2 ) ).toBe( 3 ); + } finally { + resetNamespace(); + resetScope(); + } } ); - 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 update when mutations happen', () => { - expect( state.a ).toBe( 1 ); - state.a = 11; - expect( state.a ).toBe( 11 ); - } ); - - it( 'should support setting getters on the fly', () => { - const state = proxifyState< { - counter: number; - double?: number; - } >( 'test', { - counter: 1, + 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 ); } ); - Object.defineProperty( state, 'double', { - get() { - return state.counter * 2; - }, - } ); - expect( state.double ).toBe( 2 ); - state.counter = 2; - expect( state.double ).toBe( 4 ); } ); - 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 } }, + 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 ); } ); - 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 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 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 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 keep assigned object references internally', () => { - const obj = {}; - state.nested = obj; - expect( raw.nested ).toBe( obj ); - } ); + it( 'should update when mutations happen', () => { + expect( state.a ).toBe( 1 ); + state.a = 11; + expect( state.a ).toBe( 11 ); + } ); - 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 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 use its namespace by default inside setters', () => { - const state = proxifyState( 'test/right', { - set counter( val: number ) { - const ctx = getContext< { counter: number } >(); - ctx.counter = val; - }, + 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 ); } ); - 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(); - } - } ); - } ); + 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 ); + } ); - 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; - }, + it( 'should keep assigned object references internally', () => { + const obj = {}; + state.nested = obj; + expect( raw.nested ).toBe( obj ); } ); - let counter = 0; - let double = 0; - effect( () => { - counter = state.counter; - double = state.double; + 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 ); } ); - expect( counter ).toBe( 1 ); - expect( double ).toBe( 2 ); - state.double = 4; - expect( counter ).toBe( 2 ); - expect( double ).toBe( 4 ); + 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(); + } + } ); } ); - 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 ); + 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 ); } ); - expect( sum ).toBe( 3 ); - state.splice( 2, 1 ); - expect( sum ).toBe( 2 ); - } ); + it( 'should subscribe to changes when an item is removed from the array', () => { + const state = proxifyState( 'test', [ 0, 0, 0 ] ); + let sum = 0; - 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; + sum = state.reduce( ( sum ) => sum + 1, 0 ); + } ); - effect( () => { - sum = 0; - for ( const _ in state ) { - sum += 1; - } + expect( sum ).toBe( 3 ); + state.splice( 2, 1 ); + expect( sum ).toBe( 2 ); } ); - 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; - state.c = 0; - expect( sum ).toBe( 3 ); + effect( () => { + sum = 0; + for ( const _ in state ) { + sum += 1; + } + } ); - delete state.c; - expect( sum ).toBe( 2 ); + expect( sum ).toBe( 2 ); - state.c = 0; - expect( sum ).toBe( 3 ); - } ); + 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; + delete state.c; + expect( sum ).toBe( 2 ); - effect( () => { - sum = 0; - const keys = Object.getOwnPropertyNames( state ); - for ( const _ of keys ) { - sum += 1; - } + state.c = 0; + expect( sum ).toBe( 3 ); } ); - expect( sum ).toBe( 2 ); + 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; - state.c = 0; - expect( sum ).toBe( 3 ); + effect( () => { + sum = 0; + const keys = Object.getOwnPropertyNames( state ); + for ( const _ of keys ) { + sum += 1; + } + } ); - 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; + expect( sum ).toBe( 2 ); - effect( () => { - keys = 0; - Object.keys( state ).forEach( () => ( keys += 1 ) ); - } ); + state.c = 0; + expect( sum ).toBe( 3 ); - effect( () => { - values = 0; - Object.values( state ).forEach( () => ( values += 1 ) ); + delete state.a; + expect( sum ).toBe( 2 ); } ); - effect( () => { - entries = 0; - Object.entries( state ).forEach( () => ( entries += 1 ) ); + 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 ); } ); - 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; - state.c = 0; - expect( keys ).toBe( 3 ); - expect( values ).toBe( 3 ); - expect( entries ).toBe( 3 ); + effect( () => { + sum = 0; + for ( const _ of state ) { + sum += 1; + } + } ); - delete state.a; - expect( keys ).toBe( 2 ); - expect( values ).toBe( 2 ); - expect( entries ).toBe( 2 ); - } ); + expect( sum ).toBe( 2 ); - it( 'should subscribe to changes to for..of loops', () => { - const state = proxifyState( 'test', [ 0, 0 ] ); - let sum = 0; + state.push( 0 ); + expect( sum ).toBe( 3 ); - effect( () => { - sum = 0; - for ( const _ of state ) { - sum += 1; - } + state.splice( 0, 1 ); + expect( sum ).toBe( 2 ); } ); - expect( sum ).toBe( 2 ); + it( 'should subscribe to implicit changes in length', () => { + const state = proxifyState( 'test', [ 'foo', 'bar' ] ); + let x = ''; - state.push( 0 ); - expect( sum ).toBe( 3 ); + effect( () => { + x = state.join( ' ' ); + } ); - state.splice( 0, 1 ); - expect( sum ).toBe( 2 ); - } ); + expect( x ).toBe( 'foo bar' ); - it( 'should subscribe to implicit changes in length', () => { - const state = proxifyState( 'test', [ 'foo', 'bar' ] ); - let x = ''; + state.push( 'baz' ); + expect( x ).toBe( 'foo bar baz' ); - effect( () => { - x = state.join( ' ' ); + state.splice( 0, 1 ); + expect( x ).toBe( 'bar baz' ); } ); - expect( x ).toBe( 'foo bar' ); + it( 'should subscribe to changes when deleting properties', () => { + let x, y; - state.push( 'baz' ); - expect( x ).toBe( 'foo bar baz' ); - - state.splice( 0, 1 ); - expect( x ).toBe( 'bar baz' ); - } ); + effect( () => { + x = state.a; + } ); - it( 'should subscribe to changes when deleting properties', () => { - let x, y; + effect( () => { + y = state.nested.b; + } ); - effect( () => { - x = state.a; - } ); + expect( x ).toBe( 1 ); + delete state.a; + expect( x ).toBe( undefined ); - effect( () => { - y = state.nested.b; + expect( y ).toBe( 2 ); + delete state.nested.b; + expect( y ).toBe( undefined ); } ); - expect( x ).toBe( 1 ); - delete state.a; - expect( x ).toBe( undefined ); + it( 'should subscribe to changes when mutating objects', () => { + let x, y; - expect( y ).toBe( 2 ); - delete state.nested.b; - expect( y ).toBe( undefined ); - } ); + 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 } }, + ], + } ); - 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( () => { - x = state.a?.id; - } ); + effect( () => { + y = state.a?.nested.id; + } ); - effect( () => { - y = state.a?.nested.id; - } ); + expect( x ).toBe( undefined ); + expect( y ).toBe( undefined ); - expect( x ).toBe( undefined ); - expect( y ).toBe( undefined ); + state.a = state.b[ 0 ]; - state.a = state.b[ 0 ]; + expect( x ).toBe( 1 ); + expect( y ).toBe( 1 ); - expect( x ).toBe( 1 ); - expect( y ).toBe( 1 ); + state.a = state.b[ 1 ]; + expect( x ).toBe( 2 ); + expect( y ).toBe( 2 ); - 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 = 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; + state.a = state.b[ 1 ]; + expect( x ).toBe( 2 ); + expect( y ).toBe( 2 ); } ); - 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; - }, + it( 'should trigger effects after mutations happen', () => { + let x; + effect( () => { + x = state.a; + } ); + expect( x ).toBe( 1 ); + state.a = 11; + expect( x ).toBe( 11 ); } ); - 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; - }, + 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 ); } ); - 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; + 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' ); } ); - state.a = 2; - state.nested.b = 3; + 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( a ).toBe( 2 ); - expect( b ).toBe( 3 ); + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 3 ); - Object.assign( state, initialState ); + state.array[ 1 ] = { b: 22222 }; - expect( a ).toBe( 1 ); - expect( b ).toBe( 2 ); - } ); + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); - it( 'should keep subscribed to properties that become getters', () => { - const state = proxifyState( 'test', { - number: 1, - } ); + state.array.push( 4 ); - let number = 0; + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); - effect( () => { - number = state.number; - } ); + state.array[ 3 ] = 5; - 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 react to changes in props inside getters', () => { - const state = proxifyState( 'test', { - number: 1, - otherNumber: 3, - } ); + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 2 ); + expect( spy5 ).toHaveBeenCalledTimes( 4 ); - let number = 0; + state.array = [ 333, { b: 222222 } ]; - effect( () => { - number = state.number; + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy3 ).toHaveBeenCalledTimes( 3 ); + expect( spy4 ).toHaveBeenCalledTimes( 3 ); + expect( spy5 ).toHaveBeenCalledTimes( 5 ); } ); - expect( number ).toBe( 1 ); - state.number = 2; - expect( number ).toBe( 2 ); - Object.defineProperty( state, 'number', { - get: () => state.otherNumber, - configurable: true, + 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 ); } ); - 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; + 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( () => { - number = state.number; - } ); + effect( () => { + a = state.a; + } ); + effect( () => { + b = state.nested.b; + } ); - 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 ); - } ); + state.a = 2; + state.nested.b = 3; - it( 'should allow getters to use `this`', () => { - const state = proxifyState( 'test', { - number: 1, - otherNumber: 3, - } ); + expect( a ).toBe( 2 ); + expect( b ).toBe( 3 ); - let number = 0; + Object.assign( state, initialState ); - effect( () => { - number = state.number; + expect( a ).toBe( 1 ); + expect( b ).toBe( 2 ); } ); - expect( number ).toBe( 1 ); - state.number = 2; - expect( number ).toBe( 2 ); - Object.defineProperty( state, 'number', { - get() { - return this.otherNumber; - }, - configurable: true, + 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 ); } ); - 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 }`; - }, + 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 ); } ); - 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; + 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 ); } ); - 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 an 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; - }, + 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 ); } ); - 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; - }, + 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' ); } ); - let result = 0; - - effect( () => { - result = state.sum( 2 ); + it( 'should throw an error in getters that require an 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(); } ); - 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 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 ); + } ); } ); - 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( () => { + 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 ]; - return typeof nested === 'object' && nested.b; + expect( typeof nested === 'object' && nested.b ).toBe( 2 ); + expect( peek( state.array, 'length' ) ).toBe( 2 ); } ); - 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; - }, + 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 ); } ); - 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; - }, + 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 ); } ); - 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; - }, + 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 ); } ); - const context2 = proxifyState( 'test-2', { counter: 1 } ); - const state1 = proxifyState( 'test1', { - get double() { - return state2.counter * 2; - }, + 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 ); } ); - 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 ); + 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 ] ); + describe( 'refs', () => { + it( 'should preserve object references', () => { + expect( state.nested ).toBe( state.array[ 1 ] ); - state.nested.b = 22; + 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 ); + 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 }; + 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 ); - } ); + 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 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 return the same proxy when trying to re-proxify a state object', () => { - const state = proxifyState( 'test', {} ); - expect( () => proxifyState( 'test', state ) ).toThrow(); + it( 'should return the same proxy 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(); - } ); + 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 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 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 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 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 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 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 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 ); + 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 ] ) ); + 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 ); + expect( store[ key ] ).toBe( undefined ); + expect( x ).toBe( undefined ); - store[ key ] = true; + store[ key ] = true; - expect( store[ key ] ).toBe( true ); - expect( x ).toBe( 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 ); + 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 index 2f8f1465e4d12c..225621072929d0 100644 --- a/packages/interactivity/src/proxies/test/store-proxy.ts +++ b/packages/interactivity/src/proxies/test/store-proxy.ts @@ -5,116 +5,119 @@ import { proxifyStore, proxifyState } from '../'; import { setScope, resetScope, getContext } from '../../scopes'; import { setNamespace, resetNamespace } from '../../namespaces'; -describe( 'interactivity api - 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( {} ); - } ); +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 = ''; + 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 syncFunc = () => { + const ctx = getContext< { value: string } >(); + result = ctx.value; + }; - const storeTest = proxifyStore( 'test', { - callbacks: { - syncFunc, - nested: { syncFunc }, - }, - } ); + const storeTest = proxifyStore( 'test', { + callbacks: { + syncFunc, + nested: { syncFunc }, + }, + } ); - const scope = { - context: { - test: { value: 'test' }, - }, - }; + const scope = { + context: { + test: { value: 'test' }, + }, + }; - setNamespace( 'other-namespace' ); - setScope( scope as any ); + setNamespace( 'other-namespace' ); + setScope( scope as any ); - storeTest.callbacks.syncFunc(); - expect( result ).toBe( 'test' ); - storeTest.callbacks.nested.syncFunc(); - expect( result ).toBe( 'test' ); + storeTest.callbacks.syncFunc(); + expect( result ).toBe( 'test' ); + storeTest.callbacks.nested.syncFunc(); + expect( result ).toBe( 'test' ); - resetScope(); - resetNamespace(); - } ); + 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 }`; - }; + 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 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' ); - } ); + const scope = { + context: { + test: { value: '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 }`; - }; + setNamespace( 'other-namespace' ); + setScope( scope as any ); + const promise1 = storeTest.callbacks.asyncFunc(); + const promise2 = storeTest.callbacks.nested.asyncFunc(); + resetScope(); + resetNamespace(); - const storeTest1 = proxifyStore( 'test1', { - callbacks: { asyncFunc }, + expect( await promise1 ).toBe( 'data from test' ); + expect( await promise2 ).toBe( 'data from test' ); } ); - const storeTest2 = proxifyStore( 'test2', { - callbacks: { - *asyncFunc() { - const result = yield storeTest1.callbacks.asyncFunc(); - return result; + 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' }, - }, - }; + const scope = { + context: { + test1: { value: 'test1' }, + test2: { value: 'test2' }, + }, + }; - setNamespace( 'other-namespace' ); - setScope( scope as any ); - const promise = storeTest2.callbacks.asyncFunc(); - resetScope(); - resetNamespace(); + setNamespace( 'other-namespace' ); + setScope( scope as any ); + const promise = storeTest2.callbacks.asyncFunc(); + resetScope(); + resetNamespace(); - expect( await promise ).toBe( 'data from test1' ); - } ); + 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 } ); + 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 ); + expect( store.state ).toBe( state ); + } ); } ); } ); } ); diff --git a/packages/interactivity/src/test/utils.ts b/packages/interactivity/src/test/utils.ts index ff564fa7c4c250..2de0ffe6d31e06 100644 --- a/packages/interactivity/src/test/utils.ts +++ b/packages/interactivity/src/test/utils.ts @@ -3,24 +3,26 @@ */ 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' ); +describe( 'Interactivity API', () => { + 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' ); + } ); } ); } ); From b470cc2a818f1526a83450ec2834b46e0af8fd86 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 13:57:59 +0200 Subject: [PATCH 82/91] Remove unnecessary `setNamespace` calls in tests --- packages/interactivity/src/proxies/test/state-proxy.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 6b084f3bbc9d0a..41b5117721fc07 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -114,10 +114,8 @@ describe( 'Interactivity API', () => { const scope = { context: { test: { value: 'from context' } } }; try { setScope( scope as any ); - setNamespace( 'test' ); expect( state.y ).toBe( 'from context' ); } finally { - resetNamespace(); resetScope(); } } ); @@ -152,10 +150,8 @@ describe( 'Interactivity API', () => { const scope = { context: { test: { value: 1 } } }; try { setScope( scope as any ); - setNamespace( 'test' ); expect( state.sumContextValue( 2 ) ).toBe( 3 ); } finally { - resetNamespace(); resetScope(); } } ); From 65dfdf8ea33b20bbb6f55fee6bd5ef6b759d08e7 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 14:01:45 +0200 Subject: [PATCH 83/91] Remove duplicated test --- packages/interactivity/src/proxies/test/state-proxy.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 41b5117721fc07..91c9b16998aeac 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -200,12 +200,6 @@ describe( 'Interactivity API', () => { expect( state.array.length ).toBe( 1 ); } ); - it( 'should update when mutations happen', () => { - expect( state.a ).toBe( 1 ); - state.a = 11; - expect( state.a ).toBe( 11 ); - } ); - it( 'should support setting getters on the fly', () => { const state = proxifyState< { counter: number; From e0272f8d1b2a96819b834443566b25d5f545fee2 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 16:26:51 +0200 Subject: [PATCH 84/91] Fix typo --- packages/interactivity/src/proxies/test/state-proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 91c9b16998aeac..1daeb6861c3fb7 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -866,7 +866,7 @@ describe( 'Interactivity API', () => { expect( resultNoScope ).toBe( 'No scope: 2' ); } ); - it( 'should throw an error in getters that require an scope', () => { + it( 'should throw an error in getters that require a scope', () => { const state = proxifyState( 'test', { number: 1, get sumValueFromContext() { From 4db4bff77754f02819124d074b9fdd44c5168383 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 16:42:26 +0200 Subject: [PATCH 85/91] Add extra tests for getter modification --- .../src/proxies/test/state-proxy.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 1daeb6861c3fb7..5c0253a6bebcd7 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -217,6 +217,38 @@ describe( 'Interactivity API', () => { 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 } }; @@ -741,6 +773,41 @@ describe( 'Interactivity API', () => { 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, From 80e9ff501576ec0400f6bc2522954f49c7ccf3ec Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 16:48:30 +0200 Subject: [PATCH 86/91] Test the right namespace is used inside getters --- .../src/proxies/test/state-proxy.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 5c0253a6bebcd7..d0f5761c048ee2 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -120,6 +120,31 @@ describe( 'Interactivity API', () => { } } ); + 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, From 80cf5985c14cb32b740211f55dbc7d8f67ebd844 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 16:50:18 +0200 Subject: [PATCH 87/91] Fix test name --- packages/interactivity/src/proxies/test/state-proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index d0f5761c048ee2..92500189fc8309 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -1164,7 +1164,7 @@ describe( 'Interactivity API', () => { expect( state1 ).toBe( state2 ); } ); - it( 'should return the same proxy when trying to re-proxify a state object', () => { + it( 'should throw when trying to re-proxify a state object', () => { const state = proxifyState( 'test', {} ); expect( () => proxifyState( 'test', state ) ).toThrow(); } ); From 28d329bf7aed70e2fce472ab3e248761f20429cb Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 31 Jul 2024 17:02:37 +0200 Subject: [PATCH 88/91] Check if length's PropSignal exists before updating its value --- packages/interactivity/src/proxies/state.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index 7b9c0bf4ea09cf..0978fa2ccd0264 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -168,7 +168,15 @@ const stateHandlers: ProxyHandler< object > = { objToIterable.get( target )!.value++; } - if ( Array.isArray( target ) ) { + /* + * 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 ); } From 0efe2c29751e445ce031ce8c1d7c98c5850d124f Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 6 Aug 2024 18:09:36 +0200 Subject: [PATCH 89/91] Add deepMerge tests and prevent server overwritting --- packages/interactivity-router/src/index.ts | 8 +- packages/interactivity/src/index.ts | 6 +- packages/interactivity/src/store.ts | 39 +-- packages/interactivity/src/test/utils.ts | 295 +++++++++++++++++- packages/interactivity/src/utils.ts | 31 ++ .../interactivity/router-navigate.spec.ts | 31 +- 6 files changed, 350 insertions(+), 60 deletions(-) diff --git a/packages/interactivity-router/src/index.ts b/packages/interactivity-router/src/index.ts index 79b67eeb98e656..c6e1087b038a55 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/src/index.ts b/packages/interactivity/src/index.ts index ee9a7b1c21a988..336c2a97226db7 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -13,7 +13,7 @@ import { directivePrefix } from './constants'; import { toVdom } from './vdom'; import { directive } from './hooks'; import { getNamespace } from './namespaces'; -import { parseInitialData, populateInitialData } from './store'; +import { parseServerData, populateServerData } from './store'; import { proxifyState } from './proxies'; export { store, getConfig } from './store'; @@ -47,8 +47,8 @@ export const privateApis = ( lock ): any => { cloneElement, render, proxifyState, - parseInitialData, - populateInitialData, + parseServerData, + populateServerData, batch, }; } diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index d2696d44f75e1d..25fa64eb6e160e 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -6,33 +6,7 @@ import { proxifyState, proxifyStore } from './proxies'; * External dependencies */ import { getNamespace } from './namespaces'; -import { isPlainObject } from './utils'; - -const deepMerge = ( target: any, source: any ) => { - if ( isPlainObject( target ) && isPlainObject( source ) ) { - for ( const key in source ) { - const getter = Object.getOwnPropertyDescriptor( source, key )?.get; - if ( typeof getter === 'function' ) { - Object.defineProperty( target, key, { - get: getter, - configurable: true, - } ); - } else if ( isPlainObject( 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 { deepMerge, isPlainObject } from './utils'; export const stores = new Map(); const rawStores = new Map(); @@ -189,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( @@ -205,13 +179,14 @@ export const parseInitialData = ( dom = document ) => { return {}; }; -export const populateInitialData = ( data?: { +export const populateServerData = ( data?: { state?: Record< string, unknown >; config?: Record< string, unknown >; } ) => { 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 ( isPlainObject( data?.config ) ) { @@ -222,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 2de0ffe6d31e06..2ea0d2f04fadf7 100644 --- a/packages/interactivity/src/test/utils.ts +++ b/packages/interactivity/src/test/utils.ts @@ -1,9 +1,302 @@ /** * Internal dependencies */ -import { kebabToCamelCase } from '../utils'; +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( '' ); diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index c5eb91681294f2..ee0cf1ba084dc8 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -364,3 +364,34 @@ export const isPlainObject = ( 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/router-navigate.spec.ts b/test/e2e/specs/interactivity/router-navigate.spec.ts index 872fe9ea7ea52e..d1ac30783ee2b7 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 ( { From 31ad6a9648a575f196213fd91de28eb51171ffcf Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 6 Aug 2024 20:54:41 +0200 Subject: [PATCH 90/91] Make sure context inheritance is shallow and server props don't overwrite --- packages/interactivity/src/directives.tsx | 10 ++-- .../interactivity/directive-context.spec.ts | 50 ++++++++++--------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index b128b6afbab9ed..e7a0e0b57f6bda 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -70,7 +70,7 @@ const proxifyContext = ( current: object, inherited: object = {} ): object => { ! contextAssignedObjects.get( target )?.has( k ) && isPlainObject( currentProp ) ) { - return proxifyContext( currentProp, fallback[ k ] ); + return proxifyContext( currentProp ); } // Return the stored proxy for `currentProp` when it exists. @@ -144,7 +144,7 @@ const updateContext = ( target: any, source: any ) => { isPlainObject( source[ k ] ) ) { updateContext( peek( target, k ) as object, source[ k ] ); - } else { + } else if ( ! ( k in target ) ) { target[ k ] = source[ k ]; } } @@ -291,8 +291,12 @@ export default () => { 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 ); diff --git a/test/e2e/specs/interactivity/directive-context.spec.ts b/test/e2e/specs/interactivity/directive-context.spec.ts index 41afa274155a28..e806049ff55e75 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(); } ); } ); From 185ec7295299e77f88787eb5fa3b841ebed649ad Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 7 Aug 2024 09:51:18 +0200 Subject: [PATCH 91/91] Refactor wp-each to use new proxifyContext structure --- .../interactive-blocks/directive-each/render.php | 4 ++-- .../interactive-blocks/directive-each/view.js | 10 ++++++++-- packages/interactivity/src/directives.tsx | 13 +++++++------ test/e2e/specs/interactivity/directive-each.spec.ts | 9 ++------- 4 files changed, 19 insertions(+), 17 deletions(-) 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 27fd6c7d172939..47eb351d837e78 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 @@
-