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

### @geajs/core (patch)

- **Circular reference regression tests**: Add five regression tests verifying that self-referencing objects, self-referencing arrays, and cross-type circular structures (object ↔ array) are handled correctly without infinite recursion, and that shared arrays at different paths maintain independent path tracking.
59 changes: 44 additions & 15 deletions packages/gea/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ interface StoreInstancePrivate {
flushScheduled: boolean
nextArrayOpId: number
observerRoot: ObserverNode
proxyCache: WeakMap<any, any>
proxyCache: WeakMap<any, Map<string, any>>
arrayIndexProxyCache: WeakMap<any, Map<string, any>>
internedArrayPaths: Map<string, string[]>
topLevelProxies: Map<string, [raw: any, proxy: any]>
Expand Down Expand Up @@ -853,8 +853,18 @@ function _createProxy(

const _p = existingP || getPriv(store)
if (!_isArr(target)) {
const cached = _p.proxyCache.get(target)
if (cached) return cached
const pathMap = _p.proxyCache.get(target)
if (pathMap) {
const cached = pathMap.get(basePath)
if (cached) return cached
// Circular reference guard: if the target already has a proxy at a path
// that is a prefix of basePath, the object references itself (directly or
// transitively). Return the existing proxy to break the cycle and
// preserve reference equality (e.g. store.obj.self === store.obj).
for (const [cachedPath, cachedProxy] of pathMap) {
if (basePath.startsWith(cachedPath + '.')) return cachedProxy
}
}
}

const cachedArrayMeta = arrayMeta ?? _getCachedArrayMeta(_p, baseParts)
Expand Down Expand Up @@ -900,19 +910,26 @@ function _createProxy(
const cached = indexCache.get(prop)
if (cached) return cached
}
const proxyCached = _p.proxyCache.get(value)
if (proxyCached) {
let ic = indexCache || _p.arrayIndexProxyCache.get(obj)
if (!ic) {
ic = new Map()
_p.arrayIndexProxyCache.set(obj, ic)
const proxyPathMap = _p.proxyCache.get(value)
if (proxyPathMap) {
const currentPath = joinPath(basePath, prop as string)
const proxyCached = proxyPathMap.get(currentPath)
if (proxyCached) {
let ic = indexCache || _p.arrayIndexProxyCache.get(obj)
if (!ic) {
ic = new Map()
_p.arrayIndexProxyCache.set(obj, ic)
}
ic.set(prop, proxyCached)
return proxyCached
}
ic.set(prop, proxyCached)
return proxyCached
}
} else {
const cached = _p.proxyCache.get(value)
if (cached) return cached
const pathMap = _p.proxyCache.get(value)
if (pathMap) {
const cached = pathMap.get(joinPath(basePath, prop as string))
if (cached) return cached
}
}
if (!_isPlain(value)) return value
if (isArrIdx) {
Expand Down Expand Up @@ -940,7 +957,12 @@ function _createProxy(
}
const currentPath = joinPath(basePath, prop as string)
const created = _createProxy(store, value, currentPath, getCachedPathParts(prop as string), undefined, _p)
_p.proxyCache.set(value, created)
let pathMap = _p.proxyCache.get(value)
if (!pathMap) {
pathMap = new Map()
_p.proxyCache.set(value, pathMap)
}
pathMap.set(currentPath, created)
return created
},

Expand Down Expand Up @@ -1017,8 +1039,15 @@ function _createProxy(

// Cache the proxy so subsequent accesses (e.g., via .find() in computed
// getters) return the same reference, enabling stable identity checks.
// Keyed by (rawValue, basePath) so the same raw object at different store
// paths gets separate proxies (each closes over its own basePath/baseParts).
if (!_isArr(target)) {
_p.proxyCache.set(target, proxy)
let pathMap = _p.proxyCache.get(target)
if (!pathMap) {
pathMap = new Map()
_p.proxyCache.set(target, pathMap)
}
pathMap.set(basePath, proxy)
}

return proxy
Expand Down
59 changes: 59 additions & 0 deletions packages/gea/tests/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,3 +675,62 @@ describe('Store – silent()', () => {
assert.equal(notified, false, 'observer must not fire for array mutations inside silent()')
})
})

describe('Store – circular reference protection', () => {
it('does not infinite loop when an object references itself', () => {
const store = new Store<any>({ obj: {} })
const objProxy = store.obj
objProxy.self = (objProxy as any)[GEA_PROXY_GET_TARGET]

assert.ok(store.obj.self)
assert.ok((store.obj.self as any)[GEA_PROXY_IS_PROXY])
assert.strictEqual(store.obj.self, store.obj)
})

it('does not infinite loop when an array contains itself', () => {
const arr: any[] = []
arr.push(arr)
const store = new Store<any>({ arr })

const nested = store.arr[0]
assert.ok(nested !== undefined, 'circular array access must not hang')
})

it('returns the same proxy for the same array on repeated access', () => {
const store = new Store<any>({ items: [1, 2, 3] })
assert.strictEqual(store.items, store.items, 'same array should return same proxy reference')
})

it('cross-type circular: object references array that references object', () => {
const obj: any = { name: 'root' }
const arr: any[] = [obj]
obj.arr = arr
const store = new Store<any>({ obj })

assert.equal(store.obj.name, 'root')
assert.strictEqual(store.obj.arr[0], store.obj)
assert.strictEqual(store.obj.arr[0].arr, store.obj.arr)
})

it('shared arrays at different paths maintain independent path tracking', () => {
const shared = [1, 2, 3]
const store = new Store<any>({ a: shared, b: shared })

assert.ok(store.a)
assert.ok(store.b)
assert.equal(store.a.length, 3)
assert.equal(store.b.length, 3)
})

it('circular object does not prevent reactivity', async () => {
const store = new Store<any>({ data: { value: 0 } })
const dataProxy = store.data
dataProxy.self = (dataProxy as any)[GEA_PROXY_GET_TARGET]
const values: number[] = []
store.observe('data.value', (v) => values.push(v as number))

store.data.value = 42
await flush()
assert.deepEqual(values, [42])
})
})