Next.js Cache Components 下实现「瞬间导航」的测试驱动优化工作流:用 @next/playwright instant() 扩大静态壳并锁定回归
本文以仓库
skills/next-cache-components-optimizer/目录下的技能文档为骨架,结合next源码、配置 schema、错误文档与@next/playwright的实现,介绍如何把一条 Next.js(16.3+,开启 Cache Components)路由从「不即时」驱动到「即时」,并以自动化instant()测试作为回归护栏,证明每一次静态壳扩张都真实生效。读完你将掌握:P→G 九个阶段的工作流、两种导航(硬加载/软导航)下两套壳的测试写法、RED 可信度校验与差分验证,以及把顶层await/认证闸门/并行路由槽逐一压进<Suspense>边界的可复制重构模式。
先厘清:什么不可变,什么归你定
这套技能里只有一件事是固定不变的,其余都由你的仓库和平台决定。在读任何命令、平台名、环境变量之前先理解这条分界线:
- 不可变项:验证回路(verification loop)。扩张静态壳若无法被证明就没有价值。证明方式是自动化检查:在能闸住动态数据的锁(lock)之下,静态壳依然可以提交。RED 展示差距,GREEN 证明差距已闭合,测试随 PR 上线充当回归护栏。回路必须运行在类生产构建上,并且不允许空泛地通过(vacuous pass)。回路只需搭建一次,之后每一次优化都可以通过构造被验证——回路本身才是交付物,而不是某一条路由。
- 机制:
@next/playwright的instant()。它作为「尺子」而不是「秒表」(A 阶段),由@next/playwright提供(与@playwright/test一起安装,与项目里的next处于同一发布线),不与任何特定托管平台绑定。手工给导航计时太脆弱,不可信任——这正是本技能要消灭的失败模式。 - 归你定:实验台(rig)。如何构建、部署、认证、配置 Playwright 与循环方式,都属于你的技术栈。本地
next build && next start、CI/staging 容器、按推送生成的 preview 部署都同样有效;裁决永远来自构建本身,而不是平台。阶段 0 把不可变项映射到你的仓库上。下面所有平台名、环境变量拼写、命令都当作「需要翻译的示例」,而非要求。
两种导航、两种加载状态
一条路由有两种方式到达用户,二者都必须即时:
- 首次加载(硬导航):提交该路由预渲染的静态壳;被推迟的部分在各自的加载骨架(Suspense fallback、
loading.tsx)之后以流式到达。 - 客户端导航(软导航):提交目标路由预取(prefetch)得到的 App Shell——即 Partial Prefetching 下
<Link>的默认行为——只重渲染发生变化的 segment。
两类修复模式完全相同,只有测试中「如何驱动导航」这一步不同(见下文「测试中如何驱动导航」)。两种壳可以不同:请守护你真正发布的那种导航所对应的壳;如果两者都重要就都守护(见 reference/real-app-patterns.md)。典型例子是:当共享边界之上的某个父级 layout 对未枚举的 params 做了 await 时,硬导航壳会比软导航壳内容更少——硬导航从根开始重跑每个 layout,父级 layout 一旦 await props.params 而该 segment 又没有 generateStaticParams,参数在硬导航上挂起,整个子树被挤出壳;软导航不会重渲染那个父级且已有参数。症状表现为:<Link> 点击后存在的元素,goto 后却缺失。
优化目标:最大的静态壳,且「存在 ∧ 即时」
最大化静态壳是优化目标本身:最有意义的预渲染内容立即提交,只有真正按请求(per-request)变化的数据随后流入。签入的测试确定性地编码 present ∧ instant(壳存在、且即时出现);而 non-blank(非空白) 是由工作流在 D1/D2/E 阶段靠人工判断额外强加的底线——因为仅通过 instant() 也会被 fallback={null} 的空壳满足(即 empty-shell 失败模式,见 reference/real-app-patterns.md)。
再强调一次:instant() 是尺子不是秒表——它断言「在锁下壳出现了」,绝不去掐时间。可信的裁决必须以生产构建为前提(A 阶段)。锁下的 GREEN 是确定性裁决,每个阶段闸门(gate)都在守护它的可信度。
P. 前置条件:开启 Cache Components 的当前 Next.js
工作流依赖随当前 Next.js 提供的能力:
- Next.js 16.3+ 且
next.config.ts开启cacheComponents: true。没有 Cache Components 就没有可优化的静态壳。仓库中该开关位于 packages/next/src/server/config-shared.ts,其文档注释写明:开启后路由可以把「预渲染的壳」与「流入其中的动态内容」组合,而不是要么全静态要么全动态;可通过use cache指令把数据和 UI 片段标记为可缓存并纳入预渲染;同时启用cacheLife/cacheTagAPI,并内附 Partial Prerendering(PPR)支持(早期独立 PPR 开关已废弃并合并进本开关,默认值为false):
// next.config.ts
export default { cacheComponents: true }
@next/playwright与项目next保持同一发布线,它提供instant()。用npm ls next @next/playwright(或项目所用包管理器)核对,不一致就对齐。配套的测试 API 位于next运行时内,由experimental.exposeTestingApiInProductionBuild配置开关控制暴露(A 阶段)。
若项目不满足这些条件,先升级(npx @next/codemod upgrade 可自动化大部分工作),再开启 Cache Components。开启开关后,首先要去解决那些阻塞构建的路由——仓库内另一个技能 skills/next-cache-components-adoption 负责驱动该采用过程;本优化器在应用能在 Cache Components 下构建之后才介入。这个闸门是刻意的:技能面向当前 Next.js,在旧版本上任何裁决都没有意义。
0. 一次性摸底:产出本项目的 instant-nav.rig.md
技能的原则与环境无关,但你的构建/部署/认证/测试基础设施不是。本阶段把这些原则翻译成该仓库的具体工作流:每个仓库只发现一次,把答案写进一个提交入库的 instant-nav.rig.md(仓库根目录或 e2e 配置旁),之后的每次运行都读它而不再重新发现。模板与填充示例见 rig-template.md。
发现顺序:先查仓库再提问。 多数答案已在仓库里:package.json 的 scripts(build/start/test:e2e)、e2e 配置(playwright.config.* 的 baseURL/webServer/projects)、CI 配置(.github/workflows/、vercel.json、GitLab/Circle 文件、Dockerfile)、next.config.*(现有 experimental 开关)、已有 e2e 认证辅助(grep login/storageState/session)。只把仓库答不上来的问题抛给用户——通常指:哪个部署目标算「preview」、CI 里测试套件以哪个账号运行、Agent 是否允许无人值守地 push 并等待 CI。
六个必答问题(外加两个派生字段):
- BUILD:本应用的生产构建如何产生并被服务?按推送的 preview 部署、staging 容器、或裸
next build && next start,任何「非next dev」的形式皆可。 - EXPOSE:什么条件为每一个被测构建打开
experimental.exposeTestingApiInProductionBuild,且永远不为真实生产打开?常见拼写:本地EXPOSE_TESTING_API=1;通用 CI/staging 用process.env.DEPLOY_ENV === 'staging';Vercel 用process.env.VERCEL_ENV === 'preview'。要在next build期间设置该条件,而不只是在next start时——否则instant()可能拿不到测试 cookie 就已超时;先重建产物再去调试断言。 - RUN:Playwright 套件如何调用、打向哪个
BASE_URL? - TEST USER:套件以哪个账号运行、登录如何进行(helper、
storageState、API token)?该账号拥有哪些 flags/plan/role/数据? - DRIFT:枚举一切可能造成「作者本人会话」与「测试用户环境」差异的因素(功能开关、套餐与权益、角色、有种子数据 vs 空库、locale、A/B 桶)。每一项都是一条让 RED 变得不可信的路——这个清单喂给 C 闸门。
- LOOP:你实验台对应的无人值守迭代方式:push → build → 对产物跑 e2e → 读失败 → 修复 → push(CI),或 build → start → e2e(本地)。记录 Agent 独自无法完成的事(部署审批、机密、受保护分支)。并附活性探针(LIVENESS):回显部署提交 SHA 的端点或响应头(如
/healthz路由或x-deployed-sha头),使 CI 运行能在信任裁决前确认被测构建确实等于HEAD。若平台不提供回显 SHA 的端点/头,就自建一个:把构建期提交变量(VERCEL_GIT_COMMIT_SHA、CI 提交变量)暴露到/healthz或某个响应头,或退而轮询部署平台 API 找commitSha === HEAD的那次部署。记录所选机制。本地build && start实验台产物是刚构建的,无需 SHA 探针,但请记录端口、启动前停掉旧服务、在EADDRINUSE时令循环失败、测试前确认新起的进程确实占用该端口——next start可能 fork 一个next-server子进程,启动进程的 PID 未必持有端口,请把服务放进可整体停止的进程组,或发现并停止监听记录端口的进程后再做下一次构建。
模板文件本身(复制、填充、提交为 instant-nav.rig.md):
# instant-nav rig: <project>
- BUILD: <command / platform that produces the measured production build>
- EXPOSE: <the condition wired to exposeTestingApiInProductionBuild>
- RUN: <e2e command> against <how BASE_URL is obtained>
- TEST USER: <account> via <login mechanism>; flags/plan/role/data: <...>
- DRIFT: <the enumerated drift surface>
- LOOP: <push → CI → e2e, or local build → start → test>; agent limits: <...>
- LIVENESS: <endpoint/header echoing the deployed SHA; n/a for local build && start>
- WALLS: <project-specific build/run obstacles + their workarounds>
真实应用很少一次就能干净地生产构建:缺机密、仅服务端的 import 令预渲染失败、被反复重启的服务占着端口……第一次撞上就把每堵墙及其绕行办法记进 WALLS,它专门积累其余字段无法表达的项目特有障碍。典型填充示例(完整版见 rig-template.md):本地专用 = EXPOSE_TESTING_API=1 next build && next start + 单机 build→start→test;通用 CI + 容器 = 流水线构建镜像部署到 staging + DEPLOY_ENV === 'staging' + CI 任务对 staging URL 跑 Playwright;Vercel preview = 每次 push 构建 preview + VERCEL_ENV === 'preview'。
A. 搭建实验台:暴露测试 API 的生产构建
把 instant-nav.rig.md 描述的实验台搭起来。任何平台上都成立两条不变式:
- 绝不在
next dev上测量。 dev 不预取,且其锁对阻塞路由不可靠,dev 下的instant()结果既不能当 RED 也不能当 GREEN。 - 被测构建必须暴露测试 API。 否则
instant()静默空转(silent no-op),测试空泛通过(详见 reference/red-test-robustness.md)。锁确实咬合的证明,本身就是 C 阶段的 RED:未修复的目标路由是「已知阻塞路由」,它在锁下的 RED 说明锁在该构建上生效(C 闸门);test-template.md 里的「自验证变体」则提供带内(in-band)保证。把experimental.exposeTestingApiInProductionBuild接到一个「对每个被测构建为真、对真实生产永假」的条件上:
experimental: {
// 使用你的平台提供的条件,并记录在 rig 文件中:
// local: 显式 opt-in,如下所示
// generic CI: process.env.DEPLOY_ENV === 'staging'
// Vercel: process.env.VERCEL_ENV === 'preview'
exposeTestingApiInProductionBuild:
process.env.EXPOSE_TESTING_API === '1',
}
从仓库实现看,这个开关的底层意义是:instant() 通过设置一枚名为 next-instant-navigation-testing 的 cookie 让构建内部的锁代码读取(见 packages/next-playwright/src/index.ts 与客户端 segment-cache 目录下的 navigation-testing-lock 实现)。如果构建产物里压根没有这套锁代码,cookie 会被忽略、导航照常进行、instant() 测试空泛通过。该开关在 packages/next/src/server/config-shared.ts 中位于 experimental 区(exposeTestingApiInProductionBuild?: boolean),正是决定「锁代码是否被编进产物」的总开关。
对任何部署态/远程构建,先用 rig 的 LIVENESS 活性探针轮询确认产物包含 HEAD 再相信裁决(过期部署会读成假的 RED 或 GREEN);本地 next build && next start 则无需探针。当前没有 Playwright e2e 框架的仓库,在本阶段顺带立一个最小骨架(@next/playwright、带 baseURL 的配置、一条认证路径)——本回路不假设你已有测试套件。
B. 基线(不加锁):开发脚手架,不随 PR 上线
在没有任何 instant() 锁的情况下驱动真实的导航,断言目标路由的 SHELL_MARKER 会以「测试用户」身份渲染:即 e2e 套件所认证的账号(CI 里是 CI 账号,本地是你的 e2e 登录 fixture),带它的 flags、plan、role 和数据。这确立了标记真实且可达:没有被开关门控、没有被重定向走、不是猜出来的选择器。套件以测试账号而非作者的会话运行——这种环境漂移(rig 的 DRIFT 清单)正是不可信 RED 的常见来源。脚手架与运行命令见 test-template.md;基线脚手架在 PR 前删除。
基线的导航类型必须与你要签入的测试一致:守护软导航壳就点 <Link>,守护硬导航壳就 page.goto()——两种壳可能不同,用点击驱动的基线去校验一个 goto 测试,会确认一个 goto 路径永远不会展示的标记,正好制造出 C 闸门要防的假 RED。
C. RED(加锁)+ VERIFY-RED 闸门
把同一导航包进 instant(),断言壳在锁下提交。这里的 RED 就是差距本身,这个测试才是要签入的(test-template.md)。路由有推迟内容时优先用自验证变体。若路由在阻塞状态下无法构建、或 cookie/会话读取类 RED 一直 GREEN,用 reference/red-test-robustness.md 中的 RED 配方。
C 闸门:RED 未被验证为可信之前,不要开始优化。 一个「因为错误原因而红」的 RED,会把你引去优化一条从来没坏过的路由——路由变即时了、测试却一直红(或代码被扭曲去迁就坏断言),力气全花在错的问题上。
一锤定音的问题是:SHELL_MARKER 在无锁情况下、以测试用户身份能渲染吗? 判定方法是重新以测试用户跑一遍 B 阶段,而不是往签入测试里堆断言。两种分支结局(No → 标记或环境 bug,修复标记——这是最常见情形;Yes → 真差距,进入 D)、不可信 RED 的完整分类学、检查清单与演练案例,全在 reference/red-test-robustness.md。
阻塞路由在 Cache Components 下可能先构建失败而产不出 RED:此时在目标路由上临时加 export const instant = false 作为 RED 脚手架的一部分,它让已知阻塞源能构建成功而又不把导航变即时;随修复一并移除该 opt-out,做差分回退时也要带上它。注意不要拿 cookies()/会话读取单独伪造 RED——测试锁限制导航只到壳,它并不会让请求 cookie 不可用;用路由真实的阻塞性未缓存数据,有推迟内容时优先自验证变体。
D. FIX:把每个边界下推到它守护的数据那一层
反面模式:一条粗糙的顶层边界。 树高处单个 <Suspense> 配整页级 fallback 有三重代价:
- layout UI 被留在静态壳外:壳里只有它一份会被丢弃的副本;
- 边界解析时整棵子树被替换,丢弃客户端状态并引起布局跳动;
- 手写 fallback 会随 UI 演进而失同步,因为它复制了本也存在于解析后树中的结构。
修复方法:上提静态部分、下推 Suspense。 让 layout UI 在壳中同步地只渲染一次,并把每个 await 包进一个只守护那一次读取的窄边界;只有那片叶子流式,稳定祖先原样复用。
规则:一个元素若同时出现在 fallback 与解析后的树里,就把它提到边界之上。
最常见阻塞源:fallback 路由上 layout 里的顶层 await。
app/[locale]/(app)/[tenant]/dashboard/...
│ generateStaticParams ✅ │ no generateStaticParams → fallback route
当路由中任意动态 segment 缺少 generateStaticParams,整条路由就是 fallback 路由,所有参数(包括已枚举的那些)都推迟到请求期解析。此时 layout 里的顶层 await(await params、请求期会话读取、认证闸门)即便读的是静态已知参数,也会把整棵子树挡在静态壳外。最小形态:一个动态 segment 路由 + 其中某 segment 缺 generateStaticParams + 其上方 layout 有顶层 await。
修复:推迟闸门,无条件渲染 children。 children 无条件渲染;把顶层 await 挪进被 <Suspense fallback={null}> 包裹的子组件(机制与 before→after 见 reference/real-app-patterns.md 的「Deferring an auth gate」)。壳按「已授权」的样子预渲染(会话读取在到达 redirect() 前就挂起,所以 redirect() 只在请求期执行),{children} 因此进入壳而不是被闸门挡在后面——fallback={null} 在这里是正确的,因为 AuthGate 成功时本就渲染 null:
// ❌ Before:顶层 await + redirect 挡住整个设置区框架
export default async function SettingsLayout({ children }) {
const session = await getSession() // 请求期读取,预渲染期挂起 → 框架无法构建
if (!session?.user) redirect(getLoginUrl())
return <Shell>{children}</Shell>
}
// ✅ After:children 无条件渲染,闸门移入 Suspense 子组件
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() // 会话读取在预渲染期挂起…
if (!session?.user) redirect(getLoginUrl()) // …因此 redirect() 绝不会在构建期执行
return null
}
壳下面的页面也要修,不能只修 layout。 页面级顶层 await(通常是 await params)与 layout 的阻塞方式相同,所以也要把页面变同步、把动态读取推进 <Suspense> 包裹的叶子。fallback={null} 仅当「闸门成功时本就什么都不渲染」才正确;对数据,fallback 必须是真正的加载骨架(见 D1)。
其余每种阻塞形态——cookies()/headers()、未缓存 fetch 或数据库读取、searchParams、metadata、viewport、非确定性值(Date.now()、Math.random()、crypto.randomUUID())——撞上时都会浮现自己的 insight:构建会打印一个错误页链接。仓库中这些错误页一一对应 errors 目录下的文档,例如 blocking-prerender-runtime、blocking-prerender-dynamic、blocking-prerender-current-time、blocking-prerender-random、blocking-prerender-crypto、blocking-prerender-metadata-runtime、blocking-prerender-client-hook、instant-link-prefetch-partial 等 .mdx。默认构建输出常被截断且无可用堆栈:加 --debug-prerender 看完整的失败帧并上报第一个之后的每个阻塞源;用 next build --debug-build-paths "app/<route>/**" 把构建范围收窄到你正在处理的路由,而不是重建整个应用。打开对应错误页按其配方处理,别照着内联消息即兴发挥。每种形态的 before→after 配方都在 reference/patterns.md。
有几件事这些逐错误页面不会为「瞬间导航」目标强调:
- 根 layout 里的边界对客户端导航不够用。 它过得了整页加载检查,却让同级的客户端导航继续阻塞;边界要放在「源路由与目标路由共享的最低 layout」之下。
- 让 LCP 元素(通常是主标题)留在任何边界之外,让它随壳直接上屏,而不是等流。
- 绿灯不等于即时。
export const instant = false让该 segment 跳过校验而导航仍在阻塞;文档<body>之上罩一个<Suspense>会预渲染出空壳——两者都不让路由真正即时。
D1:复用路由既有的加载 UI,不要手搓骨架
动笔写骨架前,按顺序搜索仓库里该路由已经存在的加载 UI:
- 该路由的
loading.tsx; - 与组件同目录的已导出
*Skeleton; - 组件自身
<Suspense>里现成的 fallback。
分歧点(divergence point) 是源与目标路由共享的最低 layout:软导航只重渲染它之下的 segment,硬导航则从根重跑每个 layout(也叫共享边界)。分歧点上方的 loading.tsx 只填充硬导航壳——它位于软导航重渲染范围之外;位于目标 segment 的 loading.tsx 本身就是软导航进入该 segment 时的树内边界,两者兼顾。复用真正覆盖你所发布导航的那个边界;分歧点以下,loading.tsx 与同目录骨架对那个用途是可互换的。若组件没有骨架,把它的加载标记抽成同目录骨架放旁边。不要新写一份镜像整页布局的骨架:它复制结构、随页面变化漂移,还会把设计拽回单条粗糙边界;复用组件自己的骨架还能让预取壳与加载后的 UI 保持一致(详见 test-template.md 与 reference/real-app-patterns.md)。例外:若被推迟组件对某些用户渲染 null(如开关门控的控件),fallback={null} 才正确——否则骨架会闪现后塌掉。
D2:每个断点下壳都必须与真实渲染一致
冻结在单一断点的骨架在其余断点上会错位。修法与别处同源:一个响应式组件同时渲染活 UI 与壳(D1 骨架坐在它的数据槽里),断点切换只发生一次。验证方式:用两个视口重新断言壳标记(await page.setViewportSize({ width: 1280, height: 800 }),再 { width: 390, height: 844 }),或新增一个 mobile Playwright project,让本闸门与其他闸门一样可被机器检查(细节见 reference/real-app-patterns.md)。
D 闸门:D 阶段完成的判据,是 C 阶段的加锁测试在生产构建实验台上绿灯通过,而不是「代码能编译」。 锁下那个 GREEN 是修复循环的确定性终点,然后进入 E。
当 URL 数据无法下推时(例如整页都依赖 params、searchParams 或完整 URL),可能没有有意义的静态壳可扩张。别硬造。Per-link 预取可以让软导航变即时,但它在本优化器循环之外:要求 Partial Prefetching、<Link prefetch={true}> 以及可缓存的 URL 相关内容。它的三个前提、成本取舍、手动预取注意事项与 instant() 测试坑见 reference/patterns.md 的「10. URL data that can't move」;要点包括:auto/PPR 预取在 runtime 派生前就退出(subtreeHasSpeculativePrefetch),全量预取是强制的;目标路由必须已采用 Partial Prefetching;链接指向的必须是规范化后的最终 URL(307 重定向的 href 无法预取);别给所有可见链接无差别开全量预取;标记必须是已提交的节点而非 RSC 字节——内容常是客户端组件,文本不在预取响应里,应断言客户端子树提交后渲染出的 data-testid。
E. PARITY:重构只改变「是否即时」这一件事
下推是机械变换,不是重新设计。之后路由必须渲染与之前相同的树、数据、顺序、空态与错误态、重定向和交互;唯一可观察的差异是壳现在即时提交。校验:
- 渲染输出一致:被搬走的
await计算出并返回相同值;流结束后,对测试用户而言路由与基线分支内容一致。 - 副作用仍然触发:被推迟的
redirect()/notFound()依然发生,只是从预渲染期挪到请求期——确认未授权用户仍被重定向、缺失记录仍返回 404。 - 两个视口在流之后都到达真实 UI(D2)。
- 客户端状态幸存:layout UI 被上提到稳定壳而非在解析时被换掉,因此展开的菜单、滚动位置、焦点、输入状态跨流保持。
- 既有失败保持独立:改动后若路由报错,在基线分支复现——同样的失败在那出现,就是环境或数据问题,不是优化器回归。
任何「除是否即时之外」的变化,都应缩减本次重构。
F. DIFFERENTIAL:差分验证
只回退修复 → RED;重新应用 → GREEN;把两次运行链接起来(配方见 reference/red-test-robustness.md)。部署态实验台上,信任颜色前先确认每次运行都活着(LIVENESS,A 阶段)。这是「RED 真的在测量那个性质」的最强证据:
1. on the fixed branch → GREEN
2. revert ONLY the fix (the <Suspense> push-down) → RED
3. re-apply → GREEN
4. confirm no other change moves it
把两次运行(或开关 diff 与结果)链进 PR 描述;审阅者看到差分就知道该测试在测量目标性质。
G. REVIEW(PR 检查清单)
最终绿灯本身没意义,如果 RED 从未可信过。先过测试可信度清单(reference/red-test-robustness.md),再逐项核 PR 专属项:
- [ ] 差分已展示:无修复 RED、有修复 GREEN,运行互链。
- [ ] 一致性已确认(E):内容、重定向、状态不变。
- [ ] 既有加载 UI 被复用(D1):没有镜像整页的新骨架。
- [ ] 桌面与移动宽度下壳都与真实渲染一致(D2)。
- [ ] 基线已移除:只保留 C 阶段的加锁测试。
整个工作流的停止条件:C 的加锁测试在实验台上 GREEN、差分(F)成立、上面每项都勾上。三者不齐,就没做完。
测试中如何驱动导航:软导航 vs 硬导航
仓库 test-template.md 提供签入版两种导航的 instant() 规格,以及 PR 前必删的基线脚手架:
- 软导航 → 驱动一次真实的
<Link>点击。锁下由路由自行发起并等待路由预取,无需手工预热;壳偶发缺失要当作真实阻塞源或标记 bug(C 闸门),绝不当作预热竞态。不要加 wait、不要 hover。
import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'
// 使用你 e2e 套件已有的认证/设置辅助。以测试用户运行。
import { logIntoTestAccount, testUrl } from '../helpers'
// 目标静态壳中的一个 SYNC 元素(页头、动作按钮、列头),
// 不是流入的数据,且对测试用户一定渲染。优先在已知静态节点上
// 放 data-testid,别猜 role/name。
const SHELL_MARKER = '[data-testid="<b>-shell-marker"]'
test.describe('instant nav: A -> B', () => {
test.beforeEach(async ({ page, browser }) => {
await logIntoTestAccount(page, browser)
})
test('B shell commits under instant()', async ({ page }) => {
await page.goto(testUrl('/'))
const trigger = page.getByRole('link', { name: '<Trigger>', exact: true })
await expect(trigger).toBeVisible({ timeout: 20000 })
await instant(page, async () => {
await trigger.click()
// 锁下断言静态壳;无自定义超时
await expect(page.locator(SHELL_MARKER)).toBeVisible()
})
})
})
触发器选择器与 SHELL_MARKER 同规则:优先给真实 <Link> 上 data-testid(page.getByTestId('<trigger>-link'))而不是猜无障碍名;无论用哪种,触发器和标记都必须能对测试用户稳定解析。注意:instant() 会在嵌套调用或 base URL 未知时抛错,但不会在锁代码缺失时抛错——这正是 A 阶段与自验证变体存在的原因。
- 硬导航 → 在
instant()内用带baseURL选项的page.goto()。instant()运行时page仍是about:blank,resolveURL只在没传baseURL时才回退到page.url(),所以baseURL是必需的。会话必须不经由page导航预先建立(注入storageState,或在独立 context/page 里登录)——登录 helper 若自己导航page,那趟导航会在instant()取得锁之前完成、处于未测量状态,同样破坏测量;未认证路由会重定向到登录页,RED 因此失真。
test.describe('instant initial load: B', () => {
test.beforeEach(async ({ page }) => {
await injectTestUserSession(page) // 只注入 storageState;绝不能调用 page.goto
})
test('B shell is served', async ({ page }) => {
const url = testUrl('/<b>')
await instant(
page,
async () => {
await page.goto(url)
await expect(page.locator(SHELL_MARKER)).toBeVisible()
},
{ baseURL: new URL(url).origin }
)
})
})
自验证变体(有推迟内容的路由推荐):同时断言推迟内容在锁下被闸住、放锁后流入——这让空泛通过不可能:若锁没咬合(构建缺测试 API),内容早已在场,toHaveCount(0) 会失败:
// 软导航
await instant(page, async () => {
await trigger.click()
await expect(page.locator(SHELL_MARKER)).toBeVisible() // 壳在场
await expect(page.getByTestId('<b>-content')).toHaveCount(0) // 推迟数据被闸住
})
await expect(page.getByTestId('<b>-content')).toBeVisible() // 放锁后流入
两半「被闸住」断言(壳可见、推迟内容 toHaveCount(0))同样适用于硬导航 page.goto() 形态:cookie 对两种导航一致地闸住推迟内容——软导航是客户端锁闸住动态数据写入;硬导航是服务端在文档请求上尊重 cookie(导航前用 addCookies() 设置,按 baseURL 限定作用域)并挂起动态数据,与该路由此前是否渲染/缓存过无关。「放锁后」断言仅限软导航:硬导航的文档已在锁下发出,放锁后没有东西会流入;硬导航测试删掉该断言,或先 page.reload() 取一份未加锁文档。从 packages/next-playwright/src/index.ts 的实现看,instant() 通过 context.addCookies 写入 next-instant-navigation-testing cookie(值为 JSON.stringify([0, p${Math.random()}]))触发 CookieStore change 事件取得内存中的导航锁,结束时以过期时间重加同 cookie 的方式精准删除而不触碰应用自身 cookie(Playwright 带过滤器的 clearCookies 会清空整个 jar 再补回,会产生应用 cookie 短暂消失的空窗,故不可用)。
其他测试要点:
- 并行路由下,只有变化的 slot 会在软导航中重渲染;客户端渲染的导航 UI 根本不会重渲染。 不要追一个本次导航碰不到的 slot(细节见 reference/real-app-patterns.md)。
instant()守卫无需重试、无需预热。它是确定性的:锁下路由自行发起预取并在提交前 await 它(对prefetch={false}的链接、对已在预取缓存里的路由都如此),提交的壳不依赖任何先前的渲染、hover 或菜单展开预取。守卫闪烁必有真实原因——标记不是目标壳的同步节点、测试用户的 flag/role/空态缺口、或真正阻塞的路由;修页面或修标记,重试只会掩盖守卫要抓的回归。唯一合法的.hover()/开菜单,是触发器本身要悬停/展开才进 DOM 的情形。- 别给签入断言加自定义超时、
painted布尔或isVisible({ timeout })软等待:instant()的信号是「在场」而非「多快」,自定义短超时(如 3000ms)是在跟一个不存在的时钟赛跑;isVisible({ timeout })已被 Playwright 弃用并忽略该参数,调用会立即返回。
十个可落地的重构模式速查
reference/patterns.md 收录每个阻塞形态的 before → after 完整样例,核心决策表如下(文中 insight 均对应仓库 errors 目录下同名错误文档):
- 顶层
await→ 把 await 移进 Suspense 子组件。请求期数据在页面/layout 顶层的 await 会让其下一切变动态;把paramspromise 传下去、在<Suspense>包裹的子组件里 await(含不解构单独组件的props.params.then(...)内联变体)。 - layout 里
cookies()/headers()→ 先发起、不 await、向下传。const cookieStore = cookies()不阻塞壳,把 promise 交给 Suspense 子组件去 await;{children}与导航留在壳内。 - 未缓存 fetch / DB 读 → 按数据源选
use cache或<Suspense>。对所有人相同且少变的数据用'use cache'(进壳);按请求且必须新鲜的留在边界后流入。裸'use cache'应用defaultcacheLife配置——要用cacheLife('<profile>')(default/seconds/minutes/hours/days/weeks/max)显式选择新鲜度而不是默认时长发版;无服务器注意use cache是内存态、跨实例不持久。 - 动态 params →
generateStaticParams(进壳)或<Suspense>(流入)。可枚举就预渲染使await params在壳内解析;否则视为请求期、把消费方包进边界。根 params(根 layout 所在的动态 segment)可用next/root-params从任意 Server Component 读取而无需 prop-drilling(仓库内有对应 loader packages/next/src/build/webpack/loaders/next-root-params-loader.ts),但 Cache Components 下仍须用generateStaticParams枚举(每个根参数至少一个值)才能进壳。 searchParams→ 永远包进<Suspense>(页面加载路径)。搜索参数构建期永不可知;静态内容留壳、消费方流入。客户端导航已有 URL,useSearchParams()消费方可同步解析进预取壳——但仍需边界兜住整页加载路径。- 非确定性值 →
connection()+<Suspense>,或缓存。Math.random()/Date.now()/crypto.randomUUID()每次输出不同:按请求的值await connection()后包进 Suspense(<Suspense fallback={null}>);对所有人同值就'use cache'让它进壳。 - 动态
generateMetadata→ 静态导出、use cache、或加 dynamic-marker。读 cookies/headers 就保持generateMetadata动态并在页内加一个await connection()后返回null的DynamicMarker组件,使其余页面照常预渲染进壳。generateViewport同理,但动态 viewport 会阻塞整页。export const instant = false与<body>之上的<Suspense>都是「接受动态」的退出选项,不是即时修复。 - LCP 元素留在壳内。主标题别埋进边界——它要随壳上屏而不是等流。
- 共享 layout 之下的颗粒度。根 layout 的单条边界过得了整页检查,却让兄弟客户端导航继续阻塞;把边界放在共享 layout 之下(
/store/shoes → /store/hats这类软导航只有低层边界覆盖)。优先页面内逐组件边界而非一条大 layout 边界。 - 无法移动的 URL 数据 → per-link 预取。整条路由都依赖 URL 数据时,下推读取可能留不下有意义的共享壳——这是优化器的停止点而非又一次壳重构。
真实应用形态:并行路由、空壳失败模式与响应式骨架
生产级 App Router 路由在 layout → page 线性树之外还叠加了并行路由、共享 layout UI 与认证闸门(细节全在 reference/real-app-patterns.md):
- 并行路由:每个 slot 都是独立边界。 即时校验把共享 layout 之下每个并行 slot 当作独立的导航边界:每个
@slot的动态读取都要自己的<Suspense>;任意一个 slot 未遮盖的动态读取会阻塞整个导航——@content再完美,@sidebar顶层 await 一次会话就全完;渲染null(如default.tsx)的 slot 是壳安全的静态零读取,本次导航不重渲染的 slot 零成本。 - 客户端渲染的 slot 路由不在软导航重渲染范围内。共享 layout 通过
usePathname()的客户端组件切换 slot 内容时,软导航只重渲染共享 layout 之下变化的服务端 segment;这个客户端子树既不会阻塞导航也无需为其加服务端<Suspense>,只有真正变化的服务端 segment(如@content)才重要。 - 「即时」不等于「有用的壳」:空壳失败模式。 校验检查的是「动态读取被边界守护」,不是「fallback 非空」。无 fallback(或
fallback={null})的<Suspense>通过校验并即时提交,却渲染空白壳。layout 与页面在同一空 fallback 边界下双双顶层await getSession()时,整个框架在用户等待期间塌成空白。「校验为即时」与「良好的加载体验」是两个目标——给每个边界一个真实加载骨架,并把它放低,让最多真实内容留在壳里;<body>正上方的fallback={null}是有意的空壳退出,树低处的空 fallback 几乎总是 bug。 - 响应式骨架错位。 手写骨架编码一种布局,真实组件却是响应式的:桌面端列表-详情把列表/树放侧栏,移动端塌成下拉/抽屉(自带加载态),为桌面面板写的行骨架在移动端无处对齐。修法同源:让活渲染与壳渲染共享同一个响应式组件,断点切换只发生一次,两个渲染共用,不存在第二份会漂移的桌面专用骨架。
- 边界情况。 用
React.cache(或自定义记忆化)包cookies()/headers()依然会挂起——记忆化不改变请求读取在预渲染期返回 pending promise 的事实,只有use cache指令能按静态/参数键把数据放进壳;display: contents或 fragment fallback 在 Playwright 眼里是隐藏的,instant()断言无法对它们toBeVisible()——给 fallback 一个带data-testid的真实包裹元素。
RED 可信度:改之前先验 RED
C 闸门的整套判据在 reference/red-test-robustness.md,核心如下。
决定性一问:无锁时、以测试用户身份,标记渲染吗?否 → 标记或页面对该用户/环境不存在,修标记(最常见);是 → 锁下变红才是真「不即时」,去优化路由。
可信度清单(全须成立):① 在未修复路由上失败(red on baseline);② 理由正确——无锁时在生产构建实验台上以测试用户可见(永不在 next dev);③ 差分成立——只回退修复即 RED、重放即 GREEN、无他物改变它;④ 标记不可伪造——静态壳的同步元素,绝非流入数据;⑤ 生产构建上确定性稳定;⑥ 双向可判别——即时路由锁下在场、阻塞路由锁下缺席;⑦ 对测试用户渲染;⑧ 条件重定向已计入——在该用户的真实目的地断言;⑨ 真实选择器——已知静态壳节点的 data-testid,不是猜的 role/name;⑩ 标记可见——非 display:none、非屏外、非 hover 覆盖层内,列表用 .filter({ visible: true }).first();⑪ 被测构建新鲜——部署确实含最新提交。
错误理由分类学(红得不对的八种,无一是「导航不即时」):
| 错误理由 | 成因 | 排除法 |
|---|---|---|
| 选择器匹配空 | 猜的 getByRole('button', { name: 'Folder' }) |
grep 组件找真实无障碍名;加 data-testid |
| 条件重定向 | 路由对该测试用户 redirect()(flag/role) |
检查页面顶层分支;在真实目的地断言或钉住 flag |
| flag/plan/role 门控 | 作者有而测试用户没有 | 以测试用户跑无锁基线;用项目机制钉 flag |
| 空态 | 标记只随数据存在,CI 账号是空的 | 选空态也存在的标记(页头等 layout 元素),或种数据 |
| 超时/闪烁 | 慢 API 或瞬态基建错误 | 重跑;区分基建闪烁与真实信号 |
| 流入标记 | 标记在 <Suspense> 后、从不在壳里 |
选同步壳元素,确认在每个 <Suspense> 之外 |
| 认证重定向 | 未认证 → /login |
导航前确认登录成功 |
| 过期部署 | 打到上一版构建 | 信任裁决前轮询部署中的最新提交标记 |
| 隐藏/屏外 | testid 在 hover 覆盖层或屏外列表项上 | 标记放常显节点;列表用 .filter({ visible: true }).first() |
静默空转的双重防线(A 阶段的自验证):instant() 不抛「锁缺失」错误,只会对嵌套调用或未知 base URL 抛错;无测试 API 的构建里 cookie 被忽略、instant() 测试空泛通过。防线一:把 exposeTestingApiInProductionBuild 接到平台 preview/staging 条件,绝不信任未设置它的构建上的绿灯;防线二:任何有推迟内容的路由都做自验证断言(锁下壳在、推迟内容 toHaveCount(0))——锁若未咬合,内容已在场,toHaveCount(0) 必失败。另外多跑几次 RED:间歇性红的闸门不是闸门,先判清是基建还是真竞态。
优化之后:Partial Prefetching 采用度检查与下一步
目标路由即时后,检查应用是否已采用 Partial Prefetching(partialPrefetching: true,或增量推广期目标路由仍用 prefetch = 'partial')。该配置同样定义在 packages/next/src/server/config-shared.ts:partialPrefetching: true 时 <Link prefetch={true}> 只预取路由的静态部分、绝不预取动态数据,默认 segment 级 prefetch 变为 'partial',segment 级 prefetch 导出仍然优先;要求 cacheComponents: true。
机械地做检查:
rg -n "partialPrefetching|prefetch\s*=\s*['\"]partial['\"]" --glob 'next.config.*' --glob 'app/**' --glob 'src/app/**'
- 配置里是
partialPrefetching: true→ 应用已全局采用。若只有prefetch = 'partial'命中 → 把那些目标 segment 视为增量推广期已采用,继续检查其余目标路由。 - 已采用:对前面停在「URL 数据无法下推」的路由,考虑在值得为之付出的链接上定向
<Link prefetch={true}>——前提是那次点击前备好 URL 专属内容;其余地方保持默认链接行为,让共享 App Shell 继续做低成本基线。 - 尚未采用:推荐仓库内技能 skills/next-partial-prefetching-adoption,它把应用迁移到更好的预取模型:默认预取共享 App Shell、减少可见链接的重复全量预取请求、审计既有
<Link prefetch={true}>用法,仅在 URL 专属内容值得额外服务端工作处提供可选的 per-link 预取。
面向报告与无人值守的汇报方式
本回路设计成可无人值守运行,因此不会在步骤间停下来提问。修完用户指定的那条导航就停。汇报时用用户能看见的语言描述差距与结果——「进入 dashboard 之前要先等图表查询,现在 layout 与骨架即刻上屏、图表流入」,而不是 RED/GREEN、锁或阶段字母;用浏览器演示或前后截图让用户看到壳即时提交、数据流入——前后相同意味着修复无效,回滚;把一次运行呈现为可点击逐条浏览的结果列表(每条导航一行:路由、即时提交什么、流入什么);只有遇到真正的分叉才提问——会改变行为的修复、安全敏感读取、或设计上就动态的路由(那是 per-link 预取候选而非要扩张的壳)。无人可问时别阻塞:选安全默认并记录假设——例如缓存新鲜度取舍上,把读取推迟到 <Suspense> 之后(恒新且仍即时),而不是猜一个 cacheLife。
文件索引
- skills/next-cache-components-optimizer/SKILL.md:工作流总纲(P→G 阶段、不变式、汇报方式)。
- skills/next-cache-components-optimizer/rig-template.md:阶段 0 六问摸底、
instant-nav.rig.md模板与三种填充示例。 - skills/next-cache-components-optimizer/test-template.md:签入的两种导航
instant()规格(C 阶段)与 PR 前必删的基线脚手架(B 阶段)。 - skills/next-cache-components-optimizer/reference/patterns.md:D 阶段 10 种阻塞形态的 before→after 重构配方。
- skills/next-cache-components-optimizer/reference/real-app-patterns.md:并行路由、认证闸门下推、硬/软导航双壳、空壳失败模式、响应式骨架错位。
- skills/next-cache-components-optimizer/reference/red-test-robustness.md:C 闸门与 F 阶段——RED 分类学、可信度清单、差分配方、空泛通过防线。
仓库侧的源码依据:instant() 的 cookie 锁协议见 packages/next-playwright/src/index.ts;cacheComponents、partialPrefetching 与 exposeTestingApiInProductionBuild 的配置定义见 packages/next/src/server/config-shared.ts;锁的客户端实现位于 packages/next/src/client/components/segment-cache/(含 .disabled.ts 空实现变体,正说明缺测试 API 时锁不咬合);各类阻塞源对应的官方解释都在 errors 目录按名可查。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00