Skip to content

test(ppr): e2e-pin the layout-loader trap + slot escape; design doc for loader container bake - #661

Closed
ivogt wants to merge 4 commits into
mainfrom
test/ppr-layout-loader-e2e
Closed

ivogt wants to merge 4 commits into
mainfrom
test/ppr-layout-loader-e2e

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

The most common production shape — an app-wide layout registering session/basket-style loaders, with ppr on a page beneath it — dead-ends today: every shell capture refuses and the route stays x-rango-shell: MISS forever. A real storefront migration hit this and was misled twice: the refusal warning's route-level framing ("add loading() to the loader route") suggested a fix that does not work when the loaders live on a layout, and nothing pinned the actual rules. Only the route-level variant (/shell-cache/no-hole) had e2e coverage.

What this PR does

Pins the trap in its exact consumer shapes, pins the working escape, and adds the design doc for the real fix. No framework behavior changes — the router behaves identically before and after this PR.

New e2e (test-app suite AND cloudflare-basic twin — dev + production in both)

Route Shape Pinned contract
/shell-cache/layout-loader + CF twin layout loader, ppr child WITH its own loading() stays MISS forever — a child route's loading() does not unpin a parent layout's loaders; the tree-build await lives at the entry that registers them
/shell-cache/layout-loader-bare + CF twin layout loader, bare ppr child (no use list) — the literal storefront-homepage shape stays MISS forever; registration alone pins (the loader is never consumed); axis 1 stays healthy
/shell-cache/slot-hole + CF twin same chrome data owned by a @badge parallel slot with its own loader()+loading(); no loading() on layout or route flips to HIT; chrome + static page + frozen badge fallback in the prelude; badge value streams in the resumed tail and stays live (seq advances) across HITs

The cloudflare-basic twin runs the same three pins on the real KV-backed CFCacheStore under workerd (dev worker + built preview worker).

Design doc: docs/design/loader-container-bake.md

One promise doctrine for all three lanes: a loader on a no-loading() entry executes during capture, its settled container bakes, nested promises hole at the consumer's <Suspense> — { static: 'baked', dynamic: promise }, the return shape as the entire API. Key grounding: axis 1 already implements container-settles/nested-streams for loading()-less loaders (now e2e-pinned by this PR), so the design makes capture match axis 1 rather than inventing semantics. Covers capture unmasking, the identity guard dropping its loader exemption during capture (fail-closed), HIT parity via a third capture-data-snapshot family with a fresh-run + recorded-container overlay, rollout (default-on: the only affected shape is today's eternal-MISS refusal), and open questions. The trap tests in this PR are the red→green vehicle for the implementation.

Also included: the promise-holes docs expansion already on main gets its e2e backing here (the /ppr layout-with-loaders playbook's slot pattern was previously source-verified only).

Consumer story

Nothing to adopt in this PR. For a consumer hitting the trap today, the pinned escape is the playbook shape:

layout(StoreChrome, () => [
  parallel({
    "@basket": {
      handler: () => <BasketBadge loader={BasketLoader} />,
      use: () => [loader(BasketLoader), loading(<BadgeSkeleton />)],
    },
  }),
  path("/", HomePage, { name: "home", ppr: true }),
]),

Chrome bakes into the shell; the badge is a badge-sized hole; no page-level skeleton exists. After the design doc ships, the same app drops the slot machinery and declares liveness by return shape alone.

Semantics table (all pinned, none changed)

Case Behavior
layout loaders, no loading() on layout, ppr child (with or without own loading()) capture refuses, MISS forever, axis 1 healthy
slot-owned loader with slot loading() per-slot boundary; layout bakes; HIT
route-level loader without loading() (/shell-cache/no-hole) unchanged, still pinned

Code changes

  • packages/rangojs-router/e2e/test-app: shell-cache.defs.ts (+2 loaders), shell-cache.tsx (+2 layouts, 3 routes), components/ShellBadge.tsx (new), router.named-routes.gen.ts
  • packages/rangojs-router/e2e/shell-cache.test.ts: +3 tests in the shared spec (runs in both dev and (production) describes)
  • tests/cloudflare-basic: mirrored fixtures (loaders/ppr-shell.ts, pages/ppr-shell.tsx, components/PprShellBadge.tsx, urls.tsx), +3 tests in e2e/ppr-shell.test.ts (describePprShell dev + production), router.named-routes.gen.ts
  • packages/rangojs-router/docs/design/loader-container-bake.md (new)

Test plan

  • shell-cache suite: 38 passed × dev + production (test-app)
  • ppr-shell suite: 34 passed × dev + production (cloudflare-basic, workerd)
  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format, pnpm check:e2e-bucketing
  • *.gen.ts regenerated and committed for both apps that gained routes

Notes

  • The dev-worker test output incidentally captures the live refusal warning firing with the misleading route-level text — supporting evidence for the warning fix listed in the design doc's rollout section (deliberately not implemented here; this PR is pins + design only).
  • The nonce→axis-1 interaction has zero e2e coverage anywhere (cloudflare-basic-nonce has no ppr tests); deliberately left out of this PR since nonce work is in flight elsewhere.

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 8be1a39

ivogt added 3 commits July 4, 2026 15:26
Reproduces the storefront dead-end end to end: a layout that registers a
loader with no loading() on the LAYOUT pins every shell capture, and a
loading() on the ppr child route does NOT unpin it — the tree-build await
lives at the entry that registers the loaders. Previously only the
route-level variant (/shell-cache/no-hole) was pinned.

New fixtures (test-app shell-cache):
- /shell-cache/layout-loader — ShellTrapChromeLayout registers
  ShellChromeLoader (unconsumed; registration alone pins), child route has
  its own loader + loading() + ppr:true. Test: 6 spaced GETs stay
  x-rango-shell: MISS while axis 1 stays healthy.
- /shell-cache/slot-hole — the escape from skills/ppr's
  layout-with-loaders playbook: the same chrome data owned by a @badge
  parallel slot with its own loader + loading(); neither the layout nor
  the route carries loading(). Test: warms to HIT; prelude carries chrome,
  static page content, and the frozen badge fallback (never a badge
  value); the badge value streams in the resumed tail and its seq
  advances across HITs (stays live).

Both tests run in the dev AND (production) describes via the existing
runShellCacheSpec wiring. Full shell-cache suite: 38 passed in both modes.
router.named-routes.gen.ts regenerated for the two new named routes.
…rs, handles, and loaders

Design for closing the last doctrine asymmetry: a loader on a no-loading()
entry executes during shell capture, its settled container bakes, and
nested promises hole at the consumer's Suspense — the return shape is the
declaration ({static: 'baked', dynamic: promise}), no new API. loading()
stays the guaranteed live lane, byte-for-byte unchanged.

Grounded in the existing mechanisms: axis 1 already implements
container-settles/nested-streams for loading()-less loaders (e2e-pinned);
capture unmasking + the identity guard losing its loader exemption during
capture; HIT parity via a third capture-data-snapshot family with a
fresh-run + recorded-container overlay. Ships default-on: the only shape
whose behavior changes is today's eternal-MISS structural refusal.

Includes rollout note for the shell-capture.ts:244 warning fix (route-level
framing misled a real migration) and the red->green test plan built on the
layout-loader trap e2e added in the previous commit.
… under a loader layout

/shell-cache/layout-loader-bare: a ppr route with no loader, no loading(),
no use list at all, under the loader-registering trap layout. The route is
fully shell-eligible on its own; the layout's boundary-less loader alone
keeps every capture refusing (MISS across 6 spaced GETs, axis 1 healthy),
in dev and production. Completes the trap coverage: with-child-loading()
and bare variants both pinned.
…scape

Same three pins as the test-app suite, on the real KV-backed CFCacheStore
under workerd (dev worker + built preview worker): layout-registered
loader with no loading() on the layout stays MISS with a loading()-bearing
child route; the bare storefront-homepage shape (ppr route with no use
list under the loader layout) stays MISS; the @badge parallel-slot escape
flips to HIT with the fallback frozen in the prelude and the badge value
live (seq advances) across HITs.

Full cloudflare-basic ppr-shell suite: 34 passed in dev and production.
router.named-routes.gen.ts regenerated for the three new named routes.
@ivogt
ivogt force-pushed the test/ppr-layout-loader-e2e branch from 644191e to 8be1a39 Compare July 4, 2026 13:12
@ivogt

ivogt commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Folded into #663 (same branch history — the four commits here are #663's base commits). Landing as one PR so the old-contract pins and their red→green flip ship atomically.

@ivogt ivogt closed this Jul 4, 2026
@ivogt
ivogt deleted the test/ppr-layout-loader-e2e branch July 4, 2026 14:01
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