Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot committed 2026-10-07 00:02:44 +08:00
1 parent ee2b89a64b
commit d6043fc982
19 files changed
+213 -666

No files matched your search

+6 -6
View File
@@ -132,8 +132,8 @@
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "7ff7dbf8669fa3392c294ee745dc8d8c3cee842c",
"syncedAt": "2026-10-06T07:26:41Z"
"commit": "debae9baea4d5c6bd0b1a857b09664db4b925818",
"syncedAt": "2026-10-06T16:00:00Z"
},
{
"id": "frontend-slides",
@@ -177,8 +177,8 @@
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
"adapter": "skill-collection",
"commit": "4588b32ecab9ecc9fc8cc6b6c5e7d675b6004b0d",
"syncedAt": "2026-10-05T16:00:00Z"
"commit": "6fd947921b935b7e1e69293a200400f0fdd5c15f",
"syncedAt": "2026-10-06T16:00:00Z"
},
{
"id": "updating-gitea-repositories",
@@ -258,8 +258,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
"commit": "8619c015f8cc9b26fbe10c5e97b0f8cb9ed5cb92",
"syncedAt": "2026-10-06T07:26:41Z"
"commit": "b22e5a299cb58577ce86f21b6a0f7bb31a6168f2",
"syncedAt": "2026-10-06T16:00:00Z"
},
{
"id": "shuorenhua",
@@ -2,8 +2,8 @@
"sourceId": "grill-me",
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
"commit": "4588b32ecab9ecc9fc8cc6b6c5e7d675b6004b0d",
"commit": "6fd947921b935b7e1e69293a200400f0fdd5c15f",
"adapter": "skill-collection",
"sourcePath": "skills/productivity",
"syncedAt": "2026-10-05T16:00:00Z"
"syncedAt": "2026-10-06T16:00:00Z"
}
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-10-06T07:29:21Z"
"syncedAt": "2026-10-06T16:02:44Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "8619c015f8cc9b26fbe10c5e97b0f8cb9ed5cb92",
"commit": "b22e5a299cb58577ce86f21b6a0f7bb31a6168f2",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-10-06T07:26:41Z"
"syncedAt": "2026-10-06T16:00:00Z"
}
@@ -1,12 +1,12 @@
---
name: next-browser-initial-load-optimizer
name: next-bundle-optimizer
description: >
Audit and reduce Next.js browser initial-load work. Use for slow route startup,
oversized client bundles, duplicate browser dependencies, or features that can
wait for interaction.
---
# Browser initial-load optimizer
# Bundle optimizer
## 1. Set the scope
@@ -8,12 +8,17 @@ description: "优化 Next.js Cache Components 页面静态壳和导航体验。"
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. Work the
phases P → G in order; each ends in a gate. Fix recipes live in two lazily-read
references — `reference/patterns.md` (before→after for each blocker type) and
`reference/real-app-patterns.md` (parallel routes, auth gates, the empty-shell
and responsive-skeleton failure modes). Read one only when its phase points
there.
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
@@ -42,31 +47,37 @@ command, platform, or env var below as a requirement.
name, env-var spelling, and command below as an example to translate, not a
requirement.
## Two navigations, two loading states
## Two entry points, two instant UI contracts
A route reaches the user two ways, and both must be instant:
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)** commits the destination's
prefetched App Shell — the `<Link>` default under Partial Prefetching —
re-rendering only the segments that change.
- **Client-side navigation (soft navigation)** reuses shared layouts and
commits the available App Shell for the destination segments that change.
The fix patterns are identical for both; the test differs only in how the
navigation is driven ("Driving the navigation in tests" below). The two shells
can differ; guard the one you ship, both when both matter
(`reference/real-app-patterns.md`).
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
Maximizing the static shell is the optimization objective: the most meaningful
prerendered content commits immediately, and only genuinely per-request data
streams in afterward. The shipped test deterministically encodes **present ∧
instant**; **non-blank** is the additional bar the workflow enforces by
judgment (D1/D2/E), because an `instant()` pass alone is satisfied by a blank
`fallback={null}` shell (the empty-shell failure mode,
`reference/real-app-patterns.md`).
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
@@ -111,9 +122,9 @@ hear those words.
- [ ] 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 push each Suspense boundary down to the data it guards → reference/patterns.md
- [ ] D1 reuse the route's existing loading UI; do not hand-build skeletons
- [ ] D2 the shell matches the real render at every breakpoint → reference/real-app-patterns.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)
@@ -133,7 +144,8 @@ The workflow depends on framework capabilities that ship with current Next.js:
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` config flag (phase A).
[`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`:
@@ -178,8 +190,8 @@ every platform:
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:
`experimental.exposeTestingApiInProductionBuild` to a condition that is true
for every build you measure and never true in production:
```ts
experimental: {
@@ -220,6 +232,12 @@ 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.
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`.
@@ -237,137 +255,57 @@ untrustworthy REDs, the checklist, and worked cases are in
---
## D. FIX: push each boundary down to the data it guards
## D. FIX: apply the documented pattern
**The anti-pattern: one coarse boundary.** A single `<Suspense>` high in the
tree with a page-level fallback has three costs:
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:
- The layout UI stays out of the static shell: only a throwaway copy of it is
prerendered.
- The entire subtree is replaced when the boundary resolves, which discards
client state and shifts layout.
- The hand-built fallback drifts out of sync as the UI changes, because it
duplicates structure that also exists in the resolved tree.
| 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 fix: hoist the static, push the Suspense down.** Render the layout UI
once, synchronously, in the shell, and wrap each await in a boundary scoped to
the single read it guards. Only that leaf streams; the stable ancestors are
reused as-is.
The guide's example covers the common implementation shapes:
**Rule:** if an element renders in both the fallback and the resolved tree,
hoist it above the boundary.
- [`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)
### The most common blocker: a top-level `await` in a layout on a fallback route
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.
```
app/[locale]/(app)/[tenant]/dashboard/...
│ generateStaticParams ✅ │ no generateStaticParams → fallback route
```
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.
When any dynamic segment in the route lacks `generateStaticParams`, the route
is a fallback route, and **all** params defer to request time, including the
enumerated ones. A top-level `await` in a layout (`await params`, a
request-time session read, an auth gate) then blocks the whole subtree out of
the static shell, even when it reads a statically known param. Minimal shape: a
dynamic-segment route with one segment lacking `generateStaticParams`, plus a
top-level `await` in the layout above it.
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.
### The fix: defer the gate, render children
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.
Render `children` unconditionally; move the top-level `await` into a
`<Suspense fallback={null}>`-wrapped child. Mechanism and before→after:
`reference/real-app-patterns.md`, "Deferring an auth gate".
**Fix the page below the shell too, not only the layout.** A page-level
top-level `await` (commonly `await params`) blocks the same way the layout's
does, so make the page sync and push its dynamic reads into a
`<Suspense>`-wrapped leaf as well. `fallback={null}` is correct only when a gate renders nothing on
success; for data, the fallback must be a real loading skeleton (see D1).
Every other blocker shape — `cookies()`/`headers()`, uncached fetch or database
reads, `searchParams`, metadata, viewport, non-deterministic values (`Date.now()`,
`Math.random()`, `crypto.randomUUID()`) — surfaces its own insight when you hit
it: the build prints a `https://nextjs.org/docs/messages/<slug>` link. The
default build output is often abbreviated and may carry no usable stack trace;
add `--debug-prerender` for the full failing frame and to report every blocker
past the first. Scope the build to the route you're on with
`next build --debug-build-paths "app/<route>/**"` rather than rebuilding the app.
Open that page and apply its recipe; don't improvise from the inline message.
The before→after recipe for each shape is in `reference/patterns.md`, which maps it to the insight
that explains it.
A few things those per-error pages don't stress for the instant-navigation goal:
- **A boundary in the root layout isn't enough for client navigations.** It
passes a page-load check but leaves sibling client navigations blocking; put
the boundary below the lowest layout the source and destination routes share.
- **Keep the LCP element** (usually the main heading) out of any boundary, so it
paints in the shell instead of waiting on a stream.
- **A green check isn't always instant.** `export const instant = false` opts
the segment out of validation while the navigation still blocks, and a
`<Suspense>` above the document `<body>` prerenders an empty shell — neither
makes the route instant.
### D1: reuse the route's existing loading UI; do not hand-build skeletons
Before writing any skeleton, search the repository for the loading UI that
already exists for this route, in order:
1. the route's `loading.tsx`;
2. an exported `*Skeleton` colocated with the component;
3. the fallback already inside the component's own `<Suspense>`.
The **divergence point** is the lowest layout shared by the source and
destination routes: a soft navigation re-renders only the segments below it,
while an initial load re-runs every layout from the root. (Also called the
shared boundary.) A `loading.tsx` above the divergence point fills only
the initial-load shell; it sits above the soft-nav re-render scope. A
`loading.tsx` at the destination segment is itself the in-tree boundary for a
soft navigation into that segment and serves both. Reuse whichever boundary
actually covers the navigation you are shipping; below the divergence point,
`loading.tsx` and colocated skeletons are interchangeable for that purpose.
If a component has no skeleton, extract its loading markup into a colocated
skeleton beside it. Do not author a fresh skeleton that mirrors the page
layout: it duplicates structure, drifts as the page changes, and pulls the
design back toward a single coarse boundary. Reusing the component's own
skeleton also keeps the prefetched shell consistent with the loaded UI.
See: [Streaming](https://nextjs.org/docs/app/guides/streaming#push-dynamic-access-down)
and [loading states](https://nextjs.org/docs/app/guides/instant-navigation#iterate-on-loading-states).
Exception: if the deferred component renders `null` for some users (for
example, a flag-gated control), `fallback={null}` is correct, since a skeleton
would flash and then collapse.
### D2: the shell must match the real render at every breakpoint
A skeleton frozen to one breakpoint misaligns on the others. Fix it the same
way: one responsive component renders both the live UI and the shell (D1
skeleton in its data slots), so the breakpoint switch happens once. Verify by
re-asserting the shell marker at two widths
(`await page.setViewportSize({ width: 1280, height: 800 })`, then
`{ width: 390, height: 844 }`), or by adding a mobile Playwright project, so
this gate is as machine-checkable as the others. Detail:
`reference/real-app-patterns.md`.
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 the optimization adds or expands a cache boundary, follow
[Revalidating](https://nextjs.org/docs/app/getting-started/revalidating).
A passing `instant()` test proves shell readiness, not mutation freshness.
**When URL data can't be pushed down** (for example, the whole page depends on
`params`, `searchParams`, or the full URL), there may be no meaningful static
shell to grow. Don't force one. Per-link prefetching can make the soft
navigation instant, but it is outside this optimizer loop: it requires Partial
Prefetching, a `<Link prefetch={true}>`, and cached URL-dependent content. See
[Optimizing prefetching](https://nextjs.org/docs/app/guides/optimizing-prefetching)
and pattern 10 in `reference/patterns.md` for the requirements, cost trade-offs,
manual prefetch caveat, and `instant()` test gotchas.
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
@@ -379,13 +317,17 @@ 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 rather than during prerender. Confirm an
unauthorized user is still redirected and a missing record still returns 404.
- **Both viewports reach the real UI** after the stream (D2).
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.
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.
@@ -395,8 +337,14 @@ 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`). On a deployed rig, confirm each run is live
(LIVENESS, phase A) before trusting its color.
(`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)
@@ -410,8 +358,10 @@ PR-specific items:
- [ ] **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 desktop and mobile widths (D2)**.
- [ ] **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
@@ -422,12 +372,12 @@ three hold, you are not done.
- **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 shells can differ
(`test-template.md`, `reference/real-app-patterns.md`).
`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
(`reference/real-app-patterns.md`).
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
@@ -438,9 +388,6 @@ three hold, you are not done.
- `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.
- `reference/real-app-patterns.md`: parallel routes, deferring an auth gate,
initial-load vs soft-navigation shells, the empty-shell failure mode, the
responsive-skeleton mismatch, edge cases.
## After optimization
@@ -458,11 +405,10 @@ 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:** for any URL-data route that stopped at the limitation
above, consider a targeted `<Link prefetch={true}>` on the links where having
that URL-specific content ready before the click is worth the per-link server
work. Keep the default link behavior everywhere else so the shared App Shell
remains the low-cost baseline.
- **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
@@ -1,357 +0,0 @@
# Refactor patterns — push dynamic down into the shell
Each pattern is **before → after**: keep as much as possible in the prerendered shell, and wrap only genuinely per-request work in a tight `<Suspense>` (or hoist it into `use cache`). Production shapes — parallel-route slots, deferring an auth gate, client slot-routers — are in `real-app-patterns.md`.
---
## 1. Awaiting at the top → move the await into a Suspense child
The most common blocking shape. Awaiting request-time data at the top of a page/layout makes **everything below it** dynamic.
```tsx
// ❌ before — top-level await of a non-static param + uncached data
export default async function Page(props: PageProps<'/store/[slug]'>) {
const { slug } = await props.params
const product = await db.products.findBySlug(slug)
return (
<article>
<h1>{product.name}</h1>
</article>
)
}
```
```tsx
// ✅ after — pass the params promise down; await inside a Suspense-wrapped child
import { Suspense } from 'react'
export default function Page(props: PageProps<'/store/[slug]'>) {
return (
<Suspense fallback={<p>Loading product…</p>}>
<Product params={props.params} />
</Suspense>
)
}
async function Product({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const product = await db.products.findBySlug(slug)
return (
<article>
<h1>{product.name}</h1>
</article>
)
}
```
Inline variant when you don't want a separate component — unwrap the promise without awaiting at the top:
```tsx
export default function Page(props: PageProps<'/store/[category]'>) {
return (
<Suspense fallback={<Grid.Skeleton />}>
{props.params.then(({ category }) => (
<ProductGrid category={category} />
))}
</Suspense>
)
}
```
**Insight:** [runtime data during prerendering](https://nextjs.org/docs/messages/blocking-prerender-runtime).
---
## 2. `cookies()` / `headers()` in a layout → start, don't await; pass down
A layout that awaits request data blocks the layout **and every page under it**.
```tsx
// ❌ before — whole layout (and all children) becomes dynamic
export default async function Layout({ children }) {
const cookieStore = await cookies()
const theme = cookieStore.get('theme')?.value
return <body data-theme={theme}>{children}</body>
}
```
```tsx
// ✅ after — start the read without awaiting, pass the promise to a Suspense child
import { Suspense } from 'react'
import { cookies } from 'next/headers'
export default function Layout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies() // not awaited → does not block the shell
return (
<body>
<nav>
<Suspense fallback={<UserMenu.Skeleton />}>
<UserMenu cookiePromise={cookieStore} />
</Suspense>
</nav>
{children}
</body>
)
}
async function UserMenu({
cookiePromise,
}: {
cookiePromise: ReturnType<typeof cookies>
}) {
const theme = (await cookiePromise).get('theme')?.value
return <div data-theme={theme}>…</div>
}
```
`{children}` and `<nav>` stay in the shell; only `<UserMenu>` streams.
**Insight:** [runtime data during prerendering](https://nextjs.org/docs/messages/blocking-prerender-runtime).
---
## 3. Uncached fetch / DB read → choose `use cache` _or_ `<Suspense>`
Decide per data source. Same-for-everyone & rarely-changing → cache it (it joins the shell). Per-request & must-be-fresh → leave it uncached behind a boundary.
```tsx
// ❌ before — both block the shell
const product = await db.products.findBySlug(slug) // rarely changes
const inventory = await db.inventory.findBySlug(slug) // must be fresh
```
```tsx
// ✅ after — cache the stable one (shell), defer the fresh one (streams)
async function getProduct(slug: string) {
'use cache' // → resolved at prerender, lands in the shell
return db.products.findBySlug(slug)
}
;<Suspense fallback={<p>Checking availability…</p>}>
<Inventory params={params} /> {/* uncached read stays here, streams in */}
</Suspense>
```
> A bare `'use cache'` applies the `default` `cacheLife` profile. Choose freshness explicitly with `cacheLife('<profile>')` (`default` / `seconds` / `minutes` / `hours` / `days` / `weeks` / `max`) rather than shipping the default lifetime by omission.
>
> Serverless note: `use cache` is in-memory and does not persist across instances — use [`use cache: remote`](https://nextjs.org/docs/app/api-reference/directives/use-cache-remote) for a durable shell.
**Insight:** [uncached data during prerendering](https://nextjs.org/docs/messages/blocking-prerender-dynamic).
---
## 4. Dynamic params → `generateStaticParams` (shell) or `<Suspense>` (stream)
If the set of params is enumerable, prerender them so `await params` resolves into the shell. Otherwise treat params as request-time and wrap consumers in `<Suspense>`.
```tsx
// ✅ option A — enumerate → params resolve into the shell, no Suspense needed for params
export function generateStaticParams() {
return [{ slug: 'shoes' }, { slug: 'hats' }]
}
export default async function Page({ params }: PageProps<'/store/[slug]'>) {
const { slug } = await params // known at build → shell-safe
// ...
}
```
```tsx
// ✅ option B — not enumerable → params is request-time; await it inside a boundary (pattern #1)
```
Root params (the dynamic segments the root layout sits inside, e.g. `app/[lang]/layout.tsx`) are readable from any Server Component via `next/root-params` without prop-drilling — but under Cache Components they must still be enumerated by `generateStaticParams` (at least one value per root param) to land in the shell, the same as any other dynamic param.
**Insight:** [runtime data during prerendering](https://nextjs.org/docs/messages/blocking-prerender-runtime).
---
## 5. `searchParams` → always behind `<Suspense>` (on page load)
Search params are never known at build, so awaiting them (or `useSearchParams()`) suspends on a page load. Keep the rest of the page in the shell by isolating the consumer.
```tsx
// ✅ static content stays in the shell; the search-dependent part streams
export default function Page(props: PageProps<'/search'>) {
return (
<>
<h1>Search</h1> {/* shell */}
<Suspense fallback={<Results.Skeleton />}>
<Results searchParams={props.searchParams} />
</Suspense>
</>
)
}
async function Results({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q } = await searchParams
return <ResultList query={q} />
}
```
On a **client navigation** the router already has the URL, so a `useSearchParams()` consumer resolves synchronously and can appear in the prefetched shell — but you still need the boundary for the page-load path.
**Insight:** [runtime data during prerendering](https://nextjs.org/docs/messages/blocking-prerender-runtime) (or, via `useSearchParams` in a Client Component, [URL data in a Client Component](https://nextjs.org/docs/messages/blocking-prerender-client-hook)).
---
## 6. Non-deterministic values → `connection()` + `<Suspense>`, or cache
`Math.random()`, `Date.now()`, `crypto.randomUUID()` produce different output each run, so Cache Components makes you choose: per-request (defer) or fixed (cache).
```tsx
// ✅ per-request value: gate on connection() and wrap in Suspense
import { connection } from 'next/server'
async function RequestId() {
await connection()
return <span>{crypto.randomUUID()}</span>
}
// <Suspense fallback={null}><RequestId /></Suspense>
```
```tsx
// ✅ same value for everyone: cache it so it joins the shell
async function buildId() {
'use cache'
return Date.now()
}
```
**Insight:** [`Date.now()`](https://nextjs.org/docs/messages/blocking-prerender-current-time), [`Math.random()`](https://nextjs.org/docs/messages/blocking-prerender-random), or [`crypto`](https://nextjs.org/docs/messages/blocking-prerender-crypto) while prerendering.
---
## 7. Dynamic `generateMetadata` → static export, `use cache`, or a dynamic-marker for runtime data
```tsx
// ❌ before — reading request data blocks the route's metadata
export async function generateMetadata() {
const c = await cookies()
return { title: c.get('title')?.value }
}
```
```tsx
// ✅ option A — static
export const metadata = { title: 'Store' }
// ✅ option B — cache the metadata (depends on external data, not runtime data)
export async function generateMetadata() {
'use cache'
return { title: await getTitle() }
}
```
```tsx
// ✅ option C — metadata genuinely needs runtime data (cookies/headers):
// keep generateMetadata dynamic, and add a dynamic-marker component to the
// page so the rest of the page still prerenders into the shell.
import { Suspense } from 'react'
import { connection } from 'next/server'
import { cookies } from 'next/headers'
export async function generateMetadata() {
const token = (await cookies()).get('token')?.value
return { title: token ? 'Personalized' : 'Store' }
}
async function DynamicMarker() {
await connection() // signals intentional dynamic content
return null
}
export default function Page() {
return (
<>
<article>{/* static content — stays in the shell */}</article>
<Suspense>
<DynamicMarker />
</Suspense>
</>
)
}
```
`generateViewport` is the same, except dynamic viewport blocks the **whole page**. Genuine instant fixes: a static `viewport` export, or `use cache`. The other two are dynamic-acceptance opt-outs, not instant fixes — do not treat them as a way to reach GREEN: `export const instant = false` opts the segment out of validation while the navigation still blocks, and a `<Suspense>` above the document `<body>` makes the whole route dynamic.
**Insight:** [runtime data in `generateMetadata()`](https://nextjs.org/docs/messages/blocking-prerender-metadata-runtime).
---
## 8. Keep the LCP element in the shell
Don't bury the main heading (the LCP element) inside a boundary — it can't paint until the boundary resolves.
```tsx
// ✅ LCP outside the boundary → paints in the shell
<h1>{product.name}</h1> {/* shell (cache the name if needed) */}
<Suspense fallback={<Reviews.Skeleton />}>
<Reviews productId={id} /> {/* streams */}
</Suspense>
```
---
## 9. Granularity below shared layouts (client-nav correctness)
A single boundary in the **root** layout passes a page-load check but leaves sibling client navigations blocking. Put a boundary **below the shared layout**.
```tsx
// app/store/layout.tsx — boundary below the /store shared layout covers
// client navs like /store/shoes → /store/hats (the root boundary does not)
export default function StoreLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<section>
<StoreNav /> {/* shell */}
<Suspense fallback={<Page.Skeleton />}>{children}</Suspense>
</section>
)
}
```
Prefer per-component boundaries inside the page (patterns #1–#5) over one big layout boundary — they keep more real content in the shell and stream independently.
**Insight:** the read's own insight surfaces on the client navigation when the boundary is too high — see [where to place the boundary](https://nextjs.org/docs/messages/blocking-prerender-dynamic#choosing-where-to-place-the-boundary).
## 10. URL data that can't move
Patterns 1–9 grow a **static shell** by moving dynamic reads behind boundaries. Session data from `cookies()` and `headers()` is handled by the earlier patterns. URL data is different: `params`, `searchParams`, and the full URL belong to one link, while the App Shell is shared by every link to the route.
If the whole route depends on URL data, pushing the read lower may leave no meaningful shared shell to commit. That is the optimizer's stop point, not another shell refactor. Return to `SKILL.md` after the optimization loop for the optional per-link-prefetch follow-up.
Per-link prefetching is the only way for this soft navigation to commit the
URL-specific content before the click. It has **three requirements**:
```tsx
// 1. The destination has adopted Partial Prefetching, either app-wide with
// partialPrefetching: true or route-by-route with prefetch = 'partial'.
// 2. The navigation asks for a full prefetch — normally <Link prefetch={true}>.
// A default/auto prefetch only warms the static shell.
<Link href={href} prefetch={true}>
…
</Link>
// 3. The URL-dependent content is behind `use cache`, keyed by the resolved
// params/searchParams/full URL value.
```
Under `instant()` the runtime entry is what commits, so the real content, not a skeleton, shows under the lock.
Gotchas (each cost real debugging time):
- **The full prefetch is mandatory.** With App Shells enabled an auto/PPR prefetch bails before the runtime spawn (`subtreeHasSpeculativePrefetch`); use `<Link prefetch={true}>` for normal links, or keep an existing manual full-prefetch abstraction if the app already owns one. If the route is still RED after caching the URL-dependent content, the navigation may still be doing an auto prefetch.
- **Partial Prefetching must be adopted for the destination.** Per-link prefetching uses the Partial Prefetching path. If the route is still RED after caching the URL-dependent content, check whether the link is still doing an auto prefetch or whether the destination never adopted Partial Prefetching.
- **Prefetch the canonical URL.** A link whose href 307-redirects (a `/foo` that canonicalizes to `/`) can't be prefetched — the prefetch receives the redirect, not the tree. Point the link and the prefetch at the final URL.
- **Don't blanket the full prefetch.** It fetches _all_ the target's dynamic data; enabling it for every visible link is wasteful. Scope `prefetch={true}` to the per-link-prefetch targets only, using the [trade-offs](https://nextjs.org/docs/app/guides/optimizing-prefetching#trade-offs) and [hover-triggered prefetch](https://nextjs.org/docs/app/guides/prefetching#hover-triggered-prefetch) when many links are visible.
- **Marker must be a committed node, not RSC bytes.** The content is often a client component, so its text isn't in the prefetch response — assert a `data-testid` that renders when the client subtree commits, not a substring of the stream.
Prefer a static shell (patterns 1–9) whenever the URL-data read can move: it's cheaper than a per-link prefetch and also covers hard load. Per-link prefetching is only for URL-data reads that genuinely can't move, or routes whose useful content is all URL-specific.
**Insight:** [dynamic data during prefetching](https://nextjs.org/docs/messages/instant-link-prefetch-partial).
@@ -1,88 +0,0 @@
# Real-app patterns
The rest of this skill models a single linear `layout → page` tree. Production App Router routes add **parallel routes, shared layout UI, and auth gates**, which is where most of the real static-shell work happens. These patterns bridge that gap. Read the skill's `SKILL.md` first.
## Parallel routes: each slot is its own boundary
Instant validation treats every parallel-route slot below the shared layout as an **independent** navigation boundary. Consequences:
- **Each `@slot` needs its own `<Suspense>`** around its dynamic reads; a boundary in one slot does not cover another.
- **An uncovered dynamic read in any slot blocks the whole navigation.** A perfect `@content` does not help if `@sidebar` awaits a session at the top.
- **A slot that renders `null` (e.g. `default.tsx`) is shell-safe**: it is static and performs no reads. Slots that do not re-render for this navigation cost nothing.
```
[tenant]/layout.tsx (shared: already mounted on a soft navigation; not re-rendered)
├ @content → settings/layout → billing/page ← guard each slot's dynamic reads…
├ @sidebar → side nav ← …here too (independent boundary)
└ @header → default.tsx → null ← free
```
## Client-rendered slot routing is not part of the soft-navigation re-render
A common pattern: a stable shared layout renders `@header`/`@sidebar` through a **client** component that swaps slot content based on `usePathname()`. On a soft navigation, Next.js only re-renders the **server** segments that changed below the shared layout; a client-component subtree is not part of that re-render. So that navigation UI neither blocks the navigation nor needs a server `<Suspense>` for it; only the server segments that actually change (e.g. `@content`) matter. It does participate in an initial load (see the caveat below).
## "Instant" is not "useful shell": the empty-shell failure mode
Validation checks that a dynamic read is **guarded by a boundary**, not that the fallback is non-empty. A `<Suspense>` with no `fallback` (or `fallback={null}`) passes validation and commits instantly, but renders a **blank** shell. If a layout and its page both `await getSession()` (your auth library's request-time read) at the top under one empty-fallback boundary, the whole frame collapses to nothing while the user waits. "Validates as instant" and "good loading experience" are different goals.
> Give every boundary a real loading skeleton, and place it low so the most real content stays in the shell. A `fallback={null}` directly above `<body>` is a deliberate empty-shell opt-out; an empty fallback lower in the tree is almost always a bug.
## The responsive-skeleton mismatch: the shell must match every breakpoint
A loading skeleton that misaligns with the loaded UI is its own bug, and it usually appears on mobile. A hand-built skeleton encodes one layout; the real component is responsive and changes shape at breakpoints, so a desktop-shaped skeleton no longer lines up once the viewport is small.
A concrete shape: a list-detail view renders a list or tree in a side panel on desktop, but collapses that panel into a single dropdown or drawer on mobile (with its own loading state). A row skeleton built for the desktop panel has nothing to align with on mobile.
The fix is the same push-down as everywhere else: **share the real responsive layout between the live render and the shell render.** One responsive component renders both (its data slots show the reused `*Skeleton` in the shell and real data after the stream), so the breakpoint switch happens once, for both renders, and there is no second desktop-only skeleton to drift.
(Same hoist rule, responsive layout included.) Verify the shell at both desktop and mobile widths against the real render at the same width.
## Deferring an auth gate / top-level `await` in a layout
A top-level `await` in a layout blocks everything below it (the most common blocker; see [Runtime data during prerendering](https://nextjs.org/docs/messages/blocking-prerender-runtime)). Auth gates are the most common real instance:
```tsx
// ❌ Before: the await + redirect at the top blocks the whole settings frame
export default async function SettingsLayout({ children }) {
const session = await getSession() // your auth library's request-time read; suspends during prerender → frame can't build
if (!session?.user) redirect(getLoginUrl())
return <Shell>{children}</Shell>
}
```
```tsx
// ✅ After: render children unconditionally; move the gate into a Suspense child
import { Suspense } from 'react'
export default function SettingsLayout({ children }) {
return (
<Shell>
<Suspense fallback={null}>
<AuthGate />
</Suspense>
{children}
</Shell>
)
}
async function AuthGate() {
const session = await getSession() // the session read suspends during prerender…
if (!session?.user) redirect(getLoginUrl()) // …so redirect() never runs at build time
return null
}
```
The shell prerenders as if authorized (the session read suspends before `redirect()` is reached, so the redirect only happens at request time), and `{children}` is now in the shell instead of behind the gate. (`fallback={null}` is correct here: `AuthGate` renders nothing on success.)
## Initial-load shell vs soft-navigation shell
The `../test-template.md` specs drive a `<Link>` click for soft navigations and `page.goto()` for initial loads. The two shells can differ for the same route:
> **The initial-load shell can show less than the soft-navigation shell when a layout above the shared boundary awaits un-enumerated `params`/`searchParams`.** An initial load re-runs every layout from the root; if a parent layout does `await props.params` and that segment has no `generateStaticParams`, the param suspends on the initial load and its whole subtree drops out of the shell. A soft navigation does not re-render that parent and already has the params. Symptom: an element present after a `<Link>` click is missing after `goto`.
To assert the soft-navigation shell, drive a real `<Link>` click (through menus if necessary). Use `page.goto()` inside `instant()` to assert the initial-load shell, or when no parent above the shared boundary awaits un-enumerated params, in which case the two coincide.
## Edge cases
- **A `React.cache` (or custom memoization) wrapper around `cookies()`/`headers()` still suspends.** Memoizing the call does not make it shell-safe: the underlying request read still returns a pending promise during prerender. Only the **`use cache`** directive, keyed on static or param inputs, puts data in the shell.
- **Playwright cannot see a `display: contents` or fragment fallback.** Such a fallback reads as hidden, so `instant()` assertions cannot `toBeVisible()` it. Give fallbacks a real wrapper element with a `data-testid`.
@@ -44,9 +44,11 @@ self-validating test variant when deferred content exists.
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. **Visible marker**: not `display:none`, off-screen, or inside a hover overlay; for lists,
target `.filter({ visible: true }).first()`.
11. **Fresh build under test**: the deployment being measured contains the latest commit, not a
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
@@ -76,7 +78,7 @@ reason; the rig file's DRIFT list says which rows apply to your app.
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 10.
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)
@@ -128,9 +130,9 @@ the marker; a retry masks the regression the guard exists to catch. The only leg
`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`), 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.
([`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:
@@ -35,8 +35,8 @@ the process listening on the recorded port before the next build.
### Testing API
An `instant()` test against a production build requires
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
builds do not expose the API:
[`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'
@@ -72,16 +72,21 @@ 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`.
For a local rig, a typical sequence is:
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
pnpm start --port 3000
BASE_URL=http://localhost:3000 pnpm playwright test tests/static-shell.spec.ts
```
Adapt the script names and port to the project. Keep the production server
running while the test command executes. Follow the public
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.
@@ -171,3 +176,7 @@ Before recording the static-shell contract:
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.
@@ -6,6 +6,10 @@ Ship one test per navigation type you are guarding: under `instant()`, assert th
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.
@@ -58,9 +62,13 @@ is shown only for brevity; like the marker, the trigger must reliably resolve fo
## Initial load (hard navigation)
Drive `page.goto()` inside `instant()` with the `baseURL` option. The served document is the
route's prerendered static shell. `baseURL` is required because `page` is still `about:blank` when
`instant()` runs (`resolveURL` falls back to `page.url()` only when no `baseURL` is passed).
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
@@ -118,9 +126,9 @@ cached. So the initial-load `toHaveCount(0)` gated half is as valid as the soft-
no fresh browser context and no cache-busting query param.
The **post-release** assertion (`getByTestId('<b>-content').toBeVisible()` after the `instant()`
block) is soft-nav only. On an initial load the document was already emitted under the lock, so
nothing streams in after release; drop that assertion from the initial-load test, or
`page.reload()` first to fetch an unlocked document. The mechanism is in
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
@@ -133,9 +141,10 @@ 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 (`reference/real-app-patterns.md`): 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.
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
@@ -18,7 +18,7 @@ The development insights and the preservation tests are two different paths. Ins
## preservation gate
When using test-backed preservation, the first implementation milestone is a passing flag-off `instant()` suite. Set up the production test rig, write the selected assertions, run them with `partialPrefetching` disabled, and record the command and exit status. Test-only configuration required by the rig is allowed, but until that baseline passes, do not enable `partialPrefetching` or edit the destination, cache boundaries, or Link props. Installing missing test dependencies is part of reaching the baseline, not a reason to adopt first. Use the manual path only when `rig-template.md` identifies a concrete blocker the repository cannot resolve, and record the blocker and deferred test coverage.
When using test-backed preservation, the first implementation milestone is a passing flag-off `instant()` suite. Set up the production test rig, write the selected assertions, run them with `partialPrefetching` disabled, and record the command and exit status. Test-only configuration required by the rig, including [`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild), is allowed, but until that baseline passes, do not enable `partialPrefetching` or edit the destination, cache boundaries, or Link props. Installing missing test dependencies is part of reaching the baseline, not a reason to adopt first. Use the manual path only when `rig-template.md` identifies a concrete blocker the repository cannot resolve, and record the blocker and deferred test coverage.
Talk to the user in terms of what they'll see — PRs, features, and how the app behaves after — never the insight slugs or step labels. Before you start, tell them briefly what Partial Prefetching changes: links to a route prefetch one shared App Shell, and `prefetch={true}` can also resolve cached URL-specific content. The audit determines which UI from the legacy full prefetch to preserve.
@@ -35,8 +35,8 @@ the process listening on the recorded port before the next build.
### Testing API
An `instant()` test against a production build requires
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
builds do not expose the API:
[`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'
@@ -72,20 +72,31 @@ 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`.
For a local rig, a typical sequence is:
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
pnpm start --port 3000
BASE_URL=http://localhost:3000 pnpm playwright test tests/prefetch-preservation.spec.ts
```
Adapt the script names and port to the project. Keep the production server
running while the test command executes. Follow the public
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
[client-navigation test](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests): load the source route, confirm the real
Link is visible, then enter `instant()`, click, wait for the destination URL,
and assert the prefetched UI.
Assert a `data-testid` on a real, visible DOM node after its client subtree
commits. Do not search the RSC response for text to infer that commit, because
Client Component text might not appear in the response bytes. Do not use a
marker on `display: none`, `display: contents`, a fragment, off-screen content,
or a hover-only overlay.
### Test context
Record the state required to reach the audited Links and destination UI:
@@ -90,9 +90,10 @@ prefetch budget instead of creating another rig.
If the project has no rig, use [`rig-template.md`](rig-template.md) to discover
and record one. The measured run must be a production build or
preview where `experimental.exposeTestingApiInProductionBuild` is enabled only
for testing. Development can help diagnose a route, but automatic link
prefetching is production-only.
preview where
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild)
is enabled only for testing. Development can help diagnose a route, but
automatic link prefetching is production-only.
## Prove the current behavior
@@ -18,6 +18,12 @@ user:
Only then restore the selected contract assertions and change the
implementation.
Use a `data-testid` on a real, visible DOM node after its client subtree
commits. Do not infer that commit by searching the RSC response for text:
Client Component text might not appear in the response bytes. A marker on
`display: none`, `display: contents`, a fragment, off-screen content, or a
hover-only overlay cannot prove that the user can see the selected UI.
## What each signal rules out
- **Destination URL wait** rules out matching the source page or clicking a
@@ -54,6 +60,9 @@ regressed.
- The target is URL-specific but the test user's record/query returns empty.
- The target is behind a flag, role, plan, locale, or experiment absent in CI.
- Auth redirects to a page where the shell selector happens to exist.
- The source link points to a redirect URL, so its prefetch receives the
redirect instead of the destination route tree. Point the link at its
canonical destination.
- A shared selector matches the source before the destination commits.
- The test clicks a default link while the code change modified a different
link to the same URL.
@@ -75,10 +84,10 @@ prop against `prefetch={true}`.
## Lock engagement
`experimental.exposeTestingApiInProductionBuild` must be true on the measured
artifact. A marker that is absent under lock and visible after release is the
in-band proof that the lock engaged. A vacuous no-op cannot satisfy both
halves.
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild)
must be true on the measured artifact. A marker that is absent under lock and
visible after release is the in-band proof that the lock engaged. A vacuous
no-op cannot satisfy both halves.
Never enable this flag for real production traffic. For a remote rig, verify
the deployed commit before trusting RED or GREEN.
@@ -35,8 +35,8 @@ the process listening on the recorded port before the next build.
### Testing API
An `instant()` test against a production build requires
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
builds do not expose the API:
[`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'
@@ -72,16 +72,21 @@ 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`.
For a local rig, a typical sequence is:
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
pnpm start --port 3000
BASE_URL=http://localhost:3000 pnpm playwright test tests/prefetch-optimization.spec.ts
```
Adapt the script names and port to the project. Keep the production server
running while the test command executes. Follow the public
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
[client-navigation test](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests): load the source route, confirm the real
Link is visible, then enter `instant()`, click, wait for the destination URL,
and assert the prefetched UI.
@@ -2,8 +2,8 @@
"sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"commit": "7ff7dbf8669fa3392c294ee745dc8d8c3cee842c",
"commit": "debae9baea4d5c6bd0b1a857b09664db4b925818",
"adapter": "claude-skill",
"sourcePath": "skills/shadcn",
"syncedAt": "2026-10-06T07:26:41Z"
"syncedAt": "2026-10-06T16:00:00Z"
}