Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/proxy-iterate-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@geajs/core": patch
---

### @geajs/core (patch)

- **proxyIterate O(1) proxy cache**: Reactive array iteration methods (`.map()`, `.filter()`, `.forEach()`, `.find()`, `.reduce()`) now reuse cached Proxy instances for object elements via a per-store `iterateProxyCache` keyed on `(array, index)`. The cache is validated by object identity and invalidated on any mutation (splice, push, set, delete, length). Reduces GC pressure in list-heavy applications.
65 changes: 65 additions & 0 deletions packages/gea/benchmarks/proxy-iterate.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Benchmark: proxyIterate cache — eliminate per-call proxy allocations
* PR #39: Cache proxies in iterateProxyCache WeakMap instead of creating new ones each iteration
*
* Run: npx tsx --conditions source packages/gea/benchmarks/proxy-iterate.bench.ts
*/
import { Store } from '../src/lib/store.ts'

function heapMB() {
return process.memoryUsage().heapUsed / 1024 / 1024
}

function bench(fn: () => void, iters: number): number {
for (let i = 0; i < 10; i++) fn()
const t0 = performance.now()
for (let i = 0; i < iters; i++) fn()
return performance.now() - t0
}

class TestStore extends Store {
items = Array.from({ length: 500 }, (_, i) => ({ id: i, value: `item-${i}` }))
}

const ITERS = 5000

console.log('\n=== proxyIterate cache benchmark ===')
console.log('Simulating repeated array iteration (index access) on a 500-item store array\n')

const store = new TestStore()

if (typeof global.gc === 'function') global.gc()
const h0 = heapMB()

const coldMs = bench(() => {
for (let i = 0; i < store.items.length; i++) {
void store.items[i]
}
}, 1)

if (typeof global.gc === 'function') global.gc()
const h1 = heapMB()

const warmMs = bench(() => {
for (let i = 0; i < store.items.length; i++) {
void store.items[i]
}
}, ITERS)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (typeof global.gc === 'function') global.gc()
const h2 = heapMB()

console.log(`${'Metric'.padEnd(32)} ${'Result'.padStart(14)}`)
console.log('-'.repeat(48))
console.log(`${'Array size'.padEnd(32)} ${'500 items'.padStart(14)}`)
console.log(`${'Iterations'.padEnd(32)} ${String(ITERS).padStart(14)}`)
console.log(`${'Cold pass (ms)'.padEnd(32)} ${coldMs.toFixed(2).padStart(14)}`)
console.log(`${'Warm ${ITERS} iters (ms)'.padEnd(32)} ${warmMs.toFixed(2).padStart(14)}`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
console.log(`${'Per-iter (µs)'.padEnd(32)} ${((warmMs / ITERS) * 1000).toFixed(1).padStart(14)}`)
console.log(`${'Heap before warm (MB)'.padEnd(32)} ${h1.toFixed(2).padStart(14)}`)
console.log(`${'Heap after warm (MB)'.padEnd(32)} ${h2.toFixed(2).padStart(14)}`)
console.log(`${'Heap delta (MB)'.padEnd(32)} ${(h2 - h1).toFixed(3).padStart(14)}`)
console.log()
console.log('Without cache: every array[i] access allocates a new Proxy object.')
console.log('With cache: proxy reused from iterateProxyCache WeakMap → zero allocation on hit.')
console.log('Expected heap delta ≈ 0 MB with cache (proxies reused, not collected).\n')
50 changes: 40 additions & 10 deletions packages/gea/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface StoreInstancePrivate {
observerRoot: ObserverNode
proxyCache: WeakMap<any, any>
arrayIndexProxyCache: WeakMap<any, Map<string, any>>
iterateProxyCache: WeakMap<any[], Map<number, any>>
internedArrayPaths: Map<string, string[]>
topLevelProxies: Map<string, [raw: any, proxy: any]>
pathPartsCache: Map<string, string[]>
Expand Down Expand Up @@ -152,11 +153,31 @@ function shouldWrapNestedReactiveValue(value: any): boolean {

const getByPathParts = (obj: any, pathParts: string[]): any => pathParts.reduce((o: any, k: string) => o?.[k], obj)

function _wrapItem(store: Store, arr: any[], i: number, basePath: string, baseParts: string[]): any {
function _wrapItem(
store: Store,
arr: any[],
i: number,
basePath: string,
baseParts: string[],
p?: StoreInstancePrivate,
): any {
const raw = arr[i]
return shouldWrapNestedReactiveValue(raw)
? _createProxy(store, raw, joinPath(basePath, i), appendPathParts(baseParts, String(i)))
: raw
if (!shouldWrapNestedReactiveValue(raw)) return raw
if (p !== undefined) {
let indexCache = p.iterateProxyCache.get(arr)
if (indexCache !== undefined) {
const cached = indexCache.get(i)
if (cached !== undefined && (cached as any)[GEA_PROXY_GET_TARGET] === raw) return cached
}
const proxy = _createProxy(store, raw, joinPath(basePath, i), appendPathParts(baseParts, String(i)), undefined, p)
if (indexCache === undefined) {
indexCache = new Map()
p.iterateProxyCache.set(arr, indexCache)
}
indexCache.set(i, proxy)
return proxy
}
return _createProxy(store, raw, joinPath(basePath, i), appendPathParts(baseParts, String(i)))
}

function proxyIterate(
Expand All @@ -167,17 +188,18 @@ function proxyIterate(
method: string,
cb: Function,
thisArg?: any,
storePriv?: StoreInstancePrivate,
): any {
const isMap = method === 'map'
const result: any = isMap ? new Array(arr.length) : method === 'filter' ? [] : undefined
for (let i = 0; i < arr.length; i++) {
const p = _wrapItem(store, arr, i, basePath, baseParts)
const v = cb.call(thisArg, p, i, arr)
const item = _wrapItem(store, arr, i, basePath, baseParts, storePriv)
const v = cb.call(thisArg, item, i, arr)
if (isMap) {
result[i] = v
} else if (v) {
if (method === 'filter') result.push(p)
else if (method === 'find') return p
if (method === 'filter') result.push(item)
else if (method === 'find') return item
}
}
return result
Expand Down Expand Up @@ -413,6 +435,7 @@ function _tagArrayItem(c: StoreChange, m: ArrayProxyMeta, leafParts: string[]):
function _dropCaches(p: StoreInstancePrivate, v: any): void {
p.proxyCache.delete(v)
p.arrayIndexProxyCache.delete(v)
p.iterateProxyCache.delete(v)
}

function _dropOld(p: StoreInstancePrivate, old: any): void {
Expand All @@ -421,6 +444,7 @@ function _dropOld(p: StoreInstancePrivate, old: any): void {

function _clearArrayIndexCache(p: StoreInstancePrivate, arr: any): void {
p.arrayIndexProxyCache.delete(arr)
p.iterateProxyCache.delete(arr)
}

function _normalizeBatch(p: StoreInstancePrivate, batch: StoreChange[]): StoreChange[] {
Expand Down Expand Up @@ -795,13 +819,13 @@ function _interceptArray(
case 'map':
case 'filter':
case 'find':
return (cb: Function, thisArg?: any) => proxyIterate(store, arr, basePath, baseParts, method, cb, thisArg)
return (cb: Function, thisArg?: any) => proxyIterate(store, arr, basePath, baseParts, method, cb, thisArg, p)
case 'reduce':
return function (cb: Function, init?: any) {
let acc = arguments.length >= 2 ? init : arr[0]
const start = arguments.length >= 2 ? 0 : 1
for (let i = start; i < arr.length; i++) {
acc = cb(acc, _wrapItem(store, arr, i, basePath, baseParts), i, arr)
acc = cb(acc, _wrapItem(store, arr, i, basePath, baseParts, p), i, arr)
}
return acc
}
Expand Down Expand Up @@ -969,6 +993,7 @@ function _createProxy(
value = unwrapNestedProxyValue(value)
if (prop === 'length' && _isArr(obj)) {
_p.arrayIndexProxyCache.delete(obj)
_p.iterateProxyCache.delete(obj)
obj[prop] = value
return true
}
Expand All @@ -977,6 +1002,8 @@ function _createProxy(
if (_isArr(obj) && isNumericIndex(prop)) {
const ic = _p.arrayIndexProxyCache.get(obj)
if (ic) ic.delete(prop)
const itc = _p.iterateProxyCache.get(obj)
if (itc) itc.delete(Number(prop))
}
_dropOld(_p, oldValue)
obj[prop] = value
Expand Down Expand Up @@ -1005,6 +1032,8 @@ function _createProxy(
if (_isArr(obj) && isNumericIndex(prop)) {
const ic = _p.arrayIndexProxyCache.get(obj)
if (ic) ic.delete(prop)
const itc = _p.iterateProxyCache.get(obj)
if (itc) itc.delete(Number(prop))
}
_dropOld(_p, oldValue)
delete obj[prop]
Expand Down Expand Up @@ -1146,6 +1175,7 @@ export class Store {
observerRoot: _mkNode([]),
proxyCache: new WeakMap(),
arrayIndexProxyCache: new WeakMap(),
iterateProxyCache: new WeakMap(),
internedArrayPaths: new Map(),
topLevelProxies: new Map(),
pathPartsCache: new Map(),
Expand Down