diff --git a/.changeset/map-set-reactivity.md b/.changeset/map-set-reactivity.md new file mode 100644 index 00000000..19aee587 --- /dev/null +++ b/.changeset/map-set-reactivity.md @@ -0,0 +1,8 @@ +--- +"@geajs/core": minor +"@geajs/vite-plugin": minor +--- + +### @geajs/core (minor) + +- **Map/Set reactivity**: `Map` and `Set` instances stored in the reactive store are now fully reactive. Mutations via `.set()`, `.delete()`, `.clear()` (Map) and `.add()`, `.delete()`, `.clear()` (Set) trigger observers correctly. Both top-level and nested Map/Set properties are supported. diff --git a/packages/gea/benchmarks/map-set-reactivity.bench.ts b/packages/gea/benchmarks/map-set-reactivity.bench.ts new file mode 100644 index 00000000..3dac0516 --- /dev/null +++ b/packages/gea/benchmarks/map-set-reactivity.bench.ts @@ -0,0 +1,101 @@ +/** + * Benchmark: reactive Map — overhead of change-tracking proxy on Map operations + * PR #40: _createMapProxy wraps Map fields; set/delete/clear emit StoreChange events + * + * Run: npx tsx --conditions source packages/gea/benchmarks/map-set-reactivity.bench.ts + */ +import { Store } from '../src/lib/store.ts' + +function forceGC() { if (typeof global.gc === 'function') { global.gc(); global.gc() } } +function heapMB() { return process.memoryUsage().heapUsed / 1024 / 1024 } +function median(arr: number[]) { + const s = [...arr].sort((a, b) => a - b) + const m = Math.floor(s.length / 2) + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2 +} +function stddev(arr: number[]) { + const avg = arr.reduce((a, b) => a + b, 0) / arr.length + return Math.sqrt(arr.reduce((a, b) => a + (b - avg) ** 2, 0) / arr.length) +} + +// ── Store under test ────────────────────────────────────────────────────────── + +class CacheStore extends Store { + entries = new Map() +} + +const KEYS = Array.from({ length: 20 }, (_, i) => `key-${i}`) +const WARMUP = 50 +const TRIALS = 200 +const ITERS = 100 + +function runTrials(fn: () => void): number[] { + for (let i = 0; i < WARMUP; i++) fn() + return Array.from({ length: TRIALS }, () => { + const t0 = performance.now() + for (let i = 0; i < ITERS; i++) fn() + return (performance.now() - t0) / ITERS + }) +} + +console.log('\n╔══ reactive Map: change-tracking proxy overhead ════════════════════════════╗') +console.log('║ CacheStore.entries Map: 20-key set → has → delete cycle ║') +console.log(`║ ${TRIALS} trials × ${ITERS} full cycles each ║`) +console.log('╚═════════════════════════════════════════════════════════════════════════════╝\n') + +// A: Plain Map baseline +const plain = new Map() +forceGC(); const hA0 = heapMB() +const plainTimes = runTrials(() => { + for (const k of KEYS) plain.set(k, { value: k, ttl: 300 }) + for (const k of KEYS) plain.has(k) + for (const k of KEYS) plain.delete(k) +}) +forceGC(); const hA1 = heapMB() + +// B: Reactive Map — cold (fresh store per iteration, mapSetProxyCache empty) +forceGC(); const hB0 = heapMB() +const coldTimes = runTrials(() => { + const s = new CacheStore() + for (const k of KEYS) s.entries.set(k, { value: k, ttl: 300 }) + for (const k of KEYS) s.entries.has(k) + for (const k of KEYS) s.entries.delete(k) +}) +forceGC(); const hB1 = heapMB() + +// C: Reactive Map — warm (same store, proxy cached in mapSetProxyCache) +const ws = new CacheStore() +void ws.entries // access once to warm mapSetProxyCache +forceGC(); const hC0 = heapMB() +const warmTimes = runTrials(() => { + for (const k of KEYS) ws.entries.set(k, { value: k, ttl: 300 }) + for (const k of KEYS) ws.entries.has(k) + for (const k of KEYS) ws.entries.delete(k) +}) +forceGC(); const hC1 = heapMB() + +// D: Reactive Map — read-only (has only, no mutations, no StoreChange) +const rs = new CacheStore() +for (const k of KEYS) rs.entries.set(k, { value: k, ttl: 300 }) +forceGC(); const hD0 = heapMB() +const readTimes = runTrials(() => { + for (const k of KEYS) rs.entries.has(k) +}) +forceGC(); const hD1 = heapMB() + +const plainMed = median(plainTimes) * 1_000 +const coldMed = median(coldTimes) * 1_000 +const warmMed = median(warmTimes) * 1_000 +const readMed = median(readTimes) * 1_000 + +console.log(`${'Scenario'.padEnd(44)} ${'Med (µs)'.padStart(10)} ${'σ (µs)'.padStart(8)} ${'Overhead'.padStart(10)} ${'Heap Δ MB'.padStart(11)}`) +console.log('─'.repeat(85)) +console.log(`${'A. Plain Map (set+has+delete)'.padEnd(44)} ${plainMed.toFixed(2).padStart(10)} ${(stddev(plainTimes)*1e3).toFixed(2).padStart(8)} ${'baseline'.padStart(10)} ${(hA1-hA0).toFixed(3).padStart(11)}`) +console.log(`${'B. Reactive Map — cold (new store)'.padEnd(44)} ${coldMed.toFixed(2).padStart(10)} ${(stddev(coldTimes)*1e3).toFixed(2).padStart(8)} ${((coldMed/plainMed).toFixed(1)+'x').padStart(10)} ${(hB1-hB0).toFixed(3).padStart(11)}`) +console.log(`${'C. Reactive Map — warm (proxy cached)'.padEnd(44)} ${warmMed.toFixed(2).padStart(10)} ${(stddev(warmTimes)*1e3).toFixed(2).padStart(8)} ${((warmMed/plainMed).toFixed(1)+'x').padStart(10)} ${(hC1-hC0).toFixed(3).padStart(11)}`) +console.log(`${'D. Reactive Map — read-only (has only)'.padEnd(44)} ${readMed.toFixed(2).padStart(10)} ${(stddev(readTimes)*1e3).toFixed(2).padStart(8)} ${((readMed/plainMed).toFixed(1)+'x').padStart(10)} ${(hD1-hD0).toFixed(3).padStart(11)}`) +console.log() +console.log('A: native Map, no proxy — absolute minimum cost.') +console.log('B: cold includes store init + first-access proxy construction per .entries access.') +console.log('C: warm shows true steady-state overhead — only set/delete trigger StoreChange + microtask.') +console.log('D: read-only path (has) bypasses change machinery; overhead is Proxy get-trap only.\n') diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index 7003e52c..04e928e1 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -64,6 +64,7 @@ interface StoreInstancePrivate { nextArrayOpId: number observerRoot: ObserverNode proxyCache: WeakMap + mapSetProxyCache: WeakMap> arrayIndexProxyCache: WeakMap> internedArrayPaths: Map topLevelProxies: Map @@ -150,7 +151,8 @@ function shouldWrapNestedReactiveValue(value: any): boolean { return value != null && typeof value === 'object' && _isPlain(value) } -const getByPathParts = (obj: any, pathParts: string[]): any => pathParts.reduce((o: any, k: string) => o?.[k], obj) +const getByPathParts = (obj: any, pathParts: string[]): any => + pathParts.reduce((o: any, k: string) => (o instanceof Map ? o.get(k) : o?.[k]), obj) function _wrapItem(store: Store, arr: any[], i: number, basePath: string, baseParts: string[]): any { const raw = arr[i] @@ -412,6 +414,7 @@ function _tagArrayItem(c: StoreChange, m: ArrayProxyMeta, leafParts: string[]): function _dropCaches(p: StoreInstancePrivate, v: any): void { p.proxyCache.delete(v) + p.mapSetProxyCache.delete(v) p.arrayIndexProxyCache.delete(v) } @@ -841,6 +844,128 @@ function _makePathCache(base: string[]): (prop: string) => string[] { } } +/** + * Creates a reactive Proxy for a Map instance. + * + * Keys MUST be strings or numbers. Object keys are rejected with a TypeError + * because non-string keys would be silently serialized to "[object Object]" + * when stored in StoreChange.property and pathParts, causing observer path + * collisions and unreachable change events. + */ +function _createMapProxy( + store: Store, + map: Map, + baseParts: string[], + p: StoreInstancePrivate, +): any { + const proxy = new Proxy(map, { + get(target, prop) { + if (prop === 'set') { + return (key: any, value: any) => { + if (typeof key !== 'string' && typeof key !== 'number') { + throw new TypeError( + `[gea] Reactive Map keys must be strings or numbers, got: ${typeof key}`, + ) + } + const keyStr = String(key) + const oldValue = target.get(key) + target.set(key, value) + if (oldValue !== value) { + _pushAndSchedule( + store, + [{ type: 'set', property: keyStr, target, pathParts: appendPathParts(baseParts, keyStr), newValue: value, previousValue: oldValue }], + p, + ) + } + return proxy + } + } + if (prop === 'delete') { + return (key: any) => { + if (typeof key !== 'string' && typeof key !== 'number') { + throw new TypeError( + `[gea] Reactive Map keys must be strings or numbers, got: ${typeof key}`, + ) + } + const keyStr = String(key) + const existed = target.has(key) + const oldValue = target.get(key) + target.delete(key) + if (existed) { + _pushAndSchedule( + store, + [{ type: 'delete', property: keyStr, target, pathParts: appendPathParts(baseParts, keyStr), previousValue: oldValue }], + p, + ) + } + return existed + } + } + if (prop === 'clear') { + return () => { + if (target.size > 0) { + target.clear() + _pushAndSchedule(store, [{ type: 'set', property: '', target, pathParts: baseParts }], p) + } + } + } + const value = Reflect.get(target, prop, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + return proxy +} + +function _createSetProxy( + store: Store, + set: Set, + baseParts: string[], + p: StoreInstancePrivate, +): any { + const proxy = new Proxy(set, { + get(target, prop) { + if (prop === 'add') { + return (value: any) => { + if (!target.has(value)) { + target.add(value) + _pushAndSchedule( + store, + [{ type: 'set', property: String(value), target, pathParts: appendPathParts(baseParts, String(value)), newValue: value }], + p, + ) + } + return proxy + } + } + if (prop === 'delete') { + return (value: any) => { + const existed = target.has(value) + target.delete(value) + if (existed) { + _pushAndSchedule( + store, + [{ type: 'delete', property: String(value), target, pathParts: appendPathParts(baseParts, String(value)), previousValue: value }], + p, + ) + } + return existed + } + } + if (prop === 'clear') { + return () => { + if (target.size > 0) { + target.clear() + _pushAndSchedule(store, [{ type: 'set', property: '', target, pathParts: baseParts }], p) + } + } + } + const v = Reflect.get(target, prop, target) + return typeof v === 'function' ? v.bind(target) : v + }, + }) + return proxy +} + function _createProxy( store: Store, target: any, @@ -911,8 +1036,38 @@ function _createProxy( return proxyCached } } else { - const cached = _p.proxyCache.get(value) - if (cached) return cached + if (!(value instanceof Map) && !(value instanceof Set)) { + const cached = _p.proxyCache.get(value) + if (cached) return cached + } + } + if (value instanceof Map) { + const currentPath = joinPath(basePath, prop as string) + let pathMap = _p.mapSetProxyCache.get(value) + if (!pathMap) { + pathMap = new Map() + _p.mapSetProxyCache.set(value, pathMap) + } + let mapProxy = pathMap.get(currentPath) + if (!mapProxy) { + mapProxy = _createMapProxy(store, value, getCachedPathParts(prop as string), _p) + pathMap.set(currentPath, mapProxy) + } + return mapProxy + } + if (value instanceof Set) { + const currentPath = joinPath(basePath, prop as string) + let pathMap = _p.mapSetProxyCache.get(value) + if (!pathMap) { + pathMap = new Map() + _p.mapSetProxyCache.set(value, pathMap) + } + let setProxy = pathMap.get(currentPath) + if (!setProxy) { + setProxy = _createSetProxy(store, value, getCachedPathParts(prop as string), _p) + pathMap.set(currentPath, setProxy) + } + return setProxy } if (!_isPlain(value)) return value if (isArrIdx) { @@ -1080,6 +1235,22 @@ export class Store { const value = (t as any)[prop] if (typeof value === 'function') return value if (value != null && typeof value === 'object') { + if (value instanceof Map) { + const p = storeInstancePrivate.get(t)! + const entry = p.topLevelProxies.get(prop) + if (entry && entry[0] === value) return entry[1] + const mapProxy = _createMapProxy(t, value, _rootPathPartsCache(p, prop), p) + p.topLevelProxies.set(prop, [value, mapProxy]) + return mapProxy + } + if (value instanceof Set) { + const p = storeInstancePrivate.get(t)! + const entry = p.topLevelProxies.get(prop) + if (entry && entry[0] === value) return entry[1] + const setProxy = _createSetProxy(t, value, _rootPathPartsCache(p, prop), p) + p.topLevelProxies.set(prop, [value, setProxy]) + return setProxy + } if (!_isPlain(value)) return value if (shouldSkipReactiveWrapForPath(prop)) return value const p = storeInstancePrivate.get(t)! @@ -1145,6 +1316,7 @@ export class Store { nextArrayOpId: 0, observerRoot: _mkNode([]), proxyCache: new WeakMap(), + mapSetProxyCache: new WeakMap(), arrayIndexProxyCache: new WeakMap(), internedArrayPaths: new Map(), topLevelProxies: new Map(), diff --git a/packages/gea/tests/component.test.ts b/packages/gea/tests/component.test.ts index 8f24156e..6559813d 100644 --- a/packages/gea/tests/component.test.ts +++ b/packages/gea/tests/component.test.ts @@ -389,4 +389,160 @@ describe('Component', () => { assert.deepEqual(values, [5]) }) }) + + describe('Map and Set reactivity in components', () => { + it('component with Map property fires observer on Map.set()', async () => { + class MapComp extends Component { + data = new Map() + template() { + return '
' + } + } + const comp = new MapComp() + const changes: any[][] = [] + comp.observe('data', (_v: any, c: any) => changes.push(c)) + ;(comp.data as Map).set('count', 42) + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'set') + assert.equal(changes[0][0].property, 'count') + assert.equal(changes[0][0].newValue, 42) + }) + + it('component with Map property fires observer on Map.delete()', async () => { + class MapComp extends Component { + data = new Map([['x', 1]]) + template() { + return '
' + } + } + const comp = new MapComp() + const changes: any[][] = [] + comp.observe('data', (_v: any, c: any) => changes.push(c)) + ;(comp.data as Map).delete('x') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'delete') + assert.equal(changes[0][0].property, 'x') + }) + + it('component with Map property does not fire observer when setting same value', async () => { + class MapComp extends Component { + data = new Map([['a', 1]]) + template() { + return '
' + } + } + const comp = new MapComp() + const changes: any[][] = [] + comp.observe('data', (_v: any, c: any) => changes.push(c)) + ;(comp.data as Map).set('a', 1) + await flush() + assert.equal(changes.length, 0) + }) + + it('component Map proxy returns stable reference', () => { + class MapComp extends Component { + counters = new Map() + template() { + return '
' + } + } + const comp = new MapComp() + assert.equal(comp.counters, comp.counters) + }) + + it('component with Set property fires observer on Set.add()', async () => { + class TagComp extends Component { + tags = new Set() + template() { + return '
' + } + } + const comp = new TagComp() + const changes: any[][] = [] + comp.observe('tags', (_v: any, c: any) => changes.push(c)) + ;(comp.tags as Set).add('react') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'set') + assert.equal(changes[0][0].property, 'react') + assert.equal(changes[0][0].newValue, 'react') + }) + + it('component with Set property fires observer on Set.delete()', async () => { + class TagComp extends Component { + tags = new Set(['foo', 'bar']) + template() { + return '
' + } + } + const comp = new TagComp() + const changes: any[][] = [] + comp.observe('tags', (_v: any, c: any) => changes.push(c)) + ;(comp.tags as Set).delete('foo') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'delete') + assert.equal(changes[0][0].property, 'foo') + }) + + it('component Set proxy returns stable reference', () => { + class SetComp extends Component { + ids = new Set() + template() { + return '
' + } + } + const comp = new SetComp() + assert.equal(comp.ids, comp.ids) + }) + + it('component renders Map-based content and observer tracks key change', async () => { + class DictComp extends Component { + dict = new Map([['greeting', 'Hello']]) + getGreeting() { + return (this.dict as Map).get('greeting') ?? '' + } + template() { + return `
${this.getGreeting()}
` + } + } + const comp = new DictComp() + const container = document.createElement('div') + document.body.appendChild(container) + comp.render(container) + assert.ok(container.textContent?.includes('Hello')) + const changes: any[][] = [] + // Simulate reactive re-render driven by the observer (mirrors what the vite plugin + // compiles into components: rerun template expressions when observed data changes). + comp.observe('dict', (_v: any, c: any) => { + changes.push(c) + container.children[0].textContent = comp.getGreeting() + }) + ;(comp.dict as Map).set('greeting', 'Hi') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].newValue, 'Hi') + assert.ok(container.textContent?.includes('Hi'), 'DOM must reflect Map update via observer-driven re-render') + }) + + it('component with Set fires observer on Set.clear() only when non-empty', async () => { + class FlagComp extends Component { + flags = new Set(['active', 'visible']) + template() { + return '
' + } + } + const comp = new FlagComp() + const changes: any[][] = [] + comp.observe('flags', (_v: any, c: any) => changes.push(c)) + ;(comp.flags as Set).clear() + await flush() + assert.equal(changes.length, 1) + ;(comp.flags as Set).clear() + await flush() + assert.equal(changes.length, 1, 'clear on empty set must not fire again') + }) + }) }) diff --git a/packages/gea/tests/store.test.ts b/packages/gea/tests/store.test.ts index 8dd5ee38..6be06a95 100644 --- a/packages/gea/tests/store.test.ts +++ b/packages/gea/tests/store.test.ts @@ -675,3 +675,181 @@ describe('Store – silent()', () => { assert.equal(notified, false, 'observer must not fire for array mutations inside silent()') }) }) + +describe('Store – Map reactivity', () => { + it('Map.set() triggers observer', async () => { + const store = new Store({ data: new Map() }) + const changes: StoreChange[][] = [] + store.observe('data', (_v, c) => changes.push(c)) + ;(store.data as Map).set('a', 1) + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'set') + assert.equal(changes[0][0].property, 'a') + assert.equal(changes[0][0].newValue, 1) + }) + + it('Map.set() same value does not trigger observer', async () => { + const store = new Store({ data: new Map([['a', 1]]) }) + const changes: StoreChange[][] = [] + store.observe('data', (_v, c) => changes.push(c)) + ;(store.data as Map).set('a', 1) + await flush() + assert.equal(changes.length, 0) + }) + + it('Map.delete() triggers observer', async () => { + const store = new Store({ data: new Map([['a', 1]]) }) + const changes: StoreChange[][] = [] + store.observe('data', (_v, c) => changes.push(c)) + ;(store.data as Map).delete('a') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'delete') + assert.equal(changes[0][0].property, 'a') + }) + + it('Map.clear() triggers observer', async () => { + const store = new Store({ data: new Map([['a', 1], ['b', 2]]) }) + const changes: StoreChange[][] = [] + store.observe('data', (_v, c) => changes.push(c)) + ;(store.data as Map).clear() + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'set') + }) + + it('Map.clear() on empty map does not trigger observer', async () => { + const store = new Store({ data: new Map() }) + const changes: StoreChange[][] = [] + store.observe('data', (_v, c) => changes.push(c)) + ;(store.data as Map).clear() + await flush() + assert.equal(changes.length, 0) + }) + + it('Map reads work correctly through proxy', () => { + const store = new Store({ data: new Map([['x', 42]]) }) + const map = store.data as Map + assert.equal(map.get('x'), 42) + assert.equal(map.has('x'), true) + assert.equal(map.has('y'), false) + assert.equal(map.size, 1) + }) + + it('Map proxy returns same proxy reference', () => { + const store = new Store({ data: new Map() }) + assert.equal(store.data, store.data) + }) + + it('Map.set() with object key throws TypeError', () => { + const store = new Store({ data: new Map() }) + assert.throws( + () => (store.data as Map).set({}, 1), + /Reactive Map keys must be strings or numbers/, + ) + }) + + it('Map.set() with numeric key works correctly', async () => { + const store = new Store({ data: new Map() }) + const changes: StoreChange[][] = [] + store.observe('data', (_v, c) => changes.push(c)) + ;(store.data as Map).set(1, 'one') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].property, '1') + assert.equal(changes[0][0].newValue, 'one') + }) + + it('observer at Map key path receives correct value', async () => { + const store = new Store({ data: new Map([['x', 10]]) }) + const values: number[] = [] + store.observe('data.x', (v) => values.push(v)) + ;(store.data as Map).set('x', 42) + await flush() + assert.equal(values.length, 1) + assert.equal(values[0], 42) + }) + + it('same Map instance at two nested paths uses correct pathParts for each', async () => { + const shared = new Map([['k', 1]]) + const store = new Store({ a: { m: shared }, b: { m: shared } }) + const aChanges: any[][] = [] + const bChanges: any[][] = [] + store.observe('a.m', (_v, c) => aChanges.push(c)) + store.observe('b.m', (_v, c) => bChanges.push(c)) + ;(store.a.m as Map).set('k', 2) + await flush() + ;(store.b.m as Map).set('k', 3) + await flush() + assert.equal(aChanges.length, 1, 'a.m observer must fire once') + assert.equal(bChanges.length, 1, 'b.m observer must fire once') + assert.deepEqual(aChanges[0][0].pathParts, ['a', 'm', 'k']) + assert.deepEqual(bChanges[0][0].pathParts, ['b', 'm', 'k']) + }) +}) + +describe('Store – Set reactivity', () => { + it('Set.add() triggers observer', async () => { + const store = new Store({ tags: new Set() }) + const changes: StoreChange[][] = [] + store.observe('tags', (_v, c) => changes.push(c)) + ;(store.tags as Set).add('hello') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'set') + assert.equal(changes[0][0].property, 'hello') + assert.equal(changes[0][0].newValue, 'hello') + }) + + it('Set.add() duplicate does not trigger observer', async () => { + const store = new Store({ tags: new Set(['hello']) }) + const changes: StoreChange[][] = [] + store.observe('tags', (_v, c) => changes.push(c)) + ;(store.tags as Set).add('hello') + await flush() + assert.equal(changes.length, 0) + }) + + it('Set.delete() triggers observer', async () => { + const store = new Store({ tags: new Set(['hello']) }) + const changes: StoreChange[][] = [] + store.observe('tags', (_v, c) => changes.push(c)) + ;(store.tags as Set).delete('hello') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].type, 'delete') + assert.equal(changes[0][0].property, 'hello') + }) + + it('Set.clear() triggers observer', async () => { + const store = new Store({ tags: new Set(['a', 'b']) }) + const changes: StoreChange[][] = [] + store.observe('tags', (_v, c) => changes.push(c)) + ;(store.tags as Set).clear() + await flush() + assert.equal(changes.length, 1) + }) + + it('Set.clear() on empty set does not trigger observer', async () => { + const store = new Store({ tags: new Set() }) + const changes: StoreChange[][] = [] + store.observe('tags', (_v, c) => changes.push(c)) + ;(store.tags as Set).clear() + await flush() + assert.equal(changes.length, 0) + }) + + it('Set reads work correctly through proxy', () => { + const store = new Store({ tags: new Set(['a', 'b']) }) + const set = store.tags as Set + assert.equal(set.has('a'), true) + assert.equal(set.has('c'), false) + assert.equal(set.size, 2) + }) + + it('Set proxy returns same proxy reference', () => { + const store = new Store({ tags: new Set() }) + assert.equal(store.tags, store.tags) + }) +})