From 9349b1e8e20ae9453db2b619d07386a57d52e83b Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 3 Jul 2026 15:35:40 +0200 Subject: [PATCH 1/8] test: add failing regression tests for updateObject null-chunk invariant Two regression tests reproduce an Invariant violation (expected CompositeNull, got ObjectDifference) when a normalized node has one chunk where a field is an embedded object and another where it is explicit null. A later write that changes that field produces a single ObjectDifference that is applied to every chunk of the node, tripping assert(isObjectValue(base)) on the CompositeNull chunk. One test triggers the divergent chunks via aliases, the other via a repeated node id in a list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/__tests__/regression.test.ts | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/packages/apollo-forest-run/src/__tests__/regression.test.ts b/packages/apollo-forest-run/src/__tests__/regression.test.ts index ebe407336..12a55e255 100644 --- a/packages/apollo-forest-run/src/__tests__/regression.test.ts +++ b/packages/apollo-forest-run/src/__tests__/regression.test.ts @@ -1558,3 +1558,149 @@ test("correctly handles optimistic fragment write for deeply nested node", () => { items: [item1, item2] }, ]); }); + +test("update must not crash when a node field is null in one chunk and an object in another", () => { + // Aliasing produces an Object chunk and a CompositeNull chunk for the same + // node; a later ObjectDifference is applied to both, asserting + // isObjectValue(base) on the null chunk. + const cache = new ForestRun(); + + const aliasedQuery = gql` + query Aliased { + x: foo { + __typename + id + details { + __typename + a + b + } + } + y: foo { + __typename + id + details { + __typename + a + b + } + } + } + `; + const singleQuery = gql` + query Single { + foo { + __typename + id + details { + __typename + a + b + } + } + } + `; + + // Same node written twice in one operation: object via `x`, null via `y`. + cache.write({ + query: aliasedQuery, + result: { + x: { + __typename: "Foo", + id: "1", + details: { __typename: "Detail", a: 1, b: 1 }, + }, + y: { + __typename: "Foo", + id: "1", + details: null, + }, + }, + }); + + // Changing details yields one ObjectDifference applied to every chunk, + // including the CompositeNull one. + const writeChanged = () => + cache.write({ + query: singleQuery, + result: { + foo: { + __typename: "Foo", + id: "1", + details: { __typename: "Detail", a: 2, b: 1 }, + }, + }, + }); + + expect(writeChanged).not.toThrow(); +}); + +test("update must not crash when the same node id appears twice with divergent details (no alias)", () => { + // Same regression via a normalized node-id collision rather than aliasing: + // the same node id appears twice in one list result with `details` an object + // in one occurrence and null in the other, yielding an Object chunk and a + // CompositeNull chunk without aliasing. + const cache = new ForestRun(); + + const listQuery = gql` + query List { + foos { + __typename + id + details { + __typename + a + b + } + } + } + `; + const singleQuery = gql` + query Single { + foo { + __typename + id + details { + __typename + a + b + } + } + } + `; + + // Same node id twice in one list result: object then null. + cache.write({ + query: listQuery, + result: { + foos: [ + { + __typename: "Foo", + id: "1", + details: { __typename: "Detail", a: 1, b: 1 }, + }, + { + __typename: "Foo", + id: "1", + details: null, + }, + ], + }, + }); + + // Changing details yields one ObjectDifference applied to every chunk, + // including the CompositeNull one. + const writeChanged = () => + cache.write({ + query: singleQuery, + result: { + foo: { + __typename: "Foo", + id: "1", + details: { __typename: "Detail", a: 2, b: 1 }, + }, + }, + }); + + expect(writeChanged).not.toThrow(); +}); From 61826af7a46d4366c98fcedc2be6945850d7a6f1 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Sat, 4 Jul 2026 14:24:45 +0200 Subject: [PATCH 2/8] fix(forest-run): skip null chunk when applying object/list diff to divergent node chunks A normalized node can have divergent chunks for the same field - an explicit null (CompositeNull) in one chunk and an embedded object/list in another (e.g. aliased selections or a repeated node id in one write, or a partial/errored write). The single object/list difference computed for the node was applied to every chunk, so updateValue hit assert(isObjectValue(base)) on the null chunk and threw an Invariant violation. updateObjectValue now skips the null chunk (mirroring the existing inconsistent-state handling for missing fields) and logs a descriptive, actionable warning instead of crashing. The lingering null chunk is reconciled on the next write of the node. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/forest/updateObject.ts | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/apollo-forest-run/src/forest/updateObject.ts b/packages/apollo-forest-run/src/forest/updateObject.ts index 0bf8841cb..ffe2bccf2 100644 --- a/packages/apollo-forest-run/src/forest/updateObject.ts +++ b/packages/apollo-forest-run/src/forest/updateObject.ts @@ -99,6 +99,25 @@ function updateObjectValue( continue; } + // A node can have divergent chunks for the same field: an explicit null + // (CompositeNull) in one chunk and a composite object/list in another (e.g. aliased + // selections or a repeated node id in a single write, or a partial/errored write). + // The one object/list difference computed for the node is applied to every chunk, so + // skip the null chunk here instead of asserting on it downstream in updateValue. + if ( + Value.isCompositeNullValue(value) && + (Difference.isObjectDifference(fieldDiff) || + Difference.isCompositeListDifference(fieldDiff)) + ) { + context.env.logger?.warn( + divergentNullFieldMessage(context, base, fieldDiff, { + kind: "field", + fieldName: fieldInfo.name, + }), + ); + continue; + } + const updated = updateValue(context, value, fieldDiff, { kind: "field", fieldName: fieldInfo.name, @@ -218,6 +237,40 @@ function incompatibleDifferenceMessage( ); } +// Diagnostic for a node field that resolves to an explicit null (CompositeNull) in one +// chunk while an incoming ObjectDifference/CompositeListDifference expects a composite +// value in another. Such divergent chunks are skipped (left for a later write to +// reconcile) rather than crashing; this message stays at least as descriptive as the +// previous invariant and adds best-effort, actionable advice. +function divergentNullFieldMessage( + context: UpdateTreeContext, + base: ObjectChunk, + fieldDiff: ObjectDifference | CompositeListDifference, + location: UpdateValueLocation, +): string { + const isObject = Difference.isObjectDifference(fieldDiff); + const differenceKind = isObject + ? "ObjectDifference" + : "CompositeListDifference"; + const expectedShape = isObject ? "object" : "list"; + const typeClause = incomingTypeClause(differenceTypeName(fieldDiff)); + const detail = + `field is an explicit null (CompositeNull) in this chunk, but the incoming ` + + `${differenceKind}${typeClause} expects a composite ${expectedShape}; the node has ` + + `divergent chunks for this field (null in one, ${expectedShape} in another). ` + + `Leaving the null chunk unchanged - it will be reconciled on the next write of this ` + + `node. This usually means the same node was written with conflicting values in a single ` + + `operation (e.g. aliased selections or a repeated node id) or via a partial/errored ` + + `write; ensure such selections resolve this field consistently.`; + return updateFailureMessage( + context, + base, + detail, + location, + `Skipping update for "${context.operation.debugName}"`, + ); +} + function incompatibleReplacementMessage( context: UpdateTreeContext, base: GraphChunk, @@ -294,8 +347,8 @@ function updateFailureMessage( base: GraphChunk, detail: string, location?: UpdateValueLocation, + prefix = `Failed to update "${context.operation.debugName}"`, ): string { - const prefix = `Failed to update "${context.operation.debugName}"`; const basePath = chunkPath(context, base); const pathString = basePath?.length ? basePath.join(".") From 62ae0abb2fcc89a757d52bd4b2338df0a0e4705a Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Sat, 4 Jul 2026 14:33:21 +0200 Subject: [PATCH 3/8] fix(forest-run): simplify divergent null-chunk warning wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/apollo-forest-run/src/forest/updateObject.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apollo-forest-run/src/forest/updateObject.ts b/packages/apollo-forest-run/src/forest/updateObject.ts index ffe2bccf2..80af2d534 100644 --- a/packages/apollo-forest-run/src/forest/updateObject.ts +++ b/packages/apollo-forest-run/src/forest/updateObject.ts @@ -258,8 +258,8 @@ function divergentNullFieldMessage( `field is an explicit null (CompositeNull) in this chunk, but the incoming ` + `${differenceKind}${typeClause} expects a composite ${expectedShape}; the node has ` + `divergent chunks for this field (null in one, ${expectedShape} in another). ` + - `Leaving the null chunk unchanged - it will be reconciled on the next write of this ` + - `node. This usually means the same node was written with conflicting values in a single ` + + `Leaving the null value unchanged. This usually means the same node was written with ` + + `conflicting values in a single ` + `operation (e.g. aliased selections or a repeated node id) or via a partial/errored ` + `write; ensure such selections resolve this field consistently.`; return updateFailureMessage( From 896ab83da3cfd08e16b5460e10bb78e091f1a9f1 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Sat, 4 Jul 2026 14:56:25 +0200 Subject: [PATCH 4/8] chore: add beachball change file for apollo-forest-run fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...lo-forest-run-6b6eb87f-21ce-43f0-b729-efe2047dfa19.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@graphitation-apollo-forest-run-6b6eb87f-21ce-43f0-b729-efe2047dfa19.json diff --git a/change/@graphitation-apollo-forest-run-6b6eb87f-21ce-43f0-b729-efe2047dfa19.json b/change/@graphitation-apollo-forest-run-6b6eb87f-21ce-43f0-b729-efe2047dfa19.json new file mode 100644 index 000000000..4e6fb27a2 --- /dev/null +++ b/change/@graphitation-apollo-forest-run-6b6eb87f-21ce-43f0-b729-efe2047dfa19.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix(apollo-forest-run): tolerate divergent null/object node chunks during update", + "packageName": "@graphitation/apollo-forest-run", + "email": "vrazuvaev@microsoft.com_msteamsmdb", + "dependentChangeType": "patch" +} From 3ee154ec6382b6f284ed9d4236a74df773ae47e2 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Tue, 7 Jul 2026 12:49:13 +0200 Subject: [PATCH 5/8] fix(apollo-forest-run): also tolerate null/missing composite list items during update Production telemetry shows the same divergent-chunk invariant firing at list-item positions (e.g. 'at path 0: expected CompositeUndefined/CompositeNull, got ObjectDifference'), not just fields. The field-level guard did not cover list items, which reach updateValue via updateCompositeListValue without a null/missing guard. Consolidate the tolerance into updateValue: when an Object/CompositeList difference is applied to a CompositeNull or CompositeUndefined base, skip it (leave unchanged) and log a descriptive warning, for both field and list-item locations. Genuine type mismatches (leaf null, scalars, complex scalars) still assert. Adds a list-item regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/__tests__/regression.test.ts | 74 ++++++++++++++++ .../src/forest/updateObject.ts | 86 +++++++++++-------- 2 files changed, 124 insertions(+), 36 deletions(-) diff --git a/packages/apollo-forest-run/src/__tests__/regression.test.ts b/packages/apollo-forest-run/src/__tests__/regression.test.ts index 8d8ede835..c3247617c 100644 --- a/packages/apollo-forest-run/src/__tests__/regression.test.ts +++ b/packages/apollo-forest-run/src/__tests__/regression.test.ts @@ -1841,3 +1841,77 @@ test("merge policy on embedded object must not crash when a prior null operation }, }); }); + +test("update must not crash when a list item is null in one chunk and an object in another", () => { + // Same divergent-chunk regression, but the null/object divergence is on a *list item* + // rather than a field. The list difference is applied to every chunk of the node, so the + // per-item ObjectDifference reaches the CompositeNull element of the divergent chunk + // (updateValue is called for list items without a null guard). It must be skipped, not crash. + const cache = new ForestRun(); + + const aliasedQuery = gql` + query Aliased { + x: foo { + __typename + id + items { + __typename + value + } + } + y: foo { + __typename + id + items { + __typename + value + } + } + } + `; + const singleQuery = gql` + query Single { + foo { + __typename + id + items { + __typename + value + } + } + } + `; + + // Same node written twice in one operation: item object via `x`, null item via `y`. + cache.write({ + query: aliasedQuery, + result: { + x: { + __typename: "Foo", + id: "1", + items: [{ __typename: "Item", value: 1 }], + }, + y: { + __typename: "Foo", + id: "1", + items: [null], + }, + }, + }); + + // Changing the item yields one CompositeListDifference (with a per-item ObjectDifference) + // applied to every chunk, including the one whose item is CompositeNull. + const writeChanged = () => + cache.write({ + query: singleQuery, + result: { + foo: { + __typename: "Foo", + id: "1", + items: [{ __typename: "Item", value: 2 }], + }, + }, + }); + + expect(writeChanged).not.toThrow(); +}); diff --git a/packages/apollo-forest-run/src/forest/updateObject.ts b/packages/apollo-forest-run/src/forest/updateObject.ts index 80af2d534..dceb6f539 100644 --- a/packages/apollo-forest-run/src/forest/updateObject.ts +++ b/packages/apollo-forest-run/src/forest/updateObject.ts @@ -99,25 +99,6 @@ function updateObjectValue( continue; } - // A node can have divergent chunks for the same field: an explicit null - // (CompositeNull) in one chunk and a composite object/list in another (e.g. aliased - // selections or a repeated node id in a single write, or a partial/errored write). - // The one object/list difference computed for the node is applied to every chunk, so - // skip the null chunk here instead of asserting on it downstream in updateValue. - if ( - Value.isCompositeNullValue(value) && - (Difference.isObjectDifference(fieldDiff) || - Difference.isCompositeListDifference(fieldDiff)) - ) { - context.env.logger?.warn( - divergentNullFieldMessage(context, base, fieldDiff, { - kind: "field", - fieldName: fieldInfo.name, - }), - ); - continue; - } - const updated = updateValue(context, value, fieldDiff, { kind: "field", fieldName: fieldInfo.name, @@ -188,6 +169,18 @@ function updateValue( // Note: building the diagnostic message is expensive (path/type lookups), so it is // constructed only on the failure path rather than eagerly passed to assert(). if (!Value.isObjectValue(base)) { + // A node can have divergent chunks for the same position: an explicit null + // (CompositeNull) or a missing value (CompositeUndefined) in one chunk and a + // composite object in another (e.g. aliased selections, a repeated node id in a + // single write, or a partial/errored write). The one difference computed for the + // node is applied to every chunk, so skip the null/missing chunk instead of + // crashing. Genuine type mismatches (e.g. leaf null, scalars) still assert. + if (isNullOrMissingCompositeBase(base)) { + context.env.logger?.warn( + divergentCompositeMessage(context, base, difference, location), + ); + return getSourceValue(base); + } assert( false, incompatibleDifferenceMessage(context, base, difference, location), @@ -198,6 +191,12 @@ function updateValue( case DifferenceKind.CompositeListDifference: { if (!Value.isCompositeListValue(base)) { + if (isNullOrMissingCompositeBase(base)) { + context.env.logger?.warn( + divergentCompositeMessage(context, base, difference, location), + ); + return getSourceValue(base); + } assert( false, incompatibleDifferenceMessage(context, base, difference, location), @@ -237,31 +236,46 @@ function incompatibleDifferenceMessage( ); } -// Diagnostic for a node field that resolves to an explicit null (CompositeNull) in one -// chunk while an incoming ObjectDifference/CompositeListDifference expects a composite -// value in another. Such divergent chunks are skipped (left for a later write to -// reconcile) rather than crashing; this message stays at least as descriptive as the -// previous invariant and adds best-effort, actionable advice. -function divergentNullFieldMessage( +// True when an incoming composite (Object/CompositeList) difference is being applied to a +// base that is an explicit null (CompositeNull) or missing (CompositeUndefined). This +// happens when a node has divergent chunks for the same field/list-item across a single +// operation; such chunks are skipped rather than crashing. Genuine type mismatches (leaf +// null, scalars, complex scalars, etc.) are excluded and still assert. +function isNullOrMissingCompositeBase(base: GraphChunk): boolean { + return Value.isCompositeNullValue(base) || Value.isMissingValue(base); +} + +// Diagnostic for a field or list item that resolves to an explicit null (CompositeNull) or +// a missing value (CompositeUndefined) in one chunk while an incoming composite +// Object/CompositeList difference expects an object/list in another. Such divergent chunks +// are skipped (left unchanged) rather than crashing; this message stays at least as +// descriptive as the previous invariant and adds best-effort, actionable advice. +function divergentCompositeMessage( context: UpdateTreeContext, - base: ObjectChunk, - fieldDiff: ObjectDifference | CompositeListDifference, + base: GraphChunk, + difference: ObjectDifference | CompositeListDifference, location: UpdateValueLocation, ): string { - const isObject = Difference.isObjectDifference(fieldDiff); + const isObject = Difference.isObjectDifference(difference); const differenceKind = isObject ? "ObjectDifference" : "CompositeListDifference"; const expectedShape = isObject ? "object" : "list"; - const typeClause = incomingTypeClause(differenceTypeName(fieldDiff)); + const typeClause = incomingTypeClause(differenceTypeName(difference)); + const isNull = Value.isCompositeNullValue(base); + const baseDesc = isNull + ? "an explicit null (CompositeNull)" + : "missing (CompositeUndefined)"; + const otherDesc = isNull ? "null" : "missing"; + const position = location.kind === "field" ? "field" : "list item"; const detail = - `field is an explicit null (CompositeNull) in this chunk, but the incoming ` + - `${differenceKind}${typeClause} expects a composite ${expectedShape}; the node has ` + - `divergent chunks for this field (null in one, ${expectedShape} in another). ` + - `Leaving the null value unchanged. This usually means the same node was written with ` + - `conflicting values in a single ` + - `operation (e.g. aliased selections or a repeated node id) or via a partial/errored ` + - `write; ensure such selections resolve this field consistently.`; + `${position} is ${baseDesc} in this chunk, but the incoming ${differenceKind}` + + `${typeClause} expects a composite ${expectedShape}; the node has divergent chunks for ` + + `this ${position} (${otherDesc} in one, ${expectedShape} in another). ` + + `Leaving the ${otherDesc} value unchanged. This usually means the same node was written ` + + `with conflicting values in a single operation (e.g. aliased selections or a repeated ` + + `node id) or via a partial/errored write; ensure such selections resolve this ` + + `${position} consistently.`; return updateFailureMessage( context, base, From a6e044fbb8d5ba5171899823cc4c31073fa4a0c3 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 10 Jul 2026 18:22:21 +0200 Subject: [PATCH 6/8] Report full data path in incompatible-update invariants When an incompatible-update invariant fires on a non-addressable value (a CompositeNull/CompositeUndefined, whose source data is null/undefined), getDataPathForDebugging cannot locate it, so the message fell back to only the last field/index segment (e.g. 'value' instead of 'listContainer.items.0.value'). Thread the failing value's parent chunk (always an addressable ObjectChunk/CompositeListChunk) into the location and derive the path from the parent plus the field/index step, so the full path is reported even for non-addressable values. The enclosing (in ) node is likewise resolved from the parent when needed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json | 7 ++ .../src/forest/__tests__/updateObject.test.ts | 52 ++++++++++++++ .../src/forest/updateObject.ts | 70 ++++++++++++++----- 3 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 change/@graphitation-apollo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json diff --git a/change/@graphitation-apollo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json b/change/@graphitation-apollo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json new file mode 100644 index 000000000..22601413a --- /dev/null +++ b/change/@graphitation-apollo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Report the full data path (not just the last field/index) in incompatible-update invariant messages when the failing value is a non-addressable CompositeNull/CompositeUndefined", + "packageName": "@graphitation/apollo-forest-run", + "email": "vrazuvaev@microsoft.com_msteamsmdb", + "dependentChangeType": "patch" +} diff --git a/packages/apollo-forest-run/src/forest/__tests__/updateObject.test.ts b/packages/apollo-forest-run/src/forest/__tests__/updateObject.test.ts index 5f8d28170..486a7d408 100644 --- a/packages/apollo-forest-run/src/forest/__tests__/updateObject.test.ts +++ b/packages/apollo-forest-run/src/forest/__tests__/updateObject.test.ts @@ -83,6 +83,10 @@ const NESTED_INCOMPATIBLE_OBJECT_DIFF_ERROR = 'Invariant violation: Failed to update "query BaseFeedQuery" ' + "at path listContainer.items.0.value (in Entity): expected CompositeList, got ObjectDifference"; +const NESTED_INCOMPATIBLE_NULL_DIFF_ERROR = + 'Invariant violation: Failed to update "query BaseFeedQuery" ' + + "at path listContainer.items.0.value (in Entity): expected CompositeNull, got ObjectDifference"; + describe("incompatible object difference invariant", () => { it("reports operation and path for a field two levels deep below a list", () => { const cache = new ForestRun(); @@ -131,4 +135,52 @@ describe("incompatible object difference invariant", () => { NESTED_INCOMPATIBLE_OBJECT_DIFF_ERROR, ); }); + + it("reports the full path when the failing value is a nested CompositeNull", () => { + const cache = new ForestRun(); + + // Same setup as above, but here the same Entity's "value" is a null (with a + // sub-selection, i.e. a CompositeNull) under "listContainer" and an object under + // "objectContainer". A CompositeNull's source data is `null`, so it is not + // addressable by the path utils on its own - the reported path must therefore be + // derived from its parent chunk, yielding the full "listContainer.items.0.value" + // rather than just the final "value" segment. + cache.write({ + query: nestedFeedListQuery, + result: { + objectContainer: { + __typename: "Container", + id: "object", + items: [{ __typename: "Entity", id: "1", value: { note: "old" } }], + }, + listContainer: { + __typename: "Container", + id: "list", + items: [{ __typename: "Entity", id: "1", value: null }], + }, + }, + }); + + const run = () => + cache.write({ + query: nestedFeedObjectQuery, + result: { + objectContainer: { + __typename: "Container", + id: "object", + items: [{ __typename: "Entity", id: "1", value: { note: "new" } }], + }, + }, + }); + + let error: unknown; + try { + run(); + } catch (thrown) { + error = thrown; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe(NESTED_INCOMPATIBLE_NULL_DIFF_ERROR); + }); }); diff --git a/packages/apollo-forest-run/src/forest/updateObject.ts b/packages/apollo-forest-run/src/forest/updateObject.ts index 0bf8841cb..98528312d 100644 --- a/packages/apollo-forest-run/src/forest/updateObject.ts +++ b/packages/apollo-forest-run/src/forest/updateObject.ts @@ -101,7 +101,8 @@ function updateObjectValue( const updated = updateValue(context, value, fieldDiff, { kind: "field", - fieldName: fieldInfo.name, + parent: base, + field: fieldInfo, }); if (valueIsMissing && updated !== undefined) { @@ -152,8 +153,8 @@ function updateObjectValue( } type UpdateValueLocation = - | { kind: "field"; fieldName: string } - | { kind: "listItem"; index: number }; + | { kind: "field"; parent: GraphChunk; field: FieldInfo } + | { kind: "listItem"; parent: GraphChunk; index: number }; function updateValue( context: UpdateTreeContext, @@ -296,46 +297,79 @@ function updateFailureMessage( location?: UpdateValueLocation, ): string { const prefix = `Failed to update "${context.operation.debugName}"`; - const basePath = chunkPath(context, base); - const pathString = basePath?.length - ? basePath.join(".") - : location - ? describeLocation(location) - : undefined; - const nodeClause = nodeTypeClause(context, base); + const pathString = resolveFailurePath(context, base, location); + const nodeClause = nodeTypeClause(context, base, location); return pathString ? `${prefix} at path ${pathString}${nodeClause}: ${detail}` : `${prefix}${nodeClause}: ${detail}`; } +// Full path to the failing value, e.g. "listContainer.items.0.value". +// The failing value itself may not be addressable by the path utils (e.g. a CompositeNull +// or CompositeUndefined, whose source data is null/undefined and cannot be located in the +// tree). In that case its own path resolves to nothing and we would otherwise only report +// the final field/index. So we prefer to derive the path from the failing value's parent +// chunk (always an ObjectChunk or CompositeListChunk, which *is* addressable) and append +// the field/index step, falling back to the value's own path or the bare step. +function resolveFailurePath( + context: UpdateTreeContext, + base: GraphChunk, + location?: UpdateValueLocation, +): string | undefined { + if (location) { + const parentPath = chunkPath(context, location.parent); + if (parentPath) { + return parentPath.concat(describeLocation(location)).join("."); + } + } + const basePath = chunkPath(context, base); + if (basePath?.length) { + return basePath.join("."); + } + return location ? describeLocation(location) : undefined; +} + function describeLocation(location: UpdateValueLocation): string { return location.kind === "field" - ? location.fieldName + ? location.field.dataKey : String(location.index); } // Best-effort " (in )" clause naming the closest enclosing node's __typename. // Defensive: the tree may already be inconsistent when an invariant fires, so it is // wrapped in try/catch and falls back to an empty clause (and aggregates are skipped, -// as they are not addressable by the path utils). +// as they are not addressable by the path utils). When the failing value itself is not +// addressable (e.g. a CompositeNull), the enclosing node is resolved from its parent. function nodeTypeClause( env: { findParent: ParentLocator }, chunk: GraphChunk, + location?: UpdateValueLocation, ): string { - if ( - (!Value.isObjectValue(chunk) && !Value.isCompositeListValue(chunk)) || - chunk.isAggregate - ) { + const anchor = nodeAnchor(chunk) ?? nodeAnchor(location?.parent); + if (!anchor) { return ""; } try { - const node = Value.findClosestNode(chunk, env.findParent); + const node = Value.findClosestNode(anchor, env.findParent); return node.type ? ` (in ${node.type})` : ""; } catch { return ""; } } +function nodeAnchor( + chunk: GraphChunk | undefined, +): ObjectChunk | CompositeListChunk | undefined { + if ( + chunk && + (Value.isObjectValue(chunk) || Value.isCompositeListValue(chunk)) && + !chunk.isAggregate + ) { + return chunk; + } + return undefined; +} + // Path of a chunk within its tree, e.g. ["listContainer", "items", 0, "value"]. // Returns undefined for values that are not addressable by the path utils (e.g. leaf values). // Wrapped in try/catch: this runs while reporting an invariant, so the tree may already @@ -406,7 +440,7 @@ function updateCompositeListValue( context, Value.resolveListItemChunk(base, index), itemDiff, - { kind: "listItem", index }, + { kind: "listItem", parent: base, index }, ); if (updatedValue === base.data[index]) { continue; From a153ade5f3d65e17bc44fef8c0094025e69b365e Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 10 Jul 2026 18:36:46 +0200 Subject: [PATCH 7/8] Harden invariant message builder against exceptions during path resolution resolveFailurePath now derives the path from a nested property (location.field.dataKey) rather than an eagerly-captured primitive, so a malformed field could throw while building the invariant message and mask the real invariant. Wrap the whole derivation in try/catch, consistent with the other best-effort diagnostic helpers (chunkPath, nodeTypeClause), so path resolution can never throw over the invariant it is reporting. Add a regression test that forces findParent to throw during both path and enclosing-node resolution and asserts the invariant message still surfaces. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/forest/__tests__/updateObject.test.ts | 35 +++++++++++++++++++ .../src/forest/updateObject.ts | 24 ++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/apollo-forest-run/src/forest/__tests__/updateObject.test.ts b/packages/apollo-forest-run/src/forest/__tests__/updateObject.test.ts index 486a7d408..3f709e19f 100644 --- a/packages/apollo-forest-run/src/forest/__tests__/updateObject.test.ts +++ b/packages/apollo-forest-run/src/forest/__tests__/updateObject.test.ts @@ -35,6 +35,41 @@ describe("replaceValue invariant", () => { 'Invariant violation: Failed to update "query ReplaceScalar": cannot replace Scalar with Object (of type Widget)', ); }); + + it("still reports the invariant when path/node resolution throws", () => { + const operation = createTestOperation(gql` + query ReplaceObject { + node { + id + } + } + `); + // base is an addressable, keyless embedded object, so both path resolution and + // enclosing-node resolution must ascend the tree via findParent. A corrupt tree can + // make findParent throw while an invariant is being reported; the message builder must + // swallow that and still surface the invariant rather than the internal exception. + const objectBase = resolveFieldValue( + generateChunk(`query ObjectSource { embedded { field } }`).value, + "embedded", + ) as ObjectChunk; + const listReplacement = resolveFieldValue( + generateChunk(`query ListSource { items @mock(count: 2) { id } }`).value, + "items", + ); + + const context = { + operation, + findParent: () => { + throw new Error("corrupt tree: findParent exploded"); + }, + } as unknown as UpdateTreeContext; + + expect(() => + replaceValue(context, objectBase as any, listReplacement as any), + ).toThrow( + 'Invariant violation: Failed to update "query ReplaceObject": cannot replace Object with CompositeList', + ); + }); }); const nestedFeedListQuery = gql` diff --git a/packages/apollo-forest-run/src/forest/updateObject.ts b/packages/apollo-forest-run/src/forest/updateObject.ts index 98528312d..13ff864e3 100644 --- a/packages/apollo-forest-run/src/forest/updateObject.ts +++ b/packages/apollo-forest-run/src/forest/updateObject.ts @@ -316,17 +316,23 @@ function resolveFailurePath( base: GraphChunk, location?: UpdateValueLocation, ): string | undefined { - if (location) { - const parentPath = chunkPath(context, location.parent); - if (parentPath) { - return parentPath.concat(describeLocation(location)).join("."); + try { + if (location) { + const parentPath = chunkPath(context, location.parent); + if (parentPath) { + return parentPath.concat(describeLocation(location)).join("."); + } } + const basePath = chunkPath(context, base); + if (basePath?.length) { + return basePath.join("."); + } + return location ? describeLocation(location) : undefined; + } catch { + // Diagnostics only: the tree is already inconsistent when an invariant fires, + // so path resolution is best-effort and must never throw over the invariant. + return undefined; } - const basePath = chunkPath(context, base); - if (basePath?.length) { - return basePath.join("."); - } - return location ? describeLocation(location) : undefined; } function describeLocation(location: UpdateValueLocation): string { From ae1e1bf22bd68ca422155084c5f00405dee79dc0 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Mon, 13 Jul 2026 16:44:45 +0200 Subject: [PATCH 8/8] chore: consolidate to a single apollo-forest-run change file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...lo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 change/@graphitation-apollo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json diff --git a/change/@graphitation-apollo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json b/change/@graphitation-apollo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json deleted file mode 100644 index 22601413a..000000000 --- a/change/@graphitation-apollo-forest-run-8f2a1c4d-6b3e-4a1f-9c7d-2e5b8a0d1f34.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "patch", - "comment": "Report the full data path (not just the last field/index) in incompatible-update invariant messages when the failing value is a non-addressable CompositeNull/CompositeUndefined", - "packageName": "@graphitation/apollo-forest-run", - "email": "vrazuvaev@microsoft.com_msteamsmdb", - "dependentChangeType": "patch" -}