Skip to content

fix(router): handler-invoked loaders under cache() throw on header writes (#725 gap) + drop removed __debug_manifest docs - #730

Merged
ivogt merged 2 commits into
mainfrom
fix/cached-scope-guard-handler-invoked-loaders
Jul 8, 2026
Merged

ivogt merged 2 commits into
mainfrom
fix/cached-scope-guard-handler-invoked-loaders

Conversation

@ivogt

@ivogt ivogt commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

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)

#725 established the doctrine "in any cached scenario only middleware writes response headers." But a handler-invoked loader — await ctx.use(SomeLoader) called from a handler, where SomeLoader is not registered via loader() — slipped through the guard under a cache() boundary. On the MISS the loader ran and its cookies().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: assertCachedHeaderWriteAllowed keyed its cache() exemption off isInsideAnyLoaderScope(), which is true for any loader body — including handler-invoked ones (every loader body runs inside loaderBodyScopeALS). Only DSL-registered loaders run inside loaderScopeALS and re-run on every HIT.

Before vs. after

// A route-level cache() page whose handler consumes an UNREGISTERED loader:
cache({ ttl: 600 }, () => [
  path("/cart", async (ctx) => {
    await ctx.use(SessionCookieLoader); // sets a cookie inside the loader
    return <Cart />;
  }, { name: "cart" }),
]),
  • Before: MISS writes Set-Cookie; HIT skips the handler+loader, so the cookie silently disappears on every cache hit. No error, no signal.
  • After: the write throws deterministically on the first render (dev and prod, MISS and HIT), pointing the author to middleware — the header lane that runs on every request. A DSL-registered loader (loader(SessionCookieLoader, ...)) stays allowed, because it re-runs on every HIT and merges into every response.

Semantics

Scope / layer Before After
cache() + DSL (registered) loader write allowed allowed (re-runs every HIT)
cache() + handler-invoked loader write allowed (MISS-only, vanishes on HIT) throws
cache() + handler-invoked loader nested under a DSL loader allowed allowed (DSL parent re-invokes it every HIT)
cache() + handler write throws throws (unchanged)
ppr + any loader write throws throws (unchanged — shell flushes before loaders settle)
cache() read-purity guard (non-cacheable var / cookies() read) loader-exempt unchanged (out of scope — this fix is header WRITES only)

Code change

packages/rangojs-router/src/server/context.ts, assertCachedHeaderWriteAllowed: narrow the exemption predicate from isInsideAnyLoaderScope() to isInsideLoaderScope() (DSL scope only), and key the error's "why" sentence on scope.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).

-  if (scope.kind === "cache" && insideLoader) return;
+  if (scope.kind === "cache" && isInsideLoaderScope()) return;

isInsideLoaderScope() is the same predicate loader-resolution.ts derives isDslLoader from; the DSL scope ALS survives into nested ctx.use bodies, so a handler-invoked loader nested under a DSL loader stays exempt.

Finding 2 (Medium) — #728 removed ?__debug_manifest but docs still advertised it

#728 deleted the HTTP debug endpoint and its routeAncestry data, but the debug-manifest skill and the docs guide still told users to open http://localhost:PORT/?__debug_manifest and documented the deleted { routerId, routeManifest, routeAncestry, routeTrie, precomputedEntries } shape. Removed both browser-endpoint sections; both files already carried the correct router.debugManifest() programmatic path ({ routes, layouts, totalRoutes, totalLayouts }), which is now the primary documented route.

Test plan

  • Internal unit — 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 real createRequestContext/createHandlerContext production proxies and the actual loaderScopeALS vs loaderBodyScopeALS discriminator. 21/21 pass.
  • Dev + production e2e — packages/rangojs-router/e2e/cache-scope-guard.test.ts + fixtures (cache-scope-guard.tsx, cache-scope-guard-loader.ts): new /handler-loader-cookie-blocked route (handler-invoked, unregistered loader writing a cookie under cache()) asserts the error boundary renders and the csg-hil-cookie never reaches the wire, in both modes. The existing /loader-cookie-allowed DSL case stays green in both modes as the paired control. 32/32 pass (--project=dev --project=production).
  • Regenerated router.named-routes.gen.ts (new named route); git status | grep .gen.ts shows only the intended change.
  • Doctrine docs updated in the same PR: docs/design/ppr-shell-resume.md "The header doctrine" and docs/internal/feature-map.md narrowed 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/testing primitive 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 calling ctx.use; dispatch stubs RSC rendering and its response-route handler context exposes no ctx.use (verified), and this package's testing index exports no renderRoute/renderHandler. Extending dispatch to render page segments is out of scope. The internal unit test exercises the same production createRequestContext guarded-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

  • Read-purity exemption (isInsideCacheScope, non-cacheable var / cookies() reads) is intentionally left untouched — it also keys off isInsideAnyLoaderScope(), but this finding is about response-header WRITES; narrowing reads would break the /loader-read-allowed fixtures and is a separate question.
  • No export/file/semantic-matrix changes; isInsideAnyLoaderScope stays module-private and still referenced (error label), so no unused-symbol lint.

@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@730

commit: 4950cce

ivogt added 2 commits July 8, 2026 02:36
…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
ivogt force-pushed the fix/cached-scope-guard-handler-invoked-loaders branch from 796213a to 4950cce Compare July 7, 2026 23:36
@ivogt
ivogt merged commit 7f42803 into main Jul 8, 2026
34 checks passed
@ivogt
ivogt deleted the fix/cached-scope-guard-handler-invoked-loaders branch July 8, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant