diff --git a/src/vanilla/utils/proxyMap.ts b/src/vanilla/utils/proxyMap.ts index 6fc3a9c0..40950b02 100644 --- a/src/vanilla/utils/proxyMap.ts +++ b/src/vanilla/utils/proxyMap.ts @@ -1,111 +1,149 @@ -import { proxy } from '../../vanilla.ts' +import { proxy, unstable_getInternalStates } from '../../vanilla.ts' -type KeyValRecord = [key: K, value: V] +const { proxyStateMap, snapCache } = unstable_getInternalStates() +const isProxy = (x: any) => proxyStateMap.has(x) -type InternalProxyMap = Map & { - data: KeyValRecord[] - toJSON: object +type InternalProxyObject = Map & { + data: Array + index: number + toJSON: () => Map } -/** - * proxyMap - * - * This is to create a proxy which mimic the native Map behavior. - * The API is the same as Map API - * - * @example - * import { proxyMap } from 'valtio/utils' - * const state = proxyMap([["key", "value"]]) - * - * //can be used inside a proxy as well - * const state = proxy({ - * count: 1, - * map: proxyMap() - * }) - * - * // When using an object as a key, you can wrap it with `ref` so it's not proxied - * // this is useful if you want to preserve the key equality - * import { ref } from 'valtio' - * - * const key = ref({}) - * state.set(key, "value") - * state.get(key) //value - * - * const key = {} - * state.set(key, "value") - * state.get(key) //undefined - */ -export function proxyMap(entries?: Iterable | null) { - const map: InternalProxyMap = proxy({ - data: Array.from(entries || []) as KeyValRecord[], - has(key) { - return this.data.some((p) => p[0] === key) +export function proxyMap(entries?: Iterable<[K, V]> | undefined | null) { + const initialData: Array = [] + let initialIndex = 0 + const indexMap = new Map() + + const snapMapCache = new WeakMap>() + const registerSnapMap = () => { + const cache = snapCache.get(vObject) + const latestSnap = cache?.[1] + if (latestSnap && !snapMapCache.has(latestSnap)) { + const clonedMap = new Map(indexMap) + snapMapCache.set(latestSnap, clonedMap) + } + } + const getMapForThis = (x: any) => snapMapCache.get(x) || indexMap + + if (entries) { + if (typeof entries[Symbol.iterator] !== 'function') { + throw new TypeError( + 'proxyMap:\n\tinitial state must be iterable\n\t\ttip: structure should be [[key, value]]', + ) + } + for (const [key, value] of entries) { + indexMap.set(key, initialIndex) + initialData[initialIndex++] = value + } + } + + const vObject: InternalProxyObject = { + data: initialData, + index: initialIndex, + get size() { + if (!isProxy(this)) { + registerSnapMap() + } + const map = getMapForThis(this) + return map.size + }, + get(key: K) { + const map = getMapForThis(this) + const index = map.get(key) + if (index === undefined) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this.index // touch property for tracking + return undefined + } + return this.data[index] + }, + has(key: K) { + const map = getMapForThis(this) + const exists = map.has(key) + if (!exists) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this.index // touch property for tracking + } + return exists }, - set(key, value) { - const record = this.data.find((p) => p[0] === key) - if (record) { - record[1] = value + set(key: K, value: V) { + if (!isProxy(this)) { + throw new Error('Cannot perform mutations on a snapshot') + } + const index = indexMap.get(key) + if (index === undefined) { + indexMap.set(key, this.index) + this.data[this.index++] = value } else { - this.data.push([key, value]) + this.data[index] = value } return this }, - get(key) { - return this.data.find((p) => p[0] === key)?.[1] - }, - delete(key) { - const index = this.data.findIndex((p) => p[0] === key) - if (index === -1) { + delete(key: K) { + if (!isProxy(this)) { + throw new Error('Cannot perform mutations on a snapshot') + } + const index = indexMap.get(key) + if (index === undefined) { return false } - this.data.splice(index, 1) + delete this.data[index] + indexMap.delete(key) return true }, clear() { - this.data.splice(0) - }, - get size() { - return this.data.length - }, - toJSON() { - return new Map(this.data) - }, - forEach(cb) { - this.data.forEach((p) => { - cb(p[1], p[0], this) + if (!isProxy(this)) { + throw new Error('Cannot perform mutations on a snapshot') + } + this.data.length = 0 // empty array + this.index = 0 + indexMap.clear() + }, + forEach(cb: (value: V, key: K, map: Map) => void) { + const map = getMapForThis(this) + map.forEach((index, key) => { + cb(this.data[index]!, key, this) }) }, - keys() { - return this.data.map((p) => p[0]).values() - }, - values() { - return this.data.map((p) => p[1]).values() + *entries(): MapIterator<[K, V]> { + const map = getMapForThis(this) + for (const [key, index] of map) { + yield [key, this.data[index]!] + } }, - entries() { - return new Map(this.data).entries() + *keys(): IterableIterator { + const map = getMapForThis(this) + for (const key of map.keys()) { + yield key + } }, - get [Symbol.toStringTag]() { - return 'Map' + *values(): IterableIterator { + const map = getMapForThis(this) + for (const index of map.values()) { + yield this.data[index]! + } }, [Symbol.iterator]() { return this.entries() }, - }) - - Object.defineProperties(map, { - data: { - enumerable: false, - }, - size: { - enumerable: false, + get [Symbol.toStringTag]() { + return 'Map' }, - toJSON: { - enumerable: false, + toJSON(): Map { + return new Map(this.entries()) }, + } + + const proxiedObject = proxy(vObject) + Object.defineProperties(proxiedObject, { + size: { enumerable: false }, + index: { enumerable: false }, + data: { enumerable: false }, + toJSON: { enumerable: false }, }) - Object.seal(map) + Object.seal(proxiedObject) - return map as unknown as Map & { + return proxiedObject as unknown as Map & { $$valtioSnapshot: Omit, 'set' | 'delete' | 'clear'> } } diff --git a/src/vanilla/utils/proxySet.ts b/src/vanilla/utils/proxySet.ts index 575d218c..7ba52119 100644 --- a/src/vanilla/utils/proxySet.ts +++ b/src/vanilla/utils/proxySet.ts @@ -1,98 +1,195 @@ -import { proxy } from '../../vanilla.ts' +import { proxy, unstable_getInternalStates } from '../../vanilla.ts' + +const { proxyStateMap, snapCache } = unstable_getInternalStates() +const maybeProxify = (x: any) => (typeof x === 'object' ? proxy({ x }).x : x) +const isProxy = (x: any) => proxyStateMap.has(x) -// properties that we don't want to expose to the end-user type InternalProxySet = Set & { data: T[] toJSON: object + index: number + intersection: (other: Set) => Set + isDisjointFrom: (other: Set) => boolean + isSubsetOf: (other: Set) => boolean + isSupersetOf: (other: Set) => boolean + symmetricDifference: (other: Set) => Set + union: (other: Set) => Set } -/** - * proxySet - * - * This is to create a proxy which mimic the native Set behavior. - * The API is the same as Set API - * - * @example - * import { proxySet } from 'valtio/utils' - * const state = proxySet([1,2,3]) - * //can be used inside a proxy as well - * const state = proxy({ - * count: 1, - * set: proxySet() - * }) - */ export function proxySet(initialValues?: Iterable | null) { - const set: InternalProxySet = proxy({ - data: Array.from(new Set(initialValues)), - has(value) { - return this.data.indexOf(value) !== -1 - }, - add(value) { - let hasProxy = false - if (typeof value === 'object' && value !== null) { - hasProxy = this.data.indexOf(proxy(value as T & object)) !== -1 + const initialData: T[] = [] + const indexMap = new Map() + let initialIndex = 0 + + const snapMapCache = new WeakMap>() + const registerSnapMap = () => { + const cache = snapCache.get(vObject) + const latestSnap = cache?.[1] + if (latestSnap && !snapMapCache.has(latestSnap)) { + const clonedMap = new Map(indexMap) + snapMapCache.set(latestSnap, clonedMap) + } + } + const getMapForThis = (x: any) => snapMapCache.get(x) || indexMap + + if (initialValues) { + if (typeof initialValues[Symbol.iterator] !== 'function') { + throw new TypeError('not iterable') + } + for (const value of initialValues) { + if (!indexMap.has(value)) { + const v = maybeProxify(value) + indexMap.set(v, initialIndex) + initialData[initialIndex++] = v } - if (this.data.indexOf(value) === -1 && !hasProxy) { - this.data.push(value) + } + } + + const vObject: InternalProxySet = { + data: initialData, + index: initialIndex, + get size() { + if (!isProxy(this)) { + registerSnapMap() + } + return indexMap.size + }, + has(value: T) { + const map = getMapForThis(this) + const v = maybeProxify(value) + const exists = map.has(v) + if (!exists) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this.index // touch property for tracking + } + return exists + }, + add(value: T) { + if (!isProxy(this)) { + throw new Error('Cannot perform mutations on a snapshot') + } + const v = maybeProxify(value) + if (!indexMap.has(v)) { + indexMap.set(v, this.index) + this.data[this.index++] = v } return this }, - delete(value) { - const index = this.data.indexOf(value) - if (index === -1) { + delete(value: T) { + if (!isProxy(this)) { + throw new Error('Cannot perform mutations on a snapshot') + } + const v = maybeProxify(value) + const index = indexMap.get(v) + if (index === undefined) { return false } - this.data.splice(index, 1) + delete this.data[index] + indexMap.delete(v) return true }, clear() { - this.data.splice(0) - }, - get size() { - return this.data.length + if (!isProxy(this)) { + throw new Error('Cannot perform mutations on a snapshot') + } + this.data.length = 0 // empty array + this.index = 0 + indexMap.clear() }, forEach(cb) { - this.data.forEach((value) => { - cb(value, value, this) + const map = getMapForThis(this) + map.forEach((index) => { + cb(this.data[index]!, this.data[index]!, this) }) }, - get [Symbol.toStringTag]() { - return 'Set' + *values(): IterableIterator { + const map = getMapForThis(this) + for (const index of map.values()) { + yield this.data[index]! + } + }, + keys(): IterableIterator { + return this.values() + }, + *entries(): IterableIterator<[T, T]> { + const map = getMapForThis(this) + for (const index of map.values()) { + const value = this.data[index]! + yield [value, value] + } }, - toJSON() { - return new Set(this.data) + toJSON(): Set { + return new Set(this.values()) }, [Symbol.iterator]() { - return this.data[Symbol.iterator]() + return this.values() }, - values() { - return this.data.values() + get [Symbol.toStringTag]() { + return 'Set' }, - keys() { - // for Set.keys is an alias for Set.values() - return this.data.values() + intersection(other: Set): Set { + const otherSet = proxySet(other) + const resultSet = proxySet() + for (const value of this.values()) { + if (otherSet.has(value)) { + resultSet.add(value) + } + } + return proxySet(resultSet) }, - entries() { - // array.entries returns [index, value] while Set [value, value] - return new Set(this.data).entries() + isDisjointFrom(other: Set): boolean { + const otherSet = proxySet(other) + return ( + this.size === other.size && + [...this.values()].every((value) => !otherSet.has(value)) + ) }, - }) - - Object.defineProperties(set, { - data: { - enumerable: false, + isSubsetOf(other: Set) { + const otherSet = proxySet(other) + return ( + this.size <= other.size && + [...this.values()].every((value) => otherSet.has(value)) + ) }, - size: { - enumerable: false, + isSupersetOf(other: Set) { + const otherSet = proxySet(other) + return ( + this.size >= other.size && + [...otherSet].every((value) => this.has(value)) + ) }, - toJSON: { - enumerable: false, + symmetricDifference(other: Set) { + const resultSet = proxySet() + const otherSet = proxySet(other) + for (const value of this.values()) { + if (!otherSet.has(value)) { + resultSet.add(value) + } + } + return proxySet(resultSet) }, - }) + union(other: Set) { + const resultSet = proxySet() + const otherSet = proxySet(other) + for (const value of this.values()) { + resultSet.add(value) + } + for (const value of otherSet) { + resultSet.add(value) + } + return proxySet(resultSet) + }, + } - Object.seal(set) + const proxiedObject = proxy(vObject) + Object.defineProperties(proxiedObject, { + size: { enumerable: false }, + data: { enumerable: false }, + toJSON: { enumerable: false }, + }) + Object.seal(proxiedObject) - return set as unknown as Set & { - $$valtioSnapshot: Omit, 'add' | 'delete' | 'clear'> + return proxiedObject as unknown as InternalProxySet & { + $$valtioSnapshot: Omit, 'set' | 'delete' | 'clear'> } } diff --git a/tests/proxyMap.bench.ts b/tests/proxyMap.bench.ts new file mode 100644 index 00000000..8a6bacf4 --- /dev/null +++ b/tests/proxyMap.bench.ts @@ -0,0 +1,150 @@ +/* eslint-disable vitest/consistent-test-it */ +import { bench, describe, test } from 'vitest' +import { proxy, snapshot } from 'valtio' +import { proxyMap } from 'valtio/utils' + +// Helper function to generate test data +function generateTestData(size: number): [number, number][] { + const data: [any, any][] = [] + for (let i = 0; i < size; i++) { + data.push([{ id: i }, { i }]) + } + return data +} + +const TEST_SIZES = [1000, 10_000, 100_000] + +TEST_SIZES.forEach((size) => { + describe.skip(`Insertion -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap() + testData.forEach(([key, value]) => { + map.set(key, value) + }) + }) + }) + + describe.skip(`Insertion and Update -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap() + testData.forEach(([key, value]) => { + map.set(key, value) + map.set(key, -1) + }) + }) + }) + + describe.skip(`Retrieval -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap(testData) + testData.forEach(([key]) => { + map.get(key) + }) + }) + }) + + describe.skip(`Deletion -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap(testData) + testData.forEach(([key]) => { + map.delete(key) + }) + }) + }) + + describe.skip(`Iteration -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap(testData) + testData.forEach(([key, value]) => {}) + }) + }) + + describe.skip(`Insertion, Retrieval, and Deletion -${size} items`, () => { + const testData = generateTestData(size) + bench('proxyMap', () => { + const map = proxyMap(testData) + testData.forEach(([key, value]) => { + map.set(key, value) + map.get(key) + map.delete(key) + }) + }) + }) + + describe.skip(`entries -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap(testData) + for (const [k, v] of map.entries()) { + const _k = k + const _v = v + } + }) + }) + + describe.skip(`keys -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap(testData) + for (const k of map.keys()) { + const _k = k + } + }) + }) + + describe.skip(`values -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap(testData) + for (const v of map.values()) { + const _v = v + } + }) + }) + + describe.skip(`snapshot -${size} items`, () => { + const testData = generateTestData(size) + + bench('proxyMap', () => { + const map = proxyMap(testData) + const snap = snapshot(map) + testData.forEach(([key, value]) => { + snap.get(key) + }) + }) + }) + + describe(`snapshot & modify -${size} items`, () => { + const testData = generateTestData(size) + const oneData = generateTestData(1)[0]! + + bench('proxyMap', () => { + const map = proxyMap(testData) + const snap1 = snapshot(map) + map.set(oneData[0], oneData[1]) + const snap2 = snapshot(map) + }) + }) + + describe.skip('Clear -${size} items', () => { + const testData = generateTestData(size) + const map = proxyMap(testData) + + bench('proxyMap', () => { + map.clear() + }) + }) +}) diff --git a/tests/proxyMap.test.tsx b/tests/proxyMap.test.tsx index 8b5955df..b0aa959b 100644 --- a/tests/proxyMap.test.tsx +++ b/tests/proxyMap.test.tsx @@ -319,8 +319,23 @@ describe('proxyMap internal', () => { ).toBe(false) }) }) - describe('snapshot', () => { + it('should error when trying to mutate a snapshot', () => { + const state = proxyMap() + const snap = snapshot(state) + + // @ts-expect-error - snapshot should not be able to mutate + expect(() => snap.set('foo', 'bar')).toThrow( + 'Cannot perform mutations on a snapshot', + ) + // @ts-expect-error - snapshot should not be able to mutate + expect(() => snap.delete('foo')).toThrow( + 'Cannot perform mutations on a snapshot', + ) + // @ts-expect-error - snapshot should not be able to mutate + expect(() => snap.clear()).toThrow('Cannot perform mutations on a snapshot') + }) + it('should not change snapshot with modifying the original proxy', async () => { const state = proxyMap([ ['key1', {}], diff --git a/tests/proxySet.test.tsx b/tests/proxySet.test.tsx index 74d1db39..8ff894a9 100644 --- a/tests/proxySet.test.tsx +++ b/tests/proxySet.test.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react' import { fireEvent, render, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { proxy, useSnapshot } from 'valtio' +import { proxy, snapshot, useSnapshot } from 'valtio' import { proxySet } from 'valtio/utils' // used to initialize proxySet during tests @@ -335,3 +335,33 @@ describe('proxySet internal', () => { ).toBe(false) }) }) + +describe('snapshot behavior', () => { + it('should error when trying to mutate a snapshot', () => { + const state = proxySet() + const snap = snapshot(state) + + expect(() => snap.add('foo')).toThrow( + 'Cannot perform mutations on a snapshot', + ) + // @ts-expect-error - snapshot should not be able to mutate + expect(() => snap.delete('foo')).toThrow( + 'Cannot perform mutations on a snapshot', + ) + // @ts-expect-error - snapshot should not be able to mutate + expect(() => snap.clear()).toThrow('Cannot perform mutations on a snapshot') + }) + + it('should work with deleting a key', async () => { + const state = proxySet(['val1', 'val2']) + const snap1 = snapshot(state) + expect(snap1.has('val1')).toBe(true) + expect(snap1.has('val2')).toBe(true) + state.delete('val1') + const snap2 = snapshot(state) + expect(snap1.has('val1')).toBe(true) + expect(snap1.has('val2')).toBe(true) + expect(snap2.has('val1')).toBe(false) + expect(snap2.has('val2')).toBe(true) + }) +})