From 1c9e1498c329b9a930ff5ddf360652c21771bc5b Mon Sep 17 00:00:00 2001 From: Daniel Kempner Date: Wed, 9 Jul 2025 21:33:59 -0700 Subject: [PATCH 1/6] [SSR] batched query promise rendering --- src/react/ssr/RenderPromises.ts | 40 ++++++++++++++++++++++++- src/react/ssr/getDataFromTree.ts | 11 +++++-- src/react/ssr/renderToStringWithData.ts | 5 +++- src/react/ssr/types.ts | 3 ++ 4 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/react/ssr/types.ts diff --git a/src/react/ssr/RenderPromises.ts b/src/react/ssr/RenderPromises.ts index 44ca99519b7..607259e9f0b 100644 --- a/src/react/ssr/RenderPromises.ts +++ b/src/react/ssr/RenderPromises.ts @@ -4,6 +4,7 @@ import type { ObservableQuery, OperationVariables } from "../../core/index.js"; import type { QueryDataOptions } from "../types/types.js"; import { Trie } from "@wry/trie"; import { canonicalStringify } from "../../cache/index.js"; +import type { BatchOptions } from "./types.js"; // TODO: A vestigial interface from when hooks were implemented with utility // classes, which should be deleted in the future. @@ -114,7 +115,11 @@ export class RenderPromises { return this.queryPromises.size > 0; } - public consumeAndAwaitPromises() { + public consumeAndAwaitPromises({ + batchOptions, + }: { + batchOptions?: BatchOptions; + }) { const promises: Promise[] = []; this.queryPromises.forEach((promise, queryInstance) => { // Make sure we never try to call fetchData for this query document and @@ -130,6 +135,39 @@ export class RenderPromises { promises.push(promise); }); this.queryPromises.clear(); + + if (promises.length === 0) { + return Promise.resolve(); + } + + // If batchOptions with debounce is provided, use Promise.any behavior + if (batchOptions?.debounce !== undefined) { + return new Promise((resolve) => { + let resolved = false; + let timeoutId: NodeJS.Timeout | null = null; + + const handleResolve = () => { + if (!resolved) { + resolved = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + resolve(undefined); + } + }; + + // Set up timeout for debounce + timeoutId = setTimeout(handleResolve, batchOptions.debounce); + + // Listen for the first promise to resolve + promises.forEach(promise => { + promise.then(handleResolve).catch(handleResolve); + }); + }); + } + + // Default behavior: wait for all promises (Promise.all) return Promise.all(promises); } diff --git a/src/react/ssr/getDataFromTree.ts b/src/react/ssr/getDataFromTree.ts index 4dde58ec62a..5507b7898c5 100644 --- a/src/react/ssr/getDataFromTree.ts +++ b/src/react/ssr/getDataFromTree.ts @@ -3,10 +3,12 @@ import type * as ReactTypes from "react"; import { getApolloContext } from "../context/index.js"; import { RenderPromises } from "./RenderPromises.js"; import { renderToStaticMarkup } from "react-dom/server"; +import type { BatchOptions } from "./types.js"; export function getDataFromTree( tree: ReactTypes.ReactNode, - context: { [key: string]: any } = {} + context: { [key: string]: any } = {}, + batchOptions?: BatchOptions ) { return getMarkupFromTree({ tree, @@ -14,6 +16,7 @@ export function getDataFromTree( // If you need to configure this renderFunction, call getMarkupFromTree // directly instead of getDataFromTree. renderFunction: renderToStaticMarkup, + batchOptions, }); } @@ -23,6 +26,7 @@ export type GetMarkupFromTreeOptions = { renderFunction?: ( tree: ReactTypes.ReactElement ) => string | PromiseLike; + batchOptions?: BatchOptions; }; export function getMarkupFromTree({ @@ -32,6 +36,7 @@ export function getMarkupFromTree({ // the default, because it's a little less expensive than renderToString, // and legacy usage of getDataFromTree ignores the return value anyway. renderFunction = renderToStaticMarkup, + batchOptions, }: GetMarkupFromTreeOptions): Promise { const renderPromises = new RenderPromises(); @@ -53,7 +58,9 @@ export function getMarkupFromTree({ }) .then((html) => { return renderPromises.hasPromises() ? - renderPromises.consumeAndAwaitPromises().then(process) + renderPromises + .consumeAndAwaitPromises({ batchOptions }) + .then(process) : html; }) .finally(() => { diff --git a/src/react/ssr/renderToStringWithData.ts b/src/react/ssr/renderToStringWithData.ts index f6bcb345849..ad3f0268f41 100644 --- a/src/react/ssr/renderToStringWithData.ts +++ b/src/react/ssr/renderToStringWithData.ts @@ -1,12 +1,15 @@ import type * as ReactTypes from "react"; import { getMarkupFromTree } from "./getDataFromTree.js"; import { renderToString } from "react-dom/server"; +import type { BatchOptions } from "./types.js"; export function renderToStringWithData( - component: ReactTypes.ReactElement + component: ReactTypes.ReactElement, + batchOptions?: BatchOptions ): Promise { return getMarkupFromTree({ tree: component, renderFunction: renderToString, + batchOptions, }); } diff --git a/src/react/ssr/types.ts b/src/react/ssr/types.ts new file mode 100644 index 00000000000..15f7c40a17b --- /dev/null +++ b/src/react/ssr/types.ts @@ -0,0 +1,3 @@ +export type BatchOptions = { + debounce?: number; +} From e23265741faf9f84653a4713cfa15769804119ad Mon Sep 17 00:00:00 2001 From: Daniel Kempner Date: Wed, 9 Jul 2025 21:39:59 -0700 Subject: [PATCH 2/6] fmt --- src/react/ssr/RenderPromises.ts | 2 +- src/react/ssr/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/ssr/RenderPromises.ts b/src/react/ssr/RenderPromises.ts index 607259e9f0b..0304016776e 100644 --- a/src/react/ssr/RenderPromises.ts +++ b/src/react/ssr/RenderPromises.ts @@ -161,7 +161,7 @@ export class RenderPromises { timeoutId = setTimeout(handleResolve, batchOptions.debounce); // Listen for the first promise to resolve - promises.forEach(promise => { + promises.forEach((promise) => { promise.then(handleResolve).catch(handleResolve); }); }); diff --git a/src/react/ssr/types.ts b/src/react/ssr/types.ts index 15f7c40a17b..3959eeb06ee 100644 --- a/src/react/ssr/types.ts +++ b/src/react/ssr/types.ts @@ -1,3 +1,3 @@ export type BatchOptions = { debounce?: number; -} +}; From aebf57f8e9a38664c47ed1e7ec5d8a61f36cb465 Mon Sep 17 00:00:00 2001 From: Daniel Kempner Date: Wed, 9 Jul 2025 21:44:04 -0700 Subject: [PATCH 3/6] changeset --- .changeset/breezy-gorillas-relate.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/breezy-gorillas-relate.md diff --git a/.changeset/breezy-gorillas-relate.md b/.changeset/breezy-gorillas-relate.md new file mode 100644 index 00000000000..a3c1e693304 --- /dev/null +++ b/.changeset/breezy-gorillas-relate.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +SSR functions take batchOptions, to modulate query batching From 672a73dc9c9948403a4e4cbab15e857543a00741 Mon Sep 17 00:00:00 2001 From: Daniel Kempner Date: Wed, 9 Jul 2025 21:52:12 -0700 Subject: [PATCH 4/6] tests --- src/react/ssr/RenderPromises.ts | 58 +++++- .../__tests__/RenderPromises.debounce.test.ts | 167 ++++++++++++++++++ src/react/ssr/getDataFromTree.ts | 31 +++- src/react/ssr/types.ts | 11 ++ 4 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 src/react/ssr/__tests__/RenderPromises.debounce.test.ts diff --git a/src/react/ssr/RenderPromises.ts b/src/react/ssr/RenderPromises.ts index 0304016776e..3a231ac9457 100644 --- a/src/react/ssr/RenderPromises.ts +++ b/src/react/ssr/RenderPromises.ts @@ -37,10 +37,14 @@ export class RenderPromises { // beyond a single call to renderToStaticMarkup. private queryInfoTrie = makeQueryInfoTrie(); + // Track resolved promises to prevent infinite loops in debounced mode + private resolvedPromises = new Set>(); + private stopped = false; public stop() { if (!this.stopped) { this.queryPromises.clear(); + this.resolvedPromises.clear(); this.queryInfoTrie = makeQueryInfoTrie(); this.stopped = true; } @@ -66,10 +70,17 @@ export class RenderPromises { finish?: () => ReactTypes.ReactNode ): ReactTypes.ReactNode { if (!this.stopped) { - const info = this.lookupQueryInfo(queryInstance.getOptions()); + const options = queryInstance.getOptions(); + const info = this.lookupQueryInfo(options); + + // Check if this query was already resolved in a previous batch + if (this.resolvedPromises.has(options)) { + return finish ? finish() : null; + } + if (!info.seen) { this.queryPromises.set( - queryInstance.getOptions(), + options, new Promise((resolve) => { resolve(queryInstance.fetchData()); }) @@ -121,6 +132,8 @@ export class RenderPromises { batchOptions?: BatchOptions; }) { const promises: Promise[] = []; + const promiseOptions: QueryDataOptions[] = []; + this.queryPromises.forEach((promise, queryInstance) => { // Make sure we never try to call fetchData for this query document and // these variables again. Since the queryInstance objects change with @@ -133,6 +146,7 @@ export class RenderPromises { // queryInstance.fetchData for the same Query component indefinitely. this.lookupQueryInfo(queryInstance).seen = true; promises.push(promise); + promiseOptions.push(queryInstance); }); this.queryPromises.clear(); @@ -142,27 +156,55 @@ export class RenderPromises { // If batchOptions with debounce is provided, use Promise.any behavior if (batchOptions?.debounce !== undefined) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let resolved = false; let timeoutId: NodeJS.Timeout | null = null; + let rejectedPromises = 0; + const totalPromises = promises.length; + const resolvedQueries = new Set>(); - const handleResolve = () => { + const handleResolve = (index: number) => { + resolvedQueries.add(promiseOptions[index]); if (!resolved) { resolved = true; if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } + // Mark these as resolved for the next render cycle + resolvedQueries.forEach(query => this.resolvedPromises.add(query)); resolve(undefined); } }; + const handleReject = (index: number, 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(handleResolve, batchOptions.debounce); + timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + // Mark all as resolved when timeout occurs + promiseOptions.forEach(query => this.resolvedPromises.add(query)); + resolve(undefined); + } + }, batchOptions.debounce); - // Listen for the first promise to resolve - promises.forEach((promise) => { - promise.then(handleResolve).catch(handleResolve); + // Listen for promises to resolve or reject + promises.forEach((promise, index) => { + promise.then(() => handleResolve(index)).catch((error) => handleReject(index, error)); }); }); } diff --git a/src/react/ssr/__tests__/RenderPromises.debounce.test.ts b/src/react/ssr/__tests__/RenderPromises.debounce.test.ts new file mode 100644 index 00000000000..fa25e7d04c4 --- /dev/null +++ b/src/react/ssr/__tests__/RenderPromises.debounce.test.ts @@ -0,0 +1,167 @@ +import { RenderPromises } from "../RenderPromises.js"; +import type { QueryDataOptions } from "../../types/types.js"; +import { Kind } from "graphql"; + +describe("RenderPromises with debounced batching", () => { + let renderPromises: RenderPromises; + + beforeEach(() => { + renderPromises = new RenderPromises(); + }); + + afterEach(() => { + renderPromises.stop(); + }); + + it("should resolve on first promise in debounced mode", async () => { + const fastPromise = new Promise((resolve) => { + setTimeout(() => resolve(), 10); + }); + + const slowPromise = new Promise((resolve) => { + setTimeout(() => resolve(), 100); + }); + + const mockOptions1: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 1 } + }; + + const mockOptions2: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 2 } + }; + + // Add promises to the map directly for testing + (renderPromises as any).queryPromises.set(mockOptions1, fastPromise); + (renderPromises as any).queryPromises.set(mockOptions2, slowPromise); + + const startTime = Date.now(); + + await renderPromises.consumeAndAwaitPromises({ + batchOptions: { debounce: 50 } + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should resolve quickly (around 10ms) not wait for the slow promise + expect(duration).toBeLessThan(50); + }); + + it("should handle promise rejections correctly in debounced mode", async () => { + const fastPromise = Promise.resolve(); + const failingPromise = Promise.reject(new Error("Test error")); + + const mockOptions1: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 1 } + }; + + const mockOptions2: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 2 } + }; + + (renderPromises as any).queryPromises.set(mockOptions1, fastPromise); + (renderPromises as any).queryPromises.set(mockOptions2, failingPromise); + + // Should resolve successfully because fastPromise resolves first + await expect( + renderPromises.consumeAndAwaitPromises({ + batchOptions: { debounce: 10 } + }) + ).resolves.toBeUndefined(); + }); + + it("should reject when all promises fail in debounced mode", async () => { + const failingPromise1 = Promise.reject(new Error("Error 1")); + const failingPromise2 = Promise.reject(new Error("Error 2")); + + const mockOptions1: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 1 } + }; + + const mockOptions2: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 2 } + }; + + (renderPromises as any).queryPromises.set(mockOptions1, failingPromise1); + (renderPromises as any).queryPromises.set(mockOptions2, failingPromise2); + + // Should reject when all promises fail + await expect( + renderPromises.consumeAndAwaitPromises({ + batchOptions: { debounce: 10 } + }) + ).rejects.toThrow("All 2 queries failed during SSR"); + }); + + it("should timeout when debounce expires", async () => { + const slowPromise = new Promise((resolve) => { + setTimeout(() => resolve(), 100); + }); + + const mockOptions: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 1 } + }; + + (renderPromises as any).queryPromises.set(mockOptions, slowPromise); + + const startTime = Date.now(); + + await renderPromises.consumeAndAwaitPromises({ + batchOptions: { debounce: 20 } + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should timeout around 20ms, not wait for the 100ms promise + expect(duration).toBeGreaterThanOrEqual(15); + expect(duration).toBeLessThan(50); + }); + + it("should track resolved promises to prevent infinite loops", async () => { + const mockOptions: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 1 } + }; + + const promise = Promise.resolve(); + + (renderPromises as any).queryPromises.set(mockOptions, promise); + + await renderPromises.consumeAndAwaitPromises({ + batchOptions: { debounce: 10 } + }); + + // The promise should be marked as resolved + expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe(true); + + // Adding the same query again should not create a new promise + const result = renderPromises.addQueryPromise({ + getOptions: () => mockOptions, + fetchData: () => Promise.resolve() + }); + + // Should return finish() instead of null (indicating no new promise was created) + expect(result).toBe(null); + }); + + it("should clear resolved promises on stop", () => { + const mockOptions: QueryDataOptions = { + query: { kind: Kind.DOCUMENT, definitions: [] }, + variables: { id: 1 } + }; + + (renderPromises as any).resolvedPromises.add(mockOptions); + expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe(true); + + renderPromises.stop(); + expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe(false); + }); +}); diff --git a/src/react/ssr/getDataFromTree.ts b/src/react/ssr/getDataFromTree.ts index 5507b7898c5..6d0e738c839 100644 --- a/src/react/ssr/getDataFromTree.ts +++ b/src/react/ssr/getDataFromTree.ts @@ -39,6 +39,8 @@ export function getMarkupFromTree({ batchOptions, }: GetMarkupFromTreeOptions): Promise { const renderPromises = new RenderPromises(); + let iterationCount = 0; + const MAX_ITERATIONS = 50; // Prevent infinite loops function process(): Promise { // Always re-render from the rootElement, even though it might seem @@ -60,7 +62,34 @@ export function getMarkupFromTree({ return renderPromises.hasPromises() ? renderPromises .consumeAndAwaitPromises({ batchOptions }) - .then(process) + .then(() => { + iterationCount++; + + // Safety check to prevent infinite loops + if (iterationCount > MAX_ITERATIONS) { + console.warn( + `SSR: Exceeded maximum iterations (${MAX_ITERATIONS}). ` + + `This might indicate an infinite loop in the SSR process. ` + + `Consider checking for queries that never resolve or circular dependencies.` + ); + return html; // Return current HTML to prevent infinite loop + } + + // If we still have promises after consumption, something went wrong + if (renderPromises.hasPromises()) { + console.warn( + 'SSR: Still have promises after consumption, this might indicate a bug. ' + + 'Continuing with next iteration...' + ); + } + + return process(); + }) + .catch((error) => { + console.error('SSR: Error during promise consumption:', error); + // Return current HTML on error to prevent complete failure + return html; + }) : html; }) .finally(() => { diff --git a/src/react/ssr/types.ts b/src/react/ssr/types.ts index 3959eeb06ee..c8012b89a90 100644 --- a/src/react/ssr/types.ts +++ b/src/react/ssr/types.ts @@ -1,3 +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; }; From 9a8ff8b72d02102b1942b8b7be14565b70da7fce Mon Sep 17 00:00:00 2001 From: Daniel Kempner Date: Wed, 9 Jul 2025 21:54:09 -0700 Subject: [PATCH 5/6] fmt --- src/react/ssr/RenderPromises.ts | 18 ++- .../__tests__/RenderPromises.debounce.test.ts | 42 +++--- .../__tests__/getDataFromTree.batch.test.tsx | 136 ++++++++++++++++++ src/react/ssr/getDataFromTree.ts | 10 +- 4 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 src/react/ssr/__tests__/getDataFromTree.batch.test.tsx diff --git a/src/react/ssr/RenderPromises.ts b/src/react/ssr/RenderPromises.ts index 3a231ac9457..5e912f86d64 100644 --- a/src/react/ssr/RenderPromises.ts +++ b/src/react/ssr/RenderPromises.ts @@ -172,7 +172,9 @@ export class RenderPromises { timeoutId = null; } // Mark these as resolved for the next render cycle - resolvedQueries.forEach(query => this.resolvedPromises.add(query)); + resolvedQueries.forEach((query) => + this.resolvedPromises.add(query) + ); resolve(undefined); } }; @@ -187,7 +189,13 @@ export class RenderPromises { clearTimeout(timeoutId); timeoutId = null; } - reject(new Error(`All ${totalPromises} queries failed during SSR: ${error?.message || 'Unknown error'}`)); + reject( + new Error( + `All ${totalPromises} queries failed during SSR: ${ + error?.message || "Unknown error" + }` + ) + ); } } }; @@ -197,14 +205,16 @@ export class RenderPromises { if (!resolved) { resolved = true; // Mark all as resolved when timeout occurs - promiseOptions.forEach(query => this.resolvedPromises.add(query)); + promiseOptions.forEach((query) => this.resolvedPromises.add(query)); resolve(undefined); } }, batchOptions.debounce); // Listen for promises to resolve or reject promises.forEach((promise, index) => { - promise.then(() => handleResolve(index)).catch((error) => handleReject(index, error)); + promise + .then(() => handleResolve(index)) + .catch((error) => handleReject(index, error)); }); }); } diff --git a/src/react/ssr/__tests__/RenderPromises.debounce.test.ts b/src/react/ssr/__tests__/RenderPromises.debounce.test.ts index fa25e7d04c4..7b93e6b0e2c 100644 --- a/src/react/ssr/__tests__/RenderPromises.debounce.test.ts +++ b/src/react/ssr/__tests__/RenderPromises.debounce.test.ts @@ -24,12 +24,12 @@ describe("RenderPromises with debounced batching", () => { const mockOptions1: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 1 } + variables: { id: 1 }, }; const mockOptions2: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 2 } + variables: { id: 2 }, }; // Add promises to the map directly for testing @@ -39,7 +39,7 @@ describe("RenderPromises with debounced batching", () => { const startTime = Date.now(); await renderPromises.consumeAndAwaitPromises({ - batchOptions: { debounce: 50 } + batchOptions: { debounce: 50 }, }); const endTime = Date.now(); @@ -55,12 +55,12 @@ describe("RenderPromises with debounced batching", () => { const mockOptions1: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 1 } + variables: { id: 1 }, }; const mockOptions2: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 2 } + variables: { id: 2 }, }; (renderPromises as any).queryPromises.set(mockOptions1, fastPromise); @@ -69,7 +69,7 @@ describe("RenderPromises with debounced batching", () => { // Should resolve successfully because fastPromise resolves first await expect( renderPromises.consumeAndAwaitPromises({ - batchOptions: { debounce: 10 } + batchOptions: { debounce: 10 }, }) ).resolves.toBeUndefined(); }); @@ -80,12 +80,12 @@ describe("RenderPromises with debounced batching", () => { const mockOptions1: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 1 } + variables: { id: 1 }, }; const mockOptions2: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 2 } + variables: { id: 2 }, }; (renderPromises as any).queryPromises.set(mockOptions1, failingPromise1); @@ -94,7 +94,7 @@ describe("RenderPromises with debounced batching", () => { // Should reject when all promises fail await expect( renderPromises.consumeAndAwaitPromises({ - batchOptions: { debounce: 10 } + batchOptions: { debounce: 10 }, }) ).rejects.toThrow("All 2 queries failed during SSR"); }); @@ -106,7 +106,7 @@ describe("RenderPromises with debounced batching", () => { const mockOptions: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 1 } + variables: { id: 1 }, }; (renderPromises as any).queryPromises.set(mockOptions, slowPromise); @@ -114,7 +114,7 @@ describe("RenderPromises with debounced batching", () => { const startTime = Date.now(); await renderPromises.consumeAndAwaitPromises({ - batchOptions: { debounce: 20 } + batchOptions: { debounce: 20 }, }); const endTime = Date.now(); @@ -128,7 +128,7 @@ describe("RenderPromises with debounced batching", () => { it("should track resolved promises to prevent infinite loops", async () => { const mockOptions: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 1 } + variables: { id: 1 }, }; const promise = Promise.resolve(); @@ -136,16 +136,18 @@ describe("RenderPromises with debounced batching", () => { (renderPromises as any).queryPromises.set(mockOptions, promise); await renderPromises.consumeAndAwaitPromises({ - batchOptions: { debounce: 10 } + batchOptions: { debounce: 10 }, }); // The promise should be marked as resolved - expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe(true); + expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe( + true + ); // Adding the same query again should not create a new promise const result = renderPromises.addQueryPromise({ getOptions: () => mockOptions, - fetchData: () => Promise.resolve() + fetchData: () => Promise.resolve(), }); // Should return finish() instead of null (indicating no new promise was created) @@ -155,13 +157,17 @@ describe("RenderPromises with debounced batching", () => { it("should clear resolved promises on stop", () => { const mockOptions: QueryDataOptions = { query: { kind: Kind.DOCUMENT, definitions: [] }, - variables: { id: 1 } + variables: { id: 1 }, }; (renderPromises as any).resolvedPromises.add(mockOptions); - expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe(true); + expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe( + true + ); renderPromises.stop(); - expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe(false); + expect((renderPromises as any).resolvedPromises.has(mockOptions)).toBe( + false + ); }); }); 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..e447fcac8a9 --- /dev/null +++ b/src/react/ssr/__tests__/getDataFromTree.batch.test.tsx @@ -0,0 +1,136 @@ +import * as React from "react"; +import { getDataFromTree, renderToStringWithData } from "../index.js"; +import { ApolloClient, InMemoryCache } from "../../../core/index.js"; +import { ApolloProvider } from "../../context/index.js"; +import { useQuery } from "../../hooks/index.js"; +import { gql } from "../../../core/index.js"; +import { ApolloLink } from "../../../link/core/index.js"; +import { Observable } from "../../../utilities/index.js"; + +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 InMemoryCache(), + 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).toContain("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).toContain("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).toContain("Query 1: success"); + expect(view).toContain("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).toContain("Data: success"); + }); +}); diff --git a/src/react/ssr/getDataFromTree.ts b/src/react/ssr/getDataFromTree.ts index 6d0e738c839..bfefd749b53 100644 --- a/src/react/ssr/getDataFromTree.ts +++ b/src/react/ssr/getDataFromTree.ts @@ -69,8 +69,8 @@ export function getMarkupFromTree({ if (iterationCount > MAX_ITERATIONS) { console.warn( `SSR: Exceeded maximum iterations (${MAX_ITERATIONS}). ` + - `This might indicate an infinite loop in the SSR process. ` + - `Consider checking for queries that never resolve or circular dependencies.` + `This might indicate an infinite loop in the SSR process. ` + + `Consider checking for queries that never resolve or circular dependencies.` ); return html; // Return current HTML to prevent infinite loop } @@ -78,15 +78,15 @@ export function getMarkupFromTree({ // If we still have promises after consumption, something went wrong if (renderPromises.hasPromises()) { console.warn( - 'SSR: Still have promises after consumption, this might indicate a bug. ' + - 'Continuing with next iteration...' + "SSR: Still have promises after consumption, this might indicate a bug. " + + "Continuing with next iteration..." ); } return process(); }) .catch((error) => { - console.error('SSR: Error during promise consumption:', error); + console.error("SSR: Error during promise consumption:", error); // Return current HTML on error to prevent complete failure return html; }) From 6aacf06b5fd920c6ef81b3f3cd538e662809797a Mon Sep 17 00:00:00 2001 From: Daniel Kempner Date: Wed, 9 Jul 2025 22:10:48 -0700 Subject: [PATCH 6/6] test --- .../ssr/__tests__/getDataFromTree.batch.test.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/react/ssr/__tests__/getDataFromTree.batch.test.tsx b/src/react/ssr/__tests__/getDataFromTree.batch.test.tsx index e447fcac8a9..138bc07cd78 100644 --- a/src/react/ssr/__tests__/getDataFromTree.batch.test.tsx +++ b/src/react/ssr/__tests__/getDataFromTree.batch.test.tsx @@ -1,3 +1,4 @@ +/** @jest-environment node */ import * as React from "react"; import { getDataFromTree, renderToStringWithData } from "../index.js"; import { ApolloClient, InMemoryCache } from "../../../core/index.js"; @@ -60,7 +61,7 @@ describe("getDataFromTree with batch options", () => { // Should complete within a reasonable time expect(duration).toBeLessThan(200); - expect(view).toContain("Data: success"); + expect(view).toMatch(/Data:.*success/); }); it("should work with renderToStringWithData and batching", async () => { @@ -81,7 +82,7 @@ describe("getDataFromTree with batch options", () => { // Should complete within a reasonable time expect(duration).toBeLessThan(150); - expect(view).toContain("Data: success"); + expect(view).toMatch(/Data:.*success/); }); it("should handle multiple queries with debouncing", async () => { @@ -118,8 +119,8 @@ describe("getDataFromTree with batch options", () => { // Should resolve quickly due to debouncing expect(duration).toBeLessThan(100); - expect(view).toContain("Query 1: success"); - expect(view).toContain("Query 2: success"); + 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 () => { @@ -131,6 +132,6 @@ describe("getDataFromTree with batch options", () => { const view = await getDataFromTree(element); - expect(view).toContain("Data: success"); + expect(view).toMatch(/Data:.*success/); }); });