diff --git a/.changeset/rich-hotels-sniff.md b/.changeset/rich-hotels-sniff.md new file mode 100644 index 00000000000..57d89545de3 --- /dev/null +++ b/.changeset/rich-hotels-sniff.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix issue where calling `fetchMore` from a suspense-enabled hook inside `startTransition` caused an unnecessary rerender. diff --git a/.size-limits.json b/.size-limits.json index a4c53b7f485..ba256af48d8 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39209, + "dist/apollo-client.min.cjs": 39211, "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32584 } diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index fbd7c3dd973..ac7477d51f7 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -4927,25 +4927,6 @@ describe("fetchMore", () => { expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); } - // TODO: Determine why we have this extra render here. - // Possibly related: https://github.com/apollographql/apollo-client/issues/11315 - { - const { snapshot } = await Profiler.takeRender(); - - expect(snapshot.result).toEqual({ - data: { - letters: [ - { __typename: "Letter", position: 1, letter: "A" }, - { __typename: "Letter", position: 2, letter: "B" }, - { __typename: "Letter", position: 3, letter: "C" }, - { __typename: "Letter", position: 4, letter: "D" }, - ], - }, - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - { const { snapshot } = await Profiler.takeRender(); @@ -5034,25 +5015,6 @@ describe("fetchMore", () => { expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); } - // TODO: Determine why we have this extra render here. - // Possibly related: https://github.com/apollographql/apollo-client/issues/11315 - { - const { snapshot } = await Profiler.takeRender(); - - expect(snapshot.result).toEqual({ - data: { - letters: [ - { __typename: "Letter", position: 1, letter: "A" }, - { __typename: "Letter", position: 2, letter: "B" }, - { __typename: "Letter", position: 3, letter: "C" }, - { __typename: "Letter", position: 4, letter: "D" }, - ], - }, - error: undefined, - networkStatus: NetworkStatus.ready, - }); - } - { const { snapshot } = await Profiler.takeRender(); @@ -5245,39 +5207,6 @@ describe("fetchMore", () => { }); } - // TODO: Determine why we have this extra render here. This should mimic - // the update in the next render where we see included in the - // rerendered components. - // Possibly related: https://github.com/apollographql/apollo-client/issues/11315 - { - const { snapshot, renderedComponents } = await Profiler.takeRender(); - - expect(renderedComponents).toStrictEqual([ReadQueryHook]); - expect(snapshot).toEqual({ - isPending: false, - result: { - data: { - todos: [ - { - __typename: "Todo", - id: "1", - name: "Clean room", - completed: false, - }, - { - __typename: "Todo", - id: "2", - name: "Take out trash", - completed: true, - }, - ], - }, - error: undefined, - networkStatus: NetworkStatus.ready, - }, - }); - } - { // Eventually we should see the updated todos content once its done // suspending. diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 6678763fb95..4e32f276c72 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, StrictMode, Suspense } from "react"; +import React, { Fragment, StrictMode, Suspense, useTransition } from "react"; import { act, screen, @@ -51,7 +51,15 @@ import { RefetchWritePolicy, WatchQueryFetchPolicy, } from "../../../core/watchQueryOptions"; -import { profile, spyOnConsole } from "../../../testing/internal"; +import { + PaginatedCaseData, + PaginatedCaseVariables, + createProfiler, + profile, + setupPaginatedCase, + spyOnConsole, + useTrackRenders, +} from "../../../testing/internal"; type RenderSuspenseHookOptions = Omit< RenderHookOptions, @@ -9978,6 +9986,129 @@ describe("useSuspenseQuery", () => { }); }); + // https://github.com/apollographql/apollo-client/issues/11315 + it("fetchMore does not cause extra render", async () => { + const { query, link } = setupPaginatedCase(); + + const user = userEvent.setup(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + letters: offsetLimitPagination(), + }, + }, + }, + }), + link, + }); + + const Profiler = createProfiler({ + initialSnapshot: { + result: null as UseSuspenseQueryResult< + PaginatedCaseData, + PaginatedCaseVariables + > | null, + }, + }); + + function SuspenseFallback() { + useTrackRenders(); + + return
Loading...
; + } + + function App() { + useTrackRenders(); + const [isPending, startTransition] = useTransition(); + const result = useSuspenseQuery(query, { + variables: { offset: 0, limit: 2 }, + }); + const { data, fetchMore } = result; + + Profiler.mergeSnapshot({ result }); + + return ( + + ); + } + + render(, { + wrapper: ({ children }) => ( + + + }>{children} + + + ), + }); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result?.data).toEqual({ + letters: [ + { __typename: "Letter", letter: "A", position: 1 }, + { __typename: "Letter", letter: "B", position: 2 }, + ], + }); + } + + await act(() => user.click(screen.getByText("Fetch next"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(screen.getByText("Fetch next")).toBeDisabled(); + expect(snapshot.result?.data).toEqual({ + letters: [ + { __typename: "Letter", letter: "A", position: 1 }, + { __typename: "Letter", letter: "B", position: 2 }, + ], + }); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result?.data).toEqual({ + letters: [ + { __typename: "Letter", letter: "A", position: 1 }, + { __typename: "Letter", letter: "B", position: 2 }, + { __typename: "Letter", letter: "C", position: 3 }, + { __typename: "Letter", letter: "D", position: 4 }, + { __typename: "Letter", letter: "E", position: 5 }, + ], + }); + } + + await expect(Profiler).not.toRerender(); + }); + describe.skip("type tests", () => { it("returns unknown when TData cannot be inferred", () => { const query = gql` diff --git a/src/react/internal/cache/QueryReference.ts b/src/react/internal/cache/QueryReference.ts index dc26adf541c..50865abe2d4 100644 --- a/src/react/internal/cache/QueryReference.ts +++ b/src/react/internal/cache/QueryReference.ts @@ -379,10 +379,22 @@ export class InternalQueryReference { // promise is resolved correctly. returnedPromise .then((result) => { - if (this.promise.status === "pending") { - this.result = result; - this.resolve?.(result); - } + // In the case of `fetchMore`, this promise is resolved before a cache + // result is emitted due to the fact that `fetchMore` sets a `no-cache` + // fetch policy and runs `cache.batch` in its `.then` handler. Because + // the timing is different, we accidentally run this update twice + // causing an additional re-render with the `fetchMore` result by + // itself. By wrapping in `setTimeout`, this should provide a short + // delay to allow the `QueryInfo.notify` handler to run before this + // promise is checked. + // See https://github.com/apollographql/apollo-client/issues/11315 for + // more information + setTimeout(() => { + if (this.promise.status === "pending") { + this.result = result; + this.resolve?.(result); + } + }); }) .catch(() => {}); diff --git a/src/testing/internal/scenarios/index.ts b/src/testing/internal/scenarios/index.ts index 637c2a7bec4..2ed2bb5c523 100644 --- a/src/testing/internal/scenarios/index.ts +++ b/src/testing/internal/scenarios/index.ts @@ -80,8 +80,8 @@ export interface PaginatedCaseVariables { export function setupPaginatedCase() { const query: TypedDocumentNode = gql` - query letters($limit: Int, $offset: Int) { - letters(limit: $limit) { + query LettersQuery($limit: Int, $offset: Int) { + letters(limit: $limit, offset: $offset) { letter position }