Skip to content

Commit 54ab6d9

Browse files
authored
More robust handling of local state default value when read function is defined (#12934)
1 parent c97b145 commit 54ab6d9

25 files changed

+554
-28
lines changed

.api-reports/api-report-cache.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export abstract class ApolloCache {
9494
abstract removeOptimistic(id: string): void;
9595
// (undocumented)
9696
abstract reset(options?: Cache_2.ResetOptions): Promise<void>;
97+
resolvesClientField?(typename: string, fieldName: string): boolean;
9798
abstract restore(serializedState: unknown): this;
9899
// (undocumented)
99100
transformDocument(document: DocumentNode): DocumentNode;
@@ -544,6 +545,8 @@ export class InMemoryCache extends ApolloCache {
544545
// (undocumented)
545546
reset(options?: Cache_2.ResetOptions): Promise<void>;
546547
// (undocumented)
548+
resolvesClientField(typename: string, fieldName: string): boolean;
549+
// (undocumented)
547550
restore(data: NormalizedCacheObject): this;
548551
// (undocumented)
549552
retain(rootId: string, optimistic?: boolean): number;

.api-reports/api-report-local-state.api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { NoInfer as NoInfer_2 } from '@apollo/client/utilities/internal';
1414
import type { OperationVariables } from '@apollo/client';
1515
import type { RemoveIndexSignature } from '@apollo/client/utilities/internal';
1616
import type { TypedDocumentNode } from '@apollo/client';
17+
import type { WatchQueryFetchPolicy } from '@apollo/client';
1718

1819
// @public (undocumented)
1920
type InferContextValueFromResolvers<TResolvers> = TResolvers extends {
@@ -91,14 +92,15 @@ export class LocalState<TResolvers extends LocalState.Resolvers = LocalState.Res
9192
]);
9293
addResolvers(resolvers: TResolvers): void;
9394
// (undocumented)
94-
execute<TData = unknown, TVariables extends OperationVariables = OperationVariables>({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, }: {
95+
execute<TData = unknown, TVariables extends OperationVariables = OperationVariables>({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, fetchPolicy, }: {
9596
document: DocumentNode | TypedDocumentNode<TData, TVariables>;
9697
client: ApolloClient;
9798
context: DefaultContext | undefined;
9899
remoteResult: FormattedExecutionResult<any> | undefined;
99100
variables: TVariables | undefined;
100101
onlyRunForcedResolvers?: boolean;
101102
returnPartialData?: boolean;
103+
fetchPolicy: WatchQueryFetchPolicy;
102104
}): Promise<FormattedExecutionResult<TData>>;
103105
// (undocumented)
104106
getExportedVariables<TVariables extends OperationVariables = OperationVariables>({ document, client, context, variables, }: {

.api-reports/api-report.api.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export abstract class ApolloCache {
9595
abstract removeOptimistic(id: string): void;
9696
// (undocumented)
9797
abstract reset(options?: Cache_2.ResetOptions): Promise<void>;
98+
resolvesClientField?(typename: string, fieldName: string): boolean;
9899
abstract restore(serializedState: unknown): this;
99100
// (undocumented)
100101
transformDocument(document: DocumentNode): DocumentNode;
@@ -1381,6 +1382,8 @@ export class InMemoryCache extends ApolloCache {
13811382
// (undocumented)
13821383
reset(options?: Cache_2.ResetOptions): Promise<void>;
13831384
// (undocumented)
1385+
resolvesClientField(typename: string, fieldName: string): boolean;
1386+
// (undocumented)
13841387
restore(data: NormalizedCacheObject): this;
13851388
// (undocumented)
13861389
retain(rootId: string, optimistic?: boolean): number;
@@ -1577,14 +1580,15 @@ class LocalState<TResolvers extends LocalState.Resolvers = LocalState.Resolvers<
15771580
]);
15781581
addResolvers(resolvers: TResolvers): void;
15791582
// (undocumented)
1580-
execute<TData = unknown, TVariables extends OperationVariables = OperationVariables>({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, }: {
1583+
execute<TData = unknown, TVariables extends OperationVariables = OperationVariables>({ document, client, context, remoteResult, variables, onlyRunForcedResolvers, returnPartialData, fetchPolicy, }: {
15811584
document: DocumentNode | TypedDocumentNode<TData, TVariables>;
15821585
client: ApolloClient;
15831586
context: DefaultContext | undefined;
15841587
remoteResult: FormattedExecutionResult<any> | undefined;
15851588
variables: TVariables | undefined;
15861589
onlyRunForcedResolvers?: boolean;
15871590
returnPartialData?: boolean;
1591+
fetchPolicy: WatchQueryFetchPolicy;
15881592
}): Promise<FormattedExecutionResult<TData>>;
15891593
// (undocumented)
15901594
getExportedVariables<TVariables extends OperationVariables = OperationVariables>({ document, client, context, variables, }: {
@@ -2723,9 +2727,9 @@ interface WriteContext extends ReadMergeModifyContext {
27232727
// src/core/ApolloClient.ts:362:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts
27242728
// src/core/ObservableQuery.ts:368:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts
27252729
// src/core/QueryManager.ts:180:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts
2726-
// src/local-state/LocalState.ts:147:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts
2727-
// src/local-state/LocalState.ts:200:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts
2728-
// src/local-state/LocalState.ts:243:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts
2730+
// src/local-state/LocalState.ts:149:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts
2731+
// src/local-state/LocalState.ts:202:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts
2732+
// src/local-state/LocalState.ts:245:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts
27292733

27302734
// (No @packageDocumentation comment for this package)
27312735

.changeset/flat-worms-notice.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/client": minor
3+
---
4+
5+
Don't set the fallback value of a `@client` field to `null` when a `read` function is defined. Instead the `read` function will be called with an `existing` value of `undefined` to allow default arguments to be used to set the returned value.
6+
7+
When a `read` function is not defined nor is there a defined resolver for the field, warn and set the value to `null` only in that instance.

.changeset/perfect-crabs-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Ensure `LocalState` doesn't try to read from the cache when using a `no-cache` fetch policy.

.changeset/shaggy-islands-yell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Warn when using a `no-cache` fetch policy without a local resolver defined. `no-cache` queries do not read or write to the cache which meant `no-cache` queries are silently incomplete when the `@client` field value was handled by a cache `read` function.

.changeset/unlucky-cooks-rhyme.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/client": minor
3+
---
4+
5+
Add an abstract `resolvesClientField` function to `ApolloCache` that can be used by caches to tell `LocalState` if it can resolve a `@client` field when a local resolver is not defined.
6+
7+
`LocalState` will emit a warning and set a fallback value of `null` when no local resolver is defined and `resolvesClientField` returns `false`, or isn't defined. Returning `true` from `resolvesClientField` signals that a mechanism in the cache will set the field value. In this case, `LocalState` won't set the field value.

.size-limits.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 44542,
3-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 39461,
4-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33696,
5-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27707
2+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 44753,
3+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 39420,
4+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33901,
5+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27727
66
}

src/__tests__/local-state/general.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ import {
2626
} from "@apollo/client/testing/internal";
2727
import { InvariantError } from "@apollo/client/utilities/invariant";
2828

29+
const WARNINGS = {
30+
MISSING_RESOLVER:
31+
"Could not find a resolver for the '%s' field nor does the cache resolve the field. The field value has been set to `null`. Either define a resolver for the field or ensure the cache can resolve the value, for example, by adding a 'read' function to a field policy in 'InMemoryCache'.",
32+
NO_CACHE:
33+
"The '%s' field resolves the value from the cache, for example from a 'read' function, but a 'no-cache' fetch policy was used. The field value has been set to `null`. Either define a local resolver or use a fetch policy that uses the cache to ensure the field is resolved correctly.",
34+
};
35+
2936
describe("General functionality", () => {
3037
test("should not impact normal non-@client use", async () => {
3138
const query = gql`
@@ -632,7 +639,7 @@ describe("Cache manipulation", () => {
632639
});
633640

634641
expect(read).toHaveBeenCalledTimes(1);
635-
expect(read).toHaveBeenCalledWith(null, expect.anything());
642+
expect(read).toHaveBeenCalledWith(undefined, expect.anything());
636643
expect(console.warn).not.toHaveBeenCalled();
637644
});
638645
});
@@ -1510,3 +1517,149 @@ test("throws when executing subscriptions with client fields when local state is
15101517
)
15111518
);
15121519
});
1520+
1521+
test.each(["cache-first", "network-only"] as const)(
1522+
"sets existing value of `@client` field to undefined when read function is present",
1523+
async (fetchPolicy) => {
1524+
const query = gql`
1525+
query GetUser {
1526+
user {
1527+
firstName @client
1528+
lastName
1529+
}
1530+
}
1531+
`;
1532+
1533+
const read = jest.fn((value = "Fallback") => value);
1534+
const client = new ApolloClient({
1535+
cache: new InMemoryCache({
1536+
typePolicies: {
1537+
User: {
1538+
fields: {
1539+
firstName: {
1540+
read,
1541+
},
1542+
},
1543+
},
1544+
},
1545+
}),
1546+
link: new ApolloLink(() => {
1547+
return of({
1548+
data: { user: { __typename: "User", lastName: "Smith" } },
1549+
}).pipe(delay(10));
1550+
}),
1551+
localState: new LocalState(),
1552+
});
1553+
1554+
await expect(
1555+
client.query({ query, fetchPolicy })
1556+
).resolves.toStrictEqualTyped({
1557+
data: {
1558+
user: { __typename: "User", firstName: "Fallback", lastName: "Smith" },
1559+
},
1560+
});
1561+
1562+
expect(read).toHaveBeenCalledTimes(1);
1563+
expect(read).toHaveBeenCalledWith(undefined, expect.anything());
1564+
}
1565+
);
1566+
1567+
test("sets existing value of `@client` field to null and warns when using no-cache with read function", async () => {
1568+
using _ = spyOnConsole("warn");
1569+
const query = gql`
1570+
query GetUser {
1571+
user {
1572+
firstName @client
1573+
lastName
1574+
}
1575+
}
1576+
`;
1577+
1578+
const read = jest.fn((value) => value ?? "Fallback");
1579+
const client = new ApolloClient({
1580+
cache: new InMemoryCache({
1581+
typePolicies: {
1582+
User: {
1583+
fields: {
1584+
firstName: {
1585+
read,
1586+
},
1587+
},
1588+
},
1589+
},
1590+
}),
1591+
link: new ApolloLink(() => {
1592+
return of({
1593+
data: { user: { __typename: "User", lastName: "Smith" } },
1594+
}).pipe(delay(10));
1595+
}),
1596+
localState: new LocalState(),
1597+
});
1598+
1599+
await expect(
1600+
client.query({ query, fetchPolicy: "no-cache" })
1601+
).resolves.toStrictEqualTyped({
1602+
data: {
1603+
user: { __typename: "User", firstName: null, lastName: "Smith" },
1604+
},
1605+
});
1606+
1607+
expect(read).not.toHaveBeenCalled();
1608+
expect(console.warn).toHaveBeenCalledTimes(1);
1609+
expect(console.warn).toHaveBeenCalledWith(
1610+
WARNINGS.NO_CACHE,
1611+
"User.firstName"
1612+
);
1613+
});
1614+
1615+
test("sets existing value of `@client` field to null and warns when merge function but not read function is present", async () => {
1616+
using _ = spyOnConsole("warn");
1617+
const query = gql`
1618+
query GetUser {
1619+
user {
1620+
firstName @client
1621+
lastName
1622+
}
1623+
}
1624+
`;
1625+
1626+
const merge = jest.fn(() => "Fallback");
1627+
const client = new ApolloClient({
1628+
cache: new InMemoryCache({
1629+
typePolicies: {
1630+
User: {
1631+
fields: {
1632+
firstName: {
1633+
merge,
1634+
},
1635+
},
1636+
},
1637+
},
1638+
}),
1639+
link: new ApolloLink(() => {
1640+
return of({
1641+
data: { user: { __typename: "User", lastName: "Smith" } },
1642+
}).pipe(delay(10));
1643+
}),
1644+
localState: new LocalState(),
1645+
});
1646+
1647+
await expect(client.query({ query })).resolves.toStrictEqualTyped({
1648+
data: {
1649+
user: {
1650+
__typename: "User",
1651+
firstName: "Fallback",
1652+
lastName: "Smith",
1653+
},
1654+
},
1655+
});
1656+
1657+
expect(merge).toHaveBeenCalledTimes(1);
1658+
expect(merge).toHaveBeenCalledWith(undefined, null, expect.anything());
1659+
1660+
expect(console.warn).toHaveBeenCalledTimes(1);
1661+
expect(console.warn).toHaveBeenCalledWith(
1662+
WARNINGS.MISSING_RESOLVER,
1663+
"User.firstName"
1664+
);
1665+
});

src/cache/core/cache.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,28 @@ export abstract class ApolloCache {
178178
return null;
179179
}
180180

181+
// Local state API
182+
183+
/**
184+
* Determines whether a `@client` field can be resolved by the cache. Used
185+
* when `LocalState` does not have a local resolver that can resolve the
186+
* field.
187+
*
188+
* @remarks Cache implementations should return `true` if a mechanism in the
189+
* cache is expected to provide a value for the field. `LocalState` will set
190+
* the value of the field to `undefined` in order for the cache to handle it.
191+
*
192+
* Cache implementations should return `false` to indicate that it cannot
193+
* handle resolving the field (either because it doesn't have a mechanism to
194+
* do so, or because the user hasn't provided enough information to resolve
195+
* the field). Returning `false` will emit a warning and set the value of the
196+
* field to `null`.
197+
*
198+
* A cache that doesn't implement `resolvesClientField` will be treated the
199+
* same as returning `false`.
200+
*/
201+
public resolvesClientField?(typename: string, fieldName: string): boolean;
202+
181203
// Transactional API
182204

183205
// The batch method is intended to replace/subsume both performTransaction

0 commit comments

Comments
 (0)