forked from arechen/EapilSkillMarket
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:
1 parent
ee2b89a64b
commit
d6043fc982
19 files changed
+213
-666
No files matched your search
@@ -132,8 +132,8 @@
|
|||||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "7ff7dbf8669fa3392c294ee745dc8d8c3cee842c",
|
"commit": "debae9baea4d5c6bd0b1a857b09664db4b925818",
|
||||||
"syncedAt": "2026-10-06T07:26:41Z"
|
"syncedAt": "2026-10-06T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "frontend-slides",
|
"id": "frontend-slides",
|
||||||
@@ -177,8 +177,8 @@
|
|||||||
"repo": "https://github.com/mattpocock/skills.git",
|
"repo": "https://github.com/mattpocock/skills.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "4588b32ecab9ecc9fc8cc6b6c5e7d675b6004b0d",
|
"commit": "6fd947921b935b7e1e69293a200400f0fdd5c15f",
|
||||||
"syncedAt": "2026-10-05T16:00:00Z"
|
"syncedAt": "2026-10-06T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "updating-gitea-repositories",
|
"id": "updating-gitea-repositories",
|
||||||
@@ -258,8 +258,8 @@
|
|||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "8619c015f8cc9b26fbe10c5e97b0f8cb9ed5cb92",
|
"commit": "b22e5a299cb58577ce86f21b6a0f7bb31a6168f2",
|
||||||
"syncedAt": "2026-10-06T07:26:41Z"
|
"syncedAt": "2026-10-06T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "shuorenhua",
|
"id": "shuorenhua",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "grill-me",
|
"sourceId": "grill-me",
|
||||||
"repo": "https://github.com/mattpocock/skills.git",
|
"repo": "https://github.com/mattpocock/skills.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "4588b32ecab9ecc9fc8cc6b6c5e7d675b6004b0d",
|
"commit": "6fd947921b935b7e1e69293a200400f0fdd5c15f",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills/productivity",
|
"sourcePath": "skills/productivity",
|
||||||
"syncedAt": "2026-10-05T16:00:00Z"
|
"syncedAt": "2026-10-06T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"name": "playwright浏览器自动化操作",
|
"name": "playwright浏览器自动化操作",
|
||||||
"version": "20260605",
|
"version": "20260605",
|
||||||
"keySource": "none",
|
"keySource": "none",
|
||||||
"syncedAt": "2026-10-06T07:29:21Z"
|
"syncedAt": "2026-10-06T16:02:44Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "next-skills",
|
"sourceId": "next-skills",
|
||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"commit": "8619c015f8cc9b26fbe10c5e97b0f8cb9ed5cb92",
|
"commit": "b22e5a299cb58577ce86f21b6a0f7bb31a6168f2",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"sourcePath": "skills",
|
||||||
"syncedAt": "2026-10-06T07:26:41Z"
|
"syncedAt": "2026-10-06T16:00:00Z"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: next-browser-initial-load-optimizer
|
name: next-bundle-optimizer
|
||||||
description: >
|
description: >
|
||||||
Audit and reduce Next.js browser initial-load work. Use for slow route startup,
|
Audit and reduce Next.js browser initial-load work. Use for slow route startup,
|
||||||
oversized client bundles, duplicate browser dependencies, or features that can
|
oversized client bundles, duplicate browser dependencies, or features that can
|
||||||
wait for interaction.
|
wait for interaction.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Browser initial-load optimizer
|
# Bundle optimizer
|
||||||
|
|
||||||
## 1. Set the scope
|
## 1. Set the scope
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
+110
-164
@@ -8,12 +8,17 @@ description: "优化 Next.js Cache Components 页面静态壳和导航体验。"
|
|||||||
Set up an agentic optimization loop that drives a Next.js route from "not
|
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
|
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
|
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
|
ship the test as the regression guard. Run it once per target route and entry
|
||||||
phases P → G in order; each ends in a gate. Fix recipes live in two lazily-read
|
point. Work the phases P → G in order; each ends in a gate.
|
||||||
references — `reference/patterns.md` (before→after for each blocker type) and
|
|
||||||
`reference/real-app-patterns.md` (parallel routes, auth gates, the empty-shell
|
Before changing the route, read the bundled **Optimizing the static shell**
|
||||||
and responsive-skeleton failure modes). Read one only when its phase points
|
guide at
|
||||||
there.
|
`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
|
## 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
|
name, env-var spelling, and command below as an example to translate, not a
|
||||||
requirement.
|
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
|
- **Initial load (hard navigation)** commits the route's prerendered static
|
||||||
shell; deferred parts stream in behind their loading skeletons (Suspense
|
shell; deferred parts stream in behind their loading skeletons (Suspense
|
||||||
fallbacks, `loading.tsx`).
|
fallbacks, `loading.tsx`).
|
||||||
- **Client-side navigation (soft navigation)** commits the destination's
|
- **Client-side navigation (soft navigation)** reuses shared layouts and
|
||||||
prefetched App Shell — the `<Link>` default under Partial Prefetching —
|
commits the available App Shell for the destination segments that change.
|
||||||
re-rendering only the segments that change.
|
|
||||||
|
|
||||||
The fix patterns are identical for both; the test differs only in how the
|
The same implementation patterns can make either entry point instant. The test
|
||||||
navigation is driven ("Driving the navigation in tests" below). The two shells
|
differs only in how the navigation is driven ("Driving the navigation in
|
||||||
can differ; guard the one you ship, both when both matter
|
tests" below), and the available UI can differ because a soft navigation
|
||||||
(`reference/real-app-patterns.md`).
|
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
|
## Goal
|
||||||
|
|
||||||
Maximizing the static shell is the optimization objective: the most meaningful
|
Make the most meaningful available App Shell UI commit immediately, and let
|
||||||
prerendered content commits immediately, and only genuinely per-request data
|
only genuinely request-time work stream afterward. The shipped test
|
||||||
streams in afterward. The shipped test deterministically encodes **present ∧
|
deterministically encodes **present ∧ instant**. **Non-blank** is the additional
|
||||||
instant**; **non-blank** is the additional bar the workflow enforces by
|
bar the workflow enforces by judgment because an `instant()` pass alone is
|
||||||
judgment (D1/D2/E), because an `instant()` pass alone is satisfied by a blank
|
satisfied by an empty shell.
|
||||||
`fallback={null}` shell (the empty-shell failure mode,
|
|
||||||
`reference/real-app-patterns.md`).
|
|
||||||
|
|
||||||
`instant()` is a ruler, not a stopwatch: assert that the shell appears under
|
`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
|
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
|
- [ ] 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 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
|
- [ ] 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
|
- [ ] D FIX apply the matching documented pattern → guide links below
|
||||||
- [ ] D1 reuse the route's existing loading UI; do not hand-build skeletons
|
- [ ] D1 reuse existing loading UI; keep the shell meaningful
|
||||||
- [ ] D2 the shell matches the real render at every breakpoint → reference/real-app-patterns.md
|
- [ ] D2 preserve layout and responsive behavior
|
||||||
- [ ] E PARITY the refactor changed only whether the route is instant
|
- [ ] 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
|
- [ ] F DIFFERENTIAL revert only the fix → RED; re-apply → GREEN → reference/red-test-robustness.md
|
||||||
- [ ] G REVIEW PR checklist (below)
|
- [ ] 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
|
provides `instant()`. Verify with `npm ls next @next/playwright` (or the
|
||||||
project's package manager) and align them if they differ. The matching
|
project's package manager) and align them if they differ. The matching
|
||||||
testing API is in the `next` runtime, gated by the
|
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`
|
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`:
|
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 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
|
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
|
self-validating variant in `test-template.md` is the in-band guarantee. Wire
|
||||||
`experimental.exposeTestingApiInProductionBuild` to a condition that is
|
`experimental.exposeTestingApiInProductionBuild` to a condition that is true
|
||||||
true for every build you measure and never true in production:
|
for every build you measure and never true in production:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
experimental: {
|
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**
|
lock. A RED here is the gap. **This is the test that ships**
|
||||||
(`test-template.md`).
|
(`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
|
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
|
route cannot build while blocked, or a cookie/session read stays GREEN, use the
|
||||||
RED recipes in `reference/red-test-robustness.md`.
|
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
|
Use the **Optimizing the static shell** guide you read at the start. Follow the
|
||||||
tree with a page-level fallback has three costs:
|
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
|
| Blocker | Guide pattern |
|
||||||
prerendered.
|
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
- The entire subtree is replaced when the boundary resolves, which discards
|
| 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) |
|
||||||
client state and shifts layout.
|
| 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) |
|
||||||
- The hand-built fallback drifts out of sync as the UI changes, because it
|
| New or moved boundaries need useful, stable fallbacks | [Design loading states](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#design-loading-states) |
|
||||||
duplicates structure that also exists in the resolved tree.
|
| 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
|
The guide's example covers the common implementation shapes:
|
||||||
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.
|
|
||||||
|
|
||||||
**Rule:** if an element renders in both the fallback and the resolved tree,
|
- [`params`, `searchParams`, and focused boundaries](https://nextjs.org/docs/app/guides/optimizing-the-static-shell#step-1-move-url-dependent-work-behind-suspense)
|
||||||
hoist it above the boundary.
|
- [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.
|
||||||
|
|
||||||
```
|
If development or a build surfaces another instant-navigation Insight during
|
||||||
app/[locale]/(app)/[tenant]/dashboard/...
|
the refactor, follow [validation as you
|
||||||
│ generateStaticParams ✅ │ no generateStaticParams → fallback route
|
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
|
Reuse existing loading UI and keep the resulting shell meaningful at every
|
||||||
is a fallback route, and **all** params defer to request time, including the
|
supported breakpoint. An empty fallback is valid only when the resolved
|
||||||
enumerated ones. A top-level `await` in a layout (`await params`, a
|
component also has no visual footprint.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
Do not use `export const instant = false`, weaken the contract, or ship an
|
||||||
`<Suspense fallback={null}>`-wrapped child. Mechanism and before→after:
|
empty document shell as the optimization.
|
||||||
`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`.
|
|
||||||
|
|
||||||
> **D-gate: phase D is complete when the locked test from phase C passes GREEN
|
> **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
|
> 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.
|
> GREEN is the deterministic stop for the fix loop; proceed to E.
|
||||||
|
|
||||||
If the optimization adds or expands a cache boundary, follow
|
If URL-specific content is the only missing instant UI, stop at [Include
|
||||||
[Revalidating](https://nextjs.org/docs/app/getting-started/revalidating).
|
URL-specific content in the instant
|
||||||
A passing `instant()` test proves shell readiness, not mutation freshness.
|
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`.
|
||||||
**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.
|
|
||||||
|
|
||||||
## E. PARITY: the refactor changed only whether the route is instant
|
## 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
|
- **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
|
values; after the stream, the route shows the same content as the base
|
||||||
branch for the test user.
|
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
|
- **Side effects still fire.** A deferred `redirect()` or `notFound()` still
|
||||||
happens, at request time rather than during prerender. Confirm an
|
happens at request time. Confirm unauthorized and missing-record behavior.
|
||||||
unauthorized user is still redirected and a missing record still returns 404.
|
If the route must preserve an HTTP status, account for [streaming status-code
|
||||||
- **Both viewports reach the real UI** after the stream (D2).
|
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
|
- **Client state survives.** Because the layout UI is hoisted into the stable
|
||||||
shell rather than swapped on resolve, open menus, scroll position, focus,
|
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
|
- **Pre-existing failures stay separate.** If the route errors after the
|
||||||
change, reproduce it on the base branch. The same failure there is an
|
change, reproduce it on the base branch. The same failure there is an
|
||||||
environment or data problem, not an optimizer regression.
|
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
|
## F. DIFFERENTIAL
|
||||||
|
|
||||||
Revert only the fix → RED; re-apply → GREEN; link both runs
|
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
|
(`reference/red-test-robustness.md`). Every contract intended to distinguish
|
||||||
(LIVENESS, phase A) before trusting its color.
|
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)
|
## G. REVIEW (PR checklist)
|
||||||
|
|
||||||
@@ -410,8 +358,10 @@ PR-specific items:
|
|||||||
- [ ] **Mutations verified when applicable**: after populating any cache whose
|
- [ ] **Mutations verified when applicable**: after populating any cache whose
|
||||||
data can be updated, a mutation test confirms the next read returns the
|
data can be updated, a mutation test confirms the next read returns the
|
||||||
expected data.
|
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.
|
- [ ] **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.
|
- [ ] **Baseline removed**: only the locked test from C remains.
|
||||||
|
|
||||||
**Stop condition for the whole workflow:** the locked test from C is GREEN on
|
**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
|
- **Soft navigation** → drive a real `<Link>` click. **Initial load** → use
|
||||||
`page.goto()` inside `instant()` with the `baseURL` option. Do not substitute
|
`page.goto()` inside `instant()` with the `baseURL` option. Do not substitute
|
||||||
`goto` for a soft-nav verdict; the two shells can differ
|
`goto` for a soft-nav verdict; the two contracts can differ
|
||||||
(`test-template.md`, `reference/real-app-patterns.md`).
|
(`test-template.md`).
|
||||||
- With parallel routes, only the slots that change re-render on a soft
|
- 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
|
navigation; client-rendered navigation UI does not re-render at all. Do not
|
||||||
chase a slot the navigation never touches
|
chase a slot the navigation never touches. See [Loading and Error UI with
|
||||||
(`reference/real-app-patterns.md`).
|
Parallel Routes](https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes#loading-and-error-ui).
|
||||||
|
|
||||||
## Files
|
## 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
|
- `reference/red-test-robustness.md`: the C-gate and phase F. The taxonomy of
|
||||||
untrustworthy REDs, the checklist, the differential recipe, the vacuous-pass
|
untrustworthy REDs, the checklist, the differential recipe, the vacuous-pass
|
||||||
failure mode, and worked cases.
|
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
|
## 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
|
`prefetch = 'partial'` matches, treat those destination segments as adopted
|
||||||
during an incremental rollout and keep checking any other target routes.
|
during an incremental rollout and keep checking any other target routes.
|
||||||
|
|
||||||
- **Already adopted:** for any URL-data route that stopped at the limitation
|
- **Already adopted:** when the requested instant UI is URL-specific and the
|
||||||
above, consider a targeted `<Link prefetch={true}>` on the links where having
|
destination App Shell is already instant, continue with
|
||||||
that URL-specific content ready before the click is worth the per-link server
|
`next-partial-prefetching-optimizer`. It owns the per-link value and cost
|
||||||
work. Keep the default link behavior everywhere else so the shared App Shell
|
decision.
|
||||||
remains the low-cost baseline.
|
|
||||||
- **Not adopted yet:** recommend
|
- **Not adopted yet:** recommend
|
||||||
[`next-partial-prefetching-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-partial-prefetching-adoption).
|
[`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
|
That skill moves the app onto the better prefetching model: shared App Shell
|
||||||
|
|||||||
-357
@@ -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).
|
|
||||||
-88
@@ -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`.
|
|
||||||
+9
-7
@@ -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.
|
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.
|
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`.
|
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,
|
10. **Committed marker**: assert a DOM node after the client subtree commits, not text searched in
|
||||||
target `.filter({ visible: true }).first()`.
|
the RSC response. Client Component text may not appear in the response bytes.
|
||||||
11. **Fresh build under test**: the deployment being measured contains the latest commit, not a
|
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.
|
build URL still serving the previous deploy.
|
||||||
|
|
||||||
## Taxonomy: red for the wrong reason
|
## 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
|
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.
|
`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
|
- **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()`.
|
Fix: an always-visible node; for lists, `.filter({ visible: true }).first()`.
|
||||||
|
|
||||||
## Differential check (capture in the PR)
|
## 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
|
`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
|
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
|
unknown base URL. If the build was produced without the testing API
|
||||||
(`experimental.exposeTestingApiInProductionBuild`), the cookie is ignored, the navigation runs
|
([`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild)),
|
||||||
normally, and the `instant()` test passes vacuously. A green `instant()` test is only meaningful if
|
the cookie is ignored, the navigation runs normally, and the `instant()` test passes vacuously. A
|
||||||
the lock engaged.
|
green `instant()` test is only meaningful if the lock engaged.
|
||||||
|
|
||||||
Two defenses; use both:
|
Two defenses; use both:
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -35,8 +35,8 @@ the process listening on the recorded port before the next build.
|
|||||||
### Testing API
|
### Testing API
|
||||||
|
|
||||||
An `instant()` test against a production build requires
|
An `instant()` test against a production build requires
|
||||||
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
|
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild).
|
||||||
builds do not expose the API:
|
Gate it so real production builds do not expose the API:
|
||||||
|
|
||||||
```ts filename="next.config.ts" highlight={3,8-10}
|
```ts filename="next.config.ts" highlight={3,8-10}
|
||||||
import type { NextConfig } from 'next'
|
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
|
the dependencies are absent, install `@next/playwright` on the same release
|
||||||
line as the project's `next`, alongside `@playwright/test`.
|
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"
|
```bash filename="Terminal"
|
||||||
EXPOSE_TESTING_API=1 pnpm build
|
EXPOSE_TESTING_API=1 pnpm build
|
||||||
pnpm start --port 3000
|
|
||||||
BASE_URL=http://localhost:3000 pnpm playwright test tests/static-shell.spec.ts
|
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
|
If the rig does not use `webServer`, start `pnpm start --port 3000` in a
|
||||||
running while the test command executes. Follow the public
|
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
|
[`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.
|
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,
|
Fix the rig before interpreting an optimizer failure. A missing testing API,
|
||||||
stale deployment, unreachable target, or wrong test state is an environment
|
stale deployment, unreachable target, or wrong test state is an environment
|
||||||
failure rather than evidence that the shell blocks.
|
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.
|
||||||
+18
-9
@@ -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
|
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
|
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`).
|
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
|
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
|
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.
|
(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)
|
## Initial load (hard navigation)
|
||||||
|
|
||||||
Drive `page.goto()` inside `instant()` with the `baseURL` option. The served document is the
|
Drive `page.goto()` inside `instant()` with the `baseURL` option. The document response contains
|
||||||
route's prerendered static shell. `baseURL` is required because `page` is still `about:blank` when
|
the route's prerendered static shell, but Playwright assertions run after hydration. A Client
|
||||||
`instant()` runs (`resolveURL` falls back to `page.url()` only when no `baseURL` is passed).
|
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
|
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
|
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
|
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.
|
no fresh browser context and no cache-busting query param.
|
||||||
|
|
||||||
The **post-release** assertion (`getByTestId('<b>-content').toBeVisible()` after the `instant()`
|
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
|
block) applies to both forms. When the lock is released, Next.js refreshes the route so deferred
|
||||||
nothing streams in after release; drop that assertion from the initial-load test, or
|
content can render. For an initial load, this is a soft refresh after hydration, with a hard reload
|
||||||
`page.reload()` first to fetch an unlocked document. The mechanism is in
|
fallback if the router is not ready yet. The mechanism is in
|
||||||
`reference/red-test-robustness.md`.
|
`reference/red-test-robustness.md`.
|
||||||
|
|
||||||
## Baseline scaffold: do not ship
|
## 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>`
|
**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.
|
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
|
The two shells can differ, as described in the
|
||||||
shipped `goto` test would confirm a marker that the `goto` path never shows, which produces exactly
|
[initial-load and client-navigation section](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests). A click-driven
|
||||||
the false RED the C-gate exists to prevent.
|
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
|
```ts
|
||||||
// soft-nav baseline: mirror the soft-nav instant() test
|
// 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
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+17
-6
@@ -35,8 +35,8 @@ the process listening on the recorded port before the next build.
|
|||||||
### Testing API
|
### Testing API
|
||||||
|
|
||||||
An `instant()` test against a production build requires
|
An `instant()` test against a production build requires
|
||||||
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
|
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild).
|
||||||
builds do not expose the API:
|
Gate it so real production builds do not expose the API:
|
||||||
|
|
||||||
```ts filename="next.config.ts" highlight={3,8-10}
|
```ts filename="next.config.ts" highlight={3,8-10}
|
||||||
import type { NextConfig } from 'next'
|
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
|
the dependencies are absent, install `@next/playwright` on the same release
|
||||||
line as the project's `next`, alongside `@playwright/test`.
|
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"
|
```bash filename="Terminal"
|
||||||
EXPOSE_TESTING_API=1 pnpm build
|
EXPOSE_TESTING_API=1 pnpm build
|
||||||
pnpm start --port 3000
|
|
||||||
BASE_URL=http://localhost:3000 pnpm playwright test tests/prefetch-preservation.spec.ts
|
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
|
If the rig does not use `webServer`, start `pnpm start --port 3000` in a
|
||||||
running while the test command executes. Follow the public
|
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
|
[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,
|
Link is visible, then enter `instant()`, click, wait for the destination URL,
|
||||||
and assert the prefetched UI.
|
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
|
### Test context
|
||||||
|
|
||||||
Record the state required to reach the audited Links and destination UI:
|
Record the state required to reach the audited Links and destination UI:
|
||||||
|
|||||||
+4
-3
@@ -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
|
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
|
and record one. The measured run must be a production build or
|
||||||
preview where `experimental.exposeTestingApiInProductionBuild` is enabled only
|
preview where
|
||||||
for testing. Development can help diagnose a route, but automatic link
|
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild)
|
||||||
prefetching is production-only.
|
is enabled only for testing. Development can help diagnose a route, but
|
||||||
|
automatic link prefetching is production-only.
|
||||||
|
|
||||||
## Prove the current behavior
|
## Prove the current behavior
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -18,6 +18,12 @@ user:
|
|||||||
Only then restore the selected contract assertions and change the
|
Only then restore the selected contract assertions and change the
|
||||||
implementation.
|
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
|
## What each signal rules out
|
||||||
|
|
||||||
- **Destination URL wait** rules out matching the source page or clicking a
|
- **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 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.
|
- 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.
|
- 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.
|
- A shared selector matches the source before the destination commits.
|
||||||
- The test clicks a default link while the code change modified a different
|
- The test clicks a default link while the code change modified a different
|
||||||
link to the same URL.
|
link to the same URL.
|
||||||
@@ -75,10 +84,10 @@ prop against `prefetch={true}`.
|
|||||||
|
|
||||||
## Lock engagement
|
## Lock engagement
|
||||||
|
|
||||||
`experimental.exposeTestingApiInProductionBuild` must be true on the measured
|
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild)
|
||||||
artifact. A marker that is absent under lock and visible after release is the
|
must be true on the measured artifact. A marker that is absent under lock and
|
||||||
in-band proof that the lock engaged. A vacuous no-op cannot satisfy both
|
visible after release is the in-band proof that the lock engaged. A vacuous
|
||||||
halves.
|
no-op cannot satisfy both halves.
|
||||||
|
|
||||||
Never enable this flag for real production traffic. For a remote rig, verify
|
Never enable this flag for real production traffic. For a remote rig, verify
|
||||||
the deployed commit before trusting RED or GREEN.
|
the deployed commit before trusting RED or GREEN.
|
||||||
|
|||||||
+11
-6
@@ -35,8 +35,8 @@ the process listening on the recorded port before the next build.
|
|||||||
### Testing API
|
### Testing API
|
||||||
|
|
||||||
An `instant()` test against a production build requires
|
An `instant()` test against a production build requires
|
||||||
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
|
[`experimental.exposeTestingApiInProductionBuild`](https://nextjs.org/docs/app/api-reference/config/next-config-js/exposeTestingApiInProductionBuild).
|
||||||
builds do not expose the API:
|
Gate it so real production builds do not expose the API:
|
||||||
|
|
||||||
```ts filename="next.config.ts" highlight={3,8-10}
|
```ts filename="next.config.ts" highlight={3,8-10}
|
||||||
import type { NextConfig } from 'next'
|
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
|
the dependencies are absent, install `@next/playwright` on the same release
|
||||||
line as the project's `next`, alongside `@playwright/test`.
|
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"
|
```bash filename="Terminal"
|
||||||
EXPOSE_TESTING_API=1 pnpm build
|
EXPOSE_TESTING_API=1 pnpm build
|
||||||
pnpm start --port 3000
|
|
||||||
BASE_URL=http://localhost:3000 pnpm playwright test tests/prefetch-optimization.spec.ts
|
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
|
If the rig does not use `webServer`, start `pnpm start --port 3000` in a
|
||||||
running while the test command executes. Follow the public
|
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
|
[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,
|
Link is visible, then enter `instant()`, click, wait for the destination URL,
|
||||||
and assert the prefetched UI.
|
and assert the prefetched UI.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "shadcn",
|
"sourceId": "shadcn",
|
||||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "7ff7dbf8669fa3392c294ee745dc8d8c3cee842c",
|
"commit": "debae9baea4d5c6bd0b1a857b09664db4b925818",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/shadcn",
|
"sourcePath": "skills/shadcn",
|
||||||
"syncedAt": "2026-10-06T07:26:41Z"
|
"syncedAt": "2026-10-06T16:00:00Z"
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user