From b619e76cbe7345a712dde96b7fa51cfdd6ee9d55 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 11 Apr 2024 12:46:25 +0200 Subject: [PATCH 01/11] remove superjson dependency, allow config of stringify/revive (#274) --------- Co-authored-by: Jerel Miller --- .github/renovate.json5 | 1 - integration-test/yarn.lock | 36 +---- package.json | 1 - packages/client-react-streaming/package.json | 2 - .../ApolloRehydrateSymbols.tsx | 5 +- .../ManualDataTransport.tsx | 129 ++++++++++-------- .../RehydrationContext.tsx | 26 ++-- .../src/ManualDataTransport/dataTransport.ts | 10 +- .../ManualDataTransport/serialization.test.ts | 41 ++++++ .../src/ManualDataTransport/serialization.ts | 21 +++ .../client-react-streaming/tsup.config.ts | 14 +- yarn.lock | 26 ---- 12 files changed, 167 insertions(+), 145 deletions(-) create mode 100644 packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts create mode 100644 packages/client-react-streaming/src/ManualDataTransport/serialization.ts diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 86045d9e..c3c60ae5 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -7,7 +7,6 @@ ignoreDeps: [ "react", "react-dom", - "superjson", "@apollo/experimental-nextjs-app-support", "@apollo/client-react-streaming", "@apollo/client", diff --git a/integration-test/yarn.lock b/integration-test/yarn.lock index 22b18208..c6f68bf5 100644 --- a/integration-test/yarn.lock +++ b/integration-test/yarn.lock @@ -32,13 +32,12 @@ __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.10.0 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 +81,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.10.0 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.10.0" peerDependencies: - "@apollo/client": ^3.9.0 + "@apollo/client": ^3.9.6 next: ^13.4.1 || ^14.0.0 react: ^18 checksum: 10/505b723bac0f3a7f15287ea32fab9f2e8c0cd567149abf11d750855f8a9bfc0aa26e44179ad10c32f7d162ad86318717032413ef8e1a25385185178e022588fa @@ -3973,15 +3972,6 @@ __metadata: languageName: node linkType: hard -"copy-anything@npm:^3.0.2": - version: 3.0.5 - resolution: "copy-anything@npm:3.0.5" - dependencies: - is-what: "npm:^4.1.8" - checksum: 10/4c41385a94a1cff6352a954f9b1c05b6bb1b70713a2d31f4c7b188ae7187ce00ddcc9c09bd58d24cd35b67fc6dd84df5954c0be86ea10700ff74e677db3cb09c - languageName: node - linkType: hard - "core-js-compat@npm:^3.31.0, core-js-compat@npm:^3.34.0": version: 3.36.0 resolution: "core-js-compat@npm:3.36.0" @@ -5405,13 +5395,6 @@ __metadata: languageName: node linkType: hard -"is-what@npm:^4.1.8": - version: 4.1.16 - resolution: "is-what@npm:4.1.16" - checksum: 10/f6400634bae77be6903365dc53817292e1c4d8db1b467515d0c842505b8388ee8e558326d5e6952cb2a9d74116eca2af0c6adb8aa7e9d5c845a130ce9328bf13 - languageName: node - linkType: hard - "isarray@npm:^2.0.5": version: 2.0.5 resolution: "isarray@npm:2.0.5" @@ -7959,15 +7942,6 @@ __metadata: languageName: node linkType: hard -"superjson@npm:^1.12.2 || ^2.0.0": - version: 2.2.1 - resolution: "superjson@npm:2.2.1" - dependencies: - copy-anything: "npm:^3.0.2" - checksum: 10/bb8743a87c97f7845e0c27af1af0731d3185b32099ebce2aee0e67ac9a6ae9a7c4b9edfca7e1fe48693a78b56d5922d1cd13ef80c2fa12b788d3fc0ca25afe47 - languageName: node - linkType: hard - "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" diff --git a/package.json b/package.json index acbd825d..31e1065c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "resolutions": { "react@18.2.0": "18.3.0-canary-60a927d04-20240113", "react-dom@18.2.0": "18.3.0-canary-60a927d04-20240113", - "superjson": "1.13.3", "@microsoft/api-documenter": "7.24.1" }, "devDependencies": { diff --git a/packages/client-react-streaming/package.json b/packages/client-react-streaming/package.json index 2b6ccb68..7193efbd 100644 --- a/packages/client-react-streaming/package.json +++ b/packages/client-react-streaming/package.json @@ -136,7 +136,6 @@ "react-error-boundary": "4.0.13", "react-server-dom-webpack": "18.3.0-canary-60a927d04-20240113", "rimraf": "5.0.5", - "superjson": "1.13.3", "ts-node": "10.9.2", "tsup": "8.0.2", "tsx": "4.7.1", @@ -148,7 +147,6 @@ "react": "^18" }, "dependencies": { - "superjson": "^1.12.2 || ^2.0.0", "ts-invariant": "^0.10.3" } } diff --git a/packages/client-react-streaming/src/ManualDataTransport/ApolloRehydrateSymbols.tsx b/packages/client-react-streaming/src/ManualDataTransport/ApolloRehydrateSymbols.tsx index 97108632..b1486440 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/ApolloRehydrateSymbols.tsx +++ b/packages/client-react-streaming/src/ManualDataTransport/ApolloRehydrateSymbols.tsx @@ -1,11 +1,8 @@ -// 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"; declare global { interface Window { - [ApolloSSRDataTransport]?: DataTransport; + [ApolloSSRDataTransport]?: DataTransport; } } export const ApolloSSRDataTransport = /*#__PURE__*/ Symbol.for( diff --git a/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx b/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx index 9c53f704..8d8aaca2 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx +++ b/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx @@ -5,18 +5,31 @@ import type { RehydrationCache, RehydrationContextValue } from "./types.js"; import type { HydrationContextOptions } from "./RehydrationContext.js"; import { buildApolloRehydrationContext } from "./RehydrationContext.js"; import { registerDataTransport } from "./dataTransport.js"; +import { revive, stringify } from "./serialization.js"; -interface BuildArgs { +export interface ManualDataTransportOptions { /** * A hook that allows for insertion into the stream. * Will only be called during SSR, doesn't need to actiually return something otherwise. */ useInsertHtml(): (callbacks: () => React.ReactNode) => void; + /** + * Prepare data for injecting into the stream by converting it into a string that can be parsed as JavaScript by the browser. + * Could e.g. be `SuperJSON.stringify` or `serialize-javascript`. + * The default implementation act like a JSON.stringify that preserves `undefined`, but not do much on top of that. + */ + stringifyForStream?: (value: any) => string; + /** + * If necessary, additional deserialization steps that need to be applied on top of executing the result of `stringifyForStream` in the browser. + * Could e.g. be `SuperJSON.deserialize`. (Not needed in the case of using `serialize-javascript`) + */ + reviveFromStream?: (value: any) => any; } const buildManualDataTransportSSRImpl = ({ useInsertHtml, -}: BuildArgs): DataTransportProviderImplementation => + stringifyForStream = stringify, +}: ManualDataTransportOptions): DataTransportProviderImplementation => function ManualDataTransportSSRImpl({ extraScriptProps, children, @@ -29,6 +42,7 @@ const buildManualDataTransportSSRImpl = ({ rehydrationContext.current = buildApolloRehydrationContext({ insertHtml, extraScriptProps, + stringify: stringifyForStream, }); } @@ -59,65 +73,64 @@ const buildManualDataTransportSSRImpl = ({ ); }; -const buildManualDataTransportBrowserImpl = - (): DataTransportProviderImplementation => - function ManualDataTransportBrowserImpl({ - children, - onQueryEvent, - rerunSimulatedQueries, - }) { - const hookRehydrationCache = useRef({}); - registerDataTransport({ - onQueryEvent: onQueryEvent!, - onRehydrate(rehydrate) { - Object.assign(hookRehydrationCache.current, rehydrate); - }, - }); +const buildManualDataTransportBrowserImpl = ({ + reviveFromStream = revive, +}: ManualDataTransportOptions): DataTransportProviderImplementation => + function ManualDataTransportBrowserImpl({ + children, + onQueryEvent, + rerunSimulatedQueries, + }) { + const hookRehydrationCache = useRef({}); + registerDataTransport({ + onQueryEvent: onQueryEvent!, + onRehydrate(rehydrate) { + Object.assign(hookRehydrationCache.current, rehydrate); + }, + revive: reviveFromStream, + }); - useEffect(() => { - if (document.readyState !== "complete") { - // happens simulatenously to `readyState` changing to `"complete"`, see - // https://html.spec.whatwg.org/multipage/parsing.html#the-end (step 9.1 and 9.5) - window.addEventListener("load", rerunSimulatedQueries!, { - once: true, - }); - return () => - window.removeEventListener("load", rerunSimulatedQueries!); - } else { - rerunSimulatedQueries!(); - } - }, [rerunSimulatedQueries]); + useEffect(() => { + if (document.readyState !== "complete") { + // happens simulatenously to `readyState` changing to `"complete"`, see + // https://html.spec.whatwg.org/multipage/parsing.html#the-end (step 9.1 and 9.5) + window.addEventListener("load", rerunSimulatedQueries!, { + once: true, + }); + return () => window.removeEventListener("load", rerunSimulatedQueries!); + } else { + rerunSimulatedQueries!(); + } + }, [rerunSimulatedQueries]); - const useStaticValueRef = useCallback(function useStaticValueRef( - v: T - ) { - const id = useId(); - const store = hookRehydrationCache.current; - const dataRef = useRef(UNINITIALIZED as T); - if (dataRef.current === UNINITIALIZED) { - if (store && id in store) { - dataRef.current = store[id] as T; - delete store[id]; - } else { - dataRef.current = v; - } + const useStaticValueRef = useCallback(function useStaticValueRef(v: T) { + const id = useId(); + const store = hookRehydrationCache.current; + const dataRef = useRef(UNINITIALIZED as T); + if (dataRef.current === UNINITIALIZED) { + if (store && id in store) { + dataRef.current = store[id] as T; + delete store[id]; + } else { + dataRef.current = v; } - return dataRef; - }, []); + } + return dataRef; + }, []); - return ( - ({ - useStaticValueRef, - }), - [useStaticValueRef] - )} - > - {children} - - ); - }; + return ( + ({ + useStaticValueRef, + }), + [useStaticValueRef] + )} + > + {children} + + ); + }; const UNINITIALIZED = {}; @@ -170,7 +183,7 @@ const UNINITIALIZED = {}; * @public */ export const buildManualDataTransport: ( - args: BuildArgs + args: ManualDataTransportOptions ) => DataTransportProviderImplementation = process.env.REACT_ENV === "ssr" ? buildManualDataTransportSSRImpl diff --git a/packages/client-react-streaming/src/ManualDataTransport/RehydrationContext.tsx b/packages/client-react-streaming/src/ManualDataTransport/RehydrationContext.tsx index 66ea81ca..bbf3a57b 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/RehydrationContext.tsx +++ b/packages/client-react-streaming/src/ManualDataTransport/RehydrationContext.tsx @@ -2,6 +2,7 @@ import React from "react"; import type { RehydrationContextValue } from "./types.js"; import { transportDataToJS } from "./dataTransport.js"; import { invariant } from "ts-invariant"; +import type { Stringify } from "./serialization.js"; /** * @public @@ -29,10 +30,12 @@ type ScriptProps = SerializableProps< >; export function buildApolloRehydrationContext({ - extraScriptProps, insertHtml, + stringify, + extraScriptProps, }: HydrationContextOptions & { insertHtml: (callbacks: () => React.ReactNode) => void; + stringify: Stringify; }): RehydrationContextValue { function ensureInserted() { if (!rehydrationContext.currentlyInjected) { @@ -59,15 +62,18 @@ export function buildApolloRehydrationContext({ ); invariant.debug("transporting events", rehydrationContext.incomingEvents); - const __html = transportDataToJS({ - rehydrate: Object.fromEntries( - Object.entries(rehydrationContext.transportValueData).filter( - ([key, value]) => - rehydrationContext.transportedValues[key] !== value - ) - ), - events: rehydrationContext.incomingEvents, - }); + const __html = transportDataToJS( + { + rehydrate: Object.fromEntries( + Object.entries(rehydrationContext.transportValueData).filter( + ([key, value]) => + rehydrationContext.transportedValues[key] !== value + ) + ), + events: rehydrationContext.incomingEvents, + }, + stringify + ); Object.assign( rehydrationContext.transportedValues, rehydrationContext.transportValueData diff --git a/packages/client-react-streaming/src/ManualDataTransport/dataTransport.ts b/packages/client-react-streaming/src/ManualDataTransport/dataTransport.ts index 4481a226..2b49a5ce 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/dataTransport.ts +++ b/packages/client-react-streaming/src/ManualDataTransport/dataTransport.ts @@ -1,10 +1,10 @@ -import SuperJSON from "superjson"; import { ApolloSSRDataTransport } from "./ApolloRehydrateSymbols.js"; import type { RehydrationCache } from "./types.js"; import { registerLateInitializingQueue } from "./lateInitializingQueue.js"; import { invariant } from "ts-invariant"; import { htmlEscapeJsonString } from "./htmlescape.js"; import type { QueryEvent } from "@apollo/client-react-streaming"; +import type { Revive, Stringify } from "./serialization.js"; export type DataTransport = Array | { push(...args: T[]): void }; @@ -16,10 +16,10 @@ type DataToTransport = { /** * Returns a string of JavaScript that can be used to transport data to the client. */ -export function transportDataToJS(data: DataToTransport) { +export function transportDataToJS(data: DataToTransport, stringify: Stringify) { const key = Symbol.keyFor(ApolloSSRDataTransport); return `(window[Symbol.for("${key}")] ??= []).push(${htmlEscapeJsonString( - SuperJSON.stringify(data) + stringify(data) )})`; } @@ -30,12 +30,14 @@ export function transportDataToJS(data: DataToTransport) { export function registerDataTransport({ onQueryEvent, onRehydrate, + revive, }: { onQueryEvent(event: QueryEvent): void; onRehydrate(rehydrate: RehydrationCache): void; + revive: Revive; }) { registerLateInitializingQueue(ApolloSSRDataTransport, (data) => { - const parsed = SuperJSON.deserialize(data); + const parsed = revive(data) as DataToTransport; invariant.debug(`received data from the server:`, parsed); onRehydrate(parsed.rehydrate); for (const result of parsed.events) { diff --git a/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts b/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts new file mode 100644 index 00000000..427f4981 --- /dev/null +++ b/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts @@ -0,0 +1,41 @@ +import test, { describe } from "node:test"; +import { revive, stringify } from "./serialization.js"; +import { outsideOf } from "../util/runInConditions.js"; +import { htmlEscapeJsonString } from "./htmlescape.js"; +import assert from "node:assert"; + +describe( + "serialization and deserialization of data", + // we do not test the bundle, so we really just need to run this test in one environment + { skip: outsideOf("node") }, + () => { + for (const [data, serialized] of [ + [{ a: 1, b: 2, c: 3 }, '{"a":1,"b":2,"c":3}'], + [ + { a: "$apollo.undefined$", b: 2, c: undefined }, + '{"a":"$apollo.undefined$","b":2,"c":undefined}', + ], + [ + { a: "a$apollo.undefined$", b: undefined, c: 3 }, + '{"a":"a$apollo.undefined$","b":undefined,"c":3}', + ], + [ + { + a: "$$apollo.undefined$", + b: 2, + c: undefined, + d: "$apollo.undefined$", + }, + '{"a":"$$apollo.undefined$","b":2,"c":undefined,"d":"$apollo.undefined$"}', + ], + [{ a: undefined, b: 2, c: 3 }, '{"a":undefined,"b":2,"c":3}'], + ] as const) { + test(JSON.stringify(data), () => { + const stringified = stringify(data); + const result = revive(eval(`(${htmlEscapeJsonString(stringified)})`)); + assert.equal(stringified, serialized); + assert.deepStrictEqual(data, result); + }); + } + } +); diff --git a/packages/client-react-streaming/src/ManualDataTransport/serialization.ts b/packages/client-react-streaming/src/ManualDataTransport/serialization.ts new file mode 100644 index 00000000..219434e4 --- /dev/null +++ b/packages/client-react-streaming/src/ManualDataTransport/serialization.ts @@ -0,0 +1,21 @@ +/** + * Stringifies a value to be injected into JavaScript "text" - preserves `undefined` values. + */ +export function stringify(value: any) { + let undefinedPlaceholder = "$apollo.undefined$"; + + const stringified = JSON.stringify(value); + while (stringified.includes(JSON.stringify(undefinedPlaceholder))) { + undefinedPlaceholder = "$" + undefinedPlaceholder; + } + return JSON.stringify(value, (_, v) => + v === undefined ? undefinedPlaceholder : v + ).replaceAll(JSON.stringify(undefinedPlaceholder), "undefined"); +} + +export function revive(value: any): any { + return value; +} + +export type Stringify = typeof stringify; +export type Revive = typeof revive; diff --git a/packages/client-react-streaming/tsup.config.ts b/packages/client-react-streaming/tsup.config.ts index 8b28a1cd..53148747 100644 --- a/packages/client-react-streaming/tsup.config.ts +++ b/packages/client-react-streaming/tsup.config.ts @@ -16,12 +16,7 @@ export default defineConfig((options) => { } : false, outDir: "dist/", - external: [ - "@apollo/client-react-streaming", - "react", - "rehackt", - "superjson", - ], + external: ["@apollo/client-react-streaming", "react", "rehackt"], noExternal: ["@apollo/client"], // will be handled by `acModuleImports` esbuildPlugins: [acModuleImports], }; @@ -45,8 +40,11 @@ export default defineConfig((options) => { }, footer(ctx) { return { - js: ctx.format === 'esm' ? `export const built_for_${env} = true;` : `exports.built_for_${env} = true;` - } + js: + ctx.format === "esm" + ? `export const built_for_${env} = true;` + : `exports.built_for_${env} = true;`, + }; }, }; } diff --git a/yarn.lock b/yarn.lock index 2c7369dd..25c79ee0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -73,7 +73,6 @@ __metadata: react-error-boundary: "npm:4.0.13" react-server-dom-webpack: "npm:18.3.0-canary-60a927d04-20240113" rimraf: "npm:5.0.5" - superjson: "npm:1.13.3" ts-invariant: "npm:^0.10.3" ts-node: "npm:10.9.2" tsup: "npm:8.0.2" @@ -7316,15 +7315,6 @@ __metadata: languageName: node linkType: hard -"copy-anything@npm:^3.0.2": - version: 3.0.3 - resolution: "copy-anything@npm:3.0.3" - dependencies: - is-what: "npm:^4.1.8" - checksum: 10/d376bfd5f657c96e9b0ef5e8709dcb52bc085c0af7b1c30c6c0337fa9ce56287bf0aaf4db13f9931b504ee5f6beb4494d9ca1b6e947a12995476ca288b3653c6 - languageName: node - linkType: hard - "copy-to-clipboard@npm:3.3.3": version: 3.3.3 resolution: "copy-to-clipboard@npm:3.3.3" @@ -10747,13 +10737,6 @@ __metadata: languageName: node linkType: hard -"is-what@npm:^4.1.8": - version: 4.1.8 - resolution: "is-what@npm:4.1.8" - checksum: 10/943eca6c1e64df487060857a4b6ac2e584b99e633a96e64c82bd49584a85d91f6f3e22d4a2d28c1243522152b261f50021db40a5ef7d98c754ebc7209c87b071 - languageName: node - linkType: hard - "is-windows@npm:^1.0.1": version: 1.0.2 resolution: "is-windows@npm:1.0.2" @@ -14628,15 +14611,6 @@ __metadata: languageName: node linkType: hard -"superjson@npm:1.13.3": - version: 1.13.3 - resolution: "superjson@npm:1.13.3" - dependencies: - copy-anything: "npm:^3.0.2" - checksum: 10/71a186c513a9821e58264c0563cd1b3cf07d3b5ba53a09cc5c1a604d8ffeacac976a6ba1b5d5b3c71b6ab5a1941dfba5a15e3f106ad3ef22fe8d5eee3e2be052 - languageName: node - linkType: hard - "supports-color@npm:^1.3.0": version: 1.3.1 resolution: "supports-color@npm:1.3.1" From d82ff92b1399da3aeaf3fcb2c78bf2d85c403c86 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 11 Apr 2024 12:53:45 +0200 Subject: [PATCH 02/11] transport minified string version of query. (#277) --- .../DataTransportAbstraction.ts | 2 +- .../WrappedApolloClient.test.tsx | 23 ++++++++----------- .../WrappedApolloClient.tsx | 22 ++++++++++++++---- .../printMinified.tsx | 7 ++++++ 4 files changed, 36 insertions(+), 18 deletions(-) create mode 100644 packages/client-react-streaming/src/DataTransportAbstraction/printMinified.tsx diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts b/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts index 7991fd2e..0dcdeeb8 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts +++ b/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts @@ -74,7 +74,7 @@ export type TransportIdentifier = string & { __transportIdentifier: true }; export type QueryEvent = | { type: "started"; - options: WatchQueryOptions; + options: { query: string } & Omit; id: TransportIdentifier; } | { diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx index 5a645889..1f6154c7 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx @@ -7,10 +7,7 @@ import type { TransportIdentifier, } from "./DataTransportAbstraction.js"; -import type { - TypedDocumentNode, - WatchQueryOptions, -} from "@apollo/client/index.js"; +import type { TypedDocumentNode } from "@apollo/client/index.js"; import { MockSubscriptionLink } from "@apollo/client/testing/core/mocking/mockSubscriptionLink.js"; import { useSuspenseQuery, @@ -18,6 +15,7 @@ import { DocumentTransform, } from "@apollo/client/index.js"; import { visit, Kind, print, isDefinitionNode } from "graphql"; +import { printMinified } from "./printMinified.js"; const { ApolloClient, @@ -45,16 +43,15 @@ describe( me } `; - const FIRST_REQUEST: WatchQueryOptions = { - fetchPolicy: "cache-first", - nextFetchPolicy: undefined, - notifyOnNetworkStatusChange: false, - query: QUERY_ME, - }; const EVENT_STARTED: QueryEvent = { type: "started", id: "1" as any, - options: FIRST_REQUEST, + options: { + fetchPolicy: "cache-first", + nextFetchPolicy: undefined, + notifyOnNetworkStatusChange: false, + query: printMinified(QUERY_ME), + }, }; const FIRST_RESULT = { me: "User" }; const EVENT_DATA: QueryEvent = { @@ -423,7 +420,7 @@ describe("document transforms are applied correctly", async () => { type: "started", id: "1" as TransportIdentifier, options: { - query: untransformedQuery, + query: printMinified(untransformedQuery), }, }); client.rerunSimulatedQueries!(); @@ -450,7 +447,7 @@ describe("document transforms are applied correctly", async () => { type: "started", id: "1" as TransportIdentifier, options: { - query: untransformedQuery, + query: printMinified(untransformedQuery), }, }); client.onQueryProgress!({ diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx index b8198892..c331259f 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx @@ -9,6 +9,7 @@ import type { import { ApolloClient as OrigApolloClient, Observable, + gql, } from "@apollo/client/index.js"; import type { QueryManager } from "@apollo/client/core/QueryManager.js"; import { print } from "@apollo/client/utilities/index.js"; @@ -24,6 +25,7 @@ import type { TransportIdentifier, } from "./DataTransportAbstraction.js"; import { bundle } from "../bundleInfo.js"; +import { printMinified } from "./printMinified.js"; function getQueryManager( client: OrigApolloClient @@ -123,7 +125,10 @@ class ApolloClientSSRImpl extends ApolloClientBase { this.watchQueryQueue.push({ event: { type: "started", - options: options as WatchQueryOptions, + options: { + ...(options as WatchQueryOptions), + query: printMinified(options.query), + }, id, }, observable: streamObservable, @@ -179,8 +184,13 @@ export class ApolloClientBrowserImpl< options, id, }: Extract) => { - const { query, varJson, cacheKey } = this.identifyUniqueQuery(options); - this.transportedQueryOptions.set(id, options); + const hydratedOptions = { + ...options, + query: gql(options.query), + }; + const { query, varJson, cacheKey } = + this.identifyUniqueQuery(hydratedOptions); + this.transportedQueryOptions.set(id, hydratedOptions); if (!query) return; const printedServerQuery = print(query); @@ -209,7 +219,11 @@ export class ApolloClientBrowserImpl< const promise = new Promise((resolve, reject) => { this.simulatedStreamingQueries.set( id, - (simulatedStreamingQuery = { resolve, reject, options }) + (simulatedStreamingQuery = { + resolve, + reject, + options: hydratedOptions, + }) ); }); diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/printMinified.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/printMinified.tsx new file mode 100644 index 00000000..bbcb9e77 --- /dev/null +++ b/packages/client-react-streaming/src/DataTransportAbstraction/printMinified.tsx @@ -0,0 +1,7 @@ +import type { DocumentNode } from "@apollo/client/index.js"; +import { print } from "@apollo/client/utilities/index.js"; +import { stripIgnoredCharacters } from "graphql"; + +export function printMinified(query: DocumentNode): string { + return stripIgnoredCharacters(print(query)); +} From 3e169acdba774161c52f8c4df0eebc41e4b86f6f Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 25 Apr 2024 17:53:28 +0200 Subject: [PATCH 03/11] add stream-utils sub-import (#294) with the `createInjectionTransformStream` and `pipeReaderToResponse` helpers --- integration-test/vite-streaming/server.js | 34 ++--- .../vite-streaming/src/Transport.tsx | 79 +----------- .../client-react-streaming/package-shape.json | 14 +++ packages/client-react-streaming/package.json | 19 +++ .../createInjectionTransformStream.tsx | 119 ++++++++++++++++++ .../src/stream-utils/index.ts | 2 + .../src/stream-utils/pipeReaderToResponse.ts | 37 ++++++ .../client-react-streaming/tsup.config.ts | 8 +- 8 files changed, 213 insertions(+), 99 deletions(-) create mode 100644 packages/client-react-streaming/src/stream-utils/createInjectionTransformStream.tsx create mode 100644 packages/client-react-streaming/src/stream-utils/index.ts create mode 100644 packages/client-react-streaming/src/stream-utils/pipeReaderToResponse.ts diff --git a/integration-test/vite-streaming/server.js b/integration-test/vite-streaming/server.js index ef7b81b7..f6b90b77 100644 --- a/integration-test/vite-streaming/server.js +++ b/integration-test/vite-streaming/server.js @@ -1,6 +1,10 @@ import express from "express"; import { renderToReadableStream } from "react-dom/server.edge"; import { readFile } from "node:fs/promises"; +import { + createInjectionTransformStream, + pipeReaderToResponse, +} from "@apollo/client-react-streaming/stream-utils"; // Constants const isProduction = process.env.NODE_ENV === "production"; @@ -51,14 +55,14 @@ app.use("*", async (req, res) => { console.error("Fatal", error); }); - const { createTransport, render } = - /** @type {import('./src/entry-server.jsx.js')}*/ ( - await (isProduction - ? import("./dist/server/entry-server.js") - : vite.ssrLoadModule("/src/entry-server.jsx")) - ); + const { render } = /** @type {import('./src/entry-server.jsx.js')}*/ ( + await (isProduction + ? import("./dist/server/entry-server.js") + : vite.ssrLoadModule("/src/entry-server.jsx")) + ); - const { injectIntoStream, transformStream } = createTransport(); + const { injectIntoStream, transformStream } = + createInjectionTransformStream(); const App = render({ isProduction, @@ -79,22 +83,6 @@ app.use("*", async (req, res) => { ); }); -async function pipeReaderToResponse(reader, res) { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - res.end(); - return; - } else { - res.write(value); - } - } - } catch (e) { - res.destroy(e); - } -} - // Start http server app.listen(port, () => { console.log(`Server started at http://localhost:${port}`); diff --git a/integration-test/vite-streaming/src/Transport.tsx b/integration-test/vite-streaming/src/Transport.tsx index 68aaeebb..64877a6d 100644 --- a/integration-test/vite-streaming/src/Transport.tsx +++ b/integration-test/vite-streaming/src/Transport.tsx @@ -1,19 +1,14 @@ -/** - * The logic for the transform stream was strongly inspired by `createHeadInsertionTransformStream` - * from https://github.com/vercel/next.js/blob/6481c92038cce43056005c07f80f2938faf29c29/packages/next/src/server/node-web-streams-helper.ts - * - * released under a MIT license (https://github.com/vercel/next.js/blob/6481c92038cce43056005c07f80f2938faf29c29/packages/next/license.md) - * by Vercel, Inc., marked Copyright (c) 2023 Vercel, Inc. - */ - import { WrapApolloProvider } from "@apollo/client-react-streaming"; import { buildManualDataTransport } from "@apollo/client-react-streaming/manual-transport"; -import { renderToString } from "react-dom/server"; import * as React from "react"; +import { setVerbosity } from "ts-invariant"; + +setVerbosity("debug"); const InjectionContext = React.createContext< (callback: () => React.ReactNode) => void >(() => {}); + export const InjectionContextProvider = InjectionContext.Provider; export const WrappedApolloProvider = WrapApolloProvider( @@ -23,69 +18,3 @@ export const WrappedApolloProvider = WrapApolloProvider( }, }) ); - -export function createTransport(): { - transformStream: TransformStream; - injectIntoStream: (callback: () => React.ReactNode) => void; -} { - let queuedInjections: Array<() => React.ReactNode> = []; - - async function renderInjectedHtml() { - const injections = [...queuedInjections]; - queuedInjections = []; - return renderToString( - <> - {injections.map((callback, i) => ( - {callback()} - ))} - - ); - } - - let headInserted = false; - let currentlyStreaming = false; - const textDecoder = new TextDecoder(); - - const transformStream = new TransformStream({ - async transform(chunk, controller) { - // While react is flushing chunks, we don't apply insertions - if (currentlyStreaming) { - controller.enqueue(chunk); - return; - } - - if (!headInserted) { - const content = textDecoder.decode(chunk, { stream: true }); - const index = content.indexOf(""); - if (index !== -1) { - const insertedHeadContent = - content.slice(0, index) + - (await renderInjectedHtml()) + - content.slice(index); - controller.enqueue(new TextEncoder().encode(insertedHeadContent)); - currentlyStreaming = true; - setImmediate(() => { - currentlyStreaming = false; - }); - headInserted = true; - } else { - controller.enqueue(chunk); - } - } else { - controller.enqueue( - new TextEncoder().encode(await renderInjectedHtml()) - ); - controller.enqueue(chunk); - currentlyStreaming = true; - setImmediate(() => { - currentlyStreaming = false; - }); - } - }, - }); - - return { - transformStream, - injectIntoStream: (callback) => queuedInjections.push(callback), - }; -} diff --git a/packages/client-react-streaming/package-shape.json b/packages/client-react-streaming/package-shape.json index 458be7b3..c4ec3d36 100644 --- a/packages/client-react-streaming/package-shape.json +++ b/packages/client-react-streaming/package-shape.json @@ -60,5 +60,19 @@ "resetManualSSRApolloSingletons", "built_for_ssr" ] + }, + "@apollo/client-react-streaming/stream-utils": { + "react-server": ["built_for_other"], + "browser": ["built_for_other"], + "node": [ + "built_for_ssr", + "createInjectionTransformStream", + "pipeReaderToResponse" + ], + "edge-light,worker,browser": [ + "built_for_ssr", + "createInjectionTransformStream", + "pipeReaderToResponse" + ] } } diff --git a/packages/client-react-streaming/package.json b/packages/client-react-streaming/package.json index 7193efbd..fbf12fc5 100644 --- a/packages/client-react-streaming/package.json +++ b/packages/client-react-streaming/package.json @@ -76,12 +76,31 @@ "node": "./dist/manual-transport.ssr.js" } }, + "./stream-utils": { + "require": { + "types": "./dist/stream-utils.node.d.cts", + "edge-light": "./dist/stream-utils.node.cjs", + "react-server": "./dist/empty.cjs", + "browser": "./dist/empty.cjs", + "node": "./dist/stream-utils.node.cjs" + }, + "import": { + "types": "./dist/stream-utils.node.d.ts", + "edge-light": "./dist/stream-utils.node.js", + "react-server": "./dist/empty.js", + "browser": "./dist/empty.js", + "node": "./dist/stream-utils.node.js" + } + }, "./package.json": "./package.json" }, "typesVersions": { "*": { "manual-transport": [ "./dist/manual-transport.ssr.d.ts" + ], + "stream-utils": [ + "./dist/stream-utils.node.d.ts" ] } }, diff --git a/packages/client-react-streaming/src/stream-utils/createInjectionTransformStream.tsx b/packages/client-react-streaming/src/stream-utils/createInjectionTransformStream.tsx new file mode 100644 index 00000000..1e90990b --- /dev/null +++ b/packages/client-react-streaming/src/stream-utils/createInjectionTransformStream.tsx @@ -0,0 +1,119 @@ +/** + * The logic for `createInjectionTransformStream` was strongly inspired by `createHeadInsertionTransformStream` + * from https://github.com/vercel/next.js/blob/6481c92038cce43056005c07f80f2938faf29c29/packages/next/src/server/node-web-streams-helper.ts + * + * released under a MIT license (https://github.com/vercel/next.js/blob/6481c92038cce43056005c07f80f2938faf29c29/packages/next/license.md) + * by Vercel, Inc., marked Copyright (c) 2023 Vercel, Inc. + */ + +import { renderToString } from "react-dom/server"; +import * as React from "react"; + +/** + * > This export is only available in streaming SSR Server environments + * + * Used to create a `TransformStream` that can be used for piping a React stream rendered by + * `renderToReadableStream` and using the callback to insert chunks of HTML between React Chunks. + */ +export function createInjectionTransformStream(): { + /** + * @example + * ```js + * const { injectIntoStream, transformStream } = createInjectionTransformStream(); + * const App = render({ assets, injectIntoStream }); + * const reactStream = await renderToReadableStream(App, { bootstrapModules })); + * await pipeReaderToResponse( + * reactStream.pipeThrough(transformStream).getReader(), + * response + * ); + * ``` + */ + transformStream: TransformStream; + /** + * `injectIntoStream` method that can be injected into your React application, to be made available to + * + * @example + * ```js title="setup" + * // create a Context for injection of `injectIntoStream` + * const InjectionContext = React.createContext< + * (callback: () => React.ReactNode) => void + * >(() => {}); + * // to be used in your application + * export const InjectionContextProvider = InjectionContext.Provider; + * // make it accessible to `WrapApolloProvider` + * export const WrappedApolloProvider = WrapApolloProvider( + * buildManualDataTransport({ + * useInsertHtml() { + * return React.useContext(InjectionContext); + * }, + * }) + * ); + * ``` + * Then in your applications SSR render, pass this function to `InjectionContextProvider`: + * ```js + * + * ``` + */ + injectIntoStream: (callback: () => React.ReactNode) => void; +} { + let queuedInjections: Array<() => React.ReactNode> = []; + + async function renderInjectedHtml() { + const injections = [...queuedInjections]; + queuedInjections = []; + return renderToString( + <> + {injections.map((callback, i) => ( + {callback()} + ))} + + ); + } + + let headInserted = false; + let currentlyStreaming = false; + const textDecoder = new TextDecoder(); + + const transformStream = new TransformStream({ + async transform(chunk, controller) { + // While react is flushing chunks, we don't apply insertions + if (currentlyStreaming) { + controller.enqueue(chunk); + return; + } + + if (!headInserted) { + const content = textDecoder.decode(chunk, { stream: true }); + const index = content.indexOf(""); + if (index !== -1) { + const insertedHeadContent = + content.slice(0, index) + + (await renderInjectedHtml()) + + content.slice(index); + controller.enqueue(new TextEncoder().encode(insertedHeadContent)); + currentlyStreaming = true; + setImmediate(() => { + currentlyStreaming = false; + }); + headInserted = true; + } else { + controller.enqueue(chunk); + } + } else { + controller.enqueue( + new TextEncoder().encode(await renderInjectedHtml()) + ); + controller.enqueue(chunk); + currentlyStreaming = true; + setImmediate(() => { + currentlyStreaming = false; + }); + } + }, + }); + + return { + transformStream, + injectIntoStream: (callback) => queuedInjections.push(callback), + }; +} diff --git a/packages/client-react-streaming/src/stream-utils/index.ts b/packages/client-react-streaming/src/stream-utils/index.ts new file mode 100644 index 00000000..a2b5dd2d --- /dev/null +++ b/packages/client-react-streaming/src/stream-utils/index.ts @@ -0,0 +1,2 @@ +export { createInjectionTransformStream } from "./createInjectionTransformStream.js"; +export { pipeReaderToResponse } from "./pipeReaderToResponse.js"; diff --git a/packages/client-react-streaming/src/stream-utils/pipeReaderToResponse.ts b/packages/client-react-streaming/src/stream-utils/pipeReaderToResponse.ts new file mode 100644 index 00000000..9a2eacfb --- /dev/null +++ b/packages/client-react-streaming/src/stream-utils/pipeReaderToResponse.ts @@ -0,0 +1,37 @@ +import type { ServerResponse } from "node:http"; +/** + /** + * > This export is only available in streaming SSR Server environments + * + * Used to pipe a `ReadableStreamDefaultReader` to a `ServerResponse`. + * + * @example + * ```js + * const { injectIntoStream, transformStream } = createInjectionTransformStream(); + * const App = render({ assets, injectIntoStream }); + * const reactStream = await renderToReadableStream(App, { bootstrapModules })); + * await pipeReaderToResponse( + * reactStream.pipeThrough(transformStream).getReader(), + * response + * ); + * ``` + */ +export async function pipeReaderToResponse( + reader: ReadableStreamDefaultReader, + res: ServerResponse +) { + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) { + res.end(); + return; + } else { + res.write(value); + } + } + } catch (e: any) { + res.destroy(e); + } +} diff --git a/packages/client-react-streaming/tsup.config.ts b/packages/client-react-streaming/tsup.config.ts index 53148747..9d6e9578 100644 --- a/packages/client-react-streaming/tsup.config.ts +++ b/packages/client-react-streaming/tsup.config.ts @@ -16,7 +16,12 @@ export default defineConfig((options) => { } : false, outDir: "dist/", - external: ["@apollo/client-react-streaming", "react", "rehackt"], + external: [ + "@apollo/client-react-streaming", + "react", + "rehackt", + "react-dom", + ], noExternal: ["@apollo/client"], // will be handled by `acModuleImports` esbuildPlugins: [acModuleImports], }; @@ -64,6 +69,7 @@ export default defineConfig((options) => { "src/ManualDataTransport/index.ts", "manual-transport.browser" ), + entry("ssr", "src/stream-utils/index.ts", "stream-utils.node"), ]; }); From cc189b814164874a170d511ed89b013933c98973 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 15 May 2024 12:05:00 +0200 Subject: [PATCH 04/11] RSC preloading mechanism (#258) * ApolloClient: make browser event-replaying logic available in SSR * RSC preloading mechanism prototype * fix build, update expected shape * remove console.log * progress * [WIP] queryRef * tests for both notations * fix up import * different resolutions * test fixups * integrate `useQueryRefHandlers` * typings * fix up test see https://github.com/apollographql/apollo-client/issues/11772 * merge fixup * more fixup * refactor queryRef handling * add test about referential assumptions * schema adjustments * bind `PreloadQuery` to `registerApolloClient` * move `getClient` into the promise chain * trigger CI * adjust shape * update AC build * `gql(print(gql` * pin types * tweaks * udpate lockfile * forbid `nextFetchPolicy` in `PreloadQuery` * fix up build, bump dep * use uuid, not useId * update urls * disable all kinds of minification * change transport to events * simulate GraphQL error, not network error * use `query` in a test * undo disabling minification * add clarifying comment * Revert "simulate GraphQL error, not network error" This reverts commit c8a2ad5ca35fc1707b5840c88e17d66758c268ff. * prevent unhandled promise rejections * debugging * Revert "undo disabling minification" This reverts commit 00585ea32ca442a54405d6e4b87ff65183ad1c47. * test? * clean up debugging things * update dependencies * more version pinning * update lockfile even more * also update `react-server-dom-webpack` * adjust react version for vite-streaming * TransportedQueryRef: inherit QueryReferenceBase * split `TransportedQueryReference` type * queryOptions as props on `PreloadQuery` * Update packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx Co-authored-by: Jerel Miller * adjust generic name and comment * change to QueryRef base type * rename command to match parent project * update dependency "@apollo/client": "^3.10.4" --------- Co-authored-by: Jerel Miller --- examples/app-dir-experiments/package.json | 6 +- examples/hack-the-supergraph-ssr/package.json | 6 +- examples/polls-demo/package.json | 6 +- .../experimental-react/package.json | 2 +- integration-test/jest/package.json | 6 +- integration-test/nextjs/package.json | 10 +- .../nextjs/src/app/cc/ApolloWrapper.tsx | 21 +- .../nextjs/src/app/cc/dynamic/dynamic.test.ts | 4 +- .../cc/dynamic/useBackgroundQuery/page.tsx | 4 +- .../app/cc/dynamic/useSuspenseQuery/page.tsx | 4 +- .../nextjs/src/app/graphql/route.ts | 8 +- .../nextjs/src/app/graphql/schema.ts | 18 +- integration-test/nextjs/src/app/rsc/client.ts | 13 +- .../dynamic/PreloadQuery/PreloadQuery.test.ts | 78 ++++++ .../queryRef-refTest/RefTestChild.tsx | 103 ++++++++ .../PreloadQuery/queryRef-refTest/page.tsx | 50 ++++ .../PreloadQuery/queryRef-refTest/styles.css | 8 + .../queryRef-useReadQuery/ClientChild.tsx | 25 ++ .../queryRef-useReadQuery/page.tsx | 27 ++ .../app/rsc/dynamic/PreloadQuery/shared.tsx | 21 ++ .../useSuspenseQuery/ClientChild.tsx | 18 ++ .../PreloadQuery/useSuspenseQuery/page.tsx | 25 ++ .../nextjs/src/app/rsc/static/query/page.tsx | 4 +- .../nextjs/src/shared/delayLink.ts | 6 + .../nextjs/src/shared/errorLink.tsx | 30 +++ integration-test/package.json | 2 +- integration-test/vite-streaming/package.json | 10 +- integration-test/vitest/package.json | 6 +- integration-test/yarn.lock | 157 +++++++----- package.json | 7 +- packages/client-react-streaming/package.json | 8 +- .../DataTransportAbstraction.ts | 11 +- .../WrapApolloProvider.tsx | 2 +- .../WrappedApolloClient.test.tsx | 20 +- .../WrappedApolloClient.tsx | 231 ++++++++++-------- .../src/DataTransportAbstraction/hooks.ts | 18 +- .../printMinified.tsx | 7 - .../transportedOptions.ts | 45 ++++ .../ManualDataTransport.tsx | 2 +- .../src/PreloadQuery.tsx | 84 +++++++ .../client-react-streaming/src/helperTypes.ts | 1 + .../client-react-streaming/src/index.cc.ts | 82 +++++++ .../src/index.shared.ts | 1 + .../src/registerApolloClient.tsx | 128 +++++++--- .../src/transportedQueryRef.ts | 126 ++++++++++ .../client-react-streaming/tsup.config.ts | 14 ++ .../package.json | 6 +- .../src/rsc/index.ts | 5 +- .../src/ssr/index.ts | 1 + yarn.lock | 122 ++++----- 50 files changed, 1227 insertions(+), 372 deletions(-) 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/queryRef-refTest/RefTestChild.tsx create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/page.tsx create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/styles.css create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-useReadQuery/ClientChild.tsx create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-useReadQuery/page.tsx create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/shared.tsx create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/useSuspenseQuery/ClientChild.tsx create mode 100644 integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/useSuspenseQuery/page.tsx create mode 100644 integration-test/nextjs/src/shared/errorLink.tsx delete mode 100644 packages/client-react-streaming/src/DataTransportAbstraction/printMinified.tsx create mode 100644 packages/client-react-streaming/src/DataTransportAbstraction/transportedOptions.ts create mode 100644 packages/client-react-streaming/src/PreloadQuery.tsx create mode 100644 packages/client-react-streaming/src/helperTypes.ts create mode 100644 packages/client-react-streaming/src/index.cc.ts create mode 100644 packages/client-react-streaming/src/transportedQueryRef.ts diff --git a/examples/app-dir-experiments/package.json b/examples/app-dir-experiments/package.json index ecfc778a..50854fba 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.9.9", + "@apollo/client": "3.10.4", "@apollo/experimental-nextjs-app-support": "workspace:^", "@apollo/server": "^4.9.5", "@as-integrations/next": "^3.0.0", @@ -24,8 +24,8 @@ "graphql": "^16.6.0", "html-differ": "^1.4.0", "next": "^14.1.0", - "react": "18.2.0", - "react-dom": "18.2.0", + "react": "18.3.0", + "react-dom": "18.3.0", "server-only": "^0.0.1", "typescript": "5.4.5" } diff --git a/examples/hack-the-supergraph-ssr/package.json b/examples/hack-the-supergraph-ssr/package.json index 6fe7bec6..693870ff 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.9.9", + "@apollo/client": "3.10.4", "@apollo/experimental-nextjs-app-support": "workspace:^", "@apollo/space-kit": "^9.11.0", "@chakra-ui/next-js": "^2.1.2", @@ -28,8 +28,8 @@ "graphql": "^16.6.0", "js-cookie": "^3.0.1", "next": "^14.1.0", - "react": "18.2.0", - "react-dom": "18.2.0", + "react": "18.3.0", + "react-dom": "18.3.0", "react-icons": "^4.8.0", "react-rating-stars-component": "^2.2.0", "typescript": "5.4.5" diff --git a/examples/polls-demo/package.json b/examples/polls-demo/package.json index 0233a060..0fb1668c 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.9.9", + "@apollo/client": "3.10.4", "@apollo/experimental-nextjs-app-support": "workspace:^", "@apollo/server": "^4.9.5", "@types/node": "20.12.11", @@ -28,8 +28,8 @@ "graphql-tag": "^2.12.6", "next": "^14.1.0", "postcss": "8.4.23", - "react": "18.2.0", - "react-dom": "18.2.0", + "react": "18.3.0", + "react-dom": "18.3.0", "tailwindcss": "3.3.2", "typescript": "5.4.5" }, diff --git a/integration-test/experimental-react/package.json b/integration-test/experimental-react/package.json index 675ce058..f0ce2077 100644 --- a/integration-test/experimental-react/package.json +++ b/integration-test/experimental-react/package.json @@ -13,7 +13,7 @@ "test": "yarn playwright test" }, "dependencies": { - "@apollo/client": "^3.9.6", + "@apollo/client": "3.10.4", "@apollo/client-react-streaming": "*", "compression": "^1.7.4", "express": "^4.18.2", diff --git a/integration-test/jest/package.json b/integration-test/jest/package.json index 00f4e76b..03243acc 100644 --- a/integration-test/jest/package.json +++ b/integration-test/jest/package.json @@ -4,13 +4,13 @@ "test": "jest" }, "dependencies": { - "@apollo/client": "^3.9.6", + "@apollo/client": "3.10.4", "@apollo/client-react-streaming": "workspace:*", "@apollo/experimental-nextjs-app-support": "workspace:*", "@graphql-tools/schema": "^10.0.3", "graphql-tag": "^2.12.6", - "react": "18.2.0", - "react-dom": "18.2.0" + "react": "18.3.0", + "react-dom": "18.3.0" }, "devDependencies": { "@babel/core": "^7.24.0", diff --git a/integration-test/nextjs/package.json b/integration-test/nextjs/package.json index d2459cf5..ab630d53 100644 --- a/integration-test/nextjs/package.json +++ b/integration-test/nextjs/package.json @@ -10,19 +10,19 @@ "test": "yarn playwright test" }, "dependencies": { - "@apollo/client": "^3.9.6", + "@apollo/client": "3.10.4", "@apollo/experimental-nextjs-app-support": "workspace:*", "@apollo/server": "^4.9.5", "@as-integrations/next": "^3.0.0", "@graphql-tools/schema": "^10.0.0", "@types/node": "20.3.1", - "@types/react": "^18.2.55", - "@types/react-dom": "18.2.6", + "@types/react": "18.3.0", + "@types/react-dom": "18.3.0", "graphql": "^16.7.1", "graphql-tag": "^2.12.6", "next": "^14.1.0", - "react": "18.2.0", - "react-dom": "18.2.0", + "react": "18.3.0", + "react-dom": "18.3.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 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/dynamic.test.ts b/integration-test/nextjs/src/app/cc/dynamic/dynamic.test.ts index 027fd7f6..21aa201c 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/dynamic.test.ts +++ b/integration-test/nextjs/src/app/cc/dynamic/dynamic.test.ts @@ -2,9 +2,9 @@ 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/; + /streaming connection closed before server query could be fully transported, rerunning/i; const regex_query_error_restart = - /query failed on server, rerunning in browser/; + /query failed on server, rerunning in browser/i; test.describe("CC dynamic", () => { test.describe("useSuspenseQuery", () => { 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..f5e8553f 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 }, + }); globalThis.hydrationFinished?.(); return ( diff --git a/integration-test/nextjs/src/app/graphql/route.ts b/integration-test/nextjs/src/app/graphql/route.ts index 743fa22b..c977ce7a 100644 --- a/integration-test/nextjs/src/app/graphql/route.ts +++ b/integration-test/nextjs/src/app/graphql/route.ts @@ -6,7 +6,13 @@ const server = new ApolloServer({ schema, }); -const handler = startServerAndCreateNextHandler(server); +const handler = startServerAndCreateNextHandler(server, { + context: async () => { + return { + from: "network", + }; + }, +}); export async function GET(request: Request) { return handler(request); diff --git a/integration-test/nextjs/src/app/graphql/schema.ts b/integration-test/nextjs/src/app/graphql/schema.ts index aef2ed67..c27d2535 100644 --- a/integration-test/nextjs/src/app/graphql/schema.ts +++ b/integration-test/nextjs/src/app/graphql/schema.ts @@ -1,5 +1,7 @@ import { makeExecutableSchema } from "@graphql-tools/schema"; import gql from "graphql-tag"; +import * as entryPoint from "@apollo/client-react-streaming"; +import type { IResolvers } from "@graphql-tools/utils"; const typeDefs = gql` type Product { @@ -7,7 +9,8 @@ const typeDefs = gql` title: String! } type Query { - products: [Product!]! + products(someArgument: String): [Product!]! + env: String! } `; @@ -39,8 +42,19 @@ const resolvers = { title: "The Apollo Socks", }, ], + env: (source, args, context) => { + return context && context.from === "network" + ? "browser" + : "built_for_ssr" in entryPoint + ? "SSR" + : "built_for_browser" in entryPoint + ? "Browser" + : "built_for_rsc" in entryPoint + ? "RSC" + : "unknown"; + }, }, -}; +} satisfies IResolvers; export const schema = makeExecutableSchema({ typeDefs, diff --git a/integration-test/nextjs/src/app/rsc/client.ts b/integration-test/nextjs/src/app/rsc/client.ts index 8aa56097..f81ca5b6 100644 --- a/integration-test/nextjs/src/app/rsc/client.ts +++ b/integration-test/nextjs/src/app/rsc/client.ts @@ -1,9 +1,10 @@ -import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import { ApolloClient, InMemoryCache } from "@apollo/client"; import { registerApolloClient } from "@apollo/experimental-nextjs-app-support/rsc"; 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"; @@ -12,15 +13,9 @@ setVerbosity("debug"); loadDevMessages(); loadErrorMessages(); -export const { getClient } = registerApolloClient(() => { +export const { getClient, PreloadQuery, query } = 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/PreloadQuery.test.ts b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/PreloadQuery.test.ts new file mode 100644 index 00000000..89e59658 --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/PreloadQuery.test.ts @@ -0,0 +1,78 @@ +import { expect } from "@playwright/test"; +import { test } from "../../../../../fixture"; + +test.describe("PreloadQuery", () => { + for (const [decription, path] of [ + ["with useSuspenseQuery", "useSuspenseQuery"], + ["with queryRef and useReadQuery", "queryRef-useReadQuery"], + ] as const) { + test.describe(decription, () => { + test("query resolves on the server", async ({ page, blockRequest }) => { + await page.goto( + `/rsc/dynamic/PreloadQuery/${path}?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(); + await expect( + page.getByText("Queried in RSC environment") + ).toBeVisible(); + }); + + test("query errors on the server, restarts in the browser", async ({ + page, + }) => { + page.allowErrors?.(); + await page.goto(`/rsc/dynamic/PreloadQuery/${path}?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(); + await expect( + page.getByText("Queried in Browser environment") + ).toBeVisible(); + }); + }); + } + test("queryRef works with useQueryRefHandlers", async ({ page }) => { + await page.goto(`/rsc/dynamic/PreloadQuery/queryRef-useReadQuery`, { + waitUntil: "commit", + }); + + await expect(page).toBeInitiallyLoading(true); + await expect(page.getByText("loading")).not.toBeVisible(); + await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible(); + await expect(page.getByText("Queried in RSC environment")).toBeVisible(); + + await page.getByRole("button", { name: "refetch" }).click(); + await expect( + page.getByText("Queried in Browser environment") + ).toBeVisible(); + }); + + test("queryRef: assumptions about referential equality", async ({ page }) => { + await page.goto(`/rsc/dynamic/PreloadQuery/queryRef-refTest`, { + waitUntil: "commit", + }); + + await page.getByRole("spinbutton").nth(11).waitFor(); + + for (let i = 0; i < 12; i++) { + await expect(page.getByRole("spinbutton").nth(i)).toHaveClass("valid"); + } + }); +}); diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/RefTestChild.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/RefTestChild.tsx new file mode 100644 index 00000000..79682f55 --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/RefTestChild.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { QueryRef, useQueryRefHandlers } from "@apollo/client"; +import { DynamicProductResult } from "../shared"; +import { useEffect, useState } from "react"; +import { + InternalQueryReference, + unwrapQueryRef, +} from "@apollo/client/react/internal"; + +import { TransportedQueryRef } from "@apollo/experimental-nextjs-app-support/ssr"; + +declare global { + interface Window { + testRefs?: { + distinctObjectReferences: Set; + uniqueQueryRefs1: Set>; + uniqueQueryRefs2: Set>; + distinctQueryRefs: Set>; + }; + } +} +export function RefTestChild({ + queryRef, + set, +}: { + queryRef: TransportedQueryRef; + set: "1" | "2"; +}) { + const [isClient, setIsClient] = useState(false); + + useQueryRefHandlers(queryRef); + + useEffect(() => { + const realQueryRef = queryRef as any as { + __transportedQueryRef: QueryRef; + }; + if (!window.testRefs) { + window.testRefs = { + distinctObjectReferences: new Set(), // expected: [transportedQueryRef1_1, transportedQueryRef1_2, transportedQueryRef2_1, transportedQueryRef2_2] + distinctQueryRefs: new Set(), // expected: [innerQueryRef1, innerQueryRef2] + uniqueQueryRefs1: new Set(), // expected: [innerQueryRef1] + uniqueQueryRefs2: new Set(), // expected: [innerQueryRef2] + }; + } + window.testRefs[`uniqueQueryRefs${set}`].add( + unwrapQueryRef(realQueryRef.__transportedQueryRef)! + ); + window.testRefs.distinctQueryRefs.add( + unwrapQueryRef(realQueryRef.__transportedQueryRef)! + ); + window.testRefs.distinctObjectReferences.add(queryRef); + setIsClient(true); + }, []); + + return isClient && window.testRefs ? ( + <> +
+ +
+
+ +
+
+ +
+ + ) : null; +} diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/page.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/page.tsx new file mode 100644 index 00000000..9315e936 --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/page.tsx @@ -0,0 +1,50 @@ +import { QUERY } from "../shared"; +import { Suspense } from "react"; +import { PreloadQuery } from "@/app/rsc/client"; +import { RefTestChild } from "./RefTestChild"; +import { ApolloWrapper } from "@/app/cc/ApolloWrapper"; + +import "./styles.css"; + +export const dynamic = "force-dynamic"; + +export default function Page() { + return ( + +
+ + {(queryRef1) => ( + <> + + + + )} + + + {(queryRef2) => ( + loading}> + + + + )} + +
+
+ ); +} diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/styles.css b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/styles.css new file mode 100644 index 00000000..1aaf8dc2 --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-refTest/styles.css @@ -0,0 +1,8 @@ +.invalid { + border: 1px solid red; +} + +form { + display: flex; + flex-direction: column; +} diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-useReadQuery/ClientChild.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-useReadQuery/ClientChild.tsx new file mode 100644 index 00000000..88dc9fa5 --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-useReadQuery/ClientChild.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useQueryRefHandlers, useReadQuery } from "@apollo/client"; +import { DynamicProductResult } from "../shared"; +import { TransportedQueryRef } from "@apollo/experimental-nextjs-app-support/ssr"; + +export function ClientChild({ + queryRef, +}: { + queryRef: TransportedQueryRef; +}) { + const { refetch } = useQueryRefHandlers(queryRef); + const { data } = useReadQuery(queryRef); + return ( + <> +
    + {data.products.map(({ id, title }: any) => ( +
  • {title}
  • + ))} +
+

Queried in {data.env} environment

+ + + ); +} diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-useReadQuery/page.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-useReadQuery/page.tsx new file mode 100644 index 00000000..ee7de02d --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/queryRef-useReadQuery/page.tsx @@ -0,0 +1,27 @@ +import { ApolloWrapper } from "@/app/cc/ApolloWrapper"; +import { ClientChild } from "./ClientChild"; +import { QUERY } from "../shared"; + +export const dynamic = "force-dynamic"; +import { PreloadQuery } from "../../../client"; +import { Suspense } from "react"; + +export default function Page({ searchParams }: { searchParams?: any }) { + return ( + + + {(queryRef) => ( + 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..2b6c1db2 --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/shared.tsx @@ -0,0 +1,21 @@ +import { TypedDocumentNode, gql } from "@apollo/client"; + +export interface DynamicProductResult { + products: { + id: string; + title: string; + }[]; + env: string; +} +export const QUERY: TypedDocumentNode< + DynamicProductResult, + { someArgument?: string } +> = gql` + query dynamicProducts($someArgument: String) { + products(someArgument: $someArgument) { + id + title + } + env + } +`; diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/useSuspenseQuery/ClientChild.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/useSuspenseQuery/ClientChild.tsx new file mode 100644 index 00000000..192a073c --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/useSuspenseQuery/ClientChild.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useSuspenseQuery } from "@apollo/client"; +import { QUERY } from "../shared"; + +export function ClientChild() { + const { data } = useSuspenseQuery(QUERY); + return ( + <> +
    + {data.products.map(({ id, title }: any) => ( +
  • {title}
  • + ))} +
+

Queried in {data.env} environment

+ + ); +} diff --git a/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/useSuspenseQuery/page.tsx b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/useSuspenseQuery/page.tsx new file mode 100644 index 00000000..ea2e632d --- /dev/null +++ b/integration-test/nextjs/src/app/rsc/dynamic/PreloadQuery/useSuspenseQuery/page.tsx @@ -0,0 +1,25 @@ +import { ApolloWrapper } from "@/app/cc/ApolloWrapper"; +import { ClientChild } from "./ClientChild"; +import { QUERY } from "../shared"; + +export const dynamic = "force-dynamic"; +import { PreloadQuery } from "../../../client"; +import { Suspense } from "react"; + +export default function Page({ searchParams }: { searchParams?: any }) { + return ( + + + loading}> + + + + + ); +} diff --git a/integration-test/nextjs/src/app/rsc/static/query/page.tsx b/integration-test/nextjs/src/app/rsc/static/query/page.tsx index 2d292a23..947eff66 100644 --- a/integration-test/nextjs/src/app/rsc/static/query/page.tsx +++ b/integration-test/nextjs/src/app/rsc/static/query/page.tsx @@ -1,6 +1,6 @@ import type { TypedDocumentNode } from "@apollo/client"; import { gql } from "@apollo/client"; -import { getClient } from "../../client"; +import { query } from "../../client"; const QUERY: TypedDocumentNode<{ products: { @@ -19,7 +19,7 @@ const QUERY: TypedDocumentNode<{ export const dynamic = "force-static"; export default async function Home() { - const { data } = await getClient().query({ query: QUERY }); + const { data } = await query({ query: QUERY }); return (
    {data.products.map(({ 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 b2a9af7a..c01c32e4 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" + "build:libs": "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/vite-streaming/package.json b/integration-test/vite-streaming/package.json index 0bfa3078..85ce2bcb 100644 --- a/integration-test/vite-streaming/package.json +++ b/integration-test/vite-streaming/package.json @@ -13,21 +13,21 @@ "test": "yarn playwright test" }, "dependencies": { - "@apollo/client": "^3.9.6", + "@apollo/client": "3.10.4", "@apollo/client-react-streaming": "*", "compression": "^1.7.4", "express": "^4.18.2", "graphql": "^16.8.1", "graphql-tag": "^2.12.6", - "react": "18.3.0-canary-60a927d04-20240113", - "react-dom": "18.3.0-canary-60a927d04-20240113", + "react": "19.0.0-beta-94eed63c49-20240425", + "react-dom": "19.0.0-beta-94eed63c49-20240425", "sirv": "^2.0.4" }, "devDependencies": { "@playwright/test": "^1.39.0", "@tsconfig/vite-react": "^3.0.0", - "@types/react": "^18.2.55", - "@types/react-dom": "^18.2.18", + "@types/react": "npm:types-react@19.0.0-alpha.3", + "@types/react-dom": "npm:types-react-dom@19.0.0-alpha.3", "@vitejs/plugin-react": "^4.2.1", "cross-env": "^7.0.3", "prettier": "^3.2.5", diff --git a/integration-test/vitest/package.json b/integration-test/vitest/package.json index 96aa2ba6..72f7f3cd 100644 --- a/integration-test/vitest/package.json +++ b/integration-test/vitest/package.json @@ -4,12 +4,12 @@ "test": "vitest" }, "dependencies": { - "@apollo/client": "^3.9.6", + "@apollo/client": "3.10.4", "@apollo/experimental-nextjs-app-support": "*", "@graphql-tools/schema": "^10.0.3", "graphql-tag": "^2.12.6", - "react": "18.2.0", - "react-dom": "18.2.0" + "react": "18.3.0", + "react-dom": "18.3.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.2", diff --git a/integration-test/yarn.lock b/integration-test/yarn.lock index e8537a5a..5dd3b772 100644 --- a/integration-test/yarn.lock +++ b/integration-test/yarn.lock @@ -37,15 +37,15 @@ __metadata: dependencies: ts-invariant: "npm:^0.10.3" peerDependencies: - "@apollo/client": ^3.9.6 + "@apollo/client": ^3.10.4 react: ^18 checksum: 10/8e12155ebcb9672f5b645c364d356018014df750412c61613341121ebb4d4eabb5f42cd9018cc3a81ad988f1b425548d68254ca49ede19c31d0d9e5a9a4f240a languageName: node linkType: hard -"@apollo/client@npm:^3.9.6": - version: 3.9.6 - resolution: "@apollo/client@npm:3.9.6" +"@apollo/client@npm:3.10.4": + version: 3.10.4 + resolution: "@apollo/client@npm:3.10.4" dependencies: "@graphql-typed-document-node/core": "npm:^3.1.1" "@wry/caches": "npm:^1.0.0" @@ -55,7 +55,7 @@ __metadata: hoist-non-react-statics: "npm:^3.3.2" optimism: "npm:^0.18.0" prop-types: "npm:^15.7.2" - rehackt: "npm:0.0.6" + rehackt: "npm:^0.1.0" response-iterator: "npm:^0.2.6" symbol-observable: "npm:^4.0.0" ts-invariant: "npm:^0.10.3" @@ -76,7 +76,7 @@ __metadata: optional: true subscriptions-transport-ws: optional: true - checksum: 10/8455e8fe6a2a757bff9c814aec0c152543a920587a4a4278acf44c86f373d4923e4f48912aa88582504d8a56cd6e305656d8f290d8d1357834ec83e63c0b9592 + checksum: 10/8db77625bb96f3330187a6b45c9792edf338c42d4e48ed66f6b0ce38c7cea503db9a5de27f9987b7d83306201a57f90e8ef7ebc06c8a6899aaadb8a090b175cb languageName: node linkType: hard @@ -86,7 +86,7 @@ __metadata: dependencies: "@apollo/client-react-streaming": "npm:0.10.1" peerDependencies: - "@apollo/client": ^3.9.6 + "@apollo/client": ^3.10.4 next: ^13.4.1 || ^14.0.0 react: ^18 checksum: 10/505b723bac0f3a7f15287ea32fab9f2e8c0cd567149abf11d750855f8a9bfc0aa26e44179ad10c32f7d162ad86318717032413ef8e1a25385185178e022588fa @@ -1978,7 +1978,7 @@ __metadata: version: 0.0.0-use.local resolution: "@integration-test/experimental-react@workspace:experimental-react" dependencies: - "@apollo/client": "npm:^3.9.6" + "@apollo/client": "npm:3.10.4" "@apollo/client-react-streaming": "npm:*" "@playwright/test": "npm:^1.39.0" "@tsconfig/vite-react": "npm:^3.0.0" @@ -2002,7 +2002,7 @@ __metadata: version: 0.0.0-use.local resolution: "@integration-test/jest@workspace:jest" dependencies: - "@apollo/client": "npm:^3.9.6" + "@apollo/client": "npm:3.10.4" "@apollo/client-react-streaming": "workspace:*" "@apollo/experimental-nextjs-app-support": "workspace:*" "@babel/core": "npm:^7.24.0" @@ -2016,8 +2016,8 @@ __metadata: graphql-tag: "npm:^2.12.6" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0" - react: "npm:18.2.0" - react-dom: "npm:18.2.0" + react: "npm:18.3.0" + react-dom: "npm:18.3.0" languageName: unknown linkType: soft @@ -2025,20 +2025,20 @@ __metadata: version: 0.0.0-use.local resolution: "@integration-test/nextjs@workspace:nextjs" dependencies: - "@apollo/client": "npm:^3.9.6" + "@apollo/client": "npm:3.10.4" "@apollo/experimental-nextjs-app-support": "workspace:*" "@apollo/server": "npm:^4.9.5" "@as-integrations/next": "npm:^3.0.0" "@graphql-tools/schema": "npm:^10.0.0" "@playwright/test": "npm:^1.39.0" "@types/node": "npm:20.3.1" - "@types/react": "npm:^18.2.55" - "@types/react-dom": "npm:18.2.6" + "@types/react": "npm:18.3.0" + "@types/react-dom": "npm:18.3.0" graphql: "npm:^16.7.1" graphql-tag: "npm:^2.12.6" next: "npm:^14.1.0" - react: "npm:18.2.0" - react-dom: "npm:18.2.0" + react: "npm:18.3.0" + react-dom: "npm:18.3.0" react-error-boundary: "npm:^4.0.13" ssr-only-secrets: "npm:^0.0.5" typescript: "npm:5.1.3" @@ -2064,12 +2064,12 @@ __metadata: version: 0.0.0-use.local resolution: "@integration-test/vite-streaming@workspace:vite-streaming" dependencies: - "@apollo/client": "npm:^3.9.6" + "@apollo/client": "npm:3.10.4" "@apollo/client-react-streaming": "npm:*" "@playwright/test": "npm:^1.39.0" "@tsconfig/vite-react": "npm:^3.0.0" - "@types/react": "npm:^18.2.55" - "@types/react-dom": "npm:^18.2.18" + "@types/react": "npm:types-react@19.0.0-alpha.3" + "@types/react-dom": "npm:types-react-dom@19.0.0-alpha.3" "@vitejs/plugin-react": "npm:^4.2.1" compression: "npm:^1.7.4" cross-env: "npm:^7.0.3" @@ -2077,8 +2077,8 @@ __metadata: graphql: "npm:^16.8.1" graphql-tag: "npm:^2.12.6" prettier: "npm:^3.2.5" - react: "npm:18.3.0-canary-60a927d04-20240113" - react-dom: "npm:18.3.0-canary-60a927d04-20240113" + react: "npm:19.0.0-beta-94eed63c49-20240425" + react-dom: "npm:19.0.0-beta-94eed63c49-20240425" sirv: "npm:^2.0.4" vite: "npm:^5.0.10" languageName: unknown @@ -2088,15 +2088,15 @@ __metadata: version: 0.0.0-use.local resolution: "@integration-test/vitest@workspace:vitest" dependencies: - "@apollo/client": "npm:^3.9.6" + "@apollo/client": "npm:3.10.4" "@apollo/experimental-nextjs-app-support": "npm:*" "@graphql-tools/schema": "npm:^10.0.3" "@testing-library/jest-dom": "npm:^6.4.2" "@testing-library/react": "npm:^14.2.1" "@vitejs/plugin-react": "npm:^4.2.1" graphql-tag: "npm:^2.12.6" - react: "npm:18.2.0" - react-dom: "npm:18.2.0" + react: "npm:18.3.0" + react-dom: "npm:18.3.0" vitest: "npm:^1.3.1" languageName: unknown linkType: soft @@ -3033,16 +3033,16 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:18.2.6": - version: 18.2.6 - resolution: "@types/react-dom@npm:18.2.6" +"@types/react-dom@npm:18.3.0, @types/react-dom@npm:^18.2.18": + version: 18.3.0 + resolution: "@types/react-dom@npm:18.3.0" dependencies: "@types/react": "npm:*" - checksum: 10/dbdf98d127db58130f3e4394f23a6b4b200703049579e97a4e6049c0f47f871c7731cec37e2067afb62cc7c5486bd1b151fc0ef672b9ea7369c40d811592b948 + checksum: 10/6ff53f5a7b7fba952a68e114d3b542ebdc1e87a794234785ebab0bcd9bde7fb4885f21ebaf93d26dc0a1b5b93287f42cad68b78ae04dddf6b20da7aceff0beaf languageName: node linkType: hard -"@types/react-dom@npm:^18.0.0, @types/react-dom@npm:^18.2.18": +"@types/react-dom@npm:^18.0.0": version: 18.2.19 resolution: "@types/react-dom@npm:18.2.19" dependencies: @@ -3051,7 +3051,16 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:^18.2.55": +"@types/react-dom@npm:types-react-dom@19.0.0-alpha.3": + version: 19.0.0-alpha.3 + resolution: "types-react-dom@npm:19.0.0-alpha.3" + dependencies: + "@types/react": "npm:*" + checksum: 10/21f43e3cbdcf59bc2e6ac71e52161455039ccffab3e61cc0ccbb61fdce9a05d70abfce91ce490a6e63a8aae5f1974acc102dcc40c00b3876e2a0e00158a8533d + languageName: node + linkType: hard + +"@types/react@npm:*": version: 18.2.61 resolution: "@types/react@npm:18.2.61" dependencies: @@ -3062,6 +3071,25 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:18.3.0, @types/react@npm:^18.2.55": + version: 18.3.0 + resolution: "@types/react@npm:18.3.0" + dependencies: + "@types/prop-types": "npm:*" + csstype: "npm:^3.0.2" + checksum: 10/2444294740016d61721df9adeb8635658c660c13b0782df9f067260ac6b0a4b71e68245089814ab53264843eb75f81d90f770253b94a13955cc1ddccf3593301 + languageName: node + linkType: hard + +"@types/react@npm:types-react@19.0.0-alpha.3": + version: 19.0.0-alpha.3 + resolution: "types-react@npm:19.0.0-alpha.3" + dependencies: + csstype: "npm:^3.0.2" + checksum: 10/637a0e905df8a2fc8f6a760e29d8a263dca72459e16e6a5c4bb1c950a3900ece1aaef8f6ca716e0c94a70c3172d8ed2e14e75cc72bedcd8aa6e10f7c7fb3552d + languageName: node + linkType: hard + "@types/scheduler@npm:*": version: 0.16.8 resolution: "@types/scheduler@npm:0.16.8" @@ -7144,27 +7172,26 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:18.2.0": - version: 18.2.0 - resolution: "react-dom@npm:18.2.0" +"react-dom@npm:18.3.0": + version: 18.3.0 + resolution: "react-dom@npm:18.3.0" dependencies: loose-envify: "npm:^1.1.0" - scheduler: "npm:^0.23.0" + scheduler: "npm:^0.23.1" peerDependencies: - react: ^18.2.0 - checksum: 10/ca5e7762ec8c17a472a3605b6f111895c9f87ac7d43a610ab7024f68cd833d08eda0625ce02ec7178cc1f3c957cf0b9273cdc17aa2cd02da87544331c43b1d21 + react: ^18.3.0 + checksum: 10/08d6a351fb4032c1b8cd6fc159b4d2d9f1e7ec967f3365f2585d808b381c9e83466dab9b15b72c2162c1a578776044fde641279a55af016473719be66c415408 languageName: node linkType: hard -"react-dom@npm:18.3.0-canary-60a927d04-20240113": - version: 18.3.0-canary-60a927d04-20240113 - resolution: "react-dom@npm:18.3.0-canary-60a927d04-20240113" +"react-dom@npm:19.0.0-beta-94eed63c49-20240425": + version: 19.0.0-beta-94eed63c49-20240425 + resolution: "react-dom@npm:19.0.0-beta-94eed63c49-20240425" dependencies: - loose-envify: "npm:^1.1.0" - scheduler: "npm:0.24.0-canary-60a927d04-20240113" + scheduler: "npm:0.25.0-beta-94eed63c49-20240425" peerDependencies: - react: 18.3.0-canary-60a927d04-20240113 - checksum: 10/5a339f511e32d2690021458913d40fcf3671d4fe708026851b811420d958615a298d80352bb78ed4c635c3125ce491d30591213cb1086888bac8c426a2e98e65 + react: 19.0.0-beta-94eed63c49-20240425 + checksum: 10/fe3eb846e83d0295b9efde71eb507ca3607c983bf4d13c9e21552abd3f20064a1fb7f14e7e9cf2cc4b34f7c4f0939d3d1d5d7cc0d21db36c861fe87538e318ce languageName: node linkType: hard @@ -7219,21 +7246,19 @@ __metadata: languageName: node linkType: hard -"react@npm:18.2.0": - version: 18.2.0 - resolution: "react@npm:18.2.0" +"react@npm:18.3.0": + version: 18.3.0 + resolution: "react@npm:18.3.0" dependencies: loose-envify: "npm:^1.1.0" - checksum: 10/b9214a9bd79e99d08de55f8bef2b7fc8c39630be97c4e29d7be173d14a9a10670b5325e94485f74cd8bff4966ef3c78ee53c79a7b0b9b70cba20aa8973acc694 + checksum: 10/17112377dad4e5c868b608be57c8e970a3f6ee6eb6269a5c4c23b11b8b4f393c42a8a046be0c9ddbc5f182b909330ddbecab905bcdea5051b05ec3914491fd79 languageName: node linkType: hard -"react@npm:18.3.0-canary-60a927d04-20240113": - version: 18.3.0-canary-60a927d04-20240113 - resolution: "react@npm:18.3.0-canary-60a927d04-20240113" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/7da693ec8a4e4c33941f55c2cc042a4a1ea9360ab7b7fd227b7cddcc8949cac0058e585e557ea1a447dbb74e7865bcebb996ee975680e2b98c6d98e941847cb1 +"react@npm:19.0.0-beta-94eed63c49-20240425": + version: 19.0.0-beta-94eed63c49-20240425 + resolution: "react@npm:19.0.0-beta-94eed63c49-20240425" + checksum: 10/fd6f6ef0340f9ec06f497e23eba019edccd724df280c88146ad11dd240c2ae4ee0762e6ecdf68c40a0ffa75be4f5d2e24d6222a0b544f76ac4ab1499b35fdbbd languageName: node linkType: hard @@ -7325,9 +7350,9 @@ __metadata: languageName: node linkType: hard -"rehackt@npm:0.0.6": - version: 0.0.6 - resolution: "rehackt@npm:0.0.6" +"rehackt@npm:^0.1.0": + version: 0.1.0 + resolution: "rehackt@npm:0.1.0" peerDependencies: "@types/react": "*" react: "*" @@ -7336,7 +7361,7 @@ __metadata: optional: true react: optional: true - checksum: 10/3897c93270836159406529e0fa983bf4a11c07d2efc5c8f6bdfd7f6821d3b84a30d911c3f3b9c689948739e6955c5835c8dd9d91579150bec5092f356c0d91df + checksum: 10/c81adead82c165dffc574cbf9e1de3605522782a56b48df48b68d53d45c4d8c9253df3790109335bf97072424e54ad2423bb9544ca3a985fa91995dda43452fc languageName: node linkType: hard @@ -7508,21 +7533,19 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:0.24.0-canary-60a927d04-20240113": - version: 0.24.0-canary-60a927d04-20240113 - resolution: "scheduler@npm:0.24.0-canary-60a927d04-20240113" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/0289b2a5d20e4885c268a90765ebe7192eafb5b3a065888092c1684e25438932e06efd199a852b47dc8b57cc8741c609552e9f13de66e4492400e8dd4201b809 +"scheduler@npm:0.25.0-beta-94eed63c49-20240425": + version: 0.25.0-beta-94eed63c49-20240425 + resolution: "scheduler@npm:0.25.0-beta-94eed63c49-20240425" + checksum: 10/02a8c46af07e5cb5d80441e7a5a39d6ff29e799cb31f1cad0d396d8f714f945e6cc4df8277e68493ee2785c6e5963d2ab8c74f3bf60c646044759805d52b18e0 languageName: node linkType: hard -"scheduler@npm:^0.23.0": - version: 0.23.0 - resolution: "scheduler@npm:0.23.0" +"scheduler@npm:^0.23.0, scheduler@npm:^0.23.1": + version: 0.23.1 + resolution: "scheduler@npm:0.23.1" dependencies: loose-envify: "npm:^1.1.0" - checksum: 10/0c4557aa37bafca44ff21dc0ea7c92e2dbcb298bc62eae92b29a39b029134f02fb23917d6ebc8b1fa536b4184934314c20d8864d156a9f6357f3398aaf7bfda8 + checksum: 10/6e194e726210c2d619cbf69a73fdb068cb0c9b0c99222de429ec5fc562c2f28e59a8cb3526e9104e16521e7e57c785a82bc44dd3f78fd0de86aea719a218f3c3 languageName: node linkType: hard diff --git a/package.json b/package.json index 0a1c69a2..0867efd6 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,11 @@ "build:docmodel": "yarn workspaces foreach --all --include \"@apollo/*\" exec api-extractor run" }, "resolutions": { - "react@18.2.0": "18.3.0-canary-60a927d04-20240113", - "react-dom@18.2.0": "18.3.0-canary-60a927d04-20240113", + "react": "19.0.0-beta-94eed63c49-20240425", + "react-dom": "19.0.0-beta-94eed63c49-20240425", + "react-server-dom-webpack": "19.0.0-beta-94eed63c49-20240425", + "@types/react": "npm:types-react@19.0.0-alpha.3", + "@types/react-dom": "npm:types-react-dom@19.0.0-alpha.3", "@microsoft/api-documenter": "7.24.1" }, "devDependencies": { diff --git a/packages/client-react-streaming/package.json b/packages/client-react-streaming/package.json index 83320987..df318691 100644 --- a/packages/client-react-streaming/package.json +++ b/packages/client-react-streaming/package.json @@ -131,7 +131,7 @@ "lint": "eslint --ext .ts,.tsx src" }, "devDependencies": { - "@apollo/client": "3.9.9", + "@apollo/client": "^3.10.4", "@arethetypeswrong/cli": "0.15.3", "@microsoft/api-extractor": "7.43.2", "@testing-library/react": "15.0.7", @@ -151,9 +151,9 @@ "graphql": "16.8.1", "jsdom": "24.0.0", "publint": "0.2.7", - "react": "18.3.0-canary-60a927d04-20240113", + "react": "18.3.0", "react-error-boundary": "4.0.13", - "react-server-dom-webpack": "18.3.0-canary-60a927d04-20240113", + "react-server-dom-webpack": "0.0.1", "rimraf": "5.0.5", "ts-node": "10.9.2", "tsup": "8.0.2", @@ -162,7 +162,7 @@ "vitest": "1.6.0" }, "peerDependencies": { - "@apollo/client": "^3.9.6", + "@apollo/client": "^3.10.4", "react": "^18" }, "dependencies": { diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts b/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts index 0dcdeeb8..5a7c66f0 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts +++ b/packages/client-react-streaming/src/DataTransportAbstraction/DataTransportAbstraction.ts @@ -1,10 +1,7 @@ import type React from "react"; -import type { - FetchResult, - Observable, - WatchQueryOptions, -} from "@apollo/client/index.js"; +import type { FetchResult, Observable } from "@apollo/client/index.js"; import { createContext } from "react"; +import type { TransportedOptions } from "./transportedOptions.js"; interface DataTransportAbstraction { /** @@ -74,7 +71,7 @@ export type TransportIdentifier = string & { __transportIdentifier: true }; export type QueryEvent = | { type: "started"; - options: { query: string } & Omit; + options: TransportedOptions; id: TransportIdentifier; } | { @@ -92,3 +89,5 @@ export type QueryEvent = type: "complete"; id: TransportIdentifier; }; + +export type ProgressEvent = Exclude; diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx index 2b01d11e..a48a052d 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx @@ -58,7 +58,7 @@ export function WrapApolloProvider( children, ...extraProps }) => { - const clientRef = useRef>(); + const clientRef = useRef>(undefined); if (process.env.REACT_ENV === "ssr") { if (!clientRef.current) { diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx index 1f6154c7..d1a8fd65 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx @@ -15,7 +15,7 @@ import { DocumentTransform, } from "@apollo/client/index.js"; import { visit, Kind, print, isDefinitionNode } from "graphql"; -import { printMinified } from "./printMinified.js"; +import { serializeOptions } from "./transportedOptions.js"; const { ApolloClient, @@ -46,12 +46,12 @@ describe( const EVENT_STARTED: QueryEvent = { type: "started", id: "1" as any, - options: { + options: serializeOptions({ fetchPolicy: "cache-first", nextFetchPolicy: undefined, notifyOnNetworkStatusChange: false, - query: printMinified(QUERY_ME), - }, + query: QUERY_ME, + }), }; const FIRST_RESULT = { me: "User" }; const EVENT_DATA: QueryEvent = { @@ -419,9 +419,9 @@ describe("document transforms are applied correctly", async () => { client.onQueryStarted!({ type: "started", id: "1" as TransportIdentifier, - options: { - query: printMinified(untransformedQuery), - }, + options: serializeOptions({ + query: untransformedQuery, + }), }); client.rerunSimulatedQueries!(); await Promise.resolve(); @@ -446,9 +446,9 @@ describe("document transforms are applied correctly", async () => { client.onQueryStarted!({ type: "started", id: "1" as TransportIdentifier, - options: { - query: printMinified(untransformedQuery), - }, + options: serializeOptions({ + query: untransformedQuery, + }), }); client.onQueryProgress!({ type: "error", diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx index c331259f..6779f90d 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx @@ -9,7 +9,6 @@ import type { import { ApolloClient as OrigApolloClient, Observable, - gql, } from "@apollo/client/index.js"; import type { QueryManager } from "@apollo/client/core/QueryManager.js"; import { print } from "@apollo/client/utilities/index.js"; @@ -21,11 +20,12 @@ 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 { + ProgressEvent, QueryEvent, TransportIdentifier, } from "./DataTransportAbstraction.js"; import { bundle } from "../bundleInfo.js"; -import { printMinified } from "./printMinified.js"; +import { serializeOptions, deserializeOptions } from "./transportedOptions.js"; function getQueryManager( client: OrigApolloClient @@ -35,6 +35,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; @@ -68,82 +76,12 @@ class ApolloClientBase extends OrigApolloClient { } } -class ApolloClientSSRImpl extends ApolloClientBase { - constructor(options: ApolloClientOptions) { - super(options); - - getQueryManager(this)[wrappers] = hookWrappers; - } - - watchQueryQueue = createBackpressuredCallback<{ - event: Extract; - observable: Observable>; - }>(); - - watchQuery< - T = any, - TVariables extends OperationVariables = OperationVariables, - >(options: WatchQueryOptions) { - if ( - options.fetchPolicy !== "cache-only" && - options.fetchPolicy !== "standby" - ) { - 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), - query: printMinified(options.query), - }, - id, - }, - observable: streamObservable, - }); - return observableQuery; - } - return super.watchQuery(options); - } -} - -export class ApolloClientBrowserImpl< +export class ApolloClientClientBaseImpl< TCacheShape, > extends ApolloClientBase { constructor(options: ApolloClientOptions) { super(options); + this.onQueryStarted = this.onQueryStarted.bind(this); getQueryManager(this)[wrappers] = hookWrappers; } @@ -157,7 +95,7 @@ export class ApolloClientBrowserImpl< WatchQueryOptions >(); - private identifyUniqueQuery(options: { + protected identifyUniqueQuery(options: { query: DocumentNode; variables?: unknown; }) { @@ -175,29 +113,24 @@ export class ApolloClientBrowserImpl< 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 hydratedOptions = { - ...options, - query: gql(options.query), - }; - const { query, varJson, cacheKey } = - this.identifyUniqueQuery(hydratedOptions); + onQueryStarted({ options, id }: Extract) { + const hydratedOptions = deserializeOptions(options); + const { cacheKey, cacheKeyArr } = this.identifyUniqueQuery(hydratedOptions); this.transportedQueryOptions.set(id, hydratedOptions); - 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, @@ -207,10 +140,7 @@ export class ApolloClientBrowserImpl< 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); @@ -227,7 +157,7 @@ export class ApolloClientBrowserImpl< ); }); - promise.finally(cleanup); + promise.then(cleanup, cleanup); const observable = new Observable((observer) => { promise @@ -240,9 +170,8 @@ export class ApolloClientBrowserImpl< }); }); - queryManager["inFlightLinkObservables"].lookup( - printedServerQuery, - varJson + queryManager["inFlightLinkObservables"].lookupArray( + cacheKeyArr ).observable = observable; queryManager["fetchCancelFns"].set( @@ -256,9 +185,9 @@ export class ApolloClientBrowserImpl< }) ); } - }; + } - onQueryProgress = (event: Exclude) => { + onQueryProgress = (event: ProgressEvent) => { const queryInfo = this.simulatedStreamingQueries.get(event.id); if (event.type === "data") { @@ -288,11 +217,19 @@ export class ApolloClientBrowserImpl< */ 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, 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") { @@ -332,6 +269,90 @@ export class ApolloClientBrowserImpl< }; } +class ApolloClientSSRImpl< + TCacheShape, +> extends ApolloClientClientBaseImpl { + private forwardedQueries = new (getTrieConstructor(this))(); + + watchQueryQueue = createBackpressuredCallback<{ + event: Extract; + observable: Observable>; + }>(); + + watchQuery< + T = any, + TVariables extends OperationVariables = OperationVariables, + >(options: WatchQueryOptions) { + const { cacheKeyArr } = this.identifyUniqueQuery(options); + + if ( + options.fetchPolicy !== "cache-only" && + 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; + + 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: serializeOptions(options), + id, + }, + observable: streamObservable, + }); + return observableQuery; + } + return super.watchQuery(options); + } + + onQueryStarted(event: Extract) { + const hydratedOptions = deserializeOptions(event.options); + const { cacheKeyArr } = this.identifyUniqueQuery(hydratedOptions); + // 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< + TCacheShape, +> extends ApolloClientClientBaseImpl {} + const ApolloClientImplementation = /*#__PURE__*/ process.env.REACT_ENV === "ssr" ? ApolloClientSSRImpl diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/hooks.ts b/packages/client-react-streaming/src/DataTransportAbstraction/hooks.ts index c08b0fdc..88b95b08 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/hooks.ts +++ b/packages/client-react-streaming/src/DataTransportAbstraction/hooks.ts @@ -1,5 +1,6 @@ import type { HookWrappers } from "@apollo/client/react/internal/index.js"; import { useTransportValue } from "./useTransportValue.js"; +import { useWrapTransportedQueryRef } from "../transportedQueryRef.js"; export const hookWrappers: HookWrappers = { useFragment(orig_useFragment) { @@ -18,7 +19,19 @@ export const hookWrappers: HookWrappers = { return wrap(orig_useSuspenseQuery, ["data", "networkStatus"]); }, useReadQuery(orig_useReadQuery) { - return wrap(orig_useReadQuery, ["data", "networkStatus"]); + return wrap( + (queryRef) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + return orig_useReadQuery(useWrapTransportedQueryRef(queryRef)); + }, + ["data", "networkStatus"] + ); + }, + useQueryRefHandlers(orig_useQueryRefHandlers) { + return wrap((queryRef) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + return orig_useQueryRefHandlers(useWrapTransportedQueryRef(queryRef)); + }, []); }, }; @@ -28,6 +41,9 @@ function wrap any>( ): T { return ((...args: any[]) => { const result = useFn(...args); + if (transportKeys.length == 0) { + return result; + } const transported: Partial = {}; for (const key of transportKeys) { transported[key] = result[key]; diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/printMinified.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/printMinified.tsx deleted file mode 100644 index bbcb9e77..00000000 --- a/packages/client-react-streaming/src/DataTransportAbstraction/printMinified.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import type { DocumentNode } from "@apollo/client/index.js"; -import { print } from "@apollo/client/utilities/index.js"; -import { stripIgnoredCharacters } from "graphql"; - -export function printMinified(query: DocumentNode): string { - return stripIgnoredCharacters(print(query)); -} diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/transportedOptions.ts b/packages/client-react-streaming/src/DataTransportAbstraction/transportedOptions.ts new file mode 100644 index 00000000..78f7651c --- /dev/null +++ b/packages/client-react-streaming/src/DataTransportAbstraction/transportedOptions.ts @@ -0,0 +1,45 @@ +import { gql } from "@apollo/client/index.js"; +import type { + WatchQueryOptions, + DocumentNode, + FetchPolicy, +} from "@apollo/client/index.js"; +import { print } from "@apollo/client/utilities/index.js"; +import { stripIgnoredCharacters } from "graphql"; + +export type TransportedOptions = { query: string } & Omit< + WatchQueryOptions, + "query" +>; + +export function serializeOptions>( + options: T +): { query: string; nextFetchPolicy?: FetchPolicy | undefined } & Omit< + T, + "query" +> { + return { + ...(options as typeof options & { + // little bit of a hack around the method signature, but the method signature would cause React to error anyways + nextFetchPolicy?: FetchPolicy | undefined; + }), + query: printMinified(options.query), + }; +} + +export function deserializeOptions( + options: TransportedOptions +): WatchQueryOptions { + return { + ...options, + // `gql` memoizes results, but based on the input string. + // We parse-stringify-parse here to ensure that our minified query + // has the best chance of being the referential same query as the one used in + // client-side code. + query: gql(print(gql(options.query))), + }; +} + +function printMinified(query: DocumentNode): string { + return stripIgnoredCharacters(print(query)); +} diff --git a/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx b/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx index 8d8aaca2..6900e6db 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx +++ b/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx @@ -37,7 +37,7 @@ const buildManualDataTransportSSRImpl = ({ }) { const insertHtml = useInsertHtml(); - const rehydrationContext = useRef(); + const rehydrationContext = useRef(undefined); if (!rehydrationContext.current) { rehydrationContext.current = buildApolloRehydrationContext({ insertHtml, diff --git a/packages/client-react-streaming/src/PreloadQuery.tsx b/packages/client-react-streaming/src/PreloadQuery.tsx new file mode 100644 index 00000000..c5db2270 --- /dev/null +++ b/packages/client-react-streaming/src/PreloadQuery.tsx @@ -0,0 +1,84 @@ +import { SimulatePreloadedQuery } from "./index.cc.js"; +import type { + ApolloClient, + OperationVariables, + QueryOptions, +} from "@apollo/client"; +import type { ReactNode } from "react"; +import React from "react"; +import { serializeOptions } from "./DataTransportAbstraction/transportedOptions.js"; +import type { TransportedQueryRef } from "./transportedQueryRef.js"; +import { createTransportedQueryRef } from "./transportedQueryRef.js"; +import type { ProgressEvent } from "./DataTransportAbstraction/DataTransportAbstraction.js"; + +export type RestrictedPreloadOptions = { + fetchPolicy?: "cache-first"; + returnPartialData?: false; + nextFetchPolicy?: undefined; + pollInterval?: undefined; +}; + +export type PreloadQueryOptions = QueryOptions< + TVariables, + TData +> & + RestrictedPreloadOptions; + +export function PreloadQuery({ + getClient, + children, + ...options +}: PreloadQueryOptions & { + getClient: () => ApolloClient | Promise>; + children: + | ReactNode + | (( + queryRef: TransportedQueryRef, NoInfer> + ) => ReactNode); +}) { + const preloadOptions = { + ...options, + fetchPolicy: "cache-first" as const, + returnPartialData: false, + pollInterval: undefined, + nextFetchPolicy: undefined, + } satisfies RestrictedPreloadOptions; + + const transportedOptions = sanitizeForTransport( + serializeOptions(preloadOptions) + ); + + const resultPromise = Promise.resolve(getClient()) + .then((client) => client.query(preloadOptions)) + .then>, Array>>( + (result) => [ + { type: "data", result: sanitizeForTransport(result) }, + { type: "complete" }, + ], + () => [{ type: "error" }] + ); + + const queryKey = crypto.randomUUID(); + + return ( + + options={transportedOptions} + result={resultPromise} + queryKey={typeof children === "function" ? queryKey : undefined} + > + {typeof children === "function" + ? children( + createTransportedQueryRef( + transportedOptions, + queryKey, + resultPromise + ) + ) + : children} + + ); +} + +function sanitizeForTransport(value: T) { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/packages/client-react-streaming/src/helperTypes.ts b/packages/client-react-streaming/src/helperTypes.ts new file mode 100644 index 00000000..9c29a9d2 --- /dev/null +++ b/packages/client-react-streaming/src/helperTypes.ts @@ -0,0 +1 @@ +export type NoInfer = [T][T extends any ? 0 : never]; diff --git a/packages/client-react-streaming/src/index.cc.ts b/packages/client-react-streaming/src/index.cc.ts new file mode 100644 index 00000000..1c695e2c --- /dev/null +++ b/packages/client-react-streaming/src/index.cc.ts @@ -0,0 +1,82 @@ +"use client"; + +import { + skipToken, + useApolloClient, + useBackgroundQuery, +} from "@apollo/client/index.js"; +import type { ApolloClient as WrappedApolloClient } from "./DataTransportAbstraction/WrappedApolloClient.js"; +import type { + ProgressEvent, + TransportIdentifier, +} from "./DataTransportAbstraction/DataTransportAbstraction.js"; +import { + deserializeOptions, + type TransportedOptions, +} from "./DataTransportAbstraction/transportedOptions.js"; +import type { QueryManager } from "@apollo/client/core/QueryManager.js"; +import { useMemo } from "react"; +import type { ReactNode } from "react"; +import invariant from "ts-invariant"; +import type { TransportedQueryRefOptions } from "./transportedQueryRef.js"; +import type { PreloadQueryOptions } from "./PreloadQuery.js"; + +const handledRequests = new WeakMap(); + +export function SimulatePreloadedQuery({ + options, + result, + children, + queryKey, +}: { + options: TransportedQueryRefOptions; + result: Promise>>; + children: ReactNode; + queryKey?: string; +}) { + const client = useApolloClient() as WrappedApolloClient; + if (!handledRequests.has(options)) { + 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((results) => { + invariant.debug("Preloaded query %s: received events: %o", id, results); + for (const event of results) { + client.onQueryProgress!({ ...event, id } as ProgressEvent); + } + }); + } + + const bgQueryArgs = useMemo>(() => { + const { query, ...hydratedOptions } = deserializeOptions( + options + ) as PreloadQueryOptions; + return [ + query, + // If we didn't pass in a `queryKey` prop, the user didn't use the render props form and we don't + // need to create a real `queryRef` => skip. + // Otherwise we call `useBackgroundQuery` with options in this component to create a `queryRef` + // and have it soft-retained in the SuspenseCache. + queryKey + ? { + ...hydratedOptions, + queryKey, + } + : skipToken, + ]; + }, [options, queryKey]); + + useBackgroundQuery(...bgQueryArgs); + + return children; +} diff --git a/packages/client-react-streaming/src/index.shared.ts b/packages/client-react-streaming/src/index.shared.ts index ff43ddaa..ecee6a4c 100644 --- a/packages/client-react-streaming/src/index.shared.ts +++ b/packages/client-react-streaming/src/index.shared.ts @@ -5,3 +5,4 @@ export { ApolloClient, InMemoryCache, } from "./DataTransportAbstraction/index.js"; +export type { TransportedQueryRef } from "./transportedQueryRef.js"; diff --git a/packages/client-react-streaming/src/registerApolloClient.tsx b/packages/client-react-streaming/src/registerApolloClient.tsx index 977c6d57..953ca5eb 100644 --- a/packages/client-react-streaming/src/registerApolloClient.tsx +++ b/packages/client-react-streaming/src/registerApolloClient.tsx @@ -1,5 +1,10 @@ -import type { ApolloClient } from "@apollo/client/index.js"; +import type { ApolloClient, OperationVariables } from "@apollo/client/index.js"; +import type React from "react"; import { cache } from "react"; +import type { ReactNode } from "react"; +import type { PreloadQueryOptions } from "./PreloadQuery.js"; +import { PreloadQuery as UnboundPreloadQuery } from "./PreloadQuery.js"; +import type { TransportedQueryRef } from "./transportedQueryRef.js"; const seenWrappers = WeakSet ? new WeakSet<{ client: ApolloClient | Promise> }>() @@ -17,7 +22,7 @@ const seenClients = WeakSet * * @example * ```ts - * export const { getClient } = registerApolloClient(() => { + * export const { getClient, query, PreloadQuery } = registerApolloClient(() => { * return new ApolloClient({ * cache: new InMemoryCache(), * link: new HttpLink({ @@ -29,34 +34,69 @@ const seenClients = WeakSet * * @public */ -export function registerApolloClient( - makeClient: () => Promise> -): { getClient: () => Promise> }; -/** - * Ensures that you can always access the same instance of ApolloClient - * during RSC for an ongoing request, while always returning - * a new instance for different requests. - * - * @example - * ```ts - * export const { getClient } = registerApolloClient(() => { - * return new ApolloClient({ - * cache: new InMemoryCache(), - * link: new HttpLink({ - * uri: "http://example.com/api/graphql", - * }), - * }); - * }); - * ``` - * - * @public - */ -export function registerApolloClient(makeClient: () => ApolloClient): { - getClient: () => ApolloClient; -}; -export function registerApolloClient( - makeClient: (() => Promise>) | (() => ApolloClient) -) { +export function registerApolloClient< + ApolloClientOrPromise extends Promise> | ApolloClient, +>( + makeClient: () => ApolloClientOrPromise +): { + getClient: () => ApolloClientOrPromise; + query: Awaited["query"]; + /** + * Preloads data in React Server Components to be hydrated + * in Client Components. + * + * ### Example with `queryRef` + * `ClientChild` would call `useReadQuery` with the `queryRef`, the `Suspense` boundary is optional: + * ```js + * + * {(queryRef) => ( + * loading}> + * + * + * )} + * + * ``` + * + * ### Example for `useSuspenseQuery` + * `ClientChild` would call the same query with `useSuspenseQuery`, the `Suspense` boundary is optional: + * ```js + * + * loading}> + * + * + * + * ``` + */ + PreloadQuery: PreloadQueryComponent; +} { + const getClient = makeGetClient(makeClient); + /* + We create an independent instance of Apollo Client per request, + because we don't want to mix up RSC-specific data with Client-specific + data in the same `InMemoryCache` instance. + */ + const getPreloadClient = makeGetClient(makeClient); + const PreloadQuery = makePreloadQuery(getPreloadClient); + return { + getClient, + query: async (...args) => (await getClient()).query(...args), + PreloadQuery, + }; +} + +function makeGetClient< + AC extends Promise> | ApolloClient, +>(makeClient: () => AC): () => AC { // React invalidates the cache on each server request, so the wrapping // object is needed to properly detect whether the client is a unique // reference or not. We can warn if `cachedMakeWrappedClient` creates a new "wrapper", @@ -101,7 +141,31 @@ return a new instance every time \`makeClient\` is called. } return wrapper.client; } - return { - getClient, + return getClient; +} + +interface PreloadQueryProps + extends PreloadQueryOptions { + children: + | ReactNode + | (( + queryRef: TransportedQueryRef, NoInfer> + ) => ReactNode); +} + +interface PreloadQueryComponent { + ( + props: PreloadQueryProps + ): React.ReactNode; +} + +function makePreloadQuery( + getClient: () => Promise> | ApolloClient +) { + return function PreloadQuery( + props: PreloadQueryProps + ): React.ReactNode { + // we directly execute the bound component instead of returning JSX to keep the tree a bit tidier + return UnboundPreloadQuery({ ...props, getClient }); }; } diff --git a/packages/client-react-streaming/src/transportedQueryRef.ts b/packages/client-react-streaming/src/transportedQueryRef.ts new file mode 100644 index 00000000..810ffe1f --- /dev/null +++ b/packages/client-react-streaming/src/transportedQueryRef.ts @@ -0,0 +1,126 @@ +import type { CacheKey } from "@apollo/client/react/internal/index.js"; +import { + wrapQueryRef, + getSuspenseCache, + unwrapQueryRef, + assertWrappedQueryRef, +} from "@apollo/client/react/internal/index.js"; + +import { + useApolloClient, + type ApolloClient, + type QueryRef, +} from "@apollo/client/index.js"; +import { + deserializeOptions, + type TransportedOptions, +} from "./DataTransportAbstraction/transportedOptions.js"; +import { useEffect } from "react"; +import { canonicalStringify } from "@apollo/client/cache/index.js"; +import type { RestrictedPreloadOptions } from "./PreloadQuery.js"; + +export type TransportedQueryRefOptions = TransportedOptions & + RestrictedPreloadOptions; + +/** + * A `TransportedQueryRef` is an opaque object accessible via renderProp within `PreloadQuery`. + * + * A child client component reading the `TransportedQueryRef` via useReadQuery will suspend until the promise resolves. + */ +export interface TransportedQueryRef + extends QueryRef { + /** + * Only available in React Server Components. + * Will be `undefined` after being passed to Client Components. + * + * Returns a promise that resolves back to the `TransportedQueryRef` that can be awaited in RSC to suspend a subtree until the originating query has been loaded. + */ + toPromise?: () => Promise; +} + +export interface InternalTransportedQueryRef< + TData = unknown, + TVariables = unknown, +> extends TransportedQueryRef { + __transportedQueryRef: true | QueryRef; + options: TransportedQueryRefOptions; + queryKey: string; +} + +export function createTransportedQueryRef( + options: TransportedQueryRefOptions, + queryKey: string, + promise: Promise +): InternalTransportedQueryRef { + const ref: InternalTransportedQueryRef = { + __transportedQueryRef: true, + options, + queryKey, + }; + Object.defineProperty(ref, "toPromise", { + value: () => promise.then(() => ref), + enumerable: false, + }); + return ref; +} + +export function reviveTransportedQueryRef( + queryRef: InternalTransportedQueryRef, + client: ApolloClient +): [QueryRef, CacheKey] { + const hydratedOptions = deserializeOptions(queryRef.options); + const cacheKey: CacheKey = [ + hydratedOptions.query, + canonicalStringify(hydratedOptions.variables), + queryRef.queryKey, + ]; + if (queryRef.__transportedQueryRef === true) { + queryRef.__transportedQueryRef = wrapQueryRef( + getSuspenseCache(client).getQueryRef(cacheKey, () => + client.watchQuery(hydratedOptions) + ) + ); + } + return [queryRef.__transportedQueryRef, cacheKey]; +} + +function isTransportedQueryRef( + queryRef: object +): queryRef is InternalTransportedQueryRef { + return "__transportedQueryRef" in queryRef; +} + +export function useWrapTransportedQueryRef( + queryRef: QueryRef | InternalTransportedQueryRef +): QueryRef { + const client = useApolloClient(); + let cacheKey: CacheKey | undefined; + let isTransported: boolean; + if ((isTransported = isTransportedQueryRef(queryRef))) { + [queryRef, cacheKey] = reviveTransportedQueryRef(queryRef, client); + } + assertWrappedQueryRef(queryRef); + const unwrapped = unwrapQueryRef(queryRef); + + useEffect(() => { + // We only want this to execute if the queryRef is a transported query. + if (!isTransported) return; + // We want to always keep this queryRef in the suspense cache in case another component has another instance of this transported queryRef. + // This effect could be removed after https://github.com/facebook/react/pull/28996 has been merged and we've updated deps to that version. + if (cacheKey) { + if (unwrapped.disposed) { + getSuspenseCache(client).add(cacheKey, unwrapped); + } + } + // Omitting the deps is intentional. This avoids stale closures and the + // conditional ensures we aren't running the logic on each render. + }); + // Soft-retaining because useQueryRefHandlers doesn't do it for us. + // This effect could be removed after https://github.com/facebook/react/pull/28996 has been merged and we've updated deps to that version. + useEffect(() => { + if (isTransported) { + return unwrapped.softRetain(); + } + }, [isTransported, unwrapped]); + return queryRef; +} diff --git a/packages/client-react-streaming/tsup.config.ts b/packages/client-react-streaming/tsup.config.ts index 9d6e9578..1c257480 100644 --- a/packages/client-react-streaming/tsup.config.ts +++ b/packages/client-react-streaming/tsup.config.ts @@ -70,6 +70,10 @@ export default defineConfig((options) => { "manual-transport.browser" ), entry("ssr", "src/stream-utils/index.ts", "stream-utils.node"), + { + ...entry("browser", "src/index.cc.ts", "index.cc"), + treeshake: false, // would remove the "use client" directive + }, ]; }); @@ -83,5 +87,15 @@ const acModuleImports: Plugin = { } return { path: args.path, external: true }; }); + // handle "client component" boundary imports + build.onResolve({ filter: /\.cc\.js$/ }, async (args) => { + if (build.initialOptions.define["TSUP_FORMAT"] === '"cjs"') { + return { + path: args.path.replace(/\.cc\.js$/, ".cc.cjs"), + external: true, + }; + } + return { path: args.path, external: true }; + }); }, }; diff --git a/packages/experimental-nextjs-app-support/package.json b/packages/experimental-nextjs-app-support/package.json index 0ca7ab6f..0ef4a51d 100644 --- a/packages/experimental-nextjs-app-support/package.json +++ b/packages/experimental-nextjs-app-support/package.json @@ -79,7 +79,7 @@ "lint": "eslint --ext .ts,.tsx ." }, "devDependencies": { - "@apollo/client": "3.9.9", + "@apollo/client": "3.10.4", "@apollo/client-react-streaming": "workspace:*", "@arethetypeswrong/cli": "0.15.3", "@microsoft/api-extractor": "7.43.2", @@ -101,7 +101,7 @@ "jsdom": "24.0.0", "next": "14.2.3", "publint": "0.2.7", - "react": "18.3.0-canary-60a927d04-20240113", + "react": "18.3.0", "rimraf": "5.0.5", "ts-node": "10.9.2", "tsup": "8.0.2", @@ -110,7 +110,7 @@ "vitest": "1.6.0" }, "peerDependencies": { - "@apollo/client": "^3.9.6", + "@apollo/client": "^3.10.4", "next": "^13.4.1 || ^14.0.0", "react": "^18" }, diff --git a/packages/experimental-nextjs-app-support/src/rsc/index.ts b/packages/experimental-nextjs-app-support/src/rsc/index.ts index fe8b3f08..acd8769e 100644 --- a/packages/experimental-nextjs-app-support/src/rsc/index.ts +++ b/packages/experimental-nextjs-app-support/src/rsc/index.ts @@ -1 +1,4 @@ -export { registerApolloClient } from "@apollo/client-react-streaming"; +export { + registerApolloClient, + type TransportedQueryRef, +} from "@apollo/client-react-streaming"; diff --git a/packages/experimental-nextjs-app-support/src/ssr/index.ts b/packages/experimental-nextjs-app-support/src/ssr/index.ts index b09ff525..49f8da9d 100644 --- a/packages/experimental-nextjs-app-support/src/ssr/index.ts +++ b/packages/experimental-nextjs-app-support/src/ssr/index.ts @@ -7,6 +7,7 @@ export { SSRMultipartLink, DebounceMultipartResponsesLink, RemoveMultipartDirectivesLink, + type TransportedQueryRef, } from "@apollo/client-react-streaming"; export { useBackgroundQuery, diff --git a/yarn.lock b/yarn.lock index a29035a2..04a94fd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -49,7 +49,7 @@ __metadata: version: 0.0.0-use.local resolution: "@apollo/client-react-streaming@workspace:packages/client-react-streaming" dependencies: - "@apollo/client": "npm:3.9.9" + "@apollo/client": "npm:^3.10.4" "@arethetypeswrong/cli": "npm:0.15.3" "@microsoft/api-extractor": "npm:7.43.2" "@testing-library/react": "npm:15.0.7" @@ -69,9 +69,9 @@ __metadata: graphql: "npm:16.8.1" jsdom: "npm:24.0.0" publint: "npm:0.2.7" - react: "npm:18.3.0-canary-60a927d04-20240113" + react: "npm:18.3.0" react-error-boundary: "npm:4.0.13" - react-server-dom-webpack: "npm:18.3.0-canary-60a927d04-20240113" + react-server-dom-webpack: "npm:0.0.1" rimraf: "npm:5.0.5" ts-invariant: "npm:^0.10.3" ts-node: "npm:10.9.2" @@ -80,14 +80,14 @@ __metadata: typescript: "npm:5.4.5" vitest: "npm:1.6.0" peerDependencies: - "@apollo/client": ^3.9.6 + "@apollo/client": ^3.10.4 react: ^18 languageName: unknown linkType: soft -"@apollo/client@npm:3.9.9, @apollo/client@npm:^3.9.9": - version: 3.9.9 - resolution: "@apollo/client@npm:3.9.9" +"@apollo/client@npm:3.10.4, @apollo/client@npm:^3.10.4": + version: 3.10.4 + resolution: "@apollo/client@npm:3.10.4" dependencies: "@graphql-typed-document-node/core": "npm:^3.1.1" "@wry/caches": "npm:^1.0.0" @@ -97,7 +97,7 @@ __metadata: hoist-non-react-statics: "npm:^3.3.2" optimism: "npm:^0.18.0" prop-types: "npm:^15.7.2" - rehackt: "npm:0.0.6" + rehackt: "npm:^0.1.0" response-iterator: "npm:^0.2.6" symbol-observable: "npm:^4.0.0" ts-invariant: "npm:^0.10.3" @@ -118,7 +118,7 @@ __metadata: optional: true subscriptions-transport-ws: optional: true - checksum: 10/9e67f8867eb4fd8a29c36267d919f20d003584040134744db99d85db18cfc2ba61364586c76799bef413b690fc3957a37b79b9b1e084bf1730217aa95d7efd2f + checksum: 10/8db77625bb96f3330187a6b45c9792edf338c42d4e48ed66f6b0ce38c7cea503db9a5de27f9987b7d83306201a57f90e8ef7ebc06c8a6899aaadb8a090b175cb languageName: node linkType: hard @@ -126,7 +126,7 @@ __metadata: version: 0.0.0-use.local resolution: "@apollo/experimental-nextjs-app-support@workspace:packages/experimental-nextjs-app-support" dependencies: - "@apollo/client": "npm:3.9.9" + "@apollo/client": "npm:3.10.4" "@apollo/client-react-streaming": "workspace:*" "@arethetypeswrong/cli": "npm:0.15.3" "@microsoft/api-extractor": "npm:7.43.2" @@ -148,7 +148,7 @@ __metadata: jsdom: "npm:24.0.0" next: "npm:14.2.3" publint: "npm:0.2.7" - react: "npm:18.3.0-canary-60a927d04-20240113" + react: "npm:18.3.0" rimraf: "npm:5.0.5" ts-node: "npm:10.9.2" tsup: "npm:8.0.2" @@ -156,7 +156,7 @@ __metadata: typescript: "npm:5.4.5" vitest: "npm:1.6.0" peerDependencies: - "@apollo/client": ^3.9.6 + "@apollo/client": ^3.10.4 next: ^13.4.1 || ^14.0.0 react: ^18 languageName: unknown @@ -5158,13 +5158,6 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*": - version: 15.7.5 - resolution: "@types/prop-types@npm:15.7.5" - checksum: 10/5b43b8b15415e1f298243165f1d44390403bb2bd42e662bca3b5b5633fdd39c938e91b7fce3a9483699db0f7a715d08cef220c121f723a634972fdf596aec980 - languageName: node - linkType: hard - "@types/qs@npm:*": version: 6.9.7 resolution: "@types/qs@npm:6.9.7" @@ -5179,22 +5172,21 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:18.3.0, @types/react-dom@npm:^18.0.0": - version: 18.3.0 - resolution: "@types/react-dom@npm:18.3.0" +"@types/react-dom@npm:types-react-dom@19.0.0-alpha.3": + version: 19.0.0-alpha.3 + resolution: "types-react-dom@npm:19.0.0-alpha.3" dependencies: "@types/react": "npm:*" - checksum: 10/6ff53f5a7b7fba952a68e114d3b542ebdc1e87a794234785ebab0bcd9bde7fb4885f21ebaf93d26dc0a1b5b93287f42cad68b78ae04dddf6b20da7aceff0beaf + checksum: 10/21f43e3cbdcf59bc2e6ac71e52161455039ccffab3e61cc0ccbb61fdce9a05d70abfce91ce490a6e63a8aae5f1974acc102dcc40c00b3876e2a0e00158a8533d languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:18.3.1": - version: 18.3.1 - resolution: "@types/react@npm:18.3.1" +"@types/react@npm:types-react@19.0.0-alpha.3": + version: 19.0.0-alpha.3 + resolution: "types-react@npm:19.0.0-alpha.3" dependencies: - "@types/prop-types": "npm:*" csstype: "npm:^3.0.2" - checksum: 10/baa6b8a75c471c89ebf3477b4feab57102ced25f0c1e553dd04ef6a1f0def28d5e0172fa626a631f22e223f840b5aaa2403b2d4bb671c83c5a9d6c7ae39c7a05 + checksum: 10/637a0e905df8a2fc8f6a760e29d8a263dca72459e16e6a5c4bb1c950a3900ece1aaef8f6ca716e0c94a70c3172d8ed2e14e75cc72bedcd8aa6e10f7c7fb3552d languageName: node linkType: hard @@ -6019,7 +6011,7 @@ __metadata: version: 0.0.0-use.local resolution: "apollo-next-13-demo@workspace:examples/polls-demo" dependencies: - "@apollo/client": "npm:^3.9.9" + "@apollo/client": "npm:3.10.4" "@apollo/experimental-nextjs-app-support": "workspace:^" "@apollo/server": "npm:^4.9.5" "@graphql-codegen/cli": "npm:3.3.1" @@ -6041,8 +6033,8 @@ __metadata: graphql-tag: "npm:^2.12.6" next: "npm:^14.1.0" postcss: "npm:8.4.23" - react: "npm:18.2.0" - react-dom: "npm:18.2.0" + react: "npm:18.3.0" + react-dom: "npm:18.3.0" tailwindcss: "npm:3.3.2" typescript: "npm:5.4.5" languageName: unknown @@ -6052,7 +6044,7 @@ __metadata: version: 0.0.0-use.local resolution: "app-dir@workspace:examples/app-dir-experiments" dependencies: - "@apollo/client": "npm:^3.9.9" + "@apollo/client": "npm:3.10.4" "@apollo/experimental-nextjs-app-support": "workspace:^" "@apollo/server": "npm:^4.9.5" "@as-integrations/next": "npm:^3.0.0" @@ -6066,8 +6058,8 @@ __metadata: graphql: "npm:^16.6.0" html-differ: "npm:^1.4.0" next: "npm:^14.1.0" - react: "npm:18.2.0" - react-dom: "npm:18.2.0" + react: "npm:18.3.0" + react-dom: "npm:18.3.0" server-only: "npm:^0.0.1" typescript: "npm:5.4.5" languageName: unknown @@ -9897,7 +9889,7 @@ __metadata: version: 0.0.0-use.local resolution: "hack-the-supergraph-ssr@workspace:examples/hack-the-supergraph-ssr" dependencies: - "@apollo/client": "npm:^3.9.9" + "@apollo/client": "npm:3.10.4" "@apollo/experimental-nextjs-app-support": "workspace:^" "@apollo/space-kit": "npm:^9.11.0" "@chakra-ui/next-js": "npm:^2.1.2" @@ -9916,8 +9908,8 @@ __metadata: graphql: "npm:^16.6.0" js-cookie: "npm:^3.0.1" next: "npm:^14.1.0" - react: "npm:18.2.0" - react-dom: "npm:18.2.0" + react: "npm:18.3.0" + react-dom: "npm:18.3.0" react-icons: "npm:^4.8.0" react-rating-stars-component: "npm:^2.2.0" typescript: "npm:5.4.5" @@ -11355,7 +11347,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": +"loose-envify@npm:^1.0.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -13088,15 +13080,14 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:18.3.0-canary-60a927d04-20240113": - version: 18.3.0-canary-60a927d04-20240113 - resolution: "react-dom@npm:18.3.0-canary-60a927d04-20240113" +"react-dom@npm:19.0.0-beta-94eed63c49-20240425": + version: 19.0.0-beta-94eed63c49-20240425 + resolution: "react-dom@npm:19.0.0-beta-94eed63c49-20240425" dependencies: - loose-envify: "npm:^1.1.0" - scheduler: "npm:0.24.0-canary-60a927d04-20240113" + scheduler: "npm:0.25.0-beta-94eed63c49-20240425" peerDependencies: - react: 18.3.0-canary-60a927d04-20240113 - checksum: 10/5a339f511e32d2690021458913d40fcf3671d4fe708026851b811420d958615a298d80352bb78ed4c635c3125ce491d30591213cb1086888bac8c426a2e98e65 + react: 19.0.0-beta-94eed63c49-20240425 + checksum: 10/fe3eb846e83d0295b9efde71eb507ca3607c983bf4d13c9e21552abd3f20064a1fb7f14e7e9cf2cc4b34f7c4f0939d3d1d5d7cc0d21db36c861fe87538e318ce languageName: node linkType: hard @@ -13210,18 +13201,17 @@ __metadata: languageName: node linkType: hard -"react-server-dom-webpack@npm:18.3.0-canary-60a927d04-20240113": - version: 18.3.0-canary-60a927d04-20240113 - resolution: "react-server-dom-webpack@npm:18.3.0-canary-60a927d04-20240113" +"react-server-dom-webpack@npm:19.0.0-beta-94eed63c49-20240425": + version: 19.0.0-beta-94eed63c49-20240425 + resolution: "react-server-dom-webpack@npm:19.0.0-beta-94eed63c49-20240425" dependencies: acorn-loose: "npm:^8.3.0" - loose-envify: "npm:^1.1.0" neo-async: "npm:^2.6.1" peerDependencies: - react: 18.3.0-canary-60a927d04-20240113 - react-dom: 18.3.0-canary-60a927d04-20240113 + react: 19.0.0-beta-94eed63c49-20240425 + react-dom: 19.0.0-beta-94eed63c49-20240425 webpack: ^5.59.0 - checksum: 10/b52ca5befe8e4470a82b79602d10f9949b90d3a4fd90f2067774ad90b311ecb84bd767ec05e8c64df58b12daf37f8aa09349c41c321d13d062e669d04a91507f + checksum: 10/8b4048fa632b5fe714dc6aab4e8801681a7a8cf9dbf9718ad26dbd05f77df8eb7d4389b4de637dff1f40838208666bb39317057470ca12203530531552a741fa languageName: node linkType: hard @@ -13242,12 +13232,10 @@ __metadata: languageName: node linkType: hard -"react@npm:18.3.0-canary-60a927d04-20240113": - version: 18.3.0-canary-60a927d04-20240113 - resolution: "react@npm:18.3.0-canary-60a927d04-20240113" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/7da693ec8a4e4c33941f55c2cc042a4a1ea9360ab7b7fd227b7cddcc8949cac0058e585e557ea1a447dbb74e7865bcebb996ee975680e2b98c6d98e941847cb1 +"react@npm:19.0.0-beta-94eed63c49-20240425": + version: 19.0.0-beta-94eed63c49-20240425 + resolution: "react@npm:19.0.0-beta-94eed63c49-20240425" + checksum: 10/fd6f6ef0340f9ec06f497e23eba019edccd724df280c88146ad11dd240c2ae4ee0762e6ecdf68c40a0ffa75be4f5d2e24d6222a0b544f76ac4ab1499b35fdbbd languageName: node linkType: hard @@ -13348,9 +13336,9 @@ __metadata: languageName: node linkType: hard -"rehackt@npm:0.0.6": - version: 0.0.6 - resolution: "rehackt@npm:0.0.6" +"rehackt@npm:^0.1.0": + version: 0.1.0 + resolution: "rehackt@npm:0.1.0" peerDependencies: "@types/react": "*" react: "*" @@ -13359,7 +13347,7 @@ __metadata: optional: true react: optional: true - checksum: 10/3897c93270836159406529e0fa983bf4a11c07d2efc5c8f6bdfd7f6821d3b84a30d911c3f3b9c689948739e6955c5835c8dd9d91579150bec5092f356c0d91df + checksum: 10/c81adead82c165dffc574cbf9e1de3605522782a56b48df48b68d53d45c4d8c9253df3790109335bf97072424e54ad2423bb9544ca3a985fa91995dda43452fc languageName: node linkType: hard @@ -13875,12 +13863,10 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:0.24.0-canary-60a927d04-20240113": - version: 0.24.0-canary-60a927d04-20240113 - resolution: "scheduler@npm:0.24.0-canary-60a927d04-20240113" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/0289b2a5d20e4885c268a90765ebe7192eafb5b3a065888092c1684e25438932e06efd199a852b47dc8b57cc8741c609552e9f13de66e4492400e8dd4201b809 +"scheduler@npm:0.25.0-beta-94eed63c49-20240425": + version: 0.25.0-beta-94eed63c49-20240425 + resolution: "scheduler@npm:0.25.0-beta-94eed63c49-20240425" + checksum: 10/02a8c46af07e5cb5d80441e7a5a39d6ff29e799cb31f1cad0d396d8f714f945e6cc4df8277e68493ee2785c6e5963d2ab8c74f3bf60c646044759805d52b18e0 languageName: node linkType: hard From 05e162a82dcae391a13a02d4e85d46a5cfcd6dd1 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 15 May 2024 14:01:11 +0200 Subject: [PATCH 05/11] adjustments for docmodel extraction --- .../client-react-streaming/src/registerApolloClient.tsx | 6 +++--- packages/client-react-streaming/src/transportedQueryRef.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/client-react-streaming/src/registerApolloClient.tsx b/packages/client-react-streaming/src/registerApolloClient.tsx index 953ca5eb..1d2f9051 100644 --- a/packages/client-react-streaming/src/registerApolloClient.tsx +++ b/packages/client-react-streaming/src/registerApolloClient.tsx @@ -47,7 +47,7 @@ export function registerApolloClient< * * ### Example with `queryRef` * `ClientChild` would call `useReadQuery` with the `queryRef`, the `Suspense` boundary is optional: - * ```js + * ```jsx * * * - * ``` + * ``` */ PreloadQuery: PreloadQueryComponent; } { diff --git a/packages/client-react-streaming/src/transportedQueryRef.ts b/packages/client-react-streaming/src/transportedQueryRef.ts index 810ffe1f..85cebf4c 100644 --- a/packages/client-react-streaming/src/transportedQueryRef.ts +++ b/packages/client-react-streaming/src/transportedQueryRef.ts @@ -26,6 +26,8 @@ export type TransportedQueryRefOptions = TransportedOptions & * A `TransportedQueryRef` is an opaque object accessible via renderProp within `PreloadQuery`. * * A child client component reading the `TransportedQueryRef` via useReadQuery will suspend until the promise resolves. + * + * @public */ export interface TransportedQueryRef extends QueryRef { From d502aecbf6fdb88e69a01c6def74cb4398655df7 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 16 May 2024 12:43:10 +0200 Subject: [PATCH 06/11] add shared `@apollo/experimental-nextjs-app-support` entry point (#300) This unifies `@apollo/experimental-nextjs-app-support/rsc` and `@apollo/experimental-nextjs-app-support/ssr` into `@apollo/experimental-nextjs-app-support`. Once we rename the package to remove the `experimental` label, this will be the only entry point. Also, some imports are changed: * the `useQuery`/`useSuspenseQuery` etc. hooks have been dropped. They should be imported from `@apollo/client` instead. * `NextSSRApolloClient` has been renamed to `ApolloClient` and is available in different variations from all environments, including RSC. * `NextSSRInMemoryCache` has been renamed to `InMemoryCache` and is available in different variations from all environments, including RSC. * `resetNextSSRApolloSingletons` has been renamed to `resetApolloClientSingletons` --- .size-limit.cjs | 19 ++++----- ...eact-streaming.buildmanualdatatransport.md | 4 +- docs/client-react-streaming.queryevent.md | 4 +- ...imental-nextjs-app-support.apolloclient.md | 17 ++++++++ ...extjs-app-support.apollonextappprovider.md | 9 ++-- ...ental-nextjs-app-support.inmemorycache.md} | 4 +- docs/experimental-nextjs-app-support.md | 22 +++++----- ...-nextjs-app-support.nextssrapolloclient.md | 17 -------- ...pp-support.resetapolloclientsingletons.md} | 4 +- .../app/ssr/ApolloWrapper.tsx | 10 ++--- examples/app-dir-experiments/app/ssr/page.tsx | 6 +-- .../app/ApolloWrapper.tsx | 10 ++--- examples/hack-the-supergraph-ssr/app/page.tsx | 3 +- .../app/product/[id]/page.tsx | 2 +- .../components/ProductCard.tsx | 3 +- examples/polls-demo/app/cc/apollo-wrapper.tsx | 10 ++--- examples/polls-demo/app/cc/poll-cc.tsx | 6 +-- integration-test/jest/src/App.jsx | 18 ++++---- integration-test/jest/src/App.test.jsx | 4 +- integration-test/jest/src/hooks.test.jsx | 16 ++++---- .../nextjs/src/app/cc/ApolloWrapper.tsx | 10 ++--- .../cc/dynamic/useBackgroundQuery/page.tsx | 7 ++-- .../page.tsx | 5 +-- .../src/app/cc/dynamic/useQuery/page.tsx | 3 +- .../app/cc/dynamic/useQueryWithCache/page.tsx | 6 +-- .../app/cc/dynamic/useSuspenseQuery/page.tsx | 3 +- .../useSuspenseQueryWithError/page.tsx | 5 +-- .../page.tsx | 7 ++-- .../app/cc/static/useSuspenseQuery/page.tsx | 3 +- integration-test/vitest/src/App.jsx | 18 ++++---- integration-test/vitest/src/App.test.jsx | 4 +- integration-test/vitest/src/hooks.test.jsx | 12 +++--- .../WrappedApolloClient.tsx | 2 +- .../experimental-nextjs-app-support/README.md | 40 ++++++++---------- .../api-extractor.d.ts | 3 +- .../package-shape.json | 41 +++++++++++++++++++ .../package.json | 18 +++++++- .../src/ApolloNextAppProvider.ts | 10 +++-- .../src/bundleInfo.ts | 6 +-- .../src/combined.ts | 17 ++++++++ .../src/index.rsc.ts | 2 + .../src/index.shared.ts | 27 ++++++++++++ .../src/index.ts | 16 ++++++++ .../src/rsc/index.ts | 2 +- .../src/ssr/index.ts | 26 ++---------- .../tsconfig.json | 5 ++- .../tsup.config.ts | 8 ++++ 47 files changed, 298 insertions(+), 196 deletions(-) create mode 100644 docs/experimental-nextjs-app-support.apolloclient.md rename docs/{experimental-nextjs-app-support.nextssrinmemorycache.md => experimental-nextjs-app-support.inmemorycache.md} (73%) delete mode 100644 docs/experimental-nextjs-app-support.nextssrapolloclient.md rename docs/{experimental-nextjs-app-support.resetnextssrapollosingletons.md => experimental-nextjs-app-support.resetapolloclientsingletons.md} (70%) create mode 100644 packages/experimental-nextjs-app-support/src/combined.ts create mode 100644 packages/experimental-nextjs-app-support/src/index.rsc.ts create mode 100644 packages/experimental-nextjs-app-support/src/index.shared.ts create mode 100644 packages/experimental-nextjs-app-support/src/index.ts diff --git a/.size-limit.cjs b/.size-limit.cjs index 4d14675d..27d93a11 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -1,10 +1,9 @@ /** @type {import('size-limit').SizeLimitConfig} */ const checks = [ { - name: "{ ApolloNextAppProvider, NextSSRApolloClient, NextSSRInMemoryCache } from '@apollo/experimental-nextjs-app-support/ssr' (Browser ESM)", - path: "packages/experimental-nextjs-app-support/dist/ssr/index.browser.js", - import: - "{ ApolloNextAppProvider, NextSSRApolloClient, NextSSRInMemoryCache }", + name: "{ ApolloNextAppProvider, ApolloClient, InMemoryCache } from '@apollo/experimental-nextjs-app-support' (Browser ESM)", + path: "packages/experimental-nextjs-app-support/dist/index.browser.js", + import: "{ ApolloNextAppProvider, ApolloClient, InMemoryCache }", }, { name: "{ WrapApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client-react-streaming' (Browser ESM)", @@ -37,16 +36,16 @@ const checks = [ path: "packages/client-react-streaming/dist/manual-transport.ssr.cjs", }, { - name: "@apollo/experimental-nextjs-app-support/ssr (Browser ESM)", - path: "packages/experimental-nextjs-app-support/dist/ssr/index.browser.js", + name: "@apollo/experimental-nextjs-app-support (Browser ESM)", + path: "packages/experimental-nextjs-app-support/dist/index.browser.js", }, { - name: "@apollo/experimental-nextjs-app-support/ssr (SSR ESM)", - path: "packages/experimental-nextjs-app-support/dist/ssr/index.ssr.js", + name: "@apollo/experimental-nextjs-app-support (SSR ESM)", + path: "packages/experimental-nextjs-app-support/dist/index.ssr.js", }, { - name: "@apollo/experimental-nextjs-app-support/ssr (RSC ESM)", - path: "packages/experimental-nextjs-app-support/dist/ssr/index.rsc.js", + name: "@apollo/experimental-nextjs-app-support (RSC ESM)", + path: "packages/experimental-nextjs-app-support/dist/index.rsc.js", }, { name: "@apollo/experimental-nextjs-app-support/rsc (RSC ESM)", diff --git a/docs/client-react-streaming.buildmanualdatatransport.md b/docs/client-react-streaming.buildmanualdatatransport.md index 1ea18b58..5c55d1b2 100644 --- a/docs/client-react-streaming.buildmanualdatatransport.md +++ b/docs/client-react-streaming.buildmanualdatatransport.md @@ -11,7 +11,7 @@ Creates a "manual" Data Transport, to be used with `WrapApolloProvider`. **Signature:** ```typescript -buildManualDataTransport: (args: BuildArgs) => DataTransportProviderImplementation +buildManualDataTransport: (args: ManualDataTransportOptions) => DataTransportProviderImplementation ``` ## Parameters @@ -39,7 +39,7 @@ args -BuildArgs +ManualDataTransportOptions diff --git a/docs/client-react-streaming.queryevent.md b/docs/client-react-streaming.queryevent.md index a8160b0a..087ef7a4 100644 --- a/docs/client-react-streaming.queryevent.md +++ b/docs/client-react-streaming.queryevent.md @@ -11,7 +11,9 @@ Events that will be emitted by a wrapped ApolloClient instance during SSR on `Da ```typescript type QueryEvent = { type: "started"; - options: WatchQueryOptions; + options: { + query: string; + } & Omit; id: TransportIdentifier; } | { type: "data"; diff --git a/docs/experimental-nextjs-app-support.apolloclient.md b/docs/experimental-nextjs-app-support.apolloclient.md new file mode 100644 index 00000000..eba6300f --- /dev/null +++ b/docs/experimental-nextjs-app-support.apolloclient.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [@apollo/experimental-nextjs-app-support](./experimental-nextjs-app-support.md) > [ApolloClient](./experimental-nextjs-app-support.apolloclient.md) + +## ApolloClient class + +A version of `ApolloClient` to be used with streaming SSR or in React Server Components. + +For more documentation, please see [the Apollo Client API documentation](https://www.apollographql.com/docs/react/api/core/ApolloClient). + +**Signature:** + +```typescript +declare class ApolloClient extends ApolloClient$1 +``` +**Extends:** ApolloClient$1<TCacheShape> + diff --git a/docs/experimental-nextjs-app-support.apollonextappprovider.md b/docs/experimental-nextjs-app-support.apollonextappprovider.md index 742266ee..5ff06781 100644 --- a/docs/experimental-nextjs-app-support.apollonextappprovider.md +++ b/docs/experimental-nextjs-app-support.apollonextappprovider.md @@ -10,7 +10,7 @@ A version of `ApolloProvider` to be used with the Next.js App Router. As opposed to the normal `ApolloProvider`, this version does not require a `client` prop, but requires a `makeClient` prop instead. -Use this component together with `NextSSRApolloClient` and `NextSSRInMemoryCache` to make an ApolloClient instance available to your Client Component hooks in the Next.js App Router. +Use this component together with `ApolloClient` and `InMemoryCache` from the `@apollo/experimental-nextjs-app-support` package to make an ApolloClient instance available to your Client Component hooks in the Next.js App Router. **Signature:** @@ -23,13 +23,16 @@ ApolloNextAppProvider: _apollo_client_react_streaming.WrappedApolloProvider<_apo `app/ApolloWrapper.jsx` ```tsx +import { HttpLink } from "@apollo/client"; +import { ApolloNextAppProvider, ApolloClient, InMemoryCache } from "@apollo/experimental-nextjs-app-support"; + function makeClient() { const httpLink = new HttpLink({ uri: "https://example.com/api/graphql", }); - return new NextSSRApolloClient({ - cache: new NextSSRInMemoryCache(), + return new ApolloClient({ + cache: new InMemoryCache(), link: httpLink, }); } diff --git a/docs/experimental-nextjs-app-support.nextssrinmemorycache.md b/docs/experimental-nextjs-app-support.inmemorycache.md similarity index 73% rename from docs/experimental-nextjs-app-support.nextssrinmemorycache.md rename to docs/experimental-nextjs-app-support.inmemorycache.md index c28416f3..92e4ab51 100644 --- a/docs/experimental-nextjs-app-support.nextssrinmemorycache.md +++ b/docs/experimental-nextjs-app-support.inmemorycache.md @@ -1,8 +1,8 @@ -[Home](./index.md) > [@apollo/experimental-nextjs-app-support](./experimental-nextjs-app-support.md) > [NextSSRInMemoryCache](./experimental-nextjs-app-support.nextssrinmemorycache.md) +[Home](./index.md) > [@apollo/experimental-nextjs-app-support](./experimental-nextjs-app-support.md) > [InMemoryCache](./experimental-nextjs-app-support.inmemorycache.md) -## NextSSRInMemoryCache class +## InMemoryCache class A version of `InMemoryCache` to be used with streaming SSR. diff --git a/docs/experimental-nextjs-app-support.md b/docs/experimental-nextjs-app-support.md index 2436c8d2..9a5df2fa 100644 --- a/docs/experimental-nextjs-app-support.md +++ b/docs/experimental-nextjs-app-support.md @@ -20,35 +20,35 @@ Description -[DebounceMultipartResponsesLink](./experimental-nextjs-app-support.debouncemultipartresponseslink.md) +[ApolloClient](./experimental-nextjs-app-support.apolloclient.md) -This link can be used to "debounce" the initial response of a multipart request. Any incremental data received during the `cutoffDelay` time will be merged into the initial response. - -After `cutoffDelay`, the link will return the initial response, even if there is still incremental data pending, and close the network connection. +A version of `ApolloClient` to be used with streaming SSR or in React Server Components. -If `cutoffDelay` is `0`, the link will immediately return data as soon as it is received, without waiting for incremental data, and immediately close the network connection. +For more documentation, please see [the Apollo Client API documentation](https://www.apollographql.com/docs/react/api/core/ApolloClient). -[NextSSRApolloClient](./experimental-nextjs-app-support.nextssrapolloclient.md) +[DebounceMultipartResponsesLink](./experimental-nextjs-app-support.debouncemultipartresponseslink.md) -A version of `ApolloClient` to be used with streaming SSR. +This link can be used to "debounce" the initial response of a multipart request. Any incremental data received during the `cutoffDelay` time will be merged into the initial response. -For more documentation, please see [the Apollo Client API documentation](https://www.apollographql.com/docs/react/api/core/ApolloClient). +After `cutoffDelay`, the link will return the initial response, even if there is still incremental data pending, and close the network connection. + +If `cutoffDelay` is `0`, the link will immediately return data as soon as it is received, without waiting for incremental data, and immediately close the network connection. -[NextSSRInMemoryCache](./experimental-nextjs-app-support.nextssrinmemorycache.md) +[InMemoryCache](./experimental-nextjs-app-support.inmemorycache.md) @@ -139,7 +139,7 @@ Ensures that you can always access the same instance of ApolloClient during RSC -[resetNextSSRApolloSingletons()](./experimental-nextjs-app-support.resetnextssrapollosingletons.md) +[resetApolloClientSingletons()](./experimental-nextjs-app-support.resetapolloclientsingletons.md) @@ -184,7 +184,7 @@ A version of `ApolloProvider` to be used with the Next.js App Router. As opposed to the normal `ApolloProvider`, this version does not require a `client` prop, but requires a `makeClient` prop instead. -Use this component together with `NextSSRApolloClient` and `NextSSRInMemoryCache` to make an ApolloClient instance available to your Client Component hooks in the Next.js App Router. +Use this component together with `ApolloClient` and `InMemoryCache` from the `@apollo/experimental-nextjs-app-support` package to make an ApolloClient instance available to your Client Component hooks in the Next.js App Router. diff --git a/docs/experimental-nextjs-app-support.nextssrapolloclient.md b/docs/experimental-nextjs-app-support.nextssrapolloclient.md deleted file mode 100644 index 945aad84..00000000 --- a/docs/experimental-nextjs-app-support.nextssrapolloclient.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [@apollo/experimental-nextjs-app-support](./experimental-nextjs-app-support.md) > [NextSSRApolloClient](./experimental-nextjs-app-support.nextssrapolloclient.md) - -## NextSSRApolloClient class - -A version of `ApolloClient` to be used with streaming SSR. - -For more documentation, please see [the Apollo Client API documentation](https://www.apollographql.com/docs/react/api/core/ApolloClient). - -**Signature:** - -```typescript -declare class NextSSRApolloClient extends ApolloClient -``` -**Extends:** ApolloClient<TCacheShape> - diff --git a/docs/experimental-nextjs-app-support.resetnextssrapollosingletons.md b/docs/experimental-nextjs-app-support.resetapolloclientsingletons.md similarity index 70% rename from docs/experimental-nextjs-app-support.resetnextssrapollosingletons.md rename to docs/experimental-nextjs-app-support.resetapolloclientsingletons.md index d896cf53..3ac3176e 100644 --- a/docs/experimental-nextjs-app-support.resetnextssrapollosingletons.md +++ b/docs/experimental-nextjs-app-support.resetapolloclientsingletons.md @@ -1,8 +1,8 @@ -[Home](./index.md) > [@apollo/experimental-nextjs-app-support](./experimental-nextjs-app-support.md) > [resetNextSSRApolloSingletons](./experimental-nextjs-app-support.resetnextssrapollosingletons.md) +[Home](./index.md) > [@apollo/experimental-nextjs-app-support](./experimental-nextjs-app-support.md) > [resetApolloClientSingletons](./experimental-nextjs-app-support.resetapolloclientsingletons.md) -## resetNextSSRApolloSingletons() function +## resetApolloClientSingletons() function > This export is only available in React Client Components diff --git a/examples/app-dir-experiments/app/ssr/ApolloWrapper.tsx b/examples/app-dir-experiments/app/ssr/ApolloWrapper.tsx index 204037a0..e7003ecf 100644 --- a/examples/app-dir-experiments/app/ssr/ApolloWrapper.tsx +++ b/examples/app-dir-experiments/app/ssr/ApolloWrapper.tsx @@ -3,10 +3,10 @@ import { ApolloLink, HttpLink } from "@apollo/client"; import { ApolloNextAppProvider, - NextSSRInMemoryCache, - NextSSRApolloClient, + InMemoryCache, + ApolloClient, SSRMultipartLink, -} from "@apollo/experimental-nextjs-app-support/ssr"; +} from "@apollo/experimental-nextjs-app-support"; import { setVerbosity } from "ts-invariant"; setVerbosity("debug"); @@ -17,8 +17,8 @@ function makeClient() { fetchOptions: { cache: "no-store" }, }); - return new NextSSRApolloClient({ - cache: new NextSSRInMemoryCache(), + return new ApolloClient({ + cache: new InMemoryCache(), link: typeof window === "undefined" ? ApolloLink.from([ diff --git a/examples/app-dir-experiments/app/ssr/page.tsx b/examples/app-dir-experiments/app/ssr/page.tsx index 372aec94..5b3ef26f 100644 --- a/examples/app-dir-experiments/app/ssr/page.tsx +++ b/examples/app-dir-experiments/app/ssr/page.tsx @@ -1,10 +1,6 @@ "use client"; import React, { Suspense } from "react"; -import { - useFragment, - useQuery, - useSuspenseQuery, -} from "@apollo/experimental-nextjs-app-support/ssr"; +import { useFragment, useQuery, useSuspenseQuery } from "@apollo/client"; import { gql } from "@apollo/client"; import { HtmlChangesObserver } from "@/components/HtmlChangesObserver"; diff --git a/examples/hack-the-supergraph-ssr/app/ApolloWrapper.tsx b/examples/hack-the-supergraph-ssr/app/ApolloWrapper.tsx index 7f115ba9..4167895e 100644 --- a/examples/hack-the-supergraph-ssr/app/ApolloWrapper.tsx +++ b/examples/hack-the-supergraph-ssr/app/ApolloWrapper.tsx @@ -4,10 +4,10 @@ import { ApolloLink, HttpLink } from "@apollo/client"; import clientCookies from "js-cookie"; import { ApolloNextAppProvider, - NextSSRInMemoryCache, - NextSSRApolloClient, + InMemoryCache, + ApolloClient, SSRMultipartLink, -} from "@apollo/experimental-nextjs-app-support/ssr"; +} from "@apollo/experimental-nextjs-app-support"; import { loadErrorMessages, loadDevMessages } from "@apollo/client/dev"; import { setVerbosity } from "ts-invariant"; @@ -68,8 +68,8 @@ export function ApolloWrapper({ ]) : ApolloLink.from([delayLink, httpLink]); - return new NextSSRApolloClient({ - cache: new NextSSRInMemoryCache(), + return new ApolloClient({ + cache: new InMemoryCache(), link, }); } diff --git a/examples/hack-the-supergraph-ssr/app/page.tsx b/examples/hack-the-supergraph-ssr/app/page.tsx index 1b3ede29..0a4dd36f 100644 --- a/examples/hack-the-supergraph-ssr/app/page.tsx +++ b/examples/hack-the-supergraph-ssr/app/page.tsx @@ -2,8 +2,7 @@ import ProductCard from "../components/ProductCard"; import { Heading, SimpleGrid, Stack, Text, VStack } from "@chakra-ui/react"; -import { gql, TypedDocumentNode } from "@apollo/client"; -import { useSuspenseQuery } from "@apollo/experimental-nextjs-app-support/ssr"; +import { useSuspenseQuery, gql, TypedDocumentNode } from "@apollo/client"; const GET_LATEST_PRODUCTS: TypedDocumentNode<{ products: { id: string }[]; diff --git a/examples/hack-the-supergraph-ssr/app/product/[id]/page.tsx b/examples/hack-the-supergraph-ssr/app/product/[id]/page.tsx index 81971bd4..516c9077 100644 --- a/examples/hack-the-supergraph-ssr/app/product/[id]/page.tsx +++ b/examples/hack-the-supergraph-ssr/app/product/[id]/page.tsx @@ -15,7 +15,7 @@ import { Text, } from "@chakra-ui/react"; import { gql, TypedDocumentNode } from "@apollo/client"; -import { useSuspenseQuery } from "@apollo/experimental-nextjs-app-support/ssr"; +import { useSuspenseQuery } from "@apollo/client"; const GET_PRODUCT_DETAILS: TypedDocumentNode<{ product: { diff --git a/examples/hack-the-supergraph-ssr/components/ProductCard.tsx b/examples/hack-the-supergraph-ssr/components/ProductCard.tsx index a5f002b7..06650aea 100644 --- a/examples/hack-the-supergraph-ssr/components/ProductCard.tsx +++ b/examples/hack-the-supergraph-ssr/components/ProductCard.tsx @@ -9,8 +9,7 @@ import { usePrefersReducedMotion, } from "@chakra-ui/react"; import Link from "next/link"; -import { TypedDocumentNode, gql } from "@apollo/client"; -import { useFragment } from "@apollo/experimental-nextjs-app-support/ssr"; +import { useFragment, TypedDocumentNode, gql } from "@apollo/client"; const ProductCardProductFragment: TypedDocumentNode<{ id: string; diff --git a/examples/polls-demo/app/cc/apollo-wrapper.tsx b/examples/polls-demo/app/cc/apollo-wrapper.tsx index b92ac869..f9b3e39a 100644 --- a/examples/polls-demo/app/cc/apollo-wrapper.tsx +++ b/examples/polls-demo/app/cc/apollo-wrapper.tsx @@ -2,11 +2,11 @@ import { ApolloLink, HttpLink } from "@apollo/client"; import { - NextSSRApolloClient, + ApolloClient, ApolloNextAppProvider, - NextSSRInMemoryCache, + InMemoryCache, SSRMultipartLink, -} from "@apollo/experimental-nextjs-app-support/ssr"; +} from "@apollo/experimental-nextjs-app-support"; import { loadErrorMessages, loadDevMessages } from "@apollo/client/dev"; import { setVerbosity } from "ts-invariant"; @@ -21,8 +21,8 @@ function makeClient() { uri: "https://apollo-next-poll.up.railway.app/", }); - return new NextSSRApolloClient({ - cache: new NextSSRInMemoryCache(), + return new ApolloClient({ + cache: new InMemoryCache(), link: typeof window === "undefined" ? ApolloLink.from([ diff --git a/examples/polls-demo/app/cc/poll-cc.tsx b/examples/polls-demo/app/cc/poll-cc.tsx index 73f6a4bc..d5897e4a 100644 --- a/examples/polls-demo/app/cc/poll-cc.tsx +++ b/examples/polls-demo/app/cc/poll-cc.tsx @@ -1,10 +1,6 @@ "use client"; import { Suspense } from "react"; -import { - useReadQuery, - useBackgroundQuery, -} from "@apollo/experimental-nextjs-app-support/ssr"; -import { useMutation } from "@apollo/client"; +import { useReadQuery, useBackgroundQuery, useMutation } from "@apollo/client"; import { QueryReference } from "@apollo/client/react"; import { Poll as PollInner } from "@/components/poll"; diff --git a/integration-test/jest/src/App.jsx b/integration-test/jest/src/App.jsx index 94e116a4..390f473b 100644 --- a/integration-test/jest/src/App.jsx +++ b/integration-test/jest/src/App.jsx @@ -1,12 +1,16 @@ import { Suspense, useState } from "react"; import { ApolloNextAppProvider, - NextSSRApolloClient, - NextSSRInMemoryCache, - useSuspenseQuery, -} from "@apollo/experimental-nextjs-app-support/ssr"; + ApolloClient, + InMemoryCache, +} from "@apollo/experimental-nextjs-app-support"; import { SchemaLink } from "@apollo/client/link/schema/index.js"; -import { gql, ApolloLink, Observable } from "@apollo/client/index.js"; +import { + useSuspenseQuery, + gql, + ApolloLink, + Observable, +} from "@apollo/client/index.js"; import { schema } from "./schema"; const delayLink = new ApolloLink((operation, forward) => { @@ -22,8 +26,8 @@ const delayLink = new ApolloLink((operation, forward) => { }); export const makeClient = () => { - return new NextSSRApolloClient({ - cache: new NextSSRInMemoryCache(), + return new ApolloClient({ + cache: new InMemoryCache(), link: delayLink.concat(new SchemaLink({ schema })), }); }; diff --git a/integration-test/jest/src/App.test.jsx b/integration-test/jest/src/App.test.jsx index c99bb53e..fd73b4bf 100644 --- a/integration-test/jest/src/App.test.jsx +++ b/integration-test/jest/src/App.test.jsx @@ -3,9 +3,9 @@ import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import App from "./App"; -import { resetNextSSRApolloSingletons } from "@apollo/experimental-nextjs-app-support/ssr"; +import { resetApolloClientSingletons } from "@apollo/experimental-nextjs-app-support"; -afterEach(resetNextSSRApolloSingletons); +afterEach(resetApolloClientSingletons); test("loads data", async () => { render(); diff --git a/integration-test/jest/src/hooks.test.jsx b/integration-test/jest/src/hooks.test.jsx index c16a8f98..11947f5a 100644 --- a/integration-test/jest/src/hooks.test.jsx +++ b/integration-test/jest/src/hooks.test.jsx @@ -4,11 +4,11 @@ import "@testing-library/jest-dom"; import { makeClient, QUERY } from "./App"; import { ApolloNextAppProvider, - NextSSRApolloClient, - useQuery, - resetNextSSRApolloSingletons, -} from "@apollo/experimental-nextjs-app-support/ssr"; + ApolloClient, + resetApolloClientSingletons, +} from "@apollo/experimental-nextjs-app-support"; import { Suspense } from "react"; +import { useQuery } from "@apollo/client"; const wrapper = ({ children }) => ( @@ -16,7 +16,7 @@ const wrapper = ({ children }) => ( ); -afterEach(resetNextSSRApolloSingletons); +afterEach(resetApolloClientSingletons); /** * We test that jest is using the "browser" build. @@ -25,7 +25,7 @@ afterEach(resetNextSSRApolloSingletons); */ test("uses the browser build", () => { let foundPrototype = false; - let proto = NextSSRApolloClient; + let proto = ApolloClient; while (proto) { if (proto.name === "ApolloClientBrowserImpl") { foundPrototype = true; @@ -66,11 +66,11 @@ test("will set up the data transport", () => { expect(globalThis[Symbol.for("ApolloClientSingleton")]).toBeDefined(); }); -test("resetNextSSRApolloSingletons tears down global singletons", () => { +test("resetApolloClientSingletons tears down global singletons", () => { render(<>, { wrapper }); // wrappers are now set up, see last test // usually, we do this in `afterEach` - resetNextSSRApolloSingletons(); + resetApolloClientSingletons(); expect(globalThis[Symbol.for("ApolloSSRDataTransport")]).not.toBeDefined(); expect(globalThis[Symbol.for("ApolloClientSingleton")]).not.toBeDefined(); }); diff --git a/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx b/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx index f3022dcd..76f61b17 100644 --- a/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx +++ b/integration-test/nextjs/src/app/cc/ApolloWrapper.tsx @@ -3,9 +3,9 @@ import React from "react"; import { HttpLink } from "@apollo/client"; import { ApolloNextAppProvider, - NextSSRInMemoryCache, - NextSSRApolloClient, -} from "@apollo/experimental-nextjs-app-support/ssr"; + InMemoryCache, + ApolloClient, +} from "@apollo/experimental-nextjs-app-support"; import { SchemaLink } from "@apollo/client/link/schema"; @@ -42,8 +42,8 @@ export function ApolloWrapper({ uri: "/graphql", }); - return new NextSSRApolloClient({ - cache: new NextSSRInMemoryCache(), + return new ApolloClient({ + cache: new InMemoryCache(), link: delayLink .concat(errorLink) .concat( 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 ea1d336e..74f8ed5d 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQuery/page.tsx +++ b/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQuery/page.tsx @@ -1,11 +1,12 @@ "use client"; +import type { TypedDocumentNode } from "@apollo/client"; import { useBackgroundQuery, useReadQuery, -} from "@apollo/experimental-nextjs-app-support/ssr"; -import type { TypedDocumentNode } from "@apollo/client"; -import { gql, QueryReference } from "@apollo/client"; + gql, + QueryReference, +} from "@apollo/client"; import { Suspense } from "react"; interface Data { diff --git a/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery/page.tsx b/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery/page.tsx index 76c4326e..a2cbb020 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery/page.tsx +++ b/integration-test/nextjs/src/app/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery/page.tsx @@ -1,9 +1,6 @@ "use client"; -import { - useBackgroundQuery, - useReadQuery, -} from "@apollo/experimental-nextjs-app-support/ssr"; +import { useBackgroundQuery, useReadQuery } from "@apollo/client"; import type { TypedDocumentNode } from "@apollo/client"; import { gql, QueryReference } from "@apollo/client"; import { Suspense, useState, useEffect } from "react"; diff --git a/integration-test/nextjs/src/app/cc/dynamic/useQuery/page.tsx b/integration-test/nextjs/src/app/cc/dynamic/useQuery/page.tsx index d3f34c52..f150e009 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/useQuery/page.tsx +++ b/integration-test/nextjs/src/app/cc/dynamic/useQuery/page.tsx @@ -1,8 +1,7 @@ "use client"; -import { useQuery } from "@apollo/experimental-nextjs-app-support/ssr"; import type { TypedDocumentNode } from "@apollo/client"; -import { gql } from "@apollo/client"; +import { useQuery, gql } from "@apollo/client"; const QUERY: TypedDocumentNode<{ products: { diff --git a/integration-test/nextjs/src/app/cc/dynamic/useQueryWithCache/page.tsx b/integration-test/nextjs/src/app/cc/dynamic/useQueryWithCache/page.tsx index 455d44f7..8df4b7ce 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/useQueryWithCache/page.tsx +++ b/integration-test/nextjs/src/app/cc/dynamic/useQueryWithCache/page.tsx @@ -1,11 +1,7 @@ "use client"; -import { - useQuery, - useSuspenseQuery, -} from "@apollo/experimental-nextjs-app-support/ssr"; import type { TypedDocumentNode } from "@apollo/client"; -import { gql } from "@apollo/client"; +import { useQuery, useSuspenseQuery, gql } from "@apollo/client"; const QUERY: TypedDocumentNode<{ products: { 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 f5e8553f..b154708f 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQuery/page.tsx +++ b/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQuery/page.tsx @@ -1,8 +1,7 @@ "use client"; -import { useSuspenseQuery } from "@apollo/experimental-nextjs-app-support/ssr"; import type { TypedDocumentNode } from "@apollo/client"; -import { gql } from "@apollo/client"; +import { useSuspenseQuery, gql } from "@apollo/client"; const QUERY: TypedDocumentNode<{ products: { diff --git a/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQueryWithError/page.tsx b/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQueryWithError/page.tsx index e1a5b5c0..0862ddad 100644 --- a/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQueryWithError/page.tsx +++ b/integration-test/nextjs/src/app/cc/dynamic/useSuspenseQueryWithError/page.tsx @@ -1,10 +1,9 @@ "use client"; -import { useSuspenseQuery } from "@apollo/experimental-nextjs-app-support/ssr"; import type { TypedDocumentNode } from "@apollo/client"; -import { gql } from "@apollo/client"; +import { useSuspenseQuery, gql } from "@apollo/client"; import { ErrorBoundary, FallbackProps } from "react-error-boundary"; -import { Suspense, startTransition, useState, useTransition } from "react"; +import { Suspense } from "react"; const QUERY: TypedDocumentNode<{ products: { diff --git a/integration-test/nextjs/src/app/cc/static/useBackgroundQueryWithoutSsrReadQuery/page.tsx b/integration-test/nextjs/src/app/cc/static/useBackgroundQueryWithoutSsrReadQuery/page.tsx index 5ec41b4e..18a46960 100644 --- a/integration-test/nextjs/src/app/cc/static/useBackgroundQueryWithoutSsrReadQuery/page.tsx +++ b/integration-test/nextjs/src/app/cc/static/useBackgroundQueryWithoutSsrReadQuery/page.tsx @@ -1,11 +1,12 @@ "use client"; +import type { TypedDocumentNode } from "@apollo/client"; import { useBackgroundQuery, useReadQuery, -} from "@apollo/experimental-nextjs-app-support/ssr"; -import type { TypedDocumentNode } from "@apollo/client"; -import { gql, QueryReference } from "@apollo/client"; + gql, + QueryReference, +} from "@apollo/client"; import { Suspense, useState, useEffect } from "react"; interface Data { diff --git a/integration-test/nextjs/src/app/cc/static/useSuspenseQuery/page.tsx b/integration-test/nextjs/src/app/cc/static/useSuspenseQuery/page.tsx index 65449c48..16f631c0 100644 --- a/integration-test/nextjs/src/app/cc/static/useSuspenseQuery/page.tsx +++ b/integration-test/nextjs/src/app/cc/static/useSuspenseQuery/page.tsx @@ -1,8 +1,7 @@ "use client"; -import { useSuspenseQuery } from "@apollo/experimental-nextjs-app-support/ssr"; import type { TypedDocumentNode } from "@apollo/client"; -import { gql, useApolloClient } from "@apollo/client"; +import { gql, useSuspenseQuery } from "@apollo/client"; const QUERY: TypedDocumentNode<{ products: { diff --git a/integration-test/vitest/src/App.jsx b/integration-test/vitest/src/App.jsx index 12ccf395..2f5813b1 100644 --- a/integration-test/vitest/src/App.jsx +++ b/integration-test/vitest/src/App.jsx @@ -1,12 +1,16 @@ import { Suspense, useState } from "react"; import { ApolloNextAppProvider, - NextSSRApolloClient, - NextSSRInMemoryCache, - useSuspenseQuery, -} from "@apollo/experimental-nextjs-app-support/ssr"; + ApolloClient, + InMemoryCache, +} from "@apollo/experimental-nextjs-app-support"; import { SchemaLink } from "@apollo/client/link/schema/index.js"; -import { gql, ApolloLink, Observable } from "@apollo/client/index.js"; +import { + useSuspenseQuery, + gql, + ApolloLink, + Observable, +} from "@apollo/client/index.js"; import { schema } from "./schema"; const delayLink = new ApolloLink((operation, forward) => { @@ -22,8 +26,8 @@ const delayLink = new ApolloLink((operation, forward) => { }); export const makeClient = () => { - return new NextSSRApolloClient({ - cache: new NextSSRInMemoryCache(), + return new ApolloClient({ + cache: new InMemoryCache(), link: delayLink.concat(new SchemaLink({ schema })), }); }; diff --git a/integration-test/vitest/src/App.test.jsx b/integration-test/vitest/src/App.test.jsx index 68142a18..e42304f1 100644 --- a/integration-test/vitest/src/App.test.jsx +++ b/integration-test/vitest/src/App.test.jsx @@ -2,9 +2,9 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import App from "./App"; -import { resetNextSSRApolloSingletons } from "@apollo/experimental-nextjs-app-support/ssr"; +import { resetApolloClientSingletons } from "@apollo/experimental-nextjs-app-support"; -afterEach(resetNextSSRApolloSingletons); +afterEach(resetApolloClientSingletons); test("loads data", async () => { render(); diff --git a/integration-test/vitest/src/hooks.test.jsx b/integration-test/vitest/src/hooks.test.jsx index c16a8f98..6582106d 100644 --- a/integration-test/vitest/src/hooks.test.jsx +++ b/integration-test/vitest/src/hooks.test.jsx @@ -4,11 +4,11 @@ import "@testing-library/jest-dom"; import { makeClient, QUERY } from "./App"; import { ApolloNextAppProvider, - NextSSRApolloClient, - useQuery, - resetNextSSRApolloSingletons, -} from "@apollo/experimental-nextjs-app-support/ssr"; + ApolloClient, + resetApolloClientSingletons, +} from "@apollo/experimental-nextjs-app-support"; import { Suspense } from "react"; +import { useQuery } from "@apollo/client"; const wrapper = ({ children }) => ( @@ -16,7 +16,7 @@ const wrapper = ({ children }) => ( ); -afterEach(resetNextSSRApolloSingletons); +afterEach(resetApolloClientSingletons); /** * We test that jest is using the "browser" build. @@ -25,7 +25,7 @@ afterEach(resetNextSSRApolloSingletons); */ test("uses the browser build", () => { let foundPrototype = false; - let proto = NextSSRApolloClient; + let proto = ApolloClient; while (proto) { if (proto.name === "ApolloClientBrowserImpl") { foundPrototype = true; diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx index 6779f90d..735db7b7 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx @@ -361,7 +361,7 @@ const ApolloClientImplementation = : ApolloClientBase; /** - * A version of `ApolloClient` to be used with streaming SSR. + * A version of `ApolloClient` to be used with streaming SSR or in React Server Components. * * For more documentation, please see {@link https://www.apollographql.com/docs/react/api/core/ApolloClient | the Apollo Client API documentation}. * diff --git a/packages/experimental-nextjs-app-support/README.md b/packages/experimental-nextjs-app-support/README.md index 24d4756c..1eadf21a 100644 --- a/packages/experimental-nextjs-app-support/README.md +++ b/packages/experimental-nextjs-app-support/README.md @@ -50,8 +50,12 @@ npm install @apollo/client@latest @apollo/experimental-nextjs-app-support Create an `ApolloClient.js` file: ```js -import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; -import { registerApolloClient } from "@apollo/experimental-nextjs-app-support/rsc"; +import { HttpLink } from "@apollo/client"; +import { + registerApolloClient, + ApolloClient, + InMemoryCache, +} from "@apollo/experimental-nextjs-app-support"; export const { getClient } = registerApolloClient(() => { return new ApolloClient({ @@ -86,10 +90,10 @@ First, create a new file `app/ApolloWrapper.jsx`: import { ApolloLink, HttpLink } from "@apollo/client"; import { ApolloNextAppProvider, - NextSSRInMemoryCache, - NextSSRApolloClient, + ApolloClient, + InMemoryCache, SSRMultipartLink, -} from "@apollo/experimental-nextjs-app-support/ssr"; +} from "@apollo/experimental-nextjs-app-support"; // have a function to create a client for you function makeClient() { @@ -105,21 +109,11 @@ function makeClient() { // const { data } = useSuspenseQuery(MY_QUERY, { context: { fetchOptions: { cache: "force-cache" }}}); }); - return new NextSSRApolloClient({ - // use the `NextSSRInMemoryCache`, not the normal `InMemoryCache` - cache: new NextSSRInMemoryCache(), - link: - typeof window === "undefined" - ? ApolloLink.from([ - // in a SSR environment, if you use multipart features like - // @defer, you need to decide how to handle these. - // This strips all interfaces with a `@defer` directive from your queries. - new SSRMultipartLink({ - stripDefer: true, - }), - httpLink, - ]) - : httpLink, + // use the `ApolloClient` from "@apollo/experimental-nextjs-app-support" + return new ApolloClient({ + // use the `InMemoryCache` from "@apollo/experimental-nextjs-app-support" + cache: new InMemoryCache(), + link: httpLink, }); } @@ -164,12 +158,12 @@ If you want to make the most of the streaming SSR features offered by React & th This package uses some singleton instances on the Browser side - if you are writing tests, you must reset them between tests. -For that, you can use the `resetNextSSRApolloSingletons` helper: +For that, you can use the `resetApolloClientSingletons ` helper: ```ts -import { resetNextSSRApolloSingletons } from "@apollo/experimental-nextjs-app-support/ssr"; +import { resetApolloClientSingletons } from "@apollo/experimental-nextjs-app-support"; -afterEach(resetNextSSRApolloSingletons); +afterEach(resetApolloClientSingletons); ``` ## Handling Multipart responses in SSR diff --git a/packages/experimental-nextjs-app-support/api-extractor.d.ts b/packages/experimental-nextjs-app-support/api-extractor.d.ts index 28f02f54..d8c9ad8a 100644 --- a/packages/experimental-nextjs-app-support/api-extractor.d.ts +++ b/packages/experimental-nextjs-app-support/api-extractor.d.ts @@ -2,5 +2,4 @@ * @packageDocumentation */ -export * from "./dist/rsc/index.d.ts"; -export * from "./dist/ssr/index.ssr.d.ts"; +export * from "./dist/combined.d.ts"; diff --git a/packages/experimental-nextjs-app-support/package-shape.json b/packages/experimental-nextjs-app-support/package-shape.json index 0e979872..1347707c 100644 --- a/packages/experimental-nextjs-app-support/package-shape.json +++ b/packages/experimental-nextjs-app-support/package-shape.json @@ -1,4 +1,45 @@ { + "@apollo/experimental-nextjs-app-support": { + "react-server": [ + "registerApolloClient", + "DebounceMultipartResponsesLink", + "RemoveMultipartDirectivesLink", + "SSRMultipartLink", + "ApolloClient", + "InMemoryCache", + "built_for_rsc" + ], + "browser": [ + "ApolloNextAppProvider", + "DebounceMultipartResponsesLink", + "ApolloClient", + "InMemoryCache", + "RemoveMultipartDirectivesLink", + "SSRMultipartLink", + "resetApolloClientSingletons", + "built_for_browser" + ], + "node": [ + "ApolloNextAppProvider", + "DebounceMultipartResponsesLink", + "ApolloClient", + "InMemoryCache", + "RemoveMultipartDirectivesLink", + "SSRMultipartLink", + "resetApolloClientSingletons", + "built_for_ssr" + ], + "edge-light,worker,browser": [ + "ApolloNextAppProvider", + "DebounceMultipartResponsesLink", + "ApolloClient", + "InMemoryCache", + "RemoveMultipartDirectivesLink", + "SSRMultipartLink", + "resetApolloClientSingletons", + "built_for_ssr" + ] + }, "@apollo/experimental-nextjs-app-support/ssr": { "react-server": [ "DebounceMultipartResponsesLink", diff --git a/packages/experimental-nextjs-app-support/package.json b/packages/experimental-nextjs-app-support/package.json index 0ef4a51d..092904d6 100644 --- a/packages/experimental-nextjs-app-support/package.json +++ b/packages/experimental-nextjs-app-support/package.json @@ -16,6 +16,22 @@ ], "type": "module", "exports": { + ".": { + "require": { + "types": "./dist/combined.d.cts", + "react-server": "./dist/index.rsc.cjs", + "edge-light": "./dist/index.ssr.cjs", + "browser": "./dist/index.browser.cjs", + "node": "./dist/index.ssr.cjs" + }, + "import": { + "types": "./dist/combined.d.ts", + "react-server": "./dist/index.rsc.js", + "edge-light": "./dist/index.ssr.js", + "browser": "./dist/index.browser.js", + "node": "./dist/index.ssr.js" + } + }, "./rsc": { "require": { "types": "./dist/rsc/index.d.cts", @@ -56,7 +72,7 @@ ] } }, - "typings": "./dist/empty.d.ts", + "typings": "./dist/combined.d.ts", "author": "packages@apollographql.com", "license": "MIT", "files": [ diff --git a/packages/experimental-nextjs-app-support/src/ApolloNextAppProvider.ts b/packages/experimental-nextjs-app-support/src/ApolloNextAppProvider.ts index 1679ed87..b1d3c9c1 100644 --- a/packages/experimental-nextjs-app-support/src/ApolloNextAppProvider.ts +++ b/packages/experimental-nextjs-app-support/src/ApolloNextAppProvider.ts @@ -12,20 +12,24 @@ import { bundle } from "./bundleInfo.js"; * As opposed to the normal `ApolloProvider`, this version does not require a `client` prop, * but requires a `makeClient` prop instead. * - * Use this component together with `NextSSRApolloClient` and `NextSSRInMemoryCache` + * Use this component together with `ApolloClient` and `InMemoryCache` + * from the "@apollo/experimental-nextjs-app-support" package * to make an ApolloClient instance available to your Client Component hooks in the * Next.js App Router. * * @example * `app/ApolloWrapper.jsx` * ```tsx + * import { HttpLink } from "@apollo/client"; + * import { ApolloNextAppProvider, ApolloClient, InMemoryCache } from "@apollo/experimental-nextjs-app-support"; + * * function makeClient() { * const httpLink = new HttpLink({ * uri: "https://example.com/api/graphql", * }); * - * return new NextSSRApolloClient({ - * cache: new NextSSRInMemoryCache(), + * return new ApolloClient({ + * cache: new InMemoryCache(), * link: httpLink, * }); * } diff --git a/packages/experimental-nextjs-app-support/src/bundleInfo.ts b/packages/experimental-nextjs-app-support/src/bundleInfo.ts index 58fc2bdf..5f44d9e2 100644 --- a/packages/experimental-nextjs-app-support/src/bundleInfo.ts +++ b/packages/experimental-nextjs-app-support/src/bundleInfo.ts @@ -1,5 +1,5 @@ export const bundle = { - pkg: "@apollo/experimental-nextjs-app-support/ssr", - client: "NextSSRApolloClient", - cache: "NextSSRInMemoryCache", + pkg: "@apollo/experimental-nextjs-app-support", + client: "ApolloClient", + cache: "InMemoryCache", }; diff --git a/packages/experimental-nextjs-app-support/src/combined.ts b/packages/experimental-nextjs-app-support/src/combined.ts new file mode 100644 index 00000000..086e6ca7 --- /dev/null +++ b/packages/experimental-nextjs-app-support/src/combined.ts @@ -0,0 +1,17 @@ +/** + * TypeScript does not have the concept of these environments, + * so we need to create a single entry point that combines all + * possible exports. + * That means that users will be offered "RSC" exports in a + * "SSR/Browser" code file, but those will error in a compilation + * step. + * + * This is a limitation of TypeScript, and we can't do anything + * about it. + * + * The build process will only create `.d.ts`/`d.cts` files from + * this, and not actual runtime code. + */ + +export * from "./index.rsc.js"; +export * from "./index.js"; diff --git a/packages/experimental-nextjs-app-support/src/index.rsc.ts b/packages/experimental-nextjs-app-support/src/index.rsc.ts new file mode 100644 index 00000000..a7649d95 --- /dev/null +++ b/packages/experimental-nextjs-app-support/src/index.rsc.ts @@ -0,0 +1,2 @@ +export * from "./index.shared.js"; +export { registerApolloClient } from "@apollo/client-react-streaming"; diff --git a/packages/experimental-nextjs-app-support/src/index.shared.ts b/packages/experimental-nextjs-app-support/src/index.shared.ts new file mode 100644 index 00000000..9f55f932 --- /dev/null +++ b/packages/experimental-nextjs-app-support/src/index.shared.ts @@ -0,0 +1,27 @@ +export { + SSRMultipartLink, + DebounceMultipartResponsesLink, + RemoveMultipartDirectivesLink, + InMemoryCache, + type TransportedQueryRef, +} from "@apollo/client-react-streaming"; +import { bundle } from "./bundleInfo.js"; +import { ApolloClient as UpstreamApolloClient } from "@apollo/client-react-streaming"; + +/** + * A version of `ApolloClient` to be used with streaming SSR or in React Server Components. + * + * For more documentation, please see {@link https://www.apollographql.com/docs/react/api/core/ApolloClient | the Apollo Client API documentation}. + * + * @public + */ +export class ApolloClient< + TCacheShape, +> extends UpstreamApolloClient { + /** + * Information about the current package and it's export names, for use in error messages. + * + * @internal + */ + static readonly info = bundle; +} diff --git a/packages/experimental-nextjs-app-support/src/index.ts b/packages/experimental-nextjs-app-support/src/index.ts new file mode 100644 index 00000000..bfb5f80a --- /dev/null +++ b/packages/experimental-nextjs-app-support/src/index.ts @@ -0,0 +1,16 @@ +export * from "./index.shared.js"; +export { ApolloNextAppProvider } from "./ApolloNextAppProvider.js"; +import { resetManualSSRApolloSingletons } from "@apollo/client-react-streaming/manual-transport"; +/** + * > This export is only available in React Client Components + * + * Resets the singleton instances created for the Apollo SSR data transport and caches. + * + * To be used in testing only, like + * ```ts + * afterEach(resetApolloClientSingletons); + * ``` + * + * @public + */ +export const resetApolloClientSingletons = resetManualSSRApolloSingletons; diff --git a/packages/experimental-nextjs-app-support/src/rsc/index.ts b/packages/experimental-nextjs-app-support/src/rsc/index.ts index acd8769e..5d684d3b 100644 --- a/packages/experimental-nextjs-app-support/src/rsc/index.ts +++ b/packages/experimental-nextjs-app-support/src/rsc/index.ts @@ -1,4 +1,4 @@ export { registerApolloClient, type TransportedQueryRef, -} from "@apollo/client-react-streaming"; +} from "@apollo/experimental-nextjs-app-support"; diff --git a/packages/experimental-nextjs-app-support/src/ssr/index.ts b/packages/experimental-nextjs-app-support/src/ssr/index.ts index 49f8da9d..c1c625d2 100644 --- a/packages/experimental-nextjs-app-support/src/ssr/index.ts +++ b/packages/experimental-nextjs-app-support/src/ssr/index.ts @@ -1,14 +1,13 @@ -export { ApolloNextAppProvider } from "../ApolloNextAppProvider.js"; -export { resetManualSSRApolloSingletons as resetNextSSRApolloSingletons } from "@apollo/client-react-streaming/manual-transport"; -import { ApolloClient } from "@apollo/client-react-streaming"; -import { bundle } from "../bundleInfo.js"; export { InMemoryCache as NextSSRInMemoryCache, + ApolloClient as NextSSRApolloClient, SSRMultipartLink, DebounceMultipartResponsesLink, RemoveMultipartDirectivesLink, + ApolloNextAppProvider, + resetApolloClientSingletons as resetNextSSRApolloSingletons, type TransportedQueryRef, -} from "@apollo/client-react-streaming"; +} from "@apollo/experimental-nextjs-app-support"; export { useBackgroundQuery, useFragment, @@ -16,20 +15,3 @@ export { useReadQuery, useSuspenseQuery, } from "@apollo/client/index.js"; -/** - * A version of `ApolloClient` to be used with streaming SSR. - * - * For more documentation, please see {@link https://www.apollographql.com/docs/react/api/core/ApolloClient | the Apollo Client API documentation}. - * - * @public - */ -export class NextSSRApolloClient< - TCacheShape, -> extends ApolloClient { - /** - * Information about the current package and it's export names, for use in error messages. - * - * @internal - */ - static readonly info = bundle; -} diff --git a/packages/experimental-nextjs-app-support/tsconfig.json b/packages/experimental-nextjs-app-support/tsconfig.json index 82531828..a29b456c 100644 --- a/packages/experimental-nextjs-app-support/tsconfig.json +++ b/packages/experimental-nextjs-app-support/tsconfig.json @@ -11,7 +11,10 @@ "jsx": "react", "declarationMap": true, "types": ["react/canary", "node"], - "esModuleInterop": true + "esModuleInterop": true, + "paths": { + "@apollo/experimental-nextjs-app-support": ["./src/combined.ts"] + } }, "include": ["src"] } diff --git a/packages/experimental-nextjs-app-support/tsup.config.ts b/packages/experimental-nextjs-app-support/tsup.config.ts index 404e5e14..a6d5efff 100644 --- a/packages/experimental-nextjs-app-support/tsup.config.ts +++ b/packages/experimental-nextjs-app-support/tsup.config.ts @@ -14,6 +14,7 @@ export default defineConfig((options) => { external: [ "@apollo/client-react-streaming", "@apollo/client-react-streaming/manual-transport", + "@apollo/experimental-nextjs-app-support", "react", "rehackt", ], @@ -50,7 +51,14 @@ export default defineConfig((options) => { } return [ + { + ...entry("other", "src/combined.ts", "combined"), + dts: { only: true }, + }, entry("other", "src/empty.ts", "empty"), + entry("rsc", "src/index.rsc.ts", "index.rsc"), + entry("ssr", "src/index.ts", "index.ssr"), + entry("browser", "src/index.ts", "index.browser"), entry("rsc", "src/rsc/index.ts", "rsc/index"), entry("rsc", "src/ssr/index.rsc.ts", "ssr/index.rsc"), entry("ssr", "src/ssr/index.ts", "ssr/index.ssr"), From 5757a9c2e1c89d51186f17ae6ec727e6b2110d6b Mon Sep 17 00:00:00 2001 From: Nick Muller <3781551+nphmuller@users.noreply.github.com> Date: Tue, 28 May 2024 14:53:38 +0200 Subject: [PATCH 07/11] Add next@15.0.0-rc.0 as possible `peerDependency` (#304) * Support next@15.0.0-rc.0 * adjust lockfiles --------- Co-authored-by: Lenz Weber-Tronic --- integration-test/yarn.lock | 2 +- packages/experimental-nextjs-app-support/package.json | 2 +- yarn.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/integration-test/yarn.lock b/integration-test/yarn.lock index 5dd3b772..c5bde46a 100644 --- a/integration-test/yarn.lock +++ b/integration-test/yarn.lock @@ -87,7 +87,7 @@ __metadata: "@apollo/client-react-streaming": "npm:0.10.1" peerDependencies: "@apollo/client": ^3.10.4 - next: ^13.4.1 || ^14.0.0 + next: ^13.4.1 || ^14.0.0 || 15.0.0-rc.0 react: ^18 checksum: 10/505b723bac0f3a7f15287ea32fab9f2e8c0cd567149abf11d750855f8a9bfc0aa26e44179ad10c32f7d162ad86318717032413ef8e1a25385185178e022588fa languageName: node diff --git a/packages/experimental-nextjs-app-support/package.json b/packages/experimental-nextjs-app-support/package.json index 092904d6..717d60e5 100644 --- a/packages/experimental-nextjs-app-support/package.json +++ b/packages/experimental-nextjs-app-support/package.json @@ -127,7 +127,7 @@ }, "peerDependencies": { "@apollo/client": "^3.10.4", - "next": "^13.4.1 || ^14.0.0", + "next": "^13.4.1 || ^14.0.0 || 15.0.0-rc.0", "react": "^18" }, "dependencies": { diff --git a/yarn.lock b/yarn.lock index 04a94fd4..131d218f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -157,7 +157,7 @@ __metadata: vitest: "npm:1.6.0" peerDependencies: "@apollo/client": ^3.10.4 - next: ^13.4.1 || ^14.0.0 + next: ^13.4.1 || ^14.0.0 || 15.0.0-rc.0 react: ^18 languageName: unknown linkType: soft From 5cc6125268c1ab2b21764693cab2fa1c3ee5d951 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 29 May 2024 10:28:06 +0200 Subject: [PATCH 08/11] Use a symbol property instead of instanceOf to check for correct ApolloClient/InMemoryCache (#302) * Use a symbol property instead of instanceOf * fix up test * extract assertInstance function * Update packages/client-react-streaming/src/importErrors.test.tsx Co-authored-by: Jerel Miller --------- Co-authored-by: Jerel Miller --- packages/client-react-streaming/package.json | 1 + .../WrapApolloProvider.tsx | 23 +-- .../WrappedApolloClient.test.tsx | 4 +- .../WrappedApolloClient.tsx | 73 +++---- .../WrappedInMemoryCache.tsx | 17 +- .../ManualDataTransport/serialization.test.ts | 2 +- .../src/assertInstance.ts | 14 ++ .../client-react-streaming/src/bundleInfo.ts | 4 +- .../src/importErrors.test.tsx | 164 +++++++++------ .../src/registerApolloClient.test.tsx | 2 +- .../tsconfig.tests.json | 15 +- .../package.json | 23 ++- .../src/importErrors.test.tsx | 186 ++++++++++++++++++ .../src/index.shared.ts | 22 ++- .../tsconfig.tests.json | 8 + packages/test-utils/console.d.ts | 3 + packages/test-utils/console.js | 9 + packages/test-utils/hydrationTest.d.ts | 18 ++ .../hydrationTest.js} | 20 +- packages/test-utils/package.json | 7 + packages/test-utils/react.d.ts | 14 ++ packages/test-utils/react.js | 34 ++++ packages/test-utils/runInConditions.d.ts | 15 ++ .../runInConditions.js} | 19 +- packages/test-utils/tsconfig.json | 11 ++ yarn.lock | 47 ++--- 26 files changed, 591 insertions(+), 164 deletions(-) create mode 100644 packages/client-react-streaming/src/assertInstance.ts create mode 100644 packages/experimental-nextjs-app-support/src/importErrors.test.tsx create mode 100644 packages/experimental-nextjs-app-support/tsconfig.tests.json create mode 100644 packages/test-utils/console.d.ts create mode 100644 packages/test-utils/console.js create mode 100644 packages/test-utils/hydrationTest.d.ts rename packages/{client-react-streaming/src/util/hydrationTest.ts => test-utils/hydrationTest.js} (80%) create mode 100644 packages/test-utils/package.json create mode 100644 packages/test-utils/react.d.ts create mode 100644 packages/test-utils/react.js create mode 100644 packages/test-utils/runInConditions.d.ts rename packages/{client-react-streaming/src/util/runInConditions.ts => test-utils/runInConditions.js} (73%) create mode 100644 packages/test-utils/tsconfig.json diff --git a/packages/client-react-streaming/package.json b/packages/client-react-streaming/package.json index df318691..10eb866a 100644 --- a/packages/client-react-streaming/package.json +++ b/packages/client-react-streaming/package.json @@ -133,6 +133,7 @@ "devDependencies": { "@apollo/client": "^3.10.4", "@arethetypeswrong/cli": "0.15.3", + "@internal/test-utils": "workspace:^", "@microsoft/api-extractor": "7.43.2", "@testing-library/react": "15.0.7", "@total-typescript/shoehorn": "0.1.2", diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx index a48a052d..23cb9c58 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrapApolloProvider.tsx @@ -1,11 +1,12 @@ "use client"; import React from "react"; import { useRef } from "react"; -import { ApolloClient } from "./WrappedApolloClient.js"; +import type { ApolloClient } from "./WrappedApolloClient.js"; import { ApolloProvider } from "@apollo/client/index.js"; import type { DataTransportProviderImplementation } from "./DataTransportAbstraction.js"; import { ApolloClientSingleton } from "./symbols.js"; import { bundle } from "../bundleInfo.js"; +import { assertInstance } from "../assertInstance.js"; declare global { interface Window { @@ -35,8 +36,6 @@ export interface WrappedApolloProvider { */ info: { pkg: string; - client: string; - cache: string; }; } @@ -59,18 +58,16 @@ export function WrapApolloProvider( ...extraProps }) => { const clientRef = useRef>(undefined); - - if (process.env.REACT_ENV === "ssr") { - if (!clientRef.current) { + if (!clientRef.current) { + if (process.env.REACT_ENV === "ssr") { clientRef.current = makeClient(); + } else { + clientRef.current = window[ApolloClientSingleton] ??= makeClient(); } - } else { - clientRef.current = window[ApolloClientSingleton] ??= makeClient(); - } - - if (!(clientRef.current instanceof ApolloClient)) { - throw new Error( - `When using \`ApolloClient\` in streaming SSR, you must use the \`${WrappedApolloProvider.info.client}\` export provided by \`"${WrappedApolloProvider.info.pkg}"\`.` + assertInstance( + clientRef.current, + WrappedApolloProvider.info, + "ApolloClient" ); } diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx index d1a8fd65..4dfcc850 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx @@ -1,5 +1,5 @@ import React, { Suspense, use, useMemo } from "rehackt"; -import { outsideOf } from "../util/runInConditions.js"; +import { outsideOf } from "@internal/test-utils/runInConditions.js"; import assert from "node:assert"; import test, { afterEach, describe } from "node:test"; import type { @@ -233,7 +233,7 @@ describe( { skip: outsideOf("browser") }, async () => { const { $RC, $RS, setBody, hydrateBody, appendToBody } = await import( - "../util/hydrationTest.js" + "@internal/test-utils/hydrationTest.js" ); // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint let useStaticValueRefStub = (): { current: T } => { diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx index 735db7b7..f6f9d726 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.tsx @@ -5,6 +5,7 @@ import type { WatchQueryOptions, FetchResult, DocumentNode, + NormalizedCacheObject, } from "@apollo/client/index.js"; import { ApolloClient as OrigApolloClient, @@ -15,7 +16,7 @@ import { print } from "@apollo/client/utilities/index.js"; import { canonicalStringify } from "@apollo/client/cache/index.js"; import { invariant } from "ts-invariant"; import { createBackpressuredCallback } from "./backpressuredCallback.js"; -import { InMemoryCache } from "./WrappedInMemoryCache.js"; +import type { 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"; @@ -24,12 +25,13 @@ import type { QueryEvent, TransportIdentifier, } from "./DataTransportAbstraction.js"; -import { bundle } from "../bundleInfo.js"; +import { bundle, sourceSymbol } from "../bundleInfo.js"; import { serializeOptions, deserializeOptions } from "./transportedOptions.js"; +import { assertInstance } from "../assertInstance.js"; -function getQueryManager( +function getQueryManager( client: OrigApolloClient -): QueryManager & { +): QueryManager & { [wrappers]: HookWrappers; } { return client["queryManager"]; @@ -49,8 +51,13 @@ type SimulatedQueryInfo = { options: WatchQueryOptions; }; +interface WrappedApolloClientOptions + extends Omit, "cache"> { + cache: InMemoryCache; +} + const wrappers = Symbol.for("apollo.hook.wrappers"); -class ApolloClientBase extends OrigApolloClient { +class ApolloClientBase extends OrigApolloClient { /** * Information about the current package and it's export names, for use in error messages. * @@ -58,7 +65,9 @@ class ApolloClientBase extends OrigApolloClient { */ static readonly info = bundle; - constructor(options: ApolloClientOptions) { + [sourceSymbol]: string; + + constructor(options: WrappedApolloClientOptions) { super( process.env.REACT_ENV === "rsc" || process.env.REACT_ENV === "ssr" ? { @@ -67,19 +76,19 @@ class ApolloClientBase extends OrigApolloClient { } : options ); + const info = (this.constructor as typeof ApolloClientBase).info; + this[sourceSymbol] = `${info.pkg}:ApolloClient`; - if (!(this.cache instanceof InMemoryCache)) { - throw new Error( - `When using \`InMemoryCache\` in streaming SSR, you must use the \`${(this.constructor as typeof ApolloClientBase).info.cache}\` export provided by \`"${(this.constructor as typeof ApolloClientBase).info.pkg}"\`.` - ); - } + assertInstance( + this.cache as unknown as InMemoryCache, + info, + "InMemoryCache" + ); } } -export class ApolloClientClientBaseImpl< - TCacheShape, -> extends ApolloClientBase { - constructor(options: ApolloClientOptions) { +export class ApolloClientClientBaseImpl extends ApolloClientBase { + constructor(options: WrappedApolloClientOptions) { super(options); this.onQueryStarted = this.onQueryStarted.bind(this); @@ -102,7 +111,7 @@ export class ApolloClientClientBaseImpl< const transformedDocument = this.documentTransform.transformDocument( options.query ); - const queryManager = getQueryManager(this); + 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); @@ -127,7 +136,7 @@ export class ApolloClientClientBaseImpl< const { cacheKey, cacheKeyArr } = this.identifyUniqueQuery(hydratedOptions); this.transportedQueryOptions.set(id, hydratedOptions); - const queryManager = getQueryManager(this); + const queryManager = getQueryManager(this); if ( !queryManager["inFlightLinkObservables"].peekArray(cacheKeyArr) @@ -269,9 +278,7 @@ export class ApolloClientClientBaseImpl< }; } -class ApolloClientSSRImpl< - TCacheShape, -> extends ApolloClientClientBaseImpl { +class ApolloClientSSRImpl extends ApolloClientClientBaseImpl { private forwardedQueries = new (getTrieConstructor(this))(); watchQueryQueue = createBackpressuredCallback<{ @@ -349,9 +356,7 @@ class ApolloClientSSRImpl< } } -export class ApolloClientBrowserImpl< - TCacheShape, -> extends ApolloClientClientBaseImpl {} +export class ApolloClientBrowserImpl extends ApolloClientClientBaseImpl {} const ApolloClientImplementation = /*#__PURE__*/ process.env.REACT_ENV === "ssr" @@ -367,20 +372,22 @@ const ApolloClientImplementation = * * @public */ -export class ApolloClient - extends (ApolloClientImplementation as typeof ApolloClientBase) - implements - Partial>, - Partial> +export class ApolloClient< + // this generic is obsolete as we require a `InMemoryStore`, which fixes this generic to `NormalizedCacheObject` anyways + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Ignored = NormalizedCacheObject, + > + extends (ApolloClientImplementation as typeof ApolloClientBase) + implements Partial, Partial { /** @internal */ - declare onQueryStarted?: ApolloClientBrowserImpl["onQueryStarted"]; + declare onQueryStarted?: ApolloClientBrowserImpl["onQueryStarted"]; /** @internal */ - declare onQueryProgress?: ApolloClientBrowserImpl["onQueryProgress"]; + declare onQueryProgress?: ApolloClientBrowserImpl["onQueryProgress"]; /** @internal */ - declare rerunSimulatedQueries?: ApolloClientBrowserImpl["rerunSimulatedQueries"]; + declare rerunSimulatedQueries?: ApolloClientBrowserImpl["rerunSimulatedQueries"]; /** @internal */ - declare rerunSimulatedQuery?: ApolloClientBrowserImpl["rerunSimulatedQuery"]; + declare rerunSimulatedQuery?: ApolloClientBrowserImpl["rerunSimulatedQuery"]; /** @internal */ - declare watchQueryQueue?: ApolloClientSSRImpl["watchQueryQueue"]; + declare watchQueryQueue?: ApolloClientSSRImpl["watchQueryQueue"]; } diff --git a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedInMemoryCache.tsx b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedInMemoryCache.tsx index 0792b9ed..892aa7d8 100644 --- a/packages/client-react-streaming/src/DataTransportAbstraction/WrappedInMemoryCache.tsx +++ b/packages/client-react-streaming/src/DataTransportAbstraction/WrappedInMemoryCache.tsx @@ -1,4 +1,6 @@ +import type { InMemoryCacheConfig } from "@apollo/client/index.js"; import { InMemoryCache as OrigInMemoryCache } from "@apollo/client/index.js"; +import { bundle, sourceSymbol } from "../bundleInfo.js"; /* * We just subclass `InMemoryCache` here so that `WrappedApolloClient` * can detect if it was initialized with an `InMemoryCache` instance that @@ -15,4 +17,17 @@ import { InMemoryCache as OrigInMemoryCache } from "@apollo/client/index.js"; * * @public */ -export class InMemoryCache extends OrigInMemoryCache {} +export class InMemoryCache extends OrigInMemoryCache { + /** + * Information about the current package and it's export names, for use in error messages. + * + * @internal + */ + static readonly info = bundle; + [sourceSymbol]: string; + constructor(config?: InMemoryCacheConfig | undefined) { + super(config); + const info = (this.constructor as typeof InMemoryCache).info; + this[sourceSymbol] = `${info.pkg}:InMemoryCache`; + } +} diff --git a/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts b/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts index 427f4981..e29115a7 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts +++ b/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts @@ -1,6 +1,6 @@ import test, { describe } from "node:test"; import { revive, stringify } from "./serialization.js"; -import { outsideOf } from "../util/runInConditions.js"; +import { outsideOf } from "@internal/test-utils/runInConditions.js"; import { htmlEscapeJsonString } from "./htmlescape.js"; import assert from "node:assert"; diff --git a/packages/client-react-streaming/src/assertInstance.ts b/packages/client-react-streaming/src/assertInstance.ts new file mode 100644 index 00000000..8277f41c --- /dev/null +++ b/packages/client-react-streaming/src/assertInstance.ts @@ -0,0 +1,14 @@ +import type { bundle } from "./bundleInfo.js"; +import { sourceSymbol } from "./bundleInfo.js"; + +export function assertInstance( + value: { [sourceSymbol]?: string }, + info: typeof bundle, + name: string +): void { + if (value[sourceSymbol] !== `${info.pkg}:${name}`) { + throw new Error( + `When using \`${name}\` in streaming SSR, you must use the \`${name}\` export provided by \`"${info.pkg}"\`.` + ); + } +} diff --git a/packages/client-react-streaming/src/bundleInfo.ts b/packages/client-react-streaming/src/bundleInfo.ts index 7b1c68e5..69bea952 100644 --- a/packages/client-react-streaming/src/bundleInfo.ts +++ b/packages/client-react-streaming/src/bundleInfo.ts @@ -1,5 +1,5 @@ export const bundle = { pkg: "@apollo/client-react-streaming", - client: "ApolloClient", - cache: "InMemoryCache", }; + +export const sourceSymbol = Symbol.for("apollo.source_package"); diff --git a/packages/client-react-streaming/src/importErrors.test.tsx b/packages/client-react-streaming/src/importErrors.test.tsx index 8c1a8bf2..466104f1 100644 --- a/packages/client-react-streaming/src/importErrors.test.tsx +++ b/packages/client-react-streaming/src/importErrors.test.tsx @@ -1,6 +1,8 @@ import assert from "node:assert"; import { test } from "node:test"; -import { outsideOf } from "./util/runInConditions.js"; +import { outsideOf } from "@internal/test-utils/runInConditions.js"; +import { browserEnv } from "@internal/test-utils/react.js"; +import { silenceConsoleErrors } from "@internal/test-utils/console.js"; test("Error message when `WrappedApolloClient` is instantiated with wrong `InMemoryCache`", async () => { const { ApolloClient } = await import("#bundled"); @@ -8,6 +10,7 @@ test("Error message when `WrappedApolloClient` is instantiated with wrong `InMem assert.throws( () => new ApolloClient({ + // @ts-expect-error this is what we're testing cache: new upstreamPkg.InMemoryCache(), connectToDevTools: false, }), @@ -22,81 +25,120 @@ test( "Error message when using `ManualDataTransport` with the wrong `ApolloClient`", { skip: outsideOf("node") }, async () => { - const { WrapApolloProvider } = await import("#bundled"); - const upstreamPkg = await import("@apollo/client/index.js"); - const { createElement } = await import("react"); + const { WrapApolloProvider, ...bundled } = await import("#bundled"); + const React = await import("react"); const { renderToString } = await import("react-dom/server"); - const Provider = WrapApolloProvider({} as any); + function FakeTransport({ children }: { children: any }) { + return children; + } + const Provider = WrapApolloProvider(FakeTransport); - assert.throws( - () => - renderToString( - createElement(Provider, { - makeClient: () => - // @ts-expect-error we want to test exactly this - new upstreamPkg.ApolloClient({ - cache: new upstreamPkg.InMemoryCache(), - }), - children: null, - }) - ), - { - message: - 'When using `ApolloClient` in streaming SSR, you must use the `ApolloClient` export provided by `"@apollo/client-react-streaming"`.', - } - ); + await test("@apollo/client should error", async () => { + const upstreamPkg = await import("@apollo/client/index.js"); + assert.throws( + () => + renderToString( + + // @ts-expect-error we want to test exactly this + new upstreamPkg.ApolloClient({ + cache: new upstreamPkg.InMemoryCache(), + }) + } + > + {null} + + ), + { + message: + 'When using `ApolloClient` in streaming SSR, you must use the `ApolloClient` export provided by `"@apollo/client-react-streaming"`.', + } + ); + }); + await test("this package should work", async () => { + renderToString( + + new bundled.ApolloClient({ + cache: new bundled.InMemoryCache(), + }) + } + > + {null} + + ); + }); } ); test( - "Error message when using `ManualDataTransport` with the wrong `ApolloClient`", + "Error message when using `ApolloNextAppProvider` with the wrong `ApolloClient`", { skip: outsideOf("browser") }, async () => { - const { WrapApolloProvider } = await import("#bundled"); - const upstreamPkg = await import("@apollo/client/index.js"); - const React = await import("react"); - const { createRoot } = await import("react-dom/client"); - - const jsdom = await import("global-jsdom"); - using _cleanupJSDOM = { [Symbol.dispose]: jsdom.default() }; + const { WrapApolloProvider, ...bundled } = await import("#bundled"); + const React = await import("react"); + function FakeTransport({ children }: { children: any }) { + return children; + } + const Provider = WrapApolloProvider(FakeTransport); const { ErrorBoundary } = await import("react-error-boundary"); // Even with an error Boundary, React will still log to `console.error` - we avoid the noise here. using _restoreConsole = silenceConsoleErrors(); - const Provider = WrapApolloProvider({} as any); - - const promise = new Promise((_resolve, reject) => { - createRoot(document.body).render( - }> - - // @ts-expect-error we want to test exactly this - new upstreamPkg.ApolloClient({ - cache: new upstreamPkg.InMemoryCache(), - connectToDevTools: false, - }) - } - > - {null} - - - ); + await test("@apollo/client should error", async () => { + using env = await browserEnv(); + const upstreamPkg = await import("@apollo/client/index.js"); + const promise = new Promise((_resolve, reject) => { + env.render( + document.body, + }> + + // @ts-expect-error we want to test exactly this + new upstreamPkg.ApolloClient({ + cache: new upstreamPkg.InMemoryCache(), + connectToDevTools: false, + }) + } + > + {null} + + + ); + }); + await assert.rejects(promise, { + message: + 'When using `ApolloClient` in streaming SSR, you must use the `ApolloClient` export provided by `"@apollo/client-react-streaming"`.', + }); }); - await assert.rejects(promise, { - message: - 'When using `ApolloClient` in streaming SSR, you must use the `ApolloClient` export provided by `"@apollo/client-react-streaming"`.', + + await test("this package should work", async () => { + using env = await browserEnv(); + const promise = new Promise((resolve, reject) => { + function Child() { + resolve(); + return null; + } + env.render( + document.body, + }> + + new bundled.ApolloClient({ + cache: new bundled.InMemoryCache(), + connectToDevTools: false, + }) + } + > + {} + + + ); + }); + // correct usage, should not throw + await promise; }); } ); - -function silenceConsoleErrors() { - const { error } = console; - console.error = () => {}; - return { - [Symbol.dispose]() { - console.error = error; - }, - }; -} diff --git a/packages/client-react-streaming/src/registerApolloClient.test.tsx b/packages/client-react-streaming/src/registerApolloClient.test.tsx index 5506040a..bf29c057 100644 --- a/packages/client-react-streaming/src/registerApolloClient.test.tsx +++ b/packages/client-react-streaming/src/registerApolloClient.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable no-inner-declarations */ import { it } from "node:test"; import assert from "node:assert"; -import { runInConditions } from "./util/runInConditions.js"; +import { runInConditions } from "@internal/test-utils/runInConditions.js"; import { Writable } from "node:stream"; runInConditions("react-server"); diff --git a/packages/client-react-streaming/tsconfig.tests.json b/packages/client-react-streaming/tsconfig.tests.json index d809d93d..b3edf69b 100644 --- a/packages/client-react-streaming/tsconfig.tests.json +++ b/packages/client-react-streaming/tsconfig.tests.json @@ -1,17 +1,8 @@ { - "extends": "@tsconfig/recommended/tsconfig.json", + "extends": "./tsconfig.json", "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", - "target": "esnext", - "rootDir": "src", - "outDir": "dist", - "declaration": true, - "sourceMap": true, - "jsx": "react", - "declarationMap": true, - "types": ["react/canary", "node"], - "esModuleInterop": false + "esModuleInterop": false, + "paths": {} }, "include": ["src"] } diff --git a/packages/experimental-nextjs-app-support/package.json b/packages/experimental-nextjs-app-support/package.json index 717d60e5..0165ccba 100644 --- a/packages/experimental-nextjs-app-support/package.json +++ b/packages/experimental-nextjs-app-support/package.json @@ -15,6 +15,22 @@ "app" ], "type": "module", + "imports": { + "#bundled": { + "require": { + "types": "./dist/combined.d.cts", + "react-server": "./dist/index.rsc.cjs", + "browser": "./dist/index.browser.cjs", + "node": "./dist/index.ssr.cjs" + }, + "import": { + "types": "./dist/combined.d.ts", + "react-server": "./dist/index.rsc.js", + "browser": "./dist/index.browser.js", + "node": "./dist/index.ssr.js" + } + } + }, "exports": { ".": { "require": { @@ -83,7 +99,11 @@ ], "scripts": { "build": "rimraf dist; tsup", - "test": "true", + "test": "concurrently -c auto \"yarn:test:*(!base) $@\"", + "test:base": "TSX_TSCONFIG_PATH=./tsconfig.tests.json node --import tsx/esm --no-warnings --test \"$@\" src/**/*.test.(ts|tsx)", + "test:ssr": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --conditions=node\" yarn run test:base", + "test:browser": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --conditions=browser\" yarn run test:base", + "test:rsc": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --conditions=react-server\" yarn run test:base", "prepack": "yarn build", "prepublishOnly": "yarn pack -o attw.tgz && attw attw.tgz && rm attw.tgz && yarn run test", "test-bundle": "yarn test-bundle:attw && yarn test-bundle:package && yarn test-bundle:publint && yarn test-bundle:shape", @@ -98,6 +118,7 @@ "@apollo/client": "3.10.4", "@apollo/client-react-streaming": "workspace:*", "@arethetypeswrong/cli": "0.15.3", + "@internal/test-utils": "workspace:^", "@microsoft/api-extractor": "7.43.2", "@testing-library/react": "15.0.7", "@total-typescript/shoehorn": "0.1.2", diff --git a/packages/experimental-nextjs-app-support/src/importErrors.test.tsx b/packages/experimental-nextjs-app-support/src/importErrors.test.tsx new file mode 100644 index 00000000..b0605cb6 --- /dev/null +++ b/packages/experimental-nextjs-app-support/src/importErrors.test.tsx @@ -0,0 +1,186 @@ +import * as assert from "node:assert"; +import { test } from "node:test"; +import { outsideOf } from "@internal/test-utils/runInConditions.js"; +import { browserEnv } from "@internal/test-utils/react.js"; +import { silenceConsoleErrors } from "@internal/test-utils/console.js"; + +test("Error message when `WrappedApolloClient` is instantiated with wrong `InMemoryCache`", async () => { + const { ApolloClient } = await import("#bundled"); + const upstreamPkg = await import("@apollo/client/index.js"); + assert.throws( + () => + new ApolloClient({ + // @ts-expect-error this is what we're testing + cache: new upstreamPkg.InMemoryCache(), + connectToDevTools: false, + }), + { + message: + 'When using `InMemoryCache` in streaming SSR, you must use the `InMemoryCache` export provided by `"@apollo/experimental-nextjs-app-support"`.', + } + ); +}); + +test( + "Error message when using `ApolloNextAppProvider` with the wrong `ApolloClient`", + { skip: outsideOf("node") }, + async () => { + const { ApolloNextAppProvider, ...bundled } = await import("#bundled"); + const { ServerInsertedHTMLContext } = await import("next/navigation.js"); + const React = await import("react"); + const { renderToString } = await import("react-dom/server"); + await test("@apollo/client should error", async () => { + const upstreamPkg = await import("@apollo/client/index.js"); + assert.throws( + () => + renderToString( + {}}> + + // @ts-expect-error we want to test exactly this + new upstreamPkg.ApolloClient({ + cache: new upstreamPkg.InMemoryCache(), + }) + } + > + {null} + + + ), + { + message: + 'When using `ApolloClient` in streaming SSR, you must use the `ApolloClient` export provided by `"@apollo/experimental-nextjs-app-support"`.', + } + ); + }); + await test("@apollo/client-react-streaming should error", async () => { + const streamingPkg = await import("@apollo/client-react-streaming"); + assert.throws( + () => + renderToString( + {}}> + + new streamingPkg.ApolloClient({ + cache: new streamingPkg.InMemoryCache(), + }) + } + > + {null} + + + ), + { + message: + 'When using `ApolloClient` in streaming SSR, you must use the `ApolloClient` export provided by `"@apollo/experimental-nextjs-app-support"`.', + } + ); + }); + await test("this package should work", async () => { + renderToString( + {}}> + + new bundled.ApolloClient({ + cache: new bundled.InMemoryCache(), + }) + } + > + {null} + + + ); + }); + } +); + +test( + "Error message when using `ApolloNextAppProvider` with the wrong `ApolloClient`", + { skip: outsideOf("browser") }, + async () => { + const { ApolloNextAppProvider, ...bundled } = await import("#bundled"); + const React = await import("react"); + + const { ErrorBoundary } = await import("react-error-boundary"); + // Even with an error Boundary, React will still log to `console.error` - we avoid the noise here. + using _restoreConsole = silenceConsoleErrors(); + + await test("@apollo/client should error", async () => { + using env = await browserEnv(); + const upstreamPkg = await import("@apollo/client/index.js"); + const promise = new Promise((_resolve, reject) => { + env.render( + document.body, + }> + + // @ts-expect-error we want to test exactly this + new upstreamPkg.ApolloClient({ + cache: new upstreamPkg.InMemoryCache(), + connectToDevTools: false, + }) + } + > + {null} + + + ); + }); + await assert.rejects(promise, { + message: + 'When using `ApolloClient` in streaming SSR, you must use the `ApolloClient` export provided by `"@apollo/experimental-nextjs-app-support"`.', + }); + }); + await test("@apollo/client-react-streaming should error", async () => { + using env = await browserEnv(); + const streamingPkg = await import("@apollo/client-react-streaming"); + const promise = new Promise((_resolve, reject) => { + env.render( + document.body, + }> + + new streamingPkg.ApolloClient({ + cache: new streamingPkg.InMemoryCache(), + connectToDevTools: false, + }) + } + > + {null} + + + ); + }); + await assert.rejects(promise, { + message: + 'When using `ApolloClient` in streaming SSR, you must use the `ApolloClient` export provided by `"@apollo/experimental-nextjs-app-support"`.', + }); + }); + await test("this package should work", async () => { + using env = await browserEnv(); + const promise = new Promise((resolve, reject) => { + function Child() { + resolve(); + return null; + } + env.render( + document.body, + }> + + new bundled.ApolloClient({ + cache: new bundled.InMemoryCache(), + connectToDevTools: false, + }) + } + > + {} + + + ); + }); + // correct usage, should not throw + await promise; + }); + } +); diff --git a/packages/experimental-nextjs-app-support/src/index.shared.ts b/packages/experimental-nextjs-app-support/src/index.shared.ts index 9f55f932..ef4f5123 100644 --- a/packages/experimental-nextjs-app-support/src/index.shared.ts +++ b/packages/experimental-nextjs-app-support/src/index.shared.ts @@ -2,11 +2,13 @@ export { SSRMultipartLink, DebounceMultipartResponsesLink, RemoveMultipartDirectivesLink, - InMemoryCache, type TransportedQueryRef, } from "@apollo/client-react-streaming"; import { bundle } from "./bundleInfo.js"; -import { ApolloClient as UpstreamApolloClient } from "@apollo/client-react-streaming"; +import { + ApolloClient as UpstreamApolloClient, + InMemoryCache as UpstreamInMemoryCache, +} from "@apollo/client-react-streaming"; /** * A version of `ApolloClient` to be used with streaming SSR or in React Server Components. @@ -25,3 +27,19 @@ export class ApolloClient< */ static readonly info = bundle; } + +/** + * A version of `InMemoryCache` to be used with streaming SSR. + * + * For more documentation, please see {@link https://www.apollographql.com/docs/react/api/cache/InMemoryCache | the Apollo Client API documentation}. + * + * @public + */ +export class InMemoryCache extends UpstreamInMemoryCache { + /** + * Information about the current package and it's export names, for use in error messages. + * + * @internal + */ + static readonly info = bundle; +} diff --git a/packages/experimental-nextjs-app-support/tsconfig.tests.json b/packages/experimental-nextjs-app-support/tsconfig.tests.json new file mode 100644 index 00000000..b3edf69b --- /dev/null +++ b/packages/experimental-nextjs-app-support/tsconfig.tests.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "esModuleInterop": false, + "paths": {} + }, + "include": ["src"] +} diff --git a/packages/test-utils/console.d.ts b/packages/test-utils/console.d.ts new file mode 100644 index 00000000..5e85d470 --- /dev/null +++ b/packages/test-utils/console.d.ts @@ -0,0 +1,3 @@ +export function silenceConsoleErrors(): { + [Symbol.dispose](): void; +}; diff --git a/packages/test-utils/console.js b/packages/test-utils/console.js new file mode 100644 index 00000000..d98f7ccb --- /dev/null +++ b/packages/test-utils/console.js @@ -0,0 +1,9 @@ +export function silenceConsoleErrors() { + const { error } = console; + console.error = () => {}; + return { + [Symbol.dispose]() { + console.error = error; + }, + }; +} diff --git a/packages/test-utils/hydrationTest.d.ts b/packages/test-utils/hydrationTest.d.ts new file mode 100644 index 00000000..15ed80de --- /dev/null +++ b/packages/test-utils/hydrationTest.d.ts @@ -0,0 +1,18 @@ +/** React completeSegment function */ +export function $RS(a: any, b: any): void; +/** React completeBoundary function */ +export function $RC(b: any, c: any, e?: any): void; +/** + * + * @param {Parameters[1]} initialChildren + * @param {Parameters[2]} [options] + */ +export function hydrateBody(initialChildren: [container: Element | Document, initialChildren: import("react").ReactNode, options?: import("react-dom/client").HydrationOptions][1], options?: [container: Element | Document, initialChildren: import("react").ReactNode, options?: import("react-dom/client").HydrationOptions][2]): import("react-dom/client").Root; +/** + * @param {TemplateStringsArray} html + */ +export function setBody(html: TemplateStringsArray): void; +/** + * @param {TemplateStringsArray} html + */ +export function appendToBody(html: TemplateStringsArray): void; diff --git a/packages/client-react-streaming/src/util/hydrationTest.ts b/packages/test-utils/hydrationTest.js similarity index 80% rename from packages/client-react-streaming/src/util/hydrationTest.ts rename to packages/test-utils/hydrationTest.js index fe4f1829..6d37dc12 100644 --- a/packages/client-react-streaming/src/util/hydrationTest.ts +++ b/packages/test-utils/hydrationTest.js @@ -11,21 +11,29 @@ export function $RS(a, b) { a = document.getElementById(a); b = document.getElem export function $RC(b, c, e = undefined) { c = document.getElementById(c); c.parentNode.removeChild(c); var a = document.getElementById(b); if (a) { b = a.previousSibling; if (e) b.data = "$!", a.setAttribute("data-dgst", e); else { e = b.parentNode; a = b.nextSibling; var f = 0; do { if (a && 8 === a.nodeType) { var d = a.data; if ("/$" === d) if (0 === f) break; else f--; else "$" !== d && "$?" !== d && "$!" !== d || f++ } d = a.nextSibling; e.removeChild(a); a = d } while (a); for (; c.firstChild;)e.insertBefore(c.firstChild, a); b.data = "$" } b._reactRetry && b._reactRetry() } } /* eslint-enable */ -export function hydrateBody( - initialChildren: Parameters[1], - options?: Parameters[2] -) { +/** + * + * @param {Parameters[1]} initialChildren + * @param {Parameters[2]} [options] + */ +export function hydrateBody(initialChildren, options) { return hydrateRoot(document.body, initialChildren, options); } -export function setBody(html: TemplateStringsArray) { +/** + * @param {TemplateStringsArray} html + */ +export function setBody(html) { if (html.length !== 1) throw new Error("Expected exactly one template string"); // nosemgrep document.body.innerHTML = html[0]; } -export function appendToBody(html: TemplateStringsArray) { +/** + * @param {TemplateStringsArray} html + */ +export function appendToBody(html) { if (html.length !== 1) throw new Error("Expected exactly one template string"); // nosemgrep diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 00000000..0c21b49d --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,7 @@ +{ + "name": "@internal/test-utils", + "devDependencies": { + "typescript": "*" + }, + "type": "module" +} diff --git a/packages/test-utils/react.d.ts b/packages/test-utils/react.d.ts new file mode 100644 index 00000000..9a78c77e --- /dev/null +++ b/packages/test-utils/react.d.ts @@ -0,0 +1,14 @@ +/** + * sets up a jsdom environment and returns a render function that can be used to render react components + */ +export function browserEnv(): Promise<{ + /** + * + * @param {import('react-dom/client').Container} container + * @param {import('react').ReactNode} reactNode + * @param {import('react-dom/client').RootOptions} [rootOptions] + * @returns + */ + render(container: import('react-dom/client').Container, reactNode: import('react').ReactNode, rootOptions?: import('react-dom/client').RootOptions): import("react-dom/client").Root; + [Symbol.dispose]: () => void; +}>; diff --git a/packages/test-utils/react.js b/packages/test-utils/react.js new file mode 100644 index 00000000..ca25ae9a --- /dev/null +++ b/packages/test-utils/react.js @@ -0,0 +1,34 @@ +/** + * sets up a jsdom environment and returns a render function that can be used to render react components + */ +export async function browserEnv() { + const { createRoot } = await import("react-dom/client"); + const { act } = await import("react"); + const jsdom = await import("global-jsdom"); + const cleanupJSDOM = jsdom.default(); + const origActEnv = globalThis.IS_REACT_ACT_ENVIRONMENT; + + let lastRoot; + + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + return { + /** + * + * @param {import('react-dom/client').Container} container + * @param {import('react').ReactNode} reactNode + * @param {import('react-dom/client').RootOptions} [rootOptions] + * @returns + */ + render(container, reactNode, rootOptions) { + if (lastRoot) lastRoot.unmount(); + lastRoot = createRoot(container, rootOptions); + act(() => lastRoot.render(reactNode)); + return lastRoot; + }, + [Symbol.dispose]: () => { + if (lastRoot) lastRoot.unmount(); + globalThis.IS_REACT_ACT_ENVIRONMENT = origActEnv; + cleanupJSDOM(); + }, + }; +} diff --git a/packages/test-utils/runInConditions.d.ts b/packages/test-utils/runInConditions.d.ts new file mode 100644 index 00000000..44f56598 --- /dev/null +++ b/packages/test-utils/runInConditions.d.ts @@ -0,0 +1,15 @@ +/** + * @typedef {"react-server" | "node" | "browser" | "default"} Condition + */ +/** + * To be used in test files. This will skip the test if the node runner has not been started matching at least one of the passed conditions. + * If node has been started with `node --conditions=node --test`, and `runCondition("node", "browser")` is called, the test will run. + * If node has been started with `node --conditions=react-server --test`, and `runCondition("node", "browser")` is called, the test will not run. + * @param {Condition[]} validConditions + */ +export function runInConditions(...validConditions: Condition[]): void; +/** + * @param {Condition[]} validConditions + */ +export function outsideOf(...validConditions: Condition[]): boolean; +export type Condition = "react-server" | "node" | "browser" | "default"; diff --git a/packages/client-react-streaming/src/util/runInConditions.ts b/packages/test-utils/runInConditions.js similarity index 73% rename from packages/client-react-streaming/src/util/runInConditions.ts rename to packages/test-utils/runInConditions.js index 450175b3..b111ab9b 100644 --- a/packages/client-react-streaming/src/util/runInConditions.ts +++ b/packages/test-utils/runInConditions.js @@ -1,23 +1,30 @@ import { parseArgs } from "node:util"; -type Condition = "react-server" | "node" | "browser" | "default"; +/** + * @typedef {"react-server" | "node" | "browser" | "default"} Condition + */ /** * To be used in test files. This will skip the test if the node runner has not been started matching at least one of the passed conditions. * If node has been started with `node --conditions=node --test`, and `runCondition("node", "browser")` is called, the test will run. * If node has been started with `node --conditions=react-server --test`, and `runCondition("node", "browser")` is called, the test will not run. - * @param validConditions + * @param {Condition[]} validConditions */ -export function runInConditions(...validConditions: Condition[]) { +export function runInConditions(...validConditions) { if (!conditionActive(validConditions)) { process.exit(0); } } -export function outsideOf(...validConditions: Condition[]) { +/** + * @param {Condition[]} validConditions + */ +export function outsideOf(...validConditions) { return !conditionActive(validConditions); } - -function conditionActive(validConditions: Condition[]) { +/** + * @param {Condition[]} validConditions + */ +function conditionActive(validConditions) { const args = parseArgs({ args: (process.env.NODE_OPTIONS || "").split(" ").concat(process.execArgv), options: { diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 00000000..d8a6c927 --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["./*.js"], + "exclude": ["node_modules"], + "compilerOptions": { + "allowJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": ".", + "declarationMap": false + } +} diff --git a/yarn.lock b/yarn.lock index 131d218f..e499c0c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,6 +51,7 @@ __metadata: dependencies: "@apollo/client": "npm:^3.10.4" "@arethetypeswrong/cli": "npm:0.15.3" + "@internal/test-utils": "workspace:^" "@microsoft/api-extractor": "npm:7.43.2" "@testing-library/react": "npm:15.0.7" "@total-typescript/shoehorn": "npm:0.1.2" @@ -129,6 +130,7 @@ __metadata: "@apollo/client": "npm:3.10.4" "@apollo/client-react-streaming": "workspace:*" "@arethetypeswrong/cli": "npm:0.15.3" + "@internal/test-utils": "workspace:^" "@microsoft/api-extractor": "npm:7.43.2" "@testing-library/react": "npm:15.0.7" "@total-typescript/shoehorn": "npm:0.1.2" @@ -3623,6 +3625,14 @@ __metadata: languageName: node linkType: hard +"@internal/test-utils@workspace:^, @internal/test-utils@workspace:packages/test-utils": + version: 0.0.0-use.local + resolution: "@internal/test-utils@workspace:packages/test-utils" + dependencies: + typescript: "npm:*" + languageName: unknown + linkType: soft + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -9537,16 +9547,7 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.2.0": - version: 4.7.2 - resolution: "get-tsconfig@npm:4.7.2" - dependencies: - resolve-pkg-maps: "npm:^1.0.0" - checksum: 10/f21135848fb5d16012269b7b34b186af7a41824830f8616aba17a15eb4d9e54fdc876833f1e21768395215a826c8145582f5acd594ae2b4de3284d10b38d20f8 - languageName: node - linkType: hard - -"get-tsconfig@npm:^4.7.2": +"get-tsconfig@npm:^4.2.0, get-tsconfig@npm:^4.7.2": version: 4.7.5 resolution: "get-tsconfig@npm:4.7.5" dependencies: @@ -15346,6 +15347,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:*, typescript@npm:5.4.5": + version: 5.4.5 + resolution: "typescript@npm:5.4.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/d04a9e27e6d83861f2126665aa8d84847e8ebabcea9125b9ebc30370b98cb38b5dff2508d74e2326a744938191a83a69aa9fddab41f193ffa43eabfdf3f190a5 + languageName: node + linkType: hard + "typescript@npm:5.3.3": version: 5.3.3 resolution: "typescript@npm:5.3.3" @@ -15366,13 +15377,13 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.4.5": +"typescript@patch:typescript@npm%3A*#optional!builtin, typescript@patch:typescript@npm%3A5.4.5#optional!builtin": version: 5.4.5 - resolution: "typescript@npm:5.4.5" + resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/d04a9e27e6d83861f2126665aa8d84847e8ebabcea9125b9ebc30370b98cb38b5dff2508d74e2326a744938191a83a69aa9fddab41f193ffa43eabfdf3f190a5 + checksum: 10/760f7d92fb383dbf7dee2443bf902f4365db2117f96f875cf809167f6103d55064de973db9f78fe8f31ec08fff52b2c969aee0d310939c0a3798ec75d0bca2e1 languageName: node linkType: hard @@ -15396,16 +15407,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.4.5#optional!builtin": - version: 5.4.5 - resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/760f7d92fb383dbf7dee2443bf902f4365db2117f96f875cf809167f6103d55064de973db9f78fe8f31ec08fff52b2c969aee0d310939c0a3798ec75d0bca2e1 - languageName: node - linkType: hard - "ua-parser-js@npm:^0.7.30": version: 0.7.35 resolution: "ua-parser-js@npm:0.7.35" From 012afed0095e0900b04254b354567d0a8b57625d Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 29 May 2024 10:49:02 +0200 Subject: [PATCH 09/11] Add deprecation messages to moved exports. (#301) * Add deprecation messages to moved exports. * fixup * update wording as per review --- .../src/rsc/index.ts | 31 +++- .../src/ssr/index.ts | 169 ++++++++++++++++-- 2 files changed, 182 insertions(+), 18 deletions(-) diff --git a/packages/experimental-nextjs-app-support/src/rsc/index.ts b/packages/experimental-nextjs-app-support/src/rsc/index.ts index 5d684d3b..e33f76e0 100644 --- a/packages/experimental-nextjs-app-support/src/rsc/index.ts +++ b/packages/experimental-nextjs-app-support/src/rsc/index.ts @@ -1,4 +1,29 @@ -export { - registerApolloClient, - type TransportedQueryRef, +import { + registerApolloClient as _registerApolloClient, + type TransportedQueryRef as _TransportedQueryRef, } from "@apollo/experimental-nextjs-app-support"; + +/** + * @deprecated + * This import has moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import { registerApolloClient } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export const registerApolloClient = _registerApolloClient; + +/** + * @deprecated + * This import has moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import type { TransportedQueryRef } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export type TransportedQueryRef< + TData = unknown, + TVariables = unknown, +> = _TransportedQueryRef; diff --git a/packages/experimental-nextjs-app-support/src/ssr/index.ts b/packages/experimental-nextjs-app-support/src/ssr/index.ts index c1c625d2..474637e2 100644 --- a/packages/experimental-nextjs-app-support/src/ssr/index.ts +++ b/packages/experimental-nextjs-app-support/src/ssr/index.ts @@ -1,17 +1,156 @@ -export { - InMemoryCache as NextSSRInMemoryCache, - ApolloClient as NextSSRApolloClient, - SSRMultipartLink, - DebounceMultipartResponsesLink, - RemoveMultipartDirectivesLink, - ApolloNextAppProvider, - resetApolloClientSingletons as resetNextSSRApolloSingletons, - type TransportedQueryRef, +import { + InMemoryCache, + ApolloClient, + resetApolloClientSingletons, + SSRMultipartLink as _SSRMultipartLink, + DebounceMultipartResponsesLink as _DebounceMultipartResponsesLink, + RemoveMultipartDirectivesLink as _RemoveMultipartDirectivesLink, + ApolloNextAppProvider as _ApolloNextAppProvider, + type TransportedQueryRef as _TransportedQueryRef, } from "@apollo/experimental-nextjs-app-support"; -export { - useBackgroundQuery, - useFragment, - useQuery, - useReadQuery, - useSuspenseQuery, +import { + useBackgroundQuery as _useBackgroundQuery, + useFragment as _useFragment, + useQuery as _useQuery, + useReadQuery as _useReadQuery, + useSuspenseQuery as _useSuspenseQuery, } from "@apollo/client/index.js"; + +/** + * @deprecated + * This import has been renamed to `InMemoryCache` and moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import { InMemoryCache } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export const NextSSRInMemoryCache = InMemoryCache; +/** + * @deprecated + * This import has been renamed to `ApolloClient` and moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import { ApolloClient } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export const NextSSRApolloClient = ApolloClient; +/** + * @deprecated + * This import has been renamed to `resetApolloClientSingletons` and moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import { resetApolloClientSingletons } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export const resetNextSSRApolloSingletons = resetApolloClientSingletons; +/** + * @deprecated + * This import has moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import { SSRMultipartLink } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export const SSRMultipartLink = _SSRMultipartLink; +/** + * @deprecated + * This import has moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import { DebounceMultipartResponsesLink } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export const DebounceMultipartResponsesLink = _DebounceMultipartResponsesLink; +/** + * @deprecated + * This import has moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import { RemoveMultipartDirectivesLink } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export const RemoveMultipartDirectivesLink = _RemoveMultipartDirectivesLink; +/** + * @deprecated + * This import has moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import { ApolloNextAppProvider } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export const ApolloNextAppProvider = _ApolloNextAppProvider; +/** + * @deprecated + * This import has moved to `"@apollo/experimental-nextjs-app-support"`. + * + * Please update your import to + * ```ts + * import type { TransportedQueryRef } from "@apollo/experimental-nextjs-app-support"; + * ``` + */ +export type TransportedQueryRef< + TData = unknown, + TVariables = unknown, +> = _TransportedQueryRef; +/** + * @deprecated + * Importing `useBackgroundQuery` from this package is no longer necessary. + * Import it directly from `@apollo/client` instead. + + * Please update your import to + * ```ts + * import { useBackgroundQuery } from "@apollo/client"; + * ``` + */ +export const useBackgroundQuery = _useBackgroundQuery; +/** + * @deprecated + * Importing `useFragment` from this package is no longer necessary. + * Import it directly from `@apollo/client` instead. + + * Please update your import to + * ```ts + * import { useFragment } from "@apollo/client"; + * ``` + */ +export const useFragment = _useFragment; +/** + * @deprecated + * Importing `useQuery` from this package is no longer necessary. + * Import it directly from `@apollo/client` instead. + + * Please update your import to + * ```ts + * import { useQuery } from "@apollo/client"; + * ``` + */ +export const useQuery = _useQuery; +/** + * @deprecated + * Importing `useReadQuery` from this package is no longer necessary. + * Import it directly from `@apollo/client` instead. + + * Please update your import to + * ```ts + * import { useReadQuery } from "@apollo/client"; + * ``` + */ +export const useReadQuery = _useReadQuery; +/** + * @deprecated + * Importing `useSuspenseQuery` from this package is no longer necessary. + * Import it directly from `@apollo/client` instead. + + * Please update your import to + * ```ts + * import { useSuspenseQuery } from "@apollo/client"; + * ``` + */ +export const useSuspenseQuery = _useSuspenseQuery; From 6db4732d9bbfea79a91f7aa951e279fa7e462ebb Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 29 May 2024 11:58:39 +0200 Subject: [PATCH 10/11] update README to include PreloadQuery (#303) * update README * anchor * Apply suggestions from code review Co-authored-by: Jerel Miller * review feedback * clarification --------- Co-authored-by: Jerel Miller --- .../experimental-nextjs-app-support/README.md | 97 ++++++++++++++++++- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/experimental-nextjs-app-support/README.md b/packages/experimental-nextjs-app-support/README.md index 1eadf21a..940d941e 100644 --- a/packages/experimental-nextjs-app-support/README.md +++ b/packages/experimental-nextjs-app-support/README.md @@ -9,8 +9,8 @@ > This cannot be addressed from our side, but would need API changes in Next.js or React itself. > If you do not use suspense in your application, this will not be a problem to you. -| ☑️ Apollo Client User Survey | -| :----- | +| ☑️ Apollo Client User Survey | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | What do you like best about Apollo Client? What needs to be improved? Please tell us by taking a [one-minute survey](https://docs.google.com/forms/d/e/1FAIpQLSczNDXfJne3ZUOXjk9Ursm9JYvhTh1_nFTDfdq3XBAFWCzplQ/viewform?usp=pp_url&entry.1170701325=Apollo+Client&entry.204965213=Readme). Your responses will help us understand Apollo Client usage and allow us to serve you better. | ## Detailed technical breakdown @@ -57,7 +57,7 @@ import { InMemoryCache, } from "@apollo/experimental-nextjs-app-support"; -export const { getClient } = registerApolloClient(() => { +export const { getClient, query, PreloadQuery } = registerApolloClient(() => { return new ApolloClient({ cache: new InMemoryCache(), link: new HttpLink({ @@ -75,9 +75,13 @@ You can then use that `getClient` function in your server components: ```js const { data } = await getClient().query({ query: userQuery }); +// `query` is a shortcut for `getClient().query` +const { data } = await query({ query: userQuery }); ``` -### In SSR +For a description of `PreloadQuery`, see [Preloading data in RSC for usage in Client Components](#preloading-data-in-rsc-for-usage-in-client-components) + +### In Client Components and streaming SSR If you use the `app` directory, each Client Component _will_ be SSR-rendered for the initial request. So you will need to use this package. @@ -154,6 +158,91 @@ export default function RootLayout({ 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. +### Preloading data in RSC for usage in Client Components + +Starting with version 0.11, you can preload data in RSC to populate the cache of your Client Components. + +For that, follow the setup steps for both RSC and Client Components as laid out in the last two paragraphs. Then you can use the `PreloadQuery` component in your React Server Components: + +```jsx + + loading}> + + + +``` + +And you can use `useSuspenseQuery` in your `ClientChild` component with the same QUERY: + +```jsx +"use client"; + +import { useSuspenseQuery } from "@apollo/client"; +// ... + +export function ClientChild() { + const { data } = useSuspenseQuery(QUERY); + return
    ...
    ; +} +``` + +> [!TIP] +> The `Suspense` boundary here is optional and only for demonstration purposes to show that something suspenseful is going on. +> Place `Suspense` boundaries at meaningful places in your UI, where they give your users the best user experience. + +This example will fetch a query in RSC, and then transport the data into the Client Component cache. +Before the child `ClientChild` in the example renders, a "simulated network request" for this query is started in your Client Components. +That way, if you repeat the query in your Client Component using `useSuspenseQuery` (or even `useQuery`!), it will wait for the network request in your Server Component to finish instead of making it's own network request. + +> [!IMPORTANT] +> Keep in mind that we don't recommend mixing data between Client Components and Server Components. Data fetched this way should be considered client data and never be referenced in your Server Components. `PreloadQuery` prevents mixing server data and client data by creating a separate `ApolloClient` instance using the `makeClient` function passed into `registerApolloClient`. + +#### Usage with `useReadQuery`. + +You can also use this approach in combination with `useReadQuery` in Client Components. Use the render prop approach to get a `QueryRef` that you can pass to your Client Component: + +```jsx + + {(queryRef) => ( + loading}> + + + )} + +``` + +Inside of `ClientChild`, you could then call `useReadQuery` with the `queryRef` prop. + +```jsx +"use client"; + +import { useQueryRefHandlers, useReadQuery, QueryRef } from "@apollo/client"; + +export function ClientChild({ queryRef }: { queryRef: QueryRef }) { + const { refetch } = useQueryRefHandlers(queryRef); + const { data } = useReadQuery(queryRef); + return
    ...
    ; +} +``` + +> [!TIP] +> The `Suspense` boundary here is optional and only for demonstration purposes to show that something suspenseful is going on. +> Place `Suspense` boundaries at meaningful places in your UI, where they give your users the best user experience. + +#### Caveat + +Keep in mind that this will look like a "current network request" to your Client Component and as such will update data that is already in your Client Component cache, so make sure that the data you pass from your Server Components is not outdated, e.g. because of other caching layers you might be using, like the Next.js fetch cache. + ### Resetting singletons between tests. This package uses some singleton instances on the Browser side - if you are writing tests, you must reset them between tests. From 6e76e6dd5dbcba5c0cc406d945e107ba1c06b0e0 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 29 May 2024 12:20:06 +0200 Subject: [PATCH 11/11] adjust docblock for api-extractor --- .../src/ApolloNextAppProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experimental-nextjs-app-support/src/ApolloNextAppProvider.ts b/packages/experimental-nextjs-app-support/src/ApolloNextAppProvider.ts index b1d3c9c1..d2945cfb 100644 --- a/packages/experimental-nextjs-app-support/src/ApolloNextAppProvider.ts +++ b/packages/experimental-nextjs-app-support/src/ApolloNextAppProvider.ts @@ -13,7 +13,7 @@ import { bundle } from "./bundleInfo.js"; * but requires a `makeClient` prop instead. * * Use this component together with `ApolloClient` and `InMemoryCache` - * from the "@apollo/experimental-nextjs-app-support" package + * from the `"@apollo/experimental-nextjs-app-support"` package * to make an ApolloClient instance available to your Client Component hooks in the * Next.js App Router. *