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
7 changes: 7 additions & 0 deletions .changeset/append-path-parts-interning.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 3 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 113 additions & 0 deletions packages/gea/benchmarks/path-interning.bench.ts
Original file line number Diff line number Diff line change
@@ -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<string[], Map<string, string[]>>()
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')
32 changes: 24 additions & 8 deletions packages/gea/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<baseParts, Map<segment, result>>.
// 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<string[], Map<string, string[]>>()

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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/gea/tests/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down