Repository navigation
fix(router): handler-invoked loaders under cache() throw on header writes (#725 gap) + drop removed __debug_manifest docs - #730
Merged
Conversation
commit: |
…ites (closes #725 gap) The cached-scope header guard (#725) exempted every loader body under a cache() boundary via isInsideAnyLoaderScope(). That let a handler-invoked loader — await ctx.use(Loader) from a handler, never registered with loader() — write a cookie/header on the MISS. On a HIT the handler is skipped and only DSL-registered loaders re-run, so that loader never executes and its write silently vanishes — the exact MISS/HIT divergence #725 was built to eliminate. Narrow the exemption to isInsideLoaderScope() (DSL scope only): registered loaders re-run on every HIT and merge into every response, so their writes stay allowed; a handler-invoked loader now throws deterministically like a handler write. The DSL scope ALS survives nested ctx.use bodies, so a handler-invoked loader nested under a DSL loader stays exempt (its parent re-runs it every HIT). The broad predicate is deferred to the throw path for the error label so the PPR handler-invoked-loader message stays "loader", and the throw reason keys on scope.kind so the cache() case explains the handler-skip cause. Re-ground the neighbouring read-purity guard comment, which this change makes contradictory: reads stay on the broad predicate under the consumption-lane baked-copy tradeoff (#672/#674), only writes narrow to DSL scope. Unit (cached-header-guard.test.ts): DSL-allowed pin, handler-invoked-throws pin (red before this change, green after), nested-under-DSL-allowed pin. E2e (cache-scope-guard, dev + production): /handler-loader-cookie-blocked asserts the error boundary renders and the cookie never reaches the wire; the DSL /loader-cookie-allowed control stays green in both modes.
…ll and guide #728 deleted the ?__debug_manifest HTTP endpoint and its routeAncestry data, but the debug-manifest skill and docs guide still told users to open http://localhost:PORT/?__debug_manifest and documented the deleted { routerId, routeManifest, routeAncestry, routeTrie, precomputedEntries } shape. Remove both browser-endpoint sections; both files already carry the correct router.debugManifest() programmatic path ({ routes, layouts, totalRoutes, totalLayouts }), now the primary documented route.
ivogt
force-pushed
the
fix/cached-scope-guard-handler-invoked-loaders
branch
from
July 7, 2026 23:36
796213a to
4950cce
Compare
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.
Fixes the two findings from the last-4-merged-PR review: a High-severity gap in the #725 cached-scope header guard, and stale #728 documentation.
Finding 1 (High) — cached-scope header guard let handler-invoked loaders write MISS-only headers
Problem (consumer-observable)
#725established the doctrine "in any cached scenario only middleware writes response headers." But a handler-invoked loader —await ctx.use(SomeLoader)called from a handler, whereSomeLoaderis not registered vialoader()— slipped through the guard under acache()boundary. On the MISS the loader ran and itscookies().set()/ctx.header()write landed; on a HIT the handler is skipped and only DSL-registered loaders re-run, so that loader never executed and its write silently vanished. That is exactly the MISS/HIT divergence #725 was built to make impossible (the original storefront session-cookie bug).Root cause:
assertCachedHeaderWriteAllowedkeyed itscache()exemption offisInsideAnyLoaderScope(), which is true for any loader body — including handler-invoked ones (every loader body runs insideloaderBodyScopeALS). Only DSL-registered loaders run insideloaderScopeALSand re-run on every HIT.Before vs. after
Set-Cookie; HIT skips the handler+loader, so the cookie silently disappears on every cache hit. No error, no signal.loader(SessionCookieLoader, ...)) stays allowed, because it re-runs on every HIT and merges into every response.Semantics
cache()+ DSL (registered) loader writecache()+ handler-invoked loader writecache()+ handler-invoked loader nested under a DSL loadercache()+ handler writeppr+ any loader writecache()read-purity guard (non-cacheable var / cookies() read)Code change
packages/rangojs-router/src/server/context.ts,assertCachedHeaderWriteAllowed: narrow the exemption predicate fromisInsideAnyLoaderScope()toisInsideLoaderScope()(DSL scope only), and key the error's "why" sentence onscope.kind === "ppr"rather than the layer, so the new cache()+handler-invoked-loader throw explains the handler-skip cause.insideLoader(broad predicate) is deliberately kept for the error label so a now-throwing handler-invoked loader is still named "loader" (a naive swap would mislabel the PPR handler-invoked-loader case as "handler" and break its pinned test).isInsideLoaderScope()is the same predicateloader-resolution.tsderivesisDslLoaderfrom; the DSL scope ALS survives into nestedctx.usebodies, so a handler-invoked loader nested under a DSL loader stays exempt.Finding 2 (Medium) — #728 removed
?__debug_manifestbut docs still advertised it#728deleted the HTTP debug endpoint and itsrouteAncestrydata, but the debug-manifest skill and the docs guide still told users to openhttp://localhost:PORT/?__debug_manifestand documented the deleted{ routerId, routeManifest, routeAncestry, routeTrie, precomputedEntries }shape. Removed both browser-endpoint sections; both files already carried the correctrouter.debugManifest()programmatic path ({ routes, layouts, totalRoutes, totalLayouts }), which is now the primary documented route.Test plan
packages/rangojs-router/src/rsc/__tests__/cached-header-guard.test.ts: split the old bundled "cache() + loader ALLOWED" test into a DSL-loader-allowed pin, add a handler-invoked-body throws pin (red before the fix, green after — captured), and add a nested-handler-invoked-under-DSL-loader allowed pin. Drives the realcreateRequestContext/createHandlerContextproduction proxies and the actualloaderScopeALSvsloaderBodyScopeALSdiscriminator. 21/21 pass.packages/rangojs-router/e2e/cache-scope-guard.test.ts+ fixtures (cache-scope-guard.tsx,cache-scope-guard-loader.ts): new/handler-loader-cookie-blockedroute (handler-invoked, unregistered loader writing a cookie undercache()) asserts the error boundary renders and thecsg-hil-cookienever reaches the wire, in both modes. The existing/loader-cookie-allowedDSL case stays green in both modes as the paired control. 32/32 pass (--project=dev --project=production).router.named-routes.gen.ts(new named route);git status | grep .gen.tsshows only the intended change.docs/design/ppr-shell-resume.md"The header doctrine" anddocs/internal/feature-map.mdnarrowed from "loaders under cache() stay allowed" to "DSL loaders only; handler-invoked loaders throw."pnpm run typecheck,test:unit:all,lint,format.Userland-primitive coverage — deliberate boundary
No
@rangojs/router/testingprimitive test was added, matching how #725 (the precedent for this exact guard) shipped. The feature requires a cache()-latched page-route funnel with a handler callingctx.use;dispatchstubs RSC rendering and its response-route handler context exposes noctx.use(verified), and this package's testing index exports norenderRoute/renderHandler. Extendingdispatchto render page segments is out of scope. The internal unit test exercises the same productioncreateRequestContextguarded-proxy seam a real request hits, and the dev+prod e2e drives the true consumer path (cache MISS→HIT through the browser) — the same dogfood shape #725 used.Notes
isInsideCacheScope, non-cacheable var /cookies()reads) is intentionally left untouched — it also keys offisInsideAnyLoaderScope(), but this finding is about response-header WRITES; narrowing reads would break the/loader-read-allowedfixtures and is a separate question.isInsideAnyLoaderScopestays module-private and still referenced (error label), so no unused-symbol lint.