fix: evict stale defer entries in dev server to bound registry memory#159
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #144
Problem
deferRegistryin the RSC environment is a module-level singleton. In dev, every HTML render callsdefer()with freshcrypto.randomUUID()IDs and registers new entries that are never evicted — pending entries retain the React element, and loaded entries retain the entire rendered payload string viadrainPromise. Over a long dev session, memory grows without bound.Fix
The dev-only request handlers (
serveHTML/serveRSC) call a newevictStale()method at the start of each request. The eviction policy distinguishes the two entry lifecycles:moduleToStreamMap), so even a remountedDeferredComponentnever re-fetches from the server.The cap is enforced in
evictStale()(called only from dev handlers), not inregister(), so production builds that register many defers beforeloadAll()are unaffected. Evicting an entry does not cancel an in-flight render: responses already holding the entry's stream or drain promise are unaffected.Refactoring
DeferRegistryis extracted into its own module (deferRegistry.ts) with the RSC renderer injected via the constructor. The RSC runtime cannot be imported outside areact-serverenvironment, so this is what makes the registry unit-testable. Internal modules import the registry class, types, and eviction options directly fromdeferRegistry.ts; the public API (defer,DeferOptions) stays indefer.tsxand is unchanged.Tests
loadAllbehavior including nested defers.ssr-defer), and all 29 build E2E tests pass.Remaining trade-off
A pending entry can only be lost while its page is still open if 1000+ newer
defer()registrations accumulate (e.g. leaving a tab open overnight while heavily editing with HMR). In that rare case the fetch 404s and a page refresh recovers. The policy constants live indevDeferEvictionOptionsand are easy to tune.🤖 Generated with Claude Code
https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu