diff --git a/.changeset/gentle-horses-sneeze.md b/.changeset/gentle-horses-sneeze.md new file mode 100644 index 00000000000..a3c1e693304 --- /dev/null +++ b/.changeset/gentle-horses-sneeze.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +SSR functions take batchOptions, to modulate query batching diff --git a/src/react/ssr/__tests__/getDataFromTree.batch.test.tsx b/src/react/ssr/__tests__/getDataFromTree.batch.test.tsx new file mode 100644 index 00000000000..922c1e67450 --- /dev/null +++ b/src/react/ssr/__tests__/getDataFromTree.batch.test.tsx @@ -0,0 +1,140 @@ +/** @jest-environment node */ +import React from "react"; +import { getDataFromTree, renderToStringWithData } from "../index.js"; +import { ApolloClient, ApolloLink } from "@apollo/client"; +import { InMemoryCache as Cache } from "@apollo/client/cache"; +import { ApolloProvider, useQuery } from "@apollo/client/react"; +import { gql } from "graphql-tag"; +import { Observable } from "@apollo/client/utilities"; + +interface TestData { + test: string; +} + +const TEST_QUERY = gql` + query TestQuery { + test + } +`; + +const TestComponent = () => { + const { data, loading } = useQuery(TEST_QUERY); + + if (loading) return
Loading...
; + return
Data: {data?.test}
; +}; + +describe("getDataFromTree with batch options", () => { + let client: ApolloClient; + + beforeEach(() => { + const mockLink = new ApolloLink(() => { + return new Observable((observer) => { + // Resolve immediately for testing + observer.next({ data: { test: "success" } }); + observer.complete(); + }); + }); + + client = new ApolloClient({ + cache: new Cache(), + link: mockLink, + }); + }); + + it("should work with debounced batching", async () => { + const element = ( + + + + ); + + const startTime = Date.now(); + + const view = await getDataFromTree( + element, + {}, + { + debounce: 50, + } + ); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should complete within a reasonable time + expect(duration).toBeLessThan(200); + expect(view).toMatch(/Data:.*success/); + }); + + it("should work with renderToStringWithData and batching", async () => { + const element = ( + + + + ); + + const startTime = Date.now(); + + const view = await renderToStringWithData(element, { + debounce: 30, + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should complete within a reasonable time + expect(duration).toBeLessThan(150); + expect(view).toMatch(/Data:.*success/); + }); + + it("should handle multiple queries with debouncing", async () => { + const MultiQueryComponent = () => { + const { data: data1 } = useQuery(TEST_QUERY); + const { data: data2 } = useQuery(TEST_QUERY); + + return ( +
+
Query 1: {data1?.test}
+
Query 2: {data2?.test}
+
+ ); + }; + + const element = ( + + + + ); + + const startTime = Date.now(); + + const view = await getDataFromTree( + element, + {}, + { + debounce: 20, + } + ); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should resolve quickly due to debouncing + expect(duration).toBeLessThan(100); + expect(view).toMatch(/Query 1:.*success/); + expect(view).toMatch(/Query 2:.*success/); + }); + + it("should fall back to default behavior when no batch options provided", async () => { + const element = ( + + + + ); + + const view = await getDataFromTree(element); + + expect(view).toMatch(/Data:.*success/); + }); +}); diff --git a/src/react/ssr/__tests__/prerenderStatic.batch.test.tsx b/src/react/ssr/__tests__/prerenderStatic.batch.test.tsx new file mode 100644 index 00000000000..d86b53b62ba --- /dev/null +++ b/src/react/ssr/__tests__/prerenderStatic.batch.test.tsx @@ -0,0 +1,171 @@ +/** @jest-environment node */ +import React from "react"; +import { prerenderStatic } from "../prerenderStatic.js"; +import { ApolloClient, ApolloLink } from "@apollo/client"; +import { InMemoryCache as Cache } from "@apollo/client/cache"; +import { ApolloProvider, useQuery } from "@apollo/client/react"; +import { gql } from "graphql-tag"; +import { Observable } from "@apollo/client/utilities"; + +interface TestData { + test: string; +} + +const TEST_QUERY = gql` + query TestQuery { + test + } +`; + +const TestComponent = () => { + const { data, loading } = useQuery(TEST_QUERY); + + if (loading) return
Loading...
; + return
Data: {data?.test}
; +}; + +describe("prerenderStatic with batch options", () => { + let client: ApolloClient; + + beforeEach(() => { + const mockLink = new ApolloLink(() => { + return new Observable((observer) => { + // Resolve immediately for testing + observer.next({ data: { test: "success" } }); + observer.complete(); + }); + }); + + client = new ApolloClient({ + cache: new Cache(), + link: mockLink, + }); + }); + + it("should work with debounced batching", async () => { + const element = ( + + + + ); + + const startTime = Date.now(); + + const result = await prerenderStatic({ + tree: element, + renderFunction: (await import("react-dom/server")).renderToString, + batchOptions: { + debounce: 50, + }, + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should complete within a reasonable time + expect(duration).toBeLessThan(200); + expect(result.result).toMatch(/Data:.*success/); + }); + + it("should handle multiple queries with debouncing", async () => { + const MultiQueryComponent = () => { + const { data: data1 } = useQuery(TEST_QUERY); + const { data: data2 } = useQuery(TEST_QUERY); + + return ( +
+
Query 1: {data1?.test}
+
Query 2: {data2?.test}
+
+ ); + }; + + const element = ( + + + + ); + + const startTime = Date.now(); + + const result = await prerenderStatic({ + tree: element, + renderFunction: (await import("react-dom/server")).renderToString, + batchOptions: { + debounce: 20, + }, + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should resolve quickly due to debouncing + expect(duration).toBeLessThan(100); + expect(result.result).toMatch(/Query 1:.*success/); + expect(result.result).toMatch(/Query 2:.*success/); + }); + + it("should fall back to default behavior when no batch options provided", async () => { + const element = ( + + + + ); + + const result = await prerenderStatic({ + tree: element, + renderFunction: (await import("react-dom/server")).renderToString, + }); + + expect(result.result).toMatch(/Data:.*success/); + }); + + it("should handle slow queries with debouncing", async () => { + let resolveSlowQuery: (() => void) | null = null; + const slowPromise = new Promise((resolve) => { + resolveSlowQuery = resolve; + }); + + const slowLink = new ApolloLink(() => { + return new Observable((observer) => { + // Delay the response + setTimeout(() => { + observer.next({ data: { test: "slow" } }); + observer.complete(); + if (resolveSlowQuery) resolveSlowQuery(); + }, 100); + }); + }); + + const slowClient = new ApolloClient({ + cache: new Cache(), + link: slowLink, + }); + + const element = ( + + + + ); + + const startTime = Date.now(); + + const result = await prerenderStatic({ + tree: element, + renderFunction: (await import("react-dom/server")).renderToString, + batchOptions: { + debounce: 30, + }, + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should timeout around 30ms, not wait for the 100ms query + expect(duration).toBeGreaterThanOrEqual(25); + expect(duration).toBeLessThan(80); + + // Wait for the slow query to complete + await slowPromise; + }); +}); diff --git a/src/react/ssr/getDataFromTree.ts b/src/react/ssr/getDataFromTree.ts index 50397f69b96..14416aa8603 100644 --- a/src/react/ssr/getDataFromTree.ts +++ b/src/react/ssr/getDataFromTree.ts @@ -1,6 +1,7 @@ import type * as ReactTypes from "react"; import { prerenderStatic } from "./prerenderStatic.js"; +import type { BatchOptions } from "./types.js"; /** * @deprecated This function uses the legacy `renderToStaticMarkup` API from React. @@ -9,7 +10,8 @@ import { prerenderStatic } from "./prerenderStatic.js"; */ export async function getDataFromTree( tree: ReactTypes.ReactNode, - context: { [key: string]: any } = {} + context: { [key: string]: any } = {}, + batchOptions?: BatchOptions ) { return getMarkupFromTree({ tree, @@ -17,6 +19,7 @@ export async function getDataFromTree( // If you need to configure this renderFunction, call getMarkupFromTree // directly instead of getDataFromTree. renderFunction: (await import("react-dom/server")).renderToStaticMarkup, + batchOptions, }); } @@ -26,6 +29,7 @@ type GetMarkupFromTreeOptions = { renderFunction?: | prerenderStatic.RenderToString | prerenderStatic.RenderToStringPromise; + batchOptions?: BatchOptions; }; /** @@ -40,6 +44,7 @@ export async function getMarkupFromTree({ // the default, because it's a little less expensive than renderToString, // and legacy usage of getDataFromTree ignores the return value anyway. renderFunction, + batchOptions, }: GetMarkupFromTreeOptions): Promise { if (!renderFunction) { renderFunction = (await import("react-dom/server")).renderToStaticMarkup; @@ -49,6 +54,7 @@ export async function getMarkupFromTree({ context, renderFunction, maxRerenders: Number.POSITIVE_INFINITY, + batchOptions, }); return result; } diff --git a/src/react/ssr/prerenderStatic.tsx b/src/react/ssr/prerenderStatic.tsx index dd09580c1c5..126b3a254a9 100644 --- a/src/react/ssr/prerenderStatic.tsx +++ b/src/react/ssr/prerenderStatic.tsx @@ -16,6 +16,7 @@ import { canonicalStringify } from "@apollo/client/utilities"; import { invariant } from "@apollo/client/utilities/invariant"; import { useSSRQuery } from "./useSSRQuery.js"; +import type { BatchOptions } from "./types.js"; type ObservableQueryKey = `${string}|${string}`; function getObservableQueryKey( @@ -110,6 +111,12 @@ export declare namespace prerenderStatic { * @defaultValue 50 */ maxRerenders?: number; + + /** + * Options for batching queries during SSR to optimize performance. + * When provided, this can reduce the time spent waiting for all queries to complete. + */ + batchOptions?: BatchOptions; } export interface Result { @@ -187,6 +194,7 @@ export function prerenderStatic({ ignoreResults, diagnostics, maxRerenders = 50, + batchOptions, }: prerenderStatic.Options): Promise { const availableObservableQueries = new Map< ObservableQueryKey, @@ -264,8 +272,93 @@ you have an infinite render loop in your application.`, return { result, aborted: true }; } + const observables = Array.from(recentlyCreatedObservableQueries); + + // If batchOptions with debounce is provided, use debounced behavior + if (batchOptions?.debounce !== undefined) { + const dataPromise = new Promise((resolve, reject) => { + let resolved = false; + let timeoutId: NodeJS.Timeout | null = null; + let rejectedPromises = 0; + const totalPromises = observables.length; + const resolvedObservables = new Set(); + + const handleResolve = (observable: ObservableQuery) => { + resolvedObservables.add(observable); + if (!resolved) { + resolved = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + // Mark these as resolved for the next render cycle + resolvedObservables.forEach((obs) => { + recentlyCreatedObservableQueries.delete(obs); + }); + resolve(); + } + }; + + const handleReject = (error: any) => { + rejectedPromises++; + // Only reject if ALL promises fail + if (rejectedPromises === totalPromises) { + if (!resolved) { + resolved = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + reject( + new Error( + `All ${totalPromises} queries failed during SSR: ${ + error?.message || "Unknown error" + }` + ) + ); + } + } + }; + + // Set up timeout for debounce + timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + // Mark all as resolved when timeout occurs + observables.forEach((obs) => { + recentlyCreatedObservableQueries.delete(obs); + }); + resolve(); + } + }, batchOptions.debounce); + + // Listen for observables to resolve or reject + observables.forEach((observable) => { + firstValueFrom( + observable.pipe(filter((result) => result.loading === false)) + ) + .then(() => handleResolve(observable)) + .catch((error) => handleReject(error)); + }); + }); + + let resolveAbortPromise!: () => void; + const abortPromise = new Promise((resolve) => { + resolveAbortPromise = resolve; + }); + signal?.addEventListener("abort", resolveAbortPromise); + await Promise.race([abortPromise, dataPromise]); + signal?.removeEventListener("abort", resolveAbortPromise); + + if (signal?.aborted) { + return { result, aborted: true }; + } + return process(); + } + + // Default behavior: wait for all observables (Promise.all) const dataPromise = Promise.all( - Array.from(recentlyCreatedObservableQueries).map(async (observable) => { + observables.map(async (observable) => { await firstValueFrom( observable.pipe(filter((result) => result.loading === false)) ); diff --git a/src/react/ssr/renderToStringWithData.ts b/src/react/ssr/renderToStringWithData.ts index 7cc9dacf7f3..d2a61e2cbbf 100644 --- a/src/react/ssr/renderToStringWithData.ts +++ b/src/react/ssr/renderToStringWithData.ts @@ -1,6 +1,7 @@ import type * as ReactTypes from "react"; import { prerenderStatic } from "./prerenderStatic.js"; +import type { BatchOptions } from "./types.js"; /** * @deprecated This function uses the legacy `renderToString` API from React. @@ -8,12 +9,14 @@ import { prerenderStatic } from "./prerenderStatic.js"; * React APIs. */ export async function renderToStringWithData( - component: ReactTypes.ReactElement + component: ReactTypes.ReactElement, + batchOptions?: BatchOptions ): Promise { const { result } = await prerenderStatic({ tree: component, renderFunction: (await import("react-dom/server")).renderToString, maxRerenders: Number.POSITIVE_INFINITY, + batchOptions, }); return result; } diff --git a/src/react/ssr/types.ts b/src/react/ssr/types.ts new file mode 100644 index 00000000000..c8012b89a90 --- /dev/null +++ b/src/react/ssr/types.ts @@ -0,0 +1,14 @@ +export type BatchOptions = { + /** + * Debounce timeout in milliseconds for SSR query batching. + * When provided, the SSR process will wait for the first query to resolve + * OR the debounce timeout to expire before continuing to the next render cycle. + * + * This is useful for optimizing SSR performance by not waiting for all queries + * to complete, but should be used carefully as it may result in incomplete data + * if queries have dependencies on each other. + * + * @default undefined (wait for all queries to resolve) + */ + debounce?: number; +};