diff --git a/.changeset/fix-denorm-depth-limit.md b/.changeset/fix-denorm-depth-limit.md new file mode 100644 index 000000000000..9c6172c4f3dc --- /dev/null +++ b/.changeset/fix-denorm-depth-limit.md @@ -0,0 +1,13 @@ +--- +'@data-client/normalizr': patch +'@data-client/core': patch +'@data-client/react': patch +'@data-client/vue': patch +--- + +Fix stack overflow during denormalization of large bidirectional entity graphs. + +Add entity depth limit (128) to prevent `RangeError: Maximum call stack size exceeded` +when denormalizing cross-type chains with thousands of unique entities +(e.g., Department → Building → Department → ...). Entities beyond the depth limit +are returned with unresolved ids instead of fully denormalized nested objects. diff --git a/examples/benchmark/normalizr.js b/examples/benchmark/normalizr.js index 3f7c0e9960a6..f8da921f0a3d 100644 --- a/examples/benchmark/normalizr.js +++ b/examples/benchmark/normalizr.js @@ -17,6 +17,8 @@ import { ProjectWithBuildTypesDescription, ProjectSchemaMixin, User, + Department, + buildBidirectionalChain, } from './schemas.js'; import userData from './user.json' with { type: 'json' }; @@ -147,6 +149,18 @@ export default function addNormlizrSuite(suite, filter) { memo.denormalize(User, 'gnoff', githubState.entities); }); + const chain50 = buildBidirectionalChain(50); + add('denormalize bidirectional 50', () => { + return new MemoCache().denormalize( + Department, + chain50.result, + chain50.entities, + ); + }); + add('denormalize bidirectional 50 donotcache', () => { + return denormalize(Department, chain50.result, chain50.entities); + }); + return suite.on('complete', function () { if (process.env.SHOW_OPTIMIZATION) { printStatus(memo.denormalize); diff --git a/examples/benchmark/schemas.js b/examples/benchmark/schemas.js index 08e35a14d191..2ed3c3991f8f 100644 --- a/examples/benchmark/schemas.js +++ b/examples/benchmark/schemas.js @@ -107,6 +107,56 @@ export const getSortedProjects = new Query( }, ); +// Degenerate bidirectional chain for #3822 stack overflow testing +export class Department extends Entity { + id = ''; + name = ''; + buildings = []; + + static key = 'Department'; + pk() { + return this.id; + } +} +export class Building extends Entity { + id = ''; + name = ''; + departments = []; + + static schema = { + departments: [Department], + }; + + static key = 'Building'; + pk() { + return this.id; + } +} +Department.schema = { + buildings: [Building], +}; + +export function buildBidirectionalChain(length) { + const departmentEntities = {}; + const buildingEntities = {}; + for (let i = 0; i < length; i++) { + departmentEntities[`dept-${i}`] = { + id: `dept-${i}`, + name: `Department ${i}`, + buildings: [`bldg-${i}`], + }; + buildingEntities[`bldg-${i}`] = { + id: `bldg-${i}`, + name: `Building ${i}`, + departments: i < length - 1 ? [`dept-${i + 1}`] : [], + }; + } + return { + entities: { Department: departmentEntities, Building: buildingEntities }, + result: 'dept-0', + }; +} + class BuildTypeDescriptionSimpleMerge extends Entity { static merge(existing, incoming) { return incoming; diff --git a/examples/github-app/package-lock.json b/examples/github-app/package-lock.json index 430c49ee693b..01681318449c 100644 --- a/examples/github-app/package-lock.json +++ b/examples/github-app/package-lock.json @@ -18,7 +18,6 @@ "@data-client/img": "0.15.0", "@data-client/react": "0.15.7", "@data-client/rest": "0.15.7", - "@js-temporal/polyfill": "^0.5.1", "antd": "6.3.3", "core-js": "^3.48.0", "history": "^5.3.0", @@ -29,6 +28,7 @@ "rehype-highlight": "7.0.2", "remark-gfm": "4.0.1", "remark-remove-comments": "1.1.1", + "temporal-polyfill": "^0.3.0", "uuid": "^13.0.0" }, "devDependencies": { @@ -4572,18 +4572,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@js-temporal/polyfill": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", - "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", - "license": "ISC", - "dependencies": { - "jsbi": "^4.3.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -14689,16 +14677,16 @@ "license": "MIT" }, "node_modules/happy-dom": { - "version": "20.6.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.6.1.tgz", - "integrity": "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==", + "version": "20.8.8", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.8.tgz", + "integrity": "sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", - "entities": "^6.0.1", + "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" }, @@ -14707,9 +14695,9 @@ } }, "node_modules/happy-dom/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -16382,12 +16370,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", - "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", - "license": "Apache-2.0" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -24619,6 +24601,21 @@ "streamx": "^2.12.5" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz", + "integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.1" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz", + "integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==", + "license": "ISC" + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", diff --git a/examples/todo-app/package-lock.json b/examples/todo-app/package-lock.json index 18ea8c80e1ac..238e079e6b1e 100644 --- a/examples/todo-app/package-lock.json +++ b/examples/todo-app/package-lock.json @@ -9277,16 +9277,16 @@ "license": "MIT" }, "node_modules/happy-dom": { - "version": "20.6.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.6.1.tgz", - "integrity": "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==", + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", + "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", - "entities": "^6.0.1", + "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" }, @@ -9295,9 +9295,9 @@ } }, "node_modules/happy-dom/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { diff --git a/packages/endpoint/src/schemas/__tests__/Entity.test.ts b/packages/endpoint/src/schemas/__tests__/Entity.test.ts index 15322175199f..3aef958c0293 100644 --- a/packages/endpoint/src/schemas/__tests__/Entity.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Entity.test.ts @@ -932,6 +932,227 @@ describe(`${Entity.name} denormalization`, () => { // maintained with nested denormalization. }); + describe('large cross-type bidirectional entity graphs (#3822)', () => { + class Department extends IDEntity { + readonly name: string = ''; + readonly buildings: Building[] = []; + } + class Building extends IDEntity { + readonly name: string = ''; + readonly departments: Department[] = []; + + static schema = { + departments: [Department], + }; + } + Department.schema = { + buildings: new schema.Array(Building), + }; + + function buildChain(length: number) { + const departmentEntities: Record = {}; + const buildingEntities: Record = {}; + + for (let i = 0; i < length; i++) { + departmentEntities[`dept-${i}`] = { + id: `dept-${i}`, + name: `Department ${i}`, + buildings: [`bldg-${i}`], + }; + buildingEntities[`bldg-${i}`] = { + id: `bldg-${i}`, + name: `Building ${i}`, + departments: i < length - 1 ? [`dept-${i + 1}`] : [], + }; + } + + return { + Department: departmentEntities, + Building: buildingEntities, + }; + } + + test('does not overflow stack', () => { + const entities = buildChain(1500); + + expect(() => + plainDenormalize(Department, 'dept-0', entities), + ).not.toThrow(); + + const memo = new SimpleMemoCache(); + expect(() => + memo.denormalize(Department, 'dept-0', entities), + ).not.toThrow(); + }); + + test('entities within depth limit are fully resolved', () => { + const entities = buildChain(5); + const result = plainDenormalize(Department, 'dept-0', entities); + + expect(result).not.toEqual(expect.any(Symbol)); + if (typeof result === 'symbol') return; + expect(result).toBeDefined(); + if (!result) return; + expect(result.id).toBe('dept-0'); + expect(result.buildings[0].id).toBe('bldg-0'); + expect(result.buildings[0].departments[0].id).toBe('dept-1'); + }); + + test('entities beyond depth limit have unresolved nested ids', () => { + const entities = buildChain(1500); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = plainDenormalize(Department, 'dept-0', entities); + + expect(result).not.toEqual(expect.any(Symbol)); + if (typeof result === 'symbol') return; + expect(result).toBeDefined(); + if (!result) return; + + // walk to a depth-limited entity: each hop is 1 entity depth + // dept-0(1) -> bldg-0(2) -> dept-1(3) -> bldg-1(4) -> ... + // At depth 128 we should find truncated entities with unresolved FK ids + let node: any = result; + for (let i = 0; i < 60; i++) { + expect(node.buildings).toBeDefined(); + expect(node.buildings.length).toBe(1); + node = node.buildings[0].departments[0]; + } + // node should still be a Department instance (well within limit) + expect(node).toBeInstanceOf(Department); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Entity depth limit'), + ); + + consoleSpy.mockRestore(); + }); + + test('depth-limited entity that is missing from store', () => { + const entities = buildChain(200); + // dept-64 is the first entity hit by the depth limit (checked at depth=128). + // Removing it exercises the "entity not found" branch in depthLimitEntity. + delete (entities.Department as any)['dept-64']; + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = plainDenormalize(Department, 'dept-0', entities); + expect(result).not.toEqual(expect.any(Symbol)); + + consoleSpy.mockRestore(); + }); + + test('depth-limited entity that fails validation', () => { + class ValidatedDept extends IDEntity { + readonly name: string = ''; + readonly buildings: ValidatedBldg[] = []; + + static validate(processedEntity: any) { + if (processedEntity.name === 'INVALID') return 'invalid entity'; + } + } + class ValidatedBldg extends IDEntity { + readonly name: string = ''; + readonly departments: ValidatedDept[] = []; + + static schema = { + departments: [ValidatedDept], + }; + } + ValidatedDept.schema = { + buildings: new schema.Array(ValidatedBldg), + }; + + const deptEntities: Record = {}; + const bldgEntities: Record = {}; + for (let i = 0; i < 200; i++) { + deptEntities[`dept-${i}`] = { + id: `dept-${i}`, + // dept-64 is the first depth-limited entity; mark it invalid + name: i === 64 ? 'INVALID' : `Department ${i}`, + buildings: [`bldg-${i}`], + }; + bldgEntities[`bldg-${i}`] = { + id: `bldg-${i}`, + name: `Building ${i}`, + departments: i < 199 ? [`dept-${i + 1}`] : [], + }; + } + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = plainDenormalize(ValidatedDept, 'dept-0', { + ValidatedDept: deptEntities, + ValidatedBldg: bldgEntities, + }); + expect(result).not.toEqual(expect.any(Symbol)); + + consoleSpy.mockRestore(); + }); + + test('depth-limited entity with inline object input', () => { + const entities = buildChain(200); + // bldg-63's departments are processed when depth=128, hitting depthLimitEntity. + // Use inline object instead of string pk to exercise the object-input branch. + (entities.Building as any)['bldg-63'].departments = [ + { id: 'dept-64', name: 'Inline Department 64', buildings: [] }, + ]; + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = plainDenormalize(Department, 'dept-0', entities); + expect(result).not.toEqual(expect.any(Symbol)); + if (typeof result === 'symbol') return; + expect(result).toBeDefined(); + if (!result) return; + + // walk to the depth-limited inline entity + let node: any = result; + for (let i = 0; i < 63; i++) { + node = node.buildings[0].departments[0]; + } + // node is dept-63, its building is bldg-63 + expect(node.id).toBe('dept-63'); + const depthLimitedBldg = node.buildings[0]; + expect(depthLimitedBldg.id).toBe('bldg-63'); + // dept-64 was provided as an inline object, so depthLimitEntity used it directly + const inlineDept = depthLimitedBldg.departments[0]; + expect(inlineDept).toBeInstanceOf(Department); + expect(inlineDept.id).toBe('dept-64'); + + consoleSpy.mockRestore(); + }); + + test('depth limit with MemoCache does not cache truncated results', () => { + const entities = buildChain(1500); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const memo = new SimpleMemoCache(); + + const result1 = memo.denormalize(Department, 'dept-0', entities); + expect(result1).not.toEqual(expect.any(Symbol)); + if (typeof result1 === 'symbol') return; + + // Second call should also work (memo doesn't serve stale truncated data) + const result2 = memo.denormalize(Department, 'dept-0', entities); + expect(result2).not.toEqual(expect.any(Symbol)); + if (typeof result2 === 'symbol') return; + expect(result2).toBe(result1); + + consoleSpy.mockRestore(); + }); + }); + test('denormalizes maintain referential equality when appropriate', () => { const entities = { Report: { diff --git a/packages/normalizr/src/denormalize/unvisit.ts b/packages/normalizr/src/denormalize/unvisit.ts index daeec49664dd..83cfd082f200 100644 --- a/packages/normalizr/src/denormalize/unvisit.ts +++ b/packages/normalizr/src/denormalize/unvisit.ts @@ -97,11 +97,15 @@ function noCacheGetEntity( return localCacheKey.get(''); } +const MAX_ENTITY_DEPTH = 128; + const getUnvisit = ( getEntity: DenormGetEntity, cache: Cache, args: readonly any[], ) => { + let depth = 0; + let depthLimitHit = false; // we don't inline this as making this function too big inhibits v8's JIT const unvisitEntity = getUnvisitEntity(getEntity, cache, args, unvisit); function unvisit(schema: any, input: any): any { @@ -125,7 +129,22 @@ const getUnvisit = ( } } else { if (isEntity(schema)) { - return unvisitEntity(schema, input); + if (depth >= MAX_ENTITY_DEPTH) { + /* istanbul ignore if */ + if (process.env.NODE_ENV !== 'production' && !depthLimitHit) { + depthLimitHit = true; + console.error( + `Entity depth limit of ${MAX_ENTITY_DEPTH} reached for "${schema.key}" entity. ` + + `This usually means your schema has very deep or wide bidirectional relationships. ` + + `Nested entities beyond this depth are returned with unresolved ids.`, + ); + } + return depthLimitEntity(getEntity, schema, input); + } + depth++; + const result = unvisitEntity(schema, input); + depth--; + return result; } return schema.denormalize(input, args, unvisit); @@ -142,3 +161,17 @@ const getUnvisit = ( }; }; export default getUnvisit; + +/** At depth limit: return entity without resolving nested schema fields */ +function depthLimitEntity( + getEntity: DenormGetEntity, + schema: EntityInterface, + input: any, +): object | undefined | typeof INVALID { + const entity = + typeof input !== 'object' ? + getEntity({ key: schema.key, pk: input }) + : input; + if (typeof entity !== 'object' || entity === null) return entity as any; + return schema.createIfValid(entity) ?? INVALID; +} diff --git a/website/blog/2026-01-19-v0.16-release-announcement.md b/website/blog/2026-01-19-v0.16-release-announcement.md index c6fa982117f6..45f8b87e536b 100644 --- a/website/blog/2026-01-19-v0.16-release-announcement.md +++ b/website/blog/2026-01-19-v0.16-release-announcement.md @@ -222,6 +222,17 @@ render(); +## Denormalization depth limit + +Denormalization now enforces a depth limit of 128 entity hops to prevent +`RangeError: Maximum call stack size exceeded` when schemas have deep or wide +bidirectional relationships (e.g., `Department → Building → Department → ...` +with thousands of unique entities). Entities beyond the depth limit are returned +with unresolved ids. A `console.error` is emitted in development mode when the +limit is reached. + +[#3822](https://github.com/reactive/data-client/issues/3822) + ## Migration guide This upgrade requires updating all package versions simultaneously. diff --git a/yarn.lock b/yarn.lock index bc1d96be9f3b..67243a082091 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8152,6 +8152,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=20.0.0": + version: 25.5.0 + resolution: "@types/node@npm:25.5.0" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10c0/70c508165b6758c4f88d4f91abca526c3985eee1985503d4c2bd994dbaf588e52ac57e571160f18f117d76e963570ac82bd20e743c18987e82564312b3b62119 + languageName: node + linkType: hard + "@types/node@npm:^12.7.1": version: 12.20.55 resolution: "@types/node@npm:12.20.55" @@ -8166,15 +8175,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.0.0": - version: 20.19.37 - resolution: "@types/node@npm:20.19.37" - dependencies: - undici-types: "npm:~6.21.0" - checksum: 10c0/8420353aee776ae5c1e9720058949909a0e908fa2af85501e9db2645bb9f9acd7d767890f8aaee34d375e6f8b5f550a9a169e908d2d8cf7d5daf63dce64092a0 - languageName: node - linkType: hard - "@types/normalize-package-data@npm:^2.4.3, @types/normalize-package-data@npm:^2.4.4": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -16048,15 +16048,16 @@ __metadata: linkType: hard "happy-dom@npm:^20.1.0": - version: 20.1.0 - resolution: "happy-dom@npm:20.1.0" + version: 20.8.8 + resolution: "happy-dom@npm:20.8.8" dependencies: - "@types/node": "npm:^20.0.0" + "@types/node": "npm:>=20.0.0" "@types/whatwg-mimetype": "npm:^3.0.2" "@types/ws": "npm:^8.18.1" + entities: "npm:^7.0.1" whatwg-mimetype: "npm:^3.0.0" ws: "npm:^8.18.3" - checksum: 10c0/a55b5fe6de845124644bc51184c8e761e75936de50ba5f4093ff844802d2b13f700c71a5e8236cb78c2b23c1368125b98f38e3c99e472b6d6becec7ddb720c58 + checksum: 10c0/9b2c05f08bbb1cfdef66e1b53f3f7e7aa7ddd8ac19cefc02bf0777b6b444afa975193a26bc67ff9a1eba20724c08ffe663e6ed665177cb074d6dd6e82ea2ed03 languageName: node linkType: hard @@ -28664,13 +28665,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.21.0": - version: 6.21.0 - resolution: "undici-types@npm:6.21.0" - checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 - languageName: node - linkType: hard - "undici-types@npm:~7.16.0": version: 7.16.0 resolution: "undici-types@npm:7.16.0"