diff --git a/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx b/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx index 9c53f704..d2d9eccc 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx +++ b/packages/client-react-streaming/src/ManualDataTransport/ManualDataTransport.tsx @@ -5,18 +5,30 @@ 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`. + */ + 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 +41,7 @@ const buildManualDataTransportSSRImpl = ({ rehydrationContext.current = buildApolloRehydrationContext({ insertHtml, extraScriptProps, + stringify: stringifyForStream, }); } @@ -59,65 +72,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 +182,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 85d1e720..2b49a5ce 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/dataTransport.ts +++ b/packages/client-react-streaming/src/ManualDataTransport/dataTransport.ts @@ -4,6 +4,7 @@ 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 }; @@ -15,7 +16,7 @@ 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( stringify(data) @@ -29,9 +30,11 @@ 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 = revive(data) as DataToTransport; @@ -42,22 +45,3 @@ export function registerDataTransport({ } }); } - -/** - * Stringifies a value to be injected into JavaScript "text" - preverves `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; -} diff --git a/packages/client-react-streaming/src/ManualDataTransport/dataTransport.test.ts b/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts similarity index 95% rename from packages/client-react-streaming/src/ManualDataTransport/dataTransport.test.ts rename to packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts index 20f0e1af..2f1910ff 100644 --- a/packages/client-react-streaming/src/ManualDataTransport/dataTransport.test.ts +++ b/packages/client-react-streaming/src/ManualDataTransport/serialization.test.ts @@ -1,5 +1,5 @@ import test, { describe } from "node:test"; -import { revive, stringify } from "./dataTransport.js"; +import { revive, stringify } from "./serialization.js"; import { outsideOf } from "../util/runInConditions.js"; import { htmlEscapeJsonString } from "./htmlescape.js"; import assert from "node:assert"; 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..5225ae6e --- /dev/null +++ b/packages/client-react-streaming/src/ManualDataTransport/serialization.ts @@ -0,0 +1,21 @@ +/** + * Stringifies a value to be injected into JavaScript "text" - preverves `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;