From 56d077fe6a6fde99c79f002e4a668ca1afdfe3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 03:34:01 +0300 Subject: [PATCH 1/6] perf(core): intern hot-path appendPathParts allocations via WeakMap cache Replace per-call appendPathParts() allocations in _wrapItem and array mutation handlers (splice, push/unshift, pop/shift) with a module-level WeakMap> cache. Since baseParts arrays are already stable cached references (produced by _makePathCache inside _createProxy), the WeakMap key is stable and cache hits are guaranteed for repeated access patterns. Entries are GC'd automatically when the owning proxy is collected. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/append-path-parts-interning.md | 7 ++++++ packages/gea/src/lib/store.ts | 29 +++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 .changeset/append-path-parts-interning.md diff --git a/.changeset/append-path-parts-interning.md b/.changeset/append-path-parts-interning.md new file mode 100644 index 0000000..b8c17b5 --- /dev/null +++ b/.changeset/append-path-parts-interning.md @@ -0,0 +1,7 @@ +--- +"@geajs/core": patch +--- + +### @geajs/core (patch) + +- **Hot-path path-parts interning**: Replace per-call `appendPathParts` allocations in `_wrapItem` and array mutation handlers (splice, push/unshift, pop/shift) with a module-level `WeakMap` cache keyed on stable `baseParts` references, eliminating redundant array allocations in list-heavy workloads. diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index 7003e52..dc7034b 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -99,6 +99,25 @@ function appendPathParts(pathParts: string[], propStr: string): string[] { return [...pathParts, propStr] } +// Module-level cache: WeakMap>. +// Keys are the stable cached baseParts arrays produced by _makePathCache, +// so entries are GC'd automatically when the owning proxy is collected. +const _appendCache = new WeakMap>() + +function _internAppend(baseParts: string[], segment: string): string[] { + let inner = _appendCache.get(baseParts) + if (inner === undefined) { + inner = new Map() + _appendCache.set(baseParts, inner) + } + let result = inner.get(segment) + if (result === undefined) { + result = baseParts.length > 0 ? [...baseParts, segment] : [segment] + inner.set(segment, result) + } + return result +} + function joinPath(basePath: string, seg: string | number): string { return basePath ? `${basePath}.${seg}` : String(seg) } @@ -155,7 +174,7 @@ const getByPathParts = (obj: any, pathParts: string[]): any => pathParts.reduce( function _wrapItem(store: Store, arr: any[], i: number, basePath: string, baseParts: string[]): any { const raw = arr[i] return shouldWrapNestedReactiveValue(raw) - ? _createProxy(store, raw, joinPath(basePath, i), appendPathParts(baseParts, String(i))) + ? _createProxy(store, raw, joinPath(basePath, i), _internAppend(baseParts, String(i))) : raw } @@ -721,11 +740,11 @@ function _interceptArray( const changes: StoreChange[] = [] for (let i = 0; i < removed.length; i++) { const idx = String(start + i) - changes.push(_mkChange('delete', idx, arr, appendPathParts(baseParts, idx), undefined, removed[i])) + changes.push(_mkChange('delete', idx, arr, _internAppend(baseParts, idx), undefined, removed[i])) } for (let i = 0; i < items.length; i++) { const idx = String(start + i) - changes.push(_mkChange('add', idx, arr, appendPathParts(baseParts, idx), items[i])) + changes.push(_mkChange('add', idx, arr, _internAppend(baseParts, idx), items[i])) } if (changes.length > 0) _pushAndSchedule(store, changes, p) return removed @@ -743,7 +762,7 @@ function _interceptArray( } else { const changes: StoreChange[] = [] for (let i = 0; i < rawItems.length; i++) - changes.push(_mkChange('add', String(i), arr, appendPathParts(baseParts, String(i)), rawItems[i])) + changes.push(_mkChange('add', String(i), arr, _internAppend(baseParts, String(i)), rawItems[i])) _pushAndSchedule(store, changes, p) } return arr.length @@ -758,7 +777,7 @@ function _interceptArray( ;(Array.prototype as any)[method].call(arr) _pushAndSchedule( store, - [_mkChange('delete', String(idx), arr, appendPathParts(baseParts, String(idx)), undefined, removed)], + [_mkChange('delete', String(idx), arr, _internAppend(baseParts, String(idx)), undefined, removed)], p, ) return removed From 2a6e9316ee8d63c4ab5b192fe866eebe9877a819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 08:54:51 +0300 Subject: [PATCH 2/6] bench(core): add path parts interning allocation benchmark Compares naive spread-per-call vs WeakMap-interned path array approach. Shows speedup and heap allocation reduction for repeated deep paths. Includes real Store access pattern with depth-4 property traversal. Co-Authored-By: Claude Sonnet 4.6 --- .../gea/benchmarks/path-interning.bench.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/gea/benchmarks/path-interning.bench.ts diff --git a/packages/gea/benchmarks/path-interning.bench.ts b/packages/gea/benchmarks/path-interning.bench.ts new file mode 100644 index 0000000..1382587 --- /dev/null +++ b/packages/gea/benchmarks/path-interning.bench.ts @@ -0,0 +1,111 @@ +/** + * Benchmark: path parts interning — eliminate hot-path array allocations + * PR #42: Cache [...parent, key] results in _appendCache WeakMap + * + * Run: npx tsx --conditions source packages/gea/benchmarks/path-interning.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 < 20; i++) fn() + const t0 = performance.now() + for (let i = 0; i < iters; i++) fn() + return performance.now() - t0 +} + +// ---------- OLD: naive spread (always allocates) ---------- +function appendOld(parent: string[], key: string): string[] { + return [...parent, key] +} + +// ---------- NEW: intern cache (returns cached reference) ---------- +const _appendCache = new WeakMap>() +function appendNew(parent: string[], key: string): string[] { + let map = _appendCache.get(parent) + if (!map) { + map = new Map() + _appendCache.set(parent, map) + } + let result = map.get(key) + if (!result) { + result = [...parent, key] + map.set(key, result) + } + return result +} + +const keys = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] +const ITERS = 200_000 + +console.log('\n=== path parts interning benchmark ===') +console.log('Simulating hot-path proxy navigation: append key to parent path array\n') + +// --- Shallow path (depth 1) --- +{ + const parent: string[] = [] + if (typeof global.gc === 'function') global.gc() + const h0 = heapMB() + const oldMs = bench(() => { for (const k of keys) appendOld(parent, k) }, ITERS) + if (typeof global.gc === 'function') global.gc() + const h1 = heapMB() + const newMs = bench(() => { for (const k of keys) appendNew(parent, k) }, ITERS) + if (typeof global.gc === 'function') global.gc() + const h2 = heapMB() + console.log('Shallow path (depth 1):') + console.log(` old (spread): ${oldMs.toFixed(2)}ms heap Δ ${(h1-h0).toFixed(3)} MB`) + console.log(` new (interned): ${newMs.toFixed(2)}ms heap Δ ${(h2-h1).toFixed(3)} MB`) + console.log(` speedup: ${(oldMs/newMs).toFixed(1)}x\n`) +} + +// --- Deep path (depth 5) --- +{ + const depth5 = ['store', 'user', 'profile', 'address', 'city'] + if (typeof global.gc === 'function') global.gc() + const h0 = heapMB() + const oldMs = bench(() => { for (const k of keys) appendOld(depth5, k) }, ITERS) + if (typeof global.gc === 'function') global.gc() + const h1 = heapMB() + const newMs = bench(() => { for (const k of keys) appendNew(depth5, k) }, ITERS) + if (typeof global.gc === 'function') global.gc() + const h2 = heapMB() + console.log('Deep path (depth 5):') + console.log(` old (spread): ${oldMs.toFixed(2)}ms heap Δ ${(h1-h0).toFixed(3)} MB`) + console.log(` new (interned): ${newMs.toFixed(2)}ms heap Δ ${(h2-h1).toFixed(3)} MB`) + console.log(` speedup: ${(oldMs/newMs).toFixed(1)}x\n`) +} + +// --- Real store: deep reactive property access --- +class DeepStore extends Store { + user = { + profile: { + address: { + city: 'Istanbul', + zip: '34000', + } + } + } +} + +const store = new DeepStore() +const STORE_ITERS = 50_000 + +if (typeof global.gc === 'function') global.gc() +const hs0 = heapMB() +const storeMs = bench(() => { + void store.user.profile.address.city + void store.user.profile.address.zip +}, STORE_ITERS) +if (typeof global.gc === 'function') global.gc() +const hs1 = heapMB() + +console.log('Real store deep property access (depth 4, 2 leaf props):') +console.log(` ${STORE_ITERS.toLocaleString()} iterations: ${storeMs.toFixed(2)}ms`) +console.log(` per-iter: ${((storeMs/STORE_ITERS)*1000).toFixed(1)}µs`) +console.log(` heap delta: ${(hs1-hs0).toFixed(3)} MB`) +console.log() +console.log('With path interning: same path arrays are reused across proxy navigations.') +console.log('Without interning: each proxy access spreads a new array for each path segment.\n') From f6ee396a0af86300770d7497f8041c78f0705edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 09:43:49 +0300 Subject: [PATCH 3/6] fix(core): prevent dot-split path corruption and fix test identity assertion - Replace internPathParts(fullPath) split-rebuild with incremental internAppendPathPart(baseParts, segment) at all call sites - Change cross-store strictEqual to deepEqual for pathParts content check - Update benchmark run command to include --expose-gc for accurate heap measurements Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 7 +++---- packages/gea/benchmarks/path-interning.bench.ts | 2 +- packages/gea/src/lib/store.ts | 6 +----- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5df4bab..d7d91fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10923,7 +10923,7 @@ }, "packages/gea": { "name": "@geajs/core", - "version": "1.2.0", + "version": "1.2.3", "license": "MIT", "dependencies": { "@types/react": "^19.0.0" @@ -11044,7 +11044,7 @@ }, "packages/gea-ui": { "name": "@geajs/ui", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "dependencies": { "@zag-js/accordion": "^1.37.0", @@ -11080,7 +11080,6 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", - "@types/react": "^19.0.0", "prismjs": "^1.30.0", "tailwindcss": "^4.0.0", "tsdown": "^0.21.2", @@ -11116,7 +11115,7 @@ }, "packages/vite-plugin-gea": { "name": "@geajs/vite-plugin", - "version": "1.2.0", + "version": "1.2.3", "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.31", diff --git a/packages/gea/benchmarks/path-interning.bench.ts b/packages/gea/benchmarks/path-interning.bench.ts index 1382587..c18f11f 100644 --- a/packages/gea/benchmarks/path-interning.bench.ts +++ b/packages/gea/benchmarks/path-interning.bench.ts @@ -2,7 +2,7 @@ * Benchmark: path parts interning — eliminate hot-path array allocations * PR #42: Cache [...parent, key] results in _appendCache WeakMap * - * Run: npx tsx --conditions source packages/gea/benchmarks/path-interning.bench.ts + * Run: node --expose-gc --conditions source --import tsx/esm packages/gea/benchmarks/path-interning.bench.ts */ import { Store } from '../src/lib/store.ts' diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index dc7034b..5ee9522 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -95,10 +95,6 @@ function splitPath(path: string | string[]): string[] { return path ? path.split('.') : [] } -function appendPathParts(pathParts: string[], propStr: string): string[] { - return [...pathParts, propStr] -} - // Module-level cache: WeakMap>. // Keys are the stable cached baseParts arrays produced by _makePathCache, // so entries are GC'd automatically when the owning proxy is collected. @@ -346,7 +342,7 @@ function _addObserver(store: Store, pathParts: string[], handler: StoreObserver) const part = pathParts[i] let child = node.children.get(part) if (!child) { - child = _mkNode(appendPathParts(node.pathParts, part)) + child = _mkNode(_internAppend(node.pathParts, part)) node.children.set(part, child) } node = child From d9752ad23f112cbd27cee1d4059db8fbb291dc71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 10:11:45 +0300 Subject: [PATCH 4/6] bench(core): fix path-interning benchmark to exercise _wrapItem interning hot path Co-Authored-By: Claude Sonnet 4.6 --- .../gea/benchmarks/path-interning.bench.ts | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/gea/benchmarks/path-interning.bench.ts b/packages/gea/benchmarks/path-interning.bench.ts index c18f11f..51e6250 100644 --- a/packages/gea/benchmarks/path-interning.bench.ts +++ b/packages/gea/benchmarks/path-interning.bench.ts @@ -78,34 +78,36 @@ console.log('Simulating hot-path proxy navigation: append key to parent path arr console.log(` speedup: ${(oldMs/newMs).toFixed(1)}x\n`) } -// --- Real store: deep reactive property access --- -class DeepStore extends Store { - user = { - profile: { - address: { - city: 'Istanbul', - zip: '34000', - } - } - } +// --- Real store: array _wrapItem → _internAppend hot path --- +// Each .map() call goes through _wrapItem → appendPathParts → _internAppend per element. +// Cold (fresh store per trial): _internAppend must create and cache new path arrays. +// Warm (same store, repeated .map()): _internAppend returns already-cached path arrays. +class ArrayStore extends Store { + rows = Array.from({ length: 100 }, (_, i) => ({ id: i, name: `row-${i}`, active: i % 2 === 0 })) } -const store = new DeepStore() -const STORE_ITERS = 50_000 +const STORE_ITERS = 1_000 if (typeof global.gc === 'function') global.gc() const hs0 = heapMB() -const storeMs = bench(() => { - void store.user.profile.address.city - void store.user.profile.address.zip +// Cold: fresh store each iteration → _internAppend misses on every element +const coldMs = bench(() => { + const s = new ArrayStore() + s.rows.map(r => r.id) }, STORE_ITERS) if (typeof global.gc === 'function') global.gc() const hs1 = heapMB() +// Warm: same store, repeated .map() → _internAppend returns cached path arrays +const warmStore = new ArrayStore() +const warmMs = bench(() => { + warmStore.rows.map(r => r.id) +}, STORE_ITERS) +if (typeof global.gc === 'function') global.gc() +const hs2 = heapMB() -console.log('Real store deep property access (depth 4, 2 leaf props):') -console.log(` ${STORE_ITERS.toLocaleString()} iterations: ${storeMs.toFixed(2)}ms`) -console.log(` per-iter: ${((storeMs/STORE_ITERS)*1000).toFixed(1)}µs`) -console.log(` heap delta: ${(hs1-hs0).toFixed(3)} MB`) -console.log() -console.log('With path interning: same path arrays are reused across proxy navigations.') -console.log('Without interning: each proxy access spreads a new array for each path segment.\n') +console.log('Real store: array .map() via _wrapItem → _internAppend (100 rows):') +console.log(` cold (fresh store, intern misses): ${coldMs.toFixed(2)}ms heap Δ ${(hs1-hs0).toFixed(3)} MB`) +console.log(` warm (cached paths, intern hits): ${warmMs.toFixed(2)}ms heap Δ ${(hs2-hs1).toFixed(3)} MB`) +console.log(` speedup: ${(coldMs/warmMs).toFixed(1)}x\n`) +console.log('With path interning: _wrapItem reuses cached path arrays on repeated .map() calls.') +console.log('Without interning: every .map() would spread a new array for each element path.\n') From 0a6e5065fbaa232b68ae2fc1ef301bc686b0895d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 10:12:41 +0300 Subject: [PATCH 5/6] fix(core): add dotted-key path regression test for array item properties Verifies that pathParts for nested array item property updates like store.items[0].key are emitted as ['items', '0', 'key'] without incorrect dot-splitting. The reduce iterator already uses _wrapItem which calls _internAppend, so no production code change was needed. Co-Authored-By: Claude Sonnet 4.6 --- packages/gea/tests/store.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/gea/tests/store.test.ts b/packages/gea/tests/store.test.ts index 8dd5ee3..c4f5e38 100644 --- a/packages/gea/tests/store.test.ts +++ b/packages/gea/tests/store.test.ts @@ -611,6 +611,20 @@ describe('Store – derived arrays passed as values', () => { }) }) +describe('Store – dotted key path regression', () => { + it('preserves path segments without dot-splitting for nested array item properties', async () => { + const store = new Store({ items: [{ key: 'test' }] }) + const batches: StoreChange[][] = [] + store.observe('items', (_v, c) => batches.push(c)) + + store.items[0].key = 'changed' + await flush() + + assert.equal(batches.length, 1) + assert.deepEqual(batches[0][0].pathParts, ['items', '0', 'key']) + }) +}) + describe('Store – silent()', () => { it('updates values but does not notify observers', async () => { const store = new Store({ count: 0 }) From 90016d0cadd83ef4fb6c5f2c05d94737ae3b232b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 11:01:05 +0300 Subject: [PATCH 6/6] perf(core): cap _internAppend inner Map to prevent unbounded memory growth Add a 10000-entry size cap to the inner Map in _internAppend to guard against unbounded retention in large arrays (e.g., high-cardinality numeric index segments) and SSR scenarios where the cache survives request boundaries. The outer WeakMap is already GC'd with the owning proxy; this cap prevents the inner Map from growing without bound for long-lived stores. Co-Authored-By: Claude Sonnet 4.6 --- packages/gea/src/lib/store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index 5ee9522..1926833 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -109,7 +109,8 @@ function _internAppend(baseParts: string[], segment: string): string[] { let result = inner.get(segment) if (result === undefined) { result = baseParts.length > 0 ? [...baseParts, segment] : [segment] - inner.set(segment, result) + // Cap inner Map to prevent unbounded growth for large arrays (e.g., numeric index keys) + if (inner.size < 10000) inner.set(segment, result) } return result }