From ae622d6ce906f22026ffcad11782ddeb103d4126 Mon Sep 17 00:00:00 2001
From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com>
Date: Wed, 26 Aug 2026 16:01:02 +0200
Subject: [PATCH 1/4] Migrate from box_patterns to deref_patterns (#97924)
TLDR: just enable `deref_patterns` and remove the `box` syntax. And it
just works
`box_patterns` will be removed entirely in the next Rust nightly:
https://github.com/rust-lang/rust/pull/156749
---
crates/next-custom-transforms/src/lib.rs | 2 +-
.../src/transforms/import_analyzer.rs | 2 +-
.../src/transforms/optimize_server_react.rs | 10 +++----
.../src/transforms/server_actions.rs | 24 +++++++--------
.../turbo-tasks-backend/src/backend/mod.rs | 28 ++++++++---------
.../backend/operation/aggregation_update.rs | 2 +-
.../src/backend/operation/connect_child.rs | 15 ++++------
.../src/backend/operation/invalidate.rs | 2 +-
.../crates/turbo-tasks-backend/src/lib.rs | 2 +-
.../src/analyzer/graph/eval_context.rs | 26 +++++++---------
.../src/analyzer/graph/visitor.rs | 8 ++---
.../src/analyzer/imports.rs | 2 +-
.../analyzer/well_known/require_context.rs | 2 +-
.../crates/turbopack-ecmascript/src/lib.rs | 2 +-
.../src/module_fragments/graph.rs | 12 ++++----
.../src/module_fragments/mod.rs | 2 +-
.../src/references/esm/module_item.rs | 4 +--
.../src/references/esm/url.rs | 30 +++++++------------
.../crates/turbopack-ecmascript/src/utils.rs | 4 +--
.../crates/turbopack-trace-server/src/lib.rs | 2 +-
.../crates/turbopack-trace-server/src/main.rs | 2 +-
.../src/self_time_tree.rs | 6 ++--
22 files changed, 87 insertions(+), 102 deletions(-)
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/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs b/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs
index 07d374019628..588919fff858 100644
--- a/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs
+++ b/turbopack/crates/turbo-tasks-backend/src/backend/mod.rs
@@ -551,13 +551,13 @@ impl TurboTasksBackend {
done_event,
))))
}
- Some(InProgressState::InProgress(box InProgressStateInner {
- done_event, ..
- })) => Some(Ok(ReadOutcome::InProgress(listen_to_done_event(
- reader_description,
- tracking,
- done_event,
- )))),
+ Some(InProgressState::InProgress(InProgressStateInner { done_event, .. })) => {
+ Some(Ok(ReadOutcome::InProgress(listen_to_done_event(
+ reader_description,
+ tracking,
+ done_event,
+ ))))
+ }
Some(InProgressState::Canceled) => Some(Err(anyhow::anyhow!(
"{} was canceled",
task.get_task_description()
@@ -1869,7 +1869,7 @@ impl TurboTasksBackend {
done_event,
reason: _,
} => done_event.notify(usize::MAX),
- InProgressState::InProgress(box InProgressStateInner { done_event, .. }) => {
+ InProgressState::InProgress(InProgressStateInner { done_event, .. }) => {
done_event.notify(usize::MAX)
}
InProgressState::Canceled => {}
@@ -2210,7 +2210,7 @@ impl TurboTasksBackend {
is_session_dependent,
});
}
- let &mut InProgressState::InProgress(box InProgressStateInner {
+ let &mut InProgressState::InProgress(InProgressStateInner {
stale,
ref mut new_children,
once_task: is_once_task,
@@ -2224,7 +2224,7 @@ impl TurboTasksBackend {
#[cfg(not(feature = "no_fast_stale"))]
if stale && !is_once_task {
let stale_priority = compute_stale_priority(&task);
- let Some(InProgressState::InProgress(box InProgressStateInner {
+ let Some(InProgressState::InProgress(InProgressStateInner {
done_event,
mut new_children,
..
@@ -2609,7 +2609,7 @@ impl TurboTasksBackend {
// Task was canceled in the meantime, so we don't connect the children
return None;
}
- let InProgressState::InProgress(box InProgressStateInner {
+ let InProgressState::InProgress(InProgressStateInner {
#[cfg(not(feature = "no_fast_stale"))]
stale,
once_task: is_once_task,
@@ -2623,7 +2623,7 @@ impl TurboTasksBackend {
#[cfg(not(feature = "no_fast_stale"))]
if *stale && !is_once_task {
let stale_priority = compute_stale_priority(&task);
- let Some(InProgressState::InProgress(box InProgressStateInner { done_event, .. })) =
+ let Some(InProgressState::InProgress(InProgressStateInner { done_event, .. })) =
task.take_in_progress()
else {
unreachable!();
@@ -2685,7 +2685,7 @@ impl TurboTasksBackend {
// Task was canceled in the meantime, so we don't finish it
return (None, None);
}
- let InProgressState::InProgress(box InProgressStateInner {
+ let InProgressState::InProgress(InProgressStateInner {
done_event,
once_task: is_once_task,
stale,
@@ -3250,7 +3250,7 @@ impl TurboTasksBackend {
fn mark_own_task_as_finished(&self, task: TaskId, turbo_tasks: &TurboTasks) {
let mut ctx = self.execute_context(turbo_tasks);
let mut task = ctx.task(task, TaskDataCategory::Data);
- if let Some(InProgressState::InProgress(box InProgressStateInner {
+ if let Some(InProgressState::InProgress(InProgressStateInner {
marked_as_completed,
..
})) = task.get_in_progress_mut()
diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/aggregation_update.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/aggregation_update.rs
index da56d19e9442..733de17d9e54 100644
--- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/aggregation_update.rs
+++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/aggregation_update.rs
@@ -1431,7 +1431,7 @@ impl AggregationUpdateQueue {
self.inner_of_upper_lost_followers(ctx, lost_follower_ids, upper_id, retry);
}
}
- AggregationUpdateJob::AggregatedDataUpdate(box AggregatedDataUpdateJob {
+ AggregationUpdateJob::AggregatedDataUpdate(AggregatedDataUpdateJob {
upper_ids,
update,
}) => {
diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/connect_child.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/connect_child.rs
index 359c9e30cbaf..d098e6ac64f3 100644
--- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/connect_child.rs
+++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/connect_child.rs
@@ -33,9 +33,8 @@ impl ConnectChildOperation {
) {
if let Some(parent_task_id) = parent_task_id {
let mut parent_task = ctx.task(parent_task_id, TaskDataCategory::Meta);
- let Some(InProgressState::InProgress(box InProgressStateInner {
- new_children, ..
- })) = parent_task.get_in_progress()
+ let Some(InProgressState::InProgress(InProgressStateInner { new_children, .. })) =
+ parent_task.get_in_progress()
else {
panic!("Task is not in progress while calling another task: {parent_task:?}");
};
@@ -49,9 +48,8 @@ impl ConnectChildOperation {
if parent_task.children_contains(&child_task_id) {
// It is already connected, we can skip the rest
// but we still need to update the new_children set
- let Some(InProgressState::InProgress(box InProgressStateInner {
- new_children,
- ..
+ let Some(InProgressState::InProgress(InProgressStateInner {
+ new_children, ..
})) = parent_task.get_in_progress_mut()
else {
unreachable!();
@@ -118,9 +116,8 @@ impl ConnectChildOperation {
if let Some(parent_task_id) = parent_task_id {
let mut parent_task = ctx.task(parent_task_id, TaskDataCategory::Meta);
- let Some(InProgressState::InProgress(box InProgressStateInner {
- new_children, ..
- })) = parent_task.get_in_progress_mut()
+ let Some(InProgressState::InProgress(InProgressStateInner { new_children, .. })) =
+ parent_task.get_in_progress_mut()
else {
panic!("Task is not in progress while calling another task: {parent_task:?}");
};
diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/invalidate.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/invalidate.rs
index 554d27edb44f..39908bf4a686 100644
--- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/invalidate.rs
+++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/invalidate.rs
@@ -132,7 +132,7 @@ pub fn make_task_dirty_internal(
#[cfg(feature = "trace_task_dirty")]
let task_name = task.get_task_name();
if make_stale
- && let Some(InProgressState::InProgress(box InProgressStateInner { stale, .. })) =
+ && let Some(InProgressState::InProgress(InProgressStateInner { stale, .. })) =
task.get_in_progress_mut()
&& !*stale
{
diff --git a/turbopack/crates/turbo-tasks-backend/src/lib.rs b/turbopack/crates/turbo-tasks-backend/src/lib.rs
index e7eececc584b..63b227c073fd 100644
--- a/turbopack/crates/turbo-tasks-backend/src/lib.rs
+++ b/turbopack/crates/turbo-tasks-backend/src/lib.rs
@@ -1,5 +1,5 @@
#![feature(anonymous_lifetime_in_impl_trait)]
-#![feature(box_patterns)]
+#![feature(deref_patterns)]
mod backend;
mod backing_storage;
diff --git a/turbopack/crates/turbopack-ecmascript/src/analyzer/graph/eval_context.rs b/turbopack/crates/turbopack-ecmascript/src/analyzer/graph/eval_context.rs
index a386458d96a5..31d799f0c4b7 100644
--- a/turbopack/crates/turbopack-ecmascript/src/analyzer/graph/eval_context.rs
+++ b/turbopack/crates/turbopack-ecmascript/src/analyzer/graph/eval_context.rs
@@ -175,13 +175,13 @@ impl EvalContext {
// Only treat literals as constant undefined, allowing arbitrary values inside here
// would mean that they can have sideeffects, and `JsValue::Constant` can't model
// that.
- arg: box Expr::Lit(_),
+ arg: Expr::Lit(_),
..
}) => JsValue::Constant(ConstantValue::Undefined),
Expr::Unary(UnaryExpr {
op: op!(unary, "-"),
- arg: box Expr::Lit(Lit::Num(n)),
+ arg: Expr::Lit(Lit::Num(n)),
..
}) => JsValue::Constant(ConstantValue::Num(ConstantNumber(-n.value))),
@@ -288,9 +288,9 @@ impl EvalContext {
}) => JsValue::r#in(arena, self.eval(arena, left), self.eval(arena, right)),
&Expr::Cond(CondExpr {
- box ref cons,
- box ref alt,
- box ref test,
+ ref cons,
+ ref alt,
+ ref test,
..
}) => {
let test = self.eval(arena, test);
@@ -309,8 +309,8 @@ impl EvalContext {
Expr::TaggedTpl(TaggedTpl {
tag:
- box Expr::Member(MemberExpr {
- obj: box Expr::Ident(tag_obj),
+ Expr::Member(MemberExpr {
+ obj: Expr::Ident(tag_obj),
prop: MemberProp::Ident(tag_prop),
..
}),
@@ -380,11 +380,7 @@ impl EvalContext {
JsValue::member(arena, obj, prop)
}
- Expr::New(NewExpr {
- callee: box callee,
- args,
- ..
- }) => {
+ Expr::New(NewExpr { callee, args, .. }) => {
let args = args.as_deref().unwrap_or(&[]);
// We currently do not handle spreads.
if args.iter().any(|arg| arg.spread.is_some()) {
@@ -402,7 +398,7 @@ impl EvalContext {
}
Expr::Call(CallExpr {
- callee: Callee::Expr(box callee),
+ callee: Callee::Expr(callee),
args,
..
}) => {
@@ -505,13 +501,13 @@ impl EvalContext {
PropOrSpread::Spread(SpreadElement { expr, .. }) => {
ObjectPart::Spread(self.eval(arena, expr))
}
- PropOrSpread::Prop(box Prop::KeyValue(KeyValueProp { key, box value })) => {
+ PropOrSpread::Prop(Prop::KeyValue(KeyValueProp { key, value })) => {
ObjectPart::KeyValue(
self.eval_prop_name(arena, key),
self.eval(arena, value),
)
}
- PropOrSpread::Prop(box Prop::Shorthand(ident)) => ObjectPart::KeyValue(
+ PropOrSpread::Prop(Prop::Shorthand(ident)) => ObjectPart::KeyValue(
ident.sym.clone().into(),
self.eval(arena, &Expr::Ident(ident.clone())),
),
diff --git a/turbopack/crates/turbopack-ecmascript/src/analyzer/graph/visitor.rs b/turbopack/crates/turbopack-ecmascript/src/analyzer/graph/visitor.rs
index 64713f8665ab..4ae8abe01063 100644
--- a/turbopack/crates/turbopack-ecmascript/src/analyzer/graph/visitor.rs
+++ b/turbopack/crates/turbopack-ecmascript/src/analyzer/graph/visitor.rs
@@ -1259,7 +1259,7 @@ impl<'a> Analyzer<'a, '_> {
Some(path)
}
Expr::Arrow(ArrowExpr {
- body: box BlockStmtOrExpr::BlockStmt(_),
+ body: BlockStmtOrExpr::BlockStmt(_),
..
}) => {
let mut path = as_parent_path(&ast_path);
@@ -1272,7 +1272,7 @@ impl<'a> Analyzer<'a, '_> {
Some(path)
}
Expr::Arrow(ArrowExpr {
- body: box BlockStmtOrExpr::Expr(_),
+ body: BlockStmtOrExpr::Expr(_),
..
}) => {
let mut path = as_parent_path(&ast_path);
@@ -1343,7 +1343,7 @@ impl<'a> Analyzer<'a, '_> {
export_usage,
});
}
- Callee::Expr(box expr) => {
+ Callee::Expr(expr) => {
if let Expr::Member(MemberExpr { obj, prop, .. }) = unparen(expr) {
let obj_value =
BumpBox::new_in(self.eval_context.eval(self.arena, obj), self.arena);
@@ -3116,7 +3116,7 @@ impl<'a> Analyzer<'a, '_> {
self.add_value(
key.to_id(),
- if let Some(box value) = value {
+ if let Some(value) = value {
let value = self.eval_context.eval(self.arena, value);
JsValue::alternatives(BumpVec::from_iter_in(
self.arena,
diff --git a/turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs b/turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs
index 970bbd948a7a..c76bbbb10e85 100644
--- a/turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs
+++ b/turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs
@@ -1400,7 +1400,7 @@ impl Visit for Analyzer<'_> {
MemberProp::Ident(..)
| MemberProp::PrivateName(..)
| MemberProp::Computed(ComputedPropName {
- expr: box Expr::Lit(Lit::Str(_)),
+ expr: Expr::Lit(Lit::Str(_)),
..
})
) && let Expr::Ident(ident) = &*node.obj
diff --git a/turbopack/crates/turbopack-ecmascript/src/analyzer/well_known/require_context.rs b/turbopack/crates/turbopack-ecmascript/src/analyzer/well_known/require_context.rs
index 583185ac93af..ef0facdda3ee 100644
--- a/turbopack/crates/turbopack-ecmascript/src/analyzer/well_known/require_context.rs
+++ b/turbopack/crates/turbopack-ecmascript/src/analyzer/well_known/require_context.rs
@@ -47,7 +47,7 @@ pub fn parse_require_context(args: &[JsValue<'_>]) -> Result {
let mut used_ids = ids_used_by_ignoring_nested(
@@ -1680,17 +1680,17 @@ pub(crate) fn create_turbopack_part_id_assert(dep: PartId) -> ObjectLit {
pub(crate) fn find_turbopack_part_id_in_asserts(asserts: &ObjectLit) -> Option {
asserts.props.iter().find_map(|prop| match prop {
- PropOrSpread::Prop(box Prop::KeyValue(KeyValueProp {
+ PropOrSpread::Prop(Prop::KeyValue(KeyValueProp {
key: PropName::Ident(key),
- value: box Expr::Lit(Lit::Num(chunk_id)),
+ value: Expr::Lit(Lit::Num(chunk_id)),
})) if &*key.sym == ASSERT_CHUNK_KEY => Some(PartId::Internal(
chunk_id.value.abs() as u32,
chunk_id.value.is_sign_positive(),
)),
- PropOrSpread::Prop(box Prop::KeyValue(KeyValueProp {
+ PropOrSpread::Prop(Prop::KeyValue(KeyValueProp {
key: PropName::Ident(key),
- value: box Expr::Lit(Lit::Str(s)),
+ value: Expr::Lit(Lit::Str(s)),
})) if &*key.sym == ASSERT_CHUNK_KEY => match s.value.as_str()? {
"module evaluation" => Some(PartId::ModuleEvaluation),
"exports" => Some(PartId::Exports),
diff --git a/turbopack/crates/turbopack-ecmascript/src/module_fragments/mod.rs b/turbopack/crates/turbopack-ecmascript/src/module_fragments/mod.rs
index c5d4d677fc67..7f087bfe59a7 100644
--- a/turbopack/crates/turbopack-ecmascript/src/module_fragments/mod.rs
+++ b/turbopack/crates/turbopack-ecmascript/src/module_fragments/mod.rs
@@ -524,7 +524,7 @@ pub(super) async fn split_module(asset: Vc) -> Result {
+ ModuleDecl::ExportDefaultExpr(ExportDefaultExpr { expr, .. }) => {
let decl = Decl::Var(Box::new(VarDecl {
span: DUMMY_SP,
ctxt: Default::default(),
@@ -74,7 +74,7 @@ impl EsmModuleItem {
Default::default(),
)
.into(),
- init: Some(Box::new(expr)),
+ init: Some(expr),
definite: false,
}],
}));
diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/url.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/url.rs
index 351f872f557b..ebcfb696b016 100644
--- a/turbopack/crates/turbopack-ecmascript/src/references/esm/url.rs
+++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/url.rs
@@ -271,21 +271,17 @@ impl UrlAssetReferenceCodeGen {
args: Some(args), ..
}) = new_expr
{
- if let Some(ExprOrSpread {
- box expr,
- spread: None,
- }) = args.get_mut(0)
+ if let Some(ExprOrSpread { expr, spread: None }) =
+ args.get_mut(0)
{
- *expr = url_segment_resolver.clone();
+ **expr = url_segment_resolver.clone();
}
- if let Some(ExprOrSpread {
- box expr,
- spread: None,
- }) = args.get_mut(1)
+ if let Some(ExprOrSpread { expr, spread: None }) =
+ args.get_mut(1)
{
if let Some(rewrite) = &rewrite_url_base {
- *expr = rewrite.clone();
+ **expr = rewrite.clone();
} else {
// If rewrite for the base doesn't exists, means
// __turbopack_resolve_module_id_path__
@@ -308,21 +304,17 @@ impl UrlAssetReferenceCodeGen {
args: Some(args), ..
}) = new_expr
{
- if let Some(ExprOrSpread {
- box expr,
- spread: None,
- }) = args.get_mut(0)
+ if let Some(ExprOrSpread { expr, spread: None }) =
+ args.get_mut(0)
{
*expr = request.as_str().into()
}
if let Some(rewrite) = &rewrite_url_base
- && let Some(ExprOrSpread {
- box expr,
- spread: None,
- }) = args.get_mut(1)
+ && let Some(ExprOrSpread { expr, spread: None }) =
+ args.get_mut(1)
{
- *expr = rewrite.clone();
+ **expr = rewrite.clone();
}
}
}
diff --git a/turbopack/crates/turbopack-ecmascript/src/utils.rs b/turbopack/crates/turbopack-ecmascript/src/utils.rs
index 98e42635b4e7..d3850165e665 100644
--- a/turbopack/crates/turbopack-ecmascript/src/utils.rs
+++ b/turbopack/crates/turbopack-ecmascript/src/utils.rs
@@ -35,7 +35,7 @@ pub(crate) fn extract_name_from_member_prop(prop: &MemberProp) -> Option Some(SmallVec::from_buf([ident.sym.as_str().into()])),
MemberProp::Computed(ComputedPropName {
- expr: box Expr::Lit(Lit::Str(s)),
+ expr: Expr::Lit(Lit::Str(s)),
..
}) => s.value.as_str().map(|v| SmallVec::from_buf([v.into()])),
_ => None,
@@ -83,7 +83,7 @@ pub fn js_value_to_pattern(value: &JsValue<'_>) -> Pattern {
ConstantValue::Null => rcstr!("null"),
ConstantValue::Num(ConstantNumber(n)) => n.to_string().into(),
ConstantValue::BigInt(n) => n.to_string().into(),
- ConstantValue::Regex(box (exp, flags)) => format!("/{exp}/{flags}").into(),
+ ConstantValue::Regex((exp, flags)) => format!("/{exp}/{flags}").into(),
ConstantValue::Undefined => rcstr!("undefined"),
}),
JsValue::Url(v, JsValueUrlKind::Relative) => Pattern::Constant(v.as_rcstr()),
diff --git a/turbopack/crates/turbopack-trace-server/src/lib.rs b/turbopack/crates/turbopack-trace-server/src/lib.rs
index a3874769b37a..55b88ebc3819 100644
--- a/turbopack/crates/turbopack-trace-server/src/lib.rs
+++ b/turbopack/crates/turbopack-trace-server/src/lib.rs
@@ -1,4 +1,4 @@
-#![feature(box_patterns)]
+#![feature(deref_patterns)]
#![feature(bufreader_peek)]
use std::{
diff --git a/turbopack/crates/turbopack-trace-server/src/main.rs b/turbopack/crates/turbopack-trace-server/src/main.rs
index ecb62d28bb48..0cd702563c07 100644
--- a/turbopack/crates/turbopack-trace-server/src/main.rs
+++ b/turbopack/crates/turbopack-trace-server/src/main.rs
@@ -1,4 +1,4 @@
-#![feature(box_patterns)]
+#![feature(deref_patterns)]
#![feature(bufreader_peek)]
#[global_allocator]
diff --git a/turbopack/crates/turbopack-trace-server/src/self_time_tree.rs b/turbopack/crates/turbopack-trace-server/src/self_time_tree.rs
index 6e9d4c36698e..e600951ec2c9 100644
--- a/turbopack/crates/turbopack-trace-server/src/self_time_tree.rs
+++ b/turbopack/crates/turbopack-trace-server/src/self_time_tree.rs
@@ -135,7 +135,7 @@ impl SelfTimeTree {
}
fn rebalance(&mut self) {
- if let Some(box SelfTimeChildren {
+ if let Some(SelfTimeChildren {
left,
split_point,
right,
@@ -159,7 +159,7 @@ impl SelfTimeTree {
// right' = (left.right, right) with self.split_point
// split_point' = left.split_point
// direct entries in self and left are put in self and are redistributed
- if let Some(box SelfTimeChildren {
+ if let Some(SelfTimeChildren {
left: left_left,
split_point: left_split_point,
right: left_right,
@@ -189,7 +189,7 @@ impl SelfTimeTree {
// right' = right.right
// split_point' = right.split_point
// direct entries in self and right are put in self and are redistributed
- if let Some(box SelfTimeChildren {
+ if let Some(SelfTimeChildren {
left: right_left,
split_point: right_split_point,
right: right_right,
From 94327e8ade8dedd70a0d007d6a49ace64b75ed70 Mon Sep 17 00:00:00 2001
From: Andrew Clark
Date: Wed, 26 Aug 2026 07:22:11 -0700
Subject: [PATCH 2/4] Port React's @gate test directive to the e2e harness
(#96228)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The test suite has accumulated a bunch of patterns for disabling tests
that are known to fail under some configuration: `it.skip`, `if
(isNextDev) { test('skipped in dev mode', () => {}); return }`, whole
describes toggled off by checking `process.env.__NEXT_CACHE_COMPONENTS`.
These all have the same flaw: nothing tells you when the thing you
skipped starts working. The test stays disabled forever, and the
workaround it was guarding rots along with it.
React solves this with the `@gate` pragma, and this PR ports it to the
Next.js e2e harness:
```ts
// Blocked on the optimization that marks a route as fully static when
// no dynamic params are referenced in Server Components.
// @gate !cacheComponents
it('navigates to a page with a lazily-generated static param', async () => {
// body unchanged
})
```
The test still runs. If the condition is false and the test fails, the
failure is absorbed and the suite stays green. If it _passes_, the suite
fails: the gate is stale, delete it. So instead of a skip that hides a
fixed bug indefinitely, you get a CI failure the day the fix lands.
When the condition is static, the inversion is Jest's own `test.failing`
under the hood. A lazy condition isn't known until the fixture's
resolved config is read inside the body, so those tests invert at
runtime instead.
`// @force-gate ` skips for real — for tests that can't even
be attempted (prefetching is disabled in dev, deploy has no local build
output, the fixture won't build under the condition), and for tests of a
new API, where the disabled state can only throw and running it proves
nothing:
```ts
// Prefetching is disabled in dev, so this suite has nothing to test.
// @force-gate prefetching
describe('segment cache prefetch scheduling', () => {
// ...
})
```
There's no staleness check in that case, so this is a judgment call:
prefer `@gate` when the off state fails for a meaningful reason — the
flag changes behavior that already exists — and `@force-gate` when the
body can only throw because the API doesn't exist. A static condition
(mode, bundler) resolves at collection time into a normal Jest skip. A
lazy condition resolves at runtime, and when a lazy force-gate on a
describe is false, we skip the fixture build entirely — that's what
makes it usable for suites whose fixtures are build-incompatible with
the condition. (One caveat: Jest has no way to skip a test that's
already running, so these report as passing with a warning in the log,
not as skipped.)
Conditions live in a hand-written registry. I considered deriving the
lazy ones from the config schema automatically, but a gate is a claim
about which dimension of the test matrix explains a failure, and I'd
rather each of those claims be spelled out with a description.
Referencing an undeclared name fails the suite at collection time, so a
typo can't silently disable a gate.
The important design decision for lazy conditions is that they read the
fixture's _resolved_ config, never `process.env`. The env var isn't the
truth: `__NEXT_CACHE_COMPONENTS=true` only applies when the fixture
doesn't set `cacheComponents` itself, and config resolution implies
flags the fixture never mentions (`cacheComponents: true` alone turns on
`experimental.ppr`). Resolution happens in a child process, because
in-process `loadConfig` would leak the fixture's `.env` files into the
Jest worker. Suites with no lazy gate never pay for any of this.
The condition expression is parsed using a small grammar (also ported
from the React repo). An expression that doesn't parse fails the suite:
```ts
// @gate mode === 'start' && !cacheComponents
// @gate !(turbopack || rspack)
```
There's also a runtime version, mirroring React's `gate(flags =>
flags.enableFoo)`, for tests that run under both states but assert
differently (and for `it.each`, where the pragma can't attach):
```ts
import { gate } from 'next-test-utils'
it('renders the fallback', async () => {
if (await gate((conditions) => conditions.cacheComponents)) {
// PPR: the fallback is part of the static shell
} else {
// fully dynamic: the fallback streams in
}
})
```
It also accepts the pragma expression language as a string: `await
gate('cacheComponents && !dev')`.
Docs are in `test/lib/gate/README.md`; `test/unit/gate/` covers the
transform, the expression language, and the runtime.
---
.agents/skills/gate-tests/SKILL.md | 194 +++++++
AGENTS.md | 1 +
jest.config.js | 10 +-
scripts/run-jest.sh | 29 +
.../concurrent-router-queue.test.ts | 6 +
.../concurrent-router-queue/next.config.js | 9 +-
test/e2e/app-dir/use-offline/next.config.js | 9 +-
.../app-dir/use-offline/use-offline.test.ts | 9 +-
test/jest-setup-after-env.ts | 7 +
test/lib/e2e-utils/index.ts | 105 +++-
test/lib/gate/README.md | 252 +++++++++
test/lib/gate/conditions.ts | 207 +++++++
test/lib/gate/expr.ts | 232 ++++++++
test/lib/gate/jest-transformer.js | 100 ++++
test/lib/gate/load-config-child.js | 88 +++
test/lib/gate/load-resolved-config.ts | 75 +++
test/lib/gate/pragma-transform.js | 201 +++++++
test/lib/gate/resolved-config.ts | 13 +
test/lib/gate/runtime.ts | 524 ++++++++++++++++++
test/lib/gate/state.ts | 51 ++
test/lib/gate/test-context.ts | 46 ++
test/lib/next-modes/base.ts | 58 ++
test/lib/next-modes/next-dev.ts | 23 +-
test/lib/next-modes/next-start.ts | 18 -
test/lib/next-test-utils.ts | 3 +
test/unit/gate/expr.test.ts | 59 ++
test/unit/gate/pragma-transform.test.ts | 184 ++++++
test/unit/gate/runtime.test.ts | 418 ++++++++++++++
28 files changed, 2884 insertions(+), 47 deletions(-)
create mode 100644 .agents/skills/gate-tests/SKILL.md
create mode 100644 test/lib/gate/README.md
create mode 100644 test/lib/gate/conditions.ts
create mode 100644 test/lib/gate/expr.ts
create mode 100644 test/lib/gate/jest-transformer.js
create mode 100644 test/lib/gate/load-config-child.js
create mode 100644 test/lib/gate/load-resolved-config.ts
create mode 100644 test/lib/gate/pragma-transform.js
create mode 100644 test/lib/gate/resolved-config.ts
create mode 100644 test/lib/gate/runtime.ts
create mode 100644 test/lib/gate/state.ts
create mode 100644 test/lib/gate/test-context.ts
create mode 100644 test/unit/gate/expr.test.ts
create mode 100644 test/unit/gate/pragma-transform.test.ts
create mode 100644 test/unit/gate/runtime.test.ts
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/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/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/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/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
}
+ >
+
+
+
+ )
+}
+
+async function Dynamic(props: Props) {
+ // The prerender ends here, so it doesn't observe params being awaited.
+ await connection()
+
+ return (
+ <>
+
+}
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/production/next-server-nft/next-server-nft.test.ts b/test/production/next-server-nft/next-server-nft.test.ts
index 4ad2a0e0f185..6a914dba10eb 100644
--- a/test/production/next-server-nft/next-server-nft.test.ts
+++ b/test/production/next-server-nft/next-server-nft.test.ts
@@ -707,7 +707,6 @@ async function readNormalizedNFT(next, name) {
"/node_modules/next/dist/server/route-modules/app-page/vendored/contexts/router-context.js",
"/node_modules/next/dist/server/route-modules/app-page/vendored/contexts/server-inserted-html.js",
"/node_modules/next/dist/server/runtime-reacts.external.js",
- "/node_modules/next/dist/server/web/spec-extension/adapters/reflect.js",
"/node_modules/next/dist/shared/lib/deep-freeze.js",
"/node_modules/next/dist/shared/lib/instant-messages.js",
"/node_modules/next/dist/shared/lib/invariant-error.js",