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/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
62 changes: 62 additions & 0 deletions packages/gea/tests/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,3 +675,65 @@ 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]
// Accessing a circular array element must not hang or throw;
// it returns a proxy wrapping the same raw array at a child path
assert.ok(nested !== undefined, 'circular array access must not hang')
assert.ok(nested === store.arr || Array.isArray((nested as any).__getTarget?.() ?? nested), 'circular element must be an array proxy')
})

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])
})
})