Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
96 changes: 93 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,80 @@ 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
173 changes: 173 additions & 0 deletions src/react/ssr/__tests__/RenderPromises.debounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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
);
});
});
Loading