diff --git a/packages/react-router-devtools/src/shared/bigint-util.test.ts b/packages/react-router-devtools/src/shared/bigint-util.test.ts index 24f5ae8..986051a 100644 --- a/packages/react-router-devtools/src/shared/bigint-util.test.ts +++ b/packages/react-router-devtools/src/shared/bigint-util.test.ts @@ -63,4 +63,67 @@ describe("convertBigIntToString", () => { const result = convertBigIntToString(123) expect(result).toBe(123) }) + + it("should replace circular references with [Circular]", () => { + const o: Record = {} + o.self = o + const result = convertBigIntToString(o) + expect(result).toEqual({ self: "[Circular]" }) + }) + + it("should replace circular reference at depth 1 with [Circular]", () => { + const root: Record = { a: 1 } + root.nested = { b: 2, back: root } + const result = convertBigIntToString(root) + expect(result).toEqual({ + a: 1, + nested: { b: 2, back: "[Circular]" }, + }) + }) + + it("should not replace shared (non-circular) references with [Circular]", () => { + const shared = { x: 1, y: 2 } + const root = { a: shared, b: shared } + const result = convertBigIntToString(root) + expect(result).toEqual({ + a: { x: 1, y: 2 }, + b: { x: 1, y: 2 }, + }) + }) + + it("should replace deep acyclic structure beyond maxDepth with [Max depth reached]", () => { + let deep: Record = {} + const root = deep + for (let i = 0; i < 60; i++) { + deep.next = {} + deep = deep.next as Record + } + const result = convertBigIntToString(root) + let current: unknown = result + let depth = 0 + while (current !== null && typeof current === "object" && "next" in current) { + current = (current as Record).next + depth++ + } + expect(depth).toBe(50) + expect(current).toBe("[Max depth reached]") + }) + + it("should traverse deeper when maxDepth option is increased", () => { + let deep: Record = {} + const root = deep + for (let i = 0; i < 60; i++) { + deep.next = {} + deep = deep.next as Record + } + const result = convertBigIntToString(root, { maxDepth: 100 }) + let current: unknown = result + let depth = 0 + while (current !== null && typeof current === "object" && "next" in current) { + current = (current as Record).next + depth++ + } + expect(depth).toBe(60) + expect(current).toEqual({}) + }) }) diff --git a/packages/react-router-devtools/src/shared/bigint-util.ts b/packages/react-router-devtools/src/shared/bigint-util.ts index bea311f..1c3a3b2 100644 --- a/packages/react-router-devtools/src/shared/bigint-util.ts +++ b/packages/react-router-devtools/src/shared/bigint-util.ts @@ -1,19 +1,41 @@ // biome-ignore lint/suspicious/noExplicitAny: we don't know the data export const bigIntReplacer = (_key: any, value: any) => (typeof value === "bigint" ? value.toString() : value) +const DEFAULT_MAX_DEPTH = 50 +const CIRCULAR_PLACEHOLDER = "[Circular]" +const MAX_DEPTH_PLACEHOLDER = "[Max depth reached]" + // biome-ignore lint/suspicious/noExplicitAny: we don't know the data -export const convertBigIntToString = (data: any): any => { +function convertBigIntToStringInner(data: any, depth: number, seen: WeakSet, maxDepth: number): any { if (typeof data === "bigint") { return data.toString() } if (Array.isArray(data)) { - return data.map((item) => convertBigIntToString(item)) + if (seen.has(data)) return CIRCULAR_PLACEHOLDER + if (depth >= maxDepth) return MAX_DEPTH_PLACEHOLDER + seen.add(data) + const result = data.map((item) => convertBigIntToStringInner(item, depth + 1, seen, maxDepth)) + seen.delete(data) + return result } if (data !== null && typeof data === "object") { - return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, convertBigIntToString(value)])) + if (seen.has(data)) return CIRCULAR_PLACEHOLDER + if (depth >= maxDepth) return MAX_DEPTH_PLACEHOLDER + seen.add(data) + const result = Object.fromEntries( + Object.entries(data).map(([key, value]) => [key, convertBigIntToStringInner(value, depth + 1, seen, maxDepth)]), + ) + seen.delete(data) + return result } return data } + +// biome-ignore lint/suspicious/noExplicitAny: we don't know the data +export const convertBigIntToString = (data: any, options?: { maxDepth?: number }): any => { + const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH + return convertBigIntToStringInner(data, 0, new WeakSet(), maxDepth) +}