diff --git a/.changeset/eleven-bikes-flash.md b/.changeset/eleven-bikes-flash.md new file mode 100644 index 00000000000..c68d174d1d4 --- /dev/null +++ b/.changeset/eleven-bikes-flash.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix an issue where switching from options with `variables` to `skipToken` with `useSuspenseQuery` and `useBackgroundQuery` would create a new `ObservableQuery`. This could cause unintended refetches where `variables` were absent in the request when the query was referenced with `refetchQueries`. diff --git a/.size-limits.json b/.size-limits.json index abf6288b711..4f116ec9282 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43812, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38745, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33456, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27523 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43882, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38754, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33430, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27518 } diff --git a/ROADMAP.md b/ROADMAP.md index f5b60a4a293..1a3cc5ac991 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,7 @@ For up to date release notes, refer to the project's [Changelog](https://github. ### Apollo Client #### 4.1.0 + _Release candidate - November 14th, 2025_ - Support for `@stream` diff --git a/config/jest.config.ts b/config/jest.config.ts index 9e8be6190dc..b7996a9a3b5 100644 --- a/config/jest.config.ts +++ b/config/jest.config.ts @@ -39,17 +39,23 @@ const ignoreDTSFiles = ".d.ts$"; const ignoreTSFiles = ".ts$"; const ignoreTSXFiles = ".tsx$"; -const react19TestFileIgnoreList = [ignoreDTSFiles, ignoreTSFiles]; - -const react17TestFileIgnoreList = [ +const reactSharedTestFileIgnoreList = [ ignoreDTSFiles, ignoreTSFiles, + "src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx", + "src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx", +]; + +const react17TestFileIgnoreList = [ + ...reactSharedTestFileIgnoreList, // We only support Suspense with React 18, so don't test suspense hooks with // React 17 "src/testing/experimental/__tests__/createTestSchema.test.tsx", "src/react/hooks/__tests__/useSuspenseFragment.test.tsx", "src/react/hooks/__tests__/useSuspenseQuery.test.tsx", + "src/react/hooks/__tests__/useSuspenseQuery/*", "src/react/hooks/__tests__/useBackgroundQuery.test.tsx", + "src/react/hooks/__tests__/useBackgroundQuery/*", "src/react/hooks/__tests__/useLoadableQuery.test.tsx", "src/react/hooks/__tests__/useQueryRefHandlers.test.tsx", "src/react/query-preloader/__tests__/createQueryPreloader.test.tsx", @@ -81,15 +87,14 @@ const tsRxJSMinConfig = { const standardReact19Config = { ...defaults, displayName: "ReactDOM 19", - testPathIgnorePatterns: react19TestFileIgnoreList, + testPathIgnorePatterns: reactSharedTestFileIgnoreList, }; const standardReact18Config = { ...defaults, displayName: "ReactDOM 18", testPathIgnorePatterns: [ - ignoreDTSFiles, - ignoreTSFiles, + ...reactSharedTestFileIgnoreList, "src/react/ssr/__tests__/prerenderStatic.test.tsx", ], moduleNameMapper: { diff --git a/package-lock.json b/package-lock.json index 3b31b717d01..89c275a5ddb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.1.0", - "@testing-library/react-render-stream": "2.0.0", + "@testing-library/react-render-stream": "2.0.2", "@testing-library/user-event": "14.5.2", "@types/babel__preset-env": "^7.10.0", "@types/bytes": "3.1.4", @@ -6189,7 +6189,9 @@ } }, "node_modules/@testing-library/react-render-stream": { - "version": "2.0.0", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-render-stream/-/react-render-stream-2.0.2.tgz", + "integrity": "sha512-rZNWU6ECbqaplYoxxaD5+l4NRX49qxzCzZ8Sjbetw5JADchYAe+8h+TDy7G+1sAIUwevWbthE2sQfXyVfkTExg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index be5b0f1b757..cd6a327a6b5 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,7 @@ "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.1.0", - "@testing-library/react-render-stream": "2.0.0", + "@testing-library/react-render-stream": "2.0.2", "@testing-library/user-event": "14.5.2", "@types/babel__preset-env": "^7.10.0", "@types/bytes": "3.1.4", diff --git a/src/react/hooks/__tests__/useBackgroundQuery/skipToken.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/skipToken.test.tsx new file mode 100644 index 00000000000..f70e6e8e414 --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/skipToken.test.tsx @@ -0,0 +1,293 @@ +import { disableActEnvironment } from "@testing-library/react-render-stream"; +import React from "react"; +import { delay, of } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { skipToken, useBackgroundQuery } from "@apollo/client/react"; +import { + createClientWrapper, + createMockWrapper, + setupVariablesCase, +} from "@apollo/client/testing/internal"; + +import { renderUseBackgroundQuery } from "./testUtils.js"; + +// https://github.com/apollographql/apollo-client/issues/12989 +test("maintains variables when switching to `skipToken` and calling `refetchQueries` while skipped after initial request", async () => { + const { query } = setupVariablesCase(); + + const client = new ApolloClient({ + link: new ApolloLink((operation) => { + return of( + operation.variables.id === "1" ? + { + data: { + character: { + __typename: "Character", + id: "1", + name: "Spider-Man", + }, + }, + } + : { + data: null, + errors: [ + { message: `Fetched wrong id: ${operation.variables.id}` }, + ], + } + ).pipe(delay(10)); + }), + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseBackgroundQuery( + ({ id }) => + useBackgroundQuery( + query, + id === undefined ? skipToken : { variables: { id } } + ), + { + initialProps: { id: "1" as string | undefined }, + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: undefined }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); + + await expect( + client.refetchQueries({ include: [query] }) + ).resolves.toStrictEqualTyped([ + { + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + }, + ]); + + await expect(takeRender).not.toRerender(); +}); + +test("suspends and fetches when changing variables when no longer using skipToken", async () => { + const { query, mocks } = setupVariablesCase({ + delay: React.version.startsWith("18") ? 200 : 20, + }); + + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseBackgroundQuery( + ({ id }) => + useBackgroundQuery( + query, + id === undefined ? skipToken : { variables: { id } } + ), + { + initialProps: { id: "1" as string | undefined }, + wrapper: createMockWrapper({ mocks }), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: undefined }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: "2" }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + { + const { snapshot } = await takeRender(); + + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "2", name: "Black Widow" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend for data in the cache when changing variables when no longer using skipToken", async () => { + const { query, mocks } = setupVariablesCase(); + + const cache = new InMemoryCache(); + + cache.writeQuery({ + query, + data: { + character: { __typename: "Character", id: "2", name: "Cached Widow" }, + }, + variables: { id: "2" }, + }); + + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseBackgroundQuery( + ({ id }) => + useBackgroundQuery( + query, + id === undefined ? skipToken : { variables: { id } } + ), + { + initialProps: { id: "1" as string | undefined }, + wrapper: createMockWrapper({ cache, mocks }), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: undefined }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: "2" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "useReadQuery", + ]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "2", name: "Cached Widow" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx b/src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx new file mode 100644 index 00000000000..e1d34750a5b --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx @@ -0,0 +1,75 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { DataState, ErrorLike, OperationVariables } from "@apollo/client"; +import type { QueryRef, useBackgroundQuery } from "@apollo/client/react"; +import { useReadQuery } from "@apollo/client/react"; + +export async function renderUseBackgroundQuery< + TData, + TVariables extends OperationVariables, + TQueryRef extends QueryRef, + TStates extends DataState["dataState"] = TQueryRef extends ( + QueryRef + ) ? + States + : never, + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => [TQueryRef | undefined, useBackgroundQuery.Result], + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + replaceSnapshot(useReadQuery(queryRef) as any); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useBackgroundQuery" }); + const [queryRef] = renderHook(props as any); + + return ( + }> + replaceSnapshot({ error })} + > + {queryRef && } + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useReadQuery.Result | { error: ErrorLike } + >(); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx index 978577b2cb2..fb5b2b2b570 100644 --- a/src/react/hooks/__tests__/useLazyQuery.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx @@ -2834,8 +2834,7 @@ describe("useLazyQuery Hook", () => { const [originalExecute] = getCurrentSnapshot(); countRef.current++; - // TODO: Update when https://github.com/testing-library/react-render-stream-testing-library/issues/13 is fixed - await rerender(undefined); + await rerender(); { const [, result] = await takeSnapshot(); @@ -2890,8 +2889,7 @@ describe("useLazyQuery Hook", () => { countRef.current++; - // TODO: Update when https://github.com/testing-library/react-render-stream-testing-library/issues/13 is fixed - await rerender(undefined); + await rerender(); { const [, result] = await takeSnapshot(); @@ -2950,8 +2948,7 @@ describe("useLazyQuery Hook", () => { trackClosureValue.mockClear(); countRef.current++; - // TODO: Update when https://github.com/testing-library/react-render-stream-testing-library/issues/13 is fixed - await rerender(undefined); + await rerender(); [execute] = getCurrentSnapshot(); expect(execute).toBe(originalExecute); diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 2d544bb11ac..f038bcfdb5a 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -174,7 +174,7 @@ describe("useQuery Hook", () => { }); } - await rerender({ children: null }); + await rerender(); { const result = await takeSnapshot(); @@ -244,7 +244,7 @@ describe("useQuery Hook", () => { }); } - await rerender({ children: null }); + await rerender(); { const result = await takeSnapshot(); @@ -400,7 +400,7 @@ describe("useQuery Hook", () => { }); } - await rerender(undefined); + await rerender(); { const result = await takeSnapshot(); @@ -471,7 +471,7 @@ describe("useQuery Hook", () => { expect(subscribeToMore).toBe(result.subscribeToMore); } - await rerender(undefined); + await rerender(); { const result = await takeSnapshot(); @@ -1244,7 +1244,7 @@ describe("useQuery Hook", () => { }); } - await rerender({}); + await rerender(); { const [result0, result1] = await takeSnapshot(); @@ -4064,7 +4064,7 @@ describe("useQuery Hook", () => { }); } - await rerender(undefined); + await rerender(); { const result = await takeSnapshot(); @@ -8103,7 +8103,7 @@ describe("useQuery Hook", () => { variables: {}, }); - await rerender(undefined); + await rerender(); await expect(takeSnapshot()).resolves.toStrictEqualTyped({ data: undefined, diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 7122e12171a..6ddb4ce4d32 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -8,11 +8,12 @@ import { import { userEvent } from "@testing-library/user-event"; import { equal } from "@wry/equality"; import { expectTypeOf } from "expect-type"; +import type { GraphQLFormattedError } from "graphql"; import { GraphQLError } from "graphql"; import React, { Fragment, StrictMode, Suspense, useTransition } from "react"; import type { FallbackProps } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary"; -import { Observable, of } from "rxjs"; +import { delay, Observable, of } from "rxjs"; import type { ApolloCache, @@ -50,6 +51,7 @@ import type { import { actAsync, createClientWrapper, + createMockWrapper, markAsStreaming, renderAsync, renderHookAsync, @@ -70,6 +72,8 @@ import type { WatchQueryFetchPolicy, } from "../../../core/watchQueryOptions.js"; +import { renderUseSuspenseQuery } from "./useSuspenseQuery/testUtils.js"; + const IS_REACT_19 = React.version.startsWith("19"); type RenderSuspenseHookOptions = Omit< @@ -96,6 +100,11 @@ interface SimpleQueryData { greeting: string; } +/** + * @deprecated + * Use the `renderUseSuspenseQuery` helper from utils which uses render streams + * instead of function call render counting. + */ async function renderSuspenseHook( render: (initialProps: Props) => Result, options: RenderSuspenseHookOptions = {} @@ -297,7 +306,10 @@ function useVariablesQueryCase() { character: { __typename: "Character", id: String(index + 1), name }, }, }, - delay: 20, + // React runs layout effects much later in React 18 which means tracked + // components aren't captured correctly, specifically when changing + // variables that cause the component to suspend. + delay: IS_REACT_19 ? 20 : 200, })); return { query, mocks }; @@ -1085,47 +1097,51 @@ describe("useSuspenseQuery", () => { it("suspends when changing variables", async () => { const { query, mocks } = useVariablesQueryCase(); - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { variables: { id } }), - { mocks, initialProps: { id: "1" } } + { wrapper: createMockWrapper({ mocks }), initialProps: { id: "1" } } ); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); + await expect(takeRender).not.toRerender(); }); it("suspends and fetches data from new client when changing clients", async () => { @@ -1564,135 +1580,137 @@ describe("useSuspenseQuery", () => { link: new MockLink(mocks), }); - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { variables: { id } }), - { client, initialProps: { id: "1" } } + { wrapper: createClientWrapper(client), initialProps: { id: "1" } } ); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - act(() => { - client.writeQuery({ - query, - variables: { id: "2" }, - data: { character: { id: "2", name: "Cached hero" } }, - }); + client.writeQuery({ + query, + variables: { id: "2" }, + data: { character: { id: "2", name: "Cached hero" } }, }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: { character: { id: "2", name: "Cached hero" } }, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.suspenseCount).toBe(2); - expect(renders.count).toBe(5 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.frames).toStrictEqualTyped([ - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: { character: { id: "2", name: "Cached hero" } }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); + await expect(takeRender).not.toRerender(); }); it("uses cached result and does not suspend when switching back to already used variables while using `cache-first` fetch policy", async () => { const { query, mocks } = useVariablesQueryCase(); - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { fetchPolicy: "cache-first", variables: { id }, }), - { mocks, initialProps: { id: "1" } } + { wrapper: createMockWrapper({ mocks }), initialProps: { id: "1" } } ); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "1" }); + await rerender({ id: "1" }); - expect(result.current).toStrictEqualTyped({ - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); + { + const { snapshot, renderedComponents } = await takeRender(); - expect(renders.count).toBe(5 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, - }, - ]); + }); + } + + await expect(takeRender).not.toRerender(); }); it("uses cached result with network request and does not suspend when switching back to already used variables while using `cache-and-network` fetch policy", async () => { @@ -1714,7 +1732,7 @@ describe("useSuspenseQuery", () => { character: { __typename: "Character", id: "1", name: "Spider-Man" }, }, }, - delay: 20, + delay: 200, }, { request: { query, variables: { id: "2" } }, @@ -1727,7 +1745,7 @@ describe("useSuspenseQuery", () => { }, }, }, - delay: 20, + delay: 200, }, { request: { query, variables: { id: "1" } }, @@ -1740,85 +1758,85 @@ describe("useSuspenseQuery", () => { }, }, }, - delay: 20, + delay: 200, }, ]; - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { fetchPolicy: "cache-and-network", variables: { id }, }), - { mocks, initialProps: { id: "1" } } + { wrapper: createMockWrapper({ mocks }), initialProps: { id: "1" } } ); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); + { + const { renderedComponents } = await takeRender(); - await rerenderAsync({ id: "1" }); + expect(renderedComponents).toStrictEqual([""]); + } - expect(result.current).toStrictEqualTyped({ - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.loading, - error: undefined, - }); + { + const { snapshot, renderedComponents } = await takeRender(); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - ...mocks[2].result, + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.count).toBe(6 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { + await rerender({ id: "1" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.loading, error: undefined, - }, - { + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[2].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, - }, - ]); + }); + } + + await expect(takeRender).not.toRerender(); }); it("refetches and suspends when switching back to already used variables while using `network-only` fetch policy", async () => { @@ -1840,7 +1858,7 @@ describe("useSuspenseQuery", () => { character: { __typename: "Character", id: "1", name: "Spider-Man" }, }, }, - delay: 20, + delay: 200, }, { request: { query, variables: { id: "2" } }, @@ -1853,7 +1871,7 @@ describe("useSuspenseQuery", () => { }, }, }, - delay: 20, + delay: 200, }, { request: { query, variables: { id: "1" } }, @@ -1866,72 +1884,79 @@ describe("useSuspenseQuery", () => { }, }, }, - delay: 20, + delay: 200, }, ]; - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { fetchPolicy: "network-only", variables: { id }, }), - { mocks, initialProps: { id: "1" } } + { wrapper: createMockWrapper({ mocks }), initialProps: { id: "1" } } ); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, - networkStatus: NetworkStatus.ready, dataState: "complete", + networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "1" }); + await rerender({ id: "1" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[2].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.count).toBe(6 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(3); - expect(renders.frames).toStrictEqualTyped([ - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[2].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); + await expect(takeRender).not.toRerender(); }); it("refetches and suspends when switching back to already used variables while using `no-cache` fetch policy", async () => { @@ -1953,7 +1978,7 @@ describe("useSuspenseQuery", () => { character: { __typename: "Character", id: "1", name: "Spider-Man" }, }, }, - delay: 20, + delay: 200, }, { request: { query, variables: { id: "2" } }, @@ -1966,7 +1991,7 @@ describe("useSuspenseQuery", () => { }, }, }, - delay: 20, + delay: 200, }, { request: { query, variables: { id: "1" } }, @@ -1979,72 +2004,79 @@ describe("useSuspenseQuery", () => { }, }, }, - delay: 20, + delay: 200, }, ]; - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { fetchPolicy: "no-cache", variables: { id }, }), - { mocks, initialProps: { id: "1" } } + { wrapper: createMockWrapper({ mocks }), initialProps: { id: "1" } } ); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "1" }); + await rerender({ id: "1" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[2].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.count).toBe(6 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(3); - expect(renders.frames).toStrictEqualTyped([ - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[2].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); + await expect(takeRender).not.toRerender(); }); it("responds to cache updates after changing back to already fetched variables", async () => { @@ -2055,87 +2087,83 @@ describe("useSuspenseQuery", () => { link: new MockLink(mocks), }); - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { variables: { id } }), - { client, initialProps: { id: "1" } } + { wrapper: createClientWrapper(client), initialProps: { id: "1" } } ); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "1" }); + await rerender({ id: "1" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - act(() => { - client.writeQuery({ - query, - variables: { id: "1" }, - data: { character: { id: "1", name: "Cached hero" } }, - }); + client.writeQuery({ + query, + variables: { id: "1" }, + data: { character: { id: "1", name: "Cached hero" } }, }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: { character: { id: "1", name: "Cached hero" } }, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.suspenseCount).toBe(2); - expect(renders.count).toBe(6 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.frames).toStrictEqualTyped([ - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: { character: { id: "1", name: "Cached hero" } }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); + await expect(takeRender).not.toRerender(); }); it('does not suspend when data is in the cache and using a "cache-first" fetch policy', async () => { @@ -2349,74 +2377,73 @@ describe("useSuspenseQuery", () => { } `; - const cache = new InMemoryCache(); - - cache.writeQuery({ - query: partialQuery, - data: { character: { id: "1" } }, - variables: { id: "1" }, - }); - - const { result, renders, rerenderAsync } = await renderSuspenseHook( - ({ id }) => - useSuspenseQuery(fullQuery, { - fetchPolicy: "cache-first", - returnPartialData: true, - variables: { id }, - }), - { cache, mocks, initialProps: { id: "1" } } - ); - - expect(renders.suspenseCount).toBe(0); - expect(result.current).toStrictEqualTyped({ - data: { character: { id: "1" } }, - dataState: "partial", - networkStatus: NetworkStatus.loading, - error: undefined, - }); - - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); - }); - - await rerenderAsync({ id: "2" }); + const cache = new InMemoryCache(); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }); + cache.writeQuery({ + query: partialQuery, + data: { character: { id: "1" } }, + variables: { id: "1" }, }); - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( + ({ id }) => + useSuspenseQuery(fullQuery, { + fetchPolicy: "cache-first", + returnPartialData: true, + variables: { id }, + }), { + wrapper: createMockWrapper({ cache, mocks }), + initialProps: { id: "1" }, + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: { character: { id: "1" } }, dataState: "partial", networkStatus: NetworkStatus.loading, error: undefined, - }, - { + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, - }, - { + }); + } + + await rerender({ id: "2" }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, - }, - ]); + }); + } + + await expect(takeRender).not.toRerender(); }); it('suspends when data is in the cache and using a "network-only" fetch policy', async () => { @@ -2847,66 +2874,65 @@ describe("useSuspenseQuery", () => { variables: { id: "1" }, }); - const { result, renders, rerenderAsync } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(fullQuery, { fetchPolicy: "cache-and-network", returnPartialData: true, variables: { id }, }), - { cache, mocks, initialProps: { id: "1" } } + { + wrapper: createMockWrapper({ cache, mocks }), + initialProps: { id: "1" }, + } ); - expect(renders.suspenseCount).toBe(0); - expect(result.current).toStrictEqualTyped({ - data: { character: { id: "1" } }, - dataState: "partial", - networkStatus: NetworkStatus.loading, - error: undefined, - }); + { + const { snapshot, renderedComponents } = await takeRender(); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { character: { id: "1" } }, + dataState: "partial", + networkStatus: NetworkStatus.loading, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + { + const { snapshot, renderedComponents } = await takeRender(); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ - ...mocks[1].result, + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(1); - expect(renders.frames).toStrictEqualTyped([ - { - data: { character: { id: "1" } }, - dataState: "partial", - networkStatus: NetworkStatus.loading, - error: undefined, - }, - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { + await rerender({ id: "2" }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, - }, - ]); + }); + } + + await expect(takeRender).not.toRerender(); }); it.each([ @@ -3065,53 +3091,51 @@ describe("useSuspenseQuery", () => { async (fetchPolicy) => { const { query, mocks } = useVariablesQueryCase(); - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { fetchPolicy, variables: { id } }), - { mocks, initialProps: { id: "1" } } + { wrapper: createMockWrapper({ mocks }), initialProps: { id: "1" } } ); - expect(renders.suspenseCount).toBe(1); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[0].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ ...mocks[1].result, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - // Renders: - // 1. Initiate fetch and suspend - // 2. Unsuspend and return results from initial fetch - // 3. Change variables and suspend - // 5. Unsuspend and return results from refetch - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - ...mocks[0].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - ...mocks[1].result, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); + await expect(takeRender).not.toRerender(); } ); @@ -3513,10 +3537,7 @@ describe("useSuspenseQuery", () => { const client = new ApolloClient({ cache: new InMemoryCache(), link: new ApolloLink((operation) => { - return new Observable((observer) => { - observer.next({ data: { vars: operation.variables } }); - observer.complete(); - }); + return of({ data: { vars: operation.variables } }).pipe(delay(200)); }), defaultOptions: { watchQuery: { @@ -3525,17 +3546,30 @@ describe("useSuspenseQuery", () => { }, }); - const { result, rerenderAsync, renders } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderUseSuspenseQuery( ({ source }) => useSuspenseQuery(query, { fetchPolicy: "network-only", variables: { source, localOnlyVar: true }, }), - { client, initialProps: { source: "local" } } + { + wrapper: createClientWrapper(client), + initialProps: { source: "local" }, + } ); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: { vars: { source: "local", globalOnlyVar: true, localOnlyVar: true }, }, @@ -3543,12 +3577,21 @@ describe("useSuspenseQuery", () => { networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - await rerenderAsync({ source: "rerender" }); + await rerender({ source: "rerender" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: { vars: { source: "rerender", globalOnlyVar: true, localOnlyVar: true }, }, @@ -3556,26 +3599,9 @@ describe("useSuspenseQuery", () => { networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.frames).toStrictEqualTyped([ - { - data: { - vars: { source: "local", globalOnlyVar: true, localOnlyVar: true }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - { - data: { - vars: { source: "rerender", globalOnlyVar: true, localOnlyVar: true }, - }, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); + await expect(takeRender).not.toRerender(); }); it("can unset a globally defined variable", async () => { @@ -4349,7 +4375,9 @@ describe("useSuspenseQuery", () => { } `; - const graphQLErrors = [new GraphQLError("Could not fetch user 1")]; + const graphQLErrors: GraphQLFormattedError[] = [ + { message: "Could not fetch user 1" }, + ]; const mocks = [ { @@ -4357,63 +4385,65 @@ describe("useSuspenseQuery", () => { result: { errors: graphQLErrors, }, - delay: 20, + delay: 200, }, { request: { query, variables: { id: "2" } }, result: { data: { user: { id: "2", name: "Captain Marvel" } }, }, - delay: 20, + delay: 200, }, ]; - const { result, renders, rerenderAsync } = await renderSuspenseHook( + using _disabledAct = disableActEnvironment(); + const { rerender, takeRender } = await renderUseSuspenseQuery( ({ id }) => useSuspenseQuery(query, { errorPolicy: "all", variables: { id } }), - { mocks, initialProps: { id: "1" } } + { wrapper: createMockWrapper({ mocks }), initialProps: { id: "1" } } ); const expectedError = new CombinedGraphQLErrors({ errors: graphQLErrors }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: undefined, dataState: "empty", networkStatus: NetworkStatus.error, error: expectedError, }); - }); + } - await rerenderAsync({ id: "2" }); + await rerender({ id: "2" }); - await waitFor(() => { - expect(result.current).toStrictEqualTyped({ + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ data: mocks[1].result.data, dataState: "complete", networkStatus: NetworkStatus.ready, error: undefined, }); - }); + } - expect(renders.count).toBe(4 + (IS_REACT_19 ? renders.suspenseCount : 0)); - expect(renders.errorCount).toBe(0); - expect(renders.errors).toEqual([]); - expect(renders.suspenseCount).toBe(2); - expect(renders.frames).toStrictEqualTyped([ - { - data: undefined, - dataState: "empty", - networkStatus: NetworkStatus.error, - error: expectedError, - }, - { - data: mocks[1].result.data, - dataState: "complete", - networkStatus: NetworkStatus.ready, - error: undefined, - }, - ]); + await expect(takeRender).not.toRerender(); }); it("re-suspends when calling `refetch`", async () => { diff --git a/src/react/hooks/__tests__/useSuspenseQuery/skipToken.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/skipToken.test.tsx new file mode 100644 index 00000000000..698f402312f --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/skipToken.test.tsx @@ -0,0 +1,269 @@ +import { disableActEnvironment } from "@testing-library/react-render-stream"; +import { delay, of } from "rxjs"; + +import { + ApolloClient, + ApolloLink, + InMemoryCache, + NetworkStatus, +} from "@apollo/client"; +import { skipToken, useSuspenseQuery } from "@apollo/client/react"; +import { MockLink } from "@apollo/client/testing"; +import { + createClientWrapper, + createMockWrapper, + setupVariablesCase, +} from "@apollo/client/testing/internal"; + +import { renderUseSuspenseQuery } from "./testUtils.js"; + +// https://github.com/apollographql/apollo-client/issues/12989 +test("maintains variables when switching to `skipToken` and calling `refetchQueries` while skipped after initial request", async () => { + const { query } = setupVariablesCase(); + + const client = new ApolloClient({ + link: new ApolloLink((operation) => { + return of( + operation.variables.id === "1" ? + { + data: { + character: { + __typename: "Character", + id: "1", + name: "Spider-Man", + }, + }, + } + : { + data: null, + errors: [ + { message: `Fetched wrong id: ${operation.variables.id}` }, + ], + } + ).pipe(delay(10)); + }), + cache: new InMemoryCache(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderUseSuspenseQuery( + ({ id }) => + useSuspenseQuery( + query, + id === undefined ? skipToken : { variables: { id } } + ), + { + initialProps: { id: "1" as string | undefined }, + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot } = await takeRender(); + + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: undefined }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); + + await expect( + client.refetchQueries({ include: [query] }) + ).resolves.toStrictEqualTyped([ + { + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + }, + ]); + + await expect(takeRender).not.toRerender(); +}); + +test("suspends and fetches when changing variables when no longer using skipToken", async () => { + const { query, mocks } = setupVariablesCase({ delay: 200 }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderUseSuspenseQuery( + ({ id }) => + useSuspenseQuery( + query, + id === undefined ? skipToken : { variables: { id } } + ), + { + initialProps: { id: "1" as string | undefined }, + wrapper: createMockWrapper({ mocks }), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot } = await takeRender(); + + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: undefined }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); + + await rerender({ id: "2" }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot } = await takeRender(); + + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "2", name: "Black Widow" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend for data in the cache when changing variables when no longer using skipToken", async () => { + const { query, mocks } = setupVariablesCase(); + + const client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache(), + }); + + client.writeQuery({ + query, + data: { + character: { __typename: "Character", id: "2", name: "Cached Widow" }, + }, + variables: { id: "2" }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderUseSuspenseQuery( + ({ id }) => + useSuspenseQuery( + query, + id === undefined ? skipToken : { variables: { id } } + ), + { + initialProps: { id: "1" as string | undefined }, + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot } = await takeRender(); + + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: undefined }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "1", name: "Spider-Man" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await rerender({ id: "2" }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + character: { __typename: "Character", id: "2", name: "Cached Widow" }, + }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx b/src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx new file mode 100644 index 00000000000..8554c0f566e --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx @@ -0,0 +1,73 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { ErrorLike, OperationVariables } from "@apollo/client"; +import type { useSuspenseQuery } from "@apollo/client/react"; +import { invariant } from "@apollo/client/utilities/invariant"; + +export async function renderUseSuspenseQuery< + TData, + TVariables extends OperationVariables, + Props = never, +>( + renderHook: (props: any) => any, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseQuery({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseQuery" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + replaceSnapshot({ error })} + > + + + + ); + } + + const { render, takeRender, replaceSnapshot, getCurrentRender } = + createRenderStream< + useSuspenseQuery.Result | { error: ErrorLike } + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + + invariant("data" in snapshot, "Snapshot is not a hook snapshot"); + + return snapshot; + } + + return { getCurrentSnapshot, rerender, takeRender }; +} diff --git a/src/react/hooks/internal/index.ts b/src/react/hooks/internal/index.ts index f930bd3998c..301a51269a6 100644 --- a/src/react/hooks/internal/index.ts +++ b/src/react/hooks/internal/index.ts @@ -1,5 +1,6 @@ // These hooks are used internally and are not exported publicly by the library export { useDeepMemo } from "./useDeepMemo.js"; export { useRenderGuard } from "./useRenderGuard.js"; +export { useSuspenseHookCacheKey } from "./useSuspenseHookCacheKey.js"; export { __use } from "./__use.js"; export { wrapHook } from "./wrapHook.js"; diff --git a/src/react/hooks/internal/useSuspenseHookCacheKey.ts b/src/react/hooks/internal/useSuspenseHookCacheKey.ts new file mode 100644 index 00000000000..f3c58fa9694 --- /dev/null +++ b/src/react/hooks/internal/useSuspenseHookCacheKey.ts @@ -0,0 +1,46 @@ +import type { DocumentNode } from "graphql"; +import * as React from "react"; + +import type { OperationVariables } from "@apollo/client"; +import type { CacheKey } from "@apollo/client/react/internal"; +import { canonicalStringify } from "@apollo/client/utilities"; + +import type { SkipToken } from "../constants.js"; +import { skipToken } from "../constants.js"; + +export declare namespace useSuspenseHookCacheKey { + export interface Options { + variables?: OperationVariables; + queryKey?: string | number | any[]; + } +} + +export function useSuspenseHookCacheKey( + query: DocumentNode, + options: + | (SkipToken & Partial) + | useSuspenseHookCacheKey.Options +) { + const { queryKey = [], variables } = options; + const canonicalVariables = canonicalStringify(variables); + + // This state value let's us maintain the variables used for the cache key + // when `skipToken` is used to skip a query after its been executed. + // Since options aren't provided when using `skipToken`, `variables` would + // otherwise disappear which means we'd return a new cache key without a + // variables value which creates a new `ObservableQuery` instance. This was + // particularly problematic when `refetchQueries` was used because it meant + // refetching against an `ObservableQuery` instance that had no variables. + let [cacheKeyVariables, setCacheKeyVariables] = + React.useState(canonicalVariables); + + if (options !== skipToken && cacheKeyVariables !== canonicalVariables) { + setCacheKeyVariables((cacheKeyVariables = canonicalVariables)); + } + + return [ + query, + cacheKeyVariables, + ...([] as any[]).concat(queryKey), + ] satisfies CacheKey; +} diff --git a/src/react/hooks/useBackgroundQuery.ts b/src/react/hooks/useBackgroundQuery.ts index 6634b99adc4..88f98543d87 100644 --- a/src/react/hooks/useBackgroundQuery.ts +++ b/src/react/hooks/useBackgroundQuery.ts @@ -12,10 +12,8 @@ import type { WatchQueryFetchPolicy, } from "@apollo/client"; import type { SubscribeToMoreFunction } from "@apollo/client"; -import { canonicalStringify } from "@apollo/client/cache"; import type { QueryRef } from "@apollo/client/react"; import type { - CacheKey, FetchMoreFunction, RefetchFunction, } from "@apollo/client/react/internal"; @@ -32,7 +30,7 @@ import type { } from "@apollo/client/utilities/internal"; import type { SkipToken } from "./constants.js"; -import { wrapHook } from "./internal/index.js"; +import { useSuspenseHookCacheKey, wrapHook } from "./internal/index.js"; import { useApolloClient } from "./useApolloClient.js"; import { useWatchQueryOptions } from "./useSuspenseQuery.js"; @@ -449,8 +447,8 @@ function useBackgroundQuery_< const client = useApolloClient(options.client); const suspenseCache = getSuspenseCache(client); const watchQueryOptions = useWatchQueryOptions({ client, query, options }); - const { fetchPolicy, variables } = watchQueryOptions; - const { queryKey = [] } = options; + const { fetchPolicy } = watchQueryOptions; + const cacheKey = useSuspenseHookCacheKey(query, options); // This ref tracks the first time query execution is enabled to determine // whether to return a query ref or `undefined`. When initialized @@ -461,12 +459,6 @@ function useBackgroundQuery_< const didFetchResult = React.useRef(fetchPolicy !== "standby"); didFetchResult.current ||= fetchPolicy !== "standby"; - const cacheKey: CacheKey = [ - query, - canonicalStringify(variables), - ...([] as any[]).concat(queryKey), - ]; - const queryRef = suspenseCache.getQueryRef(cacheKey, () => client.watchQuery( watchQueryOptions as ApolloClient.WatchQueryOptions diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index b8ff4434a72..4e41f801859 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -17,9 +17,7 @@ import type { } from "@apollo/client"; import type { SubscribeToMoreFunction } from "@apollo/client"; import { NetworkStatus } from "@apollo/client"; -import { canonicalStringify } from "@apollo/client/cache"; import type { - CacheKey, FetchMoreFunction, QueryKey, RefetchFunction, @@ -34,7 +32,12 @@ import type { import type { SkipToken } from "./constants.js"; import { skipToken } from "./constants.js"; -import { __use, useDeepMemo, wrapHook } from "./internal/index.js"; +import { + __use, + useDeepMemo, + useSuspenseHookCacheKey, + wrapHook, +} from "./internal/index.js"; import { validateSuspenseHookOptions } from "./internal/validateSuspenseHookOptions.js"; import { useApolloClient } from "./useApolloClient.js"; @@ -375,14 +378,8 @@ function useSuspenseQuery_< query, options, }); - const { fetchPolicy, variables } = watchQueryOptions; - const { queryKey = [] } = options; - - const cacheKey: CacheKey = [ - query, - canonicalStringify(variables), - ...([] as any[]).concat(queryKey), - ]; + const { fetchPolicy } = watchQueryOptions; + const cacheKey = useSuspenseHookCacheKey(query, options); const queryRef = suspenseCache.getQueryRef(cacheKey, () => client.watchQuery(watchQueryOptions) diff --git a/src/testing/internal/scenarios/index.ts b/src/testing/internal/scenarios/index.ts index 8b0d48ee69f..32f86aaddef 100644 --- a/src/testing/internal/scenarios/index.ts +++ b/src/testing/internal/scenarios/index.ts @@ -38,7 +38,9 @@ export interface VariablesCaseVariables { id: string; } -export function setupVariablesCase() { +export function setupVariablesCase({ + delay = 20, +}: { delay?: MockLink.Delay } = {}) { const query: TypedDocumentNode = gql` query CharacterQuery($id: ID!) { @@ -60,7 +62,7 @@ export function setupVariablesCase() { character: { __typename: "Character", id: String(index + 1), name }, }, }, - delay: 20, + delay, })); return { mocks, query };