@@ -318,13 +318,16 @@ server is back. Don't treat this error as a failure.
318318** Prerequisite:** PPR requires ` cacheComponents ` to be enabled in
319319` next.config ` . Without it the shell won't have pre-rendered content to show.
320320
321- Freeze dynamic content so you can inspect the static shell — the part of
322- the page that's instantly available before any data loads. After locking:
323- - ` goto ` — shows the server-rendered shell with holes where dynamic
324- content would appear.
325- - ` push ` — shows what the client already has from prefetching. Requires
326- the current page to already be hydrated (prefetch is client-side),
327- so lock * after* you've landed on the origin, not before.
321+ Freeze dynamic content so you can inspect the static shell. After
322+ locking:
323+ - ` goto ` — shows the ** PPR shell as HTML** : the server-rendered static
324+ shell with ` <template> ` holes where dynamic content would stream in.
325+ This is what a direct page load delivers.
326+ - ` push ` — shows the ** PPR shell as RSC payload** : the same static shell
327+ concept, but delivered as an RSC stream during client navigation — no
328+ HTML, no hydration. In dev mode there is no prefetching — the lock
329+ uses a cookie that tells the dev server to simulate instant navigation,
330+ so lock + push works on any route.
328331
329332```
330333$ next-browser ppr lock
@@ -470,12 +473,22 @@ is skipped.
470473The optional caption describes the screenshot or the rationale for taking
471474it. Captions appear in the Screenshot Log above each image.
472475
476+ Also fetches errors from the Next.js dev server alongside the capture.
477+ When errors are present, they are printed after the file path so you get
478+ both the visual state and the error details in one call.
479+
473480```
474481$ next-browser screenshot "Homepage after login"
475482/tmp/next-browser-1711234567890.png
476483
477- $ next-browser screenshot "Full page layout" --full-page
484+ $ next-browser screenshot "After bad import"
478485/tmp/next-browser-1711234567891.png
486+
487+ errors:
488+ { ... }
489+
490+ $ next-browser screenshot "Full page layout" --full-page
491+ /tmp/next-browser-1711234567892.png
479492```
480493
481494### ` snapshot `
@@ -859,20 +872,24 @@ component is the root cause, find evidence — inspect it with `tree`,
859872read its source, check what's changing via the change reason column.
860873Don't propose changes from a single observation.
861874
862- ### Growing the static shell
875+ ### Growing the HTML shell (direct page load)
876+
877+ The HTML shell is the PPR prerender delivered on a direct page load —
878+ what the user sees before any JavaScript runs. It's the static parts of
879+ the component tree baked into HTML, with ` <template> ` holes where
880+ dynamic data will stream in.
863881
864- The shell is what the user sees the instant they land — before any dynamic
865- data arrives. The measure is the screenshot while locked: does it read as
866- the page itself? A shell can be non-empty and still bad — one Suspense
867- fallback wrapping the whole content area renders * something* , but it's a
882+ The measure is the screenshot while locked: does it read as the page
883+ itself? A shell can be non-empty and still bad — one Suspense fallback
884+ wrapping the whole content area renders * something* , but it's a
868885monolithic loading state, not the page.
869886
870887A meaningful shell is the real component tree with small, local fallbacks
871- where data is genuinely pending. Getting there means the composition layer
872- — the layouts and wrappers between those leaf boundaries — can't itself
873- suspend. ` ppr unlock ` 's Quick Reference table names the primary blocker
874- and source for each hole; the Detail section adds owner chains and
875- secondary blockers. A suspend high in the tree is what collapses
888+ where data is genuinely pending. Getting there means the composition
889+ layer — the layouts and wrappers between those leaf boundaries — can't
890+ itself suspend. ` ppr unlock ` 's Quick Reference table names the primary
891+ blocker and source for each hole; the Detail section adds owner chains
892+ and secondary blockers. A suspend high in the tree is what collapses
876893everything beneath it into one fallback.
877894
878895Work it top-down. For the component that's suspending: can the dynamic
@@ -889,23 +906,143 @@ component with `tree`, or compare a route where the shell works to
889906one where it doesn't. Don't commit to a root cause or propose changes
890907from a single observation.
891908
892- There are two shells depending on how the user arrives — establish which
893- one you're optimizing first (see ** Working with the user → Escalate,
894- don't decide** ).
909+ ** Workflow:**
910+
911+ 1 . ` ppr lock `
912+ 2 . ` goto ` the target URL — the lock suppresses dynamic content so you
913+ see exactly what the server pre-rendered as HTML.
914+ 3 . ` screenshot "HTML shell" ` — evaluate visually.
915+ 4 . ` ppr unlock ` — read the shell analysis (holes, blockers, sources).
916+ 5 . Fix the top-most blocker, let HMR pick it up, re-lock, ` goto ` ,
917+ and compare.
918+
919+ Between iterations: check ` errors ` while unlocked.
920+
921+ ### Optimizing instant navigations
922+
923+ The instant shell is what the user sees the moment they click a link
924+ (or ` router.push ` ) — before any dynamic data for the target route
925+ arrives. In production, Next.js prefetches the target route's static
926+ shell while the user is still on the origin page. When the link is
927+ clicked, the router reveals this prefetched shell instantly, then
928+ streams in the dynamic parts.
929+
930+ This is the same PPR shell concept as the HTML shell above, but
931+ delivered as an RSC payload stream during client navigation — there is
932+ no HTML, no hydration. Client components in the shell are rendered with
933+ JavaScript on the client side.
934+
935+ ** In dev mode there is no prefetching.** The ` ppr lock ` + ` push `
936+ workflow simulates instant navigation using a cookie mechanism that tells
937+ the dev server to respond as it would to a prefetch — rendering only the
938+ static shell and holding back dynamic content. This lets you inspect the
939+ instant shell without needing a production build.
940+
941+ ** Workflow:**
895942
896- ** Direct load — the PPR shell.** Server HTML for a cold hit on the URL.
897- Lock first, then ` goto ` the target — the lock suppresses hydration so you
898- see exactly what the server sent. Screenshot once the load settles, then
899- unlock.
943+ 1 . ` ppr lock `
944+ 2 . ` push ` to the target route — shows the instant shell.
945+ 3 . ` screenshot "Instant shell" ` — evaluate visually.
946+ 4 . ` ppr unlock ` — read the shell analysis.
947+ 5 . Fix the top-most blocker, let HMR pick it up, re-lock, ` push ` ,
948+ and compare.
900949
901- ** Client navigation — the prefetched shell.** What the router already
902- holds when a link is clicked. The origin page decides this — it's the one
903- doing the prefetching — so ` goto ` the origin * unlocked* and let it fully
904- hydrate. Then lock, ` push ` to the target, let the navigation settle,
905- screenshot, unlock. Locking before the origin hydrates means nothing got
906- prefetched and ` push ` has nothing to show.
950+ The same principles from "Growing the HTML shell" apply — work top-down,
951+ move dynamic access into children, and escalate boundary placement and
952+ caching decisions to the user.
907953
908954Between iterations: check ` errors ` while unlocked.
909955
910- ** After making a code change:** HMR picks it up — just re-lock,
911- ` goto ` the page, and re-test. No need to ` restart-server ` .
956+ ### Runtime prefetching for cookie-dependent instant shells
957+
958+ When the instant shell (via ` ppr lock ` + ` push ` ) is empty or shows only
959+ skeletons for routes that depend on ` cookies() ` or other request-scoped
960+ data, the static prefetch can't include that content — it runs without
961+ request context. Runtime prefetching solves this: the server generates
962+ prefetch data using real cookies, and the client caches it for instant
963+ navigations.
964+
965+ Three features compose to make this work:
966+
967+ | Feature | Role |
968+ | -------------------------------- | ------------------------------------------------------------------------------------- |
969+ | ` unstable_instant ` | Declares the route must support instant navigation; validates a static shell exists |
970+ | ` unstable_prefetch = 'runtime' ` | Tells the server to produce a runtime prefetch stream with request context |
971+ | ` "use cache: private" ` | Caches per-request data (cookies) in the request-scoped Resume Data Cache so the runtime prefetch rerender reuses it without re-fetching |
972+
973+ Without ` unstable_prefetch = 'runtime' ` , the prefetch only includes the
974+ static shell. Without ` "use cache: private" ` , the runtime prefetch
975+ re-executes every data call. All three are needed for instant
976+ navigations that show real personalized content.
977+
978+ Read ` node_modules/next/dist/docs/ ` for the full technical breakdown
979+ before starting — your training data may be outdated on these APIs.
980+
981+ ** Diagnosis:**
982+
983+ 1 . Audit instant shells across the target routes:
984+ ```
985+ ppr lock
986+ push /route-a → screenshot "route-a instant shell"
987+ push /route-b → screenshot "route-b instant shell"
988+ ...
989+ ppr unlock
990+ ```
991+ Identify which routes show empty/skeleton shells.
992+
993+ 2 . For each empty route, add ` unstable_instant ` temporarily and navigate
994+ to it — ` errors ` will surface validation failures that name the
995+ blocking API (` cookies() ` , ` connection() ` , etc.) and the component
996+ calling it. This is a diagnostic tool, not the fix itself.
997+
998+ 3 . Read the source of the blocking components. The pattern to look for:
999+ a data-fetching function reads ` cookies() ` → this makes the component
1000+ dynamic → it becomes a hole in the static shell → the instant shell
1001+ has nothing to show there.
1002+
1003+ ** The fix pattern (per route):**
1004+
1005+ 1 . In the page's route segment config, export both:
1006+ ``` ts
1007+ export const unstable_instant = true
1008+ export const unstable_prefetch = ' runtime'
1009+ ```
1010+
1011+ 2 . In the data-fetching functions that read ` cookies() ` , add
1012+ ` "use cache: private" ` so the result is cached per-request and reused
1013+ by the runtime prefetch rerender. If ` "use cache: private" ` can't be
1014+ applied directly (e.g., file has ` "use server" ` directive), extract
1015+ the function to a separate file.
1016+
1017+ 3 . If a shared layout or utility calls ` connection() ` to prevent sync
1018+ I/O during prefetch, investigate whether it also blocks runtime
1019+ prefetching. ` connection() ` opts into dynamic rendering, which
1020+ prevents the runtime prefetch stream from being generated. A
1021+ ` setTimeout(resolve, 0) ` macro task boundary provides the same sync
1022+ I/O protection without blocking runtime prefetch — but this is a
1023+ judgment call for the user (see ** Escalate, don't decide** ).
1024+
1025+ ** Verification:**
1026+
1027+ Runtime prefetch data is generated during the initial page load and
1028+ streamed to the client alongside the page content. The client's segment
1029+ cache fills asynchronously — it is not instant.
1030+
1031+ To verify:
1032+ 1 . ` goto ` the origin page (the page the user navigates * from* ).
1033+ 2 . Wait 10–15 seconds for the runtime prefetch stream to complete.
1034+ The prefetch runs as a side-channel during the initial render — it
1035+ needs time to execute all ` "use cache: private" ` functions and stream
1036+ the results.
1037+ 3 . ` ppr lock `
1038+ 4 . ` push ` to the target route — the instant shell should now show real
1039+ content, not just skeletons.
1040+ 5 . ` screenshot ` to confirm.
1041+ 6 . ` ppr unlock ` to see the shell analysis.
1042+
1043+ If the shell is still empty after waiting, check:
1044+ - Did the page actually load with runtime prefetch? ` network ` should
1045+ show the initial document response — runtime prefetch data is embedded
1046+ in the RSC payload, not a separate request.
1047+ - Did ` errors ` surface any ` unstable_instant ` validation failures?
1048+ - Is ` unstable_prefetch = 'runtime' ` exported from the correct segment?
0 commit comments