Repository navigation
feat(client-urls): ppr as a projected group path option — shell caching for group routes - #817
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A clientUrls group landing page had no shell-cache story at all:
pprwas an explicit projection rejection (path option "ppr" is not supported), and sinceppris 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 forppr— 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
End-to-end example
A shop group mounted at
include("/shop", shopUrls): the group chrome, nav, and itsuseSearchParams-derived filter markup freeze into a per-URL shell (/shop?filter=shoesand/shop?filter=hatsare distinct shells — search is shell identity); theloading()subtree overFeaturedLoaderstays 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
ppr: true/PartialPrerenderPropson a grouppath()resolvePprConfigclassifies it like any ppr page (new)useSearchParamsloading()subtreeloading()pprvalue (non-finite number, empty tag, unknown key, function)intercept-as-option,parallel,revalidate)Code changes
src/client-urls/types.ts—ClientPathOptionspicksppr.src/client-urls/client-urls.ts—validateClientPpr(DSL-time shape check:trueor finite numbers + non-emptytagsstrings, unknown keys rejected).src/client-urls/server-projection.ts— rejection lifted;serializePpr(frozen defensive JSON copy, independent re-validation);materializedPathOptionspasses it onto the serverpath()— path-helper stamps the manifest entry, which is the whole runtime wiring./client-urls-e2e/pprgroup route (static markup + search read + delayed monotonic loader underloading()).Test plan
path()→loadManifest→resolvePprConfig— red-proven (entry.pprundefinedwithout the materialization spread).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 thex-rango-shellheader is absent entirely.test:unit:all, lint, format, bucketing; no.gen.tsdrift (the fixture route is unnamed).Notes
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.ppris a JSON option riding the exact projection shapesearch/trailingSlashestablished.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(Def, { stream: "navigation" })ppr.captureTimeout) and pins into the shell snapshotloading()or an inline Suspense; boundary-less reads refuse the captureloading()becomes optional when every loader is flagged — nothing masks, the shell captures complete instead of the eternal-MISS refusal. Groups inherit through the existingawaitedLoaderIndicesprojection 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:
streamoption docs on both DSL surfaces, fixture doctrine header.