首页
/ Next.js Cache Components 下实现「瞬间导航」的测试驱动优化工作流:用 @next/playwright instant() 扩大静态壳并锁定回归

Next.js Cache Components 下实现「瞬间导航」的测试驱动优化工作流:用 @next/playwright instant() 扩大静态壳并锁定回归

2026-09-07 13:58:09作者:卓炯娓

本文以仓库 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/playwrightinstant()。它作为「尺子」而不是「秒表」(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/cacheTag API,并内附 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。

六个必答问题(外加两个派生字段):

  1. BUILD:本应用的生产构建如何产生并被服务?按推送的 preview 部署、staging 容器、或裸 next build && next start,任何「非 next dev」的形式皆可。
  2. 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 就已超时;先重建产物再去调试断言。
  3. RUN:Playwright 套件如何调用、打向哪个 BASE_URL
  4. TEST USER:套件以哪个账号运行、登录如何进行(helper、storageState、API token)?该账号拥有哪些 flags/plan/role/数据?
  5. DRIFT:枚举一切可能造成「作者本人会话」与「测试用户环境」差异的因素(功能开关、套餐与权益、角色、有种子数据 vs 空库、locale、A/B 桶)。每一项都是一条让 RED 变得不可信的路——这个清单喂给 C 闸门。
  6. 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: <pushCIe2e, or local buildstarttest>; 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 描述的实验台搭起来。任何平台上都成立两条不变式:

  1. 绝不在 next dev 上测量。 dev 不预取,且其锁对阻塞路由不可靠,dev 下的 instant() 结果既不能当 RED 也不能当 GREEN。
  2. 被测构建必须暴露测试 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 里的顶层 awaitawait 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-runtimeblocking-prerender-dynamicblocking-prerender-current-timeblocking-prerender-randomblocking-prerender-cryptoblocking-prerender-metadata-runtimeblocking-prerender-client-hookinstant-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:

  1. 该路由的 loading.tsx
  2. 与组件同目录的已导出 *Skeleton
  3. 组件自身 <Suspense> 里现成的 fallback。

分歧点(divergence point) 是源与目标路由共享的最低 layout:软导航只重渲染它之下的 segment,硬导航则从根重跑每个 layout(也叫共享边界)。分歧点上方的 loading.tsx 只填充硬导航壳——它位于软导航重渲染范围之外;位于目标 segment 的 loading.tsx 本身就是软导航进入该 segment 时的树内边界,两者兼顾。复用真正覆盖你所发布导航的那个边界;分歧点以下,loading.tsx 与同目录骨架对那个用途是可互换的。若组件没有骨架,把它的加载标记抽成同目录骨架放旁边。不要新写一份镜像整页布局的骨架:它复制结构、随页面变化漂移,还会把设计拽回单条粗糙边界;复用组件自己的骨架还能让预取壳与加载后的 UI 保持一致(详见 test-template.mdreference/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 数据无法下推时(例如整页都依赖 paramssearchParams 或完整 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-testidpage.getByTestId('<trigger>-link'))而不是猜无障碍名;无论用哪种,触发器和标记都必须能对测试用户稳定解析。注意:instant() 会在嵌套调用或 base URL 未知时抛错,但不会在锁代码缺失时抛错——这正是 A 阶段与自验证变体存在的原因。

  • 硬导航 → 在 instant() 内用baseURL 选项page.goto()instant() 运行时 page 仍是 about:blankresolveURL 只在没传 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 目录下同名错误文档):

  1. 顶层 await → 把 await 移进 Suspense 子组件。请求期数据在页面/layout 顶层的 await 会让其下一切变动态;把 params promise 传下去、在 <Suspense> 包裹的子组件里 await(含不解构单独组件的 props.params.then(...) 内联变体)。
  2. layout 里 cookies()/headers() → 先发起、不 await、向下传const cookieStore = cookies() 不阻塞壳,把 promise 交给 Suspense 子组件去 await;{children} 与导航留在壳内。
  3. 未缓存 fetch / DB 读 → 按数据源选 use cache<Suspense>。对所有人相同且少变的数据用 'use cache'(进壳);按请求且必须新鲜的留在边界后流入。裸 'use cache' 应用 default cacheLife 配置——要用 cacheLife('<profile>')default/seconds/minutes/hours/days/weeks/max)显式选择新鲜度而不是默认时长发版;无服务器注意 use cache 是内存态、跨实例不持久。
  4. 动态 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 枚举(每个根参数至少一个值)才能进壳。
  5. searchParams → 永远包进 <Suspense>(页面加载路径)。搜索参数构建期永不可知;静态内容留壳、消费方流入。客户端导航已有 URL,useSearchParams() 消费方可同步解析进预取壳——但仍需边界兜住整页加载路径。
  6. 非确定性值 → connection() + <Suspense>,或缓存Math.random()/Date.now()/crypto.randomUUID() 每次输出不同:按请求的值 await connection() 后包进 Suspense(<Suspense fallback={null}>);对所有人同值就 'use cache' 让它进壳。
  7. 动态 generateMetadata → 静态导出、use cache、或加 dynamic-marker。读 cookies/headers 就保持 generateMetadata 动态并在页内加一个 await connection() 后返回 nullDynamicMarker 组件,使其余页面照常预渲染进壳。generateViewport 同理,但动态 viewport 会阻塞整页export const instant = false<body> 之上的 <Suspense> 都是「接受动态」的退出选项,不是即时修复。
  8. LCP 元素留在壳内。主标题别埋进边界——它要随壳上屏而不是等流。
  9. 共享 layout 之下的颗粒度。根 layout 的单条边界过得了整页检查,却让兄弟客户端导航继续阻塞;把边界放在共享 layout 之下(/store/shoes → /store/hats 这类软导航只有低层边界覆盖)。优先页面内逐组件边界而非一条大 layout 边界。
  10. 无法移动的 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.tspartialPrefetching: 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

文件索引

仓库侧的源码依据:instant() 的 cookie 锁协议见 packages/next-playwright/src/index.tscacheComponentspartialPrefetchingexposeTestingApiInProductionBuild 的配置定义见 packages/next/src/server/config-shared.ts;锁的客户端实现位于 packages/next/src/client/components/segment-cache/(含 .disabled.ts 空实现变体,正说明缺测试 API 时锁不咬合);各类阻塞源对应的官方解释都在 errors 目录按名可查。

登录后查看全文
热门项目推荐
相关项目推荐