diff --git a/examples/app-dir-experiments/app/ssr/ApolloWrapper.tsx b/examples/app-dir-experiments/app/ssr/ApolloWrapper.tsx index cb52c1f9..725586ef 100644 --- a/examples/app-dir-experiments/app/ssr/ApolloWrapper.tsx +++ b/examples/app-dir-experiments/app/ssr/ApolloWrapper.tsx @@ -1,14 +1,10 @@ "use client"; -import { - ApolloClient, - ApolloLink, - HttpLink, - SuspenseCache, -} from "@apollo/client"; +import { ApolloLink, HttpLink, SuspenseCache } from "@apollo/client"; import { ApolloNextAppProvider, NextSSRInMemoryCache, + NextSSRApolloClient, SSRMultipartLink, } from "@apollo/experimental-nextjs-app-support/ssr"; import { setVerbosity } from "ts-invariant"; @@ -21,7 +17,7 @@ function makeClient() { fetchOptions: { cache: "no-store" }, }); - return new ApolloClient({ + return new NextSSRApolloClient({ cache: new NextSSRInMemoryCache(), link: typeof window === "undefined" diff --git a/examples/app-dir-experiments/package.json b/examples/app-dir-experiments/package.json index af4cbbbf..d9777b84 100644 --- a/examples/app-dir-experiments/package.json +++ b/examples/app-dir-experiments/package.json @@ -10,7 +10,7 @@ "lint": "next lint" }, "dependencies": { - "@apollo/client": ">=3.8.0-beta.2", + "@apollo/client": ">=3.8.0-beta.4", "@apollo/experimental-nextjs-app-support": "workspace:^", "@apollo/server": "^4.6.0", "@as-integrations/next": "^1.3.0", diff --git a/examples/hack-the-supergraph-ssr/app/ApolloWrapper.tsx b/examples/hack-the-supergraph-ssr/app/ApolloWrapper.tsx index 19cbf80f..c914cb2e 100644 --- a/examples/hack-the-supergraph-ssr/app/ApolloWrapper.tsx +++ b/examples/hack-the-supergraph-ssr/app/ApolloWrapper.tsx @@ -1,15 +1,11 @@ "use client"; import React from "react"; -import { - ApolloClient, - ApolloLink, - HttpLink, - SuspenseCache, -} from "@apollo/client"; +import { ApolloLink, HttpLink, SuspenseCache } from "@apollo/client"; import clientCookies from "js-cookie"; import { ApolloNextAppProvider, NextSSRInMemoryCache, + NextSSRApolloClient, SSRMultipartLink, } from "@apollo/experimental-nextjs-app-support/ssr"; @@ -75,7 +71,7 @@ export function ApolloWrapper({ ]) : ApolloLink.from([delayLink, httpLink]); - return new ApolloClient({ + return new NextSSRApolloClient({ cache: new NextSSRInMemoryCache(), link, }); diff --git a/examples/hack-the-supergraph-ssr/app/error.tsx b/examples/hack-the-supergraph-ssr/app/error.tsx index 686d12fa..1b836d61 100644 --- a/examples/hack-the-supergraph-ssr/app/error.tsx +++ b/examples/hack-the-supergraph-ssr/app/error.tsx @@ -4,16 +4,14 @@ import React from "react"; import { Box, Heading, Text, VStack } from "@chakra-ui/react"; export const Error = ({ - children, - code, error, -}: React.PropsWithChildren<{ - code?: string; - error: { toString(): string }; -}>) => ( + reset, +}: { + error: Error; + reset: () => void; +}) => ( - {code ?? "Unknown Error"} Houston, something went wrong on our end Please review the information below for more details. @@ -25,10 +23,9 @@ export const Error = ({ borderRadius="8px" borderColor="brand.light" > - {error.toString()} + Error: {error.message} )} - {children} ); diff --git a/examples/hack-the-supergraph-ssr/package.json b/examples/hack-the-supergraph-ssr/package.json index 80227a09..0c9326bc 100644 --- a/examples/hack-the-supergraph-ssr/package.json +++ b/examples/hack-the-supergraph-ssr/package.json @@ -10,7 +10,7 @@ "lint": "next lint" }, "dependencies": { - "@apollo/client": ">=3.8.0-beta.2", + "@apollo/client": ">=3.8.0-beta.4", "@apollo/experimental-nextjs-app-support": "workspace:^", "@apollo/space-kit": "^9.11.0", "@chakra-ui/next-js": "^2.1.2", diff --git a/examples/polls-demo/app/cc/apollo-wrapper.tsx b/examples/polls-demo/app/cc/apollo-wrapper.tsx index ec3e398a..59fcb8c0 100644 --- a/examples/polls-demo/app/cc/apollo-wrapper.tsx +++ b/examples/polls-demo/app/cc/apollo-wrapper.tsx @@ -1,12 +1,8 @@ "use client"; +import { ApolloLink, HttpLink, SuspenseCache } from "@apollo/client"; import { - ApolloClient, - ApolloLink, - HttpLink, - SuspenseCache, -} from "@apollo/client"; -import { + NextSSRApolloClient, ApolloNextAppProvider, NextSSRInMemoryCache, SSRMultipartLink, @@ -25,7 +21,7 @@ function makeClient() { uri: "https://fragrant-shadow-9470.fly.dev/", }); - return new ApolloClient({ + return new NextSSRApolloClient({ cache: new NextSSRInMemoryCache(), link: typeof window === "undefined" diff --git a/examples/polls-demo/app/cc/error.tsx b/examples/polls-demo/app/cc/error.tsx index ba4be923..775d0917 100644 --- a/examples/polls-demo/app/cc/error.tsx +++ b/examples/polls-demo/app/cc/error.tsx @@ -1,5 +1,11 @@ "use client"; -export default function Error(error: { toString(): string }) { - return

Error: {error.toString()}

; +export default function Error({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + return

Error: {error.message}

; } diff --git a/examples/polls-demo/app/cc/loading.tsx b/examples/polls-demo/app/cc/loading.tsx deleted file mode 100644 index 999450c4..00000000 --- a/examples/polls-demo/app/cc/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PollSkeleton } from "@/components/poll"; - -export default function Loading() { - return ; -} diff --git a/examples/polls-demo/app/cc/page.tsx b/examples/polls-demo/app/cc/page.tsx index b295992c..6da0b3e7 100644 --- a/examples/polls-demo/app/cc/page.tsx +++ b/examples/polls-demo/app/cc/page.tsx @@ -1,7 +1,7 @@ -import { Poll } from "./poll-cc"; +import { PollWrapper } from "./poll-cc"; export const dynamic = "force-dynamic"; export default async function Home() { - return ; + return ; } diff --git a/examples/polls-demo/app/cc/poll-cc.tsx b/examples/polls-demo/app/cc/poll-cc.tsx index 7522b0c8..0d669475 100644 --- a/examples/polls-demo/app/cc/poll-cc.tsx +++ b/examples/polls-demo/app/cc/poll-cc.tsx @@ -1,7 +1,11 @@ "use client"; - -import { useSuspenseQuery } from "@apollo/experimental-nextjs-app-support/ssr"; +import { Suspense } from "react"; +import { + useReadQuery, + useBackgroundQuery, +} from "@apollo/experimental-nextjs-app-support/ssr"; import { useMutation } from "@apollo/client"; +import { QueryReference } from "@apollo/client/react/cache/QueryReference"; import { Poll as PollInner } from "@/components/poll"; import { useState, useCallback } from "react"; @@ -9,15 +13,24 @@ import { useState, useCallback } from "react"; import { AnswerPollDocument, GetPollDocument, + GetPollQuery, } from "@/components/poll/documents.generated"; -export const Poll = () => { - const [showResults, setShowResults] = useState(false); - - const { data } = useSuspenseQuery(GetPollDocument, { +export const PollWrapper = () => { + const [queryRef] = useBackgroundQuery(GetPollDocument, { variables: { id: "1", delay: 0 }, }); + return ( + Loading...}> + + + ); +}; + +const Poll = ({ queryRef }: { queryRef: QueryReference }) => { + const { data } = useReadQuery(queryRef); + const [showResults, setShowResults] = useState(false); const [mutate, { loading: mutationLoading }] = useMutation(AnswerPollDocument); diff --git a/examples/polls-demo/package.json b/examples/polls-demo/package.json index bdb8fe38..bef66cd3 100644 --- a/examples/polls-demo/package.json +++ b/examples/polls-demo/package.json @@ -12,7 +12,7 @@ "codegen": "graphql-codegen --config codegen.ts" }, "dependencies": { - "@apollo/client": ">=3.8.0-beta.2", + "@apollo/client": ">=3.8.0-beta.4", "@apollo/experimental-nextjs-app-support": "workspace:^", "@apollo/server": "^4.7.0", "@as-integrations/next": "^1.3.0", diff --git a/package/.eslintrc.js b/package/.eslintrc.js index 505b533b..a66876e0 100644 --- a/package/.eslintrc.js +++ b/package/.eslintrc.js @@ -20,5 +20,11 @@ module.exports = { rules: { "@typescript-eslint/no-explicit-any": "off", "react/prop-types": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + varsIgnorePattern: "^_", + }, + ], }, }; diff --git a/package/README.md b/package/README.md index 47f06456..15c39e76 100644 --- a/package/README.md +++ b/package/README.md @@ -82,7 +82,6 @@ First, create a new file `app/ApolloWrapper.js`: // ^ this file needs the "use client" pragma import { - ApolloClient, ApolloLink, HttpLink, SuspenseCache, @@ -90,6 +89,7 @@ import { import { ApolloNextAppProvider, NextSSRInMemoryCache, + NextSSRApolloClient, SSRMultipartLink, } from "@apollo/experimental-nextjs-app-support/ssr"; @@ -103,7 +103,7 @@ function makeClient() { fetchOptions: { cache: "no-store" }, }); - return new ApolloClient({ + return new NextSSRApolloClient({ // use the `NextSSRInMemoryCache`, not the normal `InMemoryCache` cache: new NextSSRInMemoryCache(), link: @@ -164,7 +164,12 @@ export default function RootLayout({ > ☝️ This will work even if your layout is a React Server Component and will also allow the children of the layout to be React Server Components. > It just makes sure that all Client Components will have access to the same Apollo Client instance, shared through the `ApolloNextAppProvider`. -Now you can use the hooks `useQuery`, `useSuspenseQuery`, `useFragment`, and `useApolloClient` from `"@apollo/experimental-nextjs-app-support/ssr"` in your Client components like you are used to. +You can import the following Apollo Client hooks from `"@apollo/experimental-nextjs-app-support/ssr"` in your client components (make sure you are not importing these hooks from `@apollo/client` as this package wraps and re-exports them to support streaming SSR): +- `useQuery` +- `useSuspenseQuery` +- `useBackgroundQuery` +- `useReadQuery` +- `useFragment` If you want to make the most of the streaming SSR features offered by React & the Next.js App Router, consider using the [`useSuspenseQuery`](https://www.apollographql.com/docs/react/api/react/hooks-experimental/#using-usesuspensequery_experimental) and [`useFragment`](https://www.apollographql.com/docs/react/api/react/hooks-experimental/#using-usefragment_experimental) hooks. diff --git a/package/package.json b/package/package.json index 214cce12..afbbba18 100644 --- a/package/package.json +++ b/package/package.json @@ -44,7 +44,7 @@ }, "packageManager": "yarn@3.5.0", "devDependencies": { - "@apollo/client": ">=3.8.0-beta.2", + "@apollo/client": ">=3.8.0-beta.4", "@total-typescript/shoehorn": "^0.1.0", "@tsconfig/recommended": "^1.0.1", "@typescript-eslint/eslint-plugin": "latest", @@ -59,7 +59,7 @@ "vitest": "^0.30.1" }, "peerDependencies": { - "@apollo/client": ">=3.8.0-beta.2", + "@apollo/client": ">=3.8.0-beta.4", "next": "^13.4.1", "react": "^18" }, diff --git a/package/src/ssr/ApolloRehydrateSymbols.tsx b/package/src/ssr/ApolloRehydrateSymbols.tsx index 5a83c98e..fd91fe1c 100644 --- a/package/src/ssr/ApolloRehydrateSymbols.tsx +++ b/package/src/ssr/ApolloRehydrateSymbols.tsx @@ -1,5 +1,9 @@ import { SuperJSONResult } from "superjson/dist/types"; -import { RehydrationCache, ResultsCache } from "./types"; +import { + RehydrationCache, + ResultsCache, + BackgroundQueriesCache, +} from "./types"; import type { DataTransport } from "./dataTransport"; declare global { @@ -7,8 +11,12 @@ declare global { [ApolloRehydrationCache]?: RehydrationCache; [ApolloResultCache]?: ResultsCache; [ApolloSSRDataTransport]?: DataTransport; + [ApolloBackgroundQueryTransport]?: BackgroundQueriesCache; } } export const ApolloRehydrationCache = Symbol.for("ApolloRehydrationCache"); export const ApolloResultCache = Symbol.for("ApolloResultCache"); export const ApolloSSRDataTransport = Symbol.for("ApolloSSRDataTransport"); +export const ApolloBackgroundQueryTransport = Symbol.for( + "ApolloBackgroundQueryTransport" +); diff --git a/package/src/ssr/NextSSRApolloClient.tsx b/package/src/ssr/NextSSRApolloClient.tsx new file mode 100644 index 00000000..1158b2c3 --- /dev/null +++ b/package/src/ssr/NextSSRApolloClient.tsx @@ -0,0 +1,170 @@ +import { + ApolloClient, + ApolloClientOptions, + OperationVariables, + WatchQueryOptions, + Observable, + FetchResult, + DocumentNode, +} from "@apollo/client"; +import type { QueryManager } from "@apollo/client/core/QueryManager"; +import { print } from "graphql"; +import { canonicalStringify } from "@apollo/client/cache"; +import { RehydrationContextValue } from "./types"; +import { registerLateInitializingQueue } from "./lateInitializingQueue"; +import { + ApolloBackgroundQueryTransport, + ApolloResultCache, +} from "./ApolloRehydrateSymbols"; + +function getQueryManager( + client: ApolloClient +): QueryManager { + return client["queryManager"]; +} + +export class NextSSRApolloClient< + TCacheShape +> extends ApolloClient { + private rehydrationContext: Pick< + RehydrationContextValue, + "incomingBackgroundQueries" + > = { + incomingBackgroundQueries: [], + }; + + constructor(options: ApolloClientOptions) { + super(options); + + this.registerWindowHook(); + } + + private resolveFakeQueries = new Map< + string, + [(result: FetchResult) => void, (reason: any) => void] + >(); + + private identifyUniqueQuery(options: { + query: DocumentNode; + variables?: unknown; + }) { + const transformedDocument = this.documentTransform.transformDocument( + options.query + ); + const queryManager = getQueryManager(this); + // Calling `transformDocument` will add __typename but won't remove client + // directives, so we need to get the `serverQuery`. + const { serverQuery } = queryManager.getDocumentInfo(transformedDocument); + + const canonicalVariables = canonicalStringify(options.variables); + + const cacheKey = [serverQuery, canonicalVariables].toString(); + + return { query: serverQuery, cacheKey, varJson: canonicalVariables }; + } + + private registerWindowHook() { + if (typeof window !== "undefined") { + if (Array.isArray(window[ApolloBackgroundQueryTransport] || [])) { + registerLateInitializingQueue( + ApolloBackgroundQueryTransport, + (options) => { + const { query, varJson, cacheKey } = + this.identifyUniqueQuery(options); + + if (!query) return; + const printedServerQuery = print(query); + const queryManager = getQueryManager(this); + + const byVariables = + queryManager["inFlightLinkObservables"].get(printedServerQuery) || + new Map(); + + queryManager["inFlightLinkObservables"].set( + printedServerQuery, + byVariables + ); + + if (!byVariables.has(varJson)) { + const promise = new Promise((resolve, reject) => { + this.resolveFakeQueries.set(cacheKey, [resolve, reject]); + }); + const cleanupCancelFn = () => + queryManager["fetchCancelFns"].delete(cacheKey); + + byVariables.set( + varJson, + new Observable((observer) => { + promise + .then((result) => { + observer.next(result); + observer.complete(); + }) + .catch((err) => { + observer.error(err); + }) + .finally(() => { + this.resolveFakeQueries.delete(cacheKey); + cleanupCancelFn(); + }); + }) + ); + + queryManager["fetchCancelFns"].set( + cacheKey, + (reason: unknown) => { + cleanupCancelFn(); + const [_, reject] = + this.resolveFakeQueries.get(cacheKey) ?? []; + if (reject) { + reject(reason); + } + } + ); + } + } + ); + } + + if (Array.isArray(window[ApolloResultCache] || [])) { + registerLateInitializingQueue(ApolloResultCache, (data) => { + const { cacheKey } = this.identifyUniqueQuery(data); + const [resolve] = this.resolveFakeQueries.get(cacheKey) ?? []; + + if (resolve) { + resolve({ + data: data.result, + }); + // In order to avoid a scenario where the promise resolves without + // a query subscribing to the promise, we immediately call + // `cache.write` here. + // For more information, see: https://github.com/apollographql/apollo-client-nextjs/pull/38/files/388813a16e2ac5c62408923a1face9ae9417d92a#r1229870523 + this.cache.write(data); + } + }); + } + } + } + + watchQuery< + T = any, + TVariables extends OperationVariables = OperationVariables + >(options: WatchQueryOptions) { + if (typeof window == "undefined") { + this.rehydrationContext.incomingBackgroundQueries.push(options); + } + const result = super.watchQuery(options); + return result; + } + + setRehydrationContext(rehydrationContext: RehydrationContextValue) { + if ( + rehydrationContext.incomingBackgroundQueries !== + this.rehydrationContext.incomingBackgroundQueries + ) + rehydrationContext.incomingBackgroundQueries.push( + ...this.rehydrationContext.incomingBackgroundQueries.splice(0) + ); + this.rehydrationContext = rehydrationContext; + } +} diff --git a/package/src/ssr/NextSSRInMemoryCache.tsx b/package/src/ssr/NextSSRInMemoryCache.tsx index ac4e2edd..9d505c77 100644 --- a/package/src/ssr/NextSSRInMemoryCache.tsx +++ b/package/src/ssr/NextSSRInMemoryCache.tsx @@ -4,9 +4,7 @@ import { Cache, Reference, } from "@apollo/client"; -import { ApolloResultCache } from "./ApolloRehydrateSymbols"; import { RehydrationContextValue } from "./types"; -import { registerLateInitializingQueue } from "./lateInitializingQueue"; export class NextSSRInMemoryCache extends InMemoryCache { private rehydrationContext: Pick< @@ -18,21 +16,6 @@ export class NextSSRInMemoryCache extends InMemoryCache { }; constructor(config?: InMemoryCacheConfig) { super(config); - - this.registerWindowHook(); - } - private registerWindowHook() { - if (typeof window !== "undefined") { - if (Array.isArray(window[ApolloResultCache] || [])) { - registerLateInitializingQueue(ApolloResultCache, (data) => - this.write(data) - ); - } else { - throw new Error( - "On the client side, only one instance of `NextSSRInMemoryCache` can be created!" - ); - } - } } write(options: Cache.WriteOptions): Reference | undefined { diff --git a/package/src/ssr/RehydrationContext.tsx b/package/src/ssr/RehydrationContext.tsx index a2f5d26f..0ad93f14 100644 --- a/package/src/ssr/RehydrationContext.tsx +++ b/package/src/ssr/RehydrationContext.tsx @@ -5,6 +5,7 @@ import { ServerInsertedHTMLContext } from "next/navigation"; import { RehydrationContextValue } from "./types"; import { registerDataTransport, transportDataToJS } from "./dataTransport"; import invariant from "ts-invariant"; +import { NextSSRApolloClient } from "./NextSSRApolloClient"; const ApolloRehydrationContext = React.createContext< RehydrationContextValue | undefined @@ -13,15 +14,21 @@ const ApolloRehydrationContext = React.createContext< export const RehydrationContextProvider = ({ children, }: React.PropsWithChildren) => { - const { cache } = useApolloClient(); + const client = useApolloClient(); const rehydrationContext = React.useRef(); if (typeof window == "undefined") { if (!rehydrationContext.current) { rehydrationContext.current = buildApolloRehydrationContext(); } - - if (cache instanceof NextSSRInMemoryCache) { - cache.setRehydrationContext(rehydrationContext.current); + if (client instanceof NextSSRApolloClient) { + client.setRehydrationContext(rehydrationContext.current); + } else { + throw new Error( + "When using Next SSR, you must use the `NextSSRApolloClient`" + ); + } + if (client.cache instanceof NextSSRInMemoryCache) { + client.cache.setRehydrationContext(rehydrationContext.current); } else { throw new Error( "When using Next SSR, you must use the `NextSSRInMemoryCache`" @@ -61,11 +68,13 @@ function buildApolloRehydrationContext(): RehydrationContextValue { transportValueData: {}, transportedValues: {}, incomingResults: [], + incomingBackgroundQueries: [], RehydrateOnClient() { rehydrationContext.currentlyInjected = false; if ( !Object.keys(rehydrationContext.transportValueData).length && - !Object.keys(rehydrationContext.incomingResults).length + !Object.keys(rehydrationContext.incomingResults).length && + !Object.keys(rehydrationContext.incomingBackgroundQueries).length ) return <>; invariant.debug( @@ -76,6 +85,10 @@ function buildApolloRehydrationContext(): RehydrationContextValue { "transporting results", rehydrationContext.incomingResults ); + invariant.debug( + "transporting incomingBackgroundQueries", + rehydrationContext.incomingBackgroundQueries + ); const __html = transportDataToJS({ rehydrate: Object.fromEntries( @@ -85,6 +98,7 @@ function buildApolloRehydrationContext(): RehydrationContextValue { ) ), results: rehydrationContext.incomingResults, + backgroundQueries: rehydrationContext.incomingBackgroundQueries, }); Object.assign( rehydrationContext.transportedValues, @@ -92,6 +106,7 @@ function buildApolloRehydrationContext(): RehydrationContextValue { ); rehydrationContext.transportValueData = {}; rehydrationContext.incomingResults = []; + rehydrationContext.incomingBackgroundQueries = []; return (