From 702fe8e8466dc65ab1ae3ffaaea7ed8889edb0f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:13:43 +0000 Subject: [PATCH 1/3] fix: evict stale defer entries in dev server to bound registry memory The RSC-side deferRegistry is a module-level singleton; in dev every render registers fresh entries (with new random IDs) that were never evicted, so pending elements and drained payload strings accumulated for the lifetime of the dev session. Entries now record a last-accessed timestamp (refreshed on load), and the dev request handlers (serveHTML / serveRSC) drop entries not touched within a 5-minute TTL. The TTL leaves a generous window for lazily-rendered DeferredComponents to still fetch their payload, and evicting an entry does not affect responses already holding its stream. Production builds are unchanged: eviction only runs from dev handlers. DeferRegistry is extracted to its own module with the RSC renderer injected, so the registry (including eviction and nested-defer loadAll behavior) is now covered by unit tests. Closes #144 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu --- packages/static/src/rsc/defer.tsx | 157 +------------- packages/static/src/rsc/deferRegistry.test.ts | 158 ++++++++++++++ packages/static/src/rsc/deferRegistry.ts | 198 ++++++++++++++++++ packages/static/src/rsc/entry.tsx | 8 +- 4 files changed, 373 insertions(+), 148 deletions(-) create mode 100644 packages/static/src/rsc/deferRegistry.test.ts create mode 100644 packages/static/src/rsc/deferRegistry.ts diff --git a/packages/static/src/rsc/defer.tsx b/packages/static/src/rsc/defer.tsx index 82e56a5..6d301b8 100644 --- a/packages/static/src/rsc/defer.tsx +++ b/packages/static/src/rsc/defer.tsx @@ -1,15 +1,16 @@ import type { ReactElement, ReactNode } from "react"; import { renderToReadableStream } from "@vitejs/plugin-rsc/react/rsc"; import { DeferredComponent } from "#rsc-client"; -import { drainStream } from "../util/drainStream"; +import { DeferRegistry } from "./deferRegistry"; import { getPayloadIDFor } from "./rscModule"; import { rscPayloadDir } from "virtual:funstack/config"; -export interface DeferEntry { - state: DeferEntryState; - name?: string; - drainPromise?: Promise; -} +export { + DeferRegistry, + devDeferEntryTTL, + type DeferEntry, + type LoadedDeferEntry, +} from "./deferRegistry"; /** * Options for the defer function. @@ -23,28 +24,6 @@ export interface DeferOptions { name?: string; } -export interface LoadedDeferEntry extends DeferEntry { - state: Exclude; - drainPromise: Promise; -} - -type DeferEntryState = - | { - state: "pending"; - element: ReactElement; - } - | { - state: "streaming"; - stream: ReadableStream; - } - | { - state: "ready"; - } - | { - state: "error"; - error: unknown; - }; - /** * Sanitizes a name for use in file paths. * Replaces non-alphanumeric characters with underscores and limits length. @@ -57,125 +36,9 @@ function sanitizeName(name: string): string { .slice(0, 50); } -export class DeferRegistry { - #registry = new Map(); - - register(element: ReactElement, id: string, name?: string) { - this.#registry.set(id, { state: { element, state: "pending" }, name }); - } - - load(id: string): LoadedDeferEntry | undefined { - const entry = this.#registry.get(id); - if (!entry) { - return undefined; - } - return this.#loadEntry(entry); - } - - #loadEntry(entry: DeferEntry): LoadedDeferEntry { - const { state } = entry; - switch (state.state) { - case "pending": { - const stream = renderToReadableStream(state.element); - const [stream1, stream2] = stream.tee(); - entry.state = { state: "streaming", stream: stream1 }; - const drainPromise = drainStream(stream2); - entry.drainPromise = drainPromise; - drainPromise.then( - () => { - entry.state = { state: "ready" }; - }, - (error) => { - entry.state = { state: "error", error }; - }, - ); - return entry as LoadedDeferEntry; - } - case "streaming": - case "ready": - case "error": - return entry as LoadedDeferEntry; - } - } - - has(id: string): boolean { - return this.#registry.has(id); - } - - /** - * Iterates over all entries in parallel. - * Yields results as each stream completes. - * - * Rendering a deferred element may itself call `defer()` (nested defer), - * registering new entries while earlier ones are still draining. Entries - * registered mid-iteration are picked up too, until none are left. - */ - async *loadAll() { - const errors: unknown[] = []; - - type Result = { id: string; data: string; name?: string }; - - // Completion queue - const completed: Array = []; - let waiting: (() => void) | undefined; - let remainingCount = 0; - const started = new Set(); - - // Start loading every entry not started yet and track its drain promise. - // We use drain promises (which drain stream2 from tee) instead of - // draining stream1 directly, because stream1 may have been locked - // by createFromReadableStream during SSR. - const startPending = () => { - for (const [id, entry] of this.#registry) { - if (started.has(id)) continue; - started.add(id); - const loaded = this.#loadEntry(entry); - remainingCount++; - loaded.drainPromise.then( - (data) => { - completed.push({ id, data, name: entry.name }); - remainingCount--; - waiting?.(); - }, - (error) => { - completed.push({ error }); - remainingCount--; - waiting?.(); - }, - ); - } - }; - - startPending(); - - // Yield from queue as results arrive - while (remainingCount > 0 || completed.length > 0) { - if (completed.length === 0) { - await new Promise((r) => { - waiting = r; - }); - waiting = undefined; - } - for (const result of completed.splice(0)) { - if ("error" in result) { - errors.push(result.error); - } else { - yield result; - } - } - // A drained entry may have registered nested entries during its - // render; any registration happens before its parent's drain promise - // resolves, so once remainingCount hits 0 no new entries can appear. - startPending(); - } - - if (errors.length > 0) { - throw new AggregateError(errors); - } - } -} - -export const deferRegistry = new DeferRegistry(); +export const deferRegistry = new DeferRegistry((element) => + renderToReadableStream(element), +); /** * Renders given Server Component into a separate RSC payload. diff --git a/packages/static/src/rsc/deferRegistry.test.ts b/packages/static/src/rsc/deferRegistry.test.ts new file mode 100644 index 0000000..ef7c610 --- /dev/null +++ b/packages/static/src/rsc/deferRegistry.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import type { ReactElement } from "react"; +import { DeferRegistry, devDeferEntryTTL } from "./deferRegistry"; + +const encoder = new TextEncoder(); + +/** + * Fake renderer that emits the element's `data-payload` prop as the stream + * content, so tests can verify what was rendered without the RSC runtime. + */ +function fakeRender(element: ReactElement): ReadableStream { + const payload = String( + (element.props as { "data-payload"?: string })["data-payload"] ?? "", + ); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + controller.close(); + }, + }); +} + +function element(payload: string): ReactElement { + return createElement("div", { "data-payload": payload }); +} + +describe("DeferRegistry", () => { + let registry: DeferRegistry; + + beforeEach(() => { + vi.useFakeTimers(); + registry = new DeferRegistry(fakeRender); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns undefined for unknown ids", () => { + expect(registry.load("nope")).toBeUndefined(); + }); + + it("loads a registered entry and drains its payload", async () => { + registry.register(element("hello"), "id1"); + const entry = registry.load("id1"); + expect(entry).toBeDefined(); + expect(entry?.state.state).toBe("streaming"); + await expect(entry?.drainPromise).resolves.toBe("hello"); + expect(entry?.state.state).toBe("ready"); + }); + + describe("evictStale", () => { + it("evicts entries older than the TTL and keeps fresh ones", () => { + registry.register(element("old"), "old"); + vi.advanceTimersByTime(devDeferEntryTTL + 1); + registry.register(element("fresh"), "fresh"); + + registry.evictStale(devDeferEntryTTL); + + expect(registry.has("old")).toBe(false); + expect(registry.has("fresh")).toBe(true); + }); + + it("keeps entries within the TTL", () => { + registry.register(element("a"), "a"); + vi.advanceTimersByTime(devDeferEntryTTL); + + registry.evictStale(devDeferEntryTTL); + + expect(registry.has("a")).toBe(true); + }); + + it("treats a load as an access that refreshes the TTL", () => { + registry.register(element("a"), "a"); + vi.advanceTimersByTime(devDeferEntryTTL - 1); + registry.load("a"); + vi.advanceTimersByTime(devDeferEntryTTL - 1); + + registry.evictStale(devDeferEntryTTL); + expect(registry.has("a")).toBe(true); + + vi.advanceTimersByTime(2); + registry.evictStale(devDeferEntryTTL); + expect(registry.has("a")).toBe(false); + }); + + it("evicts loaded (ready) entries once stale", async () => { + registry.register(element("a"), "a"); + const entry = registry.load("a"); + await entry?.drainPromise; + + vi.advanceTimersByTime(devDeferEntryTTL + 1); + registry.evictStale(devDeferEntryTTL); + + expect(registry.has("a")).toBe(false); + }); + + it("does not break an already-loaded entry when it is evicted", async () => { + registry.register(element("in-flight"), "a"); + const entry = registry.load("a"); + + vi.advanceTimersByTime(devDeferEntryTTL + 1); + registry.evictStale(devDeferEntryTTL); + expect(registry.has("a")).toBe(false); + + // The response holding the entry can still drain it. + await expect(entry?.drainPromise).resolves.toBe("in-flight"); + }); + }); + + describe("loadAll", () => { + it("yields all registered entries", async () => { + registry.register(element("one"), "id1", "first"); + registry.register(element("two"), "id2"); + + const results = []; + for await (const result of registry.loadAll()) { + results.push(result); + } + + results.sort((a, b) => a.id.localeCompare(b.id)); + expect(results).toEqual([ + { id: "id1", data: "one", name: "first" }, + { id: "id2", data: "two", name: undefined }, + ]); + }); + + it("picks up entries registered while draining (nested defer)", async () => { + const nested = element("nested"); + const parent: ReactElement = createElement("div", { + "data-payload": "parent", + }); + const renderWithNested = (el: ReactElement) => { + if (el === parent) { + return new ReadableStream({ + start(controller) { + // Simulates a nested defer() call during the parent's render. + registry.register(nested, "nested-id"); + controller.enqueue(encoder.encode("parent")); + controller.close(); + }, + }); + } + return fakeRender(el); + }; + registry = new DeferRegistry(renderWithNested); + registry.register(parent, "parent-id"); + + const ids = []; + for await (const result of registry.loadAll()) { + ids.push(result.id); + } + + expect(ids.sort()).toEqual(["nested-id", "parent-id"]); + }); + }); +}); diff --git a/packages/static/src/rsc/deferRegistry.ts b/packages/static/src/rsc/deferRegistry.ts new file mode 100644 index 0000000..574bb5e --- /dev/null +++ b/packages/static/src/rsc/deferRegistry.ts @@ -0,0 +1,198 @@ +import type { ReactElement } from "react"; +import { drainStream } from "../util/drainStream"; + +export interface DeferEntry { + state: DeferEntryState; + name?: string; + drainPromise?: Promise; + /** + * Timestamp (ms) of the last registration or load of this entry. + * Used by `evictStale` to drop entries no longer reachable by any client. + */ + lastAccessedAt: number; +} + +export interface LoadedDeferEntry extends DeferEntry { + state: Exclude; + drainPromise: Promise; +} + +type DeferEntryState = + | { + state: "pending"; + element: ReactElement; + } + | { + state: "streaming"; + stream: ReadableStream; + } + | { + state: "ready"; + } + | { + state: "error"; + error: unknown; + }; + +/** + * Renders a React element to an RSC payload stream. + * Injected so the registry does not depend on the RSC runtime directly. + */ +export type RenderToStream = ( + element: ReactElement, +) => ReadableStream; + +/** + * How long a defer entry is kept in the dev registry after it was last + * registered or loaded. Each dev render registers fresh entries (with new + * IDs), so old entries become unreachable once the client re-renders; + * without eviction the registry grows unboundedly over a dev session. + * The TTL leaves a generous window for lazily-rendered DeferredComponents + * to fetch their payload after the page loaded. + */ +export const devDeferEntryTTL = 5 * 60 * 1000; + +export class DeferRegistry { + #registry = new Map(); + #render: RenderToStream; + + constructor(render: RenderToStream) { + this.#render = render; + } + + register(element: ReactElement, id: string, name?: string) { + this.#registry.set(id, { + state: { element, state: "pending" }, + name, + lastAccessedAt: Date.now(), + }); + } + + load(id: string): LoadedDeferEntry | undefined { + const entry = this.#registry.get(id); + if (!entry) { + return undefined; + } + entry.lastAccessedAt = Date.now(); + return this.#loadEntry(entry); + } + + #loadEntry(entry: DeferEntry): LoadedDeferEntry { + const { state } = entry; + switch (state.state) { + case "pending": { + const stream = this.#render(state.element); + const [stream1, stream2] = stream.tee(); + entry.state = { state: "streaming", stream: stream1 }; + const drainPromise = drainStream(stream2); + entry.drainPromise = drainPromise; + drainPromise.then( + () => { + entry.state = { state: "ready" }; + }, + (error) => { + entry.state = { state: "error", error }; + }, + ); + return entry as LoadedDeferEntry; + } + case "streaming": + case "ready": + case "error": + return entry as LoadedDeferEntry; + } + } + + has(id: string): boolean { + return this.#registry.has(id); + } + + /** + * Drops entries that have not been registered or loaded within the given + * TTL. Called from dev server request handlers to keep the registry from + * growing unboundedly across renders; never called during a build. + * + * Evicting an entry does not cancel an in-flight render: responses + * already holding the entry's stream or drain promise are unaffected. + */ + evictStale(ttlMs: number): void { + const now = Date.now(); + for (const [id, entry] of this.#registry) { + if (now - entry.lastAccessedAt > ttlMs) { + this.#registry.delete(id); + } + } + } + + /** + * Iterates over all entries in parallel. + * Yields results as each stream completes. + * + * Rendering a deferred element may itself call `defer()` (nested defer), + * registering new entries while earlier ones are still draining. Entries + * registered mid-iteration are picked up too, until none are left. + */ + async *loadAll() { + const errors: unknown[] = []; + + type Result = { id: string; data: string; name?: string }; + + // Completion queue + const completed: Array = []; + let waiting: (() => void) | undefined; + let remainingCount = 0; + const started = new Set(); + + // Start loading every entry not started yet and track its drain promise. + // We use drain promises (which drain stream2 from tee) instead of + // draining stream1 directly, because stream1 may have been locked + // by createFromReadableStream during SSR. + const startPending = () => { + for (const [id, entry] of this.#registry) { + if (started.has(id)) continue; + started.add(id); + const loaded = this.#loadEntry(entry); + remainingCount++; + loaded.drainPromise.then( + (data) => { + completed.push({ id, data, name: entry.name }); + remainingCount--; + waiting?.(); + }, + (error) => { + completed.push({ error }); + remainingCount--; + waiting?.(); + }, + ); + } + }; + + startPending(); + + // Yield from queue as results arrive + while (remainingCount > 0 || completed.length > 0) { + if (completed.length === 0) { + await new Promise((r) => { + waiting = r; + }); + waiting = undefined; + } + for (const result of completed.splice(0)) { + if ("error" in result) { + errors.push(result.error); + } else { + yield result; + } + } + // A drained entry may have registered nested entries during its + // render; any registration happens before its parent's drain promise + // resolves, so once remainingCount hits 0 no new entries can appear. + startPending(); + } + + if (errors.length > 0) { + throw new AggregateError(errors); + } + } +} diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx index 24d6203..1e9e5cc 100644 --- a/packages/static/src/rsc/entry.tsx +++ b/packages/static/src/rsc/entry.tsx @@ -2,7 +2,7 @@ import "./defer"; import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc"; import { devMainRscPath } from "./request"; import { generateAppMarker } from "./marker"; -import { deferRegistry } from "./defer"; +import { deferRegistry, devDeferEntryTTL } from "./defer"; import { extractIDFromModulePath } from "./rscModule"; import { stripBasePath } from "../util/basePath"; import { urlPathToFileCandidates } from "../util/urlPath"; @@ -145,6 +145,10 @@ async function renderEntryToResponse( * Accepts a Request to determine which entry to render based on URL path. */ export async function serveHTML(request: Request): Promise { + // Each dev render registers fresh defer entries; drop stale ones so the + // registry does not grow unboundedly over a long dev session (#144). + deferRegistry.evictStale(devDeferEntryTTL); + const timings: string[] = []; const entriesStart = performance.now(); @@ -182,6 +186,8 @@ export function isServeRSCError(error: unknown): error is ServeRSCError { * Serves an RSC stream response */ export async function serveRSC(request: Request): Promise { + deferRegistry.evictStale(devDeferEntryTTL); + const timings: string[] = []; const url = new URL(request.url); const pathname = stripBasePath(url.pathname); From 72fc575c567b2c3fb126694fcef93d338cf8fa6f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 00:57:59 +0000 Subject: [PATCH 2/3] fix: exempt pending defer entries from TTL eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferred content may be fetched arbitrarily late — e.g. content inside an accordion UI that is rarely opened — so evicting pending entries by time would 404 the fetch on long-idle pages. Pending entries (which retain only the React element) are now never evicted by time; instead they are capped at 1000, evicting the oldest first. The TTL applies only to settled entries (which retain the rendered payload string, the bulk of the leak); those are safe to evict because the client caches fetched payloads per module ID and never legitimately re-fetches them. The cap is enforced in evictStale (dev handlers only), not register, so builds registering many defers before loadAll are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu --- packages/static/src/rsc/defer.tsx | 2 +- packages/static/src/rsc/deferRegistry.test.ts | 81 +++++++++++++------ packages/static/src/rsc/deferRegistry.ts | 62 +++++++++++--- packages/static/src/rsc/entry.tsx | 6 +- 4 files changed, 110 insertions(+), 41 deletions(-) diff --git a/packages/static/src/rsc/defer.tsx b/packages/static/src/rsc/defer.tsx index 6d301b8..3a5a3bd 100644 --- a/packages/static/src/rsc/defer.tsx +++ b/packages/static/src/rsc/defer.tsx @@ -7,7 +7,7 @@ import { rscPayloadDir } from "virtual:funstack/config"; export { DeferRegistry, - devDeferEntryTTL, + devDeferEvictionOptions, type DeferEntry, type LoadedDeferEntry, } from "./deferRegistry"; diff --git a/packages/static/src/rsc/deferRegistry.test.ts b/packages/static/src/rsc/deferRegistry.test.ts index ef7c610..5f190d9 100644 --- a/packages/static/src/rsc/deferRegistry.test.ts +++ b/packages/static/src/rsc/deferRegistry.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createElement } from "react"; import type { ReactElement } from "react"; -import { DeferRegistry, devDeferEntryTTL } from "./deferRegistry"; +import { DeferRegistry, devDeferEvictionOptions } from "./deferRegistry"; + +const { ttlMs } = devDeferEvictionOptions; const encoder = new TextEncoder(); @@ -51,48 +53,52 @@ describe("DeferRegistry", () => { }); describe("evictStale", () => { - it("evicts entries older than the TTL and keeps fresh ones", () => { + it("never evicts pending entries by time", () => { + // A pending entry may be fetched arbitrarily late, e.g. deferred + // content inside an accordion that is rarely opened. + registry.register(element("accordion"), "a"); + vi.advanceTimersByTime(ttlMs * 100); + + registry.evictStale(devDeferEvictionOptions); + + expect(registry.has("a")).toBe(true); + }); + + it("evicts settled entries older than the TTL and keeps fresh ones", async () => { registry.register(element("old"), "old"); - vi.advanceTimersByTime(devDeferEntryTTL + 1); + await registry.load("old")?.drainPromise; + vi.advanceTimersByTime(ttlMs + 1); registry.register(element("fresh"), "fresh"); + await registry.load("fresh")?.drainPromise; - registry.evictStale(devDeferEntryTTL); + registry.evictStale(devDeferEvictionOptions); expect(registry.has("old")).toBe(false); expect(registry.has("fresh")).toBe(true); }); - it("keeps entries within the TTL", () => { + it("keeps settled entries within the TTL", async () => { registry.register(element("a"), "a"); - vi.advanceTimersByTime(devDeferEntryTTL); + await registry.load("a")?.drainPromise; + vi.advanceTimersByTime(ttlMs); - registry.evictStale(devDeferEntryTTL); + registry.evictStale(devDeferEvictionOptions); expect(registry.has("a")).toBe(true); }); - it("treats a load as an access that refreshes the TTL", () => { + it("treats a load as an access that refreshes the TTL", async () => { registry.register(element("a"), "a"); - vi.advanceTimersByTime(devDeferEntryTTL - 1); + await registry.load("a")?.drainPromise; + vi.advanceTimersByTime(ttlMs - 1); registry.load("a"); - vi.advanceTimersByTime(devDeferEntryTTL - 1); + vi.advanceTimersByTime(ttlMs - 1); - registry.evictStale(devDeferEntryTTL); + registry.evictStale(devDeferEvictionOptions); expect(registry.has("a")).toBe(true); vi.advanceTimersByTime(2); - registry.evictStale(devDeferEntryTTL); - expect(registry.has("a")).toBe(false); - }); - - it("evicts loaded (ready) entries once stale", async () => { - registry.register(element("a"), "a"); - const entry = registry.load("a"); - await entry?.drainPromise; - - vi.advanceTimersByTime(devDeferEntryTTL + 1); - registry.evictStale(devDeferEntryTTL); - + registry.evictStale(devDeferEvictionOptions); expect(registry.has("a")).toBe(false); }); @@ -100,13 +106,38 @@ describe("DeferRegistry", () => { registry.register(element("in-flight"), "a"); const entry = registry.load("a"); - vi.advanceTimersByTime(devDeferEntryTTL + 1); - registry.evictStale(devDeferEntryTTL); + vi.advanceTimersByTime(ttlMs + 1); + registry.evictStale(devDeferEvictionOptions); expect(registry.has("a")).toBe(false); // The response holding the entry can still drain it. await expect(entry?.drainPromise).resolves.toBe("in-flight"); }); + + it("caps pending entries, evicting the oldest first", () => { + registry.register(element("p1"), "p1"); + registry.register(element("p2"), "p2"); + registry.register(element("p3"), "p3"); + + registry.evictStale({ ttlMs, maxPending: 2 }); + + expect(registry.has("p1")).toBe(false); + expect(registry.has("p2")).toBe(true); + expect(registry.has("p3")).toBe(true); + }); + + it("does not count settled entries toward the pending cap", async () => { + registry.register(element("s1"), "s1"); + await registry.load("s1")?.drainPromise; + registry.register(element("p1"), "p1"); + registry.register(element("p2"), "p2"); + + registry.evictStale({ ttlMs, maxPending: 2 }); + + expect(registry.has("s1")).toBe(true); + expect(registry.has("p1")).toBe(true); + expect(registry.has("p2")).toBe(true); + }); }); describe("loadAll", () => { diff --git a/packages/static/src/rsc/deferRegistry.ts b/packages/static/src/rsc/deferRegistry.ts index 574bb5e..46e0a87 100644 --- a/packages/static/src/rsc/deferRegistry.ts +++ b/packages/static/src/rsc/deferRegistry.ts @@ -42,15 +42,33 @@ export type RenderToStream = ( element: ReactElement, ) => ReadableStream; +export interface EvictStaleOptions { + /** + * How long a settled (streaming/ready/error) entry is kept after it was + * last loaded. Once served, the client caches the payload per module ID + * and never legitimately re-fetches it, so a short TTL is safe. + */ + ttlMs: number; + /** + * Maximum number of pending entries to keep (oldest evicted first). + * Pending entries are never evicted by time: a DeferredComponent may + * fetch its payload arbitrarily late (e.g. content inside an accordion + * that is rarely opened), so they stay until enough newer registrations + * push them out. + */ + maxPending: number; +} + /** - * How long a defer entry is kept in the dev registry after it was last - * registered or loaded. Each dev render registers fresh entries (with new - * IDs), so old entries become unreachable once the client re-renders; - * without eviction the registry grows unboundedly over a dev session. - * The TTL leaves a generous window for lazily-rendered DeferredComponents - * to fetch their payload after the page loaded. + * Eviction policy for the dev server. Each dev render registers fresh + * entries (with new IDs), so old entries become unreachable once the + * client re-renders; without eviction the registry grows unboundedly + * over a dev session. */ -export const devDeferEntryTTL = 5 * 60 * 1000; +export const devDeferEvictionOptions: EvictStaleOptions = { + ttlMs: 5 * 60 * 1000, + maxPending: 1000, +}; export class DeferRegistry { #registry = new Map(); @@ -108,17 +126,37 @@ export class DeferRegistry { } /** - * Drops entries that have not been registered or loaded within the given - * TTL. Called from dev server request handlers to keep the registry from - * growing unboundedly across renders; never called during a build. + * Drops entries that are no longer expected to be fetched. Called from + * dev server request handlers to keep the registry from growing + * unboundedly across renders; never called during a build. + * + * Settled entries (which retain the rendered payload string) are dropped + * once they have not been loaded within `ttlMs`. Pending entries (which + * retain only the React element) are exempt from the TTL — deferred + * content may be fetched arbitrarily late — and are instead capped at + * `maxPending`, evicting the oldest first. * * Evicting an entry does not cancel an in-flight render: responses * already holding the entry's stream or drain promise are unaffected. */ - evictStale(ttlMs: number): void { + evictStale({ ttlMs, maxPending }: EvictStaleOptions): void { const now = Date.now(); + let pendingCount = 0; + for (const entry of this.#registry.values()) { + if (entry.state.state === "pending") { + pendingCount++; + } + } + // Map iteration is in insertion order, so the oldest pending + // entries are encountered (and evicted) first. + let pendingToEvict = Math.max(0, pendingCount - maxPending); for (const [id, entry] of this.#registry) { - if (now - entry.lastAccessedAt > ttlMs) { + if (entry.state.state === "pending") { + if (pendingToEvict > 0) { + this.#registry.delete(id); + pendingToEvict--; + } + } else if (now - entry.lastAccessedAt > ttlMs) { this.#registry.delete(id); } } diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx index 1e9e5cc..1c1a81c 100644 --- a/packages/static/src/rsc/entry.tsx +++ b/packages/static/src/rsc/entry.tsx @@ -2,7 +2,7 @@ import "./defer"; import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc"; import { devMainRscPath } from "./request"; import { generateAppMarker } from "./marker"; -import { deferRegistry, devDeferEntryTTL } from "./defer"; +import { deferRegistry, devDeferEvictionOptions } from "./defer"; import { extractIDFromModulePath } from "./rscModule"; import { stripBasePath } from "../util/basePath"; import { urlPathToFileCandidates } from "../util/urlPath"; @@ -147,7 +147,7 @@ async function renderEntryToResponse( export async function serveHTML(request: Request): Promise { // Each dev render registers fresh defer entries; drop stale ones so the // registry does not grow unboundedly over a long dev session (#144). - deferRegistry.evictStale(devDeferEntryTTL); + deferRegistry.evictStale(devDeferEvictionOptions); const timings: string[] = []; @@ -186,7 +186,7 @@ export function isServeRSCError(error: unknown): error is ServeRSCError { * Serves an RSC stream response */ export async function serveRSC(request: Request): Promise { - deferRegistry.evictStale(devDeferEntryTTL); + deferRegistry.evictStale(devDeferEvictionOptions); const timings: string[] = []; const url = new URL(request.url); From 40f9d7c8c17aea02bce6278fb478c3bce0d8c464 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 12:11:30 +0000 Subject: [PATCH 3/3] refactor: import defer registry types directly instead of via re-exports Internal modules now import DeferRegistry types and the eviction options from deferRegistry.ts directly; defer.tsx no longer re-exports them. The public API (defer, DeferOptions) is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu --- packages/static/src/rsc-client/clientWrapper.tsx | 2 +- packages/static/src/rsc/defer.tsx | 7 ------- packages/static/src/rsc/entry.tsx | 3 ++- packages/static/src/ssr/entry.tsx | 2 +- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/static/src/rsc-client/clientWrapper.tsx b/packages/static/src/rsc-client/clientWrapper.tsx index cb689ec..59b948d 100644 --- a/packages/static/src/rsc-client/clientWrapper.tsx +++ b/packages/static/src/rsc-client/clientWrapper.tsx @@ -2,7 +2,7 @@ import React from "react"; import { createFromFetch } from "@vitejs/plugin-rsc/browser"; import { getModulePathFor } from "../rsc/rscModule"; import { createContext, use } from "react"; -import type { LoadedDeferEntry, DeferRegistry } from "../rsc/defer"; +import type { LoadedDeferEntry, DeferRegistry } from "../rsc/deferRegistry"; import { withBasePath } from "../util/basePath"; interface DeferContextValue { diff --git a/packages/static/src/rsc/defer.tsx b/packages/static/src/rsc/defer.tsx index 3a5a3bd..1626861 100644 --- a/packages/static/src/rsc/defer.tsx +++ b/packages/static/src/rsc/defer.tsx @@ -5,13 +5,6 @@ import { DeferRegistry } from "./deferRegistry"; import { getPayloadIDFor } from "./rscModule"; import { rscPayloadDir } from "virtual:funstack/config"; -export { - DeferRegistry, - devDeferEvictionOptions, - type DeferEntry, - type LoadedDeferEntry, -} from "./deferRegistry"; - /** * Options for the defer function. */ diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx index 1c1a81c..f3361d0 100644 --- a/packages/static/src/rsc/entry.tsx +++ b/packages/static/src/rsc/entry.tsx @@ -2,7 +2,8 @@ import "./defer"; import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc"; import { devMainRscPath } from "./request"; import { generateAppMarker } from "./marker"; -import { deferRegistry, devDeferEvictionOptions } from "./defer"; +import { deferRegistry } from "./defer"; +import { devDeferEvictionOptions } from "./deferRegistry"; import { extractIDFromModulePath } from "./rscModule"; import { stripBasePath } from "../util/basePath"; import { urlPathToFileCandidates } from "../util/urlPath"; diff --git a/packages/static/src/ssr/entry.tsx b/packages/static/src/ssr/entry.tsx index 6e1d45b..fe9ba35 100644 --- a/packages/static/src/ssr/entry.tsx +++ b/packages/static/src/ssr/entry.tsx @@ -7,7 +7,7 @@ import type { RscPayload } from "../rsc/entry"; import { appClientManifestVar } from "../client/globals"; import { rscPayloadPlaceholder } from "../build/rscPath"; import { preload } from "react-dom"; -import type { DeferRegistry } from "../rsc/defer"; +import type { DeferRegistry } from "../rsc/deferRegistry"; import { RegistryContext } from "#rsc-client"; export async function renderHTML(