Files
HealthLog/src/__tests__/query-client-server-gc-guard.test.ts
T
Marc-André Bombeck 685b46fa04 fix(ssr): keep a server render's query cache from outliving the request
Every server render left its request reachable for ten minutes after the
response was sent. Providers builds its QueryClient once per render, and
the app's defaults give queries a ten-minute gcTime. On the server each
entry the render built, disabled observers included, started a timer of
that length, and a Node timer keeps the async context it was created in.
That context is the request's: the response, the React Flight stream and
the compressed body. A heap snapshot after a run of unauthenticated
probes showed one 600 s query-core timer per request and the retained
Flight chunks, ServerResponses and gzip buffers all hanging from them:
about 150 KB of heap and 90 KB of buffers per not-found render.

TanStack Query already defaults gcTime to Infinity on the server for this
reason; an explicit value from the defaults or a hook overrides it.
AppQueryClient folds Infinity back in at defaultQueryOptions, the merge
point every observer, cache build and hydration passes through, so the
server cache goes with the client at the end of the request. The browser
keeps its configured lifetimes.

The retention test reproduces the chain without a server: a response
object held only by an AsyncLocalStorage store must be collectable after
the render's client is dropped, with a positive control on a plain client
that must keep it alive. A guard keeps client modules from building a
QueryClient themselves and allows only option-less clients elsewhere.
2026-10-24 02:12:31 +02:00

105 lines
3.8 KiB
TypeScript

/**
* Every `QueryClient` a server render can build keeps no garbage-collection
* timer.
*
* A client component's `QueryClient` is built once per server render. With a
* finite `gcTime`, from its defaults or from a hook, each entry the render
* built started a timer of that length, and the timer kept the request's
* async context: the response, the Flight stream and the compressed body.
* Every page render stayed reachable for ten minutes after it was answered,
* and a scanner walking unknown paths filled the heap with not-found pages.
* `createAppQueryClient` (`src/lib/pwa/query-client-options.ts`) folds the
* library's server default (`Infinity`) back in; its behaviour is pinned by
* `src/lib/pwa/__tests__/query-client-server-retention.test.ts`.
*
* This guard keeps a client from being built around it:
* - a client module (`"use client"`) builds no `QueryClient` itself, it uses
* `createAppQueryClient`;
* - elsewhere, the only form allowed is a bare `new QueryClient()`, the
* server components' prefetch clients. Without options the library keeps
* `gcTime` at `Infinity` on the server, so a bare client schedules no timer
* unless a call site passes its own `gcTime`, which none of them do.
*
* Limit: the matcher is textual. A subclass of `QueryClient` declared outside
* the options module, or a client built through an alias, slips it.
*/
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
readSource,
readSourceText,
scanSourceMatches,
stripComments,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** `new QueryClient(` with its argument list's first character. */
const CONSTRUCTION = /\bnew\s+QueryClient\s*\(\s*(\S)?/g;
const USE_CLIENT = /^\s*["']use client["']/;
function isProductionSource(rel: string): boolean {
return (
!rel.startsWith("generated/") &&
!rel.includes("__tests__/") &&
!rel.includes("__mocks__/") &&
!/\.test\.tsx?$/.test(rel)
);
}
function constructions() {
return scanSourceMatches(SRC, CONSTRUCTION, {
fileFloor: 500,
// The dashboard, insights, medications, mood, coach and checkups pages
// each prefetch on a bare client.
matchFloor: 6,
include: isProductionSource,
stripComments: true,
});
}
describe("QueryClient server garbage-collection guard", () => {
it("builds no QueryClient inside a client module", () => {
const offenders = constructions()
.filter((match) =>
USE_CLIENT.test(
stripComments(readSourceText(join(SRC, match.file))).trimStart(),
),
)
.map((match) => `src/${match.file}:${match.line}`);
expect(offenders).toEqual([]);
});
it("passes no options to a QueryClient outside the app client", () => {
const offenders = constructions()
.filter((match) => !match.text.endsWith(")"))
.map((match) => `src/${match.file}:${match.line}`);
expect(offenders).toEqual([]);
});
it("mounts the app client in Providers", () => {
const source = stripComments(
readSource("src/components/providers.tsx", "QueryClientProvider"),
);
expect(source).toMatch(/useState\(\s*createAppQueryClient\s*\)/);
});
it("still matches the constructions it is meant to catch", () => {
const first = (code: string) => [...code.matchAll(CONSTRUCTION)][0]?.[0];
expect(first("const c = new QueryClient();")).toBe("new QueryClient()");
expect(first("new QueryClient({ defaultOptions })")).toBe(
"new QueryClient({",
);
expect(first("new QueryClient(\n {\n defaultOptions })")).toBe(
"new QueryClient(\n {",
);
expect(first("const c = createAppQueryClient();")).toBeUndefined();
expect(USE_CLIENT.test('"use client";\n\nimport x from "y";')).toBe(true);
expect(USE_CLIENT.test('import x from "y";\n"use client";')).toBe(false);
});
});