@wordpress/interactivity
Version:
Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.
1,270 lines (1,067 loc) • 32.7 kB
text/typescript
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* External dependencies
*/
import { effect } from '@preact/signals';
/**
* Internal dependencies
*/
import { proxifyState, peek } from '../';
import { setScope, resetScope, getContext, getElement } from '../../scopes';
import { setNamespace, resetNamespace } from '../../namespaces';
type State = {
a?: number;
nested: { b?: number };
array: ( number | State[ 'nested' ] )[];
};
const withScopeAndNs = ( scope, ns, callback ) => () => {
setScope( scope );
setNamespace( ns );
try {
return callback();
} finally {
resetNamespace();
resetScope();
}
};
describe( 'Interactivity API', () => {
describe( 'state proxy', () => {
let nested = { b: 2 };
let array = [ 3, nested ];
let raw: State = { a: 1, nested, array };
let state = proxifyState( 'test', raw );
const window = globalThis as any;
beforeEach( () => {
nested = { b: 2 };
array = [ 3, nested ];
raw = { a: 1, nested, array };
state = proxifyState( 'test', raw );
} );
describe( 'get', () => {
it( 'should return plain objects/arrays', () => {
expect( state.nested ).toEqual( { b: 2 } );
expect( state.array ).toEqual( [ 3, { b: 2 } ] );
expect( state.array[ 1 ] ).toEqual( { b: 2 } );
} );
it( 'should return plain primitives', () => {
expect( state.a ).toBe( 1 );
expect( state.nested.b ).toBe( 2 );
expect( state.array[ 0 ] ).toBe( 3 );
expect(
typeof state.array[ 1 ] === 'object' && state.array[ 1 ].b
).toBe( 2 );
expect( state.array.length ).toBe( 2 );
} );
it( 'should support reading from getters', () => {
const state = proxifyState( 'test', {
counter: 1,
get double() {
return state.counter * 2;
},
} );
expect( state.double ).toBe( 2 );
state.counter = 2;
expect( state.double ).toBe( 4 );
} );
it( 'should support getters returning other parts of the state', () => {
const state = proxifyState( 'test', {
switch: 'a',
a: { data: 'a' },
b: { data: 'b' },
get aOrB() {
return state.switch === 'a' ? state.a : state.b;
},
} );
expect( state.aOrB.data ).toBe( 'a' );
state.switch = 'b';
expect( state.aOrB.data ).toBe( 'b' );
} );
it( 'should support getters using ownKeys traps', () => {
const state = proxifyState( 'test', {
x: {
a: 1,
b: 2,
},
get y() {
return Object.values( state.x );
},
} );
expect( state.y ).toEqual( [ 1, 2 ] );
} );
it( 'should support getters accessing the scope', () => {
const state = proxifyState( 'test', {
get y() {
const ctx = getContext< { value: string } >();
return ctx.value;
},
} );
const scope = { context: { test: { value: 'from context' } } };
try {
setScope( scope as any );
expect( state.y ).toBe( 'from context' );
} finally {
resetScope();
}
} );
it( 'should use its namespace by default inside getters', () => {
const state = proxifyState( 'test/right', {
get value() {
const ctx = getContext< { value: string } >();
return ctx.value;
},
} );
const scope = {
context: {
'test/right': { value: 'OK' },
'test/other': { value: 'Wrong' },
},
};
try {
setScope( scope as any );
setNamespace( 'test/other' );
expect( state.value ).toBe( 'OK' );
} finally {
resetNamespace();
resetScope();
}
} );
it( 'should work with normal functions', () => {
const state = proxifyState( 'test', {
value: 1,
isBigger: ( newValue: number ): boolean =>
state.value < newValue,
sum( newValue: number ): number {
return state.value + newValue;
},
replace: ( newValue: number ): void => {
state.value = newValue;
},
} );
expect( state.isBigger( 2 ) ).toBe( true );
expect( state.sum( 2 ) ).toBe( 3 );
expect( state.value ).toBe( 1 );
state.replace( 2 );
expect( state.value ).toBe( 2 );
} );
it( 'should work with normal functions accessing the scope', () => {
const state = proxifyState( 'test', {
sumContextValue( newValue: number ): number {
const ctx = getContext< { value: number } >();
return ctx.value + newValue;
},
} );
const scope = { context: { test: { value: 1 } } };
try {
setScope( scope as any );
expect( state.sumContextValue( 2 ) ).toBe( 3 );
} finally {
resetScope();
}
} );
it( 'should allow using `this` inside functions', () => {
const state = proxifyState( 'test', {
value: 1,
sum( newValue: number ): number {
return this.value + newValue;
},
} );
expect( state.sum( 2 ) ).toBe( 3 );
} );
} );
describe( 'set', () => {
it( 'should update like plain objects/arrays', () => {
expect( state.a ).toBe( 1 );
expect( state.nested.b ).toBe( 2 );
state.a = 2;
state.nested.b = 3;
expect( state.a ).toBe( 2 );
expect( state.nested.b ).toBe( 3 );
} );
it( 'should support setting values with setters', () => {
const state = proxifyState( 'test', {
counter: 1,
get double() {
return state.counter * 2;
},
set double( val ) {
state.counter = val / 2;
},
} );
expect( state.counter ).toBe( 1 );
state.double = 4;
expect( state.counter ).toBe( 2 );
} );
it( 'should update array length', () => {
expect( state.array.length ).toBe( 2 );
state.array.push( 4 );
expect( state.array.length ).toBe( 3 );
state.array.splice( 1, 2 );
expect( state.array.length ).toBe( 1 );
} );
it( 'should support setting getters on the fly', () => {
const state = proxifyState< {
counter: number;
double?: number;
} >( 'test', {
counter: 1,
} );
Object.defineProperty( state, 'double', {
get() {
return state.counter * 2;
},
} );
expect( state.double ).toBe( 2 );
state.counter = 2;
expect( state.double ).toBe( 4 );
} );
it( 'should support getter modification', () => {
const state = proxifyState< {
counter: number;
double: number;
} >( 'test', {
counter: 1,
get double() {
return state.counter * 2;
},
} );
const scope = {
context: { test: { counter: 2 } },
};
expect( state.double ).toBe( 2 );
Object.defineProperty( state, 'double', {
get() {
const ctx = getContext< { counter: number } >();
return ctx.counter * 2;
},
} );
try {
setScope( scope as any );
expect( state.double ).toBe( 4 );
} finally {
resetScope();
}
} );
it( 'should copy object like plain JavaScript', () => {
const state = proxifyState< {
a?: { id: number; nested: { id: number } };
b: { id: number; nested: { id: number } };
} >( 'test', {
b: { id: 1, nested: { id: 1 } },
} );
state.a = state.b;
expect( state.a.id ).toBe( 1 );
expect( state.b.id ).toBe( 1 );
expect( state.a.nested.id ).toBe( 1 );
expect( state.b.nested.id ).toBe( 1 );
state.a.id = 2;
state.a.nested.id = 2;
expect( state.a.id ).toBe( 2 );
expect( state.b.id ).toBe( 2 );
expect( state.a.nested.id ).toBe( 2 );
expect( state.b.nested.id ).toBe( 2 );
state.b.id = 3;
state.b.nested.id = 3;
expect( state.b.id ).toBe( 3 );
expect( state.a.id ).toBe( 3 );
expect( state.a.nested.id ).toBe( 3 );
expect( state.b.nested.id ).toBe( 3 );
state.a.id = 4;
state.a.nested.id = 4;
expect( state.a.id ).toBe( 4 );
expect( state.b.id ).toBe( 4 );
expect( state.a.nested.id ).toBe( 4 );
expect( state.b.nested.id ).toBe( 4 );
} );
it( 'should be able to reset values with Object.assign', () => {
const initialNested = { ...nested };
const initialState = { ...raw, nested: initialNested };
state.a = 2;
state.nested.b = 3;
Object.assign( state, initialState );
expect( state.a ).toBe( 1 );
expect( state.nested.b ).toBe( 2 );
} );
it( 'should keep assigned object references internally', () => {
const obj = {};
state.nested = obj;
expect( raw.nested ).toBe( obj );
} );
it( 'should keep object references across namespaces', () => {
const raw1 = { obj: {} };
const raw2 = { obj: {} };
const state1 = proxifyState( 'test-1', raw1 );
const state2 = proxifyState( 'test-2', raw2 );
state2.obj = state1.obj;
expect( state2.obj ).toBe( state1.obj );
expect( raw2.obj ).toBe( state1.obj );
} );
it( 'should use its namespace by default inside setters', () => {
const state = proxifyState( 'test/right', {
set counter( val: number ) {
const ctx = getContext< { counter: number } >();
ctx.counter = val;
},
} );
const scope = {
context: {
'test/other': { counter: 0 },
'test/right': { counter: 0 },
},
};
try {
setScope( scope as any );
setNamespace( 'test/other' );
state.counter = 4;
expect( scope.context[ 'test/right' ].counter ).toBe( 4 );
} finally {
resetNamespace();
resetScope();
}
} );
} );
describe( 'computations', () => {
it( 'should subscribe to values mutated with setters', () => {
const state = proxifyState( 'test', {
counter: 1,
get double() {
return state.counter * 2;
},
set double( val ) {
state.counter = val / 2;
},
} );
let counter = 0;
let double = 0;
effect( () => {
counter = state.counter;
double = state.double;
} );
expect( counter ).toBe( 1 );
expect( double ).toBe( 2 );
state.double = 4;
expect( counter ).toBe( 2 );
expect( double ).toBe( 4 );
} );
it( 'should subscribe to changes when an item is removed from the array', () => {
const state = proxifyState( 'test', [ 0, 0, 0 ] );
let sum = 0;
effect( () => {
sum = 0;
sum = state.reduce( ( sum ) => sum + 1, 0 );
} );
expect( sum ).toBe( 3 );
state.splice( 2, 1 );
expect( sum ).toBe( 2 );
} );
it( 'should subscribe to changes to for..in loops', () => {
const raw: Record< string, number > = { a: 0, b: 0 };
const state = proxifyState( 'test', raw );
let sum = 0;
effect( () => {
sum = 0;
for ( const _ in state ) {
sum += 1;
}
} );
expect( sum ).toBe( 2 );
state.c = 0;
expect( sum ).toBe( 3 );
delete state.c;
expect( sum ).toBe( 2 );
state.c = 0;
expect( sum ).toBe( 3 );
} );
it( 'should subscribe to changes for Object.getOwnPropertyNames()', () => {
const raw: Record< string, number > = { a: 1, b: 2 };
const state = proxifyState( 'test', raw );
let sum = 0;
effect( () => {
sum = 0;
const keys = Object.getOwnPropertyNames( state );
for ( const _ of keys ) {
sum += 1;
}
} );
expect( sum ).toBe( 2 );
state.c = 0;
expect( sum ).toBe( 3 );
delete state.a;
expect( sum ).toBe( 2 );
} );
it( 'should subscribe to changes to Object.keys/values/entries()', () => {
const raw: Record< string, number > = { a: 1, b: 2 };
const state = proxifyState( 'test', raw );
let keys = 0;
let values = 0;
let entries = 0;
effect( () => {
keys = 0;
Object.keys( state ).forEach( () => ( keys += 1 ) );
} );
effect( () => {
values = 0;
Object.values( state ).forEach( () => ( values += 1 ) );
} );
effect( () => {
entries = 0;
Object.entries( state ).forEach( () => ( entries += 1 ) );
} );
expect( keys ).toBe( 2 );
expect( values ).toBe( 2 );
expect( entries ).toBe( 2 );
state.c = 0;
expect( keys ).toBe( 3 );
expect( values ).toBe( 3 );
expect( entries ).toBe( 3 );
delete state.a;
expect( keys ).toBe( 2 );
expect( values ).toBe( 2 );
expect( entries ).toBe( 2 );
} );
it( 'should subscribe to changes to for..of loops', () => {
const state = proxifyState( 'test', [ 0, 0 ] );
let sum = 0;
effect( () => {
sum = 0;
for ( const _ of state ) {
sum += 1;
}
} );
expect( sum ).toBe( 2 );
state.push( 0 );
expect( sum ).toBe( 3 );
state.splice( 0, 1 );
expect( sum ).toBe( 2 );
} );
it( 'should subscribe to implicit changes in length', () => {
const state = proxifyState( 'test', [ 'foo', 'bar' ] );
let x = '';
effect( () => {
x = state.join( ' ' );
} );
expect( x ).toBe( 'foo bar' );
state.push( 'baz' );
expect( x ).toBe( 'foo bar baz' );
state.splice( 0, 1 );
expect( x ).toBe( 'bar baz' );
} );
it( 'should subscribe to changes when deleting properties', () => {
let x, y;
effect( () => {
x = state.a;
} );
effect( () => {
y = state.nested.b;
} );
expect( x ).toBe( 1 );
delete state.a;
expect( x ).toBe( undefined );
expect( y ).toBe( 2 );
delete state.nested.b;
expect( y ).toBe( undefined );
} );
it( 'should subscribe to changes when mutating objects', () => {
let x, y;
const state = proxifyState< {
a?: { id: number; nested: { id: number } };
b: { id: number; nested: { id: number } }[];
} >( 'test', {
b: [
{ id: 1, nested: { id: 1 } },
{ id: 2, nested: { id: 2 } },
],
} );
effect( () => {
x = state.a?.id;
} );
effect( () => {
y = state.a?.nested.id;
} );
expect( x ).toBe( undefined );
expect( y ).toBe( undefined );
state.a = state.b[ 0 ];
expect( x ).toBe( 1 );
expect( y ).toBe( 1 );
state.a = state.b[ 1 ];
expect( x ).toBe( 2 );
expect( y ).toBe( 2 );
state.a = undefined;
expect( x ).toBe( undefined );
expect( y ).toBe( undefined );
state.a = state.b[ 1 ];
expect( x ).toBe( 2 );
expect( y ).toBe( 2 );
} );
it( 'should trigger effects after mutations happen', () => {
let x;
effect( () => {
x = state.a;
} );
expect( x ).toBe( 1 );
state.a = 11;
expect( x ).toBe( 11 );
} );
it( 'should subscribe corretcly from getters', () => {
let x;
const state = proxifyState( 'test', {
counter: 1,
get double() {
return state.counter * 2;
},
} );
effect( () => ( x = state.double ) );
expect( x ).toBe( 2 );
state.counter = 2;
expect( x ).toBe( 4 );
} );
it( 'should subscribe corretcly from getters returning other parts of the state', () => {
let data;
const state = proxifyState( 'test', {
switch: 'a',
a: { data: 'a' },
b: { data: 'b' },
get aOrB() {
return state.switch === 'a' ? state.a : state.b;
},
} );
effect( () => ( data = state.aOrB.data ) );
expect( data ).toBe( 'a' );
state.switch = 'b';
expect( data ).toBe( 'b' );
} );
it( 'should subscribe to changes', () => {
const spy1 = jest.fn( () => state.a );
const spy2 = jest.fn( () => state.nested );
const spy3 = jest.fn( () => state.nested.b );
const spy4 = jest.fn( () => state.array[ 0 ] );
const spy5 = jest.fn(
() =>
typeof state.array[ 1 ] === 'object' &&
state.array[ 1 ].b
);
effect( spy1 );
effect( spy2 );
effect( spy3 );
effect( spy4 );
effect( spy5 );
expect( spy1 ).toHaveBeenCalledTimes( 1 );
expect( spy2 ).toHaveBeenCalledTimes( 1 );
expect( spy3 ).toHaveBeenCalledTimes( 1 );
expect( spy4 ).toHaveBeenCalledTimes( 1 );
expect( spy5 ).toHaveBeenCalledTimes( 1 );
state.a = 11;
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 1 );
expect( spy3 ).toHaveBeenCalledTimes( 1 );
expect( spy4 ).toHaveBeenCalledTimes( 1 );
expect( spy5 ).toHaveBeenCalledTimes( 1 );
state.nested.b = 22;
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 1 );
expect( spy3 ).toHaveBeenCalledTimes( 2 );
expect( spy4 ).toHaveBeenCalledTimes( 1 );
expect( spy5 ).toHaveBeenCalledTimes( 2 ); // nested also exists array[1]
state.nested = { b: 222 };
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 2 );
expect( spy3 ).toHaveBeenCalledTimes( 3 );
expect( spy4 ).toHaveBeenCalledTimes( 1 );
expect( spy5 ).toHaveBeenCalledTimes( 2 ); // now state.nested has a different reference
state.array[ 0 ] = 33;
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 2 );
expect( spy3 ).toHaveBeenCalledTimes( 3 );
expect( spy4 ).toHaveBeenCalledTimes( 2 );
expect( spy5 ).toHaveBeenCalledTimes( 2 );
if ( typeof state.array[ 1 ] === 'object' ) {
state.array[ 1 ].b = 2222;
}
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 2 );
expect( spy3 ).toHaveBeenCalledTimes( 3 );
expect( spy4 ).toHaveBeenCalledTimes( 2 );
expect( spy5 ).toHaveBeenCalledTimes( 3 );
state.array[ 1 ] = { b: 22222 };
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 2 );
expect( spy3 ).toHaveBeenCalledTimes( 3 );
expect( spy4 ).toHaveBeenCalledTimes( 2 );
expect( spy5 ).toHaveBeenCalledTimes( 4 );
state.array.push( 4 );
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 2 );
expect( spy3 ).toHaveBeenCalledTimes( 3 );
expect( spy4 ).toHaveBeenCalledTimes( 2 );
expect( spy5 ).toHaveBeenCalledTimes( 4 );
state.array[ 3 ] = 5;
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 2 );
expect( spy3 ).toHaveBeenCalledTimes( 3 );
expect( spy4 ).toHaveBeenCalledTimes( 2 );
expect( spy5 ).toHaveBeenCalledTimes( 4 );
state.array = [ 333, { b: 222222 } ];
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 2 );
expect( spy3 ).toHaveBeenCalledTimes( 3 );
expect( spy4 ).toHaveBeenCalledTimes( 3 );
expect( spy5 ).toHaveBeenCalledTimes( 5 );
} );
it( 'should subscribe to array length', () => {
const array = [ 1 ];
const state = proxifyState( 'test', { array } );
const spy1 = jest.fn( () => state.array.length );
const spy2 = jest.fn( () =>
state.array.map( ( i: number ) => i )
);
effect( spy1 );
effect( spy2 );
expect( spy1 ).toHaveBeenCalledTimes( 1 );
expect( spy2 ).toHaveBeenCalledTimes( 1 );
state.array.push( 2 );
expect( state.array.length ).toBe( 2 );
expect( spy1 ).toHaveBeenCalledTimes( 2 );
expect( spy2 ).toHaveBeenCalledTimes( 2 );
state.array[ 2 ] = 3;
expect( state.array.length ).toBe( 3 );
expect( spy1 ).toHaveBeenCalledTimes( 3 );
expect( spy2 ).toHaveBeenCalledTimes( 3 );
state.array = state.array.filter( ( i: number ) => i <= 2 );
expect( state.array.length ).toBe( 2 );
expect( spy1 ).toHaveBeenCalledTimes( 4 );
expect( spy2 ).toHaveBeenCalledTimes( 4 );
} );
it( 'should be able to reset values with Object.assign and still react to changes', () => {
const initialNested = { ...nested };
const initialState = { ...raw, nested: initialNested };
let a, b;
effect( () => {
a = state.a;
} );
effect( () => {
b = state.nested.b;
} );
state.a = 2;
state.nested.b = 3;
expect( a ).toBe( 2 );
expect( b ).toBe( 3 );
Object.assign( state, initialState );
expect( a ).toBe( 1 );
expect( b ).toBe( 2 );
} );
it( 'should keep subscribed to properties that become getters', () => {
const state = proxifyState( 'test', {
number: 1,
} );
let number = 0;
effect( () => {
number = state.number;
} );
expect( number ).toBe( 1 );
state.number = 2;
expect( number ).toBe( 2 );
Object.defineProperty( state, 'number', {
get: () => 3,
configurable: true,
} );
expect( number ).toBe( 3 );
} );
it( 'should keep subscribed to modified getters', () => {
const state = proxifyState< {
counter: number;
double: number;
} >( 'test', {
counter: 1,
get double() {
return state.counter * 2;
},
} );
const scope = {
context: { test: { counter: 2 } },
};
let double = 0;
effect(
withScopeAndNs( scope, 'test', () => {
double = state.double;
} )
);
expect( double ).toBe( 2 );
Object.defineProperty( state, 'double', {
get() {
const ctx = getContext< { counter: number } >();
return ctx.counter * 2;
},
} );
expect( double ).toBe( 4 );
} );
it( 'should react to changes in props inside getters', () => {
const state = proxifyState( 'test', {
number: 1,
otherNumber: 3,
} );
let number = 0;
effect( () => {
number = state.number;
} );
expect( number ).toBe( 1 );
state.number = 2;
expect( number ).toBe( 2 );
Object.defineProperty( state, 'number', {
get: () => state.otherNumber,
configurable: true,
} );
expect( number ).toBe( 3 );
state.otherNumber = 4;
expect( number ).toBe( 4 );
} );
it( 'should react to changes in props inside getters if they become getters', () => {
const state = proxifyState( 'test', {
number: 1,
otherNumber: 3,
} );
let number = 0;
effect( () => {
number = state.number;
} );
expect( number ).toBe( 1 );
state.number = 2;
expect( number ).toBe( 2 );
Object.defineProperty( state, 'number', {
get: () => state.otherNumber,
configurable: true,
} );
expect( number ).toBe( 3 );
state.otherNumber = 4;
expect( number ).toBe( 4 );
Object.defineProperty( state, 'otherNumber', {
get: () => 5,
configurable: true,
} );
expect( number ).toBe( 5 );
} );
it( 'should allow getters to use `this`', () => {
const state = proxifyState( 'test', {
number: 1,
otherNumber: 3,
} );
let number = 0;
effect( () => {
number = state.number;
} );
expect( number ).toBe( 1 );
state.number = 2;
expect( number ).toBe( 2 );
Object.defineProperty( state, 'number', {
get() {
return this.otherNumber;
},
configurable: true,
} );
expect( number ).toBe( 3 );
state.otherNumber = 4;
expect( number ).toBe( 4 );
} );
it( 'should support different scopes for the same getter', () => {
const state = proxifyState( 'test', {
number: 1,
get numWithTag() {
let tag = 'No scope';
try {
tag = getContext< any >().tag;
} catch ( e ) {}
return `${ tag }: ${ this.number }`;
},
} );
const scopeA = {
context: { test: { tag: 'A' } },
};
const scopeB = {
context: { test: { tag: 'B' } },
};
let resultA = '';
let resultB = '';
let resultNoScope = '';
effect(
withScopeAndNs( scopeA, 'test', () => {
resultA = state.numWithTag;
} )
);
effect(
withScopeAndNs( scopeB, 'test', () => {
resultB = state.numWithTag;
} )
);
effect( () => {
resultNoScope = state.numWithTag;
} );
expect( resultA ).toBe( 'A: 1' );
expect( resultB ).toBe( 'B: 1' );
expect( resultNoScope ).toBe( 'No scope: 1' );
state.number = 2;
expect( resultA ).toBe( 'A: 2' );
expect( resultB ).toBe( 'B: 2' );
expect( resultNoScope ).toBe( 'No scope: 2' );
} );
it( 'should throw an error in getters that require a scope', () => {
const state = proxifyState( 'test', {
number: 1,
get sumValueFromContext() {
const ctx = getContext();
return ctx
? this.number + ( ctx as any ).value
: this.number;
},
get sumValueFromElement() {
const element = getElement();
return element
? this.number + element.attributes.value
: this.number;
},
} );
expect( () => state.sumValueFromContext ).toThrow();
expect( () => state.sumValueFromElement ).toThrow();
} );
it( 'should react to changes in props inside functions', () => {
const state = proxifyState( 'test', {
number: 1,
otherNumber: 3,
sum( value: number ) {
return state.number + state.otherNumber + value;
},
} );
let result = 0;
effect( () => {
result = state.sum( 2 );
} );
expect( result ).toBe( 6 );
state.number = 2;
expect( result ).toBe( 7 );
state.otherNumber = 4;
expect( result ).toBe( 8 );
} );
} );
describe( 'peek', () => {
it( 'should return correct values when using peek()', () => {
expect( peek( state, 'a' ) ).toBe( 1 );
expect( peek( state.nested, 'b' ) ).toBe( 2 );
expect( peek( state.array, 0 ) ).toBe( 3 );
const nested = peek( state, 'array' )[ 1 ];
expect( typeof nested === 'object' && nested.b ).toBe( 2 );
expect( peek( state.array, 'length' ) ).toBe( 2 );
} );
it( 'should not subscribe to changes when peeking', () => {
const spy1 = jest.fn( () => peek( state, 'a' ) );
const spy2 = jest.fn( () => peek( state, 'nested' ) );
const spy3 = jest.fn( () => peek( state, 'nested' ).b );
const spy4 = jest.fn( () => peek( state, 'array' )[ 0 ] );
const spy5 = jest.fn( () => {
const nested = peek( state, 'array' )[ 1 ];
return typeof nested === 'object' && nested.b;
} );
const spy6 = jest.fn( () => peek( state, 'array' ).length );
effect( spy1 );
effect( spy2 );
effect( spy3 );
effect( spy4 );
effect( spy5 );
effect( spy6 );
expect( spy1 ).toHaveBeenCalledTimes( 1 );
expect( spy2 ).toHaveBeenCalledTimes( 1 );
expect( spy3 ).toHaveBeenCalledTimes( 1 );
expect( spy4 ).toHaveBeenCalledTimes( 1 );
expect( spy5 ).toHaveBeenCalledTimes( 1 );
expect( spy6 ).toHaveBeenCalledTimes( 1 );
state.a = 11;
state.nested.b = 22;
state.nested = { b: 222 };
state.array[ 0 ] = 33;
if ( typeof state.array[ 1 ] === 'object' ) {
state.array[ 1 ].b = 2222;
}
state.array.push( 4 );
expect( spy1 ).toHaveBeenCalledTimes( 1 );
expect( spy2 ).toHaveBeenCalledTimes( 1 );
expect( spy3 ).toHaveBeenCalledTimes( 1 );
expect( spy4 ).toHaveBeenCalledTimes( 1 );
expect( spy5 ).toHaveBeenCalledTimes( 1 );
expect( spy6 ).toHaveBeenCalledTimes( 1 );
} );
it( 'should subscribe to some changes but not other when peeking inside an object', () => {
const spy1 = jest.fn( () => peek( state.nested, 'b' ) );
effect( spy1 );
expect( spy1 ).toHaveBeenCalledTimes( 1 );
state.nested.b = 22;
expect( spy1 ).toHaveBeenCalledTimes( 1 );
state.nested = { b: 222 };
expect( spy1 ).toHaveBeenCalledTimes( 2 );
state.nested.b = 2222;
expect( spy1 ).toHaveBeenCalledTimes( 2 );
} );
it( 'should support returning peek from getters', () => {
const state = proxifyState( 'test', {
counter: 1,
get double() {
return state.counter * 2;
},
} );
expect( peek( state, 'double' ) ).toBe( 2 );
state.counter = 2;
expect( peek( state, 'double' ) ).toBe( 4 );
} );
it( 'should support peeking getters accessing the scope', () => {
const state = proxifyState( 'test', {
get double() {
const { counter } = getContext< { counter: number } >();
return counter * 2;
},
} );
const context = proxifyState( 'test', { counter: 1 } );
const scope = { context: { test: context } };
const peekStateDouble = withScopeAndNs( scope, 'test', () =>
peek( state, 'double' )
);
const spy = jest.fn( peekStateDouble );
effect( spy );
expect( spy ).toHaveBeenCalledTimes( 1 );
expect( peekStateDouble() ).toBe( 2 );
context.counter = 2;
expect( spy ).toHaveBeenCalledTimes( 1 );
expect( peekStateDouble() ).toBe( 4 );
} );
it( 'should support peeking getters accessing other namespaces', () => {
const state2 = proxifyState( 'test2', {
get counter() {
const { counter } = getContext< { counter: number } >();
return counter;
},
} );
const context2 = proxifyState( 'test-2', { counter: 1 } );
const state1 = proxifyState( 'test1', {
get double() {
return state2.counter * 2;
},
} );
const peekStateDouble = withScopeAndNs(
{ context: { test2: context2 } },
'test2',
() => peek( state1, 'double' )
);
const spy = jest.fn( peekStateDouble );
effect( spy );
expect( spy ).toHaveBeenCalledTimes( 1 );
expect( peekStateDouble() ).toBe( 2 );
context2.counter = 2;
expect( spy ).toHaveBeenCalledTimes( 1 );
expect( peekStateDouble() ).toBe( 4 );
} );
} );
describe( 'refs', () => {
it( 'should preserve object references', () => {
expect( state.nested ).toBe( state.array[ 1 ] );
state.nested.b = 22;
expect( state.nested ).toBe( state.array[ 1 ] );
expect( state.nested.b ).toBe( 22 );
expect(
typeof state.array[ 1 ] === 'object' && state.array[ 1 ].b
).toBe( 22 );
state.nested = { b: 222 };
expect( state.nested ).not.toBe( state.array[ 1 ] );
expect( state.nested.b ).toBe( 222 );
expect(
typeof state.array[ 1 ] === 'object' && state.array[ 1 ].b
).toBe( 22 );
} );
it( 'should return the same proxy if initialized more than once', () => {
const raw = {};
const state1 = proxifyState( 'test', raw );
const state2 = proxifyState( 'test', raw );
expect( state1 ).toBe( state2 );
} );
it( 'should throw when trying to re-proxify a state object', () => {
const state = proxifyState( 'test', {} );
expect( () => proxifyState( 'test', state ) ).toThrow();
} );
} );
describe( 'unsupported data structures', () => {
it( 'should throw when trying to proxify a class instance', () => {
class MyClass {}
const obj = new MyClass();
expect( () => proxifyState( 'test', obj ) ).toThrow();
} );
it( 'should not wrap a class instance', () => {
class MyClass {}
const obj = new MyClass();
const state = proxifyState( 'test', { obj } );
expect( state.obj ).toBe( obj );
} );
it( 'should not wrap built-ins in proxies', () => {
window.MyClass = class MyClass {};
const obj = new window.MyClass();
const state = proxifyState( 'test', { obj } );
expect( state.obj ).toBe( obj );
} );
it( 'should not wrap elements in proxies', () => {
const el = window.document.createElement( 'div' );
const state = proxifyState( 'test', { el } );
expect( state.el ).toBe( el );
} );
it( 'should wrap global objects', () => {
window.obj = { b: 2 };
const state = proxifyState( 'test', window.obj );
expect( state ).not.toBe( window.obj );
expect( state ).toStrictEqual( { b: 2 } );
} );
it( 'should not wrap dates', () => {
const date = new Date();
const state = proxifyState( 'test', { date } );
expect( state.date ).toBe( date );
} );
it( 'should not wrap regular expressions', () => {
const regex = new RegExp( '' );
const state = proxifyState( 'test', { regex } );
expect( state.regex ).toBe( regex );
} );
it( 'should not wrap Map', () => {
const map = new Map();
const state = proxifyState( 'test', { map } );
expect( state.map ).toBe( map );
} );
it( 'should not wrap Set', () => {
const set = new Set();
const state = proxifyState( 'test', { set } );
expect( state.set ).toBe( set );
} );
} );
describe( 'symbols', () => {
it( 'should observe symbols', () => {
const key = Symbol( 'key' );
let x;
const store = proxifyState< { [ key: symbol ]: any } >(
'test',
{}
);
effect( () => ( x = store[ key ] ) );
expect( store[ key ] ).toBe( undefined );
expect( x ).toBe( undefined );
store[ key ] = true;
expect( store[ key ] ).toBe( true );
expect( x ).toBe( true );
} );
it( 'should not observe well-known symbols', () => {
const key = Symbol.isConcatSpreadable;
let x;
const state = proxifyState< { [ key: symbol ]: any } >(
'test',
{}
);
effect( () => ( x = state[ key ] ) );
expect( state[ key ] ).toBe( undefined );
expect( x ).toBe( undefined );
state[ key ] = true;
expect( state[ key ] ).toBe( true );
expect( x ).toBe( undefined );
} );
} );
} );
} );