Skip to content

Commit d8d078d

Browse files
senrecepclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 403eca7 commit d8d078d

4 files changed

Lines changed: 461 additions & 0 deletions

File tree

.changeset/map-set-reactivity.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@geajs/core": minor
3+
"@geajs/vite-plugin": minor
4+
---
5+
6+
### @geajs/core (minor)
7+
8+
- **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.

packages/gea/src/lib/store.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,130 @@ function _makePathCache(base: string[]): (prop: string) => string[] {
841841
}
842842
}
843843

844+
/**
845+
* Creates a reactive Proxy for a Map instance.
846+
*
847+
* Keys MUST be strings or numbers. Object keys are rejected with a TypeError
848+
* because non-string keys would be silently serialized to "[object Object]"
849+
* when stored in StoreChange.property and pathParts, causing observer path
850+
* collisions and unreachable change events.
851+
*/
852+
function _createMapProxy(
853+
store: Store,
854+
map: Map<any, any>,
855+
_basePath: string,
856+
baseParts: string[],
857+
p: StoreInstancePrivate,
858+
): any {
859+
const proxy = new Proxy(map, {
860+
get(target, prop) {
861+
if (prop === 'set') {
862+
return (key: any, value: any) => {
863+
if (typeof key !== 'string' && typeof key !== 'number') {
864+
throw new TypeError(
865+
`[gea] Reactive Map keys must be strings or numbers, got: ${typeof key}`,
866+
)
867+
}
868+
const keyStr = String(key)
869+
const oldValue = target.get(key)
870+
target.set(key, value)
871+
if (oldValue !== value) {
872+
_pushAndSchedule(
873+
store,
874+
[{ type: 'set', property: keyStr, target, pathParts: appendPathParts(baseParts, keyStr), newValue: value, previousValue: oldValue }],
875+
p,
876+
)
877+
}
878+
return proxy
879+
}
880+
}
881+
if (prop === 'delete') {
882+
return (key: any) => {
883+
if (typeof key !== 'string' && typeof key !== 'number') {
884+
throw new TypeError(
885+
`[gea] Reactive Map keys must be strings or numbers, got: ${typeof key}`,
886+
)
887+
}
888+
const keyStr = String(key)
889+
const existed = target.has(key)
890+
const oldValue = target.get(key)
891+
target.delete(key)
892+
if (existed) {
893+
_pushAndSchedule(
894+
store,
895+
[{ type: 'delete', property: keyStr, target, pathParts: appendPathParts(baseParts, keyStr), previousValue: oldValue }],
896+
p,
897+
)
898+
}
899+
return existed
900+
}
901+
}
902+
if (prop === 'clear') {
903+
return () => {
904+
if (target.size > 0) {
905+
target.clear()
906+
_pushAndSchedule(store, [{ type: 'set', property: '', target, pathParts: baseParts }], p)
907+
}
908+
}
909+
}
910+
const value = Reflect.get(target, prop, target)
911+
return typeof value === 'function' ? value.bind(target) : value
912+
},
913+
})
914+
return proxy
915+
}
916+
917+
function _createSetProxy(
918+
store: Store,
919+
set: Set<any>,
920+
_basePath: string,
921+
baseParts: string[],
922+
p: StoreInstancePrivate,
923+
): any {
924+
const proxy = new Proxy(set, {
925+
get(target, prop) {
926+
if (prop === 'add') {
927+
return (value: any) => {
928+
if (!target.has(value)) {
929+
target.add(value)
930+
_pushAndSchedule(
931+
store,
932+
[{ type: 'set', property: String(value), target, pathParts: appendPathParts(baseParts, String(value)), newValue: value }],
933+
p,
934+
)
935+
}
936+
return proxy
937+
}
938+
}
939+
if (prop === 'delete') {
940+
return (value: any) => {
941+
const existed = target.has(value)
942+
target.delete(value)
943+
if (existed) {
944+
_pushAndSchedule(
945+
store,
946+
[{ type: 'delete', property: String(value), target, pathParts: appendPathParts(baseParts, String(value)), previousValue: value }],
947+
p,
948+
)
949+
}
950+
return existed
951+
}
952+
}
953+
if (prop === 'clear') {
954+
return () => {
955+
if (target.size > 0) {
956+
target.clear()
957+
_pushAndSchedule(store, [{ type: 'set', property: '', target, pathParts: baseParts }], p)
958+
}
959+
}
960+
}
961+
const v = Reflect.get(target, prop, target)
962+
return typeof v === 'function' ? v.bind(target) : v
963+
},
964+
})
965+
return proxy
966+
}
967+
844968
function _createProxy(
845969
store: Store,
846970
target: any,
@@ -914,6 +1038,18 @@ function _createProxy(
9141038
const cached = _p.proxyCache.get(value)
9151039
if (cached) return cached
9161040
}
1041+
if (value instanceof Map) {
1042+
const currentPath = joinPath(basePath, prop as string)
1043+
const mapProxy = _createMapProxy(store, value, currentPath, getCachedPathParts(prop as string), _p)
1044+
_p.proxyCache.set(value, mapProxy)
1045+
return mapProxy
1046+
}
1047+
if (value instanceof Set) {
1048+
const currentPath = joinPath(basePath, prop as string)
1049+
const setProxy = _createSetProxy(store, value, currentPath, getCachedPathParts(prop as string), _p)
1050+
_p.proxyCache.set(value, setProxy)
1051+
return setProxy
1052+
}
9171053
if (!_isPlain(value)) return value
9181054
if (isArrIdx) {
9191055
let indexCache = _p.arrayIndexProxyCache.get(obj)
@@ -1080,6 +1216,22 @@ export class Store {
10801216
const value = (t as any)[prop]
10811217
if (typeof value === 'function') return value
10821218
if (value != null && typeof value === 'object') {
1219+
if (value instanceof Map) {
1220+
const p = storeInstancePrivate.get(t)!
1221+
const entry = p.topLevelProxies.get(prop)
1222+
if (entry && entry[0] === value) return entry[1]
1223+
const mapProxy = _createMapProxy(t, value, prop, _rootPathPartsCache(p, prop), p)
1224+
p.topLevelProxies.set(prop, [value, mapProxy])
1225+
return mapProxy
1226+
}
1227+
if (value instanceof Set) {
1228+
const p = storeInstancePrivate.get(t)!
1229+
const entry = p.topLevelProxies.get(prop)
1230+
if (entry && entry[0] === value) return entry[1]
1231+
const setProxy = _createSetProxy(t, value, prop, _rootPathPartsCache(p, prop), p)
1232+
p.topLevelProxies.set(prop, [value, setProxy])
1233+
return setProxy
1234+
}
10831235
if (!_isPlain(value)) return value
10841236
if (shouldSkipReactiveWrapForPath(prop)) return value
10851237
const p = storeInstancePrivate.get(t)!

packages/gea/tests/component.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,154 @@ describe('Component', () => {
389389
assert.deepEqual(values, [5])
390390
})
391391
})
392+
393+
describe('Map and Set reactivity in components', () => {
394+
it('component with Map property fires observer on Map.set()', async () => {
395+
class MapComp extends Component {
396+
data = new Map<string, number>()
397+
template() {
398+
return '<div></div>'
399+
}
400+
}
401+
const comp = new MapComp()
402+
const changes: any[][] = []
403+
comp.observe('data', (_v: any, c: any) => changes.push(c))
404+
;(comp.data as Map<string, number>).set('count', 42)
405+
await flush()
406+
assert.equal(changes.length, 1)
407+
assert.equal(changes[0][0].type, 'set')
408+
assert.equal(changes[0][0].property, 'count')
409+
assert.equal(changes[0][0].newValue, 42)
410+
})
411+
412+
it('component with Map property fires observer on Map.delete()', async () => {
413+
class MapComp extends Component {
414+
data = new Map<string, number>([['x', 1]])
415+
template() {
416+
return '<div></div>'
417+
}
418+
}
419+
const comp = new MapComp()
420+
const changes: any[][] = []
421+
comp.observe('data', (_v: any, c: any) => changes.push(c))
422+
;(comp.data as Map<string, number>).delete('x')
423+
await flush()
424+
assert.equal(changes.length, 1)
425+
assert.equal(changes[0][0].type, 'delete')
426+
assert.equal(changes[0][0].property, 'x')
427+
})
428+
429+
it('component with Map property does not fire observer when setting same value', async () => {
430+
class MapComp extends Component {
431+
data = new Map<string, number>([['a', 1]])
432+
template() {
433+
return '<div></div>'
434+
}
435+
}
436+
const comp = new MapComp()
437+
const changes: any[][] = []
438+
comp.observe('data', (_v: any, c: any) => changes.push(c))
439+
;(comp.data as Map<string, number>).set('a', 1)
440+
await flush()
441+
assert.equal(changes.length, 0)
442+
})
443+
444+
it('component Map proxy returns stable reference', () => {
445+
class MapComp extends Component {
446+
counters = new Map<string, number>()
447+
template() {
448+
return '<div></div>'
449+
}
450+
}
451+
const comp = new MapComp()
452+
assert.equal(comp.counters, comp.counters)
453+
})
454+
455+
it('component with Set property fires observer on Set.add()', async () => {
456+
class TagComp extends Component {
457+
tags = new Set<string>()
458+
template() {
459+
return '<div></div>'
460+
}
461+
}
462+
const comp = new TagComp()
463+
const changes: any[][] = []
464+
comp.observe('tags', (_v: any, c: any) => changes.push(c))
465+
;(comp.tags as Set<string>).add('react')
466+
await flush()
467+
assert.equal(changes.length, 1)
468+
assert.equal(changes[0][0].type, 'set')
469+
assert.equal(changes[0][0].property, 'react')
470+
assert.equal(changes[0][0].newValue, 'react')
471+
})
472+
473+
it('component with Set property fires observer on Set.delete()', async () => {
474+
class TagComp extends Component {
475+
tags = new Set<string>(['foo', 'bar'])
476+
template() {
477+
return '<div></div>'
478+
}
479+
}
480+
const comp = new TagComp()
481+
const changes: any[][] = []
482+
comp.observe('tags', (_v: any, c: any) => changes.push(c))
483+
;(comp.tags as Set<string>).delete('foo')
484+
await flush()
485+
assert.equal(changes.length, 1)
486+
assert.equal(changes[0][0].type, 'delete')
487+
assert.equal(changes[0][0].property, 'foo')
488+
})
489+
490+
it('component Set proxy returns stable reference', () => {
491+
class SetComp extends Component {
492+
ids = new Set<number>()
493+
template() {
494+
return '<div></div>'
495+
}
496+
}
497+
const comp = new SetComp()
498+
assert.equal(comp.ids, comp.ids)
499+
})
500+
501+
it('component renders Map-based content and observer tracks key change', async () => {
502+
class DictComp extends Component {
503+
dict = new Map<string, string>([['greeting', 'Hello']])
504+
getGreeting() {
505+
return (this.dict as Map<string, string>).get('greeting') ?? ''
506+
}
507+
template() {
508+
return `<div>${this.getGreeting()}</div>`
509+
}
510+
}
511+
const comp = new DictComp()
512+
const container = document.createElement('div')
513+
document.body.appendChild(container)
514+
comp.render(container)
515+
assert.ok(container.textContent?.includes('Hello'))
516+
const changes: any[][] = []
517+
comp.observe('dict', (_v: any, c: any) => changes.push(c))
518+
;(comp.dict as Map<string, string>).set('greeting', 'Hi')
519+
await flush()
520+
assert.equal(changes.length, 1)
521+
assert.equal(changes[0][0].newValue, 'Hi')
522+
})
523+
524+
it('component with Set fires observer on Set.clear() only when non-empty', async () => {
525+
class FlagComp extends Component {
526+
flags = new Set<string>(['active', 'visible'])
527+
template() {
528+
return '<div></div>'
529+
}
530+
}
531+
const comp = new FlagComp()
532+
const changes: any[][] = []
533+
comp.observe('flags', (_v: any, c: any) => changes.push(c))
534+
;(comp.flags as Set<string>).clear()
535+
await flush()
536+
assert.equal(changes.length, 1)
537+
;(comp.flags as Set<string>).clear()
538+
await flush()
539+
assert.equal(changes.length, 1, 'clear on empty set must not fire again')
540+
})
541+
})
392542
})

0 commit comments

Comments
 (0)