Skip to content

Commit 9948015

Browse files
authored
[Skill] Runtime prefetch scenario, screenshot error surfacing, cURL -b parsing (#33)
* [Skill] Runtime prefetch scenario, screenshot error surfacing, cURL -b parsing * Distinguish PPR shell formats: HTML (goto) vs RSC payload (push)
1 parent 405886b commit 9948015

File tree

6 files changed

+197
-39
lines changed

6 files changed

+197
-39
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vercel/next-browser": minor
3+
---
4+
5+
Surface errors alongside screenshots, parse macOS Chrome cURL `-b` cookie format, guard `push` against same-URL navigation, add runtime prefetch scenario to SKILL.md

skills/next-browser/SKILL.md

Lines changed: 170 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
470473
The optional caption describes the screenshot or the rationale for taking
471474
it. 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`,
859872
read its source, check what's changing via the change reason column.
860873
Don'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
868885
monolithic loading state, not the page.
869886

870887
A 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
876893
everything beneath it into one fallback.
877894

878895
Work 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
889906
one where it doesn't. Don't commit to a root cause or propose changes
890907
from 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

908954
Between 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?

src/browser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,8 @@ export async function links() {
535535
export async function push(path: string) {
536536
if (!page) throw new Error("browser not open");
537537
const before = page.url();
538+
const resolved = new URL(path, before).href;
539+
if (resolved === before) throw new Error("already on this URL");
538540
await page.evaluate((p) => (window as any).next.router.push(p), path);
539541
await page.waitForURL((u) => u.href !== before, { timeout: 10_000 }).catch(() => {});
540542
return page.url();

src/cli.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,14 @@ if (cmd === "screenshot") {
171171
const fullPage = args.includes("--full-page");
172172
const caption = args.slice(1).filter((a) => a !== "--full-page").join(" ") || undefined;
173173
const res = await send("screenshot", { fullPage, caption });
174-
exit(res, res.ok ? String(res.data) : "");
174+
if (res.ok) {
175+
const { path, errors } = res.data as { path: string; errors: unknown };
176+
const parts = [path];
177+
if (errors) parts.push("\nerrors:\n" + json(errors));
178+
console.log(parts.join(""));
179+
process.exit(0);
180+
}
181+
exit(res, "");
175182
}
176183

177184
if (cmd === "snapshot") {

src/cookies.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ export type CookiePair = { name: string; value: string };
66
*
77
* 1. JSON array — Playwright-style `[{"name": "x", "value": "y"}, ...]`.
88
* 2. cURL command — as produced by DevTools → Network → Copy as cURL.
9-
* The Cookie header is extracted from the `-H 'cookie: …'` argument.
9+
* Cookies are extracted from `-H 'cookie: …'` or `-b '…'`/`--cookie '…'`
10+
* (macOS Chrome uses `-b` instead of `-H`).
1011
* 3. Bare cookie header — `name=v; name=v; ...` (e.g. the value of the
1112
* Cookie row in DevTools → Network → Request Headers).
1213
*
@@ -51,8 +52,11 @@ function extractCookieHeaderFromCurl(curl: string): string | null {
5152
const joined = curl.replace(/\\\r?\n\s*/g, " ").replace(/\^\r?\n\s*/g, " ");
5253
// -H 'cookie: …' (bash) or -H "cookie: …" (cmd). Chrome/Firefox use one
5354
// or the other depending on which Copy-as-cURL variant the user picked.
54-
const m = joined.match(/-H\s+(['"])\s*cookie\s*:\s*([\s\S]*?)\1/i);
55-
return m ? m[2] : null;
55+
const h = joined.match(/-H\s+(['"])\s*cookie\s*:\s*([\s\S]*?)\1/i);
56+
if (h) return h[2];
57+
// macOS Chrome uses -b (or --cookie) instead of -H for cookies.
58+
const b = joined.match(/(?:-b|--cookie)\s+(['"])([\s\S]*?)\1/);
59+
return b ? b[2] : null;
5660
}
5761

5862
function parseCookieHeader(header: string): CookiePair[] {

src/daemon.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ async function run(cmd: Cmd) {
104104
return { ok: true, data };
105105
}
106106
if (cmd.action === "screenshot") {
107-
const data = await browser.screenshot({ fullPage: cmd.fullPage, caption: cmd.caption });
108-
return { ok: true, data };
107+
const [path, errors] = await Promise.all([
108+
browser.screenshot({ fullPage: cmd.fullPage, caption: cmd.caption }),
109+
browser.mcp("get_errors").catch(() => null),
110+
]);
111+
return { ok: true, data: { path, errors } };
109112
}
110113
if (cmd.action === "links") {
111114
const data = await browser.links();

0 commit comments

Comments
 (0)