Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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/store-getter-memoization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@geajs/core": patch
---

### @geajs/core (patch)

- **Store getter memoization**: Prototype-level getters on Store subclasses are now memoized with reactive dependency tracking. Cached results are invalidated when tracked fields change. Getters reading `_`-prefixed internal fields or function-valued fields are marked uncacheable. Chained getters propagate dependencies to parent getters.
147 changes: 144 additions & 3 deletions packages/gea/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,94 @@ function shouldWrapNestedReactiveValue(value: any): boolean {

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

/** Per-prototype getter name cache — computed once per subclass. */
const _protoGetterCache = new WeakMap<object, Set<string>>()

function _getProtoGetters(t: Store): Set<string> {
const proto = Object.getPrototypeOf(t)
let cache = _protoGetterCache.get(proto)
if (!cache) {
cache = new Set<string>()
let p: any = proto
while (p && p !== Object.prototype) {
for (const name of Object.getOwnPropertyNames(p)) {
if (name === 'constructor') continue
const desc = Object.getOwnPropertyDescriptor(p, name)
if (desc?.get) cache.add(name)
}
p = Object.getPrototypeOf(p)
}
_protoGetterCache.set(proto, cache)
}
return cache
}

interface GetterMemo {
cache: Map<string, any>
fieldDeps: Map<string, Set<string>>
fieldToGetters: Map<string, Set<string>>
stack: string[]
stackSet: Set<string>
uncacheable: Set<string>
}

const _getterMemo = new WeakMap<Store, GetterMemo>()

function _gm(t: Store): GetterMemo {
let m = _getterMemo.get(t)
if (!m) {
m = { cache: new Map(), fieldDeps: new Map(), fieldToGetters: new Map(), stack: [], stackSet: new Set(), uncacheable: new Set() }
_getterMemo.set(t, m)
}
return m
}

function _gmRecordDep(m: GetterMemo, fieldName: string): void {
const getter = m.stack[m.stack.length - 1]
let deps = m.fieldDeps.get(getter)
if (!deps) { deps = new Set(); m.fieldDeps.set(getter, deps) }
if (!deps.has(fieldName)) {
deps.add(fieldName)
let gtrs = m.fieldToGetters.get(fieldName)
if (!gtrs) { gtrs = new Set(); m.fieldToGetters.set(fieldName, gtrs) }
gtrs.add(getter)
}
}

function _gmMergeChild(m: GetterMemo, childGetter: string): void {
const parentGetter = m.stack[m.stack.length - 1]
const childDeps = m.fieldDeps.get(childGetter)
if (!childDeps || !childDeps.size) return
let parentDeps = m.fieldDeps.get(parentGetter)
if (!parentDeps) { parentDeps = new Set(); m.fieldDeps.set(parentGetter, parentDeps) }
for (const field of childDeps) {
if (!parentDeps.has(field)) {
parentDeps.add(field)
let gtrs = m.fieldToGetters.get(field)
if (!gtrs) { gtrs = new Set(); m.fieldToGetters.set(field, gtrs) }
gtrs.add(parentGetter)
}
}
}

function _gmClearDeps(m: GetterMemo, getterName: string): void {
const oldDeps = m.fieldDeps.get(getterName)
if (!oldDeps) return
for (const field of oldDeps) {
const gtrs = m.fieldToGetters.get(field)
if (gtrs) { gtrs.delete(getterName); if (!gtrs.size) m.fieldToGetters.delete(field) }
}
m.fieldDeps.delete(getterName)
}

function _gmInvalidate(t: Store, fieldName: string): void {
const m = _getterMemo.get(t)
if (!m) return
const gtrs = m.fieldToGetters.get(fieldName)
if (!gtrs || !gtrs.size) return
for (const getter of gtrs) m.cache.delete(getter)
}

function _wrapItem(store: Store, arr: any[], i: number, basePath: string, baseParts: string[]): any {
const raw = arr[i]
return shouldWrapNestedReactiveValue(raw)
Expand Down Expand Up @@ -602,8 +690,15 @@ function _flushChanges(raw: Store, p: StoreInstancePrivate): void {
}

function _pushAndSchedule(raw: Store, changes: StoreChange | StoreChange[], p: StoreInstancePrivate): void {
if (_isArr(changes)) for (const c of changes) p.pendingChanges.push(c)
else p.pendingChanges.push(changes)
if (_isArr(changes)) {
for (const c of changes) {
p.pendingChanges.push(c)
if (c.pathParts.length > 0) _gmInvalidate(raw, c.pathParts[0])
}
} else {
p.pendingChanges.push(changes)
if (changes.pathParts.length > 0) _gmInvalidate(raw, changes.pathParts[0])
}
if (p.pendingBatchKind !== 2) {
p.pendingBatchKind = 2
p.pendingBatchArrayPathParts = null
Expand All @@ -626,6 +721,7 @@ function _isAppend(oldArr: any[], newArr: any[], unwrap: boolean): boolean {

function _queueChange(raw: Store, change: StoreChange, p: StoreInstancePrivate): void {
p.pendingChanges.push(change)
if (change.pathParts.length > 0) _gmInvalidate(raw, change.pathParts[0])
if (
p.pendingBatchKind !== 2 &&
!(p.pendingBatchKind === 1 && p.pendingBatchArrayPathParts === change.arrayPathParts)
Expand Down Expand Up @@ -1076,8 +1172,50 @@ export class Store {
}

static rootGetValue(t: Store, prop: string, receiver: any): any {
if (!_hasOwn.call(t, prop)) return Reflect.get(t, prop, receiver)
if (!_hasOwn.call(t, prop)) {
// Memoize user-defined getters on Store subclasses
if (_getProtoGetters(t).has(prop)) {
const m = _gm(t)
if (!m.stackSet.has(prop)) {
if (m.cache.has(prop)) {
if (m.stack.length > 0) _gmMergeChild(m, prop)
return m.cache.get(prop)
}
_gmClearDeps(m, prop)
m.uncacheable.delete(prop)
m.stack.push(prop)
m.stackSet.add(prop)
let value: any
try {
value = Reflect.get(t, prop, receiver)
} finally {
m.stack.pop()
m.stackSet.delete(prop)
if (m.uncacheable.has(prop) && m.stack.length > 0) {
m.uncacheable.add(m.stack[m.stack.length - 1])
}
}
if (m.stack.length > 0) _gmMergeChild(m, prop)
const deps = m.fieldDeps.get(prop)
if (deps && deps.size > 0 && !m.uncacheable.has(prop)) {
m.cache.set(prop, value)
}
return value
}
}
return Reflect.get(t, prop, receiver)
}
const value = (t as any)[prop]
// Track deps for any currently-computing getter
const gm = _getterMemo.get(t)
if (gm && gm.stack.length > 0) {
if (typeof value === 'function' || (prop as string).startsWith('_')) {
// Function fields and internal (_-prefixed) fields prevent caching
gm.uncacheable.add(gm.stack[gm.stack.length - 1])
} else {
_gmRecordDep(gm, prop)
}
}
if (typeof value === 'function') return value
if (value != null && typeof value === 'object') {
if (!_isPlain(value)) return value
Expand Down Expand Up @@ -1107,6 +1245,7 @@ export class Store {
p.topLevelProxies.delete(prop)
}
;(t as any)[prop] = value
_gmInvalidate(t, prop)
_pushAndSchedule(t, _mkChange(hadProp ? 'update' : 'add', prop, t, pathParts, value, oldValue), p)
return true
}
Expand All @@ -1120,6 +1259,7 @@ export class Store {
_dropOld(p, oldValue)
p.topLevelProxies.delete(prop)
;(t as any)[prop] = value
_gmInvalidate(t, prop)
_commitObjSet(t, !hadProp, prop, t, pathParts, value, oldValue, false, p)
return true
}
Expand All @@ -1132,6 +1272,7 @@ export class Store {
_dropOld(dp, oldValue)
dp.topLevelProxies.delete(prop)
delete (t as any)[prop]
_gmInvalidate(t, prop)
_pushAndSchedule(t, [_mkChange('delete', prop, t, _rootPathPartsCache(dp, prop), undefined, oldValue)], dp)
return true
}
Expand Down
188 changes: 188 additions & 0 deletions packages/gea/tests/store-getter-memoization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { Store } from '../src/lib/store'

async function flush() {
await new Promise((r) => setTimeout(r, 0))
await new Promise((r) => setTimeout(r, 0))
}

class TodoStore extends Store {
todos: { done: boolean; text: string }[] = []
filter: string = 'all'

get filteredTodos() {
if (this.filter === 'done') return this.todos.filter((t) => t.done)
if (this.filter === 'active') return this.todos.filter((t) => !t.done)
return this.todos
}

get count() {
return this.todos.length
}
}

class ChainedStore extends Store {
items: number[] = [1, 2, 3, 4, 5]

get evens() {
return this.items.filter((n) => n % 2 === 0)
}

get evenCount() {
return this.evens.length
}
}

describe('Store getter memoization', () => {
it('returns the same value on repeated access', () => {
const store = new TodoStore()
store.todos = [{ done: false, text: 'a' }]
const first = store.filteredTodos
const second = store.filteredTodos
assert.equal(first, second, 'repeated access should return same cached reference')
})

it('invalidates cache when a direct dependency changes', () => {
const store = new TodoStore()
store.todos = [{ done: false, text: 'a' }, { done: true, text: 'b' }]
store.filter = 'done'
const before = store.filteredTodos
assert.equal(before.length, 1)

store.filter = 'active'
const after = store.filteredTodos
assert.equal(after.length, 1)
assert.notEqual(before, after, 'cache should be invalidated after dependency change')
})

it('recomputes correctly after array mutation', async () => {
const store = new TodoStore()
store.todos = []
assert.equal(store.count, 0)

store.todos = [{ done: false, text: 'x' }]
assert.equal(store.count, 1)
})

it('handles chained getters (getter depending on getter)', () => {
const store = new ChainedStore()
const firstEvenCount = store.evenCount
assert.equal(firstEvenCount, 2)

assert.equal(store.evenCount, 2)

store.items = [1, 2, 3, 4, 5, 6]
const newEvenCount = store.evenCount
assert.equal(newEvenCount, 3)
})

it('does not share cache between different store instances', () => {
const a = new TodoStore()
const b = new TodoStore()
a.todos = [{ done: false, text: 'a' }]
b.todos = []

assert.equal(a.count, 1)
assert.equal(b.count, 0)

a.todos = []
assert.equal(a.count, 0)
assert.equal(b.count, 0)
})

it('tracks computation count', () => {
let calls = 0
class CountStore extends Store {
value = 42
get doubled() {
calls++
return this.value * 2
}
}

const store = new CountStore()
assert.equal(store.doubled, 84)
assert.equal(store.doubled, 84)
assert.equal(store.doubled, 84)
assert.equal(calls, 1, 'getter should only be computed once when deps do not change')

store.value = 10
assert.equal(store.doubled, 20)
assert.equal(calls, 2, 'getter should recompute after dependency change')

store.value = 10 // same value
assert.equal(store.doubled, 20)
assert.equal(calls, 2, 'getter should NOT recompute when value is unchanged')
})

it('handles push() mutation invalidating getter cache', async () => {
class ListStore extends Store {
items: number[] = []
get total() {
return this.items.reduce((s, n) => s + n, 0)
}
}
const store = new ListStore()
assert.equal(store.total, 0)
store.items.push(5)
await flush()
assert.equal(store.total, 5)
})
})

describe('Store getter memoization – uncacheable getters', () => {
it('does not cache getter that reads an internal (_-prefixed) field', () => {
let calls = 0
class MixedStore extends Store {
value = 5
_factor = 2
get total() {
calls++
return this.value * (this as any)._factor
}
}
const store = new MixedStore()
assert.equal(store.total, 10)
assert.equal(store.total, 10)
assert.equal(calls, 2, 'getter reading internal field must not be cached')

;(store as any)._factor = 3
assert.equal(store.total, 15, 'updated value when _factor changes')
})

it('does not cache getter that calls a function-valued own property', () => {
let calls = 0
class FnStore extends Store {
value = 5
multiply = (x: number) => x * 2
get doubled() {
calls++
return this.multiply(this.value)
}
}
const store = new FnStore()
assert.equal(store.doubled, 10)
assert.equal(store.doubled, 10)
assert.equal(calls, 2, 'getter calling function field must not be cached')
})

it('marks parent getter uncacheable when child getter is uncacheable', () => {
let childCalls = 0
class ChainedUncacheable extends Store {
value = 5
_scale = 2
get inner() {
childCalls++
return this.value * (this as any)._scale
}
get outer() {
return this.inner + 1
}
}
const store = new ChainedUncacheable()
assert.equal(store.outer, 11)
assert.equal(store.outer, 11)
assert.equal(childCalls, 2, 'parent of uncacheable getter must also recompute')
})
})