Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/breezy-gorillas-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": minor
---

SSR functions take batchOptions, to modulate query batching
86 changes: 83 additions & 3 deletions src/react/ssr/RenderPromises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -36,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<QueryDataOptions<any, any>>();

private stopped = false;
public stop() {
if (!this.stopped) {
this.queryPromises.clear();
this.resolvedPromises.clear();
this.queryInfoTrie = makeQueryInfoTrie();
this.stopped = true;
}
Expand All @@ -65,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());
})
Expand Down Expand Up @@ -114,8 +126,14 @@ export class RenderPromises {
return this.queryPromises.size > 0;
}

public consumeAndAwaitPromises() {
public consumeAndAwaitPromises({
batchOptions,
}: {
batchOptions?: BatchOptions;
}) {
const promises: Promise<any>[] = [];
const promiseOptions: QueryDataOptions<any, any>[] = [];

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
Expand All @@ -128,8 +146,70 @@ 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();

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, reject) => {
let resolved = false;
let timeoutId: NodeJS.Timeout | null = null;
let rejectedPromises = 0;
const totalPromises = promises.length;
const resolvedQueries = new Set<QueryDataOptions<any, any>>();

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(() => {
if (!resolved) {
resolved = true;
// Mark all as resolved when timeout occurs
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));
});
});
}

// Default behavior: wait for all promises (Promise.all)
return Promise.all(promises);
}

Expand Down
167 changes: 167 additions & 0 deletions src/react/ssr/__tests__/RenderPromises.debounce.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
setTimeout(() => resolve(), 10);
});

const slowPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), 100);
});

const mockOptions1: QueryDataOptions<any, any> = {
query: { kind: Kind.DOCUMENT, definitions: [] },
variables: { id: 1 }
};

const mockOptions2: QueryDataOptions<any, any> = {
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<any, any> = {
query: { kind: Kind.DOCUMENT, definitions: [] },
variables: { id: 1 }
};

const mockOptions2: QueryDataOptions<any, any> = {
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<any, any> = {
query: { kind: Kind.DOCUMENT, definitions: [] },
variables: { id: 1 }
};

const mockOptions2: QueryDataOptions<any, any> = {
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<void>((resolve) => {
setTimeout(() => resolve(), 100);
});

const mockOptions: QueryDataOptions<any, any> = {
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<any, any> = {
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<any, any> = {
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);
});
});
40 changes: 38 additions & 2 deletions src/react/ssr/getDataFromTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@ 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,
context,
// If you need to configure this renderFunction, call getMarkupFromTree
// directly instead of getDataFromTree.
renderFunction: renderToStaticMarkup,
batchOptions,
});
}

Expand All @@ -23,6 +26,7 @@ export type GetMarkupFromTreeOptions = {
renderFunction?: (
tree: ReactTypes.ReactElement<any>
) => string | PromiseLike<string>;
batchOptions?: BatchOptions;
};

export function getMarkupFromTree({
Expand All @@ -32,8 +36,11 @@ 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<string> {
const renderPromises = new RenderPromises();
let iterationCount = 0;
const MAX_ITERATIONS = 50; // Prevent infinite loops
Comment on lines +42 to +43
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is something we might pass in to batchOptions


function process(): Promise<string> {
// Always re-render from the rootElement, even though it might seem
Expand All @@ -53,7 +60,36 @@ export function getMarkupFromTree({
})
.then((html) => {
return renderPromises.hasPromises() ?
renderPromises.consumeAndAwaitPromises().then(process)
renderPromises
.consumeAndAwaitPromises({ batchOptions })
.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(() => {
Expand Down
Loading