Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/fix-denorm-depth-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@data-client/normalizr': 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.
14 changes: 14 additions & 0 deletions examples/benchmark/normalizr.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
ProjectWithBuildTypesDescription,
ProjectSchemaMixin,
User,
Department,
buildBidirectionalChain,
} from './schemas.js';
import userData from './user.json' with { type: 'json' };

Expand Down Expand Up @@ -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);
Expand Down
50 changes: 50 additions & 0 deletions examples/benchmark/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions packages/endpoint/src/schemas/__tests__/Entity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,57 @@ describe(`${Entity.name} denormalization`, () => {
// maintained with nested denormalization.
});

test('does not overflow stack with 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),
};

// Build a linear chain: D-0 → B-0 → D-1 → B-1 → D-2 → B-2 → ...
// Each pk is unique so same-pk cycle detection never fires.
const CHAIN_LENGTH = 1500;
const departmentEntities: Record<string, any> = {};
const buildingEntities: Record<string, any> = {};

for (let i = 0; i < CHAIN_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 < CHAIN_LENGTH - 1 ? [`dept-${i + 1}`] : [],
};
}

const entities = {
Department: departmentEntities,
Building: buildingEntities,
};

expect(() =>
plainDenormalize(Department, 'dept-0', entities),
).not.toThrow();

const memo = new SimpleMemoCache();
expect(() =>
memo.denormalize(Department, 'dept-0', entities),
).not.toThrow();
});

test('denormalizes maintain referential equality when appropriate', () => {
const entities = {
Report: {
Expand Down
34 changes: 33 additions & 1 deletion packages/normalizr/src/denormalize/unvisit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -125,7 +129,21 @@ const getUnvisit = (
}
} else {
if (isEntity(schema)) {
return unvisitEntity(schema, input);
if (depth >= MAX_ENTITY_DEPTH) {
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);
Expand All @@ -142,3 +160,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;
}
11 changes: 11 additions & 0 deletions website/blog/2026-01-19-v0.16-release-announcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ render(<PostWithComments id={1} />);

</HooksPlayground>

## 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.
Expand Down
Loading