From e8057fc22da8d7578b497e7c21bc32ad80ab4075 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 3 Apr 2024 15:10:03 +0200 Subject: [PATCH] progress --- .../nextjs/src/app/cc/ApolloWrapper.tsx | 21 +---- .../cc/dynamic/useBackgroundQuery/page.tsx | 4 +- .../app/cc/dynamic/useSuspenseQuery/page.tsx | 4 +- integration-test/nextjs/src/app/rsc/client.ts | 9 +-- .../rsc/dynamic/PreloadQuery/ClientChild.tsx | 15 ++++ .../dynamic/PreloadQuery/PreloadQuery.test.ts | 41 ++++++++++ .../src/app/rsc/dynamic/PreloadQuery/page.tsx | 29 +++++++ .../app/rsc/dynamic/PreloadQuery/shared.tsx | 15 ++++ .../nextjs/src/shared/delayLink.ts | 6 ++ .../nextjs/src/shared/errorLink.tsx | 30 ++++++++ integration-test/package.json | 2 +- integration-test/yarn.lock | 10 +-- .../WrappedApolloClient.tsx | 77 +++++++++++++------ .../src/PreloadQuery.tsx | 22 ++++-- .../client-react-streaming/src/index.cc.ts | 16 ++-- 15 files changed, 228 insertions(+), 73 deletions(-) create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/ClientChild.tsx create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/PreloadQuery.test.ts create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/page.tsx create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/shared.tsx create mode 100644 integration-test/nextjs/src/shared/errorLink.tsx diff --git a/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx b/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx index 28c80b3b..f3022dcd 100644 --- a/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx +++ b/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx @@ -1,6 +1,6 @@ "use client"; import React from "react"; -import { ApolloLink, HttpLink, Observable } from "@apollo/client"; +import { HttpLink } from "@apollo/client"; import { ApolloNextAppProvider, NextSSRInMemoryCache, @@ -15,29 +15,12 @@ import { delayLink } from "@/shared/delayLink"; import { schema } from "../graphql/schema"; import { useSSROnlySecret } from "ssr-only-secrets"; -import { GraphQLError } from "graphql"; +import { errorLink } from "../../shared/errorLink"; setVerbosity("debug"); loadDevMessages(); loadErrorMessages(); -const errorLink = new ApolloLink((operation, forward) => { - const context = operation.getContext(); - if ( - context.error === "always" || - (typeof window === "undefined" && context.error === "ssr") || - (typeof window !== "undefined" && context.error === "browser") - ) { - return new Observable((subscriber) => { - subscriber.next({ - data: null, - errors: [new GraphQLError("Simulated error")], - }); - }); - } - return forward(operation); -}); - export function ApolloWrapper({ children, nonce, diff --git a/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQuery/page.tsx b/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQuery/page.tsx index 58928057..ea1d336e 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQuery/page.tsx +++ b/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQuery/page.tsx @@ -27,7 +27,9 @@ const QUERY: TypedDocumentNode = gql` export const dynamic = "force-dynamic"; export default function Page() { - const [queryRef] = useBackgroundQuery(QUERY, { context: { delay: 2000 } }); + const [queryRef] = useBackgroundQuery(QUERY, { + context: { delay: 2000, error: "browser" }, + }); return ( loading

}> diff --git a/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQuery/page.tsx b/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQuery/page.tsx index 56148d54..8d25a5f1 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQuery/page.tsx +++ b/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQuery/page.tsx @@ -21,7 +21,9 @@ const QUERY: TypedDocumentNode<{ export const dynamic = "force-dynamic"; export default function Page() { - const { data } = useSuspenseQuery(QUERY); + const { data } = useSuspenseQuery(QUERY, { + context: { delay: 1000, error: "browser" }, + }); globalThis.hydrationFinished?.(); return ( diff --git a/integration-test/nextjs/src/app/rsc/client.ts b/integration-test/nextjs/src/app/rsc/client.ts index 8aa56097..fb3cffcf 100644 --- a/integration-test/nextjs/src/app/rsc/client.ts +++ b/integration-test/nextjs/src/app/rsc/client.ts @@ -4,6 +4,7 @@ import { registerApolloClient } from "@apollo/experimental-nextjs-app-support/rs import { loadErrorMessages, loadDevMessages } from "@apollo/client/dev"; import { setVerbosity } from "ts-invariant"; import { delayLink } from "@/shared/delayLink"; +import { errorLink } from "@/shared/errorLink"; import { SchemaLink } from "@apollo/client/link/schema"; import { schema } from "../graphql/schema"; @@ -15,12 +16,6 @@ loadErrorMessages(); export const { getClient } = registerApolloClient(() => { return new ApolloClient({ cache: new InMemoryCache(), - link: delayLink.concat( - typeof window === "undefined" - ? new SchemaLink({ schema }) - : new HttpLink({ - uri: "/graphql", - }) - ), + link: delayLink.concat(errorLink.concat(new SchemaLink({ schema }))), }); }); diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/ClientChild.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/ClientChild.tsx new file mode 100644 index 00000000..abd74f4a --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/ClientChild.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useSuspenseQuery } from "@apollo/client"; +import { QUERY } from "./shared"; + +export function ClientChild() { + const { data } = useSuspenseQuery(QUERY, { context: { error: "always" } }); + return ( +
    + {data.products.map(({ id, title }) => ( +
  • {title}
  • + ))} +
+ ); +} diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/PreloadQuery.test.ts b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/PreloadQuery.test.ts new file mode 100644 index 00000000..c48c5f11 --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/PreloadQuery.test.ts @@ -0,0 +1,41 @@ +import { expect } from "@playwright/test"; +import { test } from "../../../../../fixture"; + +test.describe("PreloadQuery", () => { + test("query resolves on the server", async ({ page, blockRequest }) => { + await page.goto( + "http://localhost:3000/rsc/dynamic/PreloadQuery?errorIn=ssr,browser", + { + waitUntil: "commit", + } + ); + + await expect(page).toBeInitiallyLoading(true); + await expect(page.getByText("loading")).not.toBeVisible(); + await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible(); + }); + + test("query errors on the server, restarts in the browser", async ({ + page, + }) => { + page.allowErrors?.(); + await page.goto( + "http://localhost:3000/rsc/dynamic/PreloadQuery?errorIn=rsc", + { + waitUntil: "commit", + } + ); + + await expect(page).toBeInitiallyLoading(true); + + await page.waitForEvent("pageerror", (error) => { + return ( + /* prod */ error.message.includes("Minified React error #419") || + /* dev */ error.message.includes("Query failed upstream.") + ); + }); + + await expect(page.getByText("loading")).not.toBeVisible(); + await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible(); + }); +}); diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/page.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/page.tsx new file mode 100644 index 00000000..cf85c659 --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/page.tsx @@ -0,0 +1,29 @@ +import { ApolloWrapper } from "@/app/cc/ApolloWrapper"; +import { PreloadQuery } from "@apollo/client-react-streaming"; +import { ClientChild } from "./ClientChild"; +import { QUERY } from "./shared"; + +export const dynamic = "force-dynamic"; +import { getClient } from "../../client"; +import { Suspense } from "react"; + +export default function Page({ searchParams }: { searchParams?: any }) { + return ( + + + loading}> + + + + + ); +} diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/shared.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/shared.tsx new file mode 100644 index 00000000..0fabe82d --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/shared.tsx @@ -0,0 +1,15 @@ +import { TypedDocumentNode, gql } from "@apollo/client"; + +export const QUERY: TypedDocumentNode<{ + products: { + id: string; + title: string; + }[]; +}> = gql` + query dynamicProducts { + products { + id + title + } + } +`; diff --git a/integration-test/nextjs/src/shared/delayLink.ts b/integration-test/nextjs/src/shared/delayLink.ts index d789b796..e4f81b1d 100644 --- a/integration-test/nextjs/src/shared/delayLink.ts +++ b/integration-test/nextjs/src/shared/delayLink.ts @@ -1,5 +1,11 @@ import { ApolloLink, Observable } from "@apollo/client"; +declare module "@apollo/client" { + export interface DefaultContext { + delay?: number; + } +} + export const delayLink = new ApolloLink((operation, forward) => { if (operation.operationName?.includes("dynamic")) { operation.setContext({ diff --git a/integration-test/nextjs/src/shared/errorLink.tsx b/integration-test/nextjs/src/shared/errorLink.tsx new file mode 100644 index 00000000..d5b1c14c --- /dev/null +++ b/integration-test/nextjs/src/shared/errorLink.tsx @@ -0,0 +1,30 @@ +import { ApolloLink, Observable } from "@apollo/client"; +import { GraphQLError } from "graphql"; +import * as entryPoint from "@apollo/client-react-streaming"; + +declare module "@apollo/client" { + type Env = "ssr" | "browser" | "rsc"; + export interface DefaultContext { + error?: "always" | Env | `${Env},${Env}`; + } +} + +export const errorLink = new ApolloLink((operation, forward) => { + const context = operation.getContext(); + if ( + context.error === "always" || + ("built_for_ssr" in entryPoint && + context.error?.split(",").includes("ssr")) || + ("built_for_browser" in entryPoint && + context.error?.split(",").includes("browser")) || + ("built_for_rsc" in entryPoint && context.error?.split(",").includes("rsc")) + ) { + return new Observable((subscriber) => { + subscriber.next({ + data: null, + errors: [new GraphQLError("Simulated error")], + }); + }); + } + return forward(operation); +}); diff --git a/integration-test/package.json b/integration-test/package.json index f6e1d0c8..15333a07 100644 --- a/integration-test/package.json +++ b/integration-test/package.json @@ -9,7 +9,7 @@ "*" ], "scripts": { - "trigger-rebuild": "find . -regextype posix-extended -regex '.*/node_modules/@apollo/(client-react-streaming|experimental-nextjs-app-support)' -printf 'rm -r %p\n' -exec rm -r {} +; glob \"../.yarn/cache/@apollo-*exec*\" \"$HOME/.yarn/berry/cache/@apollo-*exec*\" --cmd='rm -v' ; yarn" + "trigger:rebuild": "find . -regextype posix-extended -regex '.*/node_modules/@apollo/(client-react-streaming|experimental-nextjs-app-support)' -printf 'rm -r %p\n' -exec rm -r {} +; glob \"../.yarn/cache/@apollo-*exec*\" \"$HOME/.yarn/berry/cache/@apollo-*exec*\" --cmd='rm -v' ; yarn" }, "devDependencies": { "glob": "^10.3.10" diff --git a/integration-test/yarn.lock b/integration-test/yarn.lock index 22b18208..25e0664b 100644 --- a/integration-test/yarn.lock +++ b/integration-test/yarn.lock @@ -32,13 +32,13 @@ __metadata: linkType: hard "@apollo/client-react-streaming@exec:./shared/build-client-react-streaming.cjs::locator=%40integration-test%2Froot%40workspace%3A.": - version: 0.8.0 + version: 0.9.2 resolution: "@apollo/client-react-streaming@exec:./shared/build-client-react-streaming.cjs#./shared/build-client-react-streaming.cjs::hash=48b117&locator=%40integration-test%2Froot%40workspace%3A." dependencies: superjson: "npm:^1.12.2 || ^2.0.0" ts-invariant: "npm:^0.10.3" peerDependencies: - "@apollo/client": ^3.9.0 + "@apollo/client": ^3.9.6 react: ^18 checksum: 10/8e12155ebcb9672f5b645c364d356018014df750412c61613341121ebb4d4eabb5f42cd9018cc3a81ad988f1b425548d68254ca49ede19c31d0d9e5a9a4f240a languageName: node @@ -82,12 +82,12 @@ __metadata: linkType: hard "@apollo/experimental-nextjs-app-support@exec:./shared/build-experimental-nextjs-app-support.cjs::locator=%40integration-test%2Froot%40workspace%3A.": - version: 0.8.0 + version: 0.9.2 resolution: "@apollo/experimental-nextjs-app-support@exec:./shared/build-experimental-nextjs-app-support.cjs#./shared/build-experimental-nextjs-app-support.cjs::hash=fd83cc&locator=%40integration-test%2Froot%40workspace%3A." dependencies: - "@apollo/client-react-streaming": "npm:^0.9.0" + "@apollo/client-react-streaming": "npm:0.9.2" peerDependencies: - "@apollo/client": ^3.9.0 + "@apollo/client": ^3.9.6 next: ^13.4.1 || ^14.0.0 react: ^18 checksum: 10/505b723bac0f3a7f15287ea32fab9f2e8c0cd567149abf11d750855f8a9bfc0aa26e44179ad10c32f7d162ad86318717032413ef8e1a25385185178e022588fa diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx index c2a6a258..beb596c5 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx @@ -33,6 +33,14 @@ function getQueryManager( return client["queryManager"]; } +/** + * Returns the `Trie` constructor without adding a direct dependency on `@wry/trie`. + */ +function getTrieConstructor(client: OrigApolloClient) { + return getQueryManager(client)["inFlightLinkObservables"] + .constructor as typeof import("@wry/trie").Trie; +} + type SimulatedQueryInfo = { resolve: (result: FetchResult) => void; reject: (reason: any) => void; @@ -64,6 +72,7 @@ export class ApolloClientClientBaseImpl< > extends ApolloClientBase { constructor(options: ApolloClientOptions) { super(options); + this.onQueryStarted = this.onQueryStarted.bind(this); getQueryManager(this)[wrappers] = hookWrappers; } @@ -77,7 +86,7 @@ export class ApolloClientClientBaseImpl< WatchQueryOptions >(); - private identifyUniqueQuery(options: { + protected identifyUniqueQuery(options: { query: DocumentNode; variables?: unknown; }) { @@ -95,24 +104,23 @@ export class ApolloClientClientBaseImpl< const canonicalVariables = canonicalStringify(options.variables || {}); - const cacheKey = [print(serverQuery), canonicalVariables].toString(); + const cacheKeyArr = [print(serverQuery), canonicalVariables]; + const cacheKey = JSON.stringify(cacheKeyArr); - return { query: serverQuery, cacheKey, varJson: canonicalVariables }; + return { + cacheKey, + cacheKeyArr, + }; } - onQueryStarted = ({ - options, - id, - }: Extract) => { - const { query, varJson, cacheKey } = this.identifyUniqueQuery(options); + onQueryStarted({ options, id }: Extract) { + const { cacheKey, cacheKeyArr } = this.identifyUniqueQuery(options); this.transportedQueryOptions.set(id, options); - if (!query) return; - const printedServerQuery = print(query); const queryManager = getQueryManager(this); if ( - !queryManager["inFlightLinkObservables"].peek(printedServerQuery, varJson) + !queryManager["inFlightLinkObservables"].peekArray(cacheKeyArr) ?.observable ) { let simulatedStreamingQuery: SimulatedQueryInfo, @@ -122,10 +130,7 @@ export class ApolloClientClientBaseImpl< if (queryManager["fetchCancelFns"].get(cacheKey) === fetchCancelFn) queryManager["fetchCancelFns"].delete(cacheKey); - queryManager["inFlightLinkObservables"].remove( - printedServerQuery, - varJson - ); + queryManager["inFlightLinkObservables"].removeArray(cacheKeyArr); if (this.simulatedStreamingQueries.get(id) === simulatedStreamingQuery) this.simulatedStreamingQueries.delete(id); @@ -151,9 +156,8 @@ export class ApolloClientClientBaseImpl< }); }); - queryManager["inFlightLinkObservables"].lookup( - printedServerQuery, - varJson + queryManager["inFlightLinkObservables"].lookupArray( + cacheKeyArr ).observable = observable; queryManager["fetchCancelFns"].set( @@ -167,7 +171,7 @@ export class ApolloClientClientBaseImpl< }) ); } - }; + } onQueryProgress = (event: Exclude) => { const queryInfo = this.simulatedStreamingQueries.get(event.id); @@ -199,11 +203,19 @@ export class ApolloClientClientBaseImpl< */ if (queryInfo) { this.simulatedStreamingQueries.delete(event.id); - invariant.debug( - "query failed on server, rerunning in browser:", - queryInfo.options - ); - this.rerunSimulatedQuery(queryInfo); + if (process.env.REACT_ENV === "browser") { + invariant.debug( + "Query failed on server, rerunning in browser:", + queryInfo.options + ); + this.rerunSimulatedQuery(queryInfo); + } else if (process.env.REACT_ENV === "ssr") { + invariant.debug( + "Query failed upstream, we will fail it during SSR and rerun it in the browser:", + queryInfo.options + ); + queryInfo?.reject?.(new Error("Query failed upstream.")); + } } this.transportedQueryOptions.delete(event.id); } else if (event.type === "complete") { @@ -246,6 +258,8 @@ export class ApolloClientClientBaseImpl< class ApolloClientSSRImpl< TCacheShape, > extends ApolloClientClientBaseImpl { + private forwardedQueries = new (getTrieConstructor(this))(); + watchQueryQueue = createBackpressuredCallback<{ event: Extract; observable: Observable>; @@ -255,10 +269,15 @@ class ApolloClientSSRImpl< T = any, TVariables extends OperationVariables = OperationVariables, >(options: WatchQueryOptions) { + const { cacheKeyArr } = this.identifyUniqueQuery(options); + if ( options.fetchPolicy !== "cache-only" && - options.fetchPolicy !== "standby" + options.fetchPolicy !== "standby" && + !this.forwardedQueries.peekArray(cacheKeyArr) ) { + // don't transport the same query over twice + this.forwardedQueries.lookupArray(cacheKeyArr); const observableQuery = super.watchQuery(options); const queryInfo = observableQuery["queryInfo"] as QueryInfo; const id = queryInfo.queryId as TransportIdentifier; @@ -305,6 +324,14 @@ class ApolloClientSSRImpl< } return super.watchQuery(options); } + + onQueryStarted(event: Extract) { + const { cacheKeyArr } = this.identifyUniqueQuery(event.options); + // this is a replay from another source and doesn't need to be transported + // to the browser, since it will be replayed there, too. + this.forwardedQueries.lookupArray(cacheKeyArr); + super.onQueryStarted(event); + } } export class ApolloClientBrowserImpl< diff --git a/packages/client-react-streaming/src/PreloadQuery.tsx b/packages/client-react-streaming/src/PreloadQuery.tsx index de4ddee7..6bda3e33 100644 --- a/packages/client-react-streaming/src/PreloadQuery.tsx +++ b/packages/client-react-streaming/src/PreloadQuery.tsx @@ -12,15 +12,21 @@ export function PreloadQuery({ getClient: () => ApolloClient; children: ReactNode; }) { - const resultPromise = getClient().query({ - ...options, - // TODO: create a second Client instance only for `PreloadQuery` calls - // We want to prevent "client" data from leaking into our "RSC" cache, - // as that data should always be strictly separated. - fetchPolicy: "no-cache", - }); + const resultPromise = getClient() + .query({ + ...options, + // TODO: create a second Client instance only for `PreloadQuery` calls + // We want to prevent "client" data from leaking into our "RSC" cache, + // as that data should always be strictly separated. + fetchPolicy: "no-cache", + }) + .then((result) => JSON.parse(JSON.stringify(result))); + // while they would serialize nicely over the boundary, React will + // confuse the GraphQL `Location` class with the browser `Location` and + // complain about `Location` objects not being serializable + const cleanedOptions = JSON.parse(JSON.stringify(options)); return ( - + {children} ); diff --git a/packages/client-react-streaming/src/index.cc.ts b/packages/client-react-streaming/src/index.cc.ts index d58c288f..dd4bfc49 100644 --- a/packages/client-react-streaming/src/index.cc.ts +++ b/packages/client-react-streaming/src/index.cc.ts @@ -6,6 +6,7 @@ import type { ApolloClient as WrappedApolloClient } from "./DataTransportAbstrac import type { TransportIdentifier } from "./DataTransportAbstraction/DataTransportAbstraction.js"; import type { QueryManager } from "@apollo/client/core/QueryManager.js"; import type { ReactNode } from "react"; +import invariant from "ts-invariant"; const handledRequests = new WeakMap(); @@ -23,13 +24,22 @@ export function SimulatePreloadedQuery({ const id = `preloadedQuery:${(client["queryManager"] as QueryManager).generateQueryId()}` as TransportIdentifier; handledRequests.set(options, id); + invariant.debug( + "Preloaded query %s started on the server, simulating ongoing request", + id + ); client.onQueryStarted!({ type: "started", id, options, }); + result.then( (result) => { + invariant.debug( + "Preloaded query %s finished on the server, simulating result", + id + ); client.onQueryProgress!({ type: "data", id, @@ -41,12 +51,6 @@ export function SimulatePreloadedQuery({ }); }, () => { - // TODO: - // This will restart the query in SSR **and** in the browser. - // Currently there is no way of transporting the result received in SSR to the browser. - // Layers over layers... - // Maybe instead we should just "fail" the simulated request on the SSR level - // and only have it re-attempt in the browser? client.onQueryProgress!({ type: "error", id,