Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/map-set-reactivity.md
Original file line number Diff line number Diff line change
@@ -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.
101 changes: 101 additions & 0 deletions packages/gea/benchmarks/map-set-reactivity.bench.ts
Original file line number Diff line number Diff line change
@@ -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<string, { value: string; ttl: number }>()
}

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<string,{value,ttl}>: 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<string, { value: string; ttl: number }>()
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')
178 changes: 175 additions & 3 deletions packages/gea/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ interface StoreInstancePrivate {
nextArrayOpId: number
observerRoot: ObserverNode
proxyCache: WeakMap<any, any>
mapSetProxyCache: WeakMap<any, Map<string, any>>
arrayIndexProxyCache: WeakMap<any, Map<string, any>>
internedArrayPaths: Map<string, string[]>
topLevelProxies: Map<string, [raw: any, proxy: any]>
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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<any, any>,
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<any>,
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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)!
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading