Skip to content

feat(client-urls): ppr as a projected group path option — shell caching for group routes - #817

Merged
ivogt merged 2 commits into
mainfrom
feat/clienturls-ppr
Jul 28, 2026
Merged

ivogt merged 2 commits into
mainfrom
feat/clienturls-ppr

Conversation

@ivogt

@ivogt ivogt commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A clientUrls group landing page had no shell-cache story at all: ppr was an explicit projection rejection (path option "ppr" is not supported), and since ppr is a leaf path option, the surrounding server tree could not supply it either — inside the mount, the leaves are the group's materialized routes. Every group landing paid the full live document render on every visit while equivalent hand-written server routes could opt into instant-TTFB shell serving.

The rejection was minimal-surface caution, not a structural exclusion: unlike parallel() (handler-valued, unrepresentable) or boundaries (server-tree envelope ownership), no reason was recorded for ppr — the config is pure JSON and the runtime capture/serve machinery is route-generic. The one thing that used to be genuinely unsound — a group static part reading search — was fixed by the shell search-identity work (capture/resume render the shell key's own search).

Before / after

"use client";
export default clientUrls(({ path, loader, loading }) => [
  // before: throws 'path option "ppr" is not supported'
  // after: shell caching, exactly like a hand-written ppr page
  path("/", ShopFront, { ppr: { ttl: 300, swr: 120 } }, () => [
    loader(FeaturedLoader),
    loading(<GridSkeleton />),
  ]),
]);

End-to-end example

A shop group mounted at include("/shop", shopUrls): the group chrome, nav, and its useSearchParams-derived filter markup freeze into a per-URL shell (/shop?filter=shoes and /shop?filter=hats are distinct shells — search is shell identity); the loading() subtree over FeaturedLoader stays the live hole. A HIT flushes the stored prelude in the first bytes and streams only the loader hole; the browser hydrates with zero errors and the group is immediately interactive.

Semantics

Case Behavior
ppr: true / PartialPrerenderProps on a group path() Projects (frozen JSON copy); materialized route's manifest entry carries it; resolvePprConfig classifies it like any ppr page (new)
Group static part reads useSearchParams Freezes the shell key's own search value; HIT hydration agrees with the browser URL (the search-identity contract, unchanged)
loading() subtree Live hole — masked loaders postpone, resume streams them per request (unchanged)
Loader-bearing group route WITHOUT loading() Capture refuses, eternal MISS with the once-per-key warning — same rule as server routes (unchanged)
Actions / PE / per-request nonce Live axis, never shell-served (unchanged)
Malformed ppr value (non-finite number, empty tag, unknown key, function) Fails at DSL time in the authoring module with a named message; the projection serializer re-validates independently (new)
Other non-projected options (intercept-as-option, parallel, revalidate) Still rejected (unchanged)

Code changes

  • src/client-urls/types.ts — ClientPathOptions picks ppr.
  • src/client-urls/client-urls.ts — validateClientPpr (DSL-time shape check: true or finite numbers + non-empty tags strings, unknown keys rejected).
  • src/client-urls/server-projection.ts — rejection lifted; serializePpr (frozen defensive JSON copy, independent re-validation); materializedPathOptions passes it onto the server path() — path-helper stamps the manifest entry, which is the whole runtime wiring.
  • Fixture: /client-urls-e2e/ppr group route (static markup + search read + delayed monotonic loader under loading()).
  • Docs: client-urls.md supported surface + rejection list, feature-map, client-urls skill.

Test plan

  • Unit: DSL validation matrix (accept/reject shapes) and projection round-trip (frozen copy, mutation isolation) in the clientUrls suites (56 green); manifest round-trip pin — materialized group path() → loadManifest → resolvePprConfig — red-proven (entry.ppr undefined without the materialization spread).
  • E2e (shell-cache suite, dev + production): "clientUrls group route with ppr" — MISS → HIT, frozen prelude carries the group's static markup + its search value + the loading() fallback in the first flushed bytes, loader hole advances per request under the frozen shell, browser HIT hydrates with zero errors. Red-proven: without the projection the x-rango-shell header is absent entirely.
  • Full shell-cache + client-urls sweeps: 64 dev / 65 production, all green.
  • Typecheck, test:unit:all, lint, format, bucketing; no .gen.ts drift (the fixture route is unnamed).

Notes

  • This deliberately ships ONLY the runtime shell axis for groups. Build-time baking (Static()/Prerender() analogs for groups — param enumeration via id-referenced server defs, baked Flight entries) is the next design conversation; this PR is its Phase 1 and changes nothing it would need to undo.
  • The minimal-surface doctrine holds: no handler wrappers entered the group DSL — ppr is a JSON option riding the exact projection shape search/trailingSlash established.

Second commit: stream: "navigation" loaders BAKE at capture (per-loader lane; loading() optional when fully baked)

Follow-on from review discussion ("are loaders still dynamic? ideally stream:navigation's settled return bakes"). The flag's document promise — data/handle-pushes/status in the HTML before first flush — silently degraded under ppr: the loader was masked like any live loader and its data arrived in the resume stream, never the prelude. The capture lane decision is now per loader:

Loader At capture On a HIT
loader(Def, { stream: "navigation" }) Executes (BAKE lane): settled return is shell material; nested thenables stay masked (shape = liveness, the #692 cross-session scar) and postpone as holes; the record holds the capture gate (bounded by ppr.captureTimeout) and pins into the shell snapshot Frozen prelude carries the baked value; the snapshot seed replays it in the tail so hydration is byte-agreed; nested promises stream fresh
Any other loader LIVE (masked), unchanged from #813 — postpones at loading() or an inline Suspense; boundary-less reads refuse the capture Streams fresh per request

loading() becomes optional when every loader is flagged — nothing masks, the shell captures complete instead of the eternal-MISS refusal. Groups inherit through the existing awaitedLoaderIndices projection with zero group-specific code; the group-ppr probe now pins a baked group loader value frozen in the group shell.

Red-proven three ways: the flipped await-before-flush unit (flagged executes + registers its record at capture, plain sibling never runs), and both e2e pins fail without the capture branch — baked value absent from the prelude, and the fully-baked route degrades to eternal MISS. One placement rule documented in the fixture: a baked reader must not share its nearest Suspense boundary with a masked reader, or the whole boundary postpones and the baked value lands in the hole.

Additional test plan:

  • Unit: await-before-flush capture test flipped to the new contract (194 segment-resolution/shell units green).
  • E2e dev+prod: "stream:navigation loader bakes into the shell; nested promise and plain sibling stay live" (frozen-vs-live seqs across HITs), "fully-baked route needs no loading()", group probe extended.
  • Sweeps: shell-cache + shell-cache-action + prerender-ppr + semantic-matrix (segment-resolution touched): 88 dev / 90 prod.
  • Docs: loader-container-bake.md addendum (bake trigger moved to the flag), feature-map PPR rows, client-urls.md, the stream option docs on both DSL surfaces, fixture doctrine header.

ivogt added 2 commits July 28, 2026 12:46
…ng for group routes

clientUrls() routes can now declare `ppr: true | PartialPrerenderProps`.
The option was an explicit projection rejection with no structural reason
(unlike parallel/boundaries) — the config is pure JSON and the runtime
shell machinery is route-generic, so this lifts the rejection and threads
the option through the one seam that exists: DSL validation
(validateClientPpr, fails in the authoring module), projection
serialization (serializePpr — frozen JSON copy, re-validated
independently), and materializedPathOptions, where the materialized server
path() stamps it on the manifest entry so resolvePprConfig classifies the
group route exactly like a hand-written ppr page.

The interplay that motivated this lands whole: the group's static markup
freezes into the shell INCLUDING its useSearchParams read — search is part
of shell identity (the key embeds the sorted search and the capture render
seeds that same string), so the frozen value matches the URL the shell
serves and HIT hydration is clean. loading() subtrees stay the live holes;
the server-route rules (loader routes need loading() or capture refuses,
actions/PE/nonce take the live axis) apply unchanged.

Red-proven at both levels: the manifest round-trip unit (entry.ppr
undefined without the materialization spread) and the e2e (x-rango-shell
header absent without the projection). New shell-cache probe "clientUrls
group route with ppr" pins MISS -> HIT, frozen group shell with its search
value in the first flushed bytes, per-request loader liveness under the
frozen shell, and zero-hydration-error browser HIT — dev AND production.
Full shell-cache + client-urls sweeps green both modes (64 dev / 65 prod).
Docs: client-urls.md supported surface + rejection list, feature-map,
client-urls skill.
…ane, loading() optional when fully baked

The flag's document promise is "this loader's data, handle pushes, and
status are in the HTML before first flush"; under ppr that silently
degraded — the awaitBeforeFlush await was skipped at capture and the
loader masked like any live-lane loader, so its data arrived in the resume
stream, never the prelude. The capture lane decision is now per LOADER:

- stream:"navigation" — the BAKE lane. Executes at capture; the SETTLED
  return is shell material; nested thenables stay masked (shape is the
  liveness declaration — the #692 cross-session scar) and postpone as
  holes; the masked container registers on _shellCaptureLoaderRecords,
  holding the capture gate (bounded by ppr.captureTimeout) and pinning
  into the shell snapshot so HIT tails replay the baked value and
  hydration stays byte-agreed with the frozen prelude. The dormant
  post-#813 bake machinery (gate hold, snapshot drain, seed overlay,
  pin-first) re-engages unchanged; only the trigger moved from
  "entry lacks loading()" to the explicit per-loader flag.
- Every other loader — LIVE unconditionally, exactly as #813 left it:
  masked, postponed at loading() or an inline Suspense, boundary-less
  reads refuse the capture.

loading() is therefore OPTIONAL when every loader is flagged: nothing
masks, the shell captures complete instead of the eternal-MISS refusal.
Groups inherit through the existing awaitedLoaderIndices projection —
no group-specific code.

Red-proven: the flipped await-before-flush unit (flagged loader executes +
registers its record at capture; plain sibling never runs) and both e2e
pins fail without the capture branch (baked value absent from the prelude;
the fully-baked route degrades to eternal MISS). New pins, dev+prod:
"stream:navigation loader bakes into the shell; nested promise and plain
sibling stay live" (frozen-vs-live seqs across HITs), "fully-baked route
needs no loading()", and the group-ppr probe extended with a baked group
loader frozen in the group shell. Sweeps green: shell-cache +
shell-cache-action + prerender-ppr + semantic-matrix, 88 dev / 90 prod.
Docs: loader-container-bake.md addendum (trigger moved), feature-map PPR
rows, client-urls.md, stream option docs both DSL surfaces, fixture
doctrine header.
@ivogt
ivogt marked this pull request as ready for review July 28, 2026 10:11
@ivogt
ivogt merged commit a550331 into main Jul 28, 2026
17 of 19 checks passed
@ivogt
ivogt deleted the feat/clienturls-ppr branch July 28, 2026 10:11
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