Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/pink-jobs-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Fix `readField` during merge of objects sandwiched between circular objects.
118 changes: 118 additions & 0 deletions src/cache/inmemory/__tests__/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2795,6 +2795,124 @@ describe("type policies", function () {
});
});

it("`readField` in `merge` with indirectly self-nested object works", () => {
let messageIdFields: string[];
let aliasedMessageTitleFields: string[];
const conversationMergePolicy = jest.fn((existing, incoming) => incoming);
const cache = new InMemoryCache({
typePolicies: {
Conversation: {
merge: conversationMergePolicy,
fields: {
messages: {
keyArgs: false,
merge: (existing, incoming, { readField }) => {
messageIdFields = incoming.map((m: any) =>
readField("id", m)
);
aliasedMessageTitleFields = incoming.map((m: any) =>
// aliased fields can be read by their original name
// even if they haven't been written to the cache yet
// due to a merge function within a circular reference.
readField("title", m)
);
return incoming;
},
},
},
},
},
});

const query = gql`
query GetConversation {
conversation {
id
title
messages {
id
aliased: title
conversation {
id
meta
}
}
}
}
`;

const conv1 = {
id: "c-1",
__typename: "Conversation",
};

cache.writeQuery({
query,
data: {
conversation: {
...conv1,
title: "Conversation 1",
messages: [
{
id: "msg-1",
aliased: "Message 1",
conversation: { ...conv1, meta: "meta-1" },
__typename: "Message",
},
{
id: "msg-2",
aliased: "Message 2",
conversation: { ...conv1 },
__typename: "Message",
},
],
},
},
});

expect(messageIdFields!).toEqual(["msg-1", "msg-2"]);
expect(aliasedMessageTitleFields!).toEqual(["Message 1", "Message 2"]);
// The merge function for the parent-and-child-nested Conversation
// object should have been called with the fully merged object as `incoming`.
// The fact that this is called 4 times, the first time to a self-reference and then three times
// without existing data, is something to be improved in a future PR.
expect(conversationMergePolicy).toHaveBeenNthCalledWith(
1,
// TODO: fix this in a future PR
{ __ref: "Conversation:c-1" },
{
__typename: "Conversation",
id: "c-1",
title: "Conversation 1",
meta: "meta-1",
messages: expect.any(Array),
},
expect.any(Object)
);
expect(conversationMergePolicy).toHaveBeenNthCalledWith(
2,
// TODO: fix this in a future PR
undefined,
{ __ref: "Conversation:c-1" },
expect.any(Object)
);
expect(conversationMergePolicy).toHaveBeenNthCalledWith(
3,
// TODO: fix this in a future PR
undefined,
{ __ref: "Conversation:c-1" },
expect.any(Object)
);
expect(conversationMergePolicy).toHaveBeenNthCalledWith(
4,
// TODO: fix this in a future PR
undefined,
{ __ref: "Conversation:c-1" },
expect.any(Object)
);
expect(conversationMergePolicy).toHaveBeenCalledTimes(4);
});

it("readField helper function calls custom read functions", function () {
using _consoleSpies = spyOnConsole.takeSnapshots("error");
// Rather than writing ownTime data into the cache, we maintain it
Expand Down
2 changes: 1 addition & 1 deletion src/cache/inmemory/entityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ export abstract class EntityStore implements NormalizedCache {
isReference(objectOrReference) ?
this.get(objectOrReference.__ref, storeFieldName)
: objectOrReference && objectOrReference[storeFieldName]
) as SafeReadonly<T>;
) as SafeReadonly<T> | undefined;
Copy link
Member Author

@phryneas phryneas Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda unrelated, but it came up during the PR. getFieldValue<string> is a hidden cast to string, even though the function can always return undefined, too.


// Returns true for non-normalized StoreObjects and non-dangling
// References, indicating that readField(name, objOrRef) has a chance of
Expand Down
19 changes: 13 additions & 6 deletions src/cache/inmemory/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,28 +891,35 @@ export class Policies {

public readField<V = StoreValue>(
options: ReadFieldOptions,
context: ReadMergeModifyContext
context: (ReadMergeModifyContext & { incomingById?: never }) | WriteContext
): SafeReadonly<V> | undefined {
const objectOrReference = options.from;
if (!objectOrReference) return;

const nameOrField = options.field || options.fieldName;
if (!nameOrField) return;

const incomingObject =
isReference(objectOrReference) ?
context.incomingById?.get(objectOrReference.__ref)?.storeObject
: undefined;

if (options.typename === void 0) {
const typename = context.store.getFieldValue<string>(
objectOrReference,
"__typename"
);
const typename =
context.store.getFieldValue<string>(objectOrReference, "__typename") ??
incomingObject?.["__typename"];
if (typename) options.typename = typename;
}

const storeFieldName = this.getStoreFieldName(options);
const fieldName = fieldNameFromStoreName(storeFieldName);
const existing = context.store.getFieldValue<V>(
let existing = context.store.getFieldValue<V>(
objectOrReference,
storeFieldName
);
if (existing === undefined) {
existing = incomingObject?.[storeFieldName] as typeof existing;
}
const policy = this.getFieldPolicy(options.typename, fieldName);
const read = policy && policy.read;

Expand Down
Loading