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/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 new file mode 100644 index 0000000..51e6250 --- /dev/null +++ b/packages/gea/benchmarks/path-interning.bench.ts @@ -0,0 +1,113 @@ +/** + * Benchmark: path parts interning — eliminate hot-path array allocations + * PR #42: Cache [...parent, key] results in _appendCache WeakMap + * + * Run: node --expose-gc --conditions source --import tsx/esm 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: 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_ITERS = 1_000 + +if (typeof global.gc === 'function') global.gc() +const hs0 = heapMB() +// 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: 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') diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index 7003e52..1926833 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -95,8 +95,24 @@ 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. +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] + // 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 } function joinPath(basePath: string, seg: string | number): string { @@ -155,7 +171,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 } @@ -327,7 +343,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 @@ -721,11 +737,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 +759,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 +774,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 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 })