Skip to content

Commit 2551937

Browse files
authored
Always take the latest network result when polling (#13373)
This is something I discovered while experimenting with an unrelated issue. When a cache write happens in the middle of a polling request, but data never changes from the last network result when the cache re-polls, the updated cache result remains. We should always take the newest network result since it is fresh from the server. `_lastDiff` was never updated to `undefined` before a poll request was issued, so when `QueryInfo#markQueryResult` checked `shouldWrite`, it avoided the cache write and re-applied `lastDiff` to the result. Now the updated polling query both writes to the cache with its latest value and reports it in the query. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed polling so cache updates are correctly applied even when consecutive poll responses are deeply equal. * Preserved externally updated entity values until the next poll completes. * Ensured field read behavior is applied consistently to polling results. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent fefaca2 commit 2551937

4 files changed

Lines changed: 157 additions & 4 deletions

File tree

.changeset/serious-pumas-knock.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+
Fix an issue where a cache write in the middle of polling would remain as the query value if future poll requests returned deep equal results to previous polling results.

.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)": 50985,
3-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 45361,
4-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 38468,
5-
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 31847
2+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 51053,
3+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 45350,
4+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 38421,
5+
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 31787
66
}

src/core/ObservableQuery.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,6 +1473,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
14731473
!isNetworkRequestInFlight(this.networkStatus) &&
14741474
!this.options.skipPollAttempt?.()
14751475
) {
1476+
this._lastWrite = undefined;
14761477
this._reobserve(
14771478
{
14781479
// Most fetchPolicy options don't make sense to use in a polling context, as

src/core/__tests__/ApolloClient/general.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3118,6 +3118,153 @@ describe("ApolloClient", () => {
31183118
await expect(stream).not.toEmitAnything();
31193119
});
31203120

3121+
it("writes the latest polled result over a clobbered cache value when polled result equals last polled result", async () => {
3122+
const query = gql`
3123+
query {
3124+
hero {
3125+
id
3126+
name
3127+
}
3128+
}
3129+
`;
3130+
3131+
const client = new ApolloClient({
3132+
cache: new InMemoryCache(),
3133+
link: new MockLink([
3134+
{
3135+
request: { query },
3136+
result: {
3137+
data: { hero: { __typename: "Hero", id: "1", name: "Luke" } },
3138+
},
3139+
delay: 20,
3140+
maxUsageCount: Number.POSITIVE_INFINITY,
3141+
},
3142+
]),
3143+
});
3144+
3145+
const observable = client.watchQuery({ query, pollInterval: 10 });
3146+
const stream = new ObservableStream(observable);
3147+
3148+
await expect(stream).toEmitTypedValue({
3149+
data: undefined,
3150+
dataState: "empty",
3151+
loading: true,
3152+
networkStatus: NetworkStatus.loading,
3153+
partial: true,
3154+
});
3155+
3156+
await expect(stream).toEmitTypedValue({
3157+
data: { hero: { __typename: "Hero", id: "1", name: "Luke" } },
3158+
dataState: "complete",
3159+
loading: false,
3160+
networkStatus: NetworkStatus.ready,
3161+
partial: false,
3162+
});
3163+
3164+
client.writeQuery({
3165+
query,
3166+
data: { hero: { __typename: "Hero", id: "1", name: "Clobbered" } },
3167+
});
3168+
3169+
await expect(stream).toEmitTypedValue({
3170+
data: { hero: { __typename: "Hero", id: "1", name: "Clobbered" } },
3171+
dataState: "complete",
3172+
loading: false,
3173+
networkStatus: NetworkStatus.ready,
3174+
partial: false,
3175+
});
3176+
3177+
await expect(stream).toEmitTypedValue({
3178+
data: { hero: { __typename: "Hero", id: "1", name: "Clobbered" } },
3179+
dataState: "complete",
3180+
loading: true,
3181+
networkStatus: NetworkStatus.poll,
3182+
partial: false,
3183+
});
3184+
3185+
await expect(stream).toEmitTypedValue({
3186+
data: { hero: { __typename: "Hero", id: "1", name: "Luke" } },
3187+
dataState: "complete",
3188+
loading: false,
3189+
networkStatus: NetworkStatus.ready,
3190+
partial: false,
3191+
});
3192+
3193+
observable.stopPolling();
3194+
3195+
await expect(stream).not.toEmitAnything();
3196+
});
3197+
3198+
it("applies read functions when a polled result matches the last written result", async () => {
3199+
const query = gql`
3200+
query {
3201+
greeting
3202+
}
3203+
`;
3204+
3205+
const client = new ApolloClient({
3206+
cache: new InMemoryCache({
3207+
typePolicies: {
3208+
Query: {
3209+
fields: {
3210+
greeting: {
3211+
read: (existing: string | undefined) =>
3212+
existing && existing.toUpperCase(),
3213+
},
3214+
},
3215+
},
3216+
},
3217+
}),
3218+
link: new MockLink([
3219+
{
3220+
request: { query },
3221+
result: { data: { greeting: "hello" } },
3222+
delay: 20,
3223+
maxUsageCount: Number.POSITIVE_INFINITY,
3224+
},
3225+
]),
3226+
});
3227+
3228+
const observable = client.watchQuery({ query, pollInterval: 10 });
3229+
const stream = new ObservableStream(observable);
3230+
3231+
await expect(stream).toEmitTypedValue({
3232+
data: undefined,
3233+
dataState: "empty",
3234+
loading: true,
3235+
networkStatus: NetworkStatus.loading,
3236+
partial: true,
3237+
});
3238+
3239+
await expect(stream).toEmitTypedValue({
3240+
data: { greeting: "HELLO" },
3241+
dataState: "complete",
3242+
loading: false,
3243+
networkStatus: NetworkStatus.ready,
3244+
partial: false,
3245+
});
3246+
3247+
await expect(stream).toEmitTypedValue({
3248+
data: { greeting: "HELLO" },
3249+
dataState: "complete",
3250+
loading: true,
3251+
networkStatus: NetworkStatus.poll,
3252+
partial: false,
3253+
});
3254+
3255+
await expect(stream).toEmitTypedValue({
3256+
data: { greeting: "HELLO" },
3257+
dataState: "complete",
3258+
loading: false,
3259+
networkStatus: NetworkStatus.ready,
3260+
partial: false,
3261+
});
3262+
3263+
observable.stopPolling();
3264+
3265+
await expect(stream).not.toEmitAnything();
3266+
});
3267+
31213268
it("should not error when replacing unidentified data with a normalized ID", async () => {
31223269
const queryWithoutId = gql`
31233270
query {

0 commit comments

Comments
 (0)