diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 16e002d9106..456b7e0c74b 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -2416,6 +2416,40 @@ export function useSubscription(options: UseSuspenseFragmentOptions & { + from: {}; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: {} | null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions): UseSuspenseFragmentResult; + +// @public (undocumented) +interface UseSuspenseFragmentOptions extends Omit, NoInfer>, "id" | "query" | "optimistic" | "previousResult" | "returnPartialData">, Omit, "id" | "variables" | "returnPartialData"> { + client?: ApolloClient; + // (undocumented) + from: StoreObject | Reference | FragmentType> | string | null; + // (undocumented) + optimistic?: boolean; +} + +// @public (undocumented) +export type UseSuspenseFragmentResult = { + data: MaybeMasked; +}; + // @public (undocumented) export function useSuspenseQuery, "variables">>(query: DocumentNode | TypedDocumentNode, options?: SuspenseQueryHookOptions, NoInfer_2> & TOptions): UseSuspenseQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? TOptions["skip"] extends boolean ? DeepPartial | undefined : DeepPartial : TOptions["skip"] extends boolean ? TData | undefined : TData, TVariables>; diff --git a/.api-reports/api-report-react_hooks.api.md b/.api-reports/api-report-react_hooks.api.md index b252b2a672f..38930968676 100644 --- a/.api-reports/api-report-react_hooks.api.md +++ b/.api-reports/api-report-react_hooks.api.md @@ -2249,6 +2249,40 @@ export function useSubscription(options: UseSuspenseFragmentOptions & { + from: {}; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: {} | null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions): UseSuspenseFragmentResult; + +// @public (undocumented) +interface UseSuspenseFragmentOptions extends Omit, NoInfer>, "id" | "query" | "optimistic" | "previousResult" | "returnPartialData">, Omit, "id" | "variables" | "returnPartialData"> { + client?: ApolloClient; + // (undocumented) + from: StoreObject | Reference | FragmentType> | string | null; + // (undocumented) + optimistic?: boolean; +} + +// @public (undocumented) +export type UseSuspenseFragmentResult = { + data: MaybeMasked; +}; + // Warning: (ae-forgotten-export) The symbol "SuspenseQueryHookOptions" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-react_internal.api.md b/.api-reports/api-report-react_internal.api.md index 3e43a1dafbb..1fe912f490e 100644 --- a/.api-reports/api-report-react_internal.api.md +++ b/.api-reports/api-report-react_internal.api.md @@ -813,6 +813,19 @@ interface FieldSpecifier { variables?: Record; } +// @public (undocumented) +type FragmentCacheKey = [ +cacheId: string, +fragment: DocumentNode, +stringifiedVariables: string +]; + +// @public (undocumented) +interface FragmentKey { + // (undocumented) + __fragmentKey?: string; +} + // @public interface FragmentMap { // (undocumented) @@ -822,6 +835,43 @@ interface FragmentMap { // @public (undocumented) type FragmentMatcher = (rootValue: any, typeCondition: string, context: any) => boolean; +// @public (undocumented) +class FragmentReference> { + // Warning: (ae-forgotten-export) The symbol "FragmentReferenceOptions" needs to be exported by the entry point index.d.ts + constructor(client: ApolloClient, watchFragmentOptions: WatchFragmentOptions & { + from: string; + }, options: FragmentReferenceOptions); + // Warning: (ae-forgotten-export) The symbol "FragmentKey" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly key: FragmentKey; + // Warning: (ae-forgotten-export) The symbol "Listener_2" needs to be exported by the entry point index.d.ts + // + // (undocumented) + listen(listener: Listener_2>): () => void; + // (undocumented) + readonly observable: Observable>; + // Warning: (ae-forgotten-export) The symbol "FragmentRefPromise" needs to be exported by the entry point index.d.ts + // + // (undocumented) + promise: FragmentRefPromise>; + // (undocumented) + retain(): () => void; +} + +// @public (undocumented) +interface FragmentReferenceOptions { + // (undocumented) + autoDisposeTimeoutMs?: number; + // (undocumented) + onDispose?: () => void; +} + +// Warning: (ae-forgotten-export) The symbol "PromiseWithState" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type FragmentRefPromise = PromiseWithState; + // @public (undocumented) type FragmentType = [ TData @@ -1042,6 +1092,9 @@ type IsStrictlyAny = UnionToIntersection> extends never ? true // @public (undocumented) type Listener = (promise: QueryRefPromise) => void; +// @public (undocumented) +type Listener_2 = (promise: FragmentRefPromise) => void; + // @public (undocumented) class LocalState { // Warning: (ae-forgotten-export) The symbol "LocalStateOptions" needs to be exported by the entry point index.d.ts @@ -1771,8 +1824,6 @@ export interface QueryReference extends Q toPromise?: unknown; } -// Warning: (ae-forgotten-export) The symbol "PromiseWithState" needs to be exported by the entry point index.d.ts -// // @public (undocumented) type QueryRefPromise = PromiseWithState>>; @@ -2004,6 +2055,13 @@ class SuspenseCache { constructor(options?: SuspenseCacheOptions); // (undocumented) add(cacheKey: CacheKey, queryRef: InternalQueryReference): void; + // Warning: (ae-forgotten-export) The symbol "FragmentCacheKey" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "FragmentReference" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getFragmentRef(cacheKey: FragmentCacheKey, client: ApolloClient, options: WatchFragmentOptions & { + from: string; + }): FragmentReference; // (undocumented) getQueryRef(cacheKey: CacheKey, createObservable: () => ObservableQuery): InternalQueryReference; } @@ -2255,6 +2313,41 @@ interface UseReadQueryResult { networkStatus: NetworkStatus; } +// Warning: (ae-forgotten-export) The symbol "UseSuspenseFragmentOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "UseSuspenseFragmentResult" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: {}; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: {} | null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +function useSuspenseFragment(options: UseSuspenseFragmentOptions): UseSuspenseFragmentResult; + +// @public (undocumented) +interface UseSuspenseFragmentOptions extends Omit, NoInfer>, "id" | "query" | "optimistic" | "previousResult" | "returnPartialData">, Omit, "id" | "variables" | "returnPartialData"> { + client?: ApolloClient; + // (undocumented) + from: StoreObject | Reference | FragmentType> | string | null; + // (undocumented) + optimistic?: boolean; +} + +// @public (undocumented) +type UseSuspenseFragmentResult = { + data: MaybeMasked; +}; + // Warning: (ae-forgotten-export) The symbol "SuspenseQueryHookOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "UseSuspenseQueryResult" needs to be exported by the entry point index.d.ts // @@ -2382,6 +2475,10 @@ interface WrappableHooks { // // (undocumented) useReadQuery: typeof useReadQuery; + // Warning: (ae-forgotten-export) The symbol "useSuspenseFragment" needs to be exported by the entry point index.d.ts + // + // (undocumented) + useSuspenseFragment: typeof useSuspenseFragment; // Warning: (ae-forgotten-export) The symbol "useSuspenseQuery" needs to be exported by the entry point index.d.ts // // (undocumented) diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 850d6e603b9..46d85258c76 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -3087,6 +3087,40 @@ export function useSubscription(options: UseSuspenseFragmentOptions & { + from: {}; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions & { + from: {} | null; +}): UseSuspenseFragmentResult; + +// @public (undocumented) +export function useSuspenseFragment(options: UseSuspenseFragmentOptions): UseSuspenseFragmentResult; + +// @public (undocumented) +interface UseSuspenseFragmentOptions extends Omit, NoInfer>, "id" | "query" | "optimistic" | "previousResult" | "returnPartialData">, Omit, "id" | "variables" | "returnPartialData"> { + client?: ApolloClient; + // (undocumented) + from: StoreObject | Reference | FragmentType> | string | null; + // (undocumented) + optimistic?: boolean; +} + +// @public (undocumented) +export type UseSuspenseFragmentResult = { + data: MaybeMasked; +}; + // @public (undocumented) export function useSuspenseQuery, "variables">>(query: DocumentNode | TypedDocumentNode, options?: SuspenseQueryHookOptions, NoInfer_2> & TOptions): UseSuspenseQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? TOptions["skip"] extends boolean ? DeepPartial | undefined : DeepPartial : TOptions["skip"] extends boolean ? TData | undefined : TData, TVariables>; diff --git a/.changeset/blue-comics-train.md b/.changeset/blue-comics-train.md new file mode 100644 index 00000000000..6001d50d059 --- /dev/null +++ b/.changeset/blue-comics-train.md @@ -0,0 +1,9 @@ +--- +"@apollo/client": minor +--- + +Add a new `useSuspenseFragment` hook. + +`useSuspenseFragment` suspends until `data` is complete. It is a drop-in +replacement for `useFragment` when you prefer to use Suspense to control the +loading state of a fragment. diff --git a/.size-limits.json b/.size-limits.json index 54621796c0c..6087f492816 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 41639, + "dist/apollo-client.min.cjs": 42174, "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34381 } diff --git a/config/jest.config.js b/config/jest.config.js index 977c2e8e80a..011836cc41f 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -44,6 +44,7 @@ const react17TestFileIgnoreList = [ // 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__/useBackgroundQuery.test.tsx", "src/react/hooks/__tests__/useLoadableQuery.test.tsx", diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index e21e634c864..379695ffb14 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -67,6 +67,7 @@ Array [ "useReactiveVar", "useReadQuery", "useSubscription", + "useSuspenseFragment", "useSuspenseQuery", ] `; @@ -293,6 +294,7 @@ Array [ "useReactiveVar", "useReadQuery", "useSubscription", + "useSuspenseFragment", "useSuspenseQuery", ] `; @@ -338,6 +340,7 @@ Array [ "useReactiveVar", "useReadQuery", "useSubscription", + "useSuspenseFragment", "useSuspenseQuery", ] `; diff --git a/src/react/hooks/__tests__/useSuspenseFragment.test.tsx b/src/react/hooks/__tests__/useSuspenseFragment.test.tsx new file mode 100644 index 00000000000..a38c3f0fceb --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseFragment.test.tsx @@ -0,0 +1,1850 @@ +import { + useSuspenseFragment, + UseSuspenseFragmentResult, +} from "../useSuspenseFragment"; +import { + ApolloClient, + FragmentType, + gql, + InMemoryCache, + Masked, + MaskedDocumentNode, + MaybeMasked, + TypedDocumentNode, +} from "../../../core"; +import React, { Suspense } from "react"; +import { ApolloProvider } from "../../context"; +import { + createRenderStream, + disableActEnvironment, + renderHookToSnapshotStream, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import { renderAsync, spyOnConsole } from "../../../testing/internal"; +import { act, renderHook, screen, waitFor } from "@testing-library/react"; +import { InvariantError } from "ts-invariant"; +import { MockedProvider, MockSubscriptionLink, wait } from "../../../testing"; +import { expectTypeOf } from "expect-type"; +import userEvent from "@testing-library/user-event"; + +function createDefaultRenderStream() { + return createRenderStream({ + initialSnapshot: { + result: null as UseSuspenseFragmentResult> | null, + }, + }); +} + +function createDefaultTrackedComponents() { + function SuspenseFallback() { + useTrackRenders(); + return
Loading
; + } + + return { SuspenseFallback }; +} + +test("validates the GraphQL document is a fragment", () => { + using _ = spyOnConsole("error"); + + const fragment = gql` + query ShouldThrow { + createException + } + `; + + expect(() => { + renderHook( + () => useSuspenseFragment({ fragment, from: { __typename: "Nope" } }), + { wrapper: ({ children }) => {children} } + ); + }).toThrow( + new InvariantError( + "Found a query operation named 'ShouldThrow'. No operations are allowed when using a fragment as a query. Only fragments are allowed." + ) + ); +}); + +test("throws if no client is provided", () => { + using _spy = spyOnConsole("error"); + expect(() => + renderHook(() => + useSuspenseFragment({ + fragment: gql` + fragment ShouldThrow on Error { + shouldThrow + } + `, + from: {}, + }) + ) + ).toThrow(/pass an ApolloClient/); +}); + +test("suspends until cache value is complete", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { render, takeRender, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => { + return {children}; + }, + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("updates when the cache updates", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("resuspends when data goes missing until complete again", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + client.cache.modify({ + id: "Item:1", + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend and returns cache data when data is already in the cache", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Cached" }, + }); + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Cached", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("receives cache updates after initial result when data is written to the cache before mounted", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Cached" }, + }); + + function App() { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Cached", + }, + }); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Updated" }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Updated", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("allows the client to be overridden", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const defaultClient = new ApolloClient({ cache: new InMemoryCache() }); + const client = new ApolloClient({ cache: new InMemoryCache() }); + + defaultClient.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Should not be used" }, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + client, + from: { __typename: "Item", id: 1 }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + const { data } = await takeSnapshot(); + + expect(data).toEqual({ __typename: "Item", id: 1, text: "Item #1" }); +}); + +test("suspends until data is complete when changing `from` with no data written to cache", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const { takeRender, replaceSnapshot, render } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + function App({ id }: { id: number }) { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id }, + }); + + replaceSnapshot({ result }); + + return null; + } + + const { rerender } = await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + await rerender( + }> + + + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 2, + text: "Item #2", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend when changing `from` with data already written to cache", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const { takeRender, replaceSnapshot, render } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + using _disabledAct = disableActEnvironment(); + function App({ id }: { id: number }) { + useTrackRenders(); + + const result = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id }, + }); + + replaceSnapshot({ result }); + + return null; + } + + const { rerender } = await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + await rerender( + }> + + + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 2, + text: "Item #2", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +it("does not rerender when fields with @nonreactive change", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text @nonreactive + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ fragment, from: { __typename: "Item", id: 1 } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ + __typename: "Item", + id: 1, + text: "Item #1", + }); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +it("does not rerender when fields with @nonreactive on nested fragment change", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + ...ItemFields @nonreactive + } + + fragment ItemFields on Item { + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + fragmentName: "ItemFragment", + from: { __typename: "Item", id: 1 }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ + __typename: "Item", + id: 1, + text: "Item #1", + }); + } + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 1, + text: "Item #1 (updated)", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +// TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed +it.failing( + "warns and suspends when passing parent object to `from` when key fields are missing", + async () => { + using _ = spyOnConsole("warn"); + + interface Fragment { + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + age + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const { replaceSnapshot, render, takeRender } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + function App() { + const result = useSuspenseFragment({ + fragment, + from: { __typename: "User" }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.", + "UserFields" + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + } +); + +test("returns null if `from` is `null`", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useSuspenseFragment({ fragment, from: null }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + const { data } = await takeSnapshot(); + + expect(data).toBeNull(); +}); + +test("returns cached value when `from` changes from `null` to non-null value", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, rerender } = await renderHookToSnapshotStream( + ({ id }) => + useSuspenseFragment({ + fragment, + from: id === null ? null : { __typename: "Item", id }, + }), + { + initialProps: { id: null as null | number }, + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toBeNull(); + } + + await rerender({ id: 1 }); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ + __typename: "Item", + id: 1, + text: "Item #1", + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns null value when `from` changes from non-null value to `null`", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, rerender } = await renderHookToSnapshotStream( + ({ id }) => + useSuspenseFragment({ + fragment, + from: id === null ? null : { __typename: "Item", id }, + }), + { + initialProps: { id: 1 as null | number }, + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ + __typename: "Item", + id: 1, + text: "Item #1", + }); + } + + await rerender({ id: null }); + + { + const { data } = await takeSnapshot(); + + expect(data).toBeNull(); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("suspends until cached value is available when `from` changes from `null` to non-null value", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const { takeRender, render, replaceSnapshot } = + createDefaultRenderStream(); + const { SuspenseFallback } = createDefaultTrackedComponents(); + + function App({ id }: { id: number | null }) { + useTrackRenders(); + const result = useSuspenseFragment({ + fragment, + from: id === null ? null : { __typename: "Item", id }, + }); + + replaceSnapshot({ result }); + + return null; + } + + using _disabledAct = disableActEnvironment(); + const { rerender } = await render( + }> + + , + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ data: null }); + } + + await rerender( + }> + + + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([SuspenseFallback]); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + expect(snapshot.result).toEqual({ + data: { + __typename: "Item", + id: 1, + text: "Item #1", + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("returns masked fragment when data masking is enabled", async () => { + type Post = { + __typename: "Post"; + id: number; + title: string; + } & { " $fragmentRefs"?: { PostFields: PostFields } }; + + type PostFields = { + __typename: "Post"; + updatedAt: string; + } & { " $fragmentName"?: "PostFields" }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const fragment: TypedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + fragment PostFields on Post { + updatedAt + } + `; + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-01-01", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const snapshot = await takeSnapshot(); + + expect(snapshot).toEqual({ + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("does not rerender for cache writes to masked fields", async () => { + type Post = { + __typename: "Post"; + id: number; + title: string; + } & { " $fragmentRefs"?: { PostFields: PostFields } }; + + type PostFields = { + __typename: "Post"; + updatedAt: string; + } & { " $fragmentName"?: "PostFields" }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const fragment: TypedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + fragment PostFields on Post { + updatedAt + } + `; + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-01-01", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const snapshot = await takeSnapshot(); + + expect(snapshot).toEqual({ + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }); + } + + client.writeFragment({ + fragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-02-01", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("updates child fragments for cache updates to masked fields", async () => { + type Post = { + __typename: "Post"; + id: number; + title: string; + } & { " $fragmentRefs"?: { PostFields: PostFields } }; + + type PostFields = { + __typename: "Post"; + updatedAt: string; + } & { " $fragmentName"?: "PostFields" }; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + const postFieldsFragment: MaskedDocumentNode = gql` + fragment PostFields on Post { + updatedAt + } + `; + + const postFragment: MaskedDocumentNode = gql` + fragment PostFragment on Post { + id + title + ...PostFields + } + + ${postFieldsFragment} + `; + + client.writeFragment({ + fragment: postFragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-01-01", + }, + }); + + const { render, mergeSnapshot, takeRender } = createRenderStream({ + initialSnapshot: { + parent: null as UseSuspenseFragmentResult> | null, + child: null as UseSuspenseFragmentResult> | null, + }, + }); + + function Parent() { + useTrackRenders(); + const parent = useSuspenseFragment({ + fragment: postFragment, + fragmentName: "PostFragment", + from: { __typename: "Post", id: 1 }, + }); + + mergeSnapshot({ parent }); + + return ; + } + + function Child({ post }: { post: FragmentType }) { + useTrackRenders(); + const child = useSuspenseFragment({ + fragment: postFieldsFragment, + from: post, + }); + + mergeSnapshot({ child }); + return null; + } + + using _disabledAct = disableActEnvironment(); + await render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + { + const { snapshot } = await takeRender(); + + expect(snapshot).toEqual({ + parent: { + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }, + child: { + data: { + __typename: "Post", + updatedAt: "2024-01-01", + }, + }, + }); + } + + client.writeFragment({ + fragment: postFragment, + fragmentName: "PostFragment", + data: { + __typename: "Post", + id: 1, + title: "Blog post", + updatedAt: "2024-02-01", + }, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([Child]); + expect(snapshot).toEqual({ + parent: { + data: { + __typename: "Post", + id: 1, + title: "Blog post", + }, + }, + child: { + data: { + __typename: "Post", + updatedAt: "2024-02-01", + }, + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("tears down the subscription on unmount", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ cache }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + using _disabledAct = disableActEnvironment(); + const { unmount, takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ fragment, from: { __typename: "Item", id: 1 } }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ __typename: "Item", id: 1, text: "Item #1" }); + } + + expect(cache["watches"].size).toBe(1); + + unmount(); + // We need to wait a tick since the cleanup is run in a setTimeout to + // prevent strict mode bugs. + await wait(0); + + expect(cache["watches"].size).toBe(0); +}); + +test("tears down all watches when rendering multiple records", async () => { + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ cache }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + using _disabledAct = disableActEnvironment(); + const { unmount, rerender, takeSnapshot } = await renderHookToSnapshotStream( + ({ id }) => + useSuspenseFragment({ fragment, from: { __typename: "Item", id } }), + { + initialProps: { id: 1 }, + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ __typename: "Item", id: 1, text: "Item #1" }); + } + + await rerender({ id: 2 }); + + { + const { data } = await takeSnapshot(); + + expect(data).toEqual({ __typename: "Item", id: 2, text: "Item #2" }); + } + + unmount(); + // We need to wait a tick since the cleanup is run in a setTimeout to + // prevent strict mode bugs. + await wait(0); + + expect(cache["watches"].size).toBe(0); +}); + +test("tears down watches after default autoDisposeTimeoutMs if component never renders again after suspending", async () => { + jest.useFakeTimers(); + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ link, cache }); + + function App() { + const [showItem, setShowItem] = React.useState(true); + + return ( + + + {showItem && ( + + + + )} + + ); + } + + function Item() { + const { data } = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + return {data.text}; + } + + await renderAsync(); + + // Ensure suspends immediately + expect(screen.getByText("Loading item...")).toBeInTheDocument(); + + // Hide the greeting before it finishes loading data + await act(() => user.click(screen.getByText("Hide item"))); + + expect(screen.queryByText("Loading item...")).not.toBeInTheDocument(); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + // clear the microtask queue + await act(() => Promise.resolve()); + + expect(cache["watches"].size).toBe(1); + + jest.advanceTimersByTime(30_000); + + expect(cache["watches"].size).toBe(0); + + jest.useRealTimers(); +}); + +test("tears down watches after configured autoDisposeTimeoutMs if component never renders again after suspending", async () => { + jest.useFakeTimers(); + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const link = new MockSubscriptionLink(); + const cache = new InMemoryCache(); + const client = new ApolloClient({ + link, + cache, + defaultOptions: { + react: { + suspense: { + autoDisposeTimeoutMs: 5000, + }, + }, + }, + }); + + function App() { + const [showItem, setShowItem] = React.useState(true); + + return ( + + + {showItem && ( + + + + )} + + ); + } + + function Item() { + const { data } = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + return {data.text}; + } + + await renderAsync(); + + // Ensure suspends immediately + expect(screen.getByText("Loading item...")).toBeInTheDocument(); + + // Hide the greeting before it finishes loading data + await act(() => user.click(screen.getByText("Hide item"))); + + expect(screen.queryByText("Loading item...")).not.toBeInTheDocument(); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + // clear the microtask queue + await act(() => Promise.resolve()); + + expect(cache["watches"].size).toBe(1); + + jest.advanceTimersByTime(5000); + + expect(cache["watches"].size).toBe(0); + + jest.useRealTimers(); +}); + +test("cancels autoDisposeTimeoutMs if the component renders before timer finishes", async () => { + jest.useFakeTimers(); + interface ItemFragment { + __typename: "Item"; + id: number; + text: string; + } + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const link = new MockSubscriptionLink(); + const cache = new InMemoryCache(); + const client = new ApolloClient({ link, cache }); + + function App() { + return ( + + + + + + ); + } + + function Item() { + const { data } = useSuspenseFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + return {data.text}; + } + + await renderAsync(); + + // Ensure suspends immediately + expect(screen.getByText("Loading item...")).toBeInTheDocument(); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + // clear the microtask queue + await act(() => Promise.resolve()); + + await waitFor(() => { + expect(screen.getByText("Item #1")).toBeInTheDocument(); + }); + + jest.advanceTimersByTime(30_000); + + expect(cache["watches"].size).toBe(1); + + jest.useRealTimers(); +}); + +describe.skip("type tests", () => { + test("returns TData when from is a non-null value", () => { + type Data = { foo: string }; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ + fragment, + from: { __typename: "Query" }, + }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: { __typename: "Query" }, + }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + }); + + test("returns TData | null when from is null", () => { + type Data = { foo: string }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ fragment, from: null }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: null, + }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + }); + + test("returns TData | null when from is nullable", () => { + type Post = { __typename: "Post"; id: number }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + const author = {} as { post: Post | null }; + + { + const { data } = useSuspenseFragment({ fragment, from: author.post }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: author.post, + }); + + expectTypeOf(data).branded.toEqualTypeOf(); + } + }); +}); diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts index 78fc82c61f4..3fe6ccbc27d 100644 --- a/src/react/hooks/index.ts +++ b/src/react/hooks/index.ts @@ -11,6 +11,8 @@ export type { UseSuspenseQueryResult } from "./useSuspenseQuery.js"; export { useSuspenseQuery } from "./useSuspenseQuery.js"; export type { UseBackgroundQueryResult } from "./useBackgroundQuery.js"; export { useBackgroundQuery } from "./useBackgroundQuery.js"; +export type { UseSuspenseFragmentResult } from "./useSuspenseFragment.js"; +export { useSuspenseFragment } from "./useSuspenseFragment.js"; export type { LoadQueryFunction, UseLoadableQueryResult, diff --git a/src/react/hooks/internal/wrapHook.ts b/src/react/hooks/internal/wrapHook.ts index 59b112c3216..175d3e72a5b 100644 --- a/src/react/hooks/internal/wrapHook.ts +++ b/src/react/hooks/internal/wrapHook.ts @@ -5,6 +5,7 @@ import type { useReadQuery, useFragment, useQueryRefHandlers, + useSuspenseFragment, } from "../index.js"; import type { QueryManager } from "../../../core/QueryManager.js"; import type { ApolloClient } from "../../../core/ApolloClient.js"; @@ -17,6 +18,7 @@ interface WrappableHooks { createQueryPreloader: typeof createQueryPreloader; useQuery: typeof useQuery; useSuspenseQuery: typeof useSuspenseQuery; + useSuspenseFragment: typeof useSuspenseFragment; useBackgroundQuery: typeof useBackgroundQuery; useReadQuery: typeof useReadQuery; useFragment: typeof useFragment; diff --git a/src/react/hooks/useSuspenseFragment.ts b/src/react/hooks/useSuspenseFragment.ts new file mode 100644 index 00000000000..de498328ecd --- /dev/null +++ b/src/react/hooks/useSuspenseFragment.ts @@ -0,0 +1,158 @@ +import type { + ApolloClient, + OperationVariables, + Reference, + StoreObject, +} from "../../core/index.js"; +import { canonicalStringify } from "../../cache/index.js"; +import type { Cache } from "../../cache/index.js"; +import { useApolloClient } from "./useApolloClient.js"; +import { getSuspenseCache } from "../internal/index.js"; +import React, { useMemo } from "rehackt"; +import type { FragmentKey } from "../internal/cache/types.js"; +import { __use } from "./internal/__use.js"; +import { wrapHook } from "./internal/index.js"; +import type { FragmentType, MaybeMasked } from "../../masking/index.js"; + +export interface UseSuspenseFragmentOptions + extends Omit< + Cache.DiffOptions, NoInfer>, + "id" | "query" | "optimistic" | "previousResult" | "returnPartialData" + >, + Omit< + Cache.ReadFragmentOptions, + "id" | "variables" | "returnPartialData" + > { + from: StoreObject | Reference | FragmentType> | string | null; + // Override this field to make it optional (default: true). + optimistic?: boolean; + /** + * The instance of `ApolloClient` to use to look up the fragment. + * + * By default, the instance that's passed down via context is used, but you + * can provide a different instance here. + * + * @docGroup 1. Operation options + */ + client?: ApolloClient; +} + +export type UseSuspenseFragmentResult = { data: MaybeMasked }; + +const NULL_PLACEHOLDER = [] as unknown as [ + FragmentKey, + Promise | null>, +]; + +export function useSuspenseFragment< + TData, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions & { + from: {}; + } +): UseSuspenseFragmentResult; + +export function useSuspenseFragment< + TData, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions & { + from: null; + } +): UseSuspenseFragmentResult; + +export function useSuspenseFragment< + TData, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions & { + from: {} | null; + } +): UseSuspenseFragmentResult; + +export function useSuspenseFragment< + TData, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions +): UseSuspenseFragmentResult; + +export function useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions +): UseSuspenseFragmentResult { + return wrapHook( + "useSuspenseFragment", + _useSuspenseFragment, + useApolloClient(typeof options === "object" ? options.client : undefined) + )(options); +} + +function _useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: UseSuspenseFragmentOptions +): UseSuspenseFragmentResult { + const client = useApolloClient(options.client); + const { from } = options; + const { cache } = client; + + const id = useMemo( + () => + typeof from === "string" ? from + : from === null ? null + : cache.identify(from), + [cache, from] + ) as string | null; + + const fragmentRef = + id === null ? null : ( + getSuspenseCache(client).getFragmentRef( + [id, options.fragment, canonicalStringify(options.variables)], + client, + { ...options, from: id } + ) + ); + + let [current, setPromise] = React.useState< + [FragmentKey, Promise | null>] + >( + fragmentRef === null ? NULL_PLACEHOLDER : ( + [fragmentRef.key, fragmentRef.promise] + ) + ); + + React.useEffect(() => { + if (fragmentRef === null) { + return; + } + + const dispose = fragmentRef.retain(); + const removeListener = fragmentRef.listen((promise) => { + setPromise([fragmentRef.key, promise]); + }); + + return () => { + dispose(); + removeListener(); + }; + }, [fragmentRef]); + + if (fragmentRef === null) { + return { data: null }; + } + + if (current[0] !== fragmentRef.key) { + // eslint-disable-next-line react-compiler/react-compiler + current[0] = fragmentRef.key; + current[1] = fragmentRef.promise; + } + + const data = __use(current[1]); + + return { data }; +} diff --git a/src/react/internal/cache/FragmentReference.ts b/src/react/internal/cache/FragmentReference.ts new file mode 100644 index 00000000000..85453892bcc --- /dev/null +++ b/src/react/internal/cache/FragmentReference.ts @@ -0,0 +1,200 @@ +import { equal } from "@wry/equality"; +import type { + WatchFragmentOptions, + WatchFragmentResult, +} from "../../../cache/index.js"; +import type { ApolloClient } from "../../../core/ApolloClient.js"; +import type { MaybeMasked } from "../../../masking/index.js"; +import { + createFulfilledPromise, + wrapPromiseWithState, +} from "../../../utilities/index.js"; +import type { + Observable, + ObservableSubscription, + PromiseWithState, +} from "../../../utilities/index.js"; +import type { FragmentKey } from "./types.js"; + +type FragmentRefPromise = PromiseWithState; +type Listener = (promise: FragmentRefPromise) => void; + +interface FragmentReferenceOptions { + autoDisposeTimeoutMs?: number; + onDispose?: () => void; +} + +export class FragmentReference< + TData = unknown, + TVariables = Record, +> { + public readonly observable: Observable>; + public readonly key: FragmentKey = {}; + public promise!: FragmentRefPromise>; + + private resolve: ((result: MaybeMasked) => void) | undefined; + private reject: ((error: unknown) => void) | undefined; + + private subscription!: ObservableSubscription; + private listeners = new Set>>(); + private autoDisposeTimeoutId?: NodeJS.Timeout; + + private references = 0; + + constructor( + client: ApolloClient, + watchFragmentOptions: WatchFragmentOptions & { + from: string; + }, + options: FragmentReferenceOptions + ) { + this.dispose = this.dispose.bind(this); + this.handleNext = this.handleNext.bind(this); + this.handleError = this.handleError.bind(this); + + this.observable = client.watchFragment(watchFragmentOptions); + + if (options.onDispose) { + this.onDispose = options.onDispose; + } + + const diff = this.getDiff(client, watchFragmentOptions); + + // Start a timer that will automatically dispose of the query if the + // suspended resource does not use this fragmentRef in the given time. This + // helps prevent memory leaks when a component has unmounted before the + // query has finished loading. + const startDisposeTimer = () => { + if (!this.references) { + this.autoDisposeTimeoutId = setTimeout( + this.dispose, + options.autoDisposeTimeoutMs ?? 30_000 + ); + } + }; + + this.promise = + diff.complete ? + createFulfilledPromise(diff.result) + : this.createPendingPromise(); + this.subscribeToFragment(); + + this.promise.then(startDisposeTimer, startDisposeTimer); + } + + listen(listener: Listener>) { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + } + + retain() { + this.references++; + clearTimeout(this.autoDisposeTimeoutId); + let disposed = false; + + return () => { + if (disposed) { + return; + } + + disposed = true; + this.references--; + + setTimeout(() => { + if (!this.references) { + this.dispose(); + } + }); + }; + } + + private dispose() { + this.subscription.unsubscribe(); + this.onDispose(); + } + + private onDispose() { + // noop. overridable by options + } + + private subscribeToFragment() { + this.subscription = this.observable.subscribe( + this.handleNext.bind(this), + this.handleError.bind(this) + ); + } + + private handleNext(result: WatchFragmentResult) { + switch (this.promise.status) { + case "pending": { + if (result.complete) { + return this.resolve?.(result.data); + } + + this.deliver(this.promise); + break; + } + case "fulfilled": { + // This can occur when we already have a result written to the cache and + // we subscribe for the first time. We create a fulfilled promise in the + // constructor with a value that is the same as the first emitted value + // so we want to skip delivering it. + if (equal(this.promise.value, result.data)) { + return; + } + + this.promise = + result.complete ? + createFulfilledPromise(result.data) + : this.createPendingPromise(); + + this.deliver(this.promise); + } + } + } + + private handleError(error: unknown) { + this.reject?.(error); + } + + private deliver(promise: FragmentRefPromise>) { + this.listeners.forEach((listener) => listener(promise)); + } + + private createPendingPromise() { + return wrapPromiseWithState( + new Promise>((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }) + ); + } + + private getDiff( + client: ApolloClient, + options: WatchFragmentOptions & { from: string } + ) { + const { cache } = client; + const { from, fragment, fragmentName } = options; + + const diff = cache.diff({ + ...options, + query: cache["getFragmentDoc"](fragment, fragmentName), + returnPartialData: true, + id: from, + optimistic: true, + }); + + return { + ...diff, + result: client["queryManager"].maskFragment({ + fragment, + fragmentName, + data: diff.result, + }) as MaybeMasked, + }; + } +} diff --git a/src/react/internal/cache/SuspenseCache.ts b/src/react/internal/cache/SuspenseCache.ts index 8b1eba321b5..03bf0c43049 100644 --- a/src/react/internal/cache/SuspenseCache.ts +++ b/src/react/internal/cache/SuspenseCache.ts @@ -1,8 +1,13 @@ import { Trie } from "@wry/trie"; -import type { ObservableQuery } from "../../../core/index.js"; +import type { + ApolloClient, + ObservableQuery, + WatchFragmentOptions, +} from "../../../core/index.js"; import { canUseWeakMap } from "../../../utilities/index.js"; import { InternalQueryReference } from "./QueryReference.js"; -import type { CacheKey } from "./types.js"; +import type { CacheKey, FragmentCacheKey } from "./types.js"; +import { FragmentReference } from "./FragmentReference.js"; export interface SuspenseCacheOptions { /** @@ -22,6 +27,10 @@ export class SuspenseCache { private queryRefs = new Trie<{ current?: InternalQueryReference }>( canUseWeakMap ); + private fragmentRefs = new Trie<{ current?: FragmentReference }>( + canUseWeakMap + ); + private options: SuspenseCacheOptions; constructor(options: SuspenseCacheOptions = Object.create(null)) { @@ -48,6 +57,27 @@ export class SuspenseCache { return ref.current; } + getFragmentRef( + cacheKey: FragmentCacheKey, + client: ApolloClient, + options: WatchFragmentOptions & { from: string } + ) { + const ref = this.fragmentRefs.lookupArray(cacheKey) as { + current?: FragmentReference; + }; + + if (!ref.current) { + ref.current = new FragmentReference(client, options, { + autoDisposeTimeoutMs: this.options.autoDisposeTimeoutMs, + onDispose: () => { + delete ref.current; + }, + }); + } + + return ref.current; + } + add(cacheKey: CacheKey, queryRef: InternalQueryReference) { const ref = this.queryRefs.lookupArray(cacheKey); ref.current = queryRef; diff --git a/src/react/internal/cache/types.ts b/src/react/internal/cache/types.ts index 40f3c4cc8fc..a163431ad9d 100644 --- a/src/react/internal/cache/types.ts +++ b/src/react/internal/cache/types.ts @@ -6,6 +6,16 @@ export type CacheKey = [ ...queryKey: any[], ]; +export type FragmentCacheKey = [ + cacheId: string, + fragment: DocumentNode, + stringifiedVariables: string, +]; + export interface QueryKey { __queryKey?: string; } + +export interface FragmentKey { + __fragmentKey?: string; +}