From 5cc422c1be6372046521759b7cb31f2e1b0fd23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 03:31:28 +0300 Subject: [PATCH 1/4] fix(core): add circular reference regression tests Add six regression tests verifying that self-referencing objects, self-referencing arrays, and cross-type circular structures (object <-> array) do not cause infinite recursion in store proxies. Tests verify that shared arrays at different paths maintain independent path tracking, and that circular structures do not prevent reactivity. Adapted to use GEA_PROXY_IS_PROXY and GEA_PROXY_GET_TARGET symbols from the refactored store internals. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/circular-reference-protection.md | 7 +++ packages/gea/tests/store.test.ts | 59 +++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .changeset/circular-reference-protection.md diff --git a/.changeset/circular-reference-protection.md b/.changeset/circular-reference-protection.md new file mode 100644 index 00000000..687fa2f5 --- /dev/null +++ b/.changeset/circular-reference-protection.md @@ -0,0 +1,7 @@ +--- +"@geajs/core": patch +--- + +### @geajs/core (patch) + +- **Circular reference regression tests**: Add six 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/tests/store.test.ts b/packages/gea/tests/store.test.ts index 8dd5ee38..2e50b2dd 100644 --- a/packages/gea/tests/store.test.ts +++ b/packages/gea/tests/store.test.ts @@ -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({ 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] + 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({ 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]) + }) +}) From fdcfe0bf1d2d680a36410622bd61e384e8d16cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 08:17:49 +0300 Subject: [PATCH 2/4] docs: fix changeset test count for circular reference protection Co-Authored-By: Claude Sonnet 4.6 --- .changeset/circular-reference-protection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/circular-reference-protection.md b/.changeset/circular-reference-protection.md index 687fa2f5..88b7567d 100644 --- a/.changeset/circular-reference-protection.md +++ b/.changeset/circular-reference-protection.md @@ -4,4 +4,4 @@ ### @geajs/core (patch) -- **Circular reference regression tests**: Add six 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. +- **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. From 7f60eebe52d3c8b8143319ac9a5ed41e95999e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 09:46:08 +0300 Subject: [PATCH 3/4] fix(core): scope proxy cache by path to prevent stale proxies for shared objects A shared raw object accessed at different store paths must get separate proxies since each proxy closes over a different basePath/baseParts. Co-Authored-By: Claude Sonnet 4.6 --- packages/gea/src/lib/store.ts | 59 ++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 15 deletions(-) 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 From 2fcdc61e851f62519fcac1d32653c9375a15fa51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 8 Apr 2026 11:04:57 +0300 Subject: [PATCH 4/4] test(core): strengthen circular array reference assertion Replace weak `assert.ok(nested !== undefined)` with a more meaningful check: the accessed element must not hang, be non-undefined, and must be either the same proxy (path-identity case) or wrap an array target. This verifies the circular reference guard works correctly without assuming implementation-specific proxy identity. Co-Authored-By: Claude Sonnet 4.6 --- packages/gea/tests/store.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/gea/tests/store.test.ts b/packages/gea/tests/store.test.ts index 2e50b2dd..a25545c8 100644 --- a/packages/gea/tests/store.test.ts +++ b/packages/gea/tests/store.test.ts @@ -693,7 +693,10 @@ describe('Store – circular reference protection', () => { 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', () => {