From 37feeaa9aea69b90a974eb9cd0fbd636b62d841a Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 12 Mar 2024 14:54:58 +0100 Subject: [PATCH] 0.9.0 (7) - Changes to Data Transport (#223) * switch data transport to event stream * updates * adjust integration test * tooling config * remove "experimental" from entry point name of manualDataTransport * adjust import * update another import * remove comment * update more references * fixup trigger-rebuild script * more script fixup * tweaks * test error handling * Update packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx Co-authored-by: Jerel Miller --------- Co-authored-by: Jerel Miller --- .github/workflows/tests.yml | 1 - integration-test/.yarnrc.yml | 2 +- .../src/WrappedApolloProvider.tsx | 53 +++--- .../experimental-react/src/entry-client.jsx | 3 + .../experimental-react/src/entry-server.jsx | 3 + integration-test/nextjs/fixture.ts | 12 +- integration-test/nextjs/package.json | 1 + .../nextjs/src/app/cc/ApolloWrapper.tsx | 28 ++- .../nextjs/src/app/cc/dynamic/dynamic.test.ts | 55 +++++- .../useSuspenseQueryWithError/page.tsx | 56 ++++++ .../vite-streaming/src/Transport.tsx | 2 +- integration-test/yarn.lock | 54 ++---- .../client-react-streaming/package-shape.json | 2 +- packages/client-react-streaming/package.json | 18 +- .../DataTransportAbstraction.ts | 43 ++++- .../WrapApolloProvider.tsx | 10 +- .../WrappedApolloClient.test.tsx | 59 +++--- .../WrappedApolloClient.tsx | 179 +++++++++++++----- .../WrappedInMemoryCache.tsx | 44 +---- .../src/DataTransportAbstraction/index.ts | 5 +- .../ApolloRehydrateSymbols.tsx | 2 + .../ManualDataTransport.tsx | 25 +-- .../RehydrationContext.tsx | 22 +-- .../dataTransport.ts | 18 +- .../htmlescape.ts | 0 .../index.ts | 0 .../lateInitializingQueue.ts | 0 .../types.tsx | 11 +- packages/client-react-streaming/src/index.ts | 1 + .../client-react-streaming/tsup.config.ts | 10 +- .../src/ApolloNextAppProvider.ts | 2 +- .../src/ssr/index.ts | 2 +- .../tsup.config.ts | 2 +- 33 files changed, 445 insertions(+), 280 deletions(-) create mode 100644 integration-test/nextjs/src/app/cc/dynamic/useSuspenseQueryWithError/page.tsx rename packages/client-react-streaming/src/{ExperimentalManualDataTransport => ManualDataTransport}/ApolloRehydrateSymbols.tsx (69%) rename packages/client-react-streaming/src/{ExperimentalManualDataTransport => ManualDataTransport}/ManualDataTransport.tsx (84%) rename packages/client-react-streaming/src/{ExperimentalManualDataTransport => ManualDataTransport}/RehydrationContext.tsx (76%) rename packages/client-react-streaming/src/{ExperimentalManualDataTransport => ManualDataTransport}/dataTransport.ts (74%) rename packages/client-react-streaming/src/{ExperimentalManualDataTransport => ManualDataTransport}/htmlescape.ts (100%) rename packages/client-react-streaming/src/{ExperimentalManualDataTransport => ManualDataTransport}/index.ts (100%) rename packages/client-react-streaming/src/{ExperimentalManualDataTransport => ManualDataTransport}/lateInitializingQueue.ts (100%) rename packages/client-react-streaming/src/{ExperimentalManualDataTransport => ManualDataTransport}/types.tsx (80%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e0f4ab0d..72ffce5f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -95,7 +95,6 @@ jobs: - name: Install Packages (Integration Test) run: | - sed -ire '/^ checksum/d' yarn.lock yarn install env: YARN_ENABLE_IMMUTABLE_INSTALLS: "false" diff --git a/integration-test/.yarnrc.yml b/integration-test/.yarnrc.yml index 04786d5a..c726dc92 100644 --- a/integration-test/.yarnrc.yml +++ b/integration-test/.yarnrc.yml @@ -4,6 +4,6 @@ nodeLinker: node-modules npmAuthToken: "${NODE_AUTH_TOKEN-}" yarnPath: ../.yarn/releases/yarn-4.1.0.cjs cacheFolder: "../.yarn/cache" -installStatePath: "./.yarn/integration-test-install-state.gz" +installStatePath: "../.yarn/integration-test-install-state.gz" enableInlineBuilds: true checksumBehavior: ignore \ No newline at end of file diff --git a/integration-test/experimental-react/src/WrappedApolloProvider.tsx b/integration-test/experimental-react/src/WrappedApolloProvider.tsx index 9f41aaf7..710cc068 100644 --- a/integration-test/experimental-react/src/WrappedApolloProvider.tsx +++ b/integration-test/experimental-react/src/WrappedApolloProvider.tsx @@ -9,13 +9,17 @@ import { WrapApolloProvider, DataTransportContext, } from "@apollo/client-react-streaming"; -import type { DataTransportProviderImplementation } from "@apollo/client-react-streaming"; +import type { + DataTransportProviderImplementation, + QueryEvent, +} from "@apollo/client-react-streaming"; import { useMemo, useActionChannel, useStaticValue, useRef } from "react"; - -import type { Cache, WatchQueryOptions } from "@apollo/client/index.js"; +import { invariant } from "ts-invariant"; declare module "react" { - const useActionChannel: (onData: (data: T) => void) => (data: T) => void; + const useActionChannel: ( + onData: (data: T) => void + ) => (data: T | Promise) => void; /** * This api design lends itself to a memory leak - the value passed in here * can never be removed from memory. @@ -26,25 +30,28 @@ declare module "react" { } export const ExperimentalReactDataTransport: DataTransportProviderImplementation = - ({ - onRequestData, - onRequestStarted, - registerDispatchRequestStarted, - registerDispatchRequestData, - children, - }) => { - const dispatchRequestStarted = useActionChannel( - (options: WatchQueryOptions) => { - onRequestStarted?.(options); - } - ); - const dispatchRequestData = useActionChannel( - (options: Cache.WriteOptions) => { - onRequestData?.(options); - } - ); - registerDispatchRequestStarted?.(dispatchRequestStarted); - registerDispatchRequestData?.(dispatchRequestData); + ({ onQueryEvent, registerDispatchRequestStarted, children }) => { + const dispatchQueryEvent = useActionChannel((event) => { + invariant.debug("received event", event); + onQueryEvent?.(event); + }); + registerDispatchRequestStarted?.(({ event, observable }) => { + let resolve: undefined | ((event: QueryEvent) => void); + invariant.debug("sending start event", event); + dispatchQueryEvent(event); + dispatchQueryEvent(new Promise((r) => (resolve = r))); + observable.subscribe({ + next(event) { + if (event.type === "data") { + invariant.debug("sending event", event); + dispatchQueryEvent(event); + } else { + invariant.debug("resolving event promise", event); + resolve!(event); + } + }, + }); + }); return ( void; + } +} + export const test = base.extend<{ blockRequest: import("@playwright/test").Page; hydrationFinished: Promise; }>({ page: async ({ page }, use) => { - page.on("pageerror", (error) => { + function errorListener(error: Error) { expect(error.stack || error).toBe("no error"); - }); + } + page.on("pageerror", errorListener); + page.allowErrors = () => page.off("pageerror", errorListener); // this prevents the playwright http cache to kick in in test development page.route("**", (route) => route.continue()); await use(page); diff --git a/integration-test/nextjs/package.json b/integration-test/nextjs/package.json index 2af03796..d2459cf5 100644 --- a/integration-test/nextjs/package.json +++ b/integration-test/nextjs/package.json @@ -23,6 +23,7 @@ "next": "^14.1.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-error-boundary": "^4.0.13", "ssr-only-secrets": "^0.0.5", "typescript": "5.1.3" }, diff --git a/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx b/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx index b2ee68d8..28c80b3b 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 { HttpLink } from "@apollo/client"; +import { ApolloLink, HttpLink, Observable } from "@apollo/client"; import { ApolloNextAppProvider, NextSSRInMemoryCache, @@ -15,11 +15,29 @@ import { delayLink } from "@/shared/delayLink"; import { schema } from "../graphql/schema"; import { useSSROnlySecret } from "ssr-only-secrets"; +import { GraphQLError } from "graphql"; 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, @@ -43,9 +61,11 @@ export function ApolloWrapper({ return new NextSSRApolloClient({ cache: new NextSSRInMemoryCache(), - link: delayLink.concat( - typeof window === "undefined" ? new SchemaLink({ schema }) : httpLink - ), + link: delayLink + .concat(errorLink) + .concat( + typeof window === "undefined" ? new SchemaLink({ schema }) : httpLink + ), }); } } diff --git a/integration-test/nextjs/src/app/cc/dynamic/dynamic.test.ts b/integration-test/nextjs/src/app/cc/dynamic/dynamic.test.ts index f3748a60..229f327b 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/dynamic.test.ts +++ b/integration-test/nextjs/src/app/cc/dynamic/dynamic.test.ts @@ -1,6 +1,11 @@ import { expect } from "@playwright/test"; import { test } from "../../../../fixture"; +const regex_connection_closed_early = + /streaming connection closed before server query could be fully transported, rerunning/; +const regex_query_error_restart = + /query failed on server, rerunning in browser/; + test.describe("CC dynamic", () => { test.describe("useSuspenseQuery", () => { test("one query", async ({ page, blockRequest, hydrationFinished }) => { @@ -14,7 +19,42 @@ test.describe("CC dynamic", () => { await hydrationFinished; await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible(); }); + + test("error during SSR restarts query in browser", async ({ + page, + hydrationFinished, + }) => { + page.allowErrors?.(); + let allLogs: string[] = []; + page.on("console", (message) => { + allLogs.push(message.text()); + }); + + await page.goto( + "http://localhost:3000/cc/dynamic/useSuspenseQueryWithError", + { + waitUntil: "commit", + } + ); + + await expect(page).toBeInitiallyLoading(true); + + await page.waitForEvent("console", (message) => { + return regex_query_error_restart.test(message.text()); + }); + await page.waitForEvent("pageerror", (error) => { + return error.message.includes("Minified React error #419"); + }); + + await hydrationFinished; + await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible(); + + for (const log of allLogs) { + expect(log).not.toMatch(regex_connection_closed_early); + } + }); }); + test.describe("useBackgroundQuery + useReadQuery", () => { test("one query", async ({ page, blockRequest, hydrationFinished }) => { await page.goto("http://localhost:3000/cc/dynamic/useBackgroundQuery", { @@ -39,6 +79,11 @@ test.describe("CC dynamic", () => { ); await expect(page.getByText("rendered on server")).toBeVisible(); + + await page.waitForEvent("console", (message) => { + return regex_connection_closed_early.test(message.text()); + }); + await expect(page.getByText("rendered on client")).toBeVisible(); await expect(page.getByText("loading")).toBeVisible(); await expect(page.getByText("loading")).not.toBeVisible(); @@ -74,11 +119,11 @@ test.describe("CC dynamic", () => { } ); - const messagePromise = page.waitForEvent("console"); - const message = await messagePromise; - expect(message.text()).toMatch( - /^Refused to execute inline script because it violates the following Content Security Policy/ - ); + await page.waitForEvent("console", (message) => { + return /^Refused to execute inline script because it violates the following Content Security Policy/.test( + message.text() + ); + }); }); test("valid: does not log an error", async ({ page, blockRequest }) => { await page.goto( diff --git a/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQueryWithError/page.tsx b/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQueryWithError/page.tsx new file mode 100644 index 00000000..e1a5b5c0 --- /dev/null +++ b/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQueryWithError/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useSuspenseQuery } from "@apollo/experimental-nextjs-app-support/ssr"; +import type { TypedDocumentNode } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { ErrorBoundary, FallbackProps } from "react-error-boundary"; +import { Suspense, startTransition, useState, useTransition } from "react"; + +const QUERY: TypedDocumentNode<{ + products: { + id: string; + title: string; + }[]; +}> = gql` + query dynamicProducts { + products { + id + title + } + } +`; + +export const dynamic = "force-dynamic"; + +export default function Page() { + return ( + + + + + + ); +} + +function FallbackComponent({ error, resetErrorBoundary }: FallbackProps) { + return ( + <> +

{error.message}

+ + ); +} + +function Component({ errorLevel }: { errorLevel: "ssr" | "always" }) { + const { data } = useSuspenseQuery(QUERY, { + context: { error: errorLevel }, + }); + globalThis.hydrationFinished?.(); + + return ( +
    + {data.products.map(({ id, title }) => ( +
  • {title}
  • + ))} +
+ ); +} diff --git a/integration-test/vite-streaming/src/Transport.tsx b/integration-test/vite-streaming/src/Transport.tsx index f8cd4e41..68aaeebb 100644 --- a/integration-test/vite-streaming/src/Transport.tsx +++ b/integration-test/vite-streaming/src/Transport.tsx @@ -7,7 +7,7 @@ */ import { WrapApolloProvider } from "@apollo/client-react-streaming"; -import { buildManualDataTransport } from "@apollo/client-react-streaming/experimental-manual-transport"; +import { buildManualDataTransport } from "@apollo/client-react-streaming/manual-transport"; import { renderToString } from "react-dom/server"; import * as React from "react"; diff --git a/integration-test/yarn.lock b/integration-test/yarn.lock index ac45d9ac..22b18208 100644 --- a/integration-test/yarn.lock +++ b/integration-test/yarn.lock @@ -2040,6 +2040,7 @@ __metadata: next: "npm:^14.1.0" react: "npm:18.2.0" react-dom: "npm:18.2.0" + react-error-boundary: "npm:^4.0.13" ssr-only-secrets: "npm:^0.0.5" typescript: "npm:5.1.3" webpack-stats-plugin: "npm:^1.1.3" @@ -7196,6 +7197,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^4.0.13": + version: 4.0.13 + resolution: "react-error-boundary@npm:4.0.13" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + react: ">=16.13.1" + checksum: 10/28fdf498a58621e21d93978c61719c52455bc00b778080259c5830fe003736153469ebc84f243d7989567b1196e25648c7592f7c8e47de47fcd7d12b875879b9 + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -8332,47 +8344,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.0.0": - version: 5.1.5 - resolution: "vite@npm:5.1.5" - dependencies: - esbuild: "npm:^0.19.3" - fsevents: "npm:~2.3.3" - postcss: "npm:^8.4.35" - rollup: "npm:^4.2.0" - peerDependencies: - "@types/node": ^18.0.0 || >=20.0.0 - less: "*" - lightningcss: ^1.21.0 - sass: "*" - stylus: "*" - sugarss: "*" - terser: ^5.4.0 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - bin: - vite: bin/vite.js - checksum: 10/ada0a9138ca541723008ee261d80a97f6b70173508ded0f87834e2142660f45dff9801d143551aa3a8979ed446f0aec71ae114ab3ae978b3fbd5cf1f8c4bc331 - languageName: node - linkType: hard - -"vite@npm:^5.0.10": +"vite@npm:^5.0.0, vite@npm:^5.0.10": version: 5.1.4 resolution: "vite@npm:5.1.4" dependencies: diff --git a/packages/client-react-streaming/package-shape.json b/packages/client-react-streaming/package-shape.json index be250242..e0fec1fd 100644 --- a/packages/client-react-streaming/package-shape.json +++ b/packages/client-react-streaming/package-shape.json @@ -29,7 +29,7 @@ "resetApolloSingletons" ] }, - "@apollo/client-react-streaming/experimental-manual-transport": { + "@apollo/client-react-streaming/manual-transport": { "react-server": [], "browser": ["buildManualDataTransport", "resetManualSSRApolloSingletons"], "node": ["buildManualDataTransport", "resetManualSSRApolloSingletons"] diff --git a/packages/client-react-streaming/package.json b/packages/client-react-streaming/package.json index 0c2ad88e..ffdf633a 100644 --- a/packages/client-react-streaming/package.json +++ b/packages/client-react-streaming/package.json @@ -44,26 +44,26 @@ "node": "./dist/index.ssr.js" } }, - "./experimental-manual-transport": { + "./manual-transport": { "require": { - "types": "./dist/experimental-manual-transport.ssr.d.cts", + "types": "./dist/manual-transport.ssr.d.cts", "react-server": "./dist/empty.cjs", - "browser": "./dist/experimental-manual-transport.browser.cjs", - "node": "./dist/experimental-manual-transport.ssr.cjs" + "browser": "./dist/manual-transport.browser.cjs", + "node": "./dist/manual-transport.ssr.cjs" }, "import": { - "types": "./dist/experimental-manual-transport.ssr.d.ts", + "types": "./dist/manual-transport.ssr.d.ts", "react-server": "./dist/empty.js", - "browser": "./dist/experimental-manual-transport.browser.js", - "node": "./dist/experimental-manual-transport.ssr.js" + "browser": "./dist/manual-transport.browser.js", + "node": "./dist/manual-transport.ssr.js" } }, "./package.json": "./package.json" }, "typesVersions": { "*": { - "experimental-manual-transport": [ - "./dist/experimental-manual-transport.ssr.d.ts" + "manual-transport": [ + "./dist/manual-transport.ssr.d.ts" ] } }, diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts b/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts index be9f6cc0..5fa4e393 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts +++ b/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts @@ -1,5 +1,9 @@ import type React from "react"; -import type { Cache, WatchQueryOptions } from "@apollo/client/index.js"; +import type { + FetchResult, + Observable, + WatchQueryOptions, +} from "@apollo/client/index.js"; import { createContext } from "react"; interface DataTransportAbstraction { @@ -20,20 +24,41 @@ export type DataTransportProviderImplementation< > = React.FC< { /** will be present in the Browser */ - onRequestStarted?: (options: WatchQueryOptions) => void; - /** will be present in the Browser */ - onRequestData?: (options: Cache.WriteOptions) => void; + onQueryEvent?: (event: QueryEvent) => void; /** will be present in the Browser */ rerunSimulatedQueries?: () => void; /** will be present during SSR */ registerDispatchRequestStarted?: ( - callback: (options: WatchQueryOptions) => void - ) => void; - /** will be present during SSR */ - registerDispatchRequestData?: ( - callback: (options: Cache.WriteOptions) => void + callback: (query: { + event: Extract; + observable: Observable>; + }) => void ) => void; /** will always be present */ children: React.ReactNode; } & ExtraProps >; + +export type TransportIdentifier = string & { __transportIdentifier: true }; + +export type QueryEvent = + | { + type: "started"; + options: WatchQueryOptions; + id: TransportIdentifier; + } + | { + type: "data"; + id: TransportIdentifier; + result: FetchResult; + } + | { + type: "error"; + id: TransportIdentifier; + // for now we don't transport the error itself, as it might leak some sensitive information + // this is similar to how React handles errors during SSR + } + | { + type: "complete"; + id: TransportIdentifier; + }; diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx index 1de9366c..b51310fe 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx @@ -47,12 +47,12 @@ export function WrapApolloProvider( return ( + event.type === "started" + ? clientRef.current!.onQueryStarted!(event) + : clientRef.current!.onQueryProgress!(event) } + rerunSimulatedQueries={clientRef.current.rerunSimulatedQueries} registerDispatchRequestStarted={ clientRef.current.watchQueryQueue?.register } diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx index e5fa122f..ea4c3373 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx @@ -1,7 +1,6 @@ import React, { Suspense, useMemo } from "react"; import { runInConditions, testIn } from "../util/runInConditions.js"; import type { - Cache, TypedDocumentNode, WatchQueryOptions, } from "@apollo/client/index.js"; @@ -10,6 +9,7 @@ import { gql } from "@apollo/client/index.js"; import "global-jsdom/register"; import assert from "node:assert"; import { afterEach } from "node:test"; +import type { QueryEvent } from "./DataTransportAbstraction.js"; runInConditions("browser", "node"); @@ -38,13 +38,20 @@ const FIRST_REQUEST: WatchQueryOptions = { notifyOnNetworkStatusChange: false, query: QUERY_ME, }; +const EVENT_STARTED: QueryEvent = { + type: "started", + id: "1" as any, + options: FIRST_REQUEST, +}; const FIRST_RESULT = { me: "User" }; -const FIRST_WRITE: Cache.WriteOptions = { - dataId: "ROOT_QUERY", - overwrite: false, - query: QUERY_ME, - result: FIRST_RESULT, - variables: {}, +const EVENT_DATA: QueryEvent = { + type: "data", + id: "1" as any, + result: { data: FIRST_RESULT }, +}; +const EVENT_COMPLETE: QueryEvent = { + type: "complete", + id: "1" as any, }; const FIRST_HOOK_RESULT = { data: FIRST_RESULT, @@ -54,8 +61,7 @@ const FIRST_HOOK_RESULT = { await testIn("node")( "`useSuspenseQuery`: data is getting sent to the transport", async () => { - const startedRequests: unknown[] = []; - const requestData: unknown[] = []; + const events: QueryEvent[] = []; const staticData: unknown[] = []; function useStaticValueRef(current: T) { @@ -64,15 +70,13 @@ await testIn("node")( } const Provider = WrapApolloProvider( - ({ - children, - registerDispatchRequestData, - registerDispatchRequestStarted, - }) => { - registerDispatchRequestData!(requestData.push.bind(requestData)); - registerDispatchRequestStarted!( - startedRequests.push.bind(startedRequests) - ); + ({ children, registerDispatchRequestStarted }) => { + registerDispatchRequestStarted!(({ event, observable }) => { + events.push(event); + observable.subscribe({ + next: events.push.bind(events), + }); + }); return ( ); - assert.deepStrictEqual(startedRequests, [FIRST_REQUEST]); - assert.deepStrictEqual(requestData, []); + assert.deepStrictEqual(events, [EVENT_STARTED]); assert.deepStrictEqual(staticData, []); link.simulateResult({ result: { data: FIRST_RESULT } }, true); await findByText("User"); - assert.deepStrictEqual(requestData, [FIRST_WRITE]); - assert.deepStrictEqual(startedRequests, [FIRST_REQUEST]); + assert.deepStrictEqual(events, [EVENT_STARTED, EVENT_DATA, EVENT_COMPLETE]); assert.deepStrictEqual( staticData, new Array(finishedRenderCount).fill(FIRST_HOOK_RESULT) @@ -135,13 +137,11 @@ await testIn("browser")( let useStaticValueRefStub = (): { current: T } => { throw new Error("Should not be called yet!"); }; - let simulateRequestStart: (options: WatchQueryOptions) => void; - let simulateRequestData: (options: Cache.WriteOptions) => void; + let simulateQueryEvent: (event: QueryEvent) => void; const Provider = WrapApolloProvider( - ({ children, onRequestData, onRequestStarted, ..._rest }) => { - simulateRequestStart = onRequestStarted!; - simulateRequestData = onRequestData!; + ({ children, onQueryEvent, ..._rest }) => { + simulateQueryEvent = onQueryEvent!; return ( client}> ); - simulateRequestStart!(FIRST_REQUEST); + simulateQueryEvent!(EVENT_STARTED); rerender( client}> @@ -192,7 +192,8 @@ await testIn("browser")( await findByText("Fallback"); useStaticValueRefStub = () => ({ current: FIRST_HOOK_RESULT as any }); - simulateRequestData!(FIRST_WRITE); + simulateQueryEvent!(EVENT_DATA); + simulateQueryEvent!(EVENT_COMPLETE); await new Promise((resolve) => setTimeout(resolve, 1000)); diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx index e3712b52..39f31a0d 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx @@ -1,10 +1,10 @@ +/* eslint-disable prefer-rest-params */ import type { ApolloClientOptions, OperationVariables, WatchQueryOptions, FetchResult, DocumentNode, - Cache, } from "@apollo/client/index.js"; import { ApolloClient as OrigApolloClient, @@ -18,6 +18,11 @@ import { createBackpressuredCallback } from "./backpressuredCallback.js"; import { InMemoryCache } from "./WrappedInMemoryCache.js"; import { hookWrappers } from "./hooks.js"; import type { HookWrappers } from "@apollo/client/react/internal/index.js"; +import type { QueryInfo } from "@apollo/client/core/QueryInfo.js"; +import type { + QueryEvent, + TransportIdentifier, +} from "./DataTransportAbstraction.js"; function getQueryManager( client: OrigApolloClient @@ -49,7 +54,10 @@ class ApolloClientBase extends OrigApolloClient { } class ApolloClientSSRImpl extends ApolloClientBase { - watchQueryQueue = createBackpressuredCallback>(); + watchQueryQueue = createBackpressuredCallback<{ + event: Extract; + observable: Observable>; + }>(); watchQuery< T = any, @@ -59,7 +67,49 @@ class ApolloClientSSRImpl extends ApolloClientBase { options.fetchPolicy !== "cache-only" && options.fetchPolicy !== "standby" ) { - this.watchQueryQueue.push(options); + const observableQuery = super.watchQuery(options); + const queryInfo = observableQuery["queryInfo"] as QueryInfo; + const id = queryInfo.queryId as TransportIdentifier; + + const streamObservable = new Observable< + Exclude + >((subscriber) => { + const { markResult, markError, markReady } = queryInfo; + queryInfo.markResult = function (result: FetchResult) { + subscriber.next({ + type: "data", + id, + result, + }); + return markResult.apply(queryInfo, arguments as any); + }; + queryInfo.markError = function () { + subscriber.next({ + type: "error", + id, + }); + subscriber.complete(); + return markError.apply(queryInfo, arguments as any); + }; + queryInfo.markReady = function () { + subscriber.next({ + type: "complete", + id, + }); + subscriber.complete(); + return markReady.apply(queryInfo, arguments as any); + }; + }); + + this.watchQueryQueue.push({ + event: { + type: "started", + options: options as WatchQueryOptions, + id, + }, + observable: streamObservable, + }); + return observableQuery; } return super.watchQuery(options); } @@ -68,7 +118,14 @@ class ApolloClientSSRImpl extends ApolloClientBase { export class ApolloClientBrowserImpl< TCacheShape, > extends ApolloClientBase { - private simulatedStreamingQueries = new Map(); + private simulatedStreamingQueries = new Map< + TransportIdentifier, + SimulatedQueryInfo + >(); + private transportedQueryOptions = new Map< + TransportIdentifier, + WatchQueryOptions + >(); private identifyUniqueQuery(options: { query: DocumentNode; @@ -93,8 +150,12 @@ export class ApolloClientBrowserImpl< return { query: serverQuery, cacheKey, varJson: canonicalVariables }; } - protected onRequestStarted = (options: WatchQueryOptions) => { + onQueryStarted = ({ + options, + id, + }: Extract) => { const { query, varJson, cacheKey } = this.identifyUniqueQuery(options); + this.transportedQueryOptions.set(id, options); if (!query) return; const printedServerQuery = print(query); @@ -116,16 +177,13 @@ export class ApolloClientBrowserImpl< varJson ); - if ( - this.simulatedStreamingQueries.get(cacheKey) === - simulatedStreamingQuery - ) - this.simulatedStreamingQueries.delete(cacheKey); + if (this.simulatedStreamingQueries.get(id) === simulatedStreamingQuery) + this.simulatedStreamingQueries.delete(id); }; const promise = new Promise((resolve, reject) => { this.simulatedStreamingQueries.set( - cacheKey, + id, (simulatedStreamingQuery = { resolve, reject, options }) ); }); @@ -151,7 +209,7 @@ export class ApolloClientBrowserImpl< queryManager["fetchCancelFns"].set( cacheKey, (fetchCancelFn = (reason: unknown) => { - const { reject } = this.simulatedStreamingQueries.get(cacheKey) ?? {}; + const { reject } = this.simulatedStreamingQueries.get(id) ?? {}; if (reject) { reject(reason); } @@ -161,20 +219,46 @@ export class ApolloClientBrowserImpl< } }; - protected onRequestData = (data: Cache.WriteOptions) => { - const { cacheKey } = this.identifyUniqueQuery(data); - const { resolve } = this.simulatedStreamingQueries.get(cacheKey) ?? {}; + onQueryProgress = (event: Exclude) => { + const queryInfo = this.simulatedStreamingQueries.get(event.id); - if (resolve) { - resolve({ - data: data.result, + if (event.type === "data") { + queryInfo?.resolve?.({ + data: event.result.data, }); + + // 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 + const options = this.transportedQueryOptions.get(event.id); + if (options) { + this.cache.writeQuery({ + query: options.query, + data: event.result.data, + variables: options.variables, + }); + } + } else if (event.type === "error") { + /** + * At this point we're not able to correctly serialize the error over the wire + * so we do the next-best thing: restart the query in the browser as soon as it + * failed on the server. + * This matches up with what React will be doing (abort hydration and rerender) + * See https://github.com/apollographql/apollo-client-nextjs/issues/52 + */ + if (queryInfo) { + this.simulatedStreamingQueries.delete(event.id); + invariant.debug( + "query failed on server, rerunning in browser:", + queryInfo.options + ); + this.rerunSimulatedQuery(queryInfo); + } + this.transportedQueryOptions.delete(event.id); + } else if (event.type === "complete") { + this.transportedQueryOptions.delete(event.id); } - // 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); }; /** @@ -182,42 +266,35 @@ export class ApolloClientBrowserImpl< * simulated server-side queries going on. * Those queries will be cancelled and then re-run in the browser. */ - protected rerunSimulatedQueries = () => { - const queryManager = getQueryManager(this); - for (const [cacheKey, queryInfo] of this.simulatedStreamingQueries) { - this.simulatedStreamingQueries.delete(cacheKey); + rerunSimulatedQueries = () => { + for (const [id, queryInfo] of this.simulatedStreamingQueries) { + this.simulatedStreamingQueries.delete(id); invariant.debug( "streaming connection closed before server query could be fully transported, rerunning:", queryInfo.options ); - const queryId = queryManager.generateQueryId(); - queryManager - .fetchQuery(queryId, { - ...queryInfo.options, - context: { - ...queryInfo.options.context, - queryDeduplication: false, - }, - }) - .finally(() => queryManager.stopQuery(queryId)) - .then(queryInfo.resolve, queryInfo.reject); + this.rerunSimulatedQuery(queryInfo); } }; -} - -export type ApolloClient = OrigApolloClient & { - onRequestStarted?: ApolloClientBrowserImpl["onRequestStarted"]; - onRequestData?: ApolloClientBrowserImpl["onRequestData"]; - rerunSimulatedQueries?: ApolloClientBrowserImpl["rerunSimulatedQueries"]; - - watchQueryQueue: { - register?: ( - instance: ((options: Cache.WriteOptions) => void) | null - ) => void; + rerunSimulatedQuery = (queryInfo: SimulatedQueryInfo) => { + const queryManager = getQueryManager(this); + const queryId = queryManager.generateQueryId(); + queryManager + .fetchQuery(queryId, { + ...queryInfo.options, + context: { + ...queryInfo.options.context, + queryDeduplication: false, + }, + }) + .finally(() => queryManager.stopQuery(queryId)) + .then(queryInfo.resolve, queryInfo.reject); }; +} - cache: InMemoryCache; -}; +export type ApolloClient = OrigApolloClient & + Partial> & + Partial>; export const ApolloClient: { new ( diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedInMemoryCache.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedInMemoryCache.tsx index 34c00668..f11cc77e 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedInMemoryCache.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedInMemoryCache.tsx @@ -1,35 +1,11 @@ -import type { - InMemoryCacheConfig, - Cache, - Reference, -} from "@apollo/client/index.js"; import { InMemoryCache as OrigInMemoryCache } from "@apollo/client/index.js"; -import { createBackpressuredCallback } from "./backpressuredCallback.js"; - -class InMemoryCacheSSRImpl extends OrigInMemoryCache { - protected writeQueue = createBackpressuredCallback(); - - constructor(config?: InMemoryCacheConfig) { - super(config); - } - - write(options: Cache.WriteOptions): Reference | undefined { - this.writeQueue.push(options); - return super.write(options); - } -} - -export type InMemoryCache = OrigInMemoryCache & { - writeQueue?: { - register?: ( - instance: ((options: Cache.WriteOptions) => void) | null - ) => void; - }; -}; - -export const InMemoryCache: { - new (config?: InMemoryCacheConfig): InMemoryCache; -} = - /*#__PURE__*/ process.env.REACT_ENV === "ssr" - ? InMemoryCacheSSRImpl - : OrigInMemoryCache; +/** + * We just subclass `InMemoryCache` here so that `WrappedApolloClient` + * can detect if it was initialized with an `InMemoryCache` instance that + * was also exported from this package. + * Right now, we don't have extra logic here, but we might have so again + * in the future. + * So we want to enforce this import path from the start to prevent future + * subtle bugs if people update the package and don't read the patch notes. + */ +export class InMemoryCache extends OrigInMemoryCache {} diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/index.ts b/packages/client-react-streaming/src/DataTransportAbstraction/index.ts index 5dc26b3f..3c26b41e 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/index.ts +++ b/packages/client-react-streaming/src/DataTransportAbstraction/index.ts @@ -4,5 +4,8 @@ export { ApolloClient } from "./WrappedApolloClient.js"; export { resetApolloSingletons } from "./testHelpers.js"; export { DataTransportContext } from "./DataTransportAbstraction.js"; -export type { DataTransportProviderImplementation } from "./DataTransportAbstraction.js"; +export type { + DataTransportProviderImplementation, + QueryEvent, +} from "./DataTransportAbstraction.js"; export { WrapApolloProvider } from "./WrapApolloProvider.js"; diff --git a/packages/client-react-streaming/src/ExperimentalManualDataTransport/ApolloRehydrateSymbols.tsx b/packages/client-react-streaming/src/ManualDataTransport/ApolloRehydrateSymbols.tsx similarity index 69% rename from packages/client-react-streaming/src/ExperimentalManualDataTransport/ApolloRehydrateSymbols.tsx rename to packages/client-react-streaming/src/ManualDataTransport/ApolloRehydrateSymbols.tsx index 2a07192a..97108632 100644 --- a/packages/client-react-streaming/src/ExperimentalManualDataTransport/ApolloRehydrateSymbols.tsx +++ b/packages/client-react-streaming/src/ManualDataTransport/ApolloRehydrateSymbols.tsx @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore depending on the superjson version, this might not be right import type { SuperJSONResult } from "superjson"; import type { DataTransport } from "./dataTransport.js"; diff --git a/packages/client-react-streaming/src/ExperimentalManualDataTransport/ManualDataTransport.tsx b/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx similarity index 84% rename from packages/client-react-streaming/src/ExperimentalManualDataTransport/ManualDataTransport.tsx rename to packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx index 9924dab3..f2883d9b 100644 --- a/packages/client-react-streaming/src/ExperimentalManualDataTransport/ManualDataTransport.tsx +++ b/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useId, useMemo, useRef } from "react"; import type { DataTransportProviderImplementation } from "@apollo/client-react-streaming"; import { DataTransportContext } from "@apollo/client-react-streaming"; -import type { Cache, WatchQueryOptions } from "@apollo/client/index.js"; import type { RehydrationCache, RehydrationContextValue } from "./types.js"; import type { HydrationContextOptions } from "./RehydrationContext.js"; import { buildApolloRehydrationContext } from "./RehydrationContext.js"; @@ -21,7 +20,6 @@ const buildManualDataTransportSSRImpl = ({ function ManualDataTransportSSRImpl({ extraScriptProps, children, - registerDispatchRequestData, registerDispatchRequestStarted, }) { const insertHtml = useInsertHtml(); @@ -34,11 +32,13 @@ const buildManualDataTransportSSRImpl = ({ }); } - registerDispatchRequestStarted!((options: WatchQueryOptions) => { - rehydrationContext.current!.incomingBackgroundQueries.push(options); - }); - registerDispatchRequestData!((options: Cache.WriteOptions) => { - rehydrationContext.current!.incomingResults.push(options); + registerDispatchRequestStarted!(({ event, observable }) => { + rehydrationContext.current!.incomingEvents.push(event); + observable.subscribe({ + next(event) { + rehydrationContext.current!.incomingEvents.push(event); + }, + }); }); const contextValue = useMemo( @@ -63,19 +63,12 @@ const buildManualDataTransportBrowserImpl = (): DataTransportProviderImplementation => function ManualDataTransportBrowserImpl({ children, - onRequestStarted, - onRequestData, + onQueryEvent, rerunSimulatedQueries, }) { const hookRehydrationCache = useRef({}); - registerDataTransport({ - onRequestStarted: (options) => { - // we are not streaming anymore, so we should not simulate "server-side requests" - if (document.readyState === "complete") return; - onRequestStarted!(options); - }, - onRequestData: onRequestData!, + onQueryEvent: onQueryEvent!, onRehydrate(rehydrate) { Object.assign(hookRehydrationCache.current, rehydrate); }, diff --git a/packages/client-react-streaming/src/ExperimentalManualDataTransport/RehydrationContext.tsx b/packages/client-react-streaming/src/ManualDataTransport/RehydrationContext.tsx similarity index 76% rename from packages/client-react-streaming/src/ExperimentalManualDataTransport/RehydrationContext.tsx rename to packages/client-react-streaming/src/ManualDataTransport/RehydrationContext.tsx index d098b1fe..325a14cc 100644 --- a/packages/client-react-streaming/src/ExperimentalManualDataTransport/RehydrationContext.tsx +++ b/packages/client-react-streaming/src/ManualDataTransport/RehydrationContext.tsx @@ -37,28 +37,19 @@ export function buildApolloRehydrationContext({ currentlyInjected: false, transportValueData: getTransportObject(ensureInserted), transportedValues: {}, - incomingResults: getTransportArray(ensureInserted), - incomingBackgroundQueries: getTransportArray(ensureInserted), + incomingEvents: getTransportArray(ensureInserted), RehydrateOnClient() { rehydrationContext.currentlyInjected = false; if ( !Object.keys(rehydrationContext.transportValueData).length && - !Object.keys(rehydrationContext.incomingResults).length && - !Object.keys(rehydrationContext.incomingBackgroundQueries).length + !Object.keys(rehydrationContext.incomingEvents).length ) return <>; invariant.debug( "transporting data", rehydrationContext.transportValueData ); - invariant.debug( - "transporting results", - rehydrationContext.incomingResults - ); - invariant.debug( - "transporting incomingBackgroundQueries", - rehydrationContext.incomingBackgroundQueries - ); + invariant.debug("transporting events", rehydrationContext.incomingEvents); const __html = transportDataToJS({ rehydrate: Object.fromEntries( @@ -67,8 +58,7 @@ export function buildApolloRehydrationContext({ rehydrationContext.transportedValues[key] !== value ) ), - results: rehydrationContext.incomingResults, - backgroundQueries: rehydrationContext.incomingBackgroundQueries, + events: rehydrationContext.incomingEvents, }); Object.assign( rehydrationContext.transportedValues, @@ -76,9 +66,7 @@ export function buildApolloRehydrationContext({ ); rehydrationContext.transportValueData = getTransportObject(ensureInserted); - rehydrationContext.incomingResults = getTransportArray(ensureInserted); - rehydrationContext.incomingBackgroundQueries = - getTransportArray(ensureInserted); + rehydrationContext.incomingEvents = getTransportArray(ensureInserted); return (