diff --git a/.agents/skills/gate-tests/SKILL.md b/.agents/skills/gate-tests/SKILL.md
new file mode 100644
index 000000000000..c6d25be694a0
--- /dev/null
+++ b/.agents/skills/gate-tests/SKILL.md
@@ -0,0 +1,194 @@
+---
+name: gate-tests
+description: >
+ How to use the `@gate` / `@force-gate` test directives instead of `it.skip`
+ or fake-green skip patterns. Use when a test is known-failing under some
+ test-matrix dimension (dev mode, a bundler, an experimental flag like
+ cacheComponents), when converting `if (isNextDev) return` guards or
+ env-var `describe.skip` branches, when adding a condition to
+ test/lib/gate/conditions.ts, or when keying a fixture's experimental flag
+ on a __NEXT_TEST_AXIS letter. Covers directive choice, condition tiers,
+ the test-axis fixture pattern, pitfalls, and verification commands.
+user-invocable: false
+metadata:
+ internal: true
+---
+
+# Gating tests with `@gate` / `@force-gate`
+
+Full reference: [`test/lib/gate/README.md`](../../../test/lib/gate/README.md).
+This skill is the decision guide: which directive to reach for, the standard
+conversion patterns, and how to verify.
+
+## Never write these — gate instead
+
+| Anti-pattern | Replacement |
+| ---------------------------------------------------------------------- | --------------------------------------------------------------------------- |
+| `it.skip('...')` for a known failure | `// @gate ` (or `@gate FIXME` if no condition explains it) |
+| `if (isNextDev) { test('skipped in dev mode', () => {}); return }` | `// @force-gate prefetching` (or `!dev`) on the `describe` |
+| `(flagEnabled ? describe.skip : describe)(...)` keyed on `process.env` | `// @force-gate ` (lazy) on the `describe` |
+| Duplicating a fixture directory per flag state | one fixture keyed on `__NEXT_TEST_AXIS` + a `@gate`/`@force-gate` |
+| Branching expectations on `process.env.__NEXT_CACHE_COMPONENTS` | `if (await gate((c) => c.cacheComponents))` (`gate` from `next-test-utils`) |
+
+The skip patterns are fake-greens: nothing tells you when the bug they hide is
+fixed. `@gate` still runs the body and fails the suite the day the "known
+failure" starts passing, so stale workarounds get deleted instead of rotting.
+
+## Choosing the directive
+
+Ask what kind of difference you're encoding:
+
+1. **A behavior change — both states assert something meaningful.** Don't
+ gate the test at all: fork inside the body with the runtime `gate()` —
+ same condition registry, no inversion — which pinpoints exactly what
+ differs, and also covers `it.each`, where a pragma cannot attach:
+ `if (await gate((c) => c.cacheComponents)) { ... } else { ... }`. It
+ mirrors React's `gate(flags => ...)`; a pragma expression string works
+ too (`await gate('cacheComponents && !dev')`). A suite-level pragma is
+ too coarse here — it hides _what_ is different between the states.
+2. **A flag that changes the behavior of existing surface**
+ (`cacheComponents`, `optimisticRouting`) **and the suite is written for one
+ state.** `// @gate ` on the test or `describe`. The body runs; a
+ false condition inverts the expectation (failure absorbed, a pass fails as
+ "stale gate"). The off state fails for a meaningful reason — the behavior
+ differs — so a pass is real information: the gate is stale, delete it.
+3. **A new API — the off state proves nothing.** Typically
+ `// @force-gate ` (lazy) on the `describe`. An API that throws when
+ its flag is off — or is inert, like `useOffline()`, which compiles to a
+ hook that always reports online — can only fail vacuously (often slowly,
+ by timing out), and browser e2e time is considerable, so skip the run
+ (and the fixture build) instead of paying for it. Working example:
+ `test/e2e/app-dir/use-offline/`. This is discretion, not a rule: when the
+ flag changes behavior the suite can observe, the off state is meaningful
+ and `@gate` buys the staleness check.
+4. **`@force-gate ` also when running the body is impossible**, not
+ merely failing: prefetching is off in dev, deploy has no local build
+ output, the fixture cannot even build under the condition.
+ - Static condition (`!dev`, `bundler`…) → real Jest `○ skipped` at
+ collection.
+ - Lazy condition on a `describe` → the fixture **build is skipped** when
+ false; tests report passed-with-`⚠ skipped by @force-gate` (Jest cannot
+ skip at runtime). Build-skipping covers `start`/`dev` suites where
+ `nextTestSetup` owns the build — not `skipStart` suites, not deploy.
+5. Pragmas stack: a common pair is a static `// @force-gate prefetching` plus
+ a lazy `// @gate ` on the same `describe`.
+
+### Is the off-state run worth its cost?
+
+Browser e2e time is not free, so weigh what the gated-off run buys. For a
+behavior flag it usually replaces a run that was already being paid for — a
+fixture that pins its flags runs identically with and without the axis set,
+so keying the flag on an axis converts a redundant duplicate into coverage —
+and it is what proves a pass isn't vacuous: a test that passes with the
+feature off wasn't testing the feature. Absorbed failures also fail fast, so
+the off-state run is cheaper than it sounds. For a new API the calculus
+flips: the off state can only throw, which proves nothing, so use a lazy
+`// @force-gate ` on the `describe` — the fixture build is skipped
+too, so the off state costs almost nothing.
+
+## Conditions
+
+Every name in a pragma must be declared in `test/lib/gate/conditions.ts`
+(typos fail the suite at collection). Two tiers:
+
+- **static** — the run's shape: `dev`, `start`, `deploy`, `mode`, `turbopack`,
+ `rspack`, `webpack`, `bundler`, `react18`, `wasm`, `ci`, plus the
+ always-false `FIXME`/`TODO`. `prod` and `prefetching` are semantic aliases
+ for `!dev` — prefer the name that states _why_ the suite cannot run.
+- **lazy** — a predicate over the fixture's _resolved_ `next.config`
+ (`cacheComponents`, `ppr`, `useOffline`, `output`, …).
+
+Adding one is a two-line change; follow the guidance at the top of
+`conditions.ts`. The rule that matters: **lazy conditions read the resolved
+config, never `process.env`** — env vars don't survive config resolution
+(`__NEXT_CACHE_COMPONENTS` only applies when the fixture doesn't set
+`cacheComponents` itself, and resolution implies flags the fixture never
+mentions).
+
+## Pattern: cover both states of an experimental flag
+
+Instead of pinning a flag on (which makes the plain and axis runs identical),
+key it on a test axis and gate the suite. Axes are lettered (`A`, `B`, …) —
+a fixed enumeration, not a boolean and not a sharding bucket. Key the flag so
+it is **enabled by default** — then the suite exercises the feature in plain
+local runs with no special env, and the axis run covers the off state:
+
+```js
+// next.config.js — pin every dimension except the one under test
+const nextConfig = {
+ cacheComponents: true,
+ experimental: {
+ concurrentRouterQueue: process.env.__NEXT_TEST_AXIS !== 'A',
+ },
+}
+```
+
+```ts
+// @gate concurrentRouterQueue
+it('fails loudly on link navigation', async () => { ... })
+```
+
+The plain run exercises the feature; the axis-A run covers the off state —
+the gated tests are expected to fail there, and the suite fails the day they
+start passing. Working example: `test/e2e/app-dir/concurrent-router-queue/`
+(tests whose expectations hold in both states stay ungated). The same keying
+pairs with a lazy `@force-gate` when the off state proves nothing —
+`test/e2e/app-dir/use-offline/` — which skips the redundant axis run (build
+included) instead of covering it. Axis `A` aliases `__NEXT_CACHE_COMPONENTS`
+for now (see `scripts/run-jest.sh`) — fine, because these fixtures pin
+`cacheComponents` explicitly, so that run's env default is a no-op for them.
+
+**Keep exactly one flag varying per fixture.** A red shard must attribute to a
+single dimension.
+
+## Pitfalls
+
+- A pragma the transform can't attach is a **hard error**: a blank line
+ between pragma and `it(`, `it.each`/`it.failing`, or a pragma inside a
+ JSDoc block. Prose comments must not begin with `@gate`. A pragma on a
+ skipped test (`it.skip`, `xit`, …) errors as ambiguous — remove the skip or
+ the pragma. A skip without a pragma is respected.
+- A `describe`-level gate does not reach `it.each` tests.
+- Gated-false bodies that fail by _stalling_ waste the full Jest timeout —
+ and under a lazy gate they fail the suite anyway (the runtime inversion
+ only absorbs thrown errors; a static gate rides Jest's native
+ `test.failing`, which does absorb timeouts). Bodies that fail via `retry()`
+ timeouts also make the off-state run slow; a fast first assertion is worth
+ having.
+- Failures cascade in the off state: an absorbed failure mid-body skips the
+ body's cleanup (e.g. a browser context left offline), so later tests may
+ fail for cascade reasons. Acceptable for a tripwire, but don't puzzle over
+ the individual failure messages in a gated-off run.
+- `afterEach` failures (e.g. redbox matchers) are not gated — only the body is.
+- `jest.retryTimes(1)` on non-dev CI means a _flaky_ gated-false test passes
+ whenever it happens to fail; the tripwire is only deterministic for
+ deterministic tests.
+- Gated titles are unchanged in the Jest output; the
+ `⚠ gated test failed as expected` log line is the only signal.
+- `pragma-transform.js` bails out early on files containing neither `@gate`
+ nor `@force-gate` as substrings — keep both checks if you touch it.
+
+## Verify a gated suite in every state it can run in
+
+```sh
+# plain run (flag on): expect normal passes, no warnings
+NEXT_SKIP_ISOLATE=1 pnpm test-start-webpack test/e2e/app-dir//.test.ts
+
+# axis run (flag off): expect `⚠ gated test failed as expected (@gate …)`
+__NEXT_TEST_AXIS=A NEXT_SKIP_ISOLATE=1 pnpm test-start-webpack test/e2e/app-dir//.test.ts
+
+# dev (static @force-gate !dev): expect `○ skipped` at collection, no fixture boot
+NEXT_SKIP_ISOLATE=1 pnpm test-dev-webpack test/e2e/app-dir//.test.ts
+```
+
+A suite with a lazy `@force-gate` on the `describe` should additionally show
+`skipping build` behavior (no `next build`) in the state where the condition
+is false.
+
+Unit tests for the infrastructure itself: `pnpm test-unit test/unit/gate/`.
+
+## Related skills
+
+- `$flags` — adding the experimental flag itself (config-shared, schema,
+ define-env)
+- `$router-act` — the prefetch-timing patterns most gated suites also use
diff --git a/AGENTS.md b/AGENTS.md
index 4642ba43525f..1b82298679e6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -438,6 +438,7 @@ Use skills for conditional, deep workflows. Keep baseline iteration/build/test p
- `$react-sync` - build a local React checkout and sync it into Next.js for testing
- `$runtime-debug` - runtime-bundle/module-resolution regression reproduction and verification
- `$next-rspack` - @next/rspack-core and @next/rspack-binding maintenance (rspack/ directory)
+- `$gate-tests` - `@gate`/`@force-gate` test directives: replacing `it.skip`/fake-green skips, conditions, variant-shard fixtures
- `$authoring-skills` - how to create and maintain skills in `.agents/skills/`
## Context-Efficient Workflows
diff --git a/crates/next-custom-transforms/src/lib.rs b/crates/next-custom-transforms/src/lib.rs
index 0e2a59fd7283..2ed9719c32a0 100644
--- a/crates/next-custom-transforms/src/lib.rs
+++ b/crates/next-custom-transforms/src/lib.rs
@@ -28,7 +28,7 @@ DEALINGS IN THE SOFTWARE.
#![recursion_limit = "2048"]
#![deny(clippy::all)]
-#![feature(box_patterns)]
+#![feature(deref_patterns)]
#![feature(arbitrary_self_types)]
#![feature(arbitrary_self_types_pointers)]
diff --git a/crates/next-custom-transforms/src/transforms/import_analyzer.rs b/crates/next-custom-transforms/src/transforms/import_analyzer.rs
index fcc553951c78..e7a132fb2af4 100644
--- a/crates/next-custom-transforms/src/transforms/import_analyzer.rs
+++ b/crates/next-custom-transforms/src/transforms/import_analyzer.rs
@@ -34,7 +34,7 @@ impl ImportMap {
}
Expr::Member(MemberExpr {
- obj: box Expr::Ident(obj),
+ obj: Expr::Ident(obj),
prop: MemberProp::Ident(prop),
..
}) => {
diff --git a/crates/next-custom-transforms/src/transforms/optimize_server_react.rs b/crates/next-custom-transforms/src/transforms/optimize_server_react.rs
index 6a503edf85f0..0c099d1c0e9b 100644
--- a/crates/next-custom-transforms/src/transforms/optimize_server_react.rs
+++ b/crates/next-custom-transforms/src/transforms/optimize_server_react.rs
@@ -51,7 +51,7 @@ fn effect_has_side_effect_deps(call: &CallExpr) -> bool {
if let Expr::Array(arr) = &*call.args[1].expr {
for elem in arr.elems.iter().flatten() {
if let ExprOrSpread {
- expr: box Expr::Call(_),
+ expr: Expr::Call(_),
..
} = elem
{
@@ -137,7 +137,7 @@ impl Fold for OptimizeServerReact {
fn fold_expr(&mut self, expr: Expr) -> Expr {
if let Expr::Call(call) = &expr {
- if let Callee::Expr(box Expr::Ident(f)) = &call.callee {
+ if let Callee::Expr(Expr::Ident(f)) = &call.callee {
// Mark `useEffect` as DCE'able
if let Some(use_effect_ident) = &self.use_effect_ident
&& &f.to_id() == use_effect_ident
@@ -154,7 +154,7 @@ impl Fold for OptimizeServerReact {
return wrap_expr_with_env_prod_condition(call.clone());
}
} else if let Some(react_ident) = &self.react_ident
- && let Callee::Expr(box Expr::Member(member)) = &call.callee
+ && let Callee::Expr(Expr::Member(member)) = &call.callee
&& let Expr::Ident(f) = &*member.obj
&& &f.to_id() == react_ident
&& let MemberProp::Ident(i) = &member.prop
@@ -179,8 +179,8 @@ impl Fold for OptimizeServerReact {
if let Pat::Array(array_pat) = &decl.name
&& array_pat.elems.len() == 2
- && let Some(box Expr::Call(call)) = &decl.init
- && let Callee::Expr(box Expr::Ident(f)) = &call.callee
+ && let Some(Expr::Call(call)) = &decl.init
+ && let Callee::Expr(Expr::Ident(f)) = &call.callee
&& let Some(use_state_ident) = &self.use_state_ident
&& &f.to_id() == use_state_ident
&& call.args.len() == 1
diff --git a/crates/next-custom-transforms/src/transforms/server_actions.rs b/crates/next-custom-transforms/src/transforms/server_actions.rs
index 277532b658df..185a15c2a5e5 100644
--- a/crates/next-custom-transforms/src/transforms/server_actions.rs
+++ b/crates/next-custom-transforms/src/transforms/server_actions.rs
@@ -1567,15 +1567,15 @@ impl VisitMut for ServerActions {
let old_current_export_name = self.current_export_name.take();
match n {
- PropOrSpread::Prop(box Prop::KeyValue(KeyValueProp {
+ PropOrSpread::Prop(Prop::KeyValue(KeyValueProp {
key: PropName::Ident(ident_name),
- value: box Expr::Arrow(_) | box Expr::Fn(_),
+ value: Expr::Arrow(_) | Expr::Fn(_),
..
})) => {
self.current_export_name = None;
self.arrow_or_fn_expr_ident = Some(ident_name.clone().into());
}
- PropOrSpread::Prop(box Prop::Method(MethodProp { key, .. })) => {
+ PropOrSpread::Prop(Prop::Method(MethodProp { key, .. })) => {
let key = key.clone();
if let PropName::Ident(ident_name) = &key {
@@ -1603,7 +1603,7 @@ impl VisitMut for ServerActions {
if !self.in_module_level
&& self.should_track_names
- && let PropOrSpread::Prop(box Prop::Shorthand(i)) = n
+ && let PropOrSpread::Prop(Prop::Shorthand(i)) = n
{
self.names.push(Name::from(&*i));
self.should_track_names = false;
@@ -1686,7 +1686,7 @@ impl VisitMut for ServerActions {
}
fn visit_mut_call_expr(&mut self, n: &mut CallExpr) {
- if let Callee::Expr(box Expr::Ident(Ident { sym, .. })) = &mut n.callee
+ if let Callee::Expr(Expr::Ident(Ident { sym, .. })) = &mut n.callee
&& (sym == "jsxDEV" || sym == "_jsxDEV")
{
// Do not visit the 6th arg in a generated jsxDEV call, which is a `this`
@@ -2837,7 +2837,7 @@ impl VisitMut for ServerActions {
(&attr.value, &attr.name)
{
match &container.expr {
- JSXExpr::Expr(box Expr::Arrow(_)) | JSXExpr::Expr(box Expr::Fn(_)) => {
+ JSXExpr::Expr(Expr::Arrow(_)) | JSXExpr::Expr(Expr::Fn(_)) => {
self.arrow_or_fn_expr_ident = Some(ident_name.clone().into());
}
_ => {}
@@ -2852,7 +2852,7 @@ impl VisitMut for ServerActions {
let old_current_export_name = self.current_export_name.take();
let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.take();
- if let (Pat::Ident(ident), Some(box Expr::Arrow(_) | box Expr::Fn(_))) =
+ if let (Pat::Ident(ident), Some(Expr::Arrow(_) | Expr::Fn(_))) =
(&var_declarator.name, &var_declarator.init)
{
if self.in_module_level
@@ -3301,7 +3301,7 @@ fn has_body_directive(maybe_body: &Option) -> (bool, bool) {
for stmt in body.stmts.iter() {
match stmt {
Stmt::Expr(ExprStmt {
- expr: box Expr::Lit(Lit::Str(Str { value, .. })),
+ expr: Expr::Lit(Lit::Str(Str { value, .. })),
..
}) => {
if value == "use server" {
@@ -3440,7 +3440,7 @@ impl DirectiveVisitor<'_> {
match stmt {
Stmt::Expr(ExprStmt {
- expr: box Expr::Lit(Lit::Str(Str { value, span, .. })),
+ expr: Expr::Lit(Lit::Str(Str { value, span, .. })),
..
}) => {
if value == "use server" {
@@ -3575,8 +3575,8 @@ impl DirectiveVisitor<'_> {
}
Stmt::Expr(ExprStmt {
expr:
- box Expr::Paren(ParenExpr {
- expr: box Expr::Lit(Lit::Str(Str { value, .. })),
+ Expr::Paren(ParenExpr {
+ expr: Expr::Lit(Lit::Str(Str { value, .. })),
..
}),
span,
@@ -3668,7 +3668,7 @@ impl VisitMut for ClosureReplacer<'_> {
fn visit_mut_prop_or_spread(&mut self, n: &mut PropOrSpread) {
n.visit_mut_children_with(self);
- if let PropOrSpread::Prop(box Prop::Shorthand(i)) = n {
+ if let PropOrSpread::Prop(Prop::Shorthand(i)) = n {
let name = Name::from(&*i);
if let Some(index) = self.used_ids.iter().position(|used_id| *used_id == name) {
*n = PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
diff --git a/jest.config.js b/jest.config.js
index 19ab0ab0485f..746ae096c73e 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,4 +1,5 @@
const nextJest = require('next/jest')
+const { withGateTransformer } = require('./test/lib/gate/jest-transformer')
const createJestConfig = nextJest()
@@ -89,4 +90,11 @@ if (enableTestReport) {
}
// createJestConfig is exported in this way to ensure that next/jest can load the Next.js config which is async
-module.exports = createJestConfig(customJestConfig)
+const createConfig = createJestConfig(customJestConfig)
+
+module.exports = async function createConfigWithGates() {
+ // `withGateTransformer` chains the `@gate` pragma rewrite in front of the
+ // SWC transformer that `next/jest` configured, keeping next/jest's SWC
+ // options as the single source of truth. See test/lib/gate/.
+ return withGateTransformer(await createConfig())
+}
diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts
index dfbf51a64573..45fef9364fa4 100644
--- a/packages/next/src/server/app-render/dynamic-rendering.ts
+++ b/packages/next/src/server/app-render/dynamic-rendering.ts
@@ -355,7 +355,7 @@ export function abortAndThrowOnSynchronousRequestDataAccess(
// promise (see makeRuntimeHangingPromise). Unlike
// `abortOnSynchronousPlatformIOAccess`, which aborts a runtime prerender
// all the same and therefore must not record anything.
- trackRuntimeDataAccessed(prerenderStore)
+ trackRuntimeDataAccessed(prerenderStore, expression)
const prerenderSignal = prerenderStore.controller.signal
if (prerenderSignal.aborted === false) {
diff --git a/packages/next/src/server/dynamic-rendering-utils.test.ts b/packages/next/src/server/dynamic-rendering-utils.test.ts
new file mode 100644
index 000000000000..46c961faae2c
--- /dev/null
+++ b/packages/next/src/server/dynamic-rendering-utils.test.ts
@@ -0,0 +1,114 @@
+import { createPromiseWithResolvers } from '../shared/lib/promise-with-resolvers'
+import { trackPromiseUsed } from './dynamic-rendering-utils'
+
+describe('trackPromiseUsed', () => {
+ it('`then` is tracked and forwards the value', async () => {
+ const underlying = createPromiseWithResolvers()
+
+ const onUse = jest.fn()
+
+ const trackedPromise = trackPromiseUsed(underlying.promise, onUse)
+ void trackedPromise.then // accessing the property does not count as a usage
+ expect(onUse).toHaveBeenCalledTimes(0)
+
+ const derived = trackedPromise.then((value) => {
+ return value
+ })
+ expect(onUse).toHaveBeenCalledTimes(1)
+ expect(derived).toBeInstanceOf(Promise)
+
+ const result = { foo: 'bar' }
+ underlying.resolve(result)
+
+ await expect(derived).resolves.toBe(result)
+ })
+
+ it('`catch` is tracked and forwards the error', async () => {
+ const underlying = createPromiseWithResolvers()
+
+ const onUse = jest.fn()
+
+ const trackedPromise = trackPromiseUsed(underlying.promise, onUse)
+ void trackedPromise.catch // accessing the property does not count as a usage
+ expect(onUse).toHaveBeenCalledTimes(0)
+
+ const derived = trackedPromise.catch((error) => {
+ return error
+ })
+ expect(onUse).toHaveBeenCalledTimes(1)
+ expect(derived).toBeInstanceOf(Promise)
+
+ const error = new Error('kaboom')
+ underlying.reject(error)
+
+ // Note: we're catching the error, so it resolves, not rejects
+ await expect(derived).resolves.toBe(error)
+ })
+
+ it('`finally` is tracked and forwards the value', async () => {
+ const underlying = createPromiseWithResolvers()
+
+ const onUse = jest.fn()
+
+ const trackedPromise = trackPromiseUsed(underlying.promise, onUse)
+ void trackedPromise.finally // accessing the property does not count as a usage
+ expect(onUse).toHaveBeenCalledTimes(0)
+
+ const onFinally = jest.fn()
+ const derived = trackedPromise.finally(onFinally)
+ expect(onUse).toHaveBeenCalledTimes(1)
+ expect(derived).toBeInstanceOf(Promise)
+
+ const result = { foo: 'bar' }
+ underlying.resolve(result)
+
+ await expect(derived).resolves.toBe(result)
+ expect(onFinally).toHaveBeenCalledTimes(1)
+ })
+
+ it('`finally` is tracked and forwards the error', async () => {
+ const underlying = createPromiseWithResolvers()
+
+ const onUse = jest.fn()
+
+ const trackedPromise = trackPromiseUsed(underlying.promise, onUse)
+ void trackedPromise.finally // accessing the property does not count as a usage
+ expect(onUse).toHaveBeenCalledTimes(0)
+
+ const onFinally = jest.fn()
+ const derived = trackedPromise.finally(onFinally)
+ expect(onUse).toHaveBeenCalledTimes(1)
+ expect(derived).toBeInstanceOf(Promise)
+
+ const error = new Error('kaboom')
+ underlying.reject(error)
+
+ await expect(derived).rejects.toBe(error)
+ })
+
+ it('native `await` is tracked', async () => {
+ const underlying = createPromiseWithResolvers()
+
+ const onUse = jest.fn()
+
+ const trackedPromise = trackPromiseUsed(underlying.promise, onUse)
+ expect(onUse).toHaveBeenCalledTimes(0)
+
+ const derived = (async () => {
+ const result = await trackedPromise
+ return result
+ })()
+
+ // Flush microtasks
+ await new Promise((resolve) =>
+ queueMicrotask(() => process.nextTick(resolve))
+ )
+
+ expect(onUse).toHaveBeenCalledTimes(1)
+
+ const result = { foo: 'bar' }
+ underlying.resolve(result)
+
+ await expect(derived).resolves.toBe(result)
+ })
+})
diff --git a/packages/next/src/server/dynamic-rendering-utils.ts b/packages/next/src/server/dynamic-rendering-utils.ts
index 33b30b78975d..5b2330507832 100644
--- a/packages/next/src/server/dynamic-rendering-utils.ts
+++ b/packages/next/src/server/dynamic-rendering-utils.ts
@@ -2,13 +2,13 @@ import {
RenderStage,
type AdvanceableRenderStage,
} from './app-render/staged-rendering'
+import { workAsyncStorage } from './app-render/work-async-storage.external'
import type {
RequestStore,
WorkUnitStore,
} from './app-render/work-unit-async-storage.external'
import { workUnitAsyncStorage } from './app-render/work-unit-async-storage.external'
import { getServerReact, getClientReact } from './runtime-reacts.external'
-import { ReflectAdapter } from './web/spec-extension/adapters/reflect'
export function isHangingPromiseRejectionError(
err: unknown
@@ -112,7 +112,7 @@ export function makeUntrackedHangingPromise(
* searchParams, and cache entries that are excluded only from static
* prerenders.
*
- * Creating one of these during a static prerender records on the prerender
+ * Awaiting one of these during a static prerender records on the prerender
* store that a runtime prefetch would produce more content than the static
* response (`runtimeDataAccessed`), which the segment prefetch encoding uses
* to tell the client whether a runtime prefetch request could be skipped.
@@ -121,13 +121,10 @@ export function makeUntrackedHangingPromise(
* cost of over-recording is a redundant runtime prefetch request; the cost of
* under-recording is a permanently missing one.
*
- * `workUnitStore` may be null ONLY when the caller tracks the access itself
- * at observation time instead of creation time. This is for promises the
- * framework creates eagerly whether or not anything reads them (e.g. the
- * `searchParams` prop constructed for every page): recording at creation
- * would mark every render. Such a caller MUST call `trackRuntimeDataAccessed`
- * from every path that observes the promise (e.g. the proxy traps for
- * `then`/`status`), against the work unit store active at access time.
+ * `workUnitStore` may be null ONLY when the caller tracks the access itself.
+ * Such a caller MUST call `trackRuntimeDataAccessed` from every path that
+ * observes the promise (e.g. the proxy traps for `then`/`status`),
+ * against the work unit store active at access time.
*
* For fallback-param data — data a concrete (ISR-upgraded) prerender would
* resolve — use `makeFallbackParamsHangingPromise` instead, so the access
@@ -141,29 +138,31 @@ export function makeRuntimeHangingPromise(
expression: string,
workUnitStore: WorkUnitStore | null
): Promise {
- if (workUnitStore !== null) {
- trackRuntimeDataAccessed(workUnitStore)
- }
- return makeHangingPromiseWithError(
+ const promise = makeHangingPromiseWithError(
signal,
new HangingPromiseRejectionError(route, expression)
)
+ if (workUnitStore === null) {
+ return promise
+ }
+ return trackPromiseUsed(
+ promise,
+ trackRuntimeDataAccessed.bind(null, workUnitStore, expression)
+ )
}
/**
* Variant of `makeRuntimeHangingPromise` for *fallback-param* data: fallback
* route params and values derived solely from them (`params`, `rootParams`,
- * `pathname` during a fallback prerender). Like every runtime data access it
- * records the access on the prerender store's response-level flag, but its
- * effect on the build-time static-prefetch hint differs — on a
+ * `pathname` during a fallback prerender). Like every runtime data access,
+ * awaiting it records the access on the prerender store's response-level flag,
+ * but its effect on the build-time static-prefetch hint differs — on a
* fallback-upgradeable route the access is transient (a concrete prerender
- * resolves it), so it leaves the hint intact. See
- * `trackFallbackParamsAccessed`.
+ * resolves it), so it leaves the hint intact. See `trackFallbackParamsAccessed`.
*
* As with `makeRuntimeHangingPromise`, `workUnitStore` may be null ONLY when
- * the caller tracks the access itself at observation time instead of creation
- * time, by calling `trackFallbackParamsAccessed` from every path that
- * observes the promise.
+ * the caller tracks the access itself by calling `trackFallbackParamsAccessed`
+ * from every path that observes the promise.
*
* @internal
*/
@@ -173,13 +172,17 @@ export function makeFallbackParamsHangingPromise(
expression: string,
workUnitStore: WorkUnitStore | null
): Promise {
- if (workUnitStore !== null) {
- trackFallbackParamsAccessed(workUnitStore)
- }
- return makeHangingPromiseWithError(
+ const promise = makeHangingPromiseWithError(
signal,
new HangingPromiseRejectionError(route, expression)
)
+ if (workUnitStore === null) {
+ return promise
+ }
+ return trackPromiseUsed(
+ promise,
+ trackFallbackParamsAccessed.bind(null, workUnitStore, expression)
+ )
}
/**
@@ -191,8 +194,8 @@ export function makeFallbackParamsHangingPromise(
*
* A render that runs through the later stage would include the data; in
* particular a runtime prefetch renders through its later stages, so on a
- * static prerender store this records `runtimeDataAccessed`, same as
- * `makeRuntimeHangingPromise`.
+ * static prerender store awaiting this promise records `runtimeDataAccessed`,
+ * same as `makeRuntimeHangingPromise`.
*
* @internal
*/
@@ -202,10 +205,12 @@ export function makeStageHangingPromise(
expression: string,
workUnitStore: WorkUnitStore
): Promise {
- trackRuntimeDataAccessed(workUnitStore)
- return makeHangingPromiseWithError(
- signal,
- new HangingPromiseRejectionError(route, expression)
+ return trackPromiseUsed(
+ makeHangingPromiseWithError(
+ signal,
+ new HangingPromiseRejectionError(route, expression)
+ ),
+ trackRuntimeDataAccessed.bind(null, workUnitStore, expression)
)
}
@@ -214,18 +219,18 @@ export function makeStageHangingPromise(
* which would have resolved during a runtime prerender. No-op for all other
* store types.
*
- * `makeRuntimeHangingPromise` and `makeStageHangingPromise` call this
- * automatically; call it directly only where the access is observed
- * separately from the promise's creation (see the null `workUnitStore` case
- * of `makeRuntimeHangingPromise`), or where the prerender is aborted
- * synchronously instead of hanging.
+ * Prefer `makeRuntimeHangingPromise` and `makeStageHangingPromise`.
+ * Use this method only when implementing similar tracking and those two are not enough.
*
* For fallback-param data, use `trackFallbackParamsAccessed` instead. When
* unsure, this is the conservative choice: it unconditionally clears the
* static-prefetch hint.
*/
-export function trackRuntimeDataAccessed(workUnitStore: WorkUnitStore): void {
- trackRuntimeDataAccessedImpl(workUnitStore, false)
+export function trackRuntimeDataAccessed(
+ workUnitStore: WorkUnitStore,
+ expression: string
+): void {
+ trackRuntimeDataAccessedImpl(workUnitStore, false, expression)
}
/**
@@ -237,14 +242,16 @@ export function trackRuntimeDataAccessed(workUnitStore: WorkUnitStore): void {
* concrete prerender that resolves it.
*/
export function trackFallbackParamsAccessed(
- workUnitStore: WorkUnitStore
+ workUnitStore: WorkUnitStore,
+ expression: string
): void {
- trackRuntimeDataAccessedImpl(workUnitStore, true)
+ trackRuntimeDataAccessedImpl(workUnitStore, true, expression)
}
function trackRuntimeDataAccessedImpl(
workUnitStore: WorkUnitStore,
- isFallbackParamAccess: boolean
+ isFallbackParamAccess: boolean,
+ expression: string
): void {
switch (workUnitStore.type) {
case 'prerender': {
@@ -283,6 +290,13 @@ function trackRuntimeDataAccessedImpl(
hintCell !== null &&
(!isFallbackParamAccess || !workUnitStore.isFallbackUpgradeable)
) {
+ if (process.env.NEXT_PRIVATE_DEBUG_RUNTIME_DATA) {
+ const workStore = workAsyncStorage.getStore()
+ const route = workStore?.route ?? ''
+ console.log(
+ `Route '${route}' deopting to runtime requests because it used ${expression}`
+ )
+ }
hintCell.current = false
}
break
@@ -389,38 +403,72 @@ export function makeDevtoolsIOAwarePromise(
})
}
-/** Invokes `onUse` whenever `then()/catch()/finally()` are called on the promise. */
-export function trackPromiseUsed(promise: Promise, onUse: () => void) {
- const methodCache: Record any> = {}
- return new Proxy(promise, {
- get(target, prop, receiver) {
- if (prop === 'then' || prop === 'catch' || prop === 'finally') {
- let patchedMethod = methodCache[prop]
- if (patchedMethod !== undefined) {
- return patchedMethod
- }
+/**
+ * Invokes `onUse` whenever `then()/catch()/finally()` are called on the promise
+ * or when the promise is awaited. */
+export function trackPromiseUsed(
+ promise: Promise,
+ onUse: () => void
+): Promise {
+ // We can instrument `.then()/.catch()/.finally()` in one go by using a Promise subclass
+ // that implements a custom `.then()`, because `catch` and `finally` delegate to it.
+ //
+ // Alternative implementation ideas that were tried and rejected:
+ //
+ // 1. Patching the methods directly via `promise.then = (..args) => { ... }`:
+ // doesn't work, because Node does not call the monkeypatched methods for native `await`:
+ // > Native Promise [...]: The promise is directly used and awaited natively, without calling `then()`.
+ // > https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#description
+ //
+ // 2. Wrapping in a proxy that returns a custom `then/catch/finally`:
+ // breaks async stacks in React's IO tracking (stack becomes `Promise.then`)
+ return TrackedPromise.from(promise, onUse)
+}
- const originalMethod = ReflectAdapter.get(target, prop, receiver)
- patchedMethod = {
- [prop]: (...args: unknown[]) => {
- try {
- onUse()
- } catch (err) {
- // We don't want to break the method even if our tracking errored.
- console.error(err)
- }
+class TrackedPromise extends Promise {
+ #onUse: (() => void) | null = null
- return originalMethod.apply(target, args)
- },
- }[prop]
+ // We don't need derived promises to also be a TrackedPromise.
+ // We only care about the first level of `.then()`.
+ static get [Symbol.species]() {
+ return Promise
+ }
- methodCache[prop] = patchedMethod
- return patchedMethod
+ static from(promise: Promise, onUse: () => void): TrackedPromise {
+ // Whenever the promise we're tracking resolves/rejects, we should follow.
+ const tracked = new TrackedPromise(promise.then.bind(promise))
+
+ tracked.#onUse = onUse
+
+ // Hanging promises catch rejections when created. Tracked promises are generally derived
+ // from promises that may hang & reject, so we need to do the same.
+ // However, we have to bypass the tracking we do in `TrackedPromise.then`.
+ // (we're using `then` directly, because `catch` ends up delegating `TrackedPromise.then`)
+ Promise.prototype.then.call(tracked, undefined, ignoreReject)
+
+ return tracked
+ }
+
+ then(
+ onFulfilled?: (value: T) => TResult1 | PromiseLike,
+ onRejected?: (reason: unknown) => TResult2 | PromiseLike
+ ): Promise {
+ const onUse = this.#onUse
+ if (onUse) {
+ try {
+ onUse()
+ } catch (err) {
+ // We don't want to break the method even if our tracking errored.
+ console.error(err)
}
+ }
- return ReflectAdapter.get(target, prop, receiver)
- },
- })
+ return Promise.prototype.then.call(
+ this,
+ onFulfilled,
+ onRejected
+ ) as Promise
+ }
}
export const RENDER_STAGES_BY_DATA_KIND = {
diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts
index 8d2a48381c0a..9e01ada7c870 100644
--- a/packages/next/src/server/request/params.ts
+++ b/packages/next/src/server/request/params.ts
@@ -686,7 +686,7 @@ const fallbackParamsProxyHandler: ProxyHandler> = {
// rendering when it's finally awaited.
const workUnitStore = workUnitAsyncStorage.getStore()
if (workUnitStore !== undefined) {
- trackFallbackParamsAccessed(workUnitStore)
+ trackFallbackParamsAccessed(workUnitStore, '`params`')
}
const store = dynamicAccessAsyncStorage.getStore()
@@ -724,9 +724,9 @@ function makeHangingParams(
prerenderStore.renderSignal,
workStore.route,
'`params`',
- // This promise is created for every segment on a fallback route whether
- // or not it reads params, so recording the access at creation would mark
- // every render. The access is tracked in the proxy traps instead.
+ // Passing `null` for the store disables tracking of params usage.
+ // Caches need the additional logic from `fallbackParamsProxyHandler`,
+ // so we track params usage there instead.
null
),
fallbackParamsProxyHandler
diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts
index 495e2655d854..3f0760fdae4c 100644
--- a/packages/next/src/server/request/search-params.ts
+++ b/packages/next/src/server/request/search-params.ts
@@ -393,7 +393,7 @@ function makeHangingSearchParams(
// created while the RSC payload is constructed, but typically accessed
// later, during the render, under a different store.
const workUnitStore = workUnitAsyncStorage.getStore()
- trackRuntimeDataAccessed(workUnitStore ?? prerenderStore)
+ trackRuntimeDataAccessed(workUnitStore ?? prerenderStore, '`searchParams`')
}
const proxyHandler: ProxyHandler> = {
diff --git a/scripts/run-jest.sh b/scripts/run-jest.sh
index 59032799761a..c1bbedb06adf 100755
--- a/scripts/run-jest.sh
+++ b/scripts/run-jest.sh
@@ -56,6 +56,35 @@ while [ $# -gt 0 ]; do
shift
done
+# `__NEXT_TEST_AXIS` names the alternate flag configurations of the test
+# matrix. Axes are lettered (`A`, `B`, …) — a fixed enumeration a fixture opts
+# into, not a boolean, and not one of the buckets test *sharding* splits a run
+# into. CI runs the suites once plainly and once per axis, and a fixture keys
+# an experimental flag on an axis to cover both states of the flag — enabled
+# by default, disabled on that axis:
+#
+# experimental: {
+# concurrentRouterQueue: process.env.__NEXT_TEST_AXIS !== 'A',
+# }
+#
+# paired with a `// @gate concurrentRouterQueue` on the affected tests: a
+# plain run — including a local run with no special env — exercises the
+# feature, and the axis run covers the off state (see
+# test/lib/gate/README.md).
+#
+# For now there is a single axis, `A`, and it is an alias for
+# `__NEXT_CACHE_COMPONENTS` (the `--experimental` run) rather than a CI
+# dimension of its own. That works because most experiments hard-code
+# `cacheComponents: true` in their fixture anyway — the cache-components env
+# default only applies to fixtures that don't set it themselves, so for these
+# fixtures that run is free to double as the axis run. Setting either name
+# implies the other.
+if [ -n "${__NEXT_TEST_AXIS:-}" ]; then
+ export __NEXT_CACHE_COMPONENTS=true
+elif [ "${__NEXT_CACHE_COMPONENTS:-}" = "true" ]; then
+ export __NEXT_TEST_AXIS=A
+fi
+
# Resolves to `node_modules/.bin/jest` via `$PATH`. This relies on being
# invoked through pnpm (or another package runner), which prepends the
# workspace's `node_modules/.bin/` to `$PATH` before running the script.
diff --git a/test/e2e/app-dir/cache-components-request-apis/cache-components-request-apis.test.ts b/test/e2e/app-dir/cache-components-request-apis/cache-components-request-apis.test.ts
index d95fd4132d58..8aadef5c474a 100644
--- a/test/e2e/app-dir/cache-components-request-apis/cache-components-request-apis.test.ts
+++ b/test/e2e/app-dir/cache-components-request-apis/cache-components-request-apis.test.ts
@@ -60,11 +60,15 @@ describe(`Request Promises`, () => {
}
const expectError = createExpectError(next.cliOutput)
+ expectError(
+ 'Error: During prerendering, `searchParams` rejects when the prerender is complete'
+ )
expectError(
'Error: During prerendering, `params` rejects when the prerender is complete'
)
+
expectError(
- 'Error: During prerendering, `searchParams` rejects when the prerender is complete'
+ 'Error: During prerendering, `connection()` rejects when the prerender is complete'
)
expectError(
'Error: During prerendering, `cookies()` rejects when the prerender is complete'
@@ -72,9 +76,6 @@ describe(`Request Promises`, () => {
expectError(
'Error: During prerendering, `headers()` rejects when the prerender is complete'
)
- expectError(
- 'Error: During prerendering, `connection()` rejects when the prerender is complete'
- )
})
})
describe('On Prerender Interruption', () => {
@@ -100,11 +101,12 @@ describe(`Request Promises`, () => {
const expectError = createExpectError(next.cliOutput)
expectError(
- 'Error: During prerendering, `params` rejects when the prerender is complete'
+ 'Error: During prerendering, `searchParams` rejects when the prerender is complete'
)
expectError(
- 'Error: During prerendering, `searchParams` rejects when the prerender is complete'
+ 'Error: During prerendering, `params` rejects when the prerender is complete'
)
+
expectError(
'Error: During prerendering, `cookies()` rejects when the prerender is complete'
)
diff --git a/test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts b/test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts
index 2320b8b17b5e..554ef15f090b 100644
--- a/test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts
+++ b/test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts
@@ -17,6 +17,7 @@ describe('concurrent-router-queue', () => {
files: __dirname,
})
+ // Not gated: a clean hydration is expected in both states of the flag.
it('hydrates cleanly without invoking the forked entry points', async () => {
// `pushErrorAsConsoleLog` records uncaught page errors into the console
// log capture, which works in both dev and start modes.
@@ -29,6 +30,10 @@ describe('concurrent-router-queue', () => {
expect(errors).toEqual([])
})
+ // The stubs only throw when the fork is active; with the flag off, the
+ // sequential router handles the navigation and the test fails its
+ // expectations — which is what the gate asserts on the axis-A run.
+ // @gate concurrentRouterQueue
it('fails loudly on link navigation', async () => {
const browser = await next.browser('/', { pushErrorAsConsoleLog: true })
await browser.waitForElementByCss('#invoke-action')
@@ -55,6 +60,7 @@ describe('concurrent-router-queue', () => {
expect(await browser.hasElementByCssSelector('#target-page')).toBe(false)
})
+ // @gate concurrentRouterQueue
it('fails loudly on server action invocation', async () => {
const browser = await next.browser('/')
await browser.waitForElementByCss('#invoke-action')
diff --git a/test/e2e/app-dir/concurrent-router-queue/next.config.js b/test/e2e/app-dir/concurrent-router-queue/next.config.js
index 1f8fd1dd240d..fe58dfa8eef5 100644
--- a/test/e2e/app-dir/concurrent-router-queue/next.config.js
+++ b/test/e2e/app-dir/concurrent-router-queue/next.config.js
@@ -2,8 +2,15 @@
* @type {import('next').NextConfig}
*/
const nextConfig = {
+ // Pin every dimension except the one under test.
+ cacheComponents: true,
experimental: {
- concurrentRouterQueue: true,
+ // Keyed on test axis A (see scripts/run-jest.sh) so the suite covers
+ // both states. Enabled by default — a plain run exercises the fork with
+ // no special env — and disabled on axis A, where the
+ // `@gate concurrentRouterQueue` tests assert the sequential router is
+ // back in charge.
+ concurrentRouterQueue: process.env.__NEXT_TEST_AXIS !== 'A',
},
}
diff --git a/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/page.tsx
index d44acbfd8dac..1001d5b21908 100644
--- a/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/page.tsx
+++ b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/page.tsx
@@ -1,3 +1,4 @@
+import Link from 'next/link'
import { LinkAccordion } from '../components/link-accordion'
export default function Page() {
@@ -17,6 +18,56 @@ export default function Page() {
Uses cookies
+
+
+ Calls Runtime APIs but does not await them
+
+
+}
diff --git a/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/params-used-after-navigation/[id]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/params-used-after-navigation/[id]/page.tsx
new file mode 100644
index 000000000000..3316bc32ed81
--- /dev/null
+++ b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/params-used-after-navigation/[id]/page.tsx
@@ -0,0 +1,36 @@
+import { unstable_navigation as navigation } from 'next/cache'
+import { Suspense } from 'react'
+
+type Props = { params: Promise<{ id: string }> }
+
+export default async function Page(props: Props) {
+ return (
+
+
Params awaited after navigation
+ Loading navigation content...}
+ >
+
+
+
+ )
+}
+
+async function NavigationOnly(props: Props) {
+ // navigation() does not resolve in runtime prefetches, so awaiting `params`
+ // after `navigation` should not deopt this page to using runtime requests
+ // (because runtime shells/prefetches would not provide more data)
+ await navigation()
+
+ return (
+ <>
+
+}
diff --git a/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/runtime-called-but-not-awaited/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/runtime-called-but-not-awaited/page.tsx
new file mode 100644
index 000000000000..a3c8216297a0
--- /dev/null
+++ b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/runtime-called-but-not-awaited/page.tsx
@@ -0,0 +1,43 @@
+// A page that calls cookies and headers, but doesn't await them during the prerender,
+// which means it can still be prefetched statically.
+//
+// Note the Shell phase is always permitted to issue a runtime shell request,
+// so the absence of one in the test is attributable to the static shell
+// attempt succeeding, not to configuration forbidding runtime requests.
+
+import { cacheLife } from 'next/dist/server/use-cache/cache-life'
+import { cookies, headers } from 'next/headers'
+import { connection } from 'next/server'
+import { Suspense } from 'react'
+
+export default async function Page() {
+ return (
+
+
+}
+
+async function shortStaleCache() {
+ 'use cache'
+ cacheLife({ stale: 300 - 1 }) // smaller than MIN_SHELL_STALE
+ return Date.now()
+}
diff --git a/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts b/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts
index b95df1163cf6..ba6fb7a73877 100644
--- a/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts
+++ b/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts
@@ -254,6 +254,249 @@ describe('static App Shell prefetch attempt', () => {
])
})
+ it("uses a static app shell for a partial segment that calls runtime APIs but doesn't await them", async () => {
+ let page: Playwright.Page
+ const browser = await next.browser('/', {
+ beforePageLoad(p: Playwright.Page) {
+ page = p
+ },
+ })
+ const act = createRouterAct(page, { includeAppShellRequests: true })
+
+ // Reveal the LinkAccordion for /runtime-called-but-not-awaited.
+ // No runtime data was awaited, so a static app shell is sufficient
+ // (a runtime app shell would not provide more data)
+ await act(async () => {
+ await browser
+ .elementByCss(
+ 'input[data-link-accordion="/runtime-called-but-not-awaited"]'
+ )
+ .click()
+ }, [
+ { includes: 'Runtime APIs called but not awaited', kind: 'static' },
+ // We only expect a static prefetch.
+ {
+ includes: 'Runtime APIs called but not awaited',
+ kind: 'runtime',
+ block: 'reject',
+ },
+ { includes: 'Dynamic content', kind: 'runtime', block: 'reject' },
+ ])
+
+ // Navigate. The prefetched shell renders instantly, and the dynamic data arrives
+ // later, as part of the navigation request.
+ await act(
+ async () => {
+ await browser
+ .elementByCss('a[href="/runtime-called-but-not-awaited"]')
+ .click()
+
+ // While the navigation response is blocked (we're still inside the
+ // `act` scope), the prefetched shell is already visible, with the
+ // loading fallback in place of the dynamic content.
+ expect(await browser.elementById('page-content').text()).toBe(
+ 'Runtime APIs called but not awaited'
+ )
+ expect(await browser.elementById('dynamic-loading').text()).toBe(
+ 'Loading dynamic content...'
+ )
+ },
+ // The dynamic content streams in with the navigation response.
+ { includes: 'Dynamic content' }
+ )
+
+ expect(await browser.elementById('dynamic-content').text()).toBe(
+ 'Dynamic content'
+ )
+ })
+
+ it('uses a static app shell for a partial segment that only awaits params after dynamic data', async () => {
+ let page: Playwright.Page
+ const browser = await next.browser('/', {
+ beforePageLoad(p: Playwright.Page) {
+ page = p
+ },
+ })
+ const act = createRouterAct(page, { includeAppShellRequests: true })
+
+ // Reveal the LinkAccordion for /params-used-after-dynamic/1.
+ // No runtime data was awaited, so a static app shell is sufficient
+ // (a runtime app shell would not provide more data)
+ await act(async () => {
+ await browser
+ .elementByCss(
+ 'input[data-link-accordion="/params-used-after-dynamic/1"]'
+ )
+ .click()
+ }, [
+ { includes: 'Params awaited after dynamic data', kind: 'static' },
+ // We only expect a static prefetch, no runtime requests.
+ {
+ includes: 'Params awaited after dynamic data',
+ kind: 'runtime',
+ block: 'reject',
+ },
+ { includes: 'Dynamic content', kind: 'runtime', block: 'reject' },
+ ])
+
+ // Navigate to an unprefetched link with a different param value.
+ // This should re-use the app shell that we got when we prefetched /1.
+ await act(
+ async () => {
+ await browser
+ .elementByCss('a[href="/params-used-after-dynamic/2"]')
+ .click()
+
+ // While the navigation response is blocked (we're still inside the
+ // `act` scope), the prefetched shell is already visible, with the
+ // loading fallback in place of the dynamic content.
+ expect(await browser.elementById('page-content').text()).toBe(
+ 'Params awaited after dynamic data'
+ )
+ expect(await browser.elementById('dynamic-loading').text()).toBe(
+ 'Loading dynamic content...'
+ )
+ },
+ // The dynamic content streams in with the navigation response.
+ { includes: 'Dynamic content' }
+ )
+
+ expect(await browser.elementById('dynamic-content').text()).toBe(
+ 'Dynamic content'
+ )
+ expect(await browser.elementById('param-value').text()).toBe('Post: 2')
+ })
+
+ it('uses a static app shell for a partial segment that only awaits params after navigation()', async () => {
+ let page: Playwright.Page
+ const browser = await next.browser('/', {
+ beforePageLoad(p: Playwright.Page) {
+ page = p
+ },
+ })
+ const act = createRouterAct(page, { includeAppShellRequests: true })
+
+ // Reveal the LinkAccordion for /params-used-after-navigation/1.
+ // No runtime data was awaited, so a static app shell is sufficient
+ // (a runtime app shell would not provide more data)
+ await act(async () => {
+ await browser
+ .elementByCss(
+ 'input[data-link-accordion="/params-used-after-navigation/1"]'
+ )
+ .click()
+ }, [
+ { includes: 'Params awaited after navigation', kind: 'static' },
+ // We only expect a static prefetch, no runtime requests.
+ {
+ includes: 'Params awaited after navigation',
+ kind: 'runtime',
+ block: 'reject',
+ },
+ ])
+
+ // Navigate to an unprefetched link with a different param value.
+ // This should re-use the app shell that we got when we prefetched /1.
+ await act(
+ async () => {
+ await browser
+ .elementByCss('a[href="/params-used-after-navigation/2"]')
+ .click()
+
+ expect(await browser.elementById('page-content').text()).toBe(
+ 'Params awaited after navigation'
+ )
+
+ // `navigation()` *does* resolve in static prefetches so we have navigation-gated
+ // content for /1. However, it is not considered part of the app shell, so it should
+ // not be visible here.
+ expect(await browser.elementById('navigation-loading').text()).toBe(
+ 'Loading navigation content...'
+ )
+ },
+ // The navigation content streams in with the navigation response.
+ { includes: 'Navigation content' }
+ )
+
+ expect(await browser.elementById('navigation-content').text()).toBe(
+ 'Navigation content'
+ )
+ expect(await browser.elementById('param-value').text()).toBe('Post: 2')
+ })
+
+ it('uses a runtime shell for a partial segment that has a param-dependent icon.tsx', async () => {
+ let page: Playwright.Page
+ const browser = await next.browser('/', {
+ beforePageLoad(p: Playwright.Page) {
+ page = p
+ },
+ })
+ const act = createRouterAct(page, { includeAppShellRequests: true })
+
+ // Reveal the LinkAccordion for /params-used-in-icon/1.
+ // No runtime data was awaited in the page itself during the prerender,
+ // but the head is param-dependent because it needs to link to the
+ // param-dependent icon:
+ //
+ //
+ //
+ // which is tracked as a runtime data access and deopts the page
+ // to runtime requests.
+ await act(async () => {
+ await browser
+ .elementByCss('input[data-link-accordion="/params-used-in-icon/1"]')
+ .click()
+ }, [
+ {
+ includes: 'Params awaited in icon.tsx and after dynamic data',
+ kind: 'runtime',
+ },
+ {
+ includes: 'Params awaited in icon.tsx and after dynamic data',
+ kind: 'static',
+ block: 'reject',
+ },
+ { includes: 'Dynamic content', kind: 'static', block: 'reject' },
+ ])
+
+ // Navigate to an unprefetched link with a different param value.
+ // This should re-use the app shell that we got when we prefetched /1.
+ await act(
+ async () => {
+ await browser.elementByCss('a[href="/params-used-in-icon/2"]').click()
+
+ // While the navigation response is blocked (we're still inside the
+ // `act` scope), the prefetched shell is already visible, with the
+ // loading fallback in place of the dynamic content.
+ expect(await browser.elementById('page-content').text()).toBe(
+ 'Params awaited in icon.tsx and after dynamic data'
+ )
+
+ // The icon is param-dependent and should not be part of the shell.
+ expect(await browser.locator('link[rel="icon"]').count()).toBe(0)
+
+ expect(await browser.elementById('dynamic-loading').text()).toBe(
+ 'Loading dynamic content...'
+ )
+ },
+ // The dynamic content streams in with the navigation response.
+ { includes: 'Dynamic content' }
+ )
+
+ expect(await browser.elementById('dynamic-content').text()).toBe(
+ 'Dynamic content'
+ )
+
+ expect(
+ new URL(
+ await browser.elementByCss('link[rel="icon"]').getAttribute('href'),
+ 'http://__n'
+ ).pathname
+ ).toEqual('/params-used-in-icon/2/icon')
+
+ expect(await browser.elementById('param-value').text()).toBe('Post: 2')
+ })
+
it('does not fall back to a runtime shell prefetch for a partial segment whose holes are dynamic (connection)', async () => {
let page: Playwright.Page
const browser = await next.browser('/', {
diff --git a/test/e2e/app-dir/use-offline/next.config.js b/test/e2e/app-dir/use-offline/next.config.js
index 420686102585..440e5b14cd31 100644
--- a/test/e2e/app-dir/use-offline/next.config.js
+++ b/test/e2e/app-dir/use-offline/next.config.js
@@ -4,7 +4,14 @@
const nextConfig = {
cacheComponents: true,
experimental: {
- useOffline: true,
+ // Keyed on test axis A (see scripts/run-jest.sh). Enabled by default —
+ // a plain run exercises the hook with no special env — and disabled on
+ // axis A, where the `@force-gate useOffline` on the suite skips the
+ // fixture build entirely: `useOffline()` is a new API that is inert when
+ // disabled (it always reports online), so the off state has nothing to
+ // assert and would only fail by timing out. The keying turns a redundant
+ // duplicate run into a near-free skip.
+ useOffline: process.env.__NEXT_TEST_AXIS !== 'A',
varyParams: true,
optimisticRouting: true,
cachedNavigations: true,
diff --git a/test/e2e/app-dir/use-offline/use-offline.test.ts b/test/e2e/app-dir/use-offline/use-offline.test.ts
index 86d844e8de7c..812eb9a6ab4f 100644
--- a/test/e2e/app-dir/use-offline/use-offline.test.ts
+++ b/test/e2e/app-dir/use-offline/use-offline.test.ts
@@ -3,16 +3,13 @@ import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
import { retry } from 'next-test-utils'
+// @force-gate prefetching
+// @force-gate useOffline
describe('useOffline', () => {
- const { next, isNextDev } = nextTestSetup({
+ const { next } = nextTestSetup({
files: __dirname,
})
- if (isNextDev) {
- test('skipped in dev mode', () => {})
- return
- }
-
// Uses Playwright's built-in network emulation, which fires the browser's
// native offline/online events and blocks all requests at the network layer.
async function goOffline(page: Playwright.Page) {
diff --git a/test/jest-setup-after-env.ts b/test/jest-setup-after-env.ts
index dcf3748a9d28..b4f11b2db3ba 100644
--- a/test/jest-setup-after-env.ts
+++ b/test/jest-setup-after-env.ts
@@ -1,6 +1,13 @@
import * as matchers from 'jest-extended'
+import { installGate } from './lib/gate/runtime'
+
expect.extend(matchers)
+// Installs the `_test_gate` global that `// @gate` pragmas compile to, and
+// wraps `it`/`test` so a gate on a `describe` reaches the tests inside it.
+// See test/lib/gate/runtime.ts.
+installGate()
+
// Patch jscodeshift testUtils to normalize line endings (fixes Windows CRLF issues)
// The issue: jscodeshift's printer (recast) outputs CRLF on Windows, but test fixtures use LF
// We need to patch both defineTest (which uses internal closure references) and runInlineTest
diff --git a/test/lib/e2e-utils/index.ts b/test/lib/e2e-utils/index.ts
index 475f5324655b..ed5f08e8917f 100644
--- a/test/lib/e2e-utils/index.ts
+++ b/test/lib/e2e-utils/index.ts
@@ -1,12 +1,23 @@
import path from 'path'
import assert from 'assert'
import { flushAllTraces, setGlobal, trace } from 'next/dist/trace'
-import { PHASE_DEVELOPMENT_SERVER } from 'next/constants'
+import {
+ PHASE_DEVELOPMENT_SERVER,
+ PHASE_PRODUCTION_BUILD,
+} from 'next/constants'
import { NextInstance, NextInstanceOpts } from '../next-modes/base'
import { NextDevInstance } from '../next-modes/next-dev'
import { NextStartInstance } from '../next-modes/next-start'
import { NextDeployInstance } from '../next-modes/next-deploy'
import { shouldUseTurbopack } from '../next-test-utils'
+import { setGateTestContext, type GateTestMode } from '../gate/test-context'
+import { clearFixture, registerFixture } from '../gate/state'
+import { loadResolvedConfig } from '../gate/load-resolved-config'
+import {
+ getActiveDescribeGates,
+ hasLazyForceGate,
+ findLazyForceSkip,
+} from '../gate/runtime'
export type { NextInstance }
export type { Playwright } from '../browsers/playwright'
@@ -177,6 +188,19 @@ export const itTurbopack =
export const isReact18 =
parseInt(process.env.NEXT_TEST_REACT_VERSION || '', 10) === 18
+// Publish the statically-known shape of this run for `// @gate` pragmas. See
+// test/lib/gate/conditions.ts.
+setGateTestContext({
+ mode: testMode as GateTestMode,
+ bundler: isRspack
+ ? 'rspack'
+ : !isNextTestWasm && shouldUseTurbopack()
+ ? 'turbopack'
+ : 'webpack',
+ react18: isReact18,
+ wasm: isNextTestWasm,
+})
+
if (!testMode) {
throw new Error(
`No 'NEXT_TEST_MODE' set in environment, this is required for e2e-utils`
@@ -284,10 +308,17 @@ async function createNext(
nextInstance.on('destroy', () => {
nextInstance = undefined
+ clearFixture()
})
await nextInstance.setup(rootSpan)
+ // Lazy `// @gate` conditions read this fixture's resolved next.config.
+ // Registering the instance (not a snapshot) before `start()` keeps
+ // `skipStart` suites and rebuild flows working: nothing is resolved until
+ // a gate actually asks. See test/lib/gate/README.md.
+ registerFixture(nextInstance)
+
if (!opts.skipStart) {
await rootSpan
.traceChild('start next instance')
@@ -338,16 +369,86 @@ export function nextTestSetup(
}
}
+ // A lazy `@force-gate` on the enclosing `describe` (e.g. `!cacheComponents`)
+ // gates the *build*, not just the test bodies: some fixtures can't build
+ // under the condition at all. Snapshot the describe's gates now, while the
+ // describe body is still being collected — the stack is empty by `beforeAll`.
+ // Suites that manage their own build (`skipStart`) are left untouched.
+ const describeGates = getActiveDescribeGates()
+ // Deploy's "build" is a remote deployment we can't gate this way, and suites
+ // that pass `skipStart` build manually — leave both to their own handling.
+ const buildForceGated =
+ !options.skipStart && !isNextDeploy && hasLazyForceGate(describeGates)
+
let next: NextInstance | undefined
if (!skipped) {
beforeAll(async () => {
- next = await createNext(options)
+ if (!buildForceGated) {
+ next = await createNext(options)
+ return
+ }
+ // Try to decide the force-gate against the *source* fixture first,
+ // before paying for the fixture setup (which includes a dependency
+ // install when the run is isolated). The config resolver falls back to
+ // the repo's own `next` when the directory has no install, and the env
+ // mirrors what `getSpawnOpts` hands every fixture child process. An
+ // inline `files` object has no directory to resolve against, and any
+ // resolution failure (e.g. a config that imports from the fixture's
+ // own node_modules) falls through to the instance-based decision below.
+ if (typeof options.files === 'string') {
+ const config = await loadResolvedConfig({
+ dir: options.files,
+ phase: isNextDev ? PHASE_DEVELOPMENT_SERVER : PHASE_PRODUCTION_BUILD,
+ env: {
+ ...process.env,
+ ...options.env,
+ NODE_ENV: (options.env?.NODE_ENV ||
+ '') as NodeJS.ProcessEnv['NODE_ENV'],
+ PORT: '0',
+ __NEXT_TEST_MODE: 'e2e',
+ },
+ }).catch(() => null)
+ const earlySkip = config && findLazyForceSkip(describeGates, config)
+ if (earlySkip) {
+ // No instance ever exists on this path, so register the resolved
+ // config directly for the per-test force-pass decisions.
+ registerFixture({ getResolvedConfig: async () => config })
+ require('console').warn(
+ ` ⚠ suite build skipped by \`@force-gate ${earlySkip.source}\` ` +
+ `(decided from the source fixture; setup skipped)`
+ )
+ return
+ }
+ }
+ // Set the fixture up (so its config is resolvable) without building, then
+ // resolve the force-gate. If it's false, skip the build entirely — the
+ // inherited gate makes every test force-pass, so nothing touches `next`.
+ const instance = await createNext({ ...options, skipStart: true })
+ next = instance
+ const config = await instance.getResolvedConfig()
+ const forceSkip = findLazyForceSkip(describeGates, config)
+ if (forceSkip) {
+ require('console').warn(
+ ` ⚠ suite build skipped by \`@force-gate ${forceSkip.source}\``
+ )
+ return
+ }
+ try {
+ await instance.start()
+ } catch (err) {
+ await instance.destroy().catch(() => {})
+ next = undefined
+ throw err
+ }
})
afterAll(async () => {
// Gracefully destroy the instance if `createNext` success.
// If next instance is not available, it's likely beforeAll hook failed and unnecessarily throws another error
// by attempting to destroy on undefined.
await next?.destroy()
+ // The early force-skip path registers a config source without an
+ // instance (an instance clears itself on destroy).
+ if (!next) clearFixture()
})
}
diff --git a/test/lib/gate/README.md b/test/lib/gate/README.md
new file mode 100644
index 000000000000..4d225729be74
--- /dev/null
+++ b/test/lib/gate/README.md
@@ -0,0 +1,252 @@
+# `@gate` — marking a test as known-failing, without lying about it
+
+`it.skip` is a dead end. Nothing tells you when the bug it was hiding gets
+fixed, so the test stays skipped, then stays skipped after it would have passed,
+and eventually rots. `// @gate` replaces it with a tripwire.
+
+```ts
+// Blocked on the optimization that marks a route as fully static when no
+// dynamic params are referenced in Server Components.
+// @gate !cacheComponents
+it('navigate to page with a lazily-generated static param', async () => {
+ // body unchanged
+})
+```
+
+The test **still runs**. Because `cacheComponents` is on for this fixture the
+condition is false, so the failure is expected: the suite stays green and the run
+logs
+
+```
+ ⚠ gated test failed as expected (@gate !cacheComponents)
+```
+
+The day the underlying bug is fixed and the body starts passing, CI fails with
+
+```
+Gated test passed unexpectedly.
+
+This test is marked `// @gate !cacheComponents`, and that condition is currently
+false, so the test was expected to fail — but it passed.
+The gate is stale: delete the `// @gate !cacheComponents` pragma (and whatever
+workaround came with it).
+```
+
+That inversion — condition false + test passes ⇒ **failure** — is the whole
+feature. It is lifted from React's `@gate`
+(`scripts/jest/setupTests.js`, `scripts/babel/transform-test-gate-pragma.js`),
+including the expression grammar, so pragmas read the same in both repos.
+
+## `@gate` vs `@force-gate`
+
+`@gate` **runs** the body and inverts the expectation when the condition is
+false (a passing body then fails as stale). `@force-gate` **skips** instead of
+running — for a body that isn't worth attempting, giving up the tripwire in
+exchange.
+
+| directive | condition | when false | when true |
+| --- | --- | --- | --- |
+| `// @gate ` | static or lazy | assert-fail (invert; stale if it passes) | run |
+| `// @force-gate ` (static) | static | real Jest skip (`○ skipped`) at collection | run |
+| `// @force-gate ` (lazy, per-test) | lazy | force-pass the test (skip the body) | run |
+| `// @force-gate ` (lazy, on a `describe`) | lazy | skip the **build** and force-pass the suite | build + run |
+
+A **static** `@force-gate` (mode/bundler) is decided while tests are collected,
+so it's a real `○ skipped`. A **lazy** `@force-gate` (resolved-config) can't be
+known then, so it's decided at runtime once the fixture's config is resolvable:
+
+- On a `describe`, the fixture is set up but the **build is skipped** when the
+ condition is false — which is the point, since some fixtures can't build under
+ the condition at all (e.g. `revalidate` / `dynamic` route configs under Cache
+ Components). Nothing is asserted; every test force-passes.
+- Because Jest can't turn a running test into `○ skipped`, a lazy force-gate
+ reports the test as **passed with a `⚠ skipped by @force-gate ` warning**,
+ not as skipped. A static force-gate keeps the real `○ skipped`.
+
+**Prefer `@gate` when the off state fails for a meaningful reason** — a flag
+that changes the behavior of existing surface, where a pass would tell you the
+gate is stale. For a new API the off state can only throw, which proves nothing
+and costs real browser time, so tests of a new API should typically
+`@force-gate` instead. `@force-gate` is also the only option when running the
+body is impossible: prefetching is off in dev, deploy has no local build
+output, the fixture can't build under the condition.
+
+Both forms work on `it`, `test`, `fit`, `describe`, and their `.only` variants.
+A gate on a `describe` applies to every test inside it. Several pragmas may stack
+on one call. (Build-skipping applies only to suites where `nextTestSetup` owns
+the build — not `skipStart` suites — and to `start`/`dev`, not deploy.)
+
+## Conditions
+
+All condition names are declared in [`conditions.ts`](./conditions.ts) — a typo
+fails the whole suite at collection time rather than silently disabling the gate.
+There are two tiers:
+
+- **static** — the run's own shape (`dev`, `start`, `deploy`, `mode`,
+ `turbopack`, `rspack`, `webpack`, `bundler`, `react18`, `wasm`, `ci`),
+ semantic aliases for `!dev` that state the reason rather than the mode
+ (`prod`, `prefetching`), plus `FIXME` / `TODO`, which are always false.
+- **lazy** — a predicate over the fixture's *resolved* `next.config`
+ (`cacheComponents`, `ppr`, `prefetchInlining`, `output`, …), read the first
+ time a gate asks for it.
+
+Lazy conditions read the resolved config and never `process.env`, because
+`__NEXT_CACHE_COMPONENTS=true` (the `--experimental` shard) is only applied when
+the fixture has not set `cacheComponents` itself, and because resolution implies
+flags a fixture never mentions — `cacheComponents: true` alone turns on
+`experimental.ppr` and `experimental.cachedNavigations`. A gate therefore stays
+correct when a fixture's config changes or a CI shard's env var starts or stops
+applying.
+
+Add conditions freely; the guidance for doing so is at the top of
+`conditions.ts`.
+
+## Covering both states of an experiment: test axes
+
+On top of the dimensions the test matrix already has (mode, bundler, React
+version), the suites run once plainly and once per *test axis* — a fixed,
+lettered set of alternate flag configurations marked by `__NEXT_TEST_AXIS`.
+(Today there is one axis, `A`, an alias for the `--experimental` /
+`__NEXT_CACHE_COMPONENTS` run; see `scripts/run-jest.sh`.) A fixture can key
+an experimental flag on an axis instead of pinning it — enabled by default,
+disabled on that axis:
+
+```js
+// next.config.js — pin every dimension except the one under test
+const nextConfig = {
+ cacheComponents: true,
+ experimental: {
+ concurrentRouterQueue: process.env.__NEXT_TEST_AXIS !== 'A',
+ },
+}
+```
+
+paired with `// @gate concurrentRouterQueue` on the tests whose expectations
+only hold with the flag on (`test/e2e/app-dir/concurrent-router-queue/`). A
+plain run — including a local run with no special env — exercises the
+feature, while the axis-A run covers the off state — and fails the day the
+gated tests start passing. One suite, both states, no new CI job, and no
+duplicated fixture. When the off state proves nothing (a new API that throws
+or is inert when disabled), pair the keying with a lazy `@force-gate`
+instead — `test/e2e/app-dir/use-offline/` — and the axis run skips the
+fixture build entirely.
+
+Keep exactly **one** flag varying per fixture (pin the rest, like
+`cacheComponents` above) so a red shard still attributes to a single
+dimension. The gate itself keeps working either way — lazy conditions read the
+*resolved* config, so they observe whatever the fixture decided, not how it
+decided it.
+
+To reproduce the disabled (axis) state locally, set the marker the same way
+CI does:
+
+```sh
+__NEXT_TEST_AXIS=A NEXT_SKIP_ISOLATE=1 pnpm test-start-webpack test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts
+```
+
+## Expressions
+
+```
+// @gate !dev
+// @gate mode === 'start' && !cacheComponents
+// @gate !(turbopack || rspack)
+// @gate output === 'export'
+```
+
+`!`, `&&`, `||`, `===`/`!==` (and `==`/`!=`), parentheses, string and boolean
+literals. Values are coerced by truthiness in boolean position, so
+`@gate prefetchInlining` works even though it resolves to
+`false | {maxSize, maxBundleSize}`.
+
+## Conditional logic inside a body: `gate()`
+
+The pragma gates a whole test. For a body that should run under both states
+but *assert differently*, import the runtime `gate()` — the same registry,
+without the inversion:
+
+```ts
+import { gate } from 'next-test-utils'
+
+it('renders the fallback', async () => {
+ if (await gate((conditions) => conditions.cacheComponents)) {
+ // PPR shell: the fallback is part of the prerender.
+ } else {
+ // fully dynamic: the fallback streams in.
+ }
+})
+```
+
+The function form mirrors React's `gate(flags => flags.enableFoo)`
+(`scripts/jest/setupTests.js`), except it is imported rather than a global,
+and async, because a lazy condition reads the booted fixture's resolved
+config. A string is also accepted and evaluated in the pragma expression
+language: `await gate('cacheComponents && !dev')`. Either way an undeclared
+name throws, like a pragma.
+
+`gate()` also works where a pragma cannot attach (`it.each`). Prefer it over
+branching on `process.env` for the same reason lazy conditions exist: the env
+var is not what the fixture actually resolved.
+
+## How it works
+
+1. `pragma-transform.js` rewrites the pragma into
+ `_test_gate([{force,source}], 'it')(...)`. It is a line-oriented regex, not an
+ AST transform, so **only the `it(` line changes** and every other line keeps
+ its byte offsets — `toMatchInlineSnapshot()` is written back by line/column.
+2. `jest-transformer.js` chains that rewrite in front of the SWC transformer
+ `next/jest` configures. `jest.config.js` wires it up with
+ `withGateTransformer()`.
+3. `runtime.ts` installs `_test_gate` and evaluates conditions. A false
+ *static* `@gate` is known while tests are collected, so the test registers
+ through Jest's native `test.failing` and the inversion is Jest's own. A
+ lazy gate can't be decided until the fixture's config resolves, so those
+ tests wrap the body and invert the outcome at runtime. The `it`/`test`
+ globals are wrapped so a gate on a `describe` reaches the tests inside.
+4. `state.ts` holds the fixture `createNext()` registered;
+ `NextInstance.getResolvedConfig()` resolves its config out of process (in
+ process, `loadConfig` would mutate the Jest worker's `process.env` from the
+ fixture's `.env` files).
+
+A suite with no lazy gate never resolves a config, so the cost is zero.
+
+## Limitations
+
+- A pragma the transform would not pick up is a **hard error**, not a no-op:
+ blank line in between, `it.each` / `it.failing`, a pragma inside a JSDoc
+ block. Reword prose comments that start with `@gate`. A pragma on a
+ *skipped* test (`it.skip`, `xit`, …) gets a dedicated error — gating a skip
+ is ambiguous, so either remove the skip and let the gate decide, or keep the
+ plain skip and drop the pragma. (A skip *without* a pragma is left alone.)
+- A `describe`-level gate does not reach `it.each` tests (they bypass the
+ `it` wrapper) — branch inside the body with the runtime `gate()` instead.
+- A gated-false body that *stalls* rather than throwing wastes the full Jest
+ timeout. Under a static gate (native `test.failing`) the timeout counts as
+ the expected failure, so the test passes — slowly; under a lazy gate the
+ runtime inversion only absorbs thrown errors, so the timeout fails the suite
+ anyway. In practice `createRouterAct` and Playwright fail fast instead of
+ stalling.
+- Only the test body is gated. A failure from an `afterEach` (e.g. the redbox
+ matchers) still fails the test.
+- `jest.retryTimes(1)` is on for non-dev CI. A stale gate fails deterministically
+ on both attempts, but a *flaky* gated-false test now "passes" whenever it
+ happens to fail.
+- A gated test's title is unchanged (React renames its to
+ `[GATED, SHOULD FAIL] …`; we can't, because a lazy gate is not decided when
+ titles are fixed). The `⚠ gated test failed as expected` line is the only
+ signal in the log today.
+
+## Tests
+
+`test/unit/gate/` covers the transform, the expression language, and the runtime.
+The stale-gate *failure* cannot be asserted from inside Jest — a test that must
+fail cannot report itself as passing — so it is verified by hand:
+
+```sh
+# add `// @gate dev` above a test that passes in start mode, then:
+NEXT_SKIP_ISOLATE=1 pnpm test-start test/e2e/app-dir/segment-cache/basic
+# => FAIL … Gated test passed unexpectedly … The gate is stale
+```
+
+A child-process harness that automates this (the pattern React uses in
+`scripts/babel/__tests__/transform-test-gate-pragma-test.js`) is a worthwhile
+follow-up.
diff --git a/test/lib/gate/conditions.ts b/test/lib/gate/conditions.ts
new file mode 100644
index 000000000000..f028cf748439
--- /dev/null
+++ b/test/lib/gate/conditions.ts
@@ -0,0 +1,207 @@
+/**
+ * The `@gate` condition registry.
+ *
+ * Every name that may appear inside a `// @gate` / `// @force-gate` pragma has
+ * to be declared here. Referencing an undeclared name fails the whole test
+ * suite at collection time, so a typo can never silently disable a gate.
+ *
+ * The registry is deliberately hand-written rather than derived from the
+ * `next.config` schema: a gate is a claim about which *test-matrix dimension*
+ * explains a failure, and that claim is worth spelling out. Keep the list small
+ * and meaningful.
+ *
+ * ## The two tiers
+ *
+ * **`staticCondition`** — the value is known before any test runs (run mode,
+ * bundler, React version). These are the only conditions `@force-gate` accepts,
+ * because a real Jest skip has to be decided while tests are being collected.
+ *
+ * **`lazyCondition`** — a predicate over the *resolved* `next.config` of the
+ * fixture the suite booted (`NextInstance.getResolvedConfig()`). The value is
+ * read the first time a gate asks for it, which is inside the test body,
+ * because nothing about the fixture exists at collection time.
+ *
+ * Read the resolved config, never `process.env`: `__NEXT_CACHE_COMPONENTS=true`
+ * (set by the `--experimental` shard in `scripts/run-jest.sh`) is only applied
+ * when the fixture has not set `cacheComponents` itself, and resolution implies
+ * flags a fixture never mentions — `cacheComponents: true` alone turns on
+ * `experimental.ppr` and `experimental.cachedNavigations`.
+ *
+ * ## Adding a condition
+ *
+ * 1. Pick a bare name that reads well after `@gate` and `!`.
+ * 2. Add it below with a one-line description of what it means.
+ * 3. For a lazy condition, read the key off the resolved config — remember that
+ * some keys moved out of `experimental` (`config.cacheComponents`) while
+ * others are still under it (`config.experimental.ppr`), and that some
+ * normalize to an object rather than a boolean
+ * (`experimental.prefetchInlining`). Returning the raw value is fine:
+ * expressions coerce by truthiness, and `===` comparisons see the raw value.
+ *
+ * Values do not have to be booleans. `mode` and `bundler` are strings so that
+ * `// @gate mode === 'deploy'` works.
+ */
+
+import type { ResolvedNextConfig } from './resolved-config'
+import { getGateTestContext } from './test-context'
+
+export type ConditionValue = unknown
+
+export type StaticCondition = {
+ kind: 'static'
+ description: string
+ value: () => ConditionValue
+}
+
+export type LazyCondition = {
+ kind: 'lazy'
+ description: string
+ value: (config: ResolvedNextConfig) => ConditionValue
+}
+
+export type Condition = StaticCondition | LazyCondition
+
+function staticCondition(
+ description: string,
+ value: () => ConditionValue
+): StaticCondition {
+ return { kind: 'static', description, value }
+}
+
+function lazyCondition(
+ description: string,
+ value: (config: ResolvedNextConfig) => ConditionValue
+): LazyCondition {
+ return { kind: 'lazy', description, value }
+}
+
+export const conditions: Record = {
+ // --- static: the shape of this test run -----------------------------------
+
+ mode: staticCondition(
+ "the e2e run mode: 'dev' | 'start' | 'deploy'",
+ () => getGateTestContext().mode
+ ),
+ dev: staticCondition(
+ 'running `next dev`',
+ () => getGateTestContext().mode === 'dev'
+ ),
+ start: staticCondition(
+ 'running `next build` + `next start`',
+ () => getGateTestContext().mode === 'start'
+ ),
+ deploy: staticCondition(
+ 'running against a real deployment',
+ () => getGateTestContext().mode === 'deploy'
+ ),
+
+ // Semantic aliases for `!dev`. A gate is a claim about *why* a suite cannot
+ // run, so prefer the name that states the reason over the bare mode check.
+ prod: staticCondition(
+ 'the app was built with `next build` (`start` or `deploy`)',
+ () => getGateTestContext().mode !== 'dev'
+ ),
+ prefetching: staticCondition(
+ 'links prefetch — disabled in dev, the usual reason a suite skips it',
+ () => getGateTestContext().mode !== 'dev'
+ ),
+
+ bundler: staticCondition(
+ "the bundler under test: 'turbopack' | 'rspack' | 'webpack'",
+ () => getGateTestContext().bundler
+ ),
+ turbopack: staticCondition(
+ 'bundling with Turbopack',
+ () => getGateTestContext().bundler === 'turbopack'
+ ),
+ rspack: staticCondition(
+ 'bundling with Rspack',
+ () => getGateTestContext().bundler === 'rspack'
+ ),
+ webpack: staticCondition(
+ 'bundling with webpack',
+ () => getGateTestContext().bundler === 'webpack'
+ ),
+
+ react18: staticCondition(
+ 'the fixture installs React 18 instead of the default React version',
+ () => getGateTestContext().react18
+ ),
+ wasm: staticCondition(
+ 'using the wasm SWC binary (`NEXT_TEST_WASM`)',
+ () => getGateTestContext().wasm
+ ),
+ ci: staticCondition('running in CI (`NEXT_TEST_CI`)', () =>
+ Boolean(process.env.NEXT_TEST_CI)
+ ),
+
+ // Always false, so `// @gate FIXME` marks a test as a known failure without
+ // inventing a condition for it. Mirrors the same convention in React's
+ // scripts/jest/TestFlags.js. Prefer a real condition whenever one exists —
+ // these two say "we know this is broken" and nothing about why.
+ FIXME: staticCondition('known failure, no condition attached', () => false),
+ TODO: staticCondition('known failure, no condition attached', () => false),
+
+ // --- lazy: the fixture's resolved next.config ------------------------------
+
+ cacheComponents: lazyCondition(
+ 'Cache Components are enabled for the fixture',
+ (config) => config.cacheComponents
+ ),
+ ppr: lazyCondition(
+ 'partial prerendering is enabled (implied by `cacheComponents`)',
+ (config) => config.experimental?.ppr
+ ),
+ cachedNavigations: lazyCondition(
+ 'client navigations are cached (implied by `cacheComponents`)',
+ (config) => config.experimental?.cachedNavigations
+ ),
+ optimisticRouting: lazyCondition(
+ 'optimistic routing is enabled',
+ (config) => config.experimental?.optimisticRouting
+ ),
+ concurrentRouterQueue: lazyCondition(
+ 'the concurrent router queue fork is enabled',
+ (config) => config.experimental?.concurrentRouterQueue
+ ),
+ dynamicOnHover: lazyCondition(
+ 'dynamic prefetches are triggered on hover',
+ (config) => config.experimental?.dynamicOnHover
+ ),
+ useOffline: lazyCondition(
+ 'the `useOffline()` hook is enabled for the fixture',
+ (config) => config.experimental?.useOffline
+ ),
+ prefetchInlining: lazyCondition(
+ 'prefetches are inlined into the HTML payload; resolves to an object ' +
+ '(`{maxSize, maxBundleSize}`) or `false`',
+ (config) => config.experimental?.prefetchInlining
+ ),
+ output: lazyCondition(
+ "`output` in the resolved config: 'standalone' | 'export' | undefined",
+ (config) => config.output
+ ),
+ basePath: lazyCondition(
+ 'the fixture serves the app from a base path (a string, `` when unset)',
+ (config) => config.basePath
+ ),
+ trailingSlash: lazyCondition(
+ 'URLs are normalized to a trailing slash',
+ (config) => config.trailingSlash
+ ),
+}
+
+export function isDeclared(name: string): boolean {
+ return Object.prototype.hasOwnProperty.call(conditions, name)
+}
+
+export function getCondition(name: string): Condition {
+ if (!isDeclared(name)) {
+ throw new Error(
+ `\`@gate\` references an undeclared condition "${name}".\n\n` +
+ `Declare it in test/lib/gate/conditions.ts, or fix the typo. ` +
+ `Declared conditions: ${Object.keys(conditions).sort().join(', ')}.`
+ )
+ }
+ return conditions[name]
+}
diff --git a/test/lib/gate/expr.ts b/test/lib/gate/expr.ts
new file mode 100644
index 000000000000..a52a9056edec
--- /dev/null
+++ b/test/lib/gate/expr.ts
@@ -0,0 +1,232 @@
+/**
+ * The tiny expression language used inside a `// @gate` pragma.
+ *
+ * ```
+ * expression → binary ( ( "||" | "&&" ) binary )* ;
+ * binary → unary ( ( "==" | "!=" | "===" | "!==" ) unary )* ;
+ * unary → "!" unary | primary ;
+ * primary → NAME | STRING | BOOLEAN | "(" expression ")" ;
+ * ```
+ *
+ * This mirrors the grammar React uses for its own `@gate` pragmas
+ * (`scripts/babel/transform-test-gate-pragma.js` in facebook/react), so
+ * pragmas read the same in both repos:
+ *
+ * ```
+ * // @gate !dev
+ * // @gate mode === 'start' && !cacheComponents
+ * ```
+ *
+ * `NAME` is a condition declared in `./conditions.ts`. Unlike React, the
+ * expression is parsed at *runtime* rather than compiled by the transform,
+ * which keeps the source rewrite trivial and lets the runtime report the
+ * pragma text verbatim in error messages.
+ */
+
+export type ExprNode =
+ | { type: 'literal'; value: string | boolean }
+ | { type: 'condition'; name: string }
+ | { type: 'not'; argument: ExprNode }
+ | { type: 'logical'; op: '&&' | '||'; left: ExprNode; right: ExprNode }
+ | { type: 'compare'; op: '=='; left: ExprNode; right: ExprNode }
+ | { type: 'compare'; op: '!='; left: ExprNode; right: ExprNode }
+
+export type ParsedExpression = {
+ node: ExprNode
+ /** Every condition name referenced by the expression, deduplicated. */
+ names: string[]
+}
+
+type Token =
+ | { type: 'name'; name: string }
+ | { type: 'string'; value: string }
+ | { type: 'boolean'; value: boolean }
+ | { type: '&&' | '||' | '==' | '!=' | '!' | '(' | ')' }
+
+const NAME_RE = /[a-zA-Z_$][0-9a-zA-Z_$]*/y
+
+function tokenize(source: string): Token[] {
+ const tokens: Token[] = []
+ let i = 0
+ while (i < source.length) {
+ const char = source[i]
+
+ if (char === '"' || char === "'") {
+ let value = ''
+ i++
+ while (i < source.length && source[i] !== char) value += source[i++]
+ if (source[i] !== char) {
+ throw new SyntaxError(
+ `Unterminated string in \`${source}\` (missing closing ${char}).`
+ )
+ }
+ i++
+ tokens.push({ type: 'string', value })
+ continue
+ }
+
+ if (/\s/.test(char)) {
+ i++
+ continue
+ }
+
+ const next3 = source.slice(i, i + 3)
+ if (next3 === '===') {
+ tokens.push({ type: '==' })
+ i += 3
+ continue
+ }
+ if (next3 === '!==') {
+ tokens.push({ type: '!=' })
+ i += 3
+ continue
+ }
+
+ const next2 = source.slice(i, i + 2)
+ if (next2 === '&&' || next2 === '||' || next2 === '==' || next2 === '!=') {
+ tokens.push({ type: next2 })
+ i += 2
+ continue
+ }
+
+ if (char === '(' || char === ')' || char === '!') {
+ tokens.push({ type: char })
+ i++
+ continue
+ }
+
+ NAME_RE.lastIndex = i
+ const match = NAME_RE.exec(source)
+ if (match) {
+ const name = match[0]
+ if (name === 'true' || name === 'false') {
+ tokens.push({ type: 'boolean', value: name === 'true' })
+ } else {
+ tokens.push({ type: 'name', name })
+ }
+ i += name.length
+ continue
+ }
+
+ throw new SyntaxError(
+ `Unexpected character ${JSON.stringify(char)} in \`${source}\`.`
+ )
+ }
+ return tokens
+}
+
+/** Parses a pragma condition, collecting the condition names it references. */
+export function parse(source: string): ParsedExpression {
+ const tokens = tokenize(source)
+ const names = new Set()
+ let i = 0
+
+ function expression(): ExprNode {
+ let left = binary()
+ for (;;) {
+ const token = tokens[i]
+ if (token && (token.type === '&&' || token.type === '||')) {
+ i++
+ left = { type: 'logical', op: token.type, left, right: binary() }
+ continue
+ }
+ return left
+ }
+ }
+
+ function binary(): ExprNode {
+ let left = unary()
+ for (;;) {
+ const token = tokens[i]
+ if (token && (token.type === '==' || token.type === '!=')) {
+ i++
+ left = { type: 'compare', op: token.type, left, right: unary() }
+ continue
+ }
+ return left
+ }
+ }
+
+ function unary(): ExprNode {
+ if (tokens[i]?.type === '!') {
+ i++
+ return { type: 'not', argument: unary() }
+ }
+ return primary()
+ }
+
+ function primary(): ExprNode {
+ const token = tokens[i]
+ if (!token) {
+ throw new SyntaxError(`Unexpected end of expression in \`${source}\`.`)
+ }
+ switch (token.type) {
+ case 'boolean':
+ case 'string':
+ i++
+ return { type: 'literal', value: token.value }
+ case 'name':
+ i++
+ names.add(token.name)
+ return { type: 'condition', name: token.name }
+ case '(': {
+ i++
+ const inner = expression()
+ if (tokens[i]?.type !== ')') {
+ throw new SyntaxError(`Missing closing \`)\` in \`${source}\`.`)
+ }
+ i++
+ return inner
+ }
+ default:
+ throw new SyntaxError(`Unexpected \`${token.type}\` in \`${source}\`.`)
+ }
+ }
+
+ const node = expression()
+ if (i !== tokens.length) {
+ throw new SyntaxError(
+ `Unexpected \`${tokens[i].type}\` after a complete expression in ` +
+ `\`${source}\`.`
+ )
+ }
+ return { node, names: [...names] }
+}
+
+function evaluateNode(
+ node: ExprNode,
+ read: (name: string) => unknown
+): unknown {
+ switch (node.type) {
+ case 'literal':
+ return node.value
+ case 'condition':
+ return read(node.name)
+ case 'not':
+ return !evaluateNode(node.argument, read)
+ case 'logical':
+ return node.op === '&&'
+ ? evaluateNode(node.left, read) && evaluateNode(node.right, read)
+ : evaluateNode(node.left, read) || evaluateNode(node.right, read)
+ case 'compare': {
+ const left = evaluateNode(node.left, read)
+ const right = evaluateNode(node.right, read)
+ return node.op === '==' ? left === right : left !== right
+ }
+ default:
+ throw new Error(`Unknown @gate expression node: ${JSON.stringify(node)}`)
+ }
+}
+
+/**
+ * Evaluates a parsed expression. Condition values are coerced by truthiness in
+ * boolean position, so `@gate prefetchInlining` works for a condition whose
+ * value is `false | {maxSize: number}`, and `@gate output === 'export'` works
+ * for string-valued conditions.
+ */
+export function evaluate(
+ node: ExprNode,
+ read: (name: string) => unknown
+): boolean {
+ return Boolean(evaluateNode(node, read))
+}
diff --git a/test/lib/gate/jest-transformer.js b/test/lib/gate/jest-transformer.js
new file mode 100644
index 000000000000..554c1e9b4945
--- /dev/null
+++ b/test/lib/gate/jest-transformer.js
@@ -0,0 +1,100 @@
+// @ts-check
+
+/**
+ * Jest transformer that rewrites `// @gate` pragmas (see
+ * `./pragma-transform.js`) and then delegates to the transformer `next/jest`
+ * would have used on its own (SWC).
+ *
+ * It is wired up by `jest.config.js` via `withGateTransformer()`, which takes
+ * the transformer entry `next/jest` produced and nests it inside this one, so
+ * there is still exactly one source of truth for the SWC options.
+ */
+
+const crypto = require('crypto')
+const fs = require('fs')
+const path = require('path')
+
+const { rewrite } = require('./pragma-transform')
+
+const IS_TEST_FILE = /\.test\.(js|jsx|ts|tsx|mjs)$/
+
+/** The transform key `next/jest` uses for its SWC transformer. */
+const TRANSFORM_KEY = '^.+\\.(js|jsx|ts|tsx|mjs)$'
+
+/**
+ * Hash of this transformer's own sources, mixed into `getCacheKey` so that
+ * editing the pragma rewrite invalidates Jest's transform cache. Jest's
+ * built-in fallback only hashes the file contents and the serialized config,
+ * neither of which changes when this directory does.
+ */
+const SELF_VERSION = (() => {
+ const hash = crypto.createHash('sha1')
+ for (const file of ['jest-transformer.js', 'pragma-transform.js']) {
+ hash.update(fs.readFileSync(path.join(__dirname, file)))
+ }
+ return hash.digest('hex').slice(0, 16)
+})()
+
+/**
+ * @typedef {{ innerTransformer: string, innerOptions: unknown }} GateTransformerConfig
+ */
+
+/** @type {(inputOptions: GateTransformerConfig) => import('@jest/transform').SyncTransformer} */
+function createTransformer(inputOptions) {
+ if (!inputOptions?.innerTransformer) {
+ throw new Error(
+ 'test/lib/gate/jest-transformer.js must be configured through ' +
+ '`withGateTransformer()` in jest.config.js.'
+ )
+ }
+ const innerModule = require(inputOptions.innerTransformer)
+ const inner = innerModule.createTransformer(inputOptions.innerOptions)
+
+ return {
+ process(src, filename, jestOptions) {
+ const rewritten = IS_TEST_FILE.test(filename)
+ ? rewrite(src, filename)
+ : src
+ return inner.process(rewritten, filename, jestOptions)
+ },
+ getCacheKey(src, filename, options) {
+ const base = inner.getCacheKey
+ ? inner.getCacheKey(src, filename, options)
+ : crypto.createHash('sha1').update(src).update(filename).digest('hex')
+ return `${base}:gate-${SELF_VERSION}`
+ },
+ }
+}
+
+/**
+ * Wraps the transformer entry produced by `next/jest` so `@gate` pragmas are
+ * rewritten before SWC compiles the file.
+ *
+ * @template {{ transform?: Record }} T
+ * @param {T} config a resolved Jest config from `next/jest`
+ * @returns {T}
+ */
+function withGateTransformer(config) {
+ const existing = config.transform?.[TRANSFORM_KEY]
+ if (!Array.isArray(existing) || typeof existing[0] !== 'string') {
+ throw new Error(
+ `withGateTransformer: expected next/jest to define a transformer tuple ` +
+ `for ${TRANSFORM_KEY}, found ${JSON.stringify(existing)}. ` +
+ `next/jest's transform shape changed — update ` +
+ `test/lib/gate/jest-transformer.js.`
+ )
+ }
+ const [innerTransformer, innerOptions] = existing
+ return {
+ ...config,
+ transform: {
+ ...config.transform,
+ [TRANSFORM_KEY]: [
+ require.resolve('./jest-transformer.js'),
+ { innerTransformer, innerOptions },
+ ],
+ },
+ }
+}
+
+module.exports = { createTransformer, withGateTransformer, TRANSFORM_KEY }
diff --git a/test/lib/gate/load-config-child.js b/test/lib/gate/load-config-child.js
new file mode 100644
index 000000000000..1ea498554dd4
--- /dev/null
+++ b/test/lib/gate/load-config-child.js
@@ -0,0 +1,88 @@
+// @ts-check
+
+/**
+ * Resolves a fixture's `next.config` and prints it as JSON.
+ *
+ * Run as a child process by `./load-resolved-config.ts`, with the fixture's cwd
+ * and the fixture's exact spawn env. It has to be out of process for two
+ * reasons:
+ *
+ * 1. `loadConfig` calls `loadEnvConfig`, which **mutates the caller's
+ * `process.env`** from the fixture's `.env*` files. Doing that inside a Jest
+ * worker would leak fixture env into every other test in the file.
+ * 2. Resolution reads env vars from the *calling* process
+ * (`__NEXT_CACHE_COMPONENTS`, `__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS`, ...),
+ * and the fixture runs with an env that is not the Jest worker's whenever a
+ * suite passes `nextTestSetup({ env })`.
+ *
+ * Usage: node load-config-child.js
+ */
+
+const MARKER = '__NEXT_GATE_RESOLVED_CONFIG__'
+
+/**
+ * Deep-copies `value` into something `JSON.stringify` can handle: drops
+ * functions and symbols, stringifies regexps, and replaces cycles with
+ * `'[Circular]'`. Anything a `@gate` condition wants to read is plain data.
+ *
+ * @param {unknown} value
+ * @param {WeakSet