diff --git a/src/index.ts b/src/index.ts index 6f2066e..0bd33b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ export { batch } from './internal/batch'; export { equal } from './internal/equal'; export { symbolObservable } from './internal/exposeRawStores'; export { untrack } from './internal/untrack'; +export { proxyStore } from './internal/proxy'; export type * from './types'; /** diff --git a/src/internal/proxy.ts b/src/internal/proxy.ts new file mode 100644 index 0000000..3649ec5 --- /dev/null +++ b/src/internal/proxy.ts @@ -0,0 +1,229 @@ +import { batch } from './batch'; +import { RawStoreComputed } from './storeComputed'; +import { RawStoreWritable } from './storeWritable'; +import { activeConsumer } from './untrack'; + +const returnFalse = () => false; +const arrayEquals = (a: T[], b: T[]) => { + const aLength = a.length; + if (aLength !== b.length) { + return false; + } + for (let i = 0; i < aLength; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +}; + +const objectProto = Object.prototype; +const arrayProto = Array.prototype; + +class TansuArrayStore extends Array {} +class TansuObjectStore {} + +const wrapInBatch = (originalFn: (this: T, ...args: U) => V) => + function (this: T, ...args: U) { + return batch(() => originalFn.call(this, ...args)); + }; + +const tansuArrayProto = TansuArrayStore.prototype; +for (const fnName of ['fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift']) { + const fn = (arrayProto as any)[fnName]; + if (typeof fn === 'function') { + (tansuArrayProto as any)[fnName] = wrapInBatch(fn); + } +} + +type KeyInfo = { + store: RawStoreWritable; + existsStore: RawStoreWritable; + config: PropertyDescriptor; +}; + +const createProxyStore = (initialValue: T): T => { + const isArray = Array.isArray(initialValue); + const constructor: any = isArray ? TansuArrayStore : TansuObjectStore; + const proto = constructor.prototype; + const proxyTarget = new constructor(); + + for (const key of Reflect.ownKeys(initialValue)) { + proxyTarget[key] = proxyStore((initialValue as any)[key]); + } + + const proxyTarget$ = new RawStoreWritable(proxyTarget); + proxyTarget$.equalFn = returnFalse; + const keysInfo: Record>> = Object.create(null); + let length: RawStoreComputed | undefined; + let ownKeys: RawStoreComputed<(string | symbol)[]> | undefined; + + const getKeyInfo = (key: string | symbol) => { + let keyInfo = keysInfo[key]; + if (!keyInfo) { + keyInfo = {}; + keysInfo[key] = keyInfo; + } + return keyInfo; + }; + + const getKeyInfoWithStore = (key: string | symbol) => { + const keyInfo: Partial = getKeyInfo(key); + let store = keyInfo.store; + if (!store) { + store = new RawStoreWritable(proxyTarget[key]); + store.equalFn = Object.is; + keyInfo.store = store; + } + return keyInfo as Pick & Partial; + }; + + const removeKey = (key: string | symbol) => + batch(() => { + if (Object.hasOwn(proxyTarget, key)) { + delete proxyTarget[key]; + proxyTarget$.set(proxyTarget); + } + const keyInfo = keysInfo[key]; + keyInfo?.store?.set(undefined); + keyInfo?.existsStore?.set(false); + }); + + const reactiveExists = (key: string | symbol, keyInfo = getKeyInfo(key)) => { + let existsStore = keyInfo.existsStore; + if (!existsStore) { + existsStore = new RawStoreWritable(Object.hasOwn(proxyTarget, key)); + keyInfo.existsStore = existsStore; + } + return existsStore.get(); + }; + + const setValue = (key: string | symbol, value: unknown) => + batch(() => { + value = proxyStore(value); + const existsValue = Object.hasOwn(proxyTarget, key); + proxyTarget[key] = value; + const keyInfo = keysInfo[key]; + if (!existsValue) { + proxyTarget$.set(proxyTarget); + keyInfo?.existsStore?.set(true); + } + keyInfo?.store?.set(value); + }); + + const getValue = (key: string | symbol) => { + if (!activeConsumer) { + // quick path + return proxyTarget[key]; + } + if (isArray && key === 'length') { + if (!length) { + length = new RawStoreComputed(() => proxyTarget$.get().length); + } + return length.get(); + } + return getKeyInfoWithStore(key).store.get() ?? proto[key]; + }; + + return new Proxy(proxyTarget as T, { + get(target, key) { + return getValue(key); + }, + set(target, key, value) { + if (isArray && key === 'length') { + batch(() => { + const prevLength = proxyTarget.length; + proxyTarget.length = value; + const newLength = proxyTarget.length; + if (newLength !== prevLength) { + proxyTarget$.set(proxyTarget); + } + for (let i = newLength; i < prevLength; i++) { + removeKey(`${i}`); + } + }); + return true; + } + setValue(key, value); + return true; + }, + deleteProperty(target, key) { + if (isArray && key === 'length') { + return false; + } + removeKey(key); + return true; + }, + has(target, key) { + if (!activeConsumer) { + // quick path + return key in proxyTarget; + } + return reactiveExists(key) || key in proto; + }, + ownKeys() { + if (!ownKeys) { + ownKeys = new RawStoreComputed(() => Reflect.ownKeys(proxyTarget$.get())); + ownKeys.equalFn = arrayEquals; + } + return [...ownKeys.get()]; + }, + getOwnPropertyDescriptor(target, key) { + if (!activeConsumer || (isArray && key === 'length')) { + return Reflect.getOwnPropertyDescriptor(proxyTarget, key); + } + const keyInfo = getKeyInfo(key); + if (!reactiveExists(key, keyInfo)) { + return undefined; + } + let config = keyInfo.config; + if (!keyInfo.config) { + config = { + configurable: true, + enumerable: true, + get: getValue.bind(null, key), + set: setValue.bind(null, key), + }; + keyInfo.config = config; + } + return config; + }, + // unsupported features: + preventExtensions() { + return false; + }, + defineProperty() { + return false; + }, + setPrototypeOf() { + return false; + }, + }); +}; + +/** + * Create a {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy|Proxy} object + * which recursively implements each of its properties as a Tansu store. + * + * @example + * ```ts + * const myStore = proxyStore({ a: 1, b: 2 }); + * const aTimesB = computed(() => myStore.a * myStore.b); + * aTimesB.subscribe((value) => console.log(value)); // logs: 2 + * myStore.a = 2; // logs: 4 + * myStore.b = 3; // logs: 6 + * ``` + * + * @param initialValue - The initial value of the object. It is copied and wrapped in Tansu stores. + * @returns The proxy store object. + */ +export const proxyStore = (initialValue: T): T => { + if (typeof initialValue !== 'object' || initialValue == null) { + return initialValue; + } + const proto = Object.getPrototypeOf(initialValue); + if (proto === objectProto || proto === arrayProto) { + return createProxyStore(initialValue); + } + return initialValue; +}; diff --git a/src/proxy.spec.ts b/src/proxy.spec.ts new file mode 100644 index 0000000..5a08bfc --- /dev/null +++ b/src/proxy.spec.ts @@ -0,0 +1,311 @@ +import { describe, expect, it, vi } from 'vitest'; +import { batch, computed, proxyStore } from './index'; + +describe('objects', () => { + it('should not wrap simple values', () => { + expect(proxyStore(1)).toBe(1); + expect(proxyStore(NaN)).toBe(NaN); + expect(proxyStore('a')).toBe('a'); + expect(proxyStore(true)).toBe(true); + expect(proxyStore(false)).toBe(false); + expect(proxyStore(null)).toBe(null); + expect(proxyStore(undefined)).toBe(undefined); + }); + + it('should not wrap custom objects', () => { + class MyObject {} + const myObject = new MyObject(); + expect(proxyStore(myObject)).toBe(myObject); + }); + + it('should work in a non-reactive context', () => { + const myStore = proxyStore({} as { value?: number }); + expect('value' in myStore).toBe(false); + expect(myStore.value).toBe(undefined); + expect(Object.keys(myStore)).toEqual([]); + expect(Object.hasOwn(myStore, 'value')).toBe(false); + myStore.value = 1; + expect(myStore.value).toBe(1); + expect('value' in myStore).toBe(true); + expect(Object.keys(myStore)).toEqual(['value']); + expect(Object.hasOwn(myStore, 'value')).toBe(true); + myStore.value = 2; + expect(myStore.value).toBe(2); + delete myStore.value; + expect(myStore.value).toBe(undefined); + expect('value' in myStore).toBe(false); + expect(Object.keys(myStore)).toEqual([]); + expect(Object.hasOwn(myStore, 'value')).toBe(false); + delete myStore.value; // does nothing + expect(myStore.value).toBe(undefined); + }); + + it('should be reactive when adding and removing a property', () => { + const values = [] as number[]; + const myStore = proxyStore({} as any); + const value = computed(() => myStore.value); + const unsubscribe = value.subscribe((value) => values.push(value)); + expect(values).toEqual([undefined]); + myStore.value = 1; + expect(values).toEqual([undefined, 1]); + delete myStore.value; + expect(values).toEqual([undefined, 1, undefined]); + myStore.value = 2; + expect(values).toEqual([undefined, 1, undefined, 2]); + unsubscribe(); + }); + + it('should be reactive with Object.keys', () => { + const keysUpdates = [] as string[][]; + const myStore = proxyStore({} as any); + const keys = computed(() => Object.keys(myStore)); + const unsubscribe = keys.subscribe((value) => keysUpdates.push(value)); + expect(keysUpdates).toEqual([[]]); + myStore.value = 1; + expect(keysUpdates).toEqual([[], ['value']]); + myStore.value = 2; + expect(keysUpdates).toEqual([[], ['value']]); + myStore.newProp = 3; + expect(keysUpdates).toEqual([[], ['value'], ['value', 'newProp']]); + delete myStore.value; + expect(keysUpdates).toEqual([[], ['value'], ['value', 'newProp'], ['newProp']]); + myStore.value = 3; + expect(keysUpdates).toEqual([ + [], + ['value'], + ['value', 'newProp'], + ['newProp'], + ['newProp', 'value'], + ]); + unsubscribe(); + }); + + it('should be reactive with Object.hasOwn', () => { + const myStore = proxyStore({} as any); + const c = computed(() => Object.hasOwn(myStore, 'value')); + const values = [] as boolean[]; + const unsubscribe = c.subscribe((value) => values.push(value)); + expect(values).toEqual([false]); + myStore.value = 1; + expect(values).toEqual([false, true]); + delete myStore.value; + expect(values).toEqual([false, true, false]); + myStore.value = 1; + expect(values).toEqual([false, true, false, true]); + unsubscribe(); + }); + + it('should be reactive with "in" operator', () => { + const myStore = proxyStore({} as any); + const c = computed(() => 'value' in myStore); + const values = [] as boolean[]; + const unsubscribe = c.subscribe((value) => values.push(value)); + expect(values).toEqual([false]); + myStore.value = 1; + expect(values).toEqual([false, true]); + delete myStore.value; + expect(values).toEqual([false, true, false]); + myStore.value = 1; + expect(values).toEqual([false, true, false, true]); + unsubscribe(); + }); + + it('should fail with Object.preventExtensions', () => { + const myStore = proxyStore({ value: 1 } as any); + expect(() => { + Object.preventExtensions(myStore); + }).toThrow(); + }); + + it('should have a prototype that cannot be replaced', () => { + const myStore = proxyStore({} as any); + const correctProto = Object.getPrototypeOf(myStore); + const newProto = { hello: 1 }; + expect(() => { + Object.setPrototypeOf(myStore, newProto); + }).toThrow(); + expect(myStore.hello).toBe(undefined); + expect(Object.getPrototypeOf(myStore)).toBe(correctProto); + }); + + it('should not allow defining properties', () => { + const myStore = proxyStore({} as any); + expect(() => { + Object.defineProperty(myStore, 'value', { value: 1 }); + }).toThrow(); + }); + + it('should not wrap proxyStore objects', () => { + const initialValue = {}; + const myStore1 = proxyStore(initialValue); + const myStore2 = proxyStore(myStore1); + expect(myStore1).toBe(myStore2); + }); + + it('should copy the initial value', () => { + const initialValue = { a: 1 }; + const myStore = proxyStore(initialValue); + initialValue.a = 2; + expect(myStore.a).toBe(1); + }); + + it('should wrap sub-objects on initialization', () => { + const myStore = proxyStore({ a: { b: 1 } }); + const c = computed(() => myStore.a.b); + const values = [] as number[]; + const unsubscribe = c.subscribe((value) => values.push(value)); + expect(values).toEqual([1]); + myStore.a.b = 2; + expect(values).toEqual([1, 2]); + unsubscribe(); + }); + + it('should wrap sub-objects on set', () => { + const myStore = proxyStore({ a: { b: 0 } }); + myStore.a = { b: 1 }; + const c = computed(() => myStore.a.b); + const values = [] as number[]; + const unsubscribe = c.subscribe((value) => values.push(value)); + expect(values).toEqual([1]); + myStore.a.b = 2; + expect(values).toEqual([1, 2]); + myStore.a = { b: 3 }; + expect(values).toEqual([1, 2, 3]); + unsubscribe(); + }); + + it('should work with batch', () => { + const person = proxyStore({ + firstName: 'Arsène', + lastName: 'Lupin', + }); + const fullName = computed(() => `${person.firstName} ${person.lastName}`); + const values: string[] = []; + const unsubscribe = fullName.subscribe((value) => values.push(value)); + batch(() => { + person.firstName = 'Sherlock'; + person.lastName = 'Holmes'; + }); + expect(values).toEqual(['Arsène Lupin', 'Sherlock Holmes']); + unsubscribe(); + }); + + it('should not recompute when unrelated properties change', () => { + const myState = proxyStore({ foo: [{ bar: 0 }, { bar: 1 }], y: 0 }); + const computeFn = vi.fn(() => myState.foo[0].bar + 2); + const c = computed(computeFn); + const values = [] as number[]; + const unsubscribe = c.subscribe((value) => values.push(value)); + expect(values).toEqual([2]); + expect(computeFn).toHaveBeenCalledOnce(); + computeFn.mockClear(); + myState.foo[1].bar = 2; + expect(computeFn).not.toHaveBeenCalled(); + expect(values).toEqual([2]); + myState.foo[0].bar = 3; + expect(values).toEqual([2, 5]); + expect(computeFn).toHaveBeenCalledOnce(); + computeFn.mockClear(); + const previousFoo0 = myState.foo[0]; + myState.foo[0] = { bar: 4 }; + expect(values).toEqual([2, 5, 6]); + expect(computeFn).toHaveBeenCalledOnce(); + computeFn.mockClear(); + previousFoo0.bar = 5; + myState.foo.push({ bar: 10 }); + myState.foo.length = 1; + myState.y = 1; + expect(values).toEqual([2, 5, 6]); + expect(computeFn).not.toHaveBeenCalled(); + unsubscribe(); + }); +}); + +describe('arrays', () => { + it('should wrap arrays', () => { + const myStore = proxyStore([{ a: 1 }]); + expect(Array.isArray(myStore)).toBe(true); + }); + + it('should not wrap arrays with a custom prototype', () => { + class MyCustomArray extends Array {} + const myArray = new MyCustomArray(); + const myStore = proxyStore(myArray); + expect(myStore).toBe(myArray); + }); + + it('should work with push/pop', () => { + const a = proxyStore([0, 1]); + const length = computed(() => a.length); + const lengthValues = [] as number[]; + const unsubscribeLength = length.subscribe((value) => lengthValues.push(value)); + const a2 = computed(() => a[2]); + const a2Values = [] as number[]; + const unsubscribeA2 = a2.subscribe((value) => a2Values.push(value)); + expect(a2Values).toEqual([undefined]); + expect(lengthValues).toEqual([2]); + a.push(1); + expect(a2Values).toEqual([undefined, 1]); + expect(lengthValues).toEqual([2, 3]); + a.push(2); + expect(lengthValues).toEqual([2, 3, 4]); + expect(a.pop()).toBe(2); + expect(lengthValues).toEqual([2, 3, 4, 3]); + expect(a.pop()).toBe(1); + expect(lengthValues).toEqual([2, 3, 4, 3, 2]); + expect(a2Values).toEqual([undefined, 1, undefined]); + unsubscribeLength(); + unsubscribeA2(); + }); + + it('should work when setting length', () => { + const a = proxyStore([0, 1]); + const length = computed(() => a.length); + const lengthValues = [] as number[]; + const unsubscribeLength = length.subscribe((value) => lengthValues.push(value)); + const a2 = computed(() => a[2]); + const a2Values = [] as number[]; + const unsubscribeA2 = a2.subscribe((value) => a2Values.push(value)); + expect(a2Values).toEqual([undefined]); + expect(lengthValues).toEqual([2]); + a[2] = 1; + expect(a2Values).toEqual([undefined, 1]); + expect(lengthValues).toEqual([2, 3]); + a.length = 4; + expect(lengthValues).toEqual([2, 3, 4]); + a.length = 3; + expect(lengthValues).toEqual([2, 3, 4, 3]); + a.length = 2; + expect(lengthValues).toEqual([2, 3, 4, 3, 2]); + expect(a2Values).toEqual([undefined, 1, undefined]); + unsubscribeLength(); + unsubscribeA2(); + }); + + it('should fail to delete the length property', () => { + const a = proxyStore([0, 1]); + expect(() => { + delete (a as any).length; + }).toThrow(); + }); + + it('should work with Object.keys', () => { + const a = proxyStore([2, 3]); + const keys = computed(() => Object.keys(a)); + const keysValues = [] as string[][]; + const unsubscribeKeys = keys.subscribe((value) => keysValues.push(value)); + expect(keysValues).toEqual([['0', '1']]); + a.unshift(4); + expect(keysValues).toHaveLength(2); + expect(keysValues[1]).toEqual(['0', '1', '2']); + expect(a.pop()).toBe(3); + expect(keysValues).toHaveLength(3); + expect(keysValues[2]).toEqual(['0', '1']); + a.length = 6; + expect(keysValues).toHaveLength(3); + unsubscribeKeys(); + delete a[0]; + a[5] = 9; + expect(keys()).toEqual(['1', '5']); + }); +});