Repository navigation
Description
Summary
A live-lane parallel-slot loader (registered behind loading()) is correctly masked during PPR shell capture only when the captured route is the layout's index path (path("/")). For every other route under the same layout, awaiting ctx.use(SlotLoader) inside the slot handler executes the loader body inline during the capture render. If that loader reads cookies()/headers() — the exact thing the live lane exists for — the identity guard trips and the capture is deterministically refused: the route is stuck on x-rango-shell: MISS forever.
Found on @rangojs/[email protected] while adopting PPR in a storefront app (the vercel/shop-style template), vite dev on Node, macOS.
Repro
Route tree (the /ppr skill's storefront playbook shape):
export const urlpatterns = urls(({ layout, loader, loading, parallel, path }) => [
layout(RootLayout, () => [
parallel({ "@navCart": CartIcon }, () => [
loader(NavCartLoader), // reads cookies() inside
loading(<CartIconFallback />),
]),
path("/", HomePage, { name: "home", ppr: true }),
path("/about", AboutPage, { name: "about", ppr: true }),
]),
]);Loader and slot handler (the /parallel skill's "Composable Slots via handler.use" consumption shape):
export const NavCartLoader = createLoader(async () => {
"use server";
const cartId = (await cookies()).get("shopify_cartId")?.value;
return cartId ? await getCart(cartId) : null;
});
export const CartIcon: Handler = async (ctx) => {
const cart = await ctx.use(NavCartLoader); // <-- server-side consumption
return <CartIconClient initialCart={cart} />;
};RootLayout renders <ParallelOutlet name="@navCart" />. Request each route twice with Accept: text/html.
Observed
| Route | Capture outcome | x-rango-shell |
|---|---|---|
/ (layout index path) |
loader masked, shell stored | MISS → HIT |
/about |
loader body executed, cookies() guard tripped, capture refused |
MISS forever |
/collections, /collections/all, /products/:handle |
same as /about |
MISS forever |
Console (once per key):
[rango] Shell capture for "localhost:5199/about:shell" was refused: a bake-lane loader
(a loader on an entry with no loading()) called cookies() during capture. ...
Stack of the guard trip (captured by temporarily wrapping the app's cookies()):
cookies() cannot be called while capturing a shared shell (shell-cache middleware). ...
at getCartIdFromCookie (lib/cart/server.ts:9:17)
at anonymous_server_function (components/nav/loaders.ts:14:43)
at eval (@rangojs/router/src/router/loader-resolution.ts:426:11)
at AsyncLocalStorage.run (node:internal/async_local_storage/async_context_frame:63:14)
at runInsideLoaderBodyScope (@rangojs/router/src/server/context.ts:826:29)
at eval (@rangojs/router/src/router/loader-resolution.ts:425:9)
at observePhase (@rangojs/router/src/router/instrument.ts:234:34)
at useLoader (@rangojs/router/src/router/loader-resolution.ts:423:21)
at Object.eval (@rangojs/router/src/router/loader-resolution.ts:542:12)
So during the capture of a non-index route, ctx.use(SlotLoader) reaches the useLoader fallback in loader-resolution.ts that runs the loader body (runInsideLoaderBodyScope) instead of resolving the masked record created at the capture's match().
Expected
The lane is declared structurally (loader() + loading() on the parallel entry), so masking should not depend on which route under the layout is being captured. ctx.use(SlotLoader) in the slot handler should receive the masked never-resolving promise on every route, the boundary should postpone, and the slot should become the live hole — exactly what happens for path("/").
Workaround
Consume the slot loader client-side (useLoader(NavCartLoader) in a "use client" component; the slot handler just returns it). With that shape all routes mask correctly and capture/HIT works everywhere — verified on the same tree.
Notes
- Likely why e2e missed it: the
/pprskill's storefront playbook example registers onlypath("/", ...)under the layout, and the/parallelskill's "Composable Slots" section (which recommendsawait ctx.use(CartLoader)in slot handlers) predates PPR. The combination slot-ctx.use+ non-index route +pprappears untested. - Secondary (message quality):
refuseOnGuardTripinrsc/shell-capture.tshardcodes the attribution "a bake-lane loader (a loader on an entry with no loading()) called cookies()". Here the tripping loader IS behindloading()— the message sends you debugging the wrong lane. The guard already recordsfnName; recording the loader id / call origin alongside it would make the warning name the real source.
Activity
fix(ppr): mask live-lane loaders for handler-side ctx.use during shel…
fix(ppr): mask live-lane loaders for handler-side ctx.use during shel…
fix(ppr): consumption-lane rule — handler ctx.use(loader) bakes durin…
fix(ppr): consumption-lane rule — handler ctx.use(loader) bakes durin…
feat(ppr): shell fast path — HIT tails replay the handler layer throu…
fix(router): handler-invoked loaders under cache() throw on header wr…
fix(router): handler-invoked loaders under cache() throw on header wr…