首页
/ Next.js 即时导航测试指南:使用 @next/playwright 的 instant() 验证 Instant Navigation 路由结构

Next.js 即时导航测试指南:使用 @next/playwright 的 instant() 验证 Instant Navigation 路由结构

2026-09-07 23:34:08作者:钟日瑜

本文是一份面向 Next.js 开发者的实战技术指南,聚焦官方测试辅助包 @next/playwright 提供的 instant() API(Instant Navigation Testing,即时导航测试)。通过阅读本文,你将理解"即时导航(instant navigation)"与"缓存 Shell"的关系,掌握如何用 instant() 对路由做确定性断言、在 production 构建中如何显式开启测试接口,以及这套机制在客户端路由与 Node.js 服务端两侧的真实实现原理(cookie 协议、内存级导航锁与 fetch 拦截)。

注意:该 API 当前标注为 Experimental(实验性),接口尚不稳定;并且依赖 Cache Components 已启用。仓库现状与说明以当前仓库 packages/next-playwright 为准。

什么是 Instant Navigation Testing

Next.js 的客户端路由在导航时会优先渲染"缓存 Shell"——即已预取(prefetch)好的静态框架,包括任何 Suspense 加载边界(loading boundary)。真正的即时导航(instant navigation)指的是:点击链接后不等待数据请求完成就立刻提交(commit)页面,Shell 立即呈现,动态数据随后以流式(streaming)方式补充进来。换句话说,"即时"的是 Shell,而不是整页内容

从源码注释(navigation-testing-lock.ts)可以看到,这套"锁"机制的目的就是模拟"热缓存(warm cache)"下的用户体验:当测试锁被持有时,目标路由启用部分预取(partial prefetching),导航只允许命中缓存中的 Shell 条目;只有显式使用 <Link prefetch={true}> 触发整条路由的投机式预取(speculative prefetch)时,才允许命中携带具体参数的真实数据条目(对应函数 shouldRestrictNavigationToShell)。

因此,测试的核心理念是:验证路由是否被"结构化"得正确use cache 指令有没有写对位置、Suspense 边界放没放对地方、预取配置是否正确),而不是受网络抖动影响的"碰运气"测试。

安装与前置条件

@next/playwright 是 monorepo 内的独立包,其 package.json 声明:

  • 包名 @next/playwright,入口为 dist/index.js,类型声明为 dist/index.d.ts
  • @playwright/test>=1.0.0)为 optional peerDependency——也就是说它兼容任意版本的 playwrightplaywright-core@playwright/test。源码 index.ts 特意采用了**结构化类型(structural types)**而非直接 import 某个 Playwright 版本,从而保证版本无关性。

使用前请确认应用满足:

  1. 开启了 Cache Components(即 use cache 能力);
  2. 项目已安装 @playwright/test,并已通过 playwright.config 正确指定应用的 baseURL 与 webServer 启动方式。

核心 API:instant(page, callback, options?)

instant() 的完整签名与约束如下(依据 index.ts):

export async function instant<T>(
  page: PlaywrightPage,
  fn: () => Promise<T>,
  options?: { baseURL?: string }
): Promise<T>

关键行为与限制:

  • 作用域内只渲染缓存与已预取内容:在回调执行期间发生的导航,只渲染已缓存的 Shell 与预取数据,动态数据被延迟到回调结束之后;因此可以在回调内对 Shell 做无竞态(race-free)的确定性断言
  • 热缓存假设:工具假定此时所有预取已完成、所有可缓存数据已就绪。如果回调内出现你预期应被缓存的内容缺失,恰恰说明路由结构有问题——比如漏掉了 use cache 指令、Suspense 边界放错位置等。
  • 不允许嵌套:同一个 Browser Context 上若已存在进行中的 instant() 作用域,再次调用会抛出明确错误 An instant() scope is already active. Nesting instant() calls is not supported...。实现上通过进程内的 WeakSet 跟踪当前拥有作用域的 context(index.ts),从而保证不同浏览器/不同 context 之间彼此隔离、互不冲突。
  • URL 解析规则(对应 resolveURLindex.ts):
    • 优先使用 options.baseURL
    • 否则回退到 page.url()(页面已加载过即可自动推断);
    • 若页面还是全新的 about:blank 且未传 baseURL,会抛出带指引的报错:建议在测试中通过 fixture 直接取 baseURL 传入,或先 page.goto(...) 再调用 instant()

@playwright/test 已安装且运行在 Playwright 测试运行器内时,acquire/release 动作会被包装成带标签的 step("Acquire Instant Lock" / "Release Instant Lock")显示在 Playwright UI 中;若运行在 Jest 等非 Playwright runner 下则自动退化为直接执行(见 step.ts)。

例一:验证加载 Shell 即时出现(动态内容在 Suspense 边界之后)

import { instant } from '@next/playwright'

test('shows loading shell during navigation', async ({ page }) => {
  await page.goto('/')

  await instant(page, async () => {
    await page.click('a[href="/dashboard"]')

    // 加载 Shell 立即可见 —— 动态数据被推迟
    await expect(page.locator('[data-testid="loading"]')).toBeVisible()
  })

  // instant() 返回后,动态数据正常流式进入
  await expect(page.locator('[data-testid="content"]')).toBeVisible()
})

例二:验证完全即时导航(所有内容均命中缓存)

test('navigates to profile instantly', async ({ page }) => {
  await page.goto('/')

  await instant(page, async () => {
    await page.click('a[href="/profile"]')

    // 所有内容立即渲染
    await expect(page.locator('[data-testid="profile-name"]')).toBeVisible()
    await expect(page.locator('[data-testid="profile-bio"]')).toBeVisible()
  })
})

例三:首次进入页面时显式传入 baseURL

test('my test', async ({ page, baseURL }) => {
  await instant(page, async () => {
    // ...
  }, { baseURL })
})

由于 cookie 需要在首次页面加载之前就按正确域名写入,若页面尚未经过任何导航,必须通过 baseURL(通常直接取自 Playwright config 的 fixture)显式指定作用域域名。

在 production 构建中开启测试接口

在开发模式(next dev)下该测试 API 默认可用;但在 production 构建中默认禁用,必须显式开启:

// next.config.js
module.exports = {
  experimental: {
    exposeTestingApiInProductionBuild: true,
  },
}

服务端判定逻辑见 base-server.tsexposeTestingApithis.dev === true 或该 experimental 开关为真时为真。这里需要特别注意两点:

  • 不要部署到线上生产站点,只应在受控的测试环境开启,例如预览部署(preview deployments)或 CI。
  • 未开启时,打包器会把浏览器端锁模块解析为空实现 navigation-testing-lock.disabled.ts,该文件每个导出函数都返回"无锁"时的等价结果(如 isNavigationLocked() 恒为 false),从而保证锁机制相关代码完全不会随浏览器 bundle 下发——别名替换发生在 webpack 的 create-compiler-aliases.ts 与 Turbopack 的 next_import_map.rs

工作机制:cookie 协议与导航锁

instant() 的整套机制极简——本质上就是一个 cookie

// 设置 cookie,进入 instant 模式
document.cookie = 'next-instant-navigation-testing=1; path=/'

// …… 运行断言 ……

// 清除 cookie,恢复正常行为
document.cookie = 'next-instant-navigation-testing=; path=/; max-age=0'

cookie 名常量定义在 app-router-headers.tsNEXT_INSTANT_TEST_COOKIE = 'next-instant-navigation-testing'instant() 在设置 cookie 时会对值做特殊编码(JSON.stringify([0, 'p' + Math.random()])index.ts),而非简单的 1——因为 cookie 值在客户端与服务端之间还要经历多次状态迁移(见下文)。

客户端导航

当 cookie 生效时,路由器只渲染预取缓存中已有(available)的内容,动态数据推迟到 cookie 清除后才写入。cookie 变化经由浏览器的 CookieStore change 事件被 navigation-testing-lock.ts 中的 startListeningForInstantNavigationCookie 捕获,触发一次内存级导航锁的 acquire/release。

从源码看,这个锁承载了非常精细的状态机,cookie 值会在以下几类状态间迁移(parseCookieValuenavigation-testing-lock.ts):

  • [0, ...]pending(待处理),由外部测试工具(Playwright / devtools 的 Navigation Inspector)写入,表示开启一个锁作用域;
  • [1, ..., null]captured-MPA,页面以 shell 形式被服务端渲染(多页应用式整页加载)后由客户端自写;
  • [1, ..., { from, to }]captured-SPA,锁内导航预取 resolve 后确认是 SPA 导航时写入(updateCapturedSPAToTreenavigation-testing-lock.ts)。

锁生效期间还做了三件关键的事:

  1. window.fetch 拦截:非 Next.js 内部的"带外(out-of-band)"客户端 fetch(例如 useEffect 里的 fetch('/api/data'))会被阻塞,直到锁释放后才通过释放前捕获的原生 fetch 派发(globalFetchOverridenavigation-testing-lock.ts)。dev-server 内部请求(/__nextjs_ 前缀的 error overlay、source map 等)则不受阻塞。
  2. 私有 segment 缓存:锁内调度的预取任务绑定到一个专用的空缓存 Map 而非共享缓存,保证每次 instant() 导航都是一次"干净读取(clean read)",不会命中先前导航或预取留下的陈旧条目(见 NavigationLockState.segmentCacheMap 注释,navigation-testing-lock.ts)。
  3. 按导航粒度暂存动态数据:锁内同一时刻只有最近一次导航的动态数据保持 withheld;新的锁内导航开始(beginLockedNavigation)或锁释放时,前一次导航被暂存的数据才被释放写入,从而避免被复用的 segment 悬挂在未决的 deferred promise 上(navigation-testing-lock.ts)。刷新(refresh)、Server Action、Server Patch 等未开启新导航的写入,则等待其发起时刻所处导航的门闩(getCurrentNavigationGate)。

服务端渲染(首屏 / 刷新 / MPA 导航)

当 cookie 存在时,服务端只响应静态 Shell,不包含任何按请求(per-request)生成的动态数据。服务端判定见 base-server.ts:仅对 document 请求(无 RSC 头) 生效——RSC 请求即使在锁作用域内也正常处理,阻塞动作统一放在客户端完成。此外该判定还会校验 cookie 头包含 next-instant-navigation-testing=,且要求路由具备 PPR 支持(couldSupportPPR)。若页面具备 PARTIALLY_STATIC 渲染模式或处于开发/测试接口暴露状态,路由即按 PPR 启用处理(base-server.ts)。

回调完成后,cookie 被清除,锁释放(releaseLock 恢复被拦截的 window.fetch、强制 resolve 所有未完成的预取与暂存数据门闩),随后一切恢复正常行为。

释放 cookie 时的一段工程细节

值得留意的是,释放动作刻意避开了 Playwright 的 context.clearCookies({ name })——因为 Playwright 对该过滤式清理的实现是"清空整个 cookie jar 再重新添加不匹配项",这会造成瞬时清空应用自身 cookie 的空窗;而 Next.js 一检测到测试 cookie 被删除就会立即重渲染,若该渲染恰好撞上空窗,就会把页面渲染成"好像没有设置任何 cookie"。因此 releaseInstantCookie 的做法是:读出该 cookie 的条目并逐个用过期时间(expires: 1)重写以达到精准删除,且最多重试 5 次以对抗"锁定的 MPA 页面在删除后异步重写(复活)cookie"的竞态。此外释放后模块还会执行 refreshOnInstantNavigationUnlock() 做一次刷新,将暂存数据正确落位。

设计哲学:可被任何框架复制的薄协议

@next/playwright 与 Next.js 之间的分层刻意保持得很薄(README 的 Design 一节对此有明确说明):它的定位是参考实现(reference implementation),供其他测试框架与开发者工具以最小成本复刻。整个机制可以被压缩成一个 cookie,任何工具都只要做到:

  1. 在导航开始前把 next-instant-navigation-testing cookie(pending 值)写入目标域名;
  2. 在断言期间保持 cookie 存在;
  3. 结束后删除 cookie。

剩余的复杂度(锁状态机、fetch 拦截、私有 segment 缓存、PPR shell 判定)全部收敛在 Next.js 运行时的 navigation-testing-lock.tsbase-server.ts 中,对测试工具完全透明。

常见问题排查速查

现象 可能原因 处理方式
回调内 loading Shell 不可见 动态内容未被 Suspense 边界包裹,或页面整体动态渲染 调整路由结构:将动态数据放入 Suspense / use cache 边界之后
回调内本应缓存的内容缺失 漏写 use cache 指令、Suspense 边界位置错误、预取未完成 检查路由的数据边界划分,确认预取链路正常
报错 Could not infer the base URL... 页面尚未导航且未传 baseURL 从 test fixture 取 baseURL 传入,或先 page.goto()
报错 Nesting instant() calls is not supported 上一个 instant() 未被 await 确认每个 instant() 都已 await
功能在 CI 的 production 构建中无效 未开启 exposeTestingApiInProductionBuild 在 CI/预览环境显式开启该 experimental 配置

小结

@next/playwrightinstant() 把"路由是否具备即时导航能力"这一主观体验,转译成了可被 CI 反复执行的确定性断言。它依托"一个 cookie + 客户端内存导航锁 + 服务端 PPR shell 响应"的薄协议,让你在热缓存前提下验证路由的静态骨架是否结构化正确。由于该能力仍处于 Experimental 阶段且依赖 Cache Components,建议在受控的测试环境(而非线上)使用,并持续关注仓库 packages/next-playwright 后续的 API 演进。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391