From 730a130283d401699a08f74e32264c22d8831692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Tue, 7 Apr 2026 11:41:11 +0300 Subject: [PATCH 1/5] feat(core): add Map and Set reactivity to the store 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. - Add module-level _createMapProxy() and _createSetProxy() functions adapted to the WeakMap-based private state pattern - Intercept Map/Set in Store.rootGetValue (top-level props) and _createProxy get handler (nested props) - Map keys must be strings or numbers; object keys throw TypeError to prevent silent observer path collisions - Proxy references are stable via topLevelProxies / proxyCache caches - 16 new unit tests for Map/Set reactivity - 9 new component tests showing Map/Set usage in component context Co-Authored-By: Claude Sonnet 4.6 --- .changeset/map-set-reactivity.md | 8 ++ packages/gea/src/lib/store.ts | 152 +++++++++++++++++++++++++++ packages/gea/tests/component.test.ts | 150 ++++++++++++++++++++++++++ packages/gea/tests/store.test.ts | 151 ++++++++++++++++++++++++++ 4 files changed, 461 insertions(+) create mode 100644 .changeset/map-set-reactivity.md 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/src/lib/store.ts b/packages/gea/src/lib/store.ts index 7003e52c..f3970823 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -841,6 +841,130 @@ 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, + _basePath: string, + 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, + _basePath: string, + 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, @@ -914,6 +1038,18 @@ function _createProxy( const cached = _p.proxyCache.get(value) if (cached) return cached } + if (value instanceof Map) { + const currentPath = joinPath(basePath, prop as string) + const mapProxy = _createMapProxy(store, value, currentPath, getCachedPathParts(prop as string), _p) + _p.proxyCache.set(value, mapProxy) + return mapProxy + } + if (value instanceof Set) { + const currentPath = joinPath(basePath, prop as string) + const setProxy = _createSetProxy(store, value, currentPath, getCachedPathParts(prop as string), _p) + _p.proxyCache.set(value, setProxy) + return setProxy + } if (!_isPlain(value)) return value if (isArrIdx) { let indexCache = _p.arrayIndexProxyCache.get(obj) @@ -1080,6 +1216,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, prop, _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, prop, _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)! diff --git a/packages/gea/tests/component.test.ts b/packages/gea/tests/component.test.ts index 8f24156e..4d2898c9 100644 --- a/packages/gea/tests/component.test.ts +++ b/packages/gea/tests/component.test.ts @@ -389,4 +389,154 @@ 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[][] = [] + comp.observe('dict', (_v: any, c: any) => changes.push(c)) + ;(comp.dict as Map).set('greeting', 'Hi') + await flush() + assert.equal(changes.length, 1) + assert.equal(changes[0][0].newValue, 'Hi') + }) + + 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..ba6ddc50 100644 --- a/packages/gea/tests/store.test.ts +++ b/packages/gea/tests/store.test.ts @@ -675,3 +675,154 @@ 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') + }) +}) + +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) + }) +}) From 50cff7ea834996c8e1256224fad6db91b95a5eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Tue, 7 Apr 2026 11:57:23 +0300 Subject: [PATCH 2/5] test(core): add DOM assertion to Map observer-driven re-render test After mutating the Map, the observer now updates the rendered container's textContent and the test asserts the DOM reflects the new value, confirming the full observe-mutate-re-render cycle works end-to-end. Co-Authored-By: Claude Sonnet 4.6 --- packages/gea/tests/component.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/gea/tests/component.test.ts b/packages/gea/tests/component.test.ts index 4d2898c9..6559813d 100644 --- a/packages/gea/tests/component.test.ts +++ b/packages/gea/tests/component.test.ts @@ -514,11 +514,17 @@ describe('Component', () => { comp.render(container) assert.ok(container.textContent?.includes('Hello')) const changes: any[][] = [] - comp.observe('dict', (_v: any, c: any) => changes.push(c)) + // 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 () => { From 26576965abcd2e1625c0eca84a0acaf4b3eccb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Tue, 7 Apr 2026 12:16:46 +0300 Subject: [PATCH 3/5] fix(core): prevent proxyCache collision for nested Map/Set at multiple paths - Introduce `mapSetProxyCache: WeakMap>` for two-level caching of Map/Set proxies keyed by both raw object and path string, avoiding stale-proxy collisions when the same Map instance is stored at two different nested property paths - Fix `getByPathParts` to use `Map.get(key)` instead of bracket access so observers at Map-key paths (e.g. `data.x`) receive the actual value - Add regression tests for both fixes Co-Authored-By: Claude Sonnet 4.6 --- packages/gea/src/lib/store.ts | 36 +++++++++++++++++++++++++------- packages/gea/tests/store.test.ts | 27 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index f3970823..23373fa8 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) } @@ -1035,19 +1038,37 @@ 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) - const mapProxy = _createMapProxy(store, value, currentPath, getCachedPathParts(prop as string), _p) - _p.proxyCache.set(value, mapProxy) + 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, currentPath, getCachedPathParts(prop as string), _p) + pathMap.set(currentPath, mapProxy) + } return mapProxy } if (value instanceof Set) { const currentPath = joinPath(basePath, prop as string) - const setProxy = _createSetProxy(store, value, currentPath, getCachedPathParts(prop as string), _p) - _p.proxyCache.set(value, setProxy) + 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, currentPath, getCachedPathParts(prop as string), _p) + pathMap.set(currentPath, setProxy) + } return setProxy } if (!_isPlain(value)) return value @@ -1297,6 +1318,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/store.test.ts b/packages/gea/tests/store.test.ts index ba6ddc50..6be06a95 100644 --- a/packages/gea/tests/store.test.ts +++ b/packages/gea/tests/store.test.ts @@ -760,6 +760,33 @@ describe('Store – Map reactivity', () => { 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', () => { From 38e2d1eedbf52414a9973f3705baf997c0f4711e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 09:37:56 +0300 Subject: [PATCH 4/5] bench(core): add reactive Map overhead benchmark Co-Authored-By: Claude Sonnet 4.6 --- .../benchmarks/map-set-reactivity.bench.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/gea/benchmarks/map-set-reactivity.bench.ts 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') From 8b42d3307f6fc8959b7c0bdd11f7b7db635af535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 09:42:35 +0300 Subject: [PATCH 5/5] fix(core): remove unused basePath params from _createMapProxy and _createSetProxy Co-Authored-By: Claude Sonnet 4.6 --- packages/gea/src/lib/store.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index 23373fa8..04e928e1 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -855,7 +855,6 @@ function _makePathCache(base: string[]): (prop: string) => string[] { function _createMapProxy( store: Store, map: Map, - _basePath: string, baseParts: string[], p: StoreInstancePrivate, ): any { @@ -920,7 +919,6 @@ function _createMapProxy( function _createSetProxy( store: Store, set: Set, - _basePath: string, baseParts: string[], p: StoreInstancePrivate, ): any { @@ -1052,7 +1050,7 @@ function _createProxy( } let mapProxy = pathMap.get(currentPath) if (!mapProxy) { - mapProxy = _createMapProxy(store, value, currentPath, getCachedPathParts(prop as string), _p) + mapProxy = _createMapProxy(store, value, getCachedPathParts(prop as string), _p) pathMap.set(currentPath, mapProxy) } return mapProxy @@ -1066,7 +1064,7 @@ function _createProxy( } let setProxy = pathMap.get(currentPath) if (!setProxy) { - setProxy = _createSetProxy(store, value, currentPath, getCachedPathParts(prop as string), _p) + setProxy = _createSetProxy(store, value, getCachedPathParts(prop as string), _p) pathMap.set(currentPath, setProxy) } return setProxy @@ -1241,7 +1239,7 @@ export class Store { const p = storeInstancePrivate.get(t)! const entry = p.topLevelProxies.get(prop) if (entry && entry[0] === value) return entry[1] - const mapProxy = _createMapProxy(t, value, prop, _rootPathPartsCache(p, prop), p) + const mapProxy = _createMapProxy(t, value, _rootPathPartsCache(p, prop), p) p.topLevelProxies.set(prop, [value, mapProxy]) return mapProxy } @@ -1249,7 +1247,7 @@ export class Store { const p = storeInstancePrivate.get(t)! const entry = p.topLevelProxies.get(prop) if (entry && entry[0] === value) return entry[1] - const setProxy = _createSetProxy(t, value, prop, _rootPathPartsCache(p, prop), p) + const setProxy = _createSetProxy(t, value, _rootPathPartsCache(p, prop), p) p.topLevelProxies.set(prop, [value, setProxy]) return setProxy }