Skip to content

PPR: live-lane parallel-slot loader consumed via ctx.use() in the slot handler executes (and trips the identity guard) during capture on non-index routes #672

Closed

Description

@ivogt

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

  1. Likely why e2e missed it: the /ppr skill's storefront playbook example registers only path("/", ...) under the layout, and the /parallel skill's "Composable Slots" section (which recommends await ctx.use(CartLoader) in slot handlers) predates PPR. The combination slot-ctx.use + non-index route + ppr appears untested.
  2. Secondary (message quality): refuseOnGuardTrip in rsc/shell-capture.ts hardcodes the attribution "a bake-lane loader (a loader on an entry with no loading()) called cookies()". Here the tripping loader IS behind loading() — the message sends you debugging the wrong lane. The guard already records fnName; recording the loader id / call origin alongside it would make the warning name the real source.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions