From af337274d928ac085fd50d3f2d4eb8606e4ae474 Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Sun, 25 Oct 2026 01:33:02 +0700 Subject: [PATCH] fix(codec): only treat ancestors as circular references --- packages/kit/src/codec/encode.ts | 13 ++++++++++ tests/unit/codec/encode-value.test.ts | 37 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/kit/src/codec/encode.ts b/packages/kit/src/codec/encode.ts index 089d96ad9..4cb30d81f 100644 --- a/packages/kit/src/codec/encode.ts +++ b/packages/kit/src/codec/encode.ts @@ -63,6 +63,7 @@ interface EncodeState { entryOffset: number depth: number seen: WeakMap + expanded: WeakSet handles: ValueHandleRegistry handleScope?: string maxDepth: number @@ -77,6 +78,7 @@ export function encodeValue(value: unknown, options: ValueCodecOptions = {}): En entryOffset: options.entryOffset ?? 0, entryKeys: options.entryKeys, seen: new WeakMap(), + expanded: new WeakSet(), handles, handleScope: options.handleScope, maxDepth: options.maxDepth ?? 2, @@ -138,8 +140,19 @@ function encodeUnsafe(value: unknown, state: EncodeState): EncodedValue { if (circular) return { kind: 'circular', handle: circular } const handle = state.handles.getOrCreate(value, state.handleScope) + // Only ancestors on the current path are circular. A value repeated elsewhere + // is encoded again, but its children are walked only once per encodeValue call. + const repeated = state.expanded.has(value) + if (state.depth < state.maxDepth) state.expanded.add(value) state.seen.set(value, handle) + try { + return encodeReference(value, handle, repeated ? { ...state, maxDepth: state.depth } : state) + } finally { + state.seen.delete(value) + } +} +function encodeReference(value: object, handle: ValueHandle, state: EncodeState): EncodedValue { if (isCustomValue(value)) return encodeCustomValue(value, handle, state) if (isDomElement(value)) return encodeDomElement(value) diff --git a/tests/unit/codec/encode-value.test.ts b/tests/unit/codec/encode-value.test.ts index 0dc98a9cc..96d5eae15 100644 --- a/tests/unit/codec/encode-value.test.ts +++ b/tests/unit/codec/encode-value.test.ts @@ -73,6 +73,43 @@ describe('encodeValue', () => { expect(secondChild.preview.find((entry) => entry.key === 'root')?.value.kind).toBe('circular') }) + it('does not mark repeated non-circular references as circular', () => { + const item = { id: 1 } + const day = new Date(0) + + const encoded = encodeValue({ items: [item, item], start: day, end: day }, { maxDepth: 3 }) + + expect(encoded.kind).toBe('object') + if (encoded.kind !== 'object') return + + const items = encoded.preview.find((entry) => entry.key === 'items')?.value + expect(items?.kind).toBe('array') + if (items?.kind !== 'array') return + + const [first, second] = items.preview.map((entry) => entry.value) + expect(first).toMatchObject({ + kind: 'object', + entries: 1, + preview: [{ key: 'id', value: { kind: 'number', value: 1 } }], + }) + if (first?.kind !== 'object') return + // The repeat keeps its handle, so its fields load on expand like a value at the depth limit. + expect(second).toEqual({ ...first, preview: [] }) + + const date = { kind: 'date', value: '1970-01-01T00:00:00.000Z' } + expect(encoded.preview.find((entry) => entry.key === 'start')?.value).toEqual(date) + expect(encoded.preview.find((entry) => entry.key === 'end')?.value).toEqual(date) + }) + + it('walks a shared subtree once instead of once per path', () => { + let node: object = { leaf: true } + for (let level = 0; level < 16; level++) node = { left: node, right: node } + + const encoded = encodeValue(node, { maxDepth: 20 }) + + expect(JSON.stringify(encoded).length).toBeLessThan(10_000) + }) + it('truncates previews according to the configured state budget', () => { const encoded = encodeValue({ first: 1, second: 2, third: 3 }, { maxEntries: 2 })