Skip to content
Merged
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/fifty-weeks-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Fix a problem with `fetchMore` where the loading state wouldn't reset if the result wouldn't result in a data update.
8 changes: 4 additions & 4 deletions .size-limits.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43857,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38699,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33415,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27498
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43931,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38692,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33440,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27529
}
11 changes: 8 additions & 3 deletions src/core/ObservableQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,9 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
finalize();

if (isCached) {
// Separately getting a diff here before the batch - `onWatchUpdated` might be
// called with an `undefined` `lastDiff` on the watcher if the cache was just subscribed to.
const lastDiff = this.getCacheDiff();
// Performing this cache update inside a cache.batch transaction ensures
// any affected cache.watch watchers are notified at most once about any
// updates. Most watchers will be using the QueryInfo class, which
Expand Down Expand Up @@ -929,9 +932,11 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
});
}
},

onWatchUpdated: (watch) => {
if (watch.watcher === this) {
onWatchUpdated: (watch, diff) => {
if (
watch.watcher === this &&
!equal(diff.result, lastDiff.result)
) {
wasUpdated = true;
}
},
Expand Down
203 changes: 201 additions & 2 deletions src/core/__tests__/ObservableQuery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
import type {
ResultOf,
TypedDocumentNode,
} from "@graphql-typed-document-node/core";
import { waitFor } from "@testing-library/react";
import { expectTypeOf } from "expect-type";
import { GraphQLError } from "graphql";
Expand Down Expand Up @@ -26,7 +29,10 @@ import {
wait,
} from "@apollo/client/testing/internal";
import type { DeepPartial } from "@apollo/client/utilities";
import { DocumentTransform } from "@apollo/client/utilities";
import {
DocumentTransform,
relayStylePagination,
} from "@apollo/client/utilities";
import { removeDirectivesFromDocument } from "@apollo/client/utilities/internal";

describe("ObservableQuery", () => {
Expand Down Expand Up @@ -6498,6 +6504,199 @@ test("does not emit loading state on fetchMore with notifyOnNetworkStatusChange:
await expect(stream).not.toEmitAnything();
});

test.each(["cache-first", "network-only"] as const)(
"`fetchMore` with `fetchPolicy` `%s` will leave `loading` even if a result doesn't trigger cache change",
async (fetchPolicy) => {
const query: TypedDocumentNode<
{
items: {
__typename: "ItemConnection";
edges: {
__typename: "ItemEdge";
cursor: string;
node: { __typename: "Item"; id: string; attributes: string[] };
}[];
pageInfo: {
__typename: "PageInfo";
hasNextPage: boolean;
endCursor: string | null;
};
};
},
{
first?: number;
after?: string;
}
> = gql`
query Items($first: Int, $after: String) {
items(first: $first, after: $after) {
edges {
cursor
node {
id
attributes
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const firstResult: ResultOf<typeof query> = {
items: {
edges: [
{
cursor: "YXJyYXljb25uZWN0aW9uOjA=",
node: {
id: "0",
attributes: ["data"],
__typename: "Item",
},
__typename: "ItemEdge",
},
{
cursor: "YXJyYXljb25uZWN0aW9uOjk=",
node: {
id: "9",
attributes: ["data"],
__typename: "Item",
},
__typename: "ItemEdge",
},
],
pageInfo: {
hasNextPage: false,
endCursor: "YXJyYXljb25uZWN0aW9uOjk=",
__typename: "PageInfo",
},
__typename: "ItemConnection",
},
};

const secondResult: ResultOf<typeof query> = {
items: {
edges: [],
pageInfo: {
hasNextPage: false,
endCursor: null,
__typename: "PageInfo",
},
__typename: "ItemConnection",
},
};

const client = new ApolloClient({
link: new MockLink([
{
request: { query, variables: { first: 2 } },
result: {
data: firstResult,
},
},
{
request: {
query,
variables: {
first: 10,
after: "YXJyYXljb25uZWN0aW9uOjk=",
},
},
result: {
data: secondResult,
},
},
] satisfies MockLink.MockedResponse<ResultOf<typeof query>>[]),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
items: relayStylePagination(),
},
},
},
}),
});

const observable = client.watchQuery({
query,
variables: { first: 2 },
fetchPolicy,
});
const stream = new ObservableStream(observable);

await expect(stream).toEmitTypedValue({
data: undefined,
dataState: "empty",
loading: true,
networkStatus: NetworkStatus.loading,
partial: true,
});

await expect(stream).toEmitTypedValue({
data: {
items: {
__typename: "ItemConnection",
edges: firstResult.items.edges,
pageInfo: {
__typename: "PageInfo",
hasNextPage: false,
endCursor: "YXJyYXljb25uZWN0aW9uOjk=",
},
},
},
dataState: "complete",
loading: false,
networkStatus: NetworkStatus.ready,
partial: false,
});

const more = observable.fetchMore({
variables: {
first: 10,
after: "YXJyYXljb25uZWN0aW9uOjk=",
},
});

await expect(stream).toEmitSimilarValue({
expected(previous) {
return {
...previous,
loading: true,
networkStatus: NetworkStatus.fetchMore,
};
},
});

await expect(more).resolves.toStrictEqualTyped({
data: {
items: {
edges: [],
pageInfo: {
hasNextPage: false,
endCursor: null,
__typename: "PageInfo",
},
__typename: "ItemConnection",
},
},
});

await expect(stream).toEmitSimilarValue({
expected(previous) {
return {
...previous,
loading: false,
networkStatus: NetworkStatus.ready,
};
},
});

await expect(stream).not.toEmitAnything();
}
);

test("does not emit loading state on client.resetStore with notifyOnNetworkStatusChange: false", async () => {
const query: TypedDocumentNode<
{ count: number },
Expand Down