diff --git a/.changeset/circular-reference-protection.md b/.changeset/circular-reference-protection.md new file mode 100644 index 00000000..88b7567d --- /dev/null +++ b/.changeset/circular-reference-protection.md @@ -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. diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index 7003e52c..bec4293f 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -63,7 +63,7 @@ interface StoreInstancePrivate { flushScheduled: boolean nextArrayOpId: number observerRoot: ObserverNode - proxyCache: WeakMap + proxyCache: WeakMap> arrayIndexProxyCache: WeakMap> internedArrayPaths: Map topLevelProxies: Map @@ -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) @@ -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) { @@ -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 }, @@ -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 diff --git a/packages/gea/tests/store.test.ts b/packages/gea/tests/store.test.ts index 8dd5ee38..a25545c8 100644 --- a/packages/gea/tests/store.test.ts +++ b/packages/gea/tests/store.test.ts @@ -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({ 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({ 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({ 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({ 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({ 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({ 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]) + }) +})