reference/red-test-robustness.md
# RED-test robustness: verify the RED before optimizing
The C-gate of the workflow. A RED that is red for the wrong reason sends you optimizing a route that
was never broken: the route becomes instant, the test stays red (or the code is contorted to
satisfy a broken assertion), and the effort lands on the wrong problem. The prevention is cheap:
spend a few minutes verifying the RED is trustworthy first.
## The deciding question
> **Does the marker render WITHOUT the lock, as the test user?**
- **No**: the test is red because the marker or page is not there for that user or environment. A
marker bug, not an instant-navigation bug. Fix the marker. (This is the most common case.)
- **Yes**: the marker exists and is reachable; a red under the lock is a genuine "not instant".
Optimize the route.
Everything below serves answering that question honestly.
## When the blocked route will not build
With Cache Components, a top-level blocking read can fail the build before the
test can produce a RED. Add `export const instant = false` to the target route
as part of the temporary RED scaffold. It lets the known blocker build without
making the navigation instant. Remove the opt-out with the fix, and include it
when reverting the fix for the differential.
## Cookie and session reads are not lock probes
Do not manufacture a RED with `cookies()` or a session read alone. The testing
lock restricts the navigation to its shell; it does not make request cookies
unavailable. Use the route's real blocking uncached data, and prefer the
self-validating test variant when deferred content exists.
## The robustness checklist (all must hold)
1. **Red on baseline**: fails on the unfixed route.
2. **Right reason**: without the lock, the marker is visible on the production-build rig (never
`next dev`), running as the test user, not only in the author's own logged-in session.
3. **Differential**: reverting only the fix → RED; re-applying → GREEN; nothing else moves it.
4. **Non-gameable marker**: a sync element of the static shell, never streamed data.
5. **Deterministic on a production build**: stable across N runs; never `next dev`.
6. **Discriminating both ways**: present under the lock on an instant route, absent under the
lock on a blocking one.
7. **Renders for the test user**: under that user's flags, plan, role, and data.
8. **Conditional redirects accounted for**: assert at the route's real destination for that user.
9. **Real selector**: a `data-testid` on a known static-shell node, not a guessed `role`/`name`.
10. **Committed marker**: assert a DOM node after the client subtree commits, not text searched in
the RSC response. Client Component text may not appear in the response bytes.
11. **Visible marker**: not `display:none`, `display: contents`, a fragment, off-screen, or inside a
hover overlay; for lists, target `.filter({ visible: true }).first()`.
12. **Fresh build under test**: the deployment being measured contains the latest commit, not a
build URL still serving the previous deploy.
## Taxonomy: red for the wrong reason
Any of these makes a RED untrustworthy. None of them is "the navigation isn't instant."
| Wrong reason | How it occurs | How to rule it out |
| ---------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| **Selector matches nothing** | a guessed `getByRole('button', { name: 'Folder' })` | grep the component for the real accessible name; add a `data-testid` |
| **Conditional redirect** | the route `redirect()`s for the test user (flag/role), so the marker page is never reached | check the page's top-level branches; assert at the real destination, or pin the flag |
| **Flag / plan / role gate** | the author has the flag or plan; the test user does not | run the unlocked baseline as the test user; pin flags via the project's override mechanism |
| **Empty state** | the marker only exists when data does; the CI account is empty | pick a marker present in the empty state (a layout element such as the page header), or seed data |
| **Timeout / flake** | a slow API or transient infrastructure error | re-run; separate infrastructure flake from a real signal |
| **Streamed marker** | the marker is behind `<Suspense>`, so it is never in the shell | choose a sync shell element; verify it sits outside every `<Suspense>` |
| **Auth redirect** | unauthenticated → `/login` | confirm login succeeded before the navigation |
| **Stale deployment** | the test ran against the previous build (the URL under test still serves the prior deploy) | poll the deployment for a marker from the latest commit before trusting any verdict |
| **Hidden / off-screen** | the testid is on a hover-overlay or off-screen list item | put the marker on an always-visible node; `.filter({ visible: true }).first()` for lists |
## Worked cases
These are illustrative failures from real optimization runs; each was red for a wrong reason, and
none was an instant-navigation problem. One app's drift surface might be dominated by feature flags and
plans; another's by auth state, an empty database, or locale. The taxonomy lists every wrong
reason; the rig file's DRIFT list says which rows apply to your app.
- **Guessed selector + empty state**: the marker was a button picked by a guessed accessible name
that no element actually had, on a list page whose CI account had no rows. → checks 7, 9. Fix: a
`data-testid` on a real static-shell node.
- **Hidden marker**: the testid sat first on a `hidden sm:block` hover-overlay link, then on an
off-screen carousel card; Playwright resolved the element but reported it hidden. → check 11.
Fix: an always-visible node; for lists, `.filter({ visible: true }).first()`.
## Differential check (capture in the PR)
The strongest evidence that the RED measured the property:
```
1. on the fixed branch → GREEN
2. revert ONLY the fix (the <Suspense> push-down) → RED
3. re-apply → GREEN
4. confirm no other change moves it
```
Link the two runs (or include the toggle diff and results) in the PR description. A reviewer who
sees the differential knows the test measures the property.
## `instant()` is not a stopwatch
See: [`instant()`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests).
The test does not measure how fast a navigation is. `instant()` gates dynamic data so the content
of the static shell can be asserted; the signal is presence, not speed. Under the lock, an instant
route's shell is present, and a blocking route's content never commits, regardless of wait time.
Therefore:
- The shipped assertion is `await expect(SHELL_MARKER).toBeVisible()` under the lock. Do not add a
custom timeout or a `painted` boolean.
- A custom short timeout (e.g. `3000`) implies a race against a clock that does not exist. It adds
nothing to the verdict and invites false REDs on an instant route whose commit lands a microtask
late.
- Do not use `locator.isVisible({ timeout })` as a soft wait: Playwright deprecated and ignores
that timeout; the call returns immediately.
- "Renders for the test user" (checks 7-9) is established at authoring time with the unlocked
baseline scaffold, not by a timed assertion in the shipped test.
## `instant()` guards need no retries and no prefetch warming
An `instant()` guard is deterministic. Do not configure retries on one, and do not hover-warm to
help a prefetch land in time. Under the lock, the router initiates the route prefetch and awaits it
before committing (even for a `prefetch={false}` link, even for a route already in the prefetch
cache), so the committed shell does not depend on any prior render, hover, or menu-open prefetch.
A flaky guard has a real cause: a marker that is not a sync node of the destination's shell, a
flag/role/empty-state gap for the test user, or a genuinely blocking route. The fix is in the page or
the marker; a retry masks the regression the guard exists to catch. The only legitimate
`.hover()`/menu-open is when the trigger element itself is not in the DOM until hovered or opened.
## Silent no-op: the testing API must be exposed in the measured build
`instant()` works by setting a cookie (`next-instant-navigation-testing`) that lock code inside the
build reads. It does not throw when that lock code is absent; it only throws on nested calls or an
unknown base URL. If the build was produced without the testing API
([`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild)),
the cookie is ignored, the navigation runs normally, and the `instant()` test passes vacuously. A
green `instant()` test is only meaningful if the lock engaged.
Two defenses; use both:
1. **Confirm the API is exposed on the target.** Wire the flag to the platform's preview/staging
condition or an explicit environment variable; the rig file records the project's spelling
(SKILL.md phases 0 and A). Do not trust a pass from a build where it is not set.
2. **Make the test self-validating**: for any route with deferred content, also assert that the
deferred content is gated under the lock, not only that the shell is present
(`../test-template.md`, self-validating variant). If the lock did not engage, the content is
already present and `toHaveCount(0)` fails.
The gated half holds under the lock for both navigation types regardless of warm state: the
soft-nav client lock gates dynamic-data writes, and on an initial load the server honors the
cookie on the document request and suspends dynamic data. A vacuous pass is only possible with
a build produced WITHOUT the testing API, which defense #1 above covers.
## Determinism and the rig
- Always measure on a production build, never `next dev`; SKILL.md phase A owns this invariant and
its rationale.
- Run the RED several times; an intermittently red gate is not a gate. If it flakes, determine
whether the cause is infrastructure (transient errors) or a real race before trusting either
color.
rig-template.md
# Production `instant()` rig
The optimizer suite needs a production build that exposes the Next.js testing
API, a stable URL for that build, and a Playwright command that can drive the
initial loads or client navigations being optimized. Discover this setup once,
record it in `instant-nav.rig.md`, and reuse it across optimization runs.
Read an existing `instant-nav.rig.md` before creating one. Inspect the
repository before asking the user:
- `package.json` scripts for build, start, and end-to-end tests
- `playwright.config.*` for `baseURL`, `webServer`, projects, and authentication
- `next.config.*` for existing `experimental` options
- CI, preview deployment, container, and hosting configuration
- test helpers for login, `storageState`, fixtures, flags, and seeded data
Ask only for details the repository cannot answer, such as unavailable
credentials or which remote environment may expose the testing API.
## What the rig must define
### Production build and server
Use `next build` followed by `next start`, or a remote artifact produced by the
same production build. Development can help diagnose a route, but the final
RED and GREEN must come from a production build.
Record separate build and start commands. For a local rig, record the port,
stop any previous server before starting, fail on `EADDRINUSE`, and confirm the
new process owns the port before running Playwright. `next start` can fork a
`next-server` child, so the launcher process ID may not own the port. Start the
server in a process group that the rig can stop as a unit, or discover and stop
the process listening on the recorded port before the next build.
### Testing API
An `instant()` test against a production build requires
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild).
Gate it so real production builds do not expose the API:
```ts filename="next.config.ts" highlight={3,8-10}
import type { NextConfig } from 'next'
const exposeTestingApi = process.env.EXPOSE_TESTING_API === '1'
const nextConfig: NextConfig = {
cacheComponents: true,
experimental: {
exposeTestingApiInProductionBuild: exposeTestingApi,
},
}
export default nextConfig
```
Merge the option into an existing `experimental` object instead of replacing
the project's other experimental options.
Set the condition while running `next build`. Setting it only for `next start`
is too late because the testing API is compiled into the production artifact.
When the artifact was built without it, Next.js does not activate the
navigation lock, so the test cannot distinguish shell content from streamed
dynamic content. Rebuild with the condition enabled before interpreting the
results. Use the project's existing environment naming when it already
distinguishes test, staging, preview, and production builds.
### Test command and base URL
Record the exact Playwright command and how it receives the measured build's
URL. Reuse the project's package manager, Playwright configuration, projects,
and reporters. The suite must import `instant()` from `@next/playwright`. If
the dependencies are absent, install `@next/playwright` on the same release
line as the project's `next`, alongside `@playwright/test`.
Prefer an existing Playwright `webServer` configuration to own the local
server lifecycle. Ensure its command serves the production build with
`next start`. With `webServer` managing startup and cleanup, run the build and
test commands without starting another server manually:
```bash filename="Terminal"
EXPOSE_TESTING_API=1 pnpm build
BASE_URL=http://localhost:3000 pnpm playwright test tests/static-shell.spec.ts
```
If the rig does not use `webServer`, start `pnpm start --port 3000` in a
separate terminal after the build, keep it running during the test command,
and stop it afterward. Use only one server lifecycle for the measured app.
Adapt the script names and port to the project. Follow the public
[`instant()` testing pattern](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests): use `page.goto()` for an initial-load
contract and click the real `<Link>` for a client-navigation contract.
### Test context
Record the state required to reach the target route and shell marker:
- Use `public; no authentication` when the route is public.
- Otherwise record the test account and login mechanism, including a fixture,
`storageState`, API login, or seeded session.
- Record flags, plan, role, locale, seeded data, and other state that can change
which shell the test sees.
A test user is not required. The field exists to make authenticated and
state-dependent tests reproducible when the app needs one.
### Drift
List differences between the state used to choose the shell contract and the
state used by Playwright. Feature flags, permissions, empty test data, and
locale differences can make an assertion fail because the target is
unreachable, not because its shell blocks. Write `none known`
only after checking the test context.
### Iteration loop
Record the complete loop the agent can repeat without rediscovering commands:
- Local: build with the testing API, start the new artifact, run the focused
suite, stop the server, edit, and repeat.
- Remote: push, wait for the measured artifact, verify it matches `HEAD`, run
the focused suite against its URL, edit, and repeat.
Note any step the agent cannot perform without the user, including deployment
approval, protected branches, secrets, or multi-factor authentication.
### Artifact liveness
For a remote rig, record how the test proves the deployment matches `HEAD`.
Prefer an endpoint or response header that exposes the deployed commit SHA. If
the app has neither, use the deployment provider's API to select the artifact
whose commit SHA matches `HEAD`.
A freshly completed local `build` followed by `start` does not need a SHA
probe. Record `n/a; local build and start`.
### Walls
Record build and run obstacles with their working resolution, such as required
environment variables, server-only imports that fail during prerendering,
unavailable credentials, or a process that keeps reclaiming the test port.
Reuse these notes on the next iteration.
## Write `instant-nav.rig.md`
Place this file at the repository root or next to the end-to-end configuration:
```md
# instant-nav rig: <project>
- BUILD: <commands or platform that builds and serves the measured production artifact>
- EXPOSE: <condition that enables exposeTestingApiInProductionBuild during build>
- RUN: <focused Playwright command and how it receives BASE_URL>
- TEST USER: <public/no auth, or account and login>; state: <flags, role, data, locale>
- DRIFT: <differences that could change the asserted UI>
- CONTRACTS: <route, initial load or source Link, shell marker, and deferred marker>
- LOOP: <local build → start → test, or push → deploy → test>; agent limits: <...>
- LIVENESS: <deployed SHA check, or n/a for a local build and start>
- WALLS: <project-specific obstacles and their resolutions>
```
`CONTRACTS` may list more than one focused navigation, but each route and
navigation type needs its own test. Every field needs a concrete value. `n/a`
is valid only with a reason, such as `TEST USER: public; no authentication` or
`LIVENESS: n/a; local build and start`.
## Check the rig before writing the baseline
Before recording the static-shell contract:
1. Build with the testing API condition enabled.
2. Start or locate that exact artifact and confirm the base URL responds.
3. Run one focused `instant()` smoke test using the intended navigation type.
4. Confirm the test can reach the route and eventual destination UI in the
recorded test context.
Fix the rig before interpreting an optimizer failure. A missing testing API,
stale deployment, unreachable target, or wrong test state is an environment
failure rather than evidence that the shell blocks.
Once the rig produces a conclusive result, continue to the next workflow gate.
Do not repeat the same build or test to look for flakiness unless two observed
results conflict.
SKILL.md
---
name: next-cache-components-optimizer
description: >
Optimize the meaningful UI available immediately from a Next.js route on an
initial load (hard navigation) or named client-side navigation (soft
navigation). Encode the goal as a failing @next/playwright instant() e2e and
work it to green, one verified route and entry point at a time; the shipped
test then guards against regression. Use when asked to grow a static shell,
fix a slow first paint or non-instant navigation, diagnose which Suspense
boundary blocks useful UI, or add instant() regression coverage. Requires
Next.js 16.3+ with Cache Components already adopted.
---
# next-cache-components-optimizer
Set up an agentic optimization loop that drives a Next.js route from "not
instant" to "instant" and keeps it there. The loop is test-driven: encode the
goal as a failing `@next/playwright` `instant()` test, work it to green, and
ship the test as the regression guard. Run it once per target route and entry
point. Work the phases P → G in order; each ends in a gate.
Before changing the route, read the bundled **Optimizing the static shell**
guide at
`node_modules/next/dist/docs/01-app/02-guides/optimizing-the-static-shell.md`.
If it is unavailable, use the [online
guide](https://nextjs.org/docs/app/guides/optimizing-the-static-shell). The
guide owns the framework behavior and implementation patterns. This skill owns
the navigation contract, production rig, trustworthy RED-to-GREEN loop,
parity check, differential, and report.
## What is invariant, and what is yours
One thing here is fixed. The rest is yours. Read this before treating any
command, platform, or env var below as a requirement.
- **Invariant: the verification loop.** Maximizing the shell is worthless
unless you can prove it. The proof is an automated check: under a lock that
gates dynamic data, the static shell still commits. RED shows the gap, GREEN
shows it closed, the test ships as the regression guard. It must run on a
production-like build and must not be able to pass vacuously. Stand the loop
up once; every later optimization is then verifiable by construction. The
loop is the deliverable, not any one route.
- **The mechanism: `@next/playwright` `instant()`.** This skill uses
[`instant()`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests)
as a ruler, not a stopwatch (phase A). It comes from
`@next/playwright` (installed alongside `@playwright/test`, on the same
release line as `next`), so it isn't tied to any host. Keep it. Timing a
navigation by hand is too flaky to trust, and is the failure mode this skill
exists to prevent.
- **Yours: the rig.** How you build, deploy, authenticate, configure
Playwright, and loop belongs to your stack, not to this skill. A local
`next build && next start`, a CI/staging container, and a per-push preview
deploy are equally valid rigs; the verdict comes from the build, never the
platform. Phase 0 maps the invariant onto your repo. Read every platform
name, env-var spelling, and command below as an example to translate, not a
requirement.
## Two entry points, two instant UI contracts
A route reaches the user two ways:
- **Initial load (hard navigation)** commits the route's prerendered static
shell; deferred parts stream in behind their loading skeletons (Suspense
fallbacks, `loading.tsx`).
- **Client-side navigation (soft navigation)** reuses shared layouts and
commits the available App Shell for the destination segments that change.
The same implementation patterns can make either entry point instant. The test
differs only in how the navigation is driven ("Driving the navigation in
tests" below), and the available UI can differ because a soft navigation
reuses layouts above the source and destination's divergence point. Guard the
entry point the user named, and both when both matter. See [What "instant"
means](https://nextjs.org/docs/app/guides/instant-navigation#what-instant-means).
This skill is therefore not limited to increasing the direct-load static
shell. It can also fix a non-instant client navigation when caching reusable
work or moving request-time work behind a focused boundary makes the
destination's available App Shell commit immediately. When that App Shell is
already instant and the goal is to fetch additional URL-specific content
before the click, hand off to `next-partial-prefetching-optimizer`.
## Goal
Make the most meaningful available App Shell UI commit immediately, and let
only genuinely request-time work stream afterward. The shipped test
deterministically encodes **present ∧ instant**. **Non-blank** is the additional
bar the workflow enforces by judgment because an `instant()` pass alone is
satisfied by an empty shell.
`instant()` is a ruler, not a stopwatch: assert that the shell appears under
the lock; do not time it. A trustworthy verdict requires a production build
(phase A).
The GREEN under the lock is the deterministic verdict; each gate keeps it
trustworthy.
## Reporting to the user
This loop is meant to run unattended, so it doesn't stop to ask between steps.
Work the navigation the user named, finish it, and stop. What matters is how you
word and present the results, not how often you interrupt. The mechanics below —
the rig, RED, GREEN, the gates — are your scaffolding; the user never needs to
hear those words.
- **Speak their language.** Describe the gap and the result in terms of what the
user sees: "navigating to the dashboard waited on the charts query before
anything painted; now the layout and skeletons paint instantly and the charts
stream in" — not RED/GREEN, the lock, or the phase letters.
- **Show, don't tell.** When you report a route, drive the browser (or attach
before/after screenshots) so the user watches the shell commit immediately and
the data stream in, rather than reading a claim. Identical before and after
means the fix did nothing — roll it back.
- **Present a run as a list of results the user can click through** — one line
per navigation: the route, what commits instantly, and what streams in — not a
transcript of the loop.
- **Only surface a question for a genuine fork:** a fix that would change
behavior, a security-sensitive read, or a route that's dynamic by design (a
per-link-prefetch candidate, not a shell to grow). A clean instant fix is not
a fork — keep going. With no one to ask (an unattended run), don't block: take
the safe default and note the assumption — for a cache-freshness choice,
defer the read behind `<Suspense>` (always fresh, still instant) rather than
guess a `cacheLife`.
## The workflow
```
- [ ] P PREREQS Next.js 16.3+ with cacheComponents: true; upgrade first → below
- [ ] 0 SETUP once per repo: discover + write instant-nav.rig.md → rig-template.md
- [ ] A RIG production build with the testing API exposed → below
- [ ] B BASELINE unlocked: the marker renders for the test user → test-template.md
- [ ] C RED locked instant(): the shell does not commit → test-template.md
- [ ] C-gate VERIFY-RED: stop until the RED is trustworthy → reference/red-test-robustness.md
- [ ] D FIX apply the matching documented pattern → guide links below
- [ ] D1 reuse existing loading UI; keep the shell meaningful
- [ ] D2 preserve layout and responsive behavior
- [ ] E PARITY the refactor changed only whether the route is instant
- [ ] F DIFFERENTIAL revert only the fix → RED; re-apply → GREEN → reference/red-test-robustness.md
- [ ] G REVIEW PR checklist (below)
```
Phases B and C build the test; only the locked test from C ships.
---
## P. PREREQUISITES: current Next.js with Cache Components
The workflow depends on framework capabilities that ship with current Next.js:
- **Next.js 16.3+ with `cacheComponents: true`** in `next.config.ts`. Without
Cache Components there is no static shell to optimize.
- **`@next/playwright`** on the same release line as the project's `next`; it
provides `instant()`. Verify with `npm ls next @next/playwright` (or the
project's package manager) and align them if they differ. The matching
testing API is in the `next` runtime, gated by the
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild)
config flag (phase A).
If the project does not meet these, upgrade first (`npx @next/codemod upgrade`
automates most of it), then enable Cache Components in `next.config.ts`:
```ts
export default { cacheComponents: true }
```
Enabling the flag surfaces the blocking routes to resolve first; the
[`next-cache-components-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-adoption)
skill drives that adoption. Reach for this optimizer once the app builds under
Cache Components.
This gate is deliberate: the skill targets current Next.js, and none of the
verdicts below are meaningful on older versions.
## 0. SETUP: discover this project's rig, once per repo
The principles in this skill are fixed; the infrastructure they run on is
yours. On first use in a repository, discover how the project builds, deploys,
authenticates, and tests (inspect the repository first, and ask the user only
what it cannot answer), then write the answers to a committed
`instant-nav.rig.md`. Every later run reads that file instead of
rediscovering. The required build, test context, navigation contracts,
iteration loop, and file template are in **`rig-template.md`**.
If the repo has no Playwright e2e harness yet, standing up a minimal one
(`@next/playwright`, a config with `baseURL`, one authenticated path) is part
of this step; the loop does not assume a pre-existing suite.
## A. RIG: a production build with the testing API exposed
Stand up the rig described by `instant-nav.rig.md`. Two invariants hold on
every platform:
1. **Never measure on `next dev`.** It does not prefetch, and its lock is
unreliable for blocking routes, so a dev `instant()` result is not a valid
RED or GREEN.
2. **The measured build must expose the testing API.** Otherwise `instant()`
silently no-ops and the test passes vacuously (see
`reference/red-test-robustness.md`). The lock-engagement proof is the phase-C
RED itself: the unfixed target route is the known-blocking route, and its
RED under the lock shows the lock engages on this build (C-gate); the
self-validating variant in `test-template.md` is the in-band guarantee. Wire
`experimental.exposeTestingApiInProductionBuild` to a condition that is true
for every build you measure and never true in production:
```ts
experimental: {
// Use the condition your platform provides, and record it in the rig file:
// local: an explicit opt-in, as below
// generic CI: process.env.DEPLOY_ENV === 'staging'
// Vercel: process.env.VERCEL_ENV === 'preview'
exposeTestingApiInProductionBuild:
process.env.EXPOSE_TESTING_API === '1',
}
```
The rig is any production-like build that exposes the testing API: a local
`next build && next start`, a CI/staging container, and a preview deploy are
all equally valid; the verdict comes from the build, not the platform. See
`rig-template.md` for the setup requirements.
For any deployed or remote build, poll the rig's LIVENESS probe to confirm the
artifact contains `HEAD` before trusting a verdict (a stale deploy reads as a
false RED or GREEN); a local `next build && next start` needs none. The probe
mechanism is in `rig-template.md`.
## B. BASELINE (unlocked): development scaffold, do not ship
Drive the real navigation with no `instant()` lock and assert that the
destination's `SHELL_MARKER` renders **as the test user**: the account the
e2e suite authenticates as (in CI, the CI account; locally, your e2e login
fixture), with its flags, plan, role, and data. This establishes that the
marker is real and reachable: not flag-gated, not redirected away, not a
guessed selector. The suite runs as the test account, not the author's session;
that environment drift (the rig DRIFT list) is a common source of
untrustworthy REDs. Scaffold and run command: **`test-template.md`**.
**Delete this baseline before the PR.**
## C. RED (locked) + the VERIFY-RED gate
Wrap the same navigation in `instant()`; assert the shell commits under the
lock. A RED here is the gap. **This is the test that ships**
(`test-template.md`).
Assert the complete intended contract: meaningful shell markers and existing
loading states are visible, request-time UI is absent under the lock, and the
completed UI renders after release. Do not change an existing data source,
production selector, or required route variant to make the test easier. A
required contract may not be skipped or weakened.
For repeat and browser-back contracts, follow the
[visibility-aware selector guidance](https://nextjs.org/docs/app/guides/preserving-ui-state#testing)
so hidden preserved routes cannot satisfy the assertions. When the contract
contains independently suspended regions, assert each one separately.
Prefer the self-validating variant when the route has deferred content. If the
route cannot build while blocked, or a cookie/session read stays GREEN, use the
RED recipes in `reference/red-test-robustness.md`.
> **C-gate: do not start optimizing until the RED is verified trustworthy.** A
> RED that is red for the wrong reason sends you optimizing a route that was
> never broken.
The question that settles it: **does `SHELL_MARKER` render without the lock,
as the test user?** Answer it by re-running phase B as the test user, not by
adding assertions to the shipped test. The two-branch resolution (No → marker
or environment bug; Yes → genuine gap, proceed to D), the full taxonomy of
untrustworthy REDs, the checklist, and worked cases are in
**`reference/red-test-robustness.md`**. Read it now.
---
## D. FIX: apply the documented pattern
Use the **Optimizing the static shell** guide you read at the start. Follow the
section that matches the blocker instead of duplicating its framework guidance
here:
| Blocker | Guide pattern |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Stable UI is hidden by a broader loading state | [Keep static UI in the shell](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#keep-static-ui-in-the-shell) |
| A layout or page awaits request-time data too high in the tree | [Push data access down](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#push-data-access-down) |
| New or moved boundaries need useful, stable fallbacks | [Design loading states](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#design-loading-states) |
| A result can be safely reused across requests | [Cache reusable work](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#cache-reusable-work) |
The guide's example covers the common implementation shapes:
- [`params`, `searchParams`, and focused boundaries](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#step-1-move-url-dependent-work-behind-suspense)
- [authentication, `cookies()`, and `headers()`](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#step-2-move-authentication-behind-suspense)
- [cache placement and revalidation](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#step-3-cache-the-reusable-plan-data)
Preserve the route's existing data source, freshness, authorization, and
completed behavior. Do not replace a mutable read with a build-time import to
make it appear static. Cache the existing read when it can be reused. Stream it
when it must be computed for each request.
Trace each async subtree to its own cache, Suspense, and navigation boundary
before concluding that the route is cached.
If development or a build surfaces another instant-navigation Insight during
the refactor, follow [validation as you
refactor](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#follow-validation-as-you-refactor)
and then the canonical Insight it provides. This is especially important for
API-specific blockers such as metadata, viewport, and nondeterministic values.
Reuse existing loading UI and keep the resulting shell meaningful at every
supported breakpoint. An empty fallback is valid only when the resolved
component also has no visual footprint.
If the optimization adds or expands a cache boundary, follow
[Revalidating](https://nextjs.org/docs/app/getting-started/revalidating). When a
writer can change the cached data, populate the cache, perform the mutation,
and verify that the next read returns the updated value. `instant()` proves
readiness, not mutation freshness.
Do not use `export const instant = false`, weaken the contract, or ship an
empty document shell as the optimization.
> **D-gate: phase D is complete when the locked test from phase C passes GREEN
> under the lock on the production-build rig**, not when the code compiles. That
> GREEN is the deterministic stop for the fix loop; proceed to E.
If URL-specific content is the only missing instant UI, stop at [Include
URL-specific content in the instant
UI](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#include-url-specific-content-in-the-instant-ui)
and hand off to `next-partial-prefetching-optimizer`.
## E. PARITY: the refactor changed only whether the route is instant
The push-down is a mechanical transform, not a redesign. Afterward the route
must render the same tree, data, ordering, empty and error states, redirects,
and interactions as before; the only observable difference is that the shell
now commits instantly. Verify:
- **Same render output.** The moved `await`s compute and return the same
values; after the stream, the route shows the same content as the base
branch for the test user.
- **Request inputs still work.** Exercise the authentication, cookie,
parameter, search-parameter, and data variants named by the route or rig.
- **Side effects still fire.** A deferred `redirect()` or `notFound()` still
happens at request time. Confirm unauthorized and missing-record behavior.
If the route must preserve an HTTP status, account for [streaming status-code
behavior](https://nextjs.org/docs/app/guides/streaming#status-codes).
- **Supported viewports reach the real UI** after the stream.
- **Client state survives.** Because the layout UI is hoisted into the stable
shell rather than swapped on resolve, open menus, scroll position, focus,
and input state persist across the stream. See [Preserving UI
state](https://nextjs.org/docs/app/guides/preserving-ui-state).
- **Pre-existing failures stay separate.** If the route errors after the
change, reproduce it on the base branch. The same failure there is an
environment or data problem, not an optimizer regression.
If anything other than whether the route is instant changed, reduce the refactor.
## F. DIFFERENTIAL
Revert only the fix → RED; re-apply → GREEN; link both runs
(`reference/red-test-robustness.md`). Every contract intended to distinguish
the optimization must be RED after the revert and GREEN after the re-apply. On
a deployed rig, confirm each run is live (LIVENESS, phase A) before trusting
its color.
After the final re-apply, run the complete in-scope command from the rig. A
filtered subset, a written command, or a suite with a required test skipped is
not final GREEN.
## G. REVIEW (PR checklist)
A green final state means nothing if the RED was never trustworthy. The
test-trustworthiness items are the robustness checklist
(`reference/red-test-robustness.md`); confirm them, then require these
PR-specific items:
- [ ] **Differential shown**: RED without the fix, GREEN with it, runs linked.
- [ ] **Parity confirmed (E)**: same content, redirects, and state.
- [ ] **Mutations verified when applicable**: after populating any cache whose
data can be updated, a mutation test confirms the next read returns the
expected data.
- [ ] **Freshness preserved**: new cache scopes follow the data's existing or
explicitly chosen lifetime.
- [ ] **Existing loading UI reused (D1)**: no new page-mirroring skeleton.
- [ ] **Shell matches the real render at supported viewports (D2)**.
- [ ] **Baseline removed**: only the locked test from C remains.
**Stop condition for the whole workflow:** the locked test from C is GREEN on
the rig, the differential (F) holds, and every item above is checked. Until all
three hold, you are not done.
## Driving the navigation in tests
- **Soft navigation** → drive a real `<Link>` click. **Initial load** → use
`page.goto()` inside `instant()` with the `baseURL` option. Do not substitute
`goto` for a soft-nav verdict; the two contracts can differ
(`test-template.md`).
- With parallel routes, only the slots that change re-render on a soft
navigation; client-rendered navigation UI does not re-render at all. Do not
chase a slot the navigation never touches. See [Loading and Error UI with
Parallel Routes](https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes#loading-and-error-ui).
## Files
- `rig-template.md`: phase 0 production build, test context, navigation
contract, and unattended loop discovery.
- `test-template.md`: the shipped `instant()` specs for both navigation
types (phase C), and the delete-before-PR baseline scaffold (phase B).
- `reference/red-test-robustness.md`: the C-gate and phase F. The taxonomy of
untrustworthy REDs, the checklist, the differential recipe, the vacuous-pass
failure mode, and worked cases.
## After optimization
Once the target routes are instant, check whether the app has already adopted
Partial Prefetching (`partialPrefetching: true`, or the relevant destination
still uses `prefetch = 'partial'` during an incremental rollout).
Make that check mechanically:
```bash
rg -n "partialPrefetching|prefetch\s*=\s*['\"]partial['\"]" --glob 'next.config.*' --glob 'app/**' --glob 'src/app/**'
```
If `partialPrefetching: true` is in config, the app is globally adopted. If only
`prefetch = 'partial'` matches, treat those destination segments as adopted
during an incremental rollout and keep checking any other target routes.
- **Already adopted:** when the requested instant UI is URL-specific and the
destination App Shell is already instant, continue with
`next-partial-prefetching-optimizer`. It owns the per-link value and cost
decision.
- **Not adopted yet:** recommend
[`next-partial-prefetching-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-partial-prefetching-adoption).
That skill moves the app onto the better prefetching model: shared App Shell
prefetches by default, fewer duplicated full-prefetch requests for visible
links, a link audit for existing `<Link prefetch={true}>` usage, and optional
per-link prefetching only where URL-specific content is worth the
extra server work.
test-template.md
# Test template: the instant() guard
See: [`@next/playwright` `instant()`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests).
Ship one test per navigation type you are guarding: under `instant()`, assert that the
destination's static shell appears. `instant()` gates dynamic data, so a correctly instant route
commits its shell under the lock and a blocking route does not. `instant()` is a ruler, not a
stopwatch: do not add custom timeouts or timing races (see `reference/red-test-robustness.md`).
Keep each route and navigation type in its own focused test. Do not loop over several routes or
aggregate their readiness results in one test; focused tests can still run serially in one browser
worker.
Whether the marker is the right one (rendering for the test user, not flag-gated, not redirected
away, not guessed) is established at authoring time with the unlocked baseline scaffold below
(phase B, the C-gate), not by additional assertions in the shipped test.
All identifiers in angle brackets (`<b>`, `<Trigger>`) and the `../helpers` import are placeholders;
substitute your project's e2e auth helper, URL helper, and real testids/trigger before running.
## Soft navigation (client-side navigation)
Drive a real `<Link>` click. The committed shell is the destination's prefetched App Shell.
Under the lock the router initiates and awaits the route prefetch itself, so no manual warming is
needed; if the shell is intermittently absent, treat it as a real blocker or marker bug (C-gate),
never as a warming race. Do not add waits or hovers.
```ts
import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'
// Use the auth/setup helpers your e2e suite already has. Run as the test user
// (defined in SKILL.md phase B).
import { logIntoTestAccount, testUrl } from '../helpers'
// A SYNC element of the destination's static shell (header, action button,
// column header), not data that streams in, and one that renders for the
// test user (not gated by a flag, plan, role, or empty state). Prefer a
// data-testid on a known static node over a guessed role/name.
const SHELL_MARKER = '[data-testid="<b>-shell-marker"]'
test.describe('instant nav: A -> B', () => {
test.beforeEach(async ({ page, browser }) => {
await logIntoTestAccount(page, browser)
})
test('B shell commits under instant()', async ({ page }) => {
await page.goto(testUrl('/'))
const trigger = page.getByRole('link', { name: '<Trigger>', exact: true })
await expect(trigger).toBeVisible({ timeout: 20000 })
await instant(page, async () => {
await trigger.click()
// static shell asserted under the lock; no timeout
await expect(page.locator(SHELL_MARKER)).toBeVisible()
})
})
})
```
The trigger selector follows the same rule as `SHELL_MARKER`: prefer a `data-testid` on the real
`<Link>` (`page.getByTestId('<trigger>-link')`) over a guessed accessible name. `getByRole({ name })`
is shown only for brevity; like the marker, the trigger must reliably resolve for the test user.
## Initial load (hard navigation)
Drive `page.goto()` inside `instant()` with the `baseURL` option. The document response contains
the route's prerendered static shell, but Playwright assertions run after hydration. A Client
Component that can resolve without request-time server content may already be visible. Choose a
shell marker that remains valid after hydration instead of a prerender fallback that hydration
replaces immediately. `baseURL` is required because `page` is still `about:blank` when `instant()`
runs (`resolveURL` falls back to `page.url()` only when no `baseURL` is passed).
Establish the session WITHOUT navigating `page` (inject `storageState`, or log in on a separate
context/page). A login helper that navigates `page` itself defeats the measurement for a different
reason: that navigation completes before `instant()` acquires the lock, so it runs unmeasured. The
session must be pre-established either way; otherwise an authenticated route redirects to login
and the RED is false.
If the project's only login helper navigates `page`, the agent must use a
storageState/separate-context path here instead, a session-injection call that
does NOT call `page.goto`:
```ts
test.describe('instant initial load: B', () => {
test.beforeEach(async ({ page }) => {
await injectTestUserSession(page) // storageState only; must NOT call page.goto
})
test('B shell is served', async ({ page }) => {
const url = testUrl('/<b>')
await instant(
page,
async () => {
await page.goto(url)
await expect(page.locator(SHELL_MARKER)).toBeVisible()
},
{ baseURL: new URL(url).origin }
)
})
})
```
## Self-validating variant (recommended for routes with deferred content)
Also assert that the deferred content is gated under the lock and streams after release. This
makes a vacuous pass impossible: if the lock did not engage (testing API missing from the build),
the content is already present and `toHaveCount(0)` fails (see `reference/red-test-robustness.md`).
`SHELL_MARKER` is the shell node; `[data-testid="<b>-content"]` is the deferred data it guards.
```ts
// soft navigation
await instant(page, async () => {
await trigger.click()
await expect(page.locator(SHELL_MARKER)).toBeVisible() // shell present
await expect(page.getByTestId('<b>-content')).toHaveCount(0) // deferred data gated
})
await expect(page.getByTestId('<b>-content')).toBeVisible() // streams after release
```
The two **gated-half** assertions (shell visible, deferred content `toHaveCount(0)`) apply to the
initial-load `page.goto()` form too. The cookie gates the deferred content identically for both:
on a soft navigation the client lock gates dynamic-data writes; on an initial load the server
honors the cookie on the document request (set via `addCookies()` before navigation, scoped by
`baseURL`) and suspends dynamic data, independent of whether the route was previously rendered or
cached. So the initial-load `toHaveCount(0)` gated half is as valid as the soft-nav one; it needs
no fresh browser context and no cache-busting query param.
The **post-release** assertion (`getByTestId('<b>-content').toBeVisible()` after the `instant()`
block) applies to both forms. When the lock is released, Next.js refreshes the route so deferred
content can render. For an initial load, this is a soft refresh after hydration, with a hard reload
fallback if the router is not ready yet. The mechanism is in
`reference/red-test-robustness.md`.
## Baseline scaffold: do not ship
Before optimizing, confirm the target exists with an unlocked check (no `instant()`). It
disambiguates "not instant" from "marker absent for this user or environment". Run it as the test
user; mismatch against the rig DRIFT list is what the C-gate catches
(`reference/red-test-robustness.md`). Confirm the marker is real and reachable, then delete the
scaffold before the PR.
**The baseline must mirror the navigation type of the test you are shipping.** Drive a `<Link>`
click when guarding the soft-nav shell; drive `page.goto()` when guarding the initial-load shell.
The two shells can differ, as described in the
[initial-load and client-navigation section](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests). A click-driven
baseline run against a shipped `goto` test would confirm a marker that the `goto` path never shows,
which produces exactly the false RED the C-gate exists to prevent.
```ts
// soft-nav baseline: mirror the soft-nav instant() test
test('dev-only: navigating to <b> renders its shell (no lock)', async ({
page,
}) => {
await page.goto(testUrl('/'))
const trigger = page.getByRole('link', { name: '<Trigger>', exact: true })
await expect(trigger).toBeVisible({ timeout: 20000 })
await trigger.click()
await expect(page).toHaveURL(/\/<b>(\?|$)/) // confirm the real destination (no redirect away)
await expect(page.locator(SHELL_MARKER)).toBeVisible({ timeout: 15000 })
})
```
```ts
// initial-load baseline: mirror the initial-load instant() test (session pre-established)
test('dev-only: <b> shell is served (no lock)', async ({ page }) => {
await page.goto(testUrl('/<b>'))
await expect(page.locator(SHELL_MARKER)).toBeVisible({ timeout: 15000 })
})
```
Notes:
- Pick `SHELL_MARKER` as a sync element of the destination's static shell, never streamed data.
Use a `data-testid` on a known static node rather than a guessed role/name.
- Do not put a custom timeout, a `painted` boolean, or `isVisible({ timeout })` in the shipped
assertion, and do not add retries or hover-warming; see `reference/red-test-robustness.md`.