From 8f231d6380699d60a62fdd4f110f69b0bbbffee2 Mon Sep 17 00:00:00 2001
From: "Sebastian \"Sebbie\" Silbermann"
Date: Fri, 20 Feb 2026 11:31:53 -0800
Subject: [PATCH 01/11] [instant] Include declaration location of instant
config in validation errors (#90169)
---
.../next-core/src/next_server/transforms.rs | 13 +-
.../src/next_shared/transforms/mod.rs | 2 +
.../transforms/next_debug_instant_stack.rs | 35 +++
.../src/chain_transforms.rs | 1 +
.../src/transforms/debug_instant_stack.rs | 79 ++++++
.../src/transforms/mod.rs | 1 +
.../next-custom-transforms/tests/fixture.rs | 17 ++
.../debug-instant-stack/with-instant/input.js | 5 +
.../with-instant/output.js | 11 +
.../with-instant/output.map | 1 +
.../without-instant/input.js | 5 +
.../without-instant/output.js | 4 +
.../without-instant/output.map | 1 +
.../next/src/server/app-render/app-render.tsx | 113 +++++----
.../server/app-render/dynamic-rendering.ts | 124 ++++++---
.../instant-validation/instant-validation.tsx | 19 +-
.../instant-validation.test.ts | 237 +++++++++++++++++-
17 files changed, 568 insertions(+), 100 deletions(-)
create mode 100644 crates/next-core/src/next_shared/transforms/next_debug_instant_stack.rs
create mode 100644 crates/next-custom-transforms/src/transforms/debug_instant_stack.rs
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/input.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/output.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/output.map
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/input.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/output.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/output.map
diff --git a/crates/next-core/src/next_server/transforms.rs b/crates/next-core/src/next_server/transforms.rs
index 808c715b02022..4721aa982c60a 100644
--- a/crates/next-core/src/next_server/transforms.rs
+++ b/crates/next-core/src/next_server/transforms.rs
@@ -12,10 +12,11 @@ use crate::{
next_config::NextConfig,
next_server::context::ServerContextType,
next_shared::transforms::{
- get_next_dynamic_transform_rule, get_next_font_transform_rule, get_next_image_rule,
- get_next_lint_transform_rule, get_next_modularize_imports_rule,
- get_next_pages_transforms_rule, get_next_track_dynamic_imports_transform_rule,
- get_server_actions_transform_rule, next_cjs_optimizer::get_next_cjs_optimizer_rule,
+ get_next_debug_instant_stack_rule, get_next_dynamic_transform_rule,
+ get_next_font_transform_rule, get_next_image_rule, get_next_lint_transform_rule,
+ get_next_modularize_imports_rule, get_next_pages_transforms_rule,
+ get_next_track_dynamic_imports_transform_rule, get_server_actions_transform_rule,
+ next_cjs_optimizer::get_next_cjs_optimizer_rule,
next_disallow_re_export_all_in_page::get_next_disallow_export_all_in_page_rule,
next_edge_node_api_assert::next_edge_node_api_assert,
next_middleware_dynamic_assert::get_middleware_dynamic_assert_rule,
@@ -147,6 +148,10 @@ pub async fn get_next_server_transforms_rules(
ServerContextType::Middleware { .. } | ServerContextType::Instrumentation { .. } => false,
};
+ if is_app_dir {
+ rules.push(get_next_debug_instant_stack_rule(mdx_rs));
+ }
+
if is_app_dir &&
// `cacheComponents` is not supported in the edge runtime.
// (also, the code generated by the dynamic imports transform relies on `CacheSignal`, which uses nodejs-specific APIs)
diff --git a/crates/next-core/src/next_shared/transforms/mod.rs b/crates/next-core/src/next_shared/transforms/mod.rs
index da31772f4614c..b20fe491c17ba 100644
--- a/crates/next-core/src/next_shared/transforms/mod.rs
+++ b/crates/next-core/src/next_shared/transforms/mod.rs
@@ -2,6 +2,7 @@ pub(crate) mod debug_fn_name;
pub(crate) mod emotion;
pub(crate) mod modularize_imports;
pub(crate) mod next_cjs_optimizer;
+pub(crate) mod next_debug_instant_stack;
pub(crate) mod next_disallow_re_export_all_in_page;
pub(crate) mod next_dynamic;
pub(crate) mod next_edge_node_api_assert;
@@ -23,6 +24,7 @@ pub(crate) mod swc_ecma_transform_plugins;
use anyhow::Result;
pub use modularize_imports::{ModularizeImportPackageConfig, get_next_modularize_imports_rule};
+pub use next_debug_instant_stack::get_next_debug_instant_stack_rule;
pub use next_dynamic::get_next_dynamic_transform_rule;
pub use next_font::get_next_font_transform_rule;
pub use next_lint::get_next_lint_transform_rule;
diff --git a/crates/next-core/src/next_shared/transforms/next_debug_instant_stack.rs b/crates/next-core/src/next_shared/transforms/next_debug_instant_stack.rs
new file mode 100644
index 0000000000000..7c6cbcff3c179
--- /dev/null
+++ b/crates/next-core/src/next_shared/transforms/next_debug_instant_stack.rs
@@ -0,0 +1,35 @@
+use anyhow::Result;
+use async_trait::async_trait;
+use next_custom_transforms::transforms::debug_instant_stack::debug_instant_stack;
+use swc_core::ecma::ast::Program;
+use turbo_tasks::ResolvedVc;
+use turbopack::module_options::{ModuleRule, ModuleRuleEffect};
+use turbopack_ecmascript::{CustomTransformer, EcmascriptInputTransform, TransformContext};
+
+use super::module_rule_match_js_no_url;
+
+pub fn get_next_debug_instant_stack_rule(enable_mdx_rs: bool) -> ModuleRule {
+ let transform =
+ EcmascriptInputTransform::Plugin(ResolvedVc::cell(Box::new(NextDebugInstantStack {}) as _));
+
+ ModuleRule::new(
+ module_rule_match_js_no_url(enable_mdx_rs),
+ vec![ModuleRuleEffect::ExtendEcmascriptTransforms {
+ preprocess: ResolvedVc::cell(vec![]),
+ main: ResolvedVc::cell(vec![]),
+ postprocess: ResolvedVc::cell(vec![transform]),
+ }],
+ )
+}
+
+#[derive(Debug)]
+struct NextDebugInstantStack {}
+
+#[async_trait]
+impl CustomTransformer for NextDebugInstantStack {
+ #[tracing::instrument(level = tracing::Level::TRACE, name = "debug_instant_stack", skip_all)]
+ async fn transform(&self, program: &mut Program, _ctx: &TransformContext<'_>) -> Result<()> {
+ program.mutate(debug_instant_stack());
+ Ok(())
+ }
+}
diff --git a/crates/next-custom-transforms/src/chain_transforms.rs b/crates/next-custom-transforms/src/chain_transforms.rs
index 9be8bd718f0a9..691fa38a5e10b 100644
--- a/crates/next-custom-transforms/src/chain_transforms.rs
+++ b/crates/next-custom-transforms/src/chain_transforms.rs
@@ -347,6 +347,7 @@ where
crate::transforms::debug_fn_name::debug_fn_name(),
opts.debug_function_name,
),
+ crate::transforms::debug_instant_stack::debug_instant_stack(),
visit_mut_pass(crate::transforms::pure::pure_magic(comments.clone())),
Optional::new(
linter(lint_codemod_comments(comments)),
diff --git a/crates/next-custom-transforms/src/transforms/debug_instant_stack.rs b/crates/next-custom-transforms/src/transforms/debug_instant_stack.rs
new file mode 100644
index 0000000000000..4682bd3df196f
--- /dev/null
+++ b/crates/next-custom-transforms/src/transforms/debug_instant_stack.rs
@@ -0,0 +1,79 @@
+use swc_core::{
+ common::{Span, Spanned},
+ ecma::{
+ ast::*,
+ visit::{fold_pass, Fold},
+ },
+ quote,
+};
+
+pub fn debug_instant_stack() -> impl Pass {
+ fold_pass(DebugInstantStack {
+ instant_export_span: None,
+ })
+}
+
+struct DebugInstantStack {
+ instant_export_span: Option,
+}
+
+impl Fold for DebugInstantStack {
+ fn fold_module_items(&mut self, items: Vec) -> Vec {
+ // Scan for `export const unstable_instant = ...`
+ for item in &items {
+ if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) = item {
+ if let Decl::Var(var_decl) = &export_decl.decl {
+ for decl in &var_decl.decls {
+ if let Pat::Ident(ident) = &decl.name {
+ if ident.id.sym == "unstable_instant" {
+ if let Some(init) = &decl.init {
+ self.instant_export_span = Some(init.span());
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if let Some(source_span) = self.instant_export_span {
+ let mut new_items = items;
+
+ // TODO: Change React to deserialize errors with a zero-length message
+ // instead of using a fallback message ("no message was provided").
+ // We're working around this by using a message that is empty
+ // after trimming but isn't to JavaScript before trimming (' '.length === 1).
+ let mut new_error = quote!("new Error(' ')" as Expr);
+ if let Expr::New(new_expr) = &mut new_error {
+ new_expr.span = source_span;
+ }
+
+ let mut cons = quote!(
+ "function unstable_instant() {
+ const error = $new_error
+ error.name = 'Instant Validation'
+ return error
+ }" as Expr,
+ new_error: Expr = new_error,
+ );
+
+ // Patch source_span onto the Function
+ // for sourcemap mapping back to the unstable_instant config value
+ if let Expr::Fn(f) = &mut cons {
+ f.function.span = source_span;
+ }
+
+ let export = quote!(
+ "export const __debugCreateInstantConfigStack =
+ process.env.NODE_ENV !== 'production' ? $cons : null"
+ as ModuleItem,
+ cons: Expr = cons,
+ );
+
+ new_items.push(export);
+ new_items
+ } else {
+ items
+ }
+ }
+}
diff --git a/crates/next-custom-transforms/src/transforms/mod.rs b/crates/next-custom-transforms/src/transforms/mod.rs
index 287bfec0a2472..3cfff9c47f6ce 100644
--- a/crates/next-custom-transforms/src/transforms/mod.rs
+++ b/crates/next-custom-transforms/src/transforms/mod.rs
@@ -1,6 +1,7 @@
pub mod cjs_finder;
pub mod cjs_optimizer;
pub mod debug_fn_name;
+pub mod debug_instant_stack;
pub mod disallow_re_export_all_in_page;
pub mod dynamic;
pub mod fonts;
diff --git a/crates/next-custom-transforms/tests/fixture.rs b/crates/next-custom-transforms/tests/fixture.rs
index a8be20c637278..dd3e13752004f 100644
--- a/crates/next-custom-transforms/tests/fixture.rs
+++ b/crates/next-custom-transforms/tests/fixture.rs
@@ -8,6 +8,7 @@ use bytes_str::BytesStr;
use next_custom_transforms::transforms::{
cjs_optimizer::cjs_optimizer,
debug_fn_name::debug_fn_name,
+ debug_instant_stack::debug_instant_stack,
dynamic::{next_dynamic, NextDynamicMode},
fonts::{next_font_loaders, Config as FontLoaderConfig},
named_import_transform::named_import_transform,
@@ -869,6 +870,22 @@ fn test_debug_name(input: PathBuf) {
);
}
+#[fixture("tests/fixture/debug-instant-stack/**/input.js")]
+fn test_debug_instant_stack(input: PathBuf) {
+ let output = input.parent().unwrap().join("output.js");
+
+ test_fixture(
+ syntax(),
+ &|_| debug_instant_stack(),
+ &input,
+ &output,
+ FixtureTestConfig {
+ sourcemap: true,
+ ..Default::default()
+ },
+ );
+}
+
#[fixture("tests/fixture/edge-assert/**/input.js")]
fn test_edge_assert(input: PathBuf) {
let output = input.parent().unwrap().join("output.js");
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/input.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/input.js
new file mode 100644
index 0000000000000..3d789924d3c29
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/input.js
@@ -0,0 +1,5 @@
+export const unstable_instant = { prefetch: 'static' }
+
+export default function Page() {
+ return Hello
+}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/output.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/output.js
new file mode 100644
index 0000000000000..c2930c7812ff0
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/output.js
@@ -0,0 +1,11 @@
+export const unstable_instant = {
+ prefetch: 'static'
+};
+export default function Page() {
+ return Hello
;
+}
+export const __debugCreateInstantConfigStack = process.env.NODE_ENV !== 'production' ? function unstable_instant() {
+ const error = new Error(' ');
+ error.name = 'Instant Validation';
+ return error;
+} : null;
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/output.map b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/output.map
new file mode 100644
index 0000000000000..da79b18a669fc
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-instant/output.map
@@ -0,0 +1 @@
+{"version":3,"sources":["input.js"],"sourcesContent":["export const unstable_instant = { prefetch: 'static' }\n\nexport default function Page() {\n return Hello
\n}\n"],"names":[],"mappings":"AAAA,OAAO,MAAM,mBAAmB;IAAE,UAAU;AAAS,EAAC;AAEtD,eAAe,SAAS;IACtB,QAAQ,IAAI,KAAK,EAAE;AACrB;uFAJgC;kBAAA"}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/input.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/input.js
new file mode 100644
index 0000000000000..62390ed7d97f7
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/input.js
@@ -0,0 +1,5 @@
+export const revalidate = 60
+
+export default function Page() {
+ return Hello
+}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/output.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/output.js
new file mode 100644
index 0000000000000..c28954e6b3dfd
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/output.js
@@ -0,0 +1,4 @@
+export const revalidate = 60;
+export default function Page() {
+ return Hello
;
+}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/output.map b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/output.map
new file mode 100644
index 0000000000000..f842e4de89053
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/without-instant/output.map
@@ -0,0 +1 @@
+{"version":3,"sources":["input.js"],"sourcesContent":["export const revalidate = 60\n\nexport default function Page() {\n return Hello
\n}\n"],"names":[],"mappings":"AAAA,OAAO,MAAM,aAAa,GAAE;AAE5B,eAAe,SAAS;IACtB,QAAQ,IAAI,KAAK,EAAE;AACrB"}
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index 952ee1758af18..a3a9ededd1b5b 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -4144,7 +4144,7 @@ async function validateInstantConfigs(
const {
tree: validationRouteTree,
treeNodes,
- navigationParents,
+ validationTasks,
segmentsWithInstantConfigs,
} = init
@@ -4155,6 +4155,7 @@ async function validateInstantConfigs(
? instantConfig.prefetch === 'runtime'
: false
})
+
const clientReferenceManifest = getClientReferenceManifest()
const {
@@ -4175,59 +4176,66 @@ async function validateInstantConfigs(
const getFilepathForSegment = (segmentPath: InstantValidation.SegmentPath) =>
treeNodes.get(segmentPath)?.module?.conventionPath
+ const getCreateInstantStackForSegment = (
+ segmentPath: InstantValidation.SegmentPath
+ ) => treeNodes.get(segmentPath)?.module?.createInstantStack ?? null
+
+ for (const { parents, target } of validationTasks) {
+ const createInstantStack = getCreateInstantStackForSegment(target)
+ for (const navigationParent of parents) {
+ debug?.(
+ `-------------------------------\n` +
+ `Validating navigation\n` +
+ ` from '${navigationParent}/*' (${getFilepathForSegment(navigationParent) ?? ''})\n` +
+ ` to '${workAsyncStorage.getStore()!.route}'`
+ )
+ const initialResults = await validateInstantConfigNavigation(
+ initialRscPayload,
+ cache,
+ startTime,
+ stageEndTimes,
+ rootParams,
+ ctx,
+ hmrRefreshHash,
+ validationRouteTree,
+ navigationParent,
+ false, // use static stage for static segments
+ createInstantStack
+ )
+ if (initialResults.errors.length === 0) {
+ debug?.(` ✅ Validation successful`)
+ }
- for (const navigationParent of navigationParents) {
- // TODO(instant-validation): report which segment had errors
- debug?.(
- `-------------------------------\n` +
- `Validating navigation\n` +
- ` from '${navigationParent}/*' (${getFilepathForSegment(navigationParent) ?? ''})\n` +
- ` to '${workAsyncStorage.getStore()!.route}'`
- )
- const initialResults = await validateInstantConfigNavigation(
- initialRscPayload,
- cache,
- startTime,
- stageEndTimes,
- rootParams,
- ctx,
- hmrRefreshHash,
- validationRouteTree,
- navigationParent,
- false // use static stage for static segments
- )
- if (initialResults.errors.length === 0) {
- debug?.(` ✅ Validation successful`)
- }
-
- if (initialResults.errors.length > 0) {
- if (initialResults.dynamicHoleKind !== DynamicHoleKind.Dynamic) {
- debug?.(' Retrying to gather more info...')
- const runtimeResults = await validateInstantConfigNavigation(
- initialRscPayload,
- cache,
- startTime,
- stageEndTimes,
- rootParams,
- ctx,
- hmrRefreshHash,
- validationRouteTree,
- navigationParent,
- true // use runtime stage for static segments instead
- )
- if (runtimeResults.errors.length > 0) {
- // The errors remained in the runtime stage, so they were caused by a dynamic access.
- debug?.(
- ` ❌ Failed after runtime retry (${runtimeResults.errors.length} errors)`
+ if (initialResults.errors.length > 0) {
+ if (initialResults.dynamicHoleKind !== DynamicHoleKind.Dynamic) {
+ debug?.(' Retrying to gather more info...')
+ const runtimeResults = await validateInstantConfigNavigation(
+ initialRscPayload,
+ cache,
+ startTime,
+ stageEndTimes,
+ rootParams,
+ ctx,
+ hmrRefreshHash,
+ validationRouteTree,
+ navigationParent,
+ true, // use runtime stage for static segments instead
+ createInstantStack
)
- return runtimeResults.errors
+ if (runtimeResults.errors.length > 0) {
+ // The errors remained in the runtime stage, so they were caused by a dynamic access.
+ debug?.(
+ ` ❌ Failed after runtime retry (${runtimeResults.errors.length} errors)`
+ )
+ return runtimeResults.errors
+ }
+ // Otherwise, the errors disappeared in the runtime stage, so they were caused
+ // by a runtime access. report the original errors.
}
- // Otherwise, the errors disappeared in the runtime stage, so they were caused
- // by a runtime access. report the original errors.
- }
- debug?.(` ❌ Failed (${initialResults.errors.length} errors)`)
- return initialResults.errors
+ debug?.(` ❌ Failed (${initialResults.errors.length} errors)`)
+ return initialResults.errors
+ }
}
}
@@ -4245,7 +4253,8 @@ async function validateInstantConfigNavigation(
hmrRefreshHash: string | undefined,
routeTree: InstantValidation.RouteTree,
navigationParent: InstantValidation.SegmentPath,
- useRuntimeStageForPartialSegments: boolean
+ useRuntimeStageForPartialSegments: boolean,
+ createInstantStack: (() => Error) | null
): Promise<{ dynamicHoleKind: DynamicHoleKind; errors: Array }> {
const { implicitTags, nonce, workStore } = ctx
const isDebugChannelEnabled = !!ctx.renderOpts.setReactDebugChannel
@@ -4261,7 +4270,7 @@ async function validateInstantConfigNavigation(
const preinitScripts = () => {}
const { ServerInsertedHTMLProvider } = createServerInsertedHTML()
- const dynamicValidation = createInstantValidationState()
+ const dynamicValidation = createInstantValidationState(createInstantStack)
const boundaryState = createValidationBoundaryTracking()
const finalClientPrerenderStore: PrerenderStore = {
diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts
index 043f09a1ffb89..3c97df63e2512 100644
--- a/packages/next/src/server/app-render/dynamic-rendering.ts
+++ b/packages/next/src/server/app-render/dynamic-rendering.ts
@@ -769,7 +769,11 @@ export function trackAllowedDynamicAccess(
'. This delays the entire page from rendering, resulting in a ' +
'slow user experience. Learn more: ' +
'https://nextjs.org/docs/messages/blocking-route'
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ null
+ )
dynamicValidation.dynamicErrors.push(error)
return
}
@@ -792,9 +796,12 @@ export type InstantValidationState = {
dynamicErrors: Array
validationPreventingErrors: Array
thrownErrorsOutsideBoundary: Array
+ createInstantStack: (() => Error) | null
}
-export function createInstantValidationState(): InstantValidationState {
+export function createInstantValidationState(
+ createInstantStack: (() => Error) | null
+): InstantValidationState {
return {
hasDynamicMetadata: false,
hasAllowedClientDynamicAboveBoundary: false,
@@ -804,6 +811,7 @@ export function createInstantValidationState(): InstantValidationState {
dynamicErrors: [],
validationPreventingErrors: [],
thrownErrorsOutsideBoundary: [],
+ createInstantStack,
}
}
@@ -825,7 +833,11 @@ export function trackDynamicHoleInNavigation(
? `Runtime data such as \`cookies()\`, \`headers()\`, \`params\`, or \`searchParams\` was accessed inside \`generateMetadata\` or you have file-based metadata such as icons that depend on dynamic params segments.`
: `Uncached data or \`connection()\` was accessed inside \`generateMetadata\`.`
const message = `Route "${workStore.route}": ${usageDescription} Except for this instance, the page would have been entirely prerenderable which may have been the intended behavior. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ dynamicValidation.createInstantStack
+ )
dynamicValidation.dynamicMetadata = error
return
}
@@ -835,7 +847,11 @@ export function trackDynamicHoleInNavigation(
? `Runtime data such as \`cookies()\`, \`headers()\`, \`params\`, or \`searchParams\` was accessed inside \`generateViewport\`.`
: `Uncached data or \`connection()\` was accessed inside \`generateViewport\`.`
const message = `Route "${workStore.route}": ${usageDescription} This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ dynamicValidation.createInstantStack
+ )
dynamicValidation.dynamicErrors.push(error)
return
}
@@ -863,7 +879,8 @@ export function trackDynamicHoleInNavigation(
const message = `Route "${workStore.route}": Could not validate \`unstable_instant\` because a Client Component in a parent segment prevented the page from rendering.`
const error = createErrorWithComponentOrOwnerStack(
message,
- componentStack
+ componentStack,
+ dynamicValidation.createInstantStack
)
dynamicValidation.validationPreventingErrors.push(error)
return
@@ -902,9 +919,14 @@ export function trackDynamicHoleInNavigation(
if (clientDynamic.syncDynamicErrorWithStack) {
// This task was the task that called the sync error.
- dynamicValidation.dynamicErrors.push(
- clientDynamic.syncDynamicErrorWithStack
- )
+ const syncError = clientDynamic.syncDynamicErrorWithStack
+ if (
+ dynamicValidation.createInstantStack !== null &&
+ syncError.cause === undefined
+ ) {
+ syncError.cause = dynamicValidation.createInstantStack()
+ }
+ dynamicValidation.dynamicErrors.push(syncError)
return
}
@@ -913,7 +935,11 @@ export function trackDynamicHoleInNavigation(
? `Runtime data such as \`cookies()\`, \`headers()\`, \`params\`, or \`searchParams\` was accessed outside of \`\`.`
: `Uncached data or \`connection()\` was accessed outside of \`\`.`
const message = `Route "${workStore.route}": ${usageDescription} This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/blocking-route`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ dynamicValidation.createInstantStack
+ )
dynamicValidation.dynamicErrors.push(error)
return
}
@@ -942,12 +968,20 @@ export function trackDynamicHoleInRuntimeShell(
return
} else if (hasMetadataRegex.test(componentStack)) {
const message = `Route "${workStore.route}": Uncached data or \`connection()\` was accessed inside \`generateMetadata\`. Except for this instance, the page would have been entirely prerenderable which may have been the intended behavior. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ null
+ )
dynamicValidation.dynamicMetadata = error
return
} else if (hasViewportRegex.test(componentStack)) {
const message = `Route "${workStore.route}": Uncached data or \`connection()\` was accessed inside \`generateViewport\`. This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ null
+ )
dynamicValidation.dynamicErrors.push(error)
return
} else if (
@@ -975,7 +1009,11 @@ export function trackDynamicHoleInRuntimeShell(
}
const message = `Route "${workStore.route}": Uncached data or \`connection()\` was accessed outside of \`\`. This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/blocking-route`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ null
+ )
dynamicValidation.dynamicErrors.push(error)
return
}
@@ -991,12 +1029,20 @@ export function trackDynamicHoleInStaticShell(
return
} else if (hasMetadataRegex.test(componentStack)) {
const message = `Route "${workStore.route}": Runtime data such as \`cookies()\`, \`headers()\`, \`params\`, or \`searchParams\` was accessed inside \`generateMetadata\` or you have file-based metadata such as icons that depend on dynamic params segments. Except for this instance, the page would have been entirely prerenderable which may have been the intended behavior. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ null
+ )
dynamicValidation.dynamicMetadata = error
return
} else if (hasViewportRegex.test(componentStack)) {
const message = `Route "${workStore.route}": Runtime data such as \`cookies()\`, \`headers()\`, \`params\`, or \`searchParams\` was accessed inside \`generateViewport\`. This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ null
+ )
dynamicValidation.dynamicErrors.push(error)
return
} else if (
@@ -1023,7 +1069,11 @@ export function trackDynamicHoleInStaticShell(
return
} else {
const message = `Route "${workStore.route}": Runtime data such as \`cookies()\`, \`headers()\`, \`params\`, or \`searchParams\` was accessed outside of \`\`. This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/blocking-route`
- const error = createErrorWithComponentOrOwnerStack(message, componentStack)
+ const error = createErrorWithComponentOrOwnerStack(
+ message,
+ componentStack,
+ null
+ )
dynamicValidation.dynamicErrors.push(error)
return
}
@@ -1035,14 +1085,16 @@ export function trackDynamicHoleInStaticShell(
*/
function createErrorWithComponentOrOwnerStack(
message: string,
- componentStack: string
+ componentStack: string,
+ createInstantStack: (() => Error) | null
) {
const ownerStack =
process.env.NODE_ENV !== 'production' && React.captureOwnerStack
? React.captureOwnerStack()
: null
- const error = new Error(message)
+ const cause = createInstantStack !== null ? createInstantStack() : null
+ const error = new Error(message, cause !== null ? { cause } : undefined)
// TODO go back to owner stack here if available. This is temporarily using componentStack to get the right
//
error.stack = error.name + ': ' + message + (ownerStack || componentStack)
@@ -1197,27 +1249,29 @@ export function getNavigationDisallowedDynamicReasons(
}
if (boundaryState.renderedIds.size < boundaryState.expectedIds.size) {
- const { thrownErrorsOutsideBoundary } = dynamicValidation
+ const { thrownErrorsOutsideBoundary, createInstantStack } =
+ dynamicValidation
if (thrownErrorsOutsideBoundary.length === 0) {
- return [
- new Error(
- `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering for an unknown reason.`
- ),
- ]
+ const message = `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering for an unknown reason.`
+ const error =
+ createInstantStack !== null ? createInstantStack() : new Error()
+ error.name = 'Error'
+ error.message = message
+ return [error]
} else if (thrownErrorsOutsideBoundary.length === 1) {
- return [
- new Error(
- `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering, likely due to the following error.`
- ),
- thrownErrorsOutsideBoundary[0] as Error,
- ]
+ const message = `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering, likely due to the following error.`
+ const error =
+ createInstantStack !== null ? createInstantStack() : new Error()
+ error.name = 'Error'
+ error.message = message
+ return [error, thrownErrorsOutsideBoundary[0] as Error]
} else {
- return [
- new Error(
- `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering, likely due to one of the following errors.`
- ),
- ...(thrownErrorsOutsideBoundary as Error[]),
- ]
+ const message = `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering, likely due to one of the following errors.`
+ const error =
+ createInstantStack !== null ? createInstantStack() : new Error()
+ error.name = 'Error'
+ error.message = message
+ return [error, ...(thrownErrorsOutsideBoundary as Error[])]
}
}
diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx
index ea259a465b64f..e184057c2bb92 100644
--- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx
+++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx
@@ -86,6 +86,7 @@ export type RouteTree = {
// TODO(instant-validation): We should know if a layout segment is shared
instantConfig: Instant | null
conventionPath: string
+ createInstantStack: (() => Error) | null
}
slots: { [parallelRouteKey: string]: RouteTree } | null
@@ -143,10 +144,15 @@ export async function findNavigationsToValidate(
// TODO(restart-on-cache-miss): Does this work correctly for client page/layout modules?
const instantConfig =
(layoutOrPageMod as AppSegmentConfig).unstable_instant ?? null
+ const rawFactory: unknown = (layoutOrPageMod as any)
+ .__debugCreateInstantConfigStack
+ const createInstantStack: (() => Error) | null =
+ typeof rawFactory === 'function' ? (rawFactory as () => Error) : null
moduleInfo = {
type: modType!,
instantConfig,
conventionPath: conventionPath!,
+ createInstantStack,
}
if (isInsideParallelSlot) {
@@ -171,9 +177,13 @@ export async function findNavigationsToValidate(
} else {
const isRootLayout = parentLayoutPath === null
if (isRootLayout && instantConfig.prefetch === 'runtime') {
- throw new Error(
- `${conventionPath}: \`unstable_instant\` with mode 'runtime' is not supported in root layouts.`
- )
+ const message = `${conventionPath}: \`unstable_instant\` with mode 'runtime' is not supported in root layouts.`
+ const error =
+ createInstantStack !== null
+ ? createInstantStack()
+ : new Error()
+ error.message = message
+ throw error
}
const task: ValidationTask = {
@@ -256,8 +266,7 @@ export async function findNavigationsToValidate(
return {
tree: routeTree,
treeNodes,
- // TODO: do we want to preserve info about which config caused a validation to occur?
- navigationParents: validationTasks.flatMap((task) => task.parents),
+ validationTasks,
segmentsWithInstantConfigs,
}
}
diff --git a/test/e2e/app-dir/instant-validation/instant-validation.test.ts b/test/e2e/app-dir/instant-validation/instant-validation.test.ts
index 472f7e9c72669..877649192a3ec 100644
--- a/test/e2e/app-dir/instant-validation/instant-validation.test.ts
+++ b/test/e2e/app-dir/instant-validation/instant-validation.test.ts
@@ -186,6 +186,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/missing-suspense-around-runtime/page.tsx (3:33) @ unstable_instant
+ > 3 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/missing-suspense-around-runtime/page.tsx (3:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -218,6 +231,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/missing-suspense-around-dynamic/page.tsx (3:33) @ unstable_instant
+ > 3 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/missing-suspense-around-dynamic/page.tsx (3:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Data that blocks navigation was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. Uncached data such as fetch(...), cached data with a low expire time, or connection() are all examples of data that only resolve on navigation.
@@ -248,6 +274,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/runtime/missing-suspense-around-dynamic/page.tsx (4:33) @ unstable_instant
+ > 4 | export const unstable_instant = {
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/runtime/missing-suspense-around-dynamic/page.tsx (4:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Data that blocks navigation was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. Uncached data such as fetch(...), cached data with a low expire time, or connection() are all examples of data that only resolve on navigation.
@@ -280,6 +319,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/missing-suspense-around-dynamic-layout/layout.tsx (4:33) @ unstable_instant
+ > 4 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/missing-suspense-around-dynamic-layout/layout.tsx (4:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -312,6 +364,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/runtime/missing-suspense-around-dynamic-layout/layout.tsx (4:33) @ unstable_instant
+ > 4 | export const unstable_instant = {
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/runtime/missing-suspense-around-dynamic-layout/layout.tsx (4:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Data that blocks navigation was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. Uncached data such as fetch(...), cached data with a low expire time, or connection() are all examples of data that only resolve on navigation.
@@ -343,6 +408,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/missing-suspense-around-params/[param]/page.tsx (1:33) @ unstable_instant
+ > 1 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/missing-suspense-around-params/[param]/page.tsx (1:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -384,6 +462,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/missing-suspense-around-search-params/page.tsx (1:33) @ unstable_instant
+ > 1 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/missing-suspense-around-search-params/page.tsx (1:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -438,6 +529,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/suspense-too-high/page.tsx (3:33) @ unstable_instant
+ > 3 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/suspense-too-high/page.tsx (3:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -470,6 +574,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/runtime/suspense-too-high/page.tsx (4:33) @ unstable_instant
+ > 4 | export const unstable_instant = {
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/runtime/suspense-too-high/page.tsx (4:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Data that blocks navigation was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. Uncached data such as fetch(...), cached data with a low expire time, or connection() are all examples of data that only resolve on navigation.
@@ -587,6 +704,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/invalid-only-loading-around-dynamic/page.tsx (4:33) @ unstable_instant
+ > 4 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/invalid-only-loading-around-dynamic/page.tsx (4:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Data that blocks navigation was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. Uncached data such as fetch(...), cached data with a low expire time, or connection() are all examples of data that only resolve on navigation.
@@ -626,6 +756,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/blocking-layout/missing-suspense-around-dynamic/page.tsx (3:33) @ unstable_instant
+ > 3 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/blocking-layout/missing-suspense-around-dynamic/page.tsx (3:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -672,6 +815,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/invalid-blocking-inside-static/layout.tsx (1:33) @ unstable_instant
+ > 1 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/invalid-blocking-inside-static/layout.tsx (1:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -704,6 +860,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/runtime/invalid-blocking-inside-runtime/layout.tsx (3:33) @ unstable_instant
+ > 3 | export const unstable_instant = {
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/runtime/invalid-blocking-inside-runtime/layout.tsx (3:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Data that blocks navigation was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. Uncached data such as fetch(...), cached data with a low expire time, or connection() are all examples of data that only resolve on navigation.
@@ -737,6 +906,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/missing-suspense-in-parallel-route/page.tsx (3:33) @ unstable_instant
+ > 3 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/missing-suspense-in-parallel-route/page.tsx (3:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -770,6 +952,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/missing-suspense-in-parallel-route/foo/page.tsx (1:33) @ unstable_instant
+ > 1 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/missing-suspense-in-parallel-route/foo/page.tsx (1:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -803,6 +998,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/missing-suspense-in-parallel-route/bar/page.tsx (1:33) @ unstable_instant
+ > 1 | export const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/missing-suspense-in-parallel-route/bar/page.tsx (1:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Runtime data was accessed outside of
This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
@@ -838,6 +1046,19 @@ describe('instant validation', () => {
)
await expect(browser).toDisplayCollapsedRedbox(`
{
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/suspense-in-root/static/invalid-client-data-blocks-validation/page.tsx (1:33) @ unstable_instant
+ > 1 | export const unstable_instant = {
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/invalid-client-data-blocks-validation/page.tsx (1:33)",
+ "Set.forEach ",
+ ],
+ },
+ ],
"description": "Route "/suspense-in-root/static/invalid-client-data-blocks-validation": Could not validate \`unstable_instant\` because a Client Component in a parent segment prevented the page from rendering.",
"environmentLabel": "Server",
"label": "Console Error",
@@ -934,8 +1155,12 @@ describe('instant validation', () => {
"description": "Route "/suspense-in-root/static/invalid-client-error-in-parent-blocks-children": Could not validate \`unstable_instant\` because the target segment was prevented from rendering, likely due to the following error.",
"environmentLabel": "Server",
"label": "Console Error",
- "source": null,
- "stack": [],
+ "source": "app/suspense-in-root/static/invalid-client-error-in-parent-blocks-children/page.tsx (1:33) @ unstable_instant
+ > 1 | export const unstable_instant = {
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/invalid-client-error-in-parent-blocks-children/page.tsx (1:33)",
+ ],
},
{
"description": "No SSR please",
@@ -987,8 +1212,12 @@ describe('instant validation', () => {
"description": "Route "/suspense-in-root/static/invalid-client-error-in-parent-sibling": Could not validate \`unstable_instant\` because the target segment was prevented from rendering, likely due to the following error.",
"environmentLabel": "Server",
"label": "Console Error",
- "source": null,
- "stack": [],
+ "source": "app/suspense-in-root/static/invalid-client-error-in-parent-sibling/page.tsx (1:33) @ unstable_instant
+ > 1 | export const unstable_instant = {
+ | ^",
+ "stack": [
+ "unstable_instant app/suspense-in-root/static/invalid-client-error-in-parent-sibling/page.tsx (1:33)",
+ ],
},
{
"description": "No SSR please",
From 75df0bb46934fd936e82c728a319b4145873a7bd Mon Sep 17 00:00:00 2001
From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com>
Date: Fri, 20 Feb 2026 20:31:58 +0100
Subject: [PATCH 02/11] Turbopack: rename ServerPaths to AssetPaths (#90234)
We are soon going to use these for client-side static assets as well
---
crates/next-api/src/app.rs | 4 +-
crates/next-api/src/instrumentation.rs | 4 +-
crates/next-api/src/middleware.rs | 4 +-
crates/next-api/src/pages.rs | 4 +-
crates/next-api/src/paths.rs | 37 +++++++++----------
crates/next-api/src/route.rs | 6 +--
.../src/next_api/endpoint.rs | 14 +++----
.../next/src/build/swc/generated-native.d.ts | 4 +-
8 files changed, 38 insertions(+), 39 deletions(-)
diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs
index b749af7854bbf..ecd22c1233ba3 100644
--- a/crates/next-api/src/app.rs
+++ b/crates/next-api/src/app.rs
@@ -80,7 +80,7 @@ use crate::{
module_graph::{ClientReferencesGraphs, NextDynamicGraphs, ServerActionsGraphs},
nft_json::NftJsonAsset,
paths::{
- all_paths_in_root, all_server_paths, get_asset_paths_from_root, get_js_paths_from_root,
+ all_asset_paths, all_paths_in_root, get_asset_paths_from_root, get_js_paths_from_root,
get_wasm_paths_from_root, paths_to_bindings, wasm_paths_to_bindings,
},
project::{BaseAndFullModuleGraph, Project},
@@ -2019,7 +2019,7 @@ impl Endpoint for AppEndpoint {
.is_development()
{
let node_root = this.app_project.project().node_root().owned().await?;
- let server_paths = all_server_paths(output_assets, node_root).owned().await?;
+ let server_paths = all_asset_paths(output_assets, node_root).owned().await?;
let client_relative_root = this
.app_project
diff --git a/crates/next-api/src/instrumentation.rs b/crates/next-api/src/instrumentation.rs
index ae3b9351a1416..2f75a387739d3 100644
--- a/crates/next-api/src/instrumentation.rs
+++ b/crates/next-api/src/instrumentation.rs
@@ -28,7 +28,7 @@ use turbopack_core::{
use crate::{
nft_json::NftJsonAsset,
paths::{
- all_server_paths, get_js_paths_from_root, get_wasm_paths_from_root, wasm_paths_to_bindings,
+ all_asset_paths, get_js_paths_from_root, get_wasm_paths_from_root, wasm_paths_to_bindings,
},
project::Project,
route::{Endpoint, EndpointOutput, EndpointOutputPaths, ModuleGraphs},
@@ -210,7 +210,7 @@ impl Endpoint for InstrumentationEndpoint {
let server_paths = if this.project.next_mode().await?.is_development() {
let node_root = this.project.node_root().owned().await?;
- all_server_paths(output_assets, node_root).owned().await?
+ all_asset_paths(output_assets, node_root).owned().await?
} else {
vec![]
};
diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs
index 0f9a3ef18a141..bfb4d39064912 100644
--- a/crates/next-api/src/middleware.rs
+++ b/crates/next-api/src/middleware.rs
@@ -30,7 +30,7 @@ use turbopack_core::{
use crate::{
nft_json::NftJsonAsset,
paths::{
- all_paths_in_root, all_server_paths, get_asset_paths_from_root, get_js_paths_from_root,
+ all_asset_paths, all_paths_in_root, get_asset_paths_from_root, get_js_paths_from_root,
get_wasm_paths_from_root, paths_to_bindings, wasm_paths_to_bindings,
},
project::Project,
@@ -337,7 +337,7 @@ impl Endpoint for MiddlewareEndpoint {
let (server_paths, client_paths) = if this.project.next_mode().await?.is_development() {
let node_root = this.project.node_root().owned().await?;
- let server_paths = all_server_paths(output_assets, node_root).owned().await?;
+ let server_paths = all_asset_paths(output_assets, node_root).owned().await?;
// Middleware could in theory have a client path (e.g. `new URL`).
let client_relative_root = this.project.client_relative_path().owned().await?;
diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs
index bb645f66f3086..e72282c98ab86 100644
--- a/crates/next-api/src/pages.rs
+++ b/crates/next-api/src/pages.rs
@@ -75,7 +75,7 @@ use crate::{
module_graph::{NextDynamicGraphs, validate_pages_css_imports},
nft_json::NftJsonAsset,
paths::{
- all_paths_in_root, all_server_paths, get_asset_paths_from_root, get_js_paths_from_root,
+ all_asset_paths, all_paths_in_root, get_asset_paths_from_root, get_js_paths_from_root,
get_wasm_paths_from_root, paths_to_bindings, wasm_paths_to_bindings,
},
project::Project,
@@ -1613,7 +1613,7 @@ impl Endpoint for PageEndpoint {
.await?
.is_development()
{
- let server_paths = all_server_paths(output_assets, node_root.clone())
+ let server_paths = all_asset_paths(output_assets, node_root.clone())
.owned()
.await?;
diff --git a/crates/next-api/src/paths.rs b/crates/next-api/src/paths.rs
index 9e0c6553a8709..bc9d464099acb 100644
--- a/crates/next-api/src/paths.rs
+++ b/crates/next-api/src/paths.rs
@@ -11,31 +11,31 @@ use turbopack_core::{
};
use turbopack_wasm::wasm_edge_var_name;
-/// A reference to a server file with content hash for change detection
+/// A reference to an output asset with content hash for change detection
#[turbo_tasks::value]
#[derive(Debug, Clone)]
-pub struct ServerPath {
+pub struct AssetPath {
/// Relative to the root_path
pub path: RcStr,
pub content_hash: u64,
}
-/// A list of server paths
+/// A list of asset paths
#[turbo_tasks::value(transparent)]
-pub struct ServerPaths(Vec);
+pub struct AssetPaths(Vec);
#[turbo_tasks::value(transparent)]
-pub struct OptionServerPath(Option);
+pub struct OptionAssetPath(Option);
#[turbo_tasks::function]
-async fn server_path(
+async fn asset_path(
asset: Vc>,
node_root: FileSystemPath,
-) -> Result> {
+) -> Result> {
Ok(Vc::cell(
if let Some(path) = node_root.get_path_to(&*asset.path().await?) {
let content_hash = *asset.content().hash().await?;
- Some(ServerPath {
+ Some(AssetPath {
path: RcStr::from(path),
content_hash,
})
@@ -45,30 +45,29 @@ async fn server_path(
))
}
-/// Return a list of all server paths with filename and hash for all output
-/// assets references from the `assets` list. Server paths are identified by
-/// being inside `node_root`.
+/// Return a list of all asset paths with filename and hash for all output
+/// assets references from the `assets` list. Only paths inside `node_root` are included.
#[turbo_tasks::function]
-pub async fn all_server_paths(
+pub async fn all_asset_paths(
assets: Vc,
node_root: FileSystemPath,
-) -> Result> {
+) -> Result> {
let span = tracing::info_span!(
- "collect all server paths",
+ "collect all asset paths",
assets_count = tracing::field::Empty,
- server_assets_count = tracing::field::Empty
+ asset_paths_count = tracing::field::Empty
);
let span_clone = span.clone();
async move {
let all_assets = all_assets_from_entries(assets).await?;
span.record("assets_count", all_assets.len());
- let server_paths = all_assets
+ let asset_paths = all_assets
.iter()
- .map(|&asset| server_path(*asset, node_root.clone()).owned())
+ .map(|&asset| asset_path(*asset, node_root.clone()).owned())
.try_flat_join()
.await?;
- span.record("server_assets_count", server_paths.len());
- Ok(Vc::cell(server_paths))
+ span.record("asset_paths_count", asset_paths.len());
+ Ok(Vc::cell(asset_paths))
}
.instrument(span_clone)
.await
diff --git a/crates/next-api/src/route.rs b/crates/next-api/src/route.rs
index bd9815b7bb974..9fb9390834c8a 100644
--- a/crates/next-api/src/route.rs
+++ b/crates/next-api/src/route.rs
@@ -12,7 +12,7 @@ use turbopack_core::{
output::OutputAssets,
};
-use crate::{operation::OptionEndpoint, paths::ServerPath, project::Project};
+use crate::{operation::OptionEndpoint, paths::AssetPath, project::Project};
#[derive(
TraceRawVcs, PartialEq, Eq, ValueDebugFormat, Clone, Debug, NonLocalValue, Encode, Decode,
@@ -267,11 +267,11 @@ pub enum EndpointOutputPaths {
NodeJs {
/// Relative to the root_path
server_entry_path: RcStr,
- server_paths: Vec,
+ server_paths: Vec,
client_paths: Vec,
},
Edge {
- server_paths: Vec,
+ server_paths: Vec,
client_paths: Vec,
},
NotFound,
diff --git a/crates/next-napi-bindings/src/next_api/endpoint.rs b/crates/next-napi-bindings/src/next_api/endpoint.rs
index 514ff038115c9..fd94c22130ac9 100644
--- a/crates/next-napi-bindings/src/next_api/endpoint.rs
+++ b/crates/next-napi-bindings/src/next_api/endpoint.rs
@@ -6,7 +6,7 @@ use napi::{JsFunction, bindgen_prelude::External};
use napi_derive::napi;
use next_api::{
operation::OptionEndpoint,
- paths::ServerPath,
+ paths::AssetPath,
route::{
Endpoint, EndpointOutputPaths, endpoint_client_changed_operation,
endpoint_server_changed_operation, endpoint_write_to_disk_operation,
@@ -30,16 +30,16 @@ pub struct NapiEndpointConfig {}
#[napi(object)]
#[derive(Default)]
-pub struct NapiServerPath {
+pub struct NapiAssetPath {
pub path: String,
pub content_hash: String,
}
-impl From for NapiServerPath {
- fn from(server_path: ServerPath) -> Self {
+impl From for NapiAssetPath {
+ fn from(asset_path: AssetPath) -> Self {
Self {
- path: server_path.path.into_owned(),
- content_hash: format!("{:x}", server_path.content_hash),
+ path: asset_path.path.into_owned(),
+ content_hash: format!("{:x}", asset_path.content_hash),
}
}
}
@@ -50,7 +50,7 @@ pub struct NapiWrittenEndpoint {
pub r#type: String,
pub entry_path: Option,
pub client_paths: Vec,
- pub server_paths: Vec,
+ pub server_paths: Vec,
pub config: NapiEndpointConfig,
}
diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts
index e02a5448df971..2411c01f7732f 100644
--- a/packages/next/src/build/swc/generated-native.d.ts
+++ b/packages/next/src/build/swc/generated-native.d.ts
@@ -67,7 +67,7 @@ export declare function minify(
): Promise
export declare function minifySync(input: Buffer, opts: Buffer): TransformOutput
export interface NapiEndpointConfig {}
-export interface NapiServerPath {
+export interface NapiAssetPath {
path: string
contentHash: string
}
@@ -75,7 +75,7 @@ export interface NapiWrittenEndpoint {
type: string
entryPath?: string
clientPaths: Array
- serverPaths: Array
+ serverPaths: Array
config: NapiEndpointConfig
}
export declare function endpointWriteToDisk(endpoint: {
From 39eb8e0ac498b48855a0430fbf4c22276a73b4bd Mon Sep 17 00:00:00 2001
From: Steven
Date: Fri, 20 Feb 2026 15:12:26 -0500
Subject: [PATCH 03/11] feat(next/image): add lru disk cache and
`images.maximumDiskCacheSize` (#89963)
This PR adds an LRU disk cache so that reads and writes from the Image
Optimization API will evict old entries based on the value of
`images.maximumDiskCacheSize` configuration.
The LRU ensures that cache reads bump the entry to the top so that they
don't get evicted - only the least recently used entries get evicted.
When `next start` is run, if there is an existing disk cache the we will
replay the files in order to populate the LRU and respect
`images.maximumDiskCacheSize` if it was changed.
If no configuration is provided, default to 50% available disk space.
---
.../03-api-reference/02-components/image.mdx | 31 ++++
packages/next/errors.json | 4 +-
packages/next/src/server/config-schema.ts | 1 +
packages/next/src/server/image-optimizer.ts | 142 ++++++++++++----
.../src/server/lib/disk-lru-cache.external.ts | 60 +++++++
.../next/src/server/lib/lru-cache.test.ts | 29 +++-
packages/next/src/server/lib/lru-cache.ts | 6 +-
packages/next/src/shared/lib/image-config.ts | 4 +
.../test/max-disk-size-cache-85kb.test.ts | 13 ++
.../test/max-disk-size-cache-zero.test.ts | 13 ++
test/integration/image-optimizer/test/util.ts | 104 +++++++++++-
.../image-optimizer/lru-disk-eviction.test.ts | 157 ++++++++++++++++++
12 files changed, 521 insertions(+), 43 deletions(-)
create mode 100644 packages/next/src/server/lib/disk-lru-cache.external.ts
create mode 100644 test/integration/image-optimizer/test/max-disk-size-cache-85kb.test.ts
create mode 100644 test/integration/image-optimizer/test/max-disk-size-cache-zero.test.ts
create mode 100644 test/unit/image-optimizer/lru-disk-eviction.test.ts
diff --git a/docs/01-app/03-api-reference/02-components/image.mdx b/docs/01-app/03-api-reference/02-components/image.mdx
index d18811c8d285b..f810b3831106e 100644
--- a/docs/01-app/03-api-reference/02-components/image.mdx
+++ b/docs/01-app/03-api-reference/02-components/image.mdx
@@ -837,6 +837,36 @@ module.exports = {
}
```
+#### `maximumDiskCacheSize`
+
+The default image optimization loader will write optimized images to disk so subsequent requests can be served faster from the disk cache.
+
+You can configure the maximum disk cache size in bytes, for example 500 MB:
+
+```js filename="next.config.js"
+module.exports = {
+ images: {
+ maximumDiskCacheSize: 500_000_000,
+ },
+}
+```
+
+You can also disable the disk cache entirely by setting the value to `0`.
+
+```js filename="next.config.js"
+module.exports = {
+ images: {
+ maximumDiskCacheSize: 0,
+ },
+}
+```
+
+If no value is configured, the default behavior is to check the current available disk space once during startup and use 50%.
+
+When the disk cache exceeds the configured size, the least recently used optimized images will be deleted until the cache is under the limit again.
+
+Alternatively, you can implement your own cache handler using [`cacheHandler`](/docs/app/api-reference/config/next-config-js/incrementalCacheHandlerPath) which will ignore the `maximumDiskCacheSize` configuration.
+
#### `maximumResponseBody`
The default image optimization loader will fetch source images up to 50 MB in size.
@@ -1363,6 +1393,7 @@ export default function Home() {
| Version | Changes |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `v16.1.7` | `maximumDiskCacheSize` configuration added. |
| `v16.1.2` | `maximumResponseBody` configuration added. |
| `v16.0.0` | `qualities` default configuration changed to `[75]`, `preload` prop added, `priority` prop deprecated, `dangerouslyAllowLocalIP` config added, `maximumRedirects` config added. |
| `v15.3.0` | `remotePatterns` added support for array of `URL` objects. |
diff --git a/packages/next/errors.json b/packages/next/errors.json
index 9ae79417b82e4..519cfcdf5a66d 100644
--- a/packages/next/errors.json
+++ b/packages/next/errors.json
@@ -1066,5 +1066,7 @@
"1065": "createServerPathnameForMetadata should not be called in client contexts.",
"1066": "createServerSearchParamsForServerPage should not be called in a client validation.",
"1067": "The Next.js unhandled rejection filter is being installed more than once. This is a bug in Next.js.",
- "1068": "Expected workStore to be initialized"
+ "1068": "Expected workStore to be initialized",
+ "1069": "Invariant: cache entry \"%s\" not found in dir \"%s\"",
+ "1070": "image of size %s could not be tracked by lru cache"
}
diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts
index 7e2e755b74dc0..ff2c3fb157192 100644
--- a/packages/next/src/server/config-schema.ts
+++ b/packages/next/src/server/config-schema.ts
@@ -623,6 +623,7 @@ export const configSchema: zod.ZodType = z.lazy(() =>
.optional(),
loader: z.enum(VALID_LOADERS).optional(),
loaderFile: z.string().optional(),
+ maximumDiskCacheSize: z.number().int().min(0).optional(),
maximumRedirects: z.number().int().min(0).max(20).optional(),
maximumResponseBody: z
.number()
diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts
index 64ee8a09477dd..010a88b2f0e72 100644
--- a/packages/next/src/server/image-optimizer.ts
+++ b/packages/next/src/server/image-optimizer.ts
@@ -30,6 +30,7 @@ import { getContentType, getExtension } from './serve-static'
import * as Log from '../build/output/log'
import isError from '../lib/is-error'
import { isPrivateIp } from './is-private-ip'
+import { getOrInitDiskLRU } from './lib/disk-lru-cache.external'
import { parseUrl } from '../lib/url'
import type { CacheControl } from './lib/cache-control'
import { InvariantError } from '../shared/lib/invariant-error'
@@ -61,6 +62,29 @@ const BLUR_QUALITY = 70 // should match `next-image-loader`
let _sharp: typeof import('sharp')
+async function initCacheEntries(
+ cacheDir: string
+): Promise> {
+ const cacheKeys = await promises.readdir(cacheDir).catch(() => [])
+ const entries: Array<{ key: string; size: number; expireAt: number }> = []
+
+ for (const cacheKey of cacheKeys) {
+ try {
+ const { expireAt, buffer } = await readFromCacheDir(cacheDir, cacheKey)
+ entries.push({
+ key: cacheKey,
+ size: buffer.byteLength,
+ expireAt,
+ })
+ } catch {
+ // Skip entries that can't be read from disk
+ }
+ }
+
+ // Sort oldest-first so we can replay them chronologically into LRU
+ return entries.sort((a, b) => a.expireAt - b.expireAt)
+}
+
export function getSharp(concurrency: number | null | undefined) {
if (_sharp) {
return _sharp
@@ -139,7 +163,8 @@ export function getImageEtag(image: Buffer) {
}
async function writeToCacheDir(
- dir: string,
+ cacheDir: string,
+ cacheKey: string,
extension: string,
maxAge: number,
expireAt: number,
@@ -147,6 +172,7 @@ async function writeToCacheDir(
etag: string,
upstreamEtag: string
) {
+ const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey)
const filename = join(
/* turbopackIgnore: true */
dir,
@@ -159,6 +185,37 @@ async function writeToCacheDir(
await promises.writeFile(filename, buffer)
}
+async function readFromCacheDir(cacheDir: string, cacheKey: string) {
+ const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey)
+ const files = await promises.readdir(dir)
+ const file = files[0]
+ if (!file) {
+ throw new Error(
+ `Invariant: cache entry "${cacheKey}" not found in dir "${cacheDir}"`
+ )
+ }
+ const [maxAgeSt, expireAtSt, etag, upstreamEtag, extension] = file.split(
+ '.',
+ 5
+ )
+ const filePath = join(/* turbopackIgnore: true */ dir, file)
+ const buffer = await promises.readFile(/* turbopackIgnore: true */ filePath)
+ const expireAt = Number(expireAtSt)
+ const maxAge = Number(maxAgeSt)
+ return { maxAge, expireAt, etag, upstreamEtag, buffer, extension }
+}
+
+async function deleteFromCacheDir(cacheDir: string, cacheKey: string) {
+ return promises
+ .rm(join(/* turbopackIgnore: true */ cacheDir, cacheKey), {
+ recursive: true,
+ force: true,
+ })
+ .catch((err) => {
+ Log.error(`Failed to delete cache key ${cacheKey}`, err)
+ })
+}
+
/**
* Inspects the first few bytes of a buffer to determine if
* it matches the "magic number" of known file signatures.
@@ -318,6 +375,8 @@ export class ImageOptimizerCache {
private cacheDir: string
private nextConfig: NextConfigRuntime
private cacheHandler?: CacheHandler
+ private cacheDiskLRU?: ReturnType
+ private isDiskCacheEnabled?: boolean
static validateParams(
req: IncomingMessage,
@@ -507,6 +566,21 @@ export class ImageOptimizerCache {
this.cacheDir = join(/* turbopackIgnore: true */ distDir, 'cache', 'images')
this.nextConfig = nextConfig
this.cacheHandler = cacheHandler
+
+ // Eagerly start LRU initialization for filesystem cache
+ if (
+ !cacheHandler &&
+ nextConfig.images.maximumDiskCacheSize !== 0 &&
+ nextConfig.experimental.isrFlushToDisk
+ ) {
+ this.isDiskCacheEnabled = true
+ this.cacheDiskLRU = getOrInitDiskLRU(
+ this.cacheDir,
+ nextConfig.images.maximumDiskCacheSize,
+ initCacheEntries,
+ deleteFromCacheDir
+ )
+ }
}
async get(cacheKey: string): Promise {
@@ -549,38 +623,34 @@ export class ImageOptimizerCache {
return null
}
+ // If the filesystem cache is disabled, return early
+ if (!this.isDiskCacheEnabled) {
+ return null
+ }
+
// Fall back to filesystem cache
try {
- const cacheDir = join(/* turbopackIgnore: true */ this.cacheDir, cacheKey)
- const files = await promises.readdir(cacheDir)
const now = Date.now()
+ const { maxAge, expireAt, etag, upstreamEtag, buffer, extension } =
+ await readFromCacheDir(this.cacheDir, cacheKey)
- for (const file of files) {
- const [maxAgeSt, expireAtSt, etag, upstreamEtag, extension] =
- file.split('.', 5)
- const buffer = await promises.readFile(
- /* turbopackIgnore: true */ join(
- /* turbopackIgnore: true */ cacheDir,
- file
- )
- )
- const expireAt = Number(expireAtSt)
- const maxAge = Number(maxAgeSt)
+ // Promote entry in LRU (mark as recently used)
+ const lru = await this.cacheDiskLRU
+ lru?.get(cacheKey)
- return {
- value: {
- kind: CachedRouteKind.IMAGE,
- etag,
- buffer,
- extension,
- upstreamEtag,
- },
- revalidateAfter:
- Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 +
- Date.now(),
- cacheControl: { revalidate: maxAge, expire: undefined },
- isStale: now > expireAt,
- }
+ return {
+ value: {
+ kind: CachedRouteKind.IMAGE,
+ etag,
+ buffer,
+ extension,
+ upstreamEtag,
+ },
+ revalidateAfter:
+ Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 +
+ Date.now(),
+ cacheControl: { revalidate: maxAge, expire: undefined },
+ isStale: now > expireAt,
}
} catch (_) {
// failed to read from cache dir, treat as cache miss
@@ -630,18 +700,28 @@ export class ImageOptimizerCache {
return
}
- // Fall back to filesystem cache
- if (!this.nextConfig.experimental.isrFlushToDisk) {
+ // If the filesystem cache is disabled, return early
+ if (!this.isDiskCacheEnabled) {
return
}
+ // Fall back to filesystem cache
const expireAt =
Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 +
Date.now()
try {
+ const lru = await this.cacheDiskLRU
+ const success = lru?.set(cacheKey, value.buffer.byteLength)
+ if (success === false) {
+ throw new Error(
+ `image of size ${value.buffer.byteLength} could not be tracked by lru cache`
+ )
+ }
+
await writeToCacheDir(
- join(/* turbopackIgnore: true */ this.cacheDir, cacheKey),
+ this.cacheDir,
+ cacheKey,
value.extension,
revalidate,
expireAt,
diff --git a/packages/next/src/server/lib/disk-lru-cache.external.ts b/packages/next/src/server/lib/disk-lru-cache.external.ts
new file mode 100644
index 0000000000000..40b4525d38e07
--- /dev/null
+++ b/packages/next/src/server/lib/disk-lru-cache.external.ts
@@ -0,0 +1,60 @@
+import { promises } from 'fs'
+import { LRUCache } from './lru-cache'
+
+/**
+ * Module-level LRU singleton for disk cache eviction.
+ * Initialized once on first `set()`, shared across all consumers.
+ * Once resolved, the promise stays resolved — subsequent calls just await the cached result.
+ */
+let _diskLRUPromise: Promise> | null = null
+
+/**
+ * Initialize or return the module-level LRU for disk cache eviction.
+ * Concurrent calls are deduplicated via the shared promise.
+ *
+ * @param cacheDir - The directory where cached files are stored
+ * @param maxDiskSize - Maximum disk cache size in bytes
+ * @param readEntries - Callback to scan existing cache entries (format-agnostic)
+ */
+export async function getOrInitDiskLRU(
+ cacheDir: string,
+ maxDiskSize: number | undefined,
+ readEntries: (
+ cacheDir: string
+ ) => Promise>,
+ evictEntry: (cacheDir: string, cacheKey: string) => Promise
+): Promise> {
+ if (!_diskLRUPromise) {
+ _diskLRUPromise = (async () => {
+ let maxSize = maxDiskSize
+ if (typeof maxSize === 'undefined') {
+ // Ensure cacheDir exists before checking disk space
+ await promises.mkdir(cacheDir, { recursive: true })
+ // Since config was not provided, default to 50% of available disk space
+ const { bavail, bsize } = await promises.statfs(cacheDir)
+ maxSize = Math.floor((bavail * bsize) / 2)
+ }
+
+ const lru = new LRUCache(
+ maxSize,
+ (size) => size,
+ (cacheKey) => evictEntry(cacheDir, cacheKey)
+ )
+
+ const entries = await readEntries(cacheDir)
+ for (const entry of entries) {
+ lru.set(entry.key, entry.size)
+ }
+
+ return lru
+ })()
+ }
+ return _diskLRUPromise
+}
+
+/**
+ * Reset the module-level LRU singleton. Exported for testing only.
+ */
+export function resetDiskLRU(): void {
+ _diskLRUPromise = null
+}
diff --git a/packages/next/src/server/lib/lru-cache.test.ts b/packages/next/src/server/lib/lru-cache.test.ts
index d184cdb69c853..c51f58e9d8526 100644
--- a/packages/next/src/server/lib/lru-cache.test.ts
+++ b/packages/next/src/server/lib/lru-cache.test.ts
@@ -9,7 +9,7 @@ describe('LRUCache', () => {
})
it('should set and get values', () => {
- cache.set('key1', 'value1')
+ expect(cache.set('key1', 'value1')).toBe(true)
expect(cache.get('key1')).toBe('value1')
})
@@ -105,11 +105,11 @@ describe('LRUCache', () => {
expect(cache.currentSize).toBe(8) // 5 + 2 + 1
})
- it('should handle items larger than max size', () => {
+ it('should prevent adding item larger than max size when lru is empty', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const cache = new LRUCache(5, (value) => value.length)
- cache.set('key1', 'toolarge') // size 8 > maxSize 5
+ expect(cache.set('key1', 'toolarge')).toBe(false) // size 8 > maxSize 5
expect(cache.has('key1')).toBe(false)
expect(cache.size).toBe(0)
@@ -120,6 +120,27 @@ describe('LRUCache', () => {
consoleSpy.mockRestore()
})
+ it('should prevent adding item larger than max size when lru is not empty', () => {
+ const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
+ const cache = new LRUCache(5, (value) => value.length)
+
+ expect(cache.set('key1', 'ab')).toBe(true) // size 2
+ expect(cache.set('key2', 'cd')).toBe(true) // size 2, total = 4
+
+ expect(cache.set('key3', 'toolarge')).toBe(false) // size 8 > maxSize 5, should be rejected
+
+ expect(cache.has('key1')).toBe(true)
+ expect(cache.has('key2')).toBe(true)
+ expect(cache.has('key3')).toBe(false)
+ expect(cache.size).toBe(2)
+ expect(cache.currentSize).toBe(4)
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Single item size exceeds maxSize'
+ )
+
+ consoleSpy.mockRestore()
+ })
+
it('should update size when overwriting existing keys', () => {
const cache = new LRUCache(10, (value) => value.length)
@@ -184,7 +205,7 @@ describe('LRUCache', () => {
describe('Edge Cases', () => {
it('should handle zero max size', () => {
const cache = new LRUCache(0)
- cache.set('key1', 'value1')
+ expect(cache.set('key1', 'value1')).toBe(false)
expect(cache.has('key1')).toBe(false)
expect(cache.size).toBe(0)
})
diff --git a/packages/next/src/server/lib/lru-cache.ts b/packages/next/src/server/lib/lru-cache.ts
index 4249b95a3f776..59b76b5d0b4ca 100644
--- a/packages/next/src/server/lib/lru-cache.ts
+++ b/packages/next/src/server/lib/lru-cache.ts
@@ -123,7 +123,7 @@ export class LRUCache {
* - O(1) for uniform item sizes
* - O(k) where k is the number of items evicted (can be O(N) for variable sizes)
*/
- public set(key: string, value: T): void {
+ public set(key: string, value: T): boolean {
const size = this.calculateSize?.(value) ?? 1
if (size <= 0) {
throw new Error(
@@ -133,7 +133,7 @@ export class LRUCache {
}
if (size > this.maxSize) {
console.warn('Single item size exceeds maxSize')
- return
+ return false
}
const existing = this.cache.get(key)
@@ -158,6 +158,8 @@ export class LRUCache {
this.totalSize -= tail.size
this.onEvict?.(tail.key, tail.data)
}
+
+ return true
}
/**
diff --git a/packages/next/src/shared/lib/image-config.ts b/packages/next/src/shared/lib/image-config.ts
index 32e86c2521f89..112e98d8ed179 100644
--- a/packages/next/src/shared/lib/image-config.ts
+++ b/packages/next/src/shared/lib/image-config.ts
@@ -103,6 +103,9 @@ export type ImageConfigComplete = {
/** @see [Acceptable formats](https://nextjs.org/docs/api-reference/next/image#acceptable-formats) */
formats: ImageFormat[]
+ /** @see [Maximum Disk Cache Size (in bytes)](https://nextjs.org/docs/api-reference/next/image#maximumdiskcachesize) */
+ maximumDiskCacheSize: number | undefined
+
/** @see [Maximum Redirects](https://nextjs.org/docs/api-reference/next/image#maximumredirects) */
maximumRedirects: number
@@ -156,6 +159,7 @@ export const imageConfigDefault: ImageConfigComplete = {
disableStaticImages: false,
minimumCacheTTL: 14400, // 4 hours
formats: ['image/webp'],
+ maximumDiskCacheSize: undefined, // auto-detect by default
maximumRedirects: 3,
maximumResponseBody: 50_000_000, // 50 MB
dangerouslyAllowLocalIP: false,
diff --git a/test/integration/image-optimizer/test/max-disk-size-cache-85kb.test.ts b/test/integration/image-optimizer/test/max-disk-size-cache-85kb.test.ts
new file mode 100644
index 0000000000000..b9fc0d6f76be1
--- /dev/null
+++ b/test/integration/image-optimizer/test/max-disk-size-cache-85kb.test.ts
@@ -0,0 +1,13 @@
+import { join } from 'path'
+import { setupTests } from './util'
+
+const appDir = join(__dirname, '../app')
+
+describe('with maximumDiskCacheSize 85KB config', () => {
+ setupTests({
+ appDir,
+ nextConfigImages: {
+ maximumDiskCacheSize: 85_000,
+ },
+ })
+})
diff --git a/test/integration/image-optimizer/test/max-disk-size-cache-zero.test.ts b/test/integration/image-optimizer/test/max-disk-size-cache-zero.test.ts
new file mode 100644
index 0000000000000..415279848e748
--- /dev/null
+++ b/test/integration/image-optimizer/test/max-disk-size-cache-zero.test.ts
@@ -0,0 +1,13 @@
+import { join } from 'path'
+import { setupTests } from './util'
+
+const appDir = join(__dirname, '../app')
+
+describe('with maximumDiskCacheSize zero config', () => {
+ setupTests({
+ appDir,
+ nextConfigImages: {
+ maximumDiskCacheSize: 0,
+ },
+ })
+})
diff --git a/test/integration/image-optimizer/test/util.ts b/test/integration/image-optimizer/test/util.ts
index 3ca4d86a581e9..b7855be353c9a 100644
--- a/test/integration/image-optimizer/test/util.ts
+++ b/test/integration/image-optimizer/test/util.ts
@@ -13,6 +13,7 @@ import {
launchApp,
nextBuild,
nextStart,
+ retry,
waitFor,
} from 'next-test-utils'
import isAnimated from 'next/dist/compiled/is-animated'
@@ -122,6 +123,22 @@ export const cleanImagesDir = async (imagesDir) => {
await fs.remove(imagesDir)
}
+async function getDirSize(dir: string): Promise {
+ let totalSize = 0
+ const entries = await fs.readdir(dir).catch(() => [] as string[])
+ for (const entry of entries) {
+ const entryPath = join(dir, entry)
+ const stat = await fs.stat(entryPath).catch(() => null)
+ if (!stat) continue
+ if (stat.isDirectory()) {
+ totalSize += await getDirSize(entryPath)
+ } else {
+ totalSize += stat.size
+ }
+ }
+ return totalSize
+}
+
async function expectAvifSmallerThanWebp(
w: number,
q: number,
@@ -979,7 +996,10 @@ export function runTests(ctx: RunTestsCtx) {
})
it('should use cache and stale-while-revalidate when query is the same for external image', async () => {
- if (ctx.nextConfigExperimental?.isrFlushToDisk === false) {
+ if (
+ ctx.nextConfigExperimental?.isrFlushToDisk === false ||
+ ctx.nextConfigImages?.maximumDiskCacheSize === 0
+ ) {
return // this test is not applicable when we don't write the cache
}
await cleanImagesDir(imagesDir)
@@ -1201,7 +1221,10 @@ export function runTests(ctx: RunTestsCtx) {
}
it('should use cache and stale-while-revalidate when query is the same for internal image', async () => {
- if (ctx.nextConfigExperimental?.isrFlushToDisk === false) {
+ if (
+ ctx.nextConfigExperimental?.isrFlushToDisk === false ||
+ ctx.nextConfigImages?.maximumDiskCacheSize === 0
+ ) {
return // this test is not applicable when we don't write the cache
}
await cleanImagesDir(imagesDir)
@@ -1348,7 +1371,10 @@ export function runTests(ctx: RunTestsCtx) {
}
it('should use cached image file when parameters are the same for animated gif', async () => {
- if (ctx.nextConfigExperimental?.isrFlushToDisk === false) {
+ if (
+ ctx.nextConfigExperimental?.isrFlushToDisk === false ||
+ ctx.nextConfigImages?.maximumDiskCacheSize === 0
+ ) {
return // this test is not applicable when we don't write the cache
}
await cleanImagesDir(imagesDir)
@@ -1455,7 +1481,10 @@ export function runTests(ctx: RunTestsCtx) {
`${contentDispositionType}; filename="test.bmp"`
)
- if (ctx.nextConfigExperimental?.isrFlushToDisk === false) {
+ if (
+ ctx.nextConfigExperimental?.isrFlushToDisk === false ||
+ ctx.nextConfigImages?.maximumDiskCacheSize === 0
+ ) {
expect(json1).toEqual({})
expect(await fsToJson(ctx.imagesDir)).toEqual({})
} else {
@@ -1583,7 +1612,10 @@ export function runTests(ctx: RunTestsCtx) {
await expectWidth(res3, ctx.w)
const length =
- ctx.nextConfigExperimental?.isrFlushToDisk === false ? 0 : 1
+ ctx.nextConfigExperimental?.isrFlushToDisk === false ||
+ ctx.nextConfigImages?.maximumDiskCacheSize === 0
+ ? 0
+ : 1
await check(async () => {
const json1 = await fsToJson(ctx.imagesDir)
@@ -1600,6 +1632,68 @@ export function runTests(ctx: RunTestsCtx) {
expect(xCache).toEqual(['MISS', 'MISS', 'MISS'])
})
}
+
+ if (typeof ctx.nextConfigImages?.maximumDiskCacheSize !== 'undefined') {
+ const { maximumDiskCacheSize } = ctx.nextConfigImages
+ it(`should handle maximumDiskCacheSize ${maximumDiskCacheSize}`, async () => {
+ const opts = { headers: { accept: 'image/webp' } }
+ const requests = [
+ { url: '/test.png', w: largeSize },
+ { url: '/test.jpg', w: largeSize },
+ { url: '/test.gif', w: largeSize },
+ { url: '/test.bmp', w: largeSize },
+ { url: '/test.webp', w: largeSize },
+ { url: '/test.avif', w: largeSize },
+ { url: '/test.tiff', w: largeSize },
+ { url: '/test.ico', w: largeSize },
+ { url: '/animated.gif', w: largeSize },
+ { url: '/animated.png', w: largeSize },
+ { url: '/animated2.png', w: largeSize },
+ ]
+ await cleanImagesDir(imagesDir)
+ const json1 = await fsToJson(ctx.imagesDir)
+ expect(Object.keys(json1).length).toEqual(0)
+ for (const { url, w } of requests) {
+ const query = { url, w, q: ctx.q }
+ const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
+ expect(res.status).toBe(200)
+ await res.buffer() // consume response body
+ await retry(async () => {
+ const size = await getDirSize(imagesDir)
+ expect(size).toBeLessThanOrEqual(maximumDiskCacheSize)
+ })
+ }
+
+ const json2 = await fsToJson(ctx.imagesDir)
+ const json2Length = Object.keys(json2).length
+ if (maximumDiskCacheSize === 0) {
+ expect(json2Length).toEqual(0)
+ } else {
+ expect(json2Length).toBeGreaterThan(0)
+ }
+
+ const res = await fetchViaHTTP(
+ ctx.appPort,
+ '/_next/image',
+ { url: '/mountains.jpg', w: ctx.w, q: ctx.q },
+ opts
+ )
+ expect(res.status).toBe(200)
+
+ await retry(async () => {
+ const json3 = await fsToJson(ctx.imagesDir)
+ const json3Length = Object.keys(json3).length
+ if (maximumDiskCacheSize === 0) {
+ expect(json3Length).toEqual(0)
+ } else {
+ expect(json3Length).toBeGreaterThan(0)
+ expect(json3).not.toStrictEqual(json2)
+ }
+ const size = await getDirSize(imagesDir)
+ expect(size).toBeLessThanOrEqual(maximumDiskCacheSize)
+ })
+ })
+ }
}
export const setupTests = (ctx: SetupTestsCtx) => {
diff --git a/test/unit/image-optimizer/lru-disk-eviction.test.ts b/test/unit/image-optimizer/lru-disk-eviction.test.ts
new file mode 100644
index 0000000000000..cb65bef02b10a
--- /dev/null
+++ b/test/unit/image-optimizer/lru-disk-eviction.test.ts
@@ -0,0 +1,157 @@
+/* eslint-env jest */
+import { join } from 'path'
+import { promises } from 'fs'
+import { tmpdir } from 'os'
+import { setTimeout } from 'timers/promises'
+import {
+ getOrInitDiskLRU,
+ resetDiskLRU,
+} from 'next/dist/server/lib/disk-lru-cache.external'
+
+async function writeEntry(
+ cacheDir: string,
+ key: string,
+ sizeInBytes: number,
+ expireAt: number = Date.now() + 60_000
+) {
+ const dir = join(cacheDir, key)
+ const buffer = Buffer.alloc(sizeInBytes, 0x42) // Fill with dummy data
+ await promises.mkdir(dir, { recursive: true })
+ await promises.writeFile(join(dir, `${expireAt}.bin`), buffer)
+}
+
+async function readEntry(cacheDir: string, key: string) {
+ const dir = join(cacheDir, key)
+ const [file] = await promises.readdir(dir)
+ const buffer = await promises.readFile(join(dir, file))
+ const [expireAtStr] = file.split('.')
+ return { size: buffer.byteLength, expireAt: Number(expireAtStr) }
+}
+
+async function initEntries(
+ cacheDir: string
+): Promise> {
+ const keys = await promises.readdir(cacheDir).catch(() => [])
+ const entries: Array<{ key: string; size: number; expireAt: number }> = []
+
+ for (const key of keys) {
+ const { size, expireAt } = await readEntry(cacheDir, key)
+ entries.push({ key, size, expireAt })
+ }
+
+ // Sort oldest-first so we can replay them chronologically into LRU
+ return entries.sort((a, b) => a.expireAt - b.expireAt)
+}
+
+async function rmEntry(cacheDir: string, cacheKey: string): Promise {
+ await promises.rm(join(cacheDir, cacheKey), { recursive: true, force: true })
+}
+
+describe('LRU disk eviction', () => {
+ let cacheDir: string
+
+ beforeEach(async () => {
+ cacheDir = await promises.mkdtemp(join(tmpdir(), 'next-lru-test-'))
+ resetDiskLRU()
+ })
+
+ afterEach(async () => {
+ resetDiskLRU()
+ await promises.rm(cacheDir, { recursive: true, force: true })
+ })
+
+ it('should evict oldest entries on initialization', async () => {
+ const expireAt = Date.now() + 60_000
+ // Write 4 entries of 400 bytes each (total 1600)
+ await writeEntry(cacheDir, 'entry-a', 400, expireAt + 1)
+ await writeEntry(cacheDir, 'entry-b', 400, expireAt + 2)
+ await writeEntry(cacheDir, 'entry-c', 400, expireAt + 3)
+ await writeEntry(cacheDir, 'entry-d', 400, expireAt + 4)
+
+ // Init LRU with 1500 byte limit (less than 1600 current total)
+ const lru = await getOrInitDiskLRU(cacheDir, 1500, initEntries, rmEntry)
+
+ // entry-a should have been evicted (oldest)
+ expect(lru.has('entry-a')).toBe(false)
+ expect(lru.has('entry-b')).toBe(true)
+ expect(lru.has('entry-c')).toBe(true)
+ expect(lru.has('entry-d')).toBe(true)
+
+ // Verify disk eviction (fire-and-forget, so wait a tick)
+ await setTimeout(100)
+ const contents = await promises.readdir(cacheDir)
+ expect(contents).toEqual(['entry-b', 'entry-c', 'entry-d'])
+ })
+
+ it('should evict old entries when new entries are set', async () => {
+ const lru = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)
+
+ // Add entries via LRU set (simulating what ImageOptimizerCache.set does)
+ await writeEntry(cacheDir, 'new-a', 400)
+ await writeEntry(cacheDir, 'new-b', 400)
+ lru.set('new-a', 400)
+ lru.set('new-b', 400)
+
+ // Both should exist
+ expect(lru.has('new-a')).toBe(true)
+ expect(lru.has('new-b')).toBe(true)
+
+ // Adding a third entry should evict the oldest (new-a)
+ await writeEntry(cacheDir, 'new-c', 400)
+ lru.set('new-c', 400)
+
+ expect(lru.has('new-a')).toBe(false)
+ expect(lru.has('new-b')).toBe(true)
+ expect(lru.has('new-c')).toBe(true)
+
+ // Verify disk eviction (fire-and-forget, wait a tick)
+ await setTimeout(100)
+ const contents = await promises.readdir(cacheDir)
+ expect(contents).toEqual(['new-b', 'new-c'])
+ })
+
+ it('should promote entries on get() to prevent eviction', async () => {
+ const lru = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)
+
+ await writeEntry(cacheDir, 'x', 400)
+ await writeEntry(cacheDir, 'y', 400)
+ lru.set('x', 400)
+ lru.set('y', 400)
+
+ // Access 'x' to promote it (mark as recently used)
+ lru.get('x')
+
+ // Add 'z' - should evict 'y' (least recently used) instead of 'x'
+ await writeEntry(cacheDir, 'z', 400)
+ lru.set('z', 400)
+
+ expect(lru.has('x')).toBe(true)
+ expect(lru.has('y')).toBe(false)
+ expect(lru.has('z')).toBe(true)
+ })
+
+ it('should return the same LRU instance on subsequent calls', async () => {
+ const lru1 = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)
+ const lru2 = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)
+ expect(lru1 === lru2).toBeTrue()
+ })
+
+ it('should deduplicate concurrent init calls', async () => {
+ const [lru1, lru2] = await Promise.all([
+ getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry),
+ getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry),
+ ])
+ expect(lru1 === lru2).toBeTrue()
+ })
+
+ it('should handle empty cache directory', async () => {
+ const lru = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)
+ expect(lru.size).toBe(0)
+ })
+
+ it('should handle non-existent cache directory', async () => {
+ const missing = join(cacheDir, 'this-does-not-exist')
+ const lru = await getOrInitDiskLRU(missing, 1000, initEntries, rmEntry)
+ expect(lru.size).toBe(0)
+ })
+})
From e5d04e9866c3714ee046a2fd4be4e3090c858a10 Mon Sep 17 00:00:00 2001
From: nextjs-bot
Date: Fri, 20 Feb 2026 20:18:45 +0000
Subject: [PATCH 04/11] v16.2.0-canary.54
---
lerna.json | 2 +-
packages/create-next-app/package.json | 2 +-
packages/eslint-config-next/package.json | 4 ++--
packages/eslint-plugin-internal/package.json | 2 +-
packages/eslint-plugin-next/package.json | 2 +-
packages/font/package.json | 2 +-
packages/next-bundle-analyzer/package.json | 2 +-
packages/next-codemod/package.json | 2 +-
packages/next-env/package.json | 2 +-
packages/next-mdx/package.json | 2 +-
packages/next-plugin-storybook/package.json | 2 +-
packages/next-polyfill-module/package.json | 2 +-
packages/next-polyfill-nomodule/package.json | 2 +-
packages/next-routing/package.json | 2 +-
packages/next-rspack/package.json | 2 +-
packages/next-swc/package.json | 2 +-
packages/next/package.json | 14 +++++++-------
packages/react-refresh-utils/package.json | 2 +-
packages/third-parties/package.json | 4 ++--
pnpm-lock.yaml | 16 ++++++++--------
20 files changed, 35 insertions(+), 35 deletions(-)
diff --git a/lerna.json b/lerna.json
index 63883e99f069c..d949357bb94b2 100644
--- a/lerna.json
+++ b/lerna.json
@@ -15,5 +15,5 @@
"registry": "https://registry.npmjs.org/"
}
},
- "version": "16.2.0-canary.53"
+ "version": "16.2.0-canary.54"
}
\ No newline at end of file
diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json
index e1cf938b1d210..0028c4fb9ce92 100644
--- a/packages/create-next-app/package.json
+++ b/packages/create-next-app/package.json
@@ -1,6 +1,6 @@
{
"name": "create-next-app",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"keywords": [
"react",
"next",
diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json
index 6373ed74a6e9b..9c2e15c7b3625 100644
--- a/packages/eslint-config-next/package.json
+++ b/packages/eslint-config-next/package.json
@@ -1,6 +1,6 @@
{
"name": "eslint-config-next",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"description": "ESLint configuration used by Next.js.",
"license": "MIT",
"repository": {
@@ -12,7 +12,7 @@
"dist"
],
"dependencies": {
- "@next/eslint-plugin-next": "16.2.0-canary.53",
+ "@next/eslint-plugin-next": "16.2.0-canary.54",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json
index d42579f9eefc8..15ecb1a6c9934 100644
--- a/packages/eslint-plugin-internal/package.json
+++ b/packages/eslint-plugin-internal/package.json
@@ -1,7 +1,7 @@
{
"name": "@next/eslint-plugin-internal",
"private": true,
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"description": "ESLint plugin for working on Next.js.",
"exports": {
".": "./src/eslint-plugin-internal.js"
diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json
index e61ac0fe4a5ba..f2f4b20424751 100644
--- a/packages/eslint-plugin-next/package.json
+++ b/packages/eslint-plugin-next/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/eslint-plugin-next",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"description": "ESLint plugin for Next.js.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/font/package.json b/packages/font/package.json
index 736b3ee0f910f..24207c8f143b8 100644
--- a/packages/font/package.json
+++ b/packages/font/package.json
@@ -1,7 +1,7 @@
{
"name": "@next/font",
"private": true,
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"repository": {
"url": "vercel/next.js",
"directory": "packages/font"
diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json
index cab299f9cb247..3f4b034455878 100644
--- a/packages/next-bundle-analyzer/package.json
+++ b/packages/next-bundle-analyzer/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/bundle-analyzer",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"main": "index.js",
"types": "index.d.ts",
"license": "MIT",
diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json
index eceb78b7b4ece..e2887e8295a53 100644
--- a/packages/next-codemod/package.json
+++ b/packages/next-codemod/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/codemod",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"license": "MIT",
"repository": {
"type": "git",
diff --git a/packages/next-env/package.json b/packages/next-env/package.json
index 9a5c50eb82797..2f5835ba4038f 100644
--- a/packages/next-env/package.json
+++ b/packages/next-env/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/env",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"keywords": [
"react",
"next",
diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json
index 65f7ff84ff74a..fa2e0fc09e446 100644
--- a/packages/next-mdx/package.json
+++ b/packages/next-mdx/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/mdx",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"main": "index.js",
"license": "MIT",
"repository": {
diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json
index 2528c1708e216..846f56cb10b28 100644
--- a/packages/next-plugin-storybook/package.json
+++ b/packages/next-plugin-storybook/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/plugin-storybook",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"repository": {
"url": "vercel/next.js",
"directory": "packages/next-plugin-storybook"
diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json
index 534b180671c75..82d58ad78d3e4 100644
--- a/packages/next-polyfill-module/package.json
+++ b/packages/next-polyfill-module/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/polyfill-module",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)",
"main": "dist/polyfill-module.js",
"license": "MIT",
diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json
index a05747b88fedd..7512c0a9a8e1b 100644
--- a/packages/next-polyfill-nomodule/package.json
+++ b/packages/next-polyfill-nomodule/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/polyfill-nomodule",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"description": "A polyfill for non-dead, nomodule browsers.",
"main": "dist/polyfill-nomodule.js",
"license": "MIT",
diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json
index 6b4e66de21635..9e78a6be165b2 100644
--- a/packages/next-routing/package.json
+++ b/packages/next-routing/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/routing",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"keywords": [
"react",
"next",
diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json
index 2e6475852d8a7..371d8763e0a45 100644
--- a/packages/next-rspack/package.json
+++ b/packages/next-rspack/package.json
@@ -1,6 +1,6 @@
{
"name": "next-rspack",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"repository": {
"url": "vercel/next.js",
"directory": "packages/next-rspack"
diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json
index 5d2dd1386181e..1a8f81d3ef954 100644
--- a/packages/next-swc/package.json
+++ b/packages/next-swc/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/swc",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"private": true,
"files": [
"native/"
diff --git a/packages/next/package.json b/packages/next/package.json
index 51865cb81a0cb..a0cb54c325bc8 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -1,6 +1,6 @@
{
"name": "next",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"description": "The React Framework",
"main": "./dist/server/next.js",
"license": "MIT",
@@ -97,7 +97,7 @@
]
},
"dependencies": {
- "@next/env": "16.2.0-canary.53",
+ "@next/env": "16.2.0-canary.54",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -162,11 +162,11 @@
"@modelcontextprotocol/sdk": "1.18.1",
"@mswjs/interceptors": "0.23.0",
"@napi-rs/triples": "1.2.0",
- "@next/font": "16.2.0-canary.53",
- "@next/polyfill-module": "16.2.0-canary.53",
- "@next/polyfill-nomodule": "16.2.0-canary.53",
- "@next/react-refresh-utils": "16.2.0-canary.53",
- "@next/swc": "16.2.0-canary.53",
+ "@next/font": "16.2.0-canary.54",
+ "@next/polyfill-module": "16.2.0-canary.54",
+ "@next/polyfill-nomodule": "16.2.0-canary.54",
+ "@next/react-refresh-utils": "16.2.0-canary.54",
+ "@next/swc": "16.2.0-canary.54",
"@opentelemetry/api": "1.6.0",
"@playwright/test": "1.51.1",
"@rspack/core": "1.6.7",
diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json
index bbe1346cb2693..cf86f9a25d147 100644
--- a/packages/react-refresh-utils/package.json
+++ b/packages/react-refresh-utils/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/react-refresh-utils",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"description": "An experimental package providing utilities for React Refresh.",
"repository": {
"url": "vercel/next.js",
diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json
index 2cc8c0dd12bf0..5fdc1913f62f2 100644
--- a/packages/third-parties/package.json
+++ b/packages/third-parties/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/third-parties",
- "version": "16.2.0-canary.53",
+ "version": "16.2.0-canary.54",
"repository": {
"url": "vercel/next.js",
"directory": "packages/third-parties"
@@ -26,7 +26,7 @@
"third-party-capital": "1.0.20"
},
"devDependencies": {
- "next": "16.2.0-canary.53",
+ "next": "16.2.0-canary.54",
"outdent": "0.8.0",
"prettier": "2.5.1",
"typescript": "5.9.2"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e89517ec04837..cd46b112ad107 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1011,7 +1011,7 @@ importers:
packages/eslint-config-next:
dependencies:
'@next/eslint-plugin-next':
- specifier: 16.2.0-canary.53
+ specifier: 16.2.0-canary.54
version: link:../eslint-plugin-next
eslint:
specifier: '>=9.0.0'
@@ -1088,7 +1088,7 @@ importers:
packages/next:
dependencies:
'@next/env':
- specifier: 16.2.0-canary.53
+ specifier: 16.2.0-canary.54
version: link:../next-env
'@swc/helpers':
specifier: 0.5.15
@@ -1216,19 +1216,19 @@ importers:
specifier: 1.2.0
version: 1.2.0
'@next/font':
- specifier: 16.2.0-canary.53
+ specifier: 16.2.0-canary.54
version: link:../font
'@next/polyfill-module':
- specifier: 16.2.0-canary.53
+ specifier: 16.2.0-canary.54
version: link:../next-polyfill-module
'@next/polyfill-nomodule':
- specifier: 16.2.0-canary.53
+ specifier: 16.2.0-canary.54
version: link:../next-polyfill-nomodule
'@next/react-refresh-utils':
- specifier: 16.2.0-canary.53
+ specifier: 16.2.0-canary.54
version: link:../react-refresh-utils
'@next/swc':
- specifier: 16.2.0-canary.53
+ specifier: 16.2.0-canary.54
version: link:../next-swc
'@opentelemetry/api':
specifier: 1.6.0
@@ -1943,7 +1943,7 @@ importers:
version: 1.0.20
devDependencies:
next:
- specifier: 16.2.0-canary.53
+ specifier: 16.2.0-canary.54
version: link:../next
outdent:
specifier: 0.8.0
From 074ba4a953f124126b351277711560daa11d558e Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Fri, 20 Feb 2026 13:02:16 -0800
Subject: [PATCH 05/11] [test] Improve fetch timeout error stack for `act`
(#90261)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Before:
```
apiRequestContext.fetch: Request timed out after 30000ms
Call log:
- → GET http://localhost:55200/refetch-on-new-base-tree/a?_rsc=1u5ob
-
225 | // server; we pass the request to the server the immediately.
226 | result: (async () => {
> 227 | const originalResponse = await page.request.fetch(request, {
| ^
228 | maxRedirects: 0,
229 | })
230 |
at fetch (lib/router-act.ts:227:59)
at lib/router-act.ts:245:13
at routeHandler (lib/router-act.ts:257:7)
```
After:
```
apiRequestContext.fetch: Request timed out after 30000ms
Call log:
- → GET http://localhost:61662/refetch-on-new-base-tree/a?_rsc=1u5ob
-
264 |
265 | // Reveal the links to trigger prefetches
> 266 | await act(async () => {
| ^
267 | await linkALinkVisibilityToggle.click()
268 | await linkBLinkVisibilityToggle.click()
269 | }, [
at Object.act (e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts:266:11)
```
---
test/lib/router-act.ts | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/test/lib/router-act.ts b/test/lib/router-act.ts
index 01b02247d94ff..1e468afd9565a 100644
--- a/test/lib/router-act.ts
+++ b/test/lib/router-act.ts
@@ -224,9 +224,18 @@ export function createRouterAct(
// but it should not affect the timing of when requests reach the
// server; we pass the request to the server the immediately.
result: (async () => {
- const originalResponse = await page.request.fetch(request, {
- maxRedirects: 0,
- })
+ let originalResponse: Playwright.APIResponse
+ try {
+ originalResponse = await page.request.fetch(request, {
+ maxRedirects: 0,
+ })
+ } catch (fetchError) {
+ error.message =
+ fetchError instanceof Error
+ ? fetchError.message
+ : String(fetchError)
+ throw error
+ }
// WORKAROUND:
// intercepting responses with 'Transfer-Encoding: chunked' (used for streaming)
From 9404b901ab8c2fbc935c75258a48c08f3d192391 Mon Sep 17 00:00:00 2001
From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com>
Date: Fri, 20 Feb 2026 22:25:22 +0100
Subject: [PATCH 06/11] Turbopack: add Rope.content_hash and SHA hashing
(#90235)
We want `FileContent.content_hash()` to only hash the content bytes. This is also needed to produce the correct hashes [for SRI](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity)
However, the default `DeterministicHash` and `Hash` impls are ill-suited for that because they're a generic hashing infrastructure.
Hashing only the content bytes isn't what you want actually, otherwise `hash(("a", "b", "c"))` would be the same as `hash("abc")`
https://doc.rust-lang.org/std/hash/trait.Hasher.html#method.write_str describes this problem as well
This is exactly what we want for Rope though, so this adds a `Rope.content_hash() -> impl DeterministicHash`.
Additionally, this changes `content_hash` to accept a HashAlgorithm to prepare for sha SRI hashes. This required changing the return type to `RcStr`, because of the variable hash lengths. But the return value was always `encode_hex`d everywhere anyway.
---
Cargo.lock | 3 +
Cargo.toml | 1 +
crates/next-api/src/app.rs | 26 +++-----
crates/next-api/src/instrumentation.rs | 4 +-
crates/next-api/src/middleware.rs | 4 +-
crates/next-api/src/pages.rs | 30 +++-------
crates/next-api/src/paths.rs | 22 +++++--
.../next-core/src/next_app/metadata/image.rs | 27 +++------
.../src/next_api/endpoint.rs | 2 +-
.../next-image/next-image-proxy.test.ts | 2 +-
.../e2e/app-dir/next-image/next-image.test.ts | 24 ++++----
.../css/test/basic-global-support.test.ts | 16 ++---
turbopack/crates/turbo-tasks-fs/Cargo.toml | 2 +-
turbopack/crates/turbo-tasks-fs/src/lib.rs | 10 +++-
turbopack/crates/turbo-tasks-fs/src/rope.rs | 32 +++++++++-
turbopack/crates/turbo-tasks-hash/Cargo.toml | 3 +
turbopack/crates/turbo-tasks-hash/src/lib.rs | 42 +++++++++++++
turbopack/crates/turbo-tasks-hash/src/sha.rs | 60 +++++++++++++++++++
.../crates/turbo-tasks/src/task/task_input.rs | 4 +-
turbopack/crates/turbo-tasks/src/trace.rs | 2 +
.../turbopack-browser/src/chunking_context.rs | 17 ++++--
turbopack/crates/turbopack-core/src/asset.rs | 6 +-
.../src/chunk/chunking_context.rs | 2 +-
.../crates/turbopack-core/src/version.rs | 12 ++--
.../turbopack-nodejs/src/chunking_context.rs | 5 +-
.../turbopack-static/src/output_asset.rs | 11 +++-
...napshot_css_embed-url_input_6213f966._.css | 12 ++--
...{image.0ccdd6e3.png => image.7bbaf41b.png} | 0
...apshot_import-meta_url_input_3fece31c._.js | 2 +-
...{asset.a5b1faf2.txt => asset.6fdf3006.txt} | 0
...mports_ignore-comments_input_1f8151c3._.js | 4 +-
...81250f3.cjs => ignore-worker.4e0cf842.cjs} | 0
...ercel.242d4ff2.cjs => vercel.fad5a703.cjs} | 0
...napshot_imports_static_input_fb142aa8._.js | 2 +-
...ercel.ede1923f.svg => vercel.77adf0a9.svg} | 0
...snapshot_workers_basic_input_73fc86c5._.js | 2 +-
...{worker.60655f93.js => worker.adfa2c73.js} | 0
...napshot_workers_shared_input_3845375a._.js | 2 +-
...{worker.35b5336b.js => worker.b01ba0d1.js} | 0
turbopack/crates/turbopack-wasm/src/lib.rs | 9 ++-
40 files changed, 274 insertions(+), 128 deletions(-)
create mode 100644 turbopack/crates/turbo-tasks-hash/src/sha.rs
rename turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/static/{image.0ccdd6e3.png => image.7bbaf41b.png} (100%)
rename turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/static/{asset.a5b1faf2.txt => asset.6fdf3006.txt} (100%)
rename turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/{ignore-worker.481250f3.cjs => ignore-worker.4e0cf842.cjs} (100%)
rename turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/{vercel.242d4ff2.cjs => vercel.fad5a703.cjs} (100%)
rename turbopack/crates/turbopack-tests/tests/snapshot/imports/static/static/{vercel.ede1923f.svg => vercel.77adf0a9.svg} (100%)
rename turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/static/{worker.60655f93.js => worker.adfa2c73.js} (100%)
rename turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/static/{worker.35b5336b.js => worker.b01ba0d1.js} (100%)
diff --git a/Cargo.lock b/Cargo.lock
index 0b481d7455964..ae201e2df9cdc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -9646,6 +9646,9 @@ dependencies = [
name = "turbo-tasks-hash"
version = "0.1.0"
dependencies = [
+ "bincode 2.0.1",
+ "data-encoding",
+ "sha2",
"turbo-tasks-macros",
"twox-hash 2.1.0",
]
diff --git a/Cargo.toml b/Cargo.toml
index 07e53b4cc489f..c2bf214cd4e77 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -448,6 +448,7 @@ serde_json = "1.0.138"
serde_path_to_error = "0.1.16"
serde_qs = "0.13.0"
serde_with = "3.12.0"
+sha2 = "0.10.2"
smallvec = { version = "1.15.1", features = [
"serde",
"const_generics",
diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs
index ecd22c1233ba3..0c9c97e46414e 100644
--- a/crates/next-api/src/app.rs
+++ b/crates/next-api/src/app.rs
@@ -2007,24 +2007,14 @@ impl Endpoint for AppEndpoint {
async move {
let output = self.output();
- let output_assets = output.output_assets();
- let output = output.await?;
- let node_root = &*this.app_project.project().node_root().await?;
+ let project = this.app_project.project();
+ let node_root = project.node_root().owned().await?;
+ let client_relative_root = project.client_relative_path().owned().await?;
- let (server_paths, client_paths) = if this
- .app_project
- .project()
- .next_mode()
- .await?
- .is_development()
- {
- let node_root = this.app_project.project().node_root().owned().await?;
- let server_paths = all_asset_paths(output_assets, node_root).owned().await?;
+ let output_assets = output.output_assets();
- let client_relative_root = this
- .app_project
- .project()
- .client_relative_path()
+ let (server_paths, client_paths) = if project.next_mode().await?.is_development() {
+ let server_paths = all_asset_paths(output_assets, node_root.clone(), None)
.owned()
.await?;
let client_paths = all_paths_in_root(output_assets, client_relative_root)
@@ -2035,7 +2025,7 @@ impl Endpoint for AppEndpoint {
(vec![], vec![])
};
- let written_endpoint = match *output {
+ let written_endpoint = match *output.await? {
AppEndpointOutput::NodeJs { rsc_chunk, .. } => EndpointOutputPaths::NodeJs {
server_entry_path: node_root
.get_path_to(&*rsc_chunk.path().await?)
@@ -2054,7 +2044,7 @@ impl Endpoint for AppEndpoint {
EndpointOutput {
output_assets: output_assets.to_resolved().await?,
output_paths: written_endpoint.resolved_cell(),
- project: this.app_project.project().to_resolved().await?,
+ project: project.to_resolved().await?,
}
.cell(),
)
diff --git a/crates/next-api/src/instrumentation.rs b/crates/next-api/src/instrumentation.rs
index 2f75a387739d3..2954f05466beb 100644
--- a/crates/next-api/src/instrumentation.rs
+++ b/crates/next-api/src/instrumentation.rs
@@ -210,7 +210,9 @@ impl Endpoint for InstrumentationEndpoint {
let server_paths = if this.project.next_mode().await?.is_development() {
let node_root = this.project.node_root().owned().await?;
- all_asset_paths(output_assets, node_root).owned().await?
+ all_asset_paths(output_assets, node_root, None)
+ .owned()
+ .await?
} else {
vec![]
};
diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs
index bfb4d39064912..e814a425f4e68 100644
--- a/crates/next-api/src/middleware.rs
+++ b/crates/next-api/src/middleware.rs
@@ -337,7 +337,9 @@ impl Endpoint for MiddlewareEndpoint {
let (server_paths, client_paths) = if this.project.next_mode().await?.is_development() {
let node_root = this.project.node_root().owned().await?;
- let server_paths = all_asset_paths(output_assets, node_root).owned().await?;
+ let server_paths = all_asset_paths(output_assets, node_root, None)
+ .owned()
+ .await?;
// Middleware could in theory have a client path (e.g. `new URL`).
let client_relative_root = this.project.client_relative_path().owned().await?;
diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs
index e72282c98ab86..ebcccecf7a3e8 100644
--- a/crates/next-api/src/pages.rs
+++ b/crates/next-api/src/pages.rs
@@ -1601,26 +1601,15 @@ impl Endpoint for PageEndpoint {
}
};
async move {
- let output = self.output().await?;
- let output_assets = self.output().output_assets();
+ let output = self.output();
+ let project = this.pages_project.project();
+ let node_root = project.node_root().owned().await?;
+ let client_relative_root = project.client_relative_path().owned().await?;
- let node_root = this.pages_project.project().node_root().owned().await?;
+ let output_assets = output.output_assets();
- let (server_paths, client_paths) = if this
- .pages_project
- .project()
- .next_mode()
- .await?
- .is_development()
- {
- let server_paths = all_asset_paths(output_assets, node_root.clone())
- .owned()
- .await?;
-
- let client_relative_root = this
- .pages_project
- .project()
- .client_relative_path()
+ let (server_paths, client_paths) = if project.next_mode().await?.is_development() {
+ let server_paths = all_asset_paths(output_assets, node_root.clone(), None)
.owned()
.await?;
let client_paths = all_paths_in_root(output_assets, client_relative_root)
@@ -1631,8 +1620,7 @@ impl Endpoint for PageEndpoint {
(vec![], vec![])
};
- let node_root = node_root.clone();
- let written_endpoint = match *output {
+ let written_endpoint = match *output.await? {
PageEndpointOutput::NodeJs { entry_chunk, .. } => {
// Only set server_entry_path if pages should be created
let pages_structure = this.pages_structure.await?;
@@ -1661,7 +1649,7 @@ impl Endpoint for PageEndpoint {
EndpointOutput {
output_assets: output_assets.to_resolved().await?,
output_paths: written_endpoint.resolved_cell(),
- project: this.pages_project.project().to_resolved().await?,
+ project: project.to_resolved().await?,
}
.cell(),
)
diff --git a/crates/next-api/src/paths.rs b/crates/next-api/src/paths.rs
index bc9d464099acb..80e46d066278a 100644
--- a/crates/next-api/src/paths.rs
+++ b/crates/next-api/src/paths.rs
@@ -1,9 +1,10 @@
-use anyhow::Result;
+use anyhow::{Context, Result};
use next_core::next_manifests::AssetBinding;
use tracing::Instrument;
use turbo_rcstr::RcStr;
use turbo_tasks::{ResolvedVc, TryFlatJoinIterExt, TryJoinIterExt, Vc};
use turbo_tasks_fs::FileSystemPath;
+use turbo_tasks_hash::{HashAlgorithm, encode_hex};
use turbopack_core::{
asset::Asset,
output::{OutputAsset, OutputAssets},
@@ -17,7 +18,7 @@ use turbopack_wasm::wasm_edge_var_name;
pub struct AssetPath {
/// Relative to the root_path
pub path: RcStr,
- pub content_hash: u64,
+ pub content_hash: RcStr,
}
/// A list of asset paths
@@ -31,13 +32,23 @@ pub struct OptionAssetPath(Option);
async fn asset_path(
asset: Vc>,
node_root: FileSystemPath,
+ should_content_hash: Option,
) -> Result> {
Ok(Vc::cell(
if let Some(path) = node_root.get_path_to(&*asset.path().await?) {
- let content_hash = *asset.content().hash().await?;
+ let hash = if let Some(algorithm) = should_content_hash {
+ asset
+ .content()
+ .content_hash(algorithm)
+ .owned()
+ .await?
+ .context("asset content not found")?
+ } else {
+ encode_hex(*asset.content().hash().await?).into()
+ };
Some(AssetPath {
path: RcStr::from(path),
- content_hash,
+ content_hash: hash,
})
} else {
None
@@ -51,6 +62,7 @@ async fn asset_path(
pub async fn all_asset_paths(
assets: Vc,
node_root: FileSystemPath,
+ should_content_hash: Option,
) -> Result> {
let span = tracing::info_span!(
"collect all asset paths",
@@ -63,7 +75,7 @@ pub async fn all_asset_paths(
span.record("assets_count", all_assets.len());
let asset_paths = all_assets
.iter()
- .map(|&asset| asset_path(*asset, node_root.clone()).owned())
+ .map(|&asset| asset_path(*asset, node_root.clone(), should_content_hash).owned())
.try_flat_join()
.await?;
span.record("asset_paths_count", asset_paths.len());
diff --git a/crates/next-core/src/next_app/metadata/image.rs b/crates/next-core/src/next_app/metadata/image.rs
index 06f550b8c444b..1597d61e051b0 100644
--- a/crates/next-core/src/next_app/metadata/image.rs
+++ b/crates/next-core/src/next_app/metadata/image.rs
@@ -7,6 +7,7 @@ use indoc::formatdoc;
use turbo_rcstr::RcStr;
use turbo_tasks::{ResolvedVc, Vc};
use turbo_tasks_fs::{File, FileContent, FileSystemPath};
+use turbo_tasks_hash::HashAlgorithm;
use turbopack_core::{
asset::AssetContent,
context::AssetContext,
@@ -33,13 +34,8 @@ async fn dynamic_image_metadata_with_generator_source(
let stem = stem.unwrap_or_default();
let ext = path.extension();
- let hash_query = format!(
- "?{:x}",
- path.read()
- .content_hash()
- .await?
- .context("metadata file not found")?
- );
+ let hash = path.read().content_hash(HashAlgorithm::default()).await?;
+ let hash = hash.as_ref().context("metadata file not found")?;
let use_numeric_sizes = ty == "twitter" || ty == "openGraph";
let sizes = if use_numeric_sizes {
@@ -70,7 +66,7 @@ async fn dynamic_image_metadata_with_generator_source(
const data = {{
alt: imageMetadata.alt,
type: imageMetadata.contentType || 'image/png',
- url: imageUrl + (idParam ? ('/' + idParam) : '') + {hash_query},
+ url: imageUrl + (idParam ? ('/' + idParam) : '') + '?' + {hash},
}}
const {{ size }} = imageMetadata
if (size) {{
@@ -91,7 +87,7 @@ async fn dynamic_image_metadata_with_generator_source(
pathname_prefix = StringifyJs(&page.to_string()),
page_segment = StringifyJs(stem),
sizes = sizes,
- hash_query = StringifyJs(&hash_query),
+ hash = StringifyJs(&hash),
};
let file = File::from(code);
@@ -113,13 +109,8 @@ async fn dynamic_image_metadata_without_generator_source(
let stem = stem.unwrap_or_default();
let ext = path.extension();
- let hash_query = format!(
- "?{:x}",
- path.read()
- .content_hash()
- .await?
- .context("metadata file not found")?
- );
+ let hash = path.read().content_hash(HashAlgorithm::default()).await?;
+ let hash = hash.as_ref().context("metadata file not found")?;
let use_numeric_sizes = ty == "twitter" || ty == "openGraph";
let sizes = if use_numeric_sizes {
@@ -148,7 +139,7 @@ async fn dynamic_image_metadata_without_generator_source(
const data = {{
alt: imageMetadata.alt,
type: imageMetadata.contentType || 'image/png',
- url: imageUrl + (idParam ? ('/' + idParam) : '') + {hash_query},
+ url: imageUrl + (idParam ? ('/' + idParam) : '') + '?' + {hash},
}}
const {{ size }} = imageMetadata
if (size) {{
@@ -165,7 +156,7 @@ async fn dynamic_image_metadata_without_generator_source(
pathname_prefix = StringifyJs(&page.to_string()),
page_segment = StringifyJs(stem),
sizes = sizes,
- hash_query = StringifyJs(&hash_query),
+ hash = StringifyJs(&hash),
};
let file = File::from(code);
diff --git a/crates/next-napi-bindings/src/next_api/endpoint.rs b/crates/next-napi-bindings/src/next_api/endpoint.rs
index fd94c22130ac9..a5a5684c77166 100644
--- a/crates/next-napi-bindings/src/next_api/endpoint.rs
+++ b/crates/next-napi-bindings/src/next_api/endpoint.rs
@@ -39,7 +39,7 @@ impl From for NapiAssetPath {
fn from(asset_path: AssetPath) -> Self {
Self {
path: asset_path.path.into_owned(),
- content_hash: format!("{:x}", asset_path.content_hash),
+ content_hash: asset_path.content_hash.into_owned(),
}
}
}
diff --git a/test/e2e/app-dir/next-image/next-image-proxy.test.ts b/test/e2e/app-dir/next-image/next-image-proxy.test.ts
index 3228852f74a3f..ed62aa3331db8 100644
--- a/test/e2e/app-dir/next-image/next-image-proxy.test.ts
+++ b/test/e2e/app-dir/next-image/next-image-proxy.test.ts
@@ -80,7 +80,7 @@ describe('next-image-proxy', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(local).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=90"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=90"`
)
} else {
expect(local).toMatchInlineSnapshot(
diff --git a/test/e2e/app-dir/next-image/next-image.test.ts b/test/e2e/app-dir/next-image/next-image.test.ts
index 54a816ce01157..70c43a484255f 100644
--- a/test/e2e/app-dir/next-image/next-image.test.ts
+++ b/test/e2e/app-dir/next-image/next-image.test.ts
@@ -50,7 +50,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(layout.attr('src')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=85"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=85"`
)
} else {
expect(layout.attr('src')).toMatchInlineSnapshot(
@@ -60,7 +60,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(layout.attr('srcset')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=640&q=85 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=85 2x"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=640&q=85 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=85 2x"`
)
} else {
expect(layout.attr('srcset')).toMatchInlineSnapshot(
@@ -72,7 +72,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(page.attr('src')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=90"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=90"`
)
} else {
expect(page.attr('src')).toMatchInlineSnapshot(
@@ -82,7 +82,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(page.attr('srcset')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=640&q=90 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=90 2x"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=640&q=90 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=90 2x"`
)
} else {
expect(page.attr('srcset')).toMatchInlineSnapshot(
@@ -94,7 +94,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(comp.attr('src')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=80"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=80"`
)
} else {
expect(comp.attr('src')).toMatchInlineSnapshot(
@@ -104,7 +104,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(comp.attr('srcset')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=640&q=80 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=80 2x"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=640&q=80 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=80 2x"`
)
} else {
expect(comp.attr('srcset')).toMatchInlineSnapshot(
@@ -194,7 +194,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(await layout.getAttribute('src')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=85"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=85"`
)
} else {
expect(await layout.getAttribute('src')).toMatchInlineSnapshot(
@@ -204,7 +204,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(await layout.getAttribute('srcset')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=640&q=85 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=85 2x"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=640&q=85 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=85 2x"`
)
} else {
expect(await layout.getAttribute('srcset')).toMatchInlineSnapshot(
@@ -216,7 +216,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(await page.getAttribute('src')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=90"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=90"`
)
} else {
expect(await page.getAttribute('src')).toMatchInlineSnapshot(
@@ -226,7 +226,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(await page.getAttribute('srcset')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=640&q=90 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=90 2x"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=640&q=90 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=90 2x"`
)
} else {
expect(await page.getAttribute('srcset')).toMatchInlineSnapshot(
@@ -238,7 +238,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(await comp.getAttribute('src')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=80"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=80"`
)
} else {
expect(await comp.getAttribute('src')).toMatchInlineSnapshot(
@@ -248,7 +248,7 @@ describe('app dir - next-image', () => {
if (process.env.IS_TURBOPACK_TEST) {
expect(await comp.getAttribute('srcset')).toMatchInlineSnapshot(
- `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=640&q=80 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.4813cd24.png&w=828&q=80 2x"`
+ `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=640&q=80 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.55df2443.png&w=828&q=80 2x"`
)
} else {
expect(await comp.getAttribute('srcset')).toMatchInlineSnapshot(
diff --git a/test/integration/css/test/basic-global-support.test.ts b/test/integration/css/test/basic-global-support.test.ts
index 92206b02961a0..fd89a1cecf60c 100644
--- a/test/integration/css/test/basic-global-support.test.ts
+++ b/test/integration/css/test/basic-global-support.test.ts
@@ -564,8 +564,8 @@ module.exports = {
expect(cssContent).toMatchInlineSnapshot(`
[
"/_next/static/chunks/HASH.css:
- .red-text{color:red;background-image:url(../media/dark.8425d343.svg),url(../media/dark2.8425d343.svg)}
- .blue-text{color:orange;background-image:url(../media/light.fc9b5caa.svg);font-weight:bolder}
+ .red-text{color:red;background-image:url(../media/dark.656fe8ee.svg),url(../media/dark2.656fe8ee.svg)}
+ .blue-text{color:orange;background-image:url(../media/light.750c956a.svg);font-weight:bolder}
.blue-text{color:#00f}",
]
`)
@@ -573,8 +573,8 @@ module.exports = {
expect(cssContent).toMatchInlineSnapshot(`
[
"/_next/static/chunks/HASH.css:
- .red-text{color:red;background-image:url(../media/dark.8425d343.svg),url(../media/dark2.8425d343.svg)}
- .blue-text{color:orange;background-image:url(../media/light.fc9b5caa.svg);font-weight:bolder}
+ .red-text{color:red;background-image:url(../media/dark.656fe8ee.svg),url(../media/dark2.656fe8ee.svg)}
+ .blue-text{color:orange;background-image:url(../media/light.750c956a.svg);font-weight:bolder}
.blue-text{color:#00f}",
]
`)
@@ -640,8 +640,8 @@ describe('CSS URL via `file-loader` and asset prefix (1)', () => {
expect(cssContent).toMatchInlineSnapshot(`
[
"/_next/static/chunks/HASH.css:
- .red-text{color:red;background-image:url(../media/dark.8425d343.svg) url(../media/dark2.8425d343.svg)}
- .blue-text{color:orange;background-image:url(../media/light.fc9b5caa.svg);font-weight:bolder}
+ .red-text{color:red;background-image:url(../media/dark.656fe8ee.svg) url(../media/dark2.656fe8ee.svg)}
+ .blue-text{color:orange;background-image:url(../media/light.750c956a.svg);font-weight:bolder}
.blue-text{color:#00f}",
]
`)
@@ -692,8 +692,8 @@ describe('CSS URL via `file-loader` and asset prefix (2)', () => {
expect(cssContent).toMatchInlineSnapshot(`
[
"/_next/static/chunks/HASH.css:
- .red-text{color:red;background-image:url(../media/dark.8425d343.svg) url(../media/dark2.8425d343.svg)}
- .blue-text{color:orange;background-image:url(../media/light.fc9b5caa.svg);font-weight:bolder}
+ .red-text{color:red;background-image:url(../media/dark.656fe8ee.svg) url(../media/dark2.656fe8ee.svg)}
+ .blue-text{color:orange;background-image:url(../media/light.750c956a.svg);font-weight:bolder}
.blue-text{color:#00f}",
]
`)
diff --git a/turbopack/crates/turbo-tasks-fs/Cargo.toml b/turbopack/crates/turbo-tasks-fs/Cargo.toml
index d9699c19a9728..dc01622369f37 100644
--- a/turbopack/crates/turbo-tasks-fs/Cargo.toml
+++ b/turbopack/crates/turbo-tasks-fs/Cargo.toml
@@ -58,7 +58,7 @@ urlencoding = { workspace = true }
criterion = { workspace = true, features = ["async_tokio"] }
rand = { workspace = true }
rstest = { workspace = true }
-sha2 = "0.10.2"
+sha2 = { workspace = true }
tempfile = { workspace = true }
turbo-tasks-testing = { workspace = true }
turbo-tasks-backend = { workspace = true }
diff --git a/turbopack/crates/turbo-tasks-fs/src/lib.rs b/turbopack/crates/turbo-tasks-fs/src/lib.rs
index 3a401a5fd2c7d..13732e5995bca 100644
--- a/turbopack/crates/turbo-tasks-fs/src/lib.rs
+++ b/turbopack/crates/turbo-tasks-fs/src/lib.rs
@@ -67,7 +67,9 @@ use turbo_tasks::{
ResolvedVc, TaskInput, TurboTasksApi, ValueToString, Vc, debug::ValueDebugFormat, effect,
mark_session_dependent, parallel, trace::TraceRawVcs, turbo_tasks_weak,
};
-use turbo_tasks_hash::{DeterministicHash, DeterministicHasher, hash_xxh3_hash64};
+use turbo_tasks_hash::{
+ DeterministicHash, DeterministicHasher, HashAlgorithm, deterministic_hash, hash_xxh3_hash64,
+};
use turbo_unix_path::{
get_parent_path, get_relative_path_to, join_path, normalize_path, sys_to_unix, unix_to_sys,
};
@@ -2371,9 +2373,11 @@ impl FileContent {
/// Compared to [FileContent::hash], this hashes only the bytes of the file content and nothing
/// else. If there is no file content, it returns `None`.
#[turbo_tasks::function]
- pub async fn content_hash(&self) -> Result>> {
+ pub async fn content_hash(&self, algorithm: HashAlgorithm) -> Result>> {
match self {
- FileContent::Content(file) => Ok(Vc::cell(Some(hash_xxh3_hash64(&file.content)))),
+ FileContent::Content(file) => Ok(Vc::cell(Some(
+ deterministic_hash(file.content().content_hash(), algorithm).into(),
+ ))),
FileContent::NotFound => Ok(Vc::cell(None)),
}
}
diff --git a/turbopack/crates/turbo-tasks-fs/src/rope.rs b/turbopack/crates/turbo-tasks-fs/src/rope.rs
index 7301eca1ae408..43025424427ee 100644
--- a/turbopack/crates/turbo-tasks-fs/src/rope.rs
+++ b/turbopack/crates/turbo-tasks-fs/src/rope.rs
@@ -391,6 +391,25 @@ impl DeterministicHash for Rope {
}
}
+impl Rope {
+ /// Returns a DeterministicHash impl that only hashes the bytes of the rope (still regardless of
+ /// their structure).
+ ///
+ /// The default (Deterministic)Hash implementation also includes the length of the rope. Be
+ /// careful when using this, as it would case `(Rope("abc"), Rope("def"))` and `(Rope("abcd"),
+ /// Rope("ef"))` to have the same hash. The best usecase is when the rope is the _whole_
+ /// datastructure being hashed and it isn't part of some other structure.
+ pub fn content_hash(&self) -> impl DeterministicHash + '_ {
+ RopeBytesOnlyHash(self)
+ }
+}
+pub struct RopeBytesOnlyHash<'a>(&'a Rope);
+impl DeterministicHash for RopeBytesOnlyHash<'_> {
+ fn deterministic_hash(&self, state: &mut H) {
+ self.0.data.deterministic_hash(state);
+ }
+}
+
/// Encode as a len + raw bytes format using the encoder's [`bincode::enc::write::Writer`]. Encoding
/// [`Rope::to_bytes`] instead would be easier, but would require copying to an intermediate buffer.
///
@@ -893,7 +912,7 @@ mod test {
};
use anyhow::Result;
- use turbo_tasks_hash::hash_xxh3_hash64;
+ use turbo_tasks_hash::{DeterministicHasher, Xxh3Hash64Hasher, hash_xxh3_hash64};
use super::{InnerRope, Rope, RopeBuilder, RopeElem};
@@ -1094,6 +1113,17 @@ mod test {
assert_eq!(hash_xxh3_hash64(a), hash_xxh3_hash64(b));
}
+ #[test]
+ fn content_hash() {
+ let rope = Rope::new(vec!["abc".into(), "def".into()]);
+
+ let string = "abcdef";
+ let mut hasher = Xxh3Hash64Hasher::default();
+ hasher.write_bytes(string.as_bytes());
+
+ assert_eq!(hash_xxh3_hash64(rope.content_hash()), hasher.finish());
+ }
+
#[test]
fn iteration() {
let shared = Rope::from("def");
diff --git a/turbopack/crates/turbo-tasks-hash/Cargo.toml b/turbopack/crates/turbo-tasks-hash/Cargo.toml
index 1b88064715ed6..4be1293fb4e09 100644
--- a/turbopack/crates/turbo-tasks-hash/Cargo.toml
+++ b/turbopack/crates/turbo-tasks-hash/Cargo.toml
@@ -13,5 +13,8 @@ bench = false
workspace = true
[dependencies]
+bincode = { workspace = true }
+data-encoding = { workspace = true }
+sha2 = { workspace = true }
turbo-tasks-macros = { workspace = true }
twox-hash = { workspace = true }
diff --git a/turbopack/crates/turbo-tasks-hash/src/lib.rs b/turbopack/crates/turbo-tasks-hash/src/lib.rs
index ddcc8ca13d131..9951306fd511e 100644
--- a/turbopack/crates/turbo-tasks-hash/src/lib.rs
+++ b/turbopack/crates/turbo-tasks-hash/src/lib.rs
@@ -6,10 +6,52 @@
mod deterministic_hash;
mod hex;
+mod sha;
mod xxh3_hash64;
+use bincode::{Decode, Encode};
+
+#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Decode, Encode)]
+pub enum HashAlgorithm {
+ /// The default hash algorithm, use this when the exact hashing algorithm doesn't matter.
+ #[default]
+ Xxh3Hash64Hex,
+ /// Used for https://nextjs.org/docs/app/guides/content-security-policy#enabling-sri
+ Sha256Base64,
+ /// Used for https://nextjs.org/docs/app/guides/content-security-policy#enabling-sri
+ Sha384Base64,
+ /// Used for https://nextjs.org/docs/app/guides/content-security-policy#enabling-sri
+ Sha512Base64,
+}
+
+pub fn deterministic_hash(input: T, algorithm: HashAlgorithm) -> String {
+ match algorithm {
+ HashAlgorithm::Xxh3Hash64Hex => {
+ let mut hasher = Xxh3Hash64Hasher::new();
+ input.deterministic_hash(&mut hasher);
+ encode_hex(hasher.finish())
+ }
+ HashAlgorithm::Sha256Base64 => {
+ let mut hasher = ShaHasher::new_sha256();
+ input.deterministic_hash(&mut hasher);
+ hasher.finish_base64()
+ }
+ HashAlgorithm::Sha384Base64 => {
+ let mut hasher = ShaHasher::new_sha384();
+ input.deterministic_hash(&mut hasher);
+ hasher.finish_base64()
+ }
+ HashAlgorithm::Sha512Base64 => {
+ let mut hasher = ShaHasher::new_sha512();
+ input.deterministic_hash(&mut hasher);
+ hasher.finish_base64()
+ }
+ }
+}
+
pub use crate::{
deterministic_hash::{DeterministicHash, DeterministicHasher},
hex::encode_hex,
+ sha::ShaHasher,
xxh3_hash64::{Xxh3Hash64Hasher, hash_xxh3_hash64},
};
diff --git a/turbopack/crates/turbo-tasks-hash/src/sha.rs b/turbopack/crates/turbo-tasks-hash/src/sha.rs
new file mode 100644
index 0000000000000..71a2fdfdd5465
--- /dev/null
+++ b/turbopack/crates/turbo-tasks-hash/src/sha.rs
@@ -0,0 +1,60 @@
+use sha2::{Digest, Sha256, Sha384, Sha512, digest::typenum::Unsigned};
+
+use crate::{DeterministicHash, DeterministicHasher};
+
+pub struct ShaHasher(D);
+
+impl ShaHasher
+where
+ sha2::digest::Output: core::fmt::LowerHex,
+{
+ /// Uses the DeterministicHash trait to hash the input in a
+ /// cross-platform way.
+ pub fn write_value(&mut self, input: T) {
+ input.deterministic_hash(self);
+ }
+
+ /// Uses the DeterministicHash trait to hash the input in a
+ /// cross-platform way.
+ pub fn write_ref(&mut self, input: &T) {
+ input.deterministic_hash(self);
+ }
+
+ /// Finish the hash computation and return the digest as hex.
+ pub fn finish(self) -> String {
+ let result = self.0.finalize();
+ format!("{:01$x}", result, D::OutputSize::to_usize() * 2)
+ }
+
+ /// Finish the hash computation and return the digest as base64.
+ pub fn finish_base64(self) -> String {
+ let result = self.0.finalize();
+ data_encoding::BASE64.encode(result.as_slice())
+ }
+}
+
+impl DeterministicHasher for ShaHasher {
+ fn finish(&self) -> u64 {
+ panic!("use the ShaHasher non-trait function instead");
+ }
+
+ fn write_bytes(&mut self, bytes: &[u8]) {
+ self.0.update(bytes);
+ }
+}
+
+impl ShaHasher {
+ pub fn new_sha256() -> Self {
+ ShaHasher(Sha256::new())
+ }
+}
+impl ShaHasher {
+ pub fn new_sha384() -> Self {
+ ShaHasher(Sha384::new())
+ }
+}
+impl ShaHasher {
+ pub fn new_sha512() -> Self {
+ ShaHasher(Sha512::new())
+ }
+}
diff --git a/turbopack/crates/turbo-tasks/src/task/task_input.rs b/turbopack/crates/turbo-tasks/src/task/task_input.rs
index 285c08b44d726..ade62cee2be58 100644
--- a/turbopack/crates/turbo-tasks/src/task/task_input.rs
+++ b/turbopack/crates/turbo-tasks/src/task/task_input.rs
@@ -18,6 +18,7 @@ use bincode::{
use either::Either;
use turbo_frozenmap::{FrozenMap, FrozenSet};
use turbo_rcstr::RcStr;
+use turbo_tasks_hash::HashAlgorithm;
// This import is necessary for derive macros to work, as their expansion refers to the crate
// name directly.
@@ -72,7 +73,8 @@ impl_task_input! {
TaskId,
ValueTypeId,
Duration,
- String
+ String,
+ HashAlgorithm
}
impl TaskInput for Vec
diff --git a/turbopack/crates/turbo-tasks/src/trace.rs b/turbopack/crates/turbo-tasks/src/trace.rs
index e0bc148ef58b0..eb0f35c29fa22 100644
--- a/turbopack/crates/turbo-tasks/src/trace.rs
+++ b/turbopack/crates/turbo-tasks/src/trace.rs
@@ -14,6 +14,7 @@ use indexmap::{IndexMap, IndexSet};
use smallvec::SmallVec;
use turbo_frozenmap::{FrozenMap, FrozenSet};
use turbo_rcstr::RcStr;
+use turbo_tasks_hash::HashAlgorithm;
use crate::RawVc;
@@ -81,6 +82,7 @@ ignore!(
ignore!((), str, String, Duration, anyhow::Error, RcStr);
ignore!(Path, PathBuf);
ignore!(serde_json::Value, serde_json::Map);
+ignore!(HashAlgorithm);
impl TraceRawVcs for PhantomData {
fn trace_raw_vcs(&self, _trace_context: &mut TraceRawVcsContext) {}
diff --git a/turbopack/crates/turbopack-browser/src/chunking_context.rs b/turbopack/crates/turbopack-browser/src/chunking_context.rs
index 2971cf82c94a6..247881c9060ef 100644
--- a/turbopack/crates/turbopack-browser/src/chunking_context.rs
+++ b/turbopack/crates/turbopack-browser/src/chunking_context.rs
@@ -7,7 +7,7 @@ use turbo_tasks::{
trace::TraceRawVcs,
};
use turbo_tasks_fs::FileSystemPath;
-use turbo_tasks_hash::{DeterministicHash, encode_hex};
+use turbo_tasks_hash::{DeterministicHash, HashAlgorithm};
use turbopack_core::{
asset::Asset,
chunk::{
@@ -577,15 +577,20 @@ impl ChunkingContext for BrowserChunkingContext {
let Some(asset) = asset else {
bail!("chunk_path requires an asset when content hashing is enabled");
};
- let hash = asset.content().content_hash().await?.context(
+ let hash = asset
+ .content()
+ .content_hash(HashAlgorithm::default())
+ .await?;
+ let hash = hash.as_ref().context(
"chunk_path requires an asset with file content when content hashing is \
enabled",
)?;
let length = length as usize;
+ let hash = &hash[0..length];
if let Some(prefix) = prefix {
- format!("{prefix}-{hash:0length$x}{extension}").into()
+ format!("{prefix}-{hash}{extension}").into()
} else {
- format!("{hash:0length$x}{extension}").into()
+ format!("{hash}{extension}").into()
}
}
};
@@ -641,13 +646,13 @@ impl ChunkingContext for BrowserChunkingContext {
#[turbo_tasks::function]
async fn asset_path(
&self,
- content_hash: Vc,
+ content_hash: Vc,
original_asset_ident: Vc,
tag: Option,
) -> Result> {
let source_path = original_asset_ident.path().await?;
let basename = source_path.file_name();
- let content_hash = encode_hex(*content_hash.await?);
+ let content_hash = content_hash.await?;
let asset_path = match source_path.extension_ref() {
Some(ext) => format!(
"{basename}.{content_hash}.{ext}",
diff --git a/turbopack/crates/turbopack-core/src/asset.rs b/turbopack/crates/turbopack-core/src/asset.rs
index cf5571d96a68a..b40e81ad66f7d 100644
--- a/turbopack/crates/turbopack-core/src/asset.rs
+++ b/turbopack/crates/turbopack-core/src/asset.rs
@@ -4,7 +4,7 @@ use turbo_tasks::{ResolvedVc, Vc};
use turbo_tasks_fs::{
FileContent, FileJsonContent, FileLinesContent, FileSystemPath, LinkContent, LinkType,
};
-use turbo_tasks_hash::Xxh3Hash64Hasher;
+use turbo_tasks_hash::{HashAlgorithm, Xxh3Hash64Hasher};
use crate::version::{VersionedAssetContent, VersionedContent};
@@ -121,9 +121,9 @@ impl AssetContent {
/// Compared to [AssetContent::hash], this hashes only the bytes of the file content and nothing
/// else. If there is no file content, it returns `None`.
#[turbo_tasks::function]
- pub async fn content_hash(&self) -> Result>> {
+ pub async fn content_hash(&self, algorithm: HashAlgorithm) -> Result>> {
match self {
- AssetContent::File(content) => Ok(content.content_hash()),
+ AssetContent::File(content) => Ok(content.content_hash(algorithm)),
AssetContent::Redirect { .. } => Ok(Vc::cell(None)),
}
}
diff --git a/turbopack/crates/turbopack-core/src/chunk/chunking_context.rs b/turbopack/crates/turbopack-core/src/chunk/chunking_context.rs
index daf74414e3b5a..c6e45aa63f064 100644
--- a/turbopack/crates/turbopack-core/src/chunk/chunking_context.rs
+++ b/turbopack/crates/turbopack-core/src/chunk/chunking_context.rs
@@ -350,7 +350,7 @@ pub trait ChunkingContext {
#[turbo_tasks::function]
fn asset_path(
self: Vc,
- content_hash: Vc,
+ content_hash: Vc,
original_asset_ident: Vc,
tag: Option,
) -> Vc;
diff --git a/turbopack/crates/turbopack-core/src/version.rs b/turbopack/crates/turbopack-core/src/version.rs
index edefdc9d46e0b..4d322a5115111 100644
--- a/turbopack/crates/turbopack-core/src/version.rs
+++ b/turbopack/crates/turbopack-core/src/version.rs
@@ -7,7 +7,7 @@ use turbo_tasks::{
debug::ValueDebugFormat, trace::TraceRawVcs,
};
use turbo_tasks_fs::{FileContent, LinkType};
-use turbo_tasks_hash::encode_hex;
+use turbo_tasks_hash::HashAlgorithm;
use crate::asset::AssetContent;
@@ -233,10 +233,12 @@ impl FileHashVersion {
pub async fn compute(asset_content: &AssetContent) -> Result> {
match asset_content {
AssetContent::File(file_vc) => {
- let hash = file_vc.content_hash().await?.context("file not found")?;
- Ok(Self::cell(FileHashVersion {
- hash: encode_hex(hash).into(),
- }))
+ let hash = file_vc
+ .content_hash(HashAlgorithm::default())
+ .owned()
+ .await?
+ .context("file not found")?;
+ Ok(Self::cell(FileHashVersion { hash }))
}
AssetContent::Redirect { .. } => Err(anyhow!("not a file")),
}
diff --git a/turbopack/crates/turbopack-nodejs/src/chunking_context.rs b/turbopack/crates/turbopack-nodejs/src/chunking_context.rs
index 59c8e36fdb560..441bfadc323f4 100644
--- a/turbopack/crates/turbopack-nodejs/src/chunking_context.rs
+++ b/turbopack/crates/turbopack-nodejs/src/chunking_context.rs
@@ -3,7 +3,6 @@ use tracing::Instrument;
use turbo_rcstr::{RcStr, rcstr};
use turbo_tasks::{FxIndexMap, ResolvedVc, TryJoinIterExt, Upcast, ValueToString, Vc};
use turbo_tasks_fs::FileSystemPath;
-use turbo_tasks_hash::encode_hex;
use turbopack_core::{
asset::Asset,
chunk::{
@@ -455,13 +454,13 @@ impl ChunkingContext for NodeJsChunkingContext {
#[turbo_tasks::function]
async fn asset_path(
&self,
- content_hash: Vc,
+ content_hash: Vc,
original_asset_ident: Vc,
tag: Option,
) -> Result> {
let source_path = original_asset_ident.path().await?;
let basename = source_path.file_name();
- let content_hash = encode_hex(*content_hash.await?);
+ let content_hash = content_hash.await?;
let asset_path = match source_path.extension_ref() {
Some(ext) => format!(
"{basename}.{content_hash}.{ext}",
diff --git a/turbopack/crates/turbopack-static/src/output_asset.rs b/turbopack/crates/turbopack-static/src/output_asset.rs
index 9bddf6584bd74..c029013d5476f 100644
--- a/turbopack/crates/turbopack-static/src/output_asset.rs
+++ b/turbopack/crates/turbopack-static/src/output_asset.rs
@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use turbo_rcstr::RcStr;
use turbo_tasks::{ResolvedVc, Vc};
use turbo_tasks_fs::FileSystemPath;
+use turbo_tasks_hash::HashAlgorithm;
use turbopack_core::{
asset::{Asset, AssetContent},
chunk::ChunkingContext,
@@ -39,9 +40,13 @@ impl OutputAsset for StaticOutputAsset {
#[turbo_tasks::function]
async fn path(&self) -> Result> {
let content = self.source.content();
- let content_hash = content.content_hash().await?.context(
- "Missing content when trying to generate the content hash for StaticOutputAsset",
- )?;
+ let content_hash = content
+ .content_hash(HashAlgorithm::default())
+ .owned()
+ .await?
+ .context(
+ "Missing content when trying to generate the content hash for StaticOutputAsset",
+ )?;
Ok(self.chunking_context.asset_path(
Vc::cell(content_hash),
self.source.ident(),
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/output/turbopack_crates_turbopack-tests_tests_snapshot_css_embed-url_input_6213f966._.css b/turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/output/turbopack_crates_turbopack-tests_tests_snapshot_css_embed-url_input_6213f966._.css
index a27d8db4b3164..c294258e959df 100644
--- a/turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/output/turbopack_crates_turbopack-tests_tests_snapshot_css_embed-url_input_6213f966._.css
+++ b/turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/output/turbopack_crates_turbopack-tests_tests_snapshot_css_embed-url_input_6213f966._.css
@@ -1,15 +1,15 @@
/* [project]/turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/input/style.module.css [test] (css) */
.style-module__3oVw9q__module-style {
- cursor: url("../static/image.0ccdd6e3.png");
- background-image: url("../static/image.0ccdd6e3.png");
- list-style-image: url("../static/image.0ccdd6e3.png");
+ cursor: url("../static/image.7bbaf41b.png");
+ background-image: url("../static/image.7bbaf41b.png");
+ list-style-image: url("../static/image.7bbaf41b.png");
}
/* [project]/turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/input/style.css [test] (css) */
.style {
- cursor: url("../static/image.0ccdd6e3.png");
- background-image: url("../static/image.0ccdd6e3.png");
- list-style-image: url("../static/image.0ccdd6e3.png");
+ cursor: url("../static/image.7bbaf41b.png");
+ background-image: url("../static/image.7bbaf41b.png");
+ list-style-image: url("../static/image.7bbaf41b.png");
}
.style-unresolveable {
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/static/image.0ccdd6e3.png b/turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/static/image.7bbaf41b.png
similarity index 100%
rename from turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/static/image.0ccdd6e3.png
rename to turbopack/crates/turbopack-tests/tests/snapshot/css/embed-url/static/image.7bbaf41b.png
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/output/turbopack_crates_turbopack-tests_tests_snapshot_import-meta_url_input_3fece31c._.js b/turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/output/turbopack_crates_turbopack-tests_tests_snapshot_import-meta_url_input_3fece31c._.js
index 82ed382854299..05aacef16f9b4 100644
--- a/turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/output/turbopack_crates_turbopack-tests_tests_snapshot_import-meta_url_input_3fece31c._.js
+++ b/turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/output/turbopack_crates_turbopack-tests_tests_snapshot_import-meta_url_input_3fece31c._.js
@@ -1,7 +1,7 @@
(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push(["output/turbopack_crates_turbopack-tests_tests_snapshot_import-meta_url_input_3fece31c._.js",
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/input/asset.txt (static in ecmascript)", ((__turbopack_context__) => {
-__turbopack_context__.q("/static/asset.a5b1faf2.txt");}),
+__turbopack_context__.q("/static/asset.6fdf3006.txt");}),
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/input/mod.mjs [test] (ecmascript)", ((__turbopack_context__) => {
"use strict";
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/static/asset.a5b1faf2.txt b/turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/static/asset.6fdf3006.txt
similarity index 100%
rename from turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/static/asset.a5b1faf2.txt
rename to turbopack/crates/turbopack-tests/tests/snapshot/import-meta/url/static/asset.6fdf3006.txt
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/output/aaf3a_crates_turbopack-tests_tests_snapshot_imports_ignore-comments_input_1f8151c3._.js b/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/output/aaf3a_crates_turbopack-tests_tests_snapshot_imports_ignore-comments_input_1f8151c3._.js
index a912af832688e..ab37cdb4bee5f 100644
--- a/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/output/aaf3a_crates_turbopack-tests_tests_snapshot_imports_ignore-comments_input_1f8151c3._.js
+++ b/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/output/aaf3a_crates_turbopack-tests_tests_snapshot_imports_ignore-comments_input_1f8151c3._.js
@@ -5,7 +5,7 @@ module.exports = 'turbopack';
}),
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/input/vercel.cjs (static in ecmascript)", ((__turbopack_context__) => {
-__turbopack_context__.q("/static/vercel.242d4ff2.cjs");}),
+__turbopack_context__.q("/static/vercel.fad5a703.cjs");}),
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/input/vercel.cjs [test] (ecmascript, worker loader)", ((__turbopack_context__) => {
__turbopack_context__.v(function(Ctor, opts) {
@@ -14,7 +14,7 @@ __turbopack_context__.v(function(Ctor, opts) {
}),
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/input/ignore-worker.cjs (static in ecmascript)", ((__turbopack_context__) => {
-__turbopack_context__.q("/static/ignore-worker.481250f3.cjs");}),
+__turbopack_context__.q("/static/ignore-worker.4e0cf842.cjs");}),
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/input/index.js [test] (ecmascript)", ((__turbopack_context__) => {
"use strict";
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/ignore-worker.481250f3.cjs b/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/ignore-worker.4e0cf842.cjs
similarity index 100%
rename from turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/ignore-worker.481250f3.cjs
rename to turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/ignore-worker.4e0cf842.cjs
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/vercel.242d4ff2.cjs b/turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/vercel.fad5a703.cjs
similarity index 100%
rename from turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/vercel.242d4ff2.cjs
rename to turbopack/crates/turbopack-tests/tests/snapshot/imports/ignore-comments/static/vercel.fad5a703.cjs
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/imports/static/output/turbopack_crates_turbopack-tests_tests_snapshot_imports_static_input_fb142aa8._.js b/turbopack/crates/turbopack-tests/tests/snapshot/imports/static/output/turbopack_crates_turbopack-tests_tests_snapshot_imports_static_input_fb142aa8._.js
index 5f0c283df6fb5..a63a390b90daf 100644
--- a/turbopack/crates/turbopack-tests/tests/snapshot/imports/static/output/turbopack_crates_turbopack-tests_tests_snapshot_imports_static_input_fb142aa8._.js
+++ b/turbopack/crates/turbopack-tests/tests/snapshot/imports/static/output/turbopack_crates_turbopack-tests_tests_snapshot_imports_static_input_fb142aa8._.js
@@ -1,7 +1,7 @@
(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push(["output/turbopack_crates_turbopack-tests_tests_snapshot_imports_static_input_fb142aa8._.js",
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/imports/static/input/vercel.svg (static in ecmascript)", ((__turbopack_context__) => {
-__turbopack_context__.q("/static/vercel.ede1923f.svg");}),
+__turbopack_context__.q("/static/vercel.77adf0a9.svg");}),
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/imports/static/input/index.js [test] (ecmascript)", ((__turbopack_context__) => {
"use strict";
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/imports/static/static/vercel.ede1923f.svg b/turbopack/crates/turbopack-tests/tests/snapshot/imports/static/static/vercel.77adf0a9.svg
similarity index 100%
rename from turbopack/crates/turbopack-tests/tests/snapshot/imports/static/static/vercel.ede1923f.svg
rename to turbopack/crates/turbopack-tests/tests/snapshot/imports/static/static/vercel.77adf0a9.svg
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/output/turbopack_crates_turbopack-tests_tests_snapshot_workers_basic_input_73fc86c5._.js b/turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/output/turbopack_crates_turbopack-tests_tests_snapshot_workers_basic_input_73fc86c5._.js
index a61d14649055c..ba0947bf4dcdd 100644
--- a/turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/output/turbopack_crates_turbopack-tests_tests_snapshot_workers_basic_input_73fc86c5._.js
+++ b/turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/output/turbopack_crates_turbopack-tests_tests_snapshot_workers_basic_input_73fc86c5._.js
@@ -1,7 +1,7 @@
(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push(["output/turbopack_crates_turbopack-tests_tests_snapshot_workers_basic_input_73fc86c5._.js",
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/input/worker.js (static in ecmascript)", ((__turbopack_context__) => {
-__turbopack_context__.q("/static/worker.60655f93.js");}),
+__turbopack_context__.q("/static/worker.adfa2c73.js");}),
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/input/worker.js [test] (ecmascript, worker loader)", ((__turbopack_context__) => {
__turbopack_context__.v(function(Ctor, opts) {
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/static/worker.60655f93.js b/turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/static/worker.adfa2c73.js
similarity index 100%
rename from turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/static/worker.60655f93.js
rename to turbopack/crates/turbopack-tests/tests/snapshot/workers/basic/static/worker.adfa2c73.js
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/output/turbopack_crates_turbopack-tests_tests_snapshot_workers_shared_input_3845375a._.js b/turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/output/turbopack_crates_turbopack-tests_tests_snapshot_workers_shared_input_3845375a._.js
index 0fa051e815b6e..3d64aa4355ec8 100644
--- a/turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/output/turbopack_crates_turbopack-tests_tests_snapshot_workers_shared_input_3845375a._.js
+++ b/turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/output/turbopack_crates_turbopack-tests_tests_snapshot_workers_shared_input_3845375a._.js
@@ -1,7 +1,7 @@
(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push(["output/turbopack_crates_turbopack-tests_tests_snapshot_workers_shared_input_3845375a._.js",
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/input/worker.js (static in ecmascript)", ((__turbopack_context__) => {
-__turbopack_context__.q("/static/worker.35b5336b.js");}),
+__turbopack_context__.q("/static/worker.b01ba0d1.js");}),
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/input/worker.js [test] (ecmascript, worker loader)", ((__turbopack_context__) => {
__turbopack_context__.v(function(Ctor, opts) {
diff --git a/turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/static/worker.35b5336b.js b/turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/static/worker.b01ba0d1.js
similarity index 100%
rename from turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/static/worker.35b5336b.js
rename to turbopack/crates/turbopack-tests/tests/snapshot/workers/shared/static/worker.b01ba0d1.js
diff --git a/turbopack/crates/turbopack-wasm/src/lib.rs b/turbopack/crates/turbopack-wasm/src/lib.rs
index f33984f625d38..acb77bc247438 100644
--- a/turbopack/crates/turbopack-wasm/src/lib.rs
+++ b/turbopack/crates/turbopack-wasm/src/lib.rs
@@ -12,6 +12,7 @@
use anyhow::{Context, Result};
use turbo_rcstr::RcStr;
use turbo_tasks::Vc;
+use turbo_tasks_hash::HashAlgorithm;
use turbopack_core::asset::Asset;
pub(crate) mod analysis;
@@ -25,8 +26,10 @@ pub mod source;
pub async fn wasm_edge_var_name(asset: Vc>) -> Result> {
let hash = asset
.content()
- .content_hash()
- .await?
+ .content_hash(HashAlgorithm::default())
+ .await?;
+ let hash = hash
+ .as_ref()
.context("Missing content when trying to generate the content hash for a WASM asset")?;
- Ok(Vc::cell(format!("wasm_{:08x}", hash).into()))
+ Ok(Vc::cell(format!("wasm_{}", hash).into()))
}
From f33b8af469fdc572daa34f8074c2c398be4f7161 Mon Sep 17 00:00:00 2001
From: Zack Tanner <1939140+ztanner@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:04:52 -0800
Subject: [PATCH 07/11] Revert "Handle null history.state in client-side router
popstate handler" (#90268)
Reverts vercel/next.js#90083
This causes an MPA navigation when updating the hash fragment via
`window.location.hash = "foo"`
---
.../next/src/client/components/app-router.tsx | 6 +-
.../popstate-null-state/app/layout.tsx | 8 --
.../popstate-null-state/app/other/page.tsx | 8 --
.../app-dir/popstate-null-state/app/page.tsx | 22 -----
.../popstate-null-state/next.config.js | 6 --
.../popstate-null-state.test.ts | 84 -------------------
6 files changed, 1 insertion(+), 133 deletions(-)
delete mode 100644 test/e2e/app-dir/popstate-null-state/app/layout.tsx
delete mode 100644 test/e2e/app-dir/popstate-null-state/app/other/page.tsx
delete mode 100644 test/e2e/app-dir/popstate-null-state/app/page.tsx
delete mode 100644 test/e2e/app-dir/popstate-null-state/next.config.js
delete mode 100644 test/e2e/app-dir/popstate-null-state/popstate-null-state.test.ts
diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx
index a7b1c48442079..693306d2b453a 100644
--- a/packages/next/src/client/components/app-router.tsx
+++ b/packages/next/src/client/components/app-router.tsx
@@ -375,11 +375,7 @@ function Router({
*/
const onPopState = (event: PopStateEvent) => {
if (!event.state) {
- // This case happens when pushState/replaceState was called outside
- // of Next.js with a null state, or the initial history entry has no
- // state. The router tree would be completely out of sync so we need
- // to do a hard navigation to get back to a consistent state.
- window.location.reload()
+ // TODO-APP: this case only happens when pushState/replaceState was called outside of Next.js. It should probably reload the page in this case.
return
}
diff --git a/test/e2e/app-dir/popstate-null-state/app/layout.tsx b/test/e2e/app-dir/popstate-null-state/app/layout.tsx
deleted file mode 100644
index 888614deda3ba..0000000000000
--- a/test/e2e/app-dir/popstate-null-state/app/layout.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { ReactNode } from 'react'
-export default function Root({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- )
-}
diff --git a/test/e2e/app-dir/popstate-null-state/app/other/page.tsx b/test/e2e/app-dir/popstate-null-state/app/other/page.tsx
deleted file mode 100644
index 5195e86bf3026..0000000000000
--- a/test/e2e/app-dir/popstate-null-state/app/other/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-'use client'
-
-import { usePathname } from 'next/navigation'
-
-export default function Page() {
- const pathname = usePathname()
- return {pathname}
-}
diff --git a/test/e2e/app-dir/popstate-null-state/app/page.tsx b/test/e2e/app-dir/popstate-null-state/app/page.tsx
deleted file mode 100644
index 0b01e3f69dc78..0000000000000
--- a/test/e2e/app-dir/popstate-null-state/app/page.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-'use client'
-
-import Link from 'next/link'
-import { usePathname } from 'next/navigation'
-
-export default function Page() {
- const pathname = usePathname()
- return (
- <>
- {pathname}
-
- Go to other
-
-
- Hash link
-
-
- Hash target
-
- >
- )
-}
diff --git a/test/e2e/app-dir/popstate-null-state/next.config.js b/test/e2e/app-dir/popstate-null-state/next.config.js
deleted file mode 100644
index 807126e4cf0bf..0000000000000
--- a/test/e2e/app-dir/popstate-null-state/next.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-/**
- * @type {import('next').NextConfig}
- */
-const nextConfig = {}
-
-module.exports = nextConfig
diff --git a/test/e2e/app-dir/popstate-null-state/popstate-null-state.test.ts b/test/e2e/app-dir/popstate-null-state/popstate-null-state.test.ts
deleted file mode 100644
index 73e5447ee7b31..0000000000000
--- a/test/e2e/app-dir/popstate-null-state/popstate-null-state.test.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { nextTestSetup } from 'e2e-utils'
-import { retry } from 'next-test-utils'
-
-describe('popstate-null-state', () => {
- const { next } = nextTestSetup({
- files: __dirname,
- })
-
- it('should hard navigate when history.state is null on popstate', async () => {
- const browser = await next.browser('/')
-
- // Verify the initial page is rendered
- await retry(async () => {
- expect(await browser.elementByCss('#pathname').text()).toBe('/')
- })
-
- // Navigate to /other via client-side navigation so the router tree updates
- await browser.elementByCss('#to-other').click()
- await retry(async () => {
- expect(await browser.elementByCss('#pathname').text()).toBe('/other')
- })
-
- // Push a history entry with null state for '/', bypassing the Next.js
- // patched pushState (which copies internal state). The patch is on the
- // instance, so calling via the prototype bypasses it.
- // History stack: [('/', nextjs), ('/other', nextjs), ('/', null), ('/', null)]
- await browser.eval(
- `History.prototype.pushState.call(window.history, null, '', '/')`
- )
-
- await browser.eval(
- `History.prototype.pushState.call(window.history, null, '', '/')`
- )
-
- // Validate the push worked: URL changed and history.state is null
- expect(await browser.eval('window.location.pathname')).toBe('/')
- expect(await browser.eval('window.history.state')).toBe(null)
-
- // Now go back — this triggers popstate with event.state === null
- // (the state from the ('/', null) entry).
- // Without the fix the router would do nothing, leaving pathname as '/other'.
- // With the fix a hard navigation (reload) occurs, loading '/' fresh.
- await browser.back()
-
- await retry(async () => {
- expect(await browser.elementByCss('#pathname').text()).toBe('/')
- })
- })
-
- it('should handle back navigation to a hash change without full reload', async () => {
- const browser = await next.browser('/')
-
- // Verify the initial page is rendered
- await retry(async () => {
- expect(await browser.elementByCss('#pathname').text()).toBe('/')
- })
-
- // Click a regular anchor link. The browser pushes a new history entry
- // with null state for the hash URL.
- // History stack: [('/', nextjs), ('/#hash', null)]
- await browser.elementByCss('#hash-link').click()
-
- await retry(async () => {
- expect(await browser.url()).toContain('#hash')
- })
-
- // Navigate to /other via Link so there's a forward entry.
- // History stack: [('/', nextjs), ('/#hash', null), ('/other', nextjs)]
- await browser.elementByCss('#to-other').click()
- await retry(async () => {
- expect(await browser.elementByCss('#pathname').text()).toBe('/other')
- })
-
- // Go back — popstate fires with the hash entry's state (null).
- // Since only the hash changed, the page should remain functional
- // without losing client-side state.
- await browser.back()
-
- await retry(async () => {
- expect(await browser.url()).toContain('#hash')
- expect(await browser.elementByCss('#pathname').text()).toBe('/')
- })
- })
-})
From 19dcd1339f946e6979acd415ec380c2fcfb3ccf2 Mon Sep 17 00:00:00 2001
From: "Sebastian \"Sebbie\" Silbermann"
Date: Fri, 20 Feb 2026 14:19:54 -0800
Subject: [PATCH 08/11] [instant] Handle more instant declaration patterns
(#90251)
Adds handling for
```tsx
export { unstable_instant } from './config'
^
```
```tsx
const unstable_instant = ...
^
export { unstable_instant }
```
```tsx
const instant = ...
^
export { instant as unstable_instant }
```
We're not following assignments recursively and stop at the first declaration:
```tsx
const _instant = ...
const instant = _instant
^
export { instant as unstable_instant }
```
---
.../src/transforms/debug_instant_stack.rs | 88 +++++-
.../with-aliased-export/input.js | 6 +
.../with-aliased-export/output.js | 12 +
.../with-aliased-export/output.map | 1 +
.../with-indirect-export/input.js | 7 +
.../with-indirect-export/output.js | 13 +
.../with-indirect-export/output.map | 1 +
.../with-named-export/input.js | 6 +
.../with-named-export/output.js | 12 +
.../with-named-export/output.map | 1 +
.../with-reexport/input.js | 5 +
.../with-reexport/output.js | 9 +
.../with-reexport/output.map | 1 +
.../app/aliased-export/page.tsx | 9 +
.../app/indirect-export/page.tsx | 10 +
.../instant-validation-causes/app/layout.tsx | 34 +++
.../app/named-export/page.tsx | 9 +
.../app/reexport/config.ts | 1 +
.../app/reexport/page.tsx | 8 +
.../instant-validation-causes.test.ts | 273 ++++++++++++++++++
.../instant-validation-causes/next.config.ts | 11 +
21 files changed, 509 insertions(+), 8 deletions(-)
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/input.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/output.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/output.map
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/input.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/output.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/output.map
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/input.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/output.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/output.map
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/input.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/output.js
create mode 100644 crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/output.map
create mode 100644 test/e2e/app-dir/instant-validation-causes/app/aliased-export/page.tsx
create mode 100644 test/e2e/app-dir/instant-validation-causes/app/indirect-export/page.tsx
create mode 100644 test/e2e/app-dir/instant-validation-causes/app/layout.tsx
create mode 100644 test/e2e/app-dir/instant-validation-causes/app/named-export/page.tsx
create mode 100644 test/e2e/app-dir/instant-validation-causes/app/reexport/config.ts
create mode 100644 test/e2e/app-dir/instant-validation-causes/app/reexport/page.tsx
create mode 100644 test/e2e/app-dir/instant-validation-causes/instant-validation-causes.test.ts
create mode 100644 test/e2e/app-dir/instant-validation-causes/next.config.ts
diff --git a/crates/next-custom-transforms/src/transforms/debug_instant_stack.rs b/crates/next-custom-transforms/src/transforms/debug_instant_stack.rs
index 4682bd3df196f..6ef0562829dff 100644
--- a/crates/next-custom-transforms/src/transforms/debug_instant_stack.rs
+++ b/crates/next-custom-transforms/src/transforms/debug_instant_stack.rs
@@ -17,22 +17,94 @@ struct DebugInstantStack {
instant_export_span: Option,
}
+/// Given an export specifier, returns `Some((exported_name, local_name))` if
+/// the exported name is `unstable_instant`.
+fn get_instant_specifier_names(specifier: &ExportSpecifier) -> Option<(&Ident, &Ident)> {
+ match specifier {
+ // `export { orig as unstable_instant }`
+ ExportSpecifier::Named(ExportNamedSpecifier {
+ exported: Some(ModuleExportName::Ident(exported)),
+ orig: ModuleExportName::Ident(orig),
+ ..
+ }) if exported.sym == "unstable_instant" => Some((exported, orig)),
+ // `export { unstable_instant }`
+ ExportSpecifier::Named(ExportNamedSpecifier {
+ exported: None,
+ orig: ModuleExportName::Ident(orig),
+ ..
+ }) if orig.sym == "unstable_instant" => Some((orig, orig)),
+ _ => None,
+ }
+}
+
+/// Find the initializer span of a variable declaration with the given name.
+fn find_var_init_span(items: &[ModuleItem], local_name: &str) -> Option {
+ for item in items {
+ let decl = match item {
+ ModuleItem::Stmt(Stmt::Decl(Decl::Var(var_decl))) => var_decl,
+ ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) => {
+ if let Decl::Var(var_decl) = &export_decl.decl {
+ var_decl
+ } else {
+ continue;
+ }
+ }
+ _ => continue,
+ };
+ for d in &decl.decls {
+ if let Pat::Ident(ident) = &d.name {
+ if ident.id.sym == local_name {
+ if let Some(init) = &d.init {
+ return Some(init.span());
+ }
+ }
+ }
+ }
+ }
+ None
+}
+
impl Fold for DebugInstantStack {
fn fold_module_items(&mut self, items: Vec) -> Vec {
- // Scan for `export const unstable_instant = ...`
for item in &items {
- if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) = item {
- if let Decl::Var(var_decl) = &export_decl.decl {
- for decl in &var_decl.decls {
- if let Pat::Ident(ident) = &decl.name {
- if ident.id.sym == "unstable_instant" {
- if let Some(init) = &decl.init {
- self.instant_export_span = Some(init.span());
+ match item {
+ // `export const unstable_instant = ...`
+ ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) => {
+ if let Decl::Var(var_decl) = &export_decl.decl {
+ for decl in &var_decl.decls {
+ if let Pat::Ident(ident) = &decl.name {
+ if ident.id.sym == "unstable_instant" {
+ if let Some(init) = &decl.init {
+ self.instant_export_span = Some(init.span());
+ }
+ }
+ }
+ }
+ }
+ }
+ // `export { unstable_instant }` or `export { x as unstable_instant }`
+ // with or without `from '...'`
+ ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(named)) => {
+ for specifier in &named.specifiers {
+ if let Some((_exported, orig)) = get_instant_specifier_names(specifier) {
+ if named.src.is_some() {
+ // Re-export: `export { unstable_instant } from './config'`
+ // Point at the export specifier itself
+ self.instant_export_span = Some(specifier.span());
+ } else {
+ // Local named export: try to find the variable's initializer
+ let local_name = &orig.sym;
+ if let Some(init_span) = find_var_init_span(&items, local_name) {
+ self.instant_export_span = Some(init_span);
+ } else {
+ // Fallback to the export specifier span
+ self.instant_export_span = Some(specifier.span());
}
}
}
}
}
+ _ => {}
}
}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/input.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/input.js
new file mode 100644
index 0000000000000..5cebf8ac19b1c
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/input.js
@@ -0,0 +1,6 @@
+const instant = { prefetch: 'static' }
+export { instant as unstable_instant }
+
+export default function Page() {
+ return Hello
+}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/output.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/output.js
new file mode 100644
index 0000000000000..24cc0e982a610
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/output.js
@@ -0,0 +1,12 @@
+const instant = {
+ prefetch: 'static'
+};
+export { instant as unstable_instant };
+export default function Page() {
+ return Hello
;
+}
+export const __debugCreateInstantConfigStack = process.env.NODE_ENV !== 'production' ? function unstable_instant() {
+ const error = new Error(' ');
+ error.name = 'Instant Validation';
+ return error;
+} : null;
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/output.map b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/output.map
new file mode 100644
index 0000000000000..7e1157c404c5d
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-aliased-export/output.map
@@ -0,0 +1 @@
+{"version":3,"sources":["input.js"],"sourcesContent":["const instant = { prefetch: 'static' }\nexport { instant as unstable_instant }\n\nexport default function Page() {\n return Hello
\n}\n"],"names":[],"mappings":"AAAA,MAAM,UAAU;IAAE,UAAU;AAAS;AACrC,SAAS,WAAW,gBAAgB,GAAE;AAEtC,eAAe,SAAS;IACtB,QAAQ,IAAI,KAAK,EAAE;AACrB;uFALgB;kBAAA"}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/input.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/input.js
new file mode 100644
index 0000000000000..0949c523df47b
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/input.js
@@ -0,0 +1,7 @@
+const _instant = { prefetch: 'static' }
+const instant = _instant
+export { instant as unstable_instant }
+
+export default function Page() {
+ return Hello
+}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/output.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/output.js
new file mode 100644
index 0000000000000..2d9525836c2a4
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/output.js
@@ -0,0 +1,13 @@
+const _instant = {
+ prefetch: 'static'
+};
+const instant = _instant;
+export { instant as unstable_instant };
+export default function Page() {
+ return Hello
;
+}
+export const __debugCreateInstantConfigStack = process.env.NODE_ENV !== 'production' ? function unstable_instant() {
+ const error = new Error(' ');
+ error.name = 'Instant Validation';
+ return error;
+} : null;
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/output.map b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/output.map
new file mode 100644
index 0000000000000..f5a74cde8f674
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-indirect-export/output.map
@@ -0,0 +1 @@
+{"version":3,"sources":["input.js"],"sourcesContent":["const _instant = { prefetch: 'static' }\nconst instant = _instant\nexport { instant as unstable_instant }\n\nexport default function Page() {\n return Hello
\n}\n"],"names":[],"mappings":"AAAA,MAAM,WAAW;IAAE,UAAU;AAAS;AACtC,MAAM,UAAU;AAChB,SAAS,WAAW,gBAAgB,GAAE;AAEtC,eAAe,SAAS;IACtB,QAAQ,IAAI,KAAK,EAAE;AACrB;uFALgB;kBAAA"}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/input.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/input.js
new file mode 100644
index 0000000000000..1e58dc4a565b4
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/input.js
@@ -0,0 +1,6 @@
+const unstable_instant = { prefetch: 'static' }
+export { unstable_instant }
+
+export default function Page() {
+ return Hello
+}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/output.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/output.js
new file mode 100644
index 0000000000000..6db7b829cda31
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/output.js
@@ -0,0 +1,12 @@
+const unstable_instant = {
+ prefetch: 'static'
+};
+export { unstable_instant };
+export default function Page() {
+ return Hello
;
+}
+export const __debugCreateInstantConfigStack = process.env.NODE_ENV !== 'production' ? function unstable_instant() {
+ const error = new Error(' ');
+ error.name = 'Instant Validation';
+ return error;
+} : null;
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/output.map b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/output.map
new file mode 100644
index 0000000000000..602266de00156
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-named-export/output.map
@@ -0,0 +1 @@
+{"version":3,"sources":["input.js"],"sourcesContent":["const unstable_instant = { prefetch: 'static' }\nexport { unstable_instant }\n\nexport default function Page() {\n return Hello
\n}\n"],"names":[],"mappings":"AAAA,MAAM,mBAAmB;IAAE,UAAU;AAAS;AAC9C,SAAS,gBAAgB,GAAE;AAE3B,eAAe,SAAS;IACtB,QAAQ,IAAI,KAAK,EAAE;AACrB;uFALyB;kBAAA"}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/input.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/input.js
new file mode 100644
index 0000000000000..53990f142991a
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/input.js
@@ -0,0 +1,5 @@
+export { unstable_instant } from './config'
+
+export default function Page() {
+ return Hello
+}
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/output.js b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/output.js
new file mode 100644
index 0000000000000..e438085a9f86c
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/output.js
@@ -0,0 +1,9 @@
+export { unstable_instant } from './config';
+export default function Page() {
+ return Hello
;
+}
+export const __debugCreateInstantConfigStack = process.env.NODE_ENV !== 'production' ? function unstable_instant() {
+ const error = new Error(' ');
+ error.name = 'Instant Validation';
+ return error;
+} : null;
diff --git a/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/output.map b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/output.map
new file mode 100644
index 0000000000000..d5fb4cfe0d77c
--- /dev/null
+++ b/crates/next-custom-transforms/tests/fixture/debug-instant-stack/with-reexport/output.map
@@ -0,0 +1 @@
+{"version":3,"sources":["input.js"],"sourcesContent":["export { unstable_instant } from './config'\n\nexport default function Page() {\n return Hello
\n}\n"],"names":[],"mappings":"AAAA,SAAS,gBAAgB,QAAQ,WAAU;AAE3C,eAAe,SAAS;IACtB,QAAQ,IAAI,KAAK,EAAE;AACrB;uFAJS;kBAAA"}
diff --git a/test/e2e/app-dir/instant-validation-causes/app/aliased-export/page.tsx b/test/e2e/app-dir/instant-validation-causes/app/aliased-export/page.tsx
new file mode 100644
index 0000000000000..73578fbbd8ed7
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation-causes/app/aliased-export/page.tsx
@@ -0,0 +1,9 @@
+import { cookies } from 'next/headers'
+
+const instant = { prefetch: 'static' }
+export { instant as unstable_instant }
+
+export default async function Page() {
+ await cookies()
+ return aliased export
+}
diff --git a/test/e2e/app-dir/instant-validation-causes/app/indirect-export/page.tsx b/test/e2e/app-dir/instant-validation-causes/app/indirect-export/page.tsx
new file mode 100644
index 0000000000000..162aa26f3fdea
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation-causes/app/indirect-export/page.tsx
@@ -0,0 +1,10 @@
+import { cookies } from 'next/headers'
+
+const _instant = { prefetch: 'static' }
+const instant = _instant
+export { instant as unstable_instant }
+
+export default async function Page() {
+ await cookies()
+ return indirect export
+}
diff --git a/test/e2e/app-dir/instant-validation-causes/app/layout.tsx b/test/e2e/app-dir/instant-validation-causes/app/layout.tsx
new file mode 100644
index 0000000000000..6265e0a484482
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation-causes/app/layout.tsx
@@ -0,0 +1,34 @@
+import { connection } from 'next/server'
+import { ReactNode, Suspense } from 'react'
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+ Root suspense boundary...}>
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function Header() {
+ return (
+
+ )
+}
+
+async function Now() {
+ await connection()
+ return Date.now()
+}
diff --git a/test/e2e/app-dir/instant-validation-causes/app/named-export/page.tsx b/test/e2e/app-dir/instant-validation-causes/app/named-export/page.tsx
new file mode 100644
index 0000000000000..436327084f25a
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation-causes/app/named-export/page.tsx
@@ -0,0 +1,9 @@
+import { cookies } from 'next/headers'
+
+const unstable_instant = { prefetch: 'static' }
+export { unstable_instant }
+
+export default async function Page() {
+ await cookies()
+ return named export
+}
diff --git a/test/e2e/app-dir/instant-validation-causes/app/reexport/config.ts b/test/e2e/app-dir/instant-validation-causes/app/reexport/config.ts
new file mode 100644
index 0000000000000..67a7f9979f2ea
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation-causes/app/reexport/config.ts
@@ -0,0 +1 @@
+export const unstable_instant = { prefetch: 'static' }
diff --git a/test/e2e/app-dir/instant-validation-causes/app/reexport/page.tsx b/test/e2e/app-dir/instant-validation-causes/app/reexport/page.tsx
new file mode 100644
index 0000000000000..7dddcd586426f
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation-causes/app/reexport/page.tsx
@@ -0,0 +1,8 @@
+import { cookies } from 'next/headers'
+
+export { unstable_instant } from './config'
+
+export default async function Page() {
+ await cookies()
+ return reexport
+}
diff --git a/test/e2e/app-dir/instant-validation-causes/instant-validation-causes.test.ts b/test/e2e/app-dir/instant-validation-causes/instant-validation-causes.test.ts
new file mode 100644
index 0000000000000..204bee76ef8c4
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation-causes/instant-validation-causes.test.ts
@@ -0,0 +1,273 @@
+import { nextTestSetup } from 'e2e-utils'
+import { retry } from '../../../lib/next-test-utils'
+
+describe('instant validation causes', () => {
+ const { next, skipped, isNextDev } = nextTestSetup({
+ files: __dirname,
+ skipDeployment: true,
+ env: {
+ NEXT_TEST_LOG_VALIDATION: '1',
+ },
+ })
+ if (skipped) return
+ if (!isNextDev) {
+ it.skip('Only implemented in dev', () => {})
+ return
+ }
+
+ let currentCliOutputIndex = 0
+ beforeEach(() => {
+ currentCliOutputIndex = next.cliOutput.length
+ })
+
+ function getCliOutputSinceMark(): string {
+ if (next.cliOutput.length < currentCliOutputIndex) {
+ currentCliOutputIndex = 0
+ }
+ return next.cliOutput.slice(currentCliOutputIndex)
+ }
+
+ type ValidationEvent =
+ | { type: 'validation_start'; requestId: string; url: string }
+ | { type: 'validation_end'; requestId: string; url: string }
+
+ function parseValidationMessages(output: string): ValidationEvent[] {
+ const messageRe = /(.*?)<\/VALIDATION_MESSAGE>/g
+ const events: ValidationEvent[] = []
+ let match: RegExpExecArray | null
+ while ((match = messageRe.exec(output)) !== null) {
+ try {
+ events.push(JSON.parse(match[1]))
+ } catch (err) {
+ throw new Error(`Failed to parse message '${match[1]}'`, {
+ cause: err,
+ })
+ }
+ }
+ return events
+ }
+
+ function normalizeValidationUrl(url: string): string {
+ const parsed = new URL(url, 'http://n')
+ parsed.searchParams.delete('_rsc')
+ return parsed.pathname + parsed.search + parsed.hash
+ }
+
+ async function waitForValidation(targetUrl: string) {
+ const parsedTargetUrl = new URL(targetUrl)
+ const relativeTargetUrl =
+ parsedTargetUrl.pathname + parsedTargetUrl.search + parsedTargetUrl.hash
+
+ const requestId = await retry(
+ async () => {
+ const events = parseValidationMessages(getCliOutputSinceMark())
+ const start = events.find(
+ (e) =>
+ e.type === 'validation_start' &&
+ normalizeValidationUrl(e.url) === relativeTargetUrl
+ )
+ expect(start).toBeDefined()
+ return start!.requestId
+ },
+ undefined,
+ undefined,
+ `wait for validation of '${relativeTargetUrl}' to start`
+ )
+
+ await retry(
+ async () => {
+ const events = parseValidationMessages(getCliOutputSinceMark())
+ const end = events.find(
+ (e) => e.type === 'validation_end' && e.requestId === requestId
+ )
+ expect(end).toBeDefined()
+ },
+ undefined,
+ undefined,
+ 'wait for validation to end'
+ )
+ }
+
+ it('named export - export { unstable_instant }', async () => {
+ const browser = await next.browser('/named-export')
+ await waitForValidation(await browser.url())
+ await expect(browser).toDisplayCollapsedRedbox(`
+ {
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/named-export/page.tsx (3:26) @ unstable_instant
+ > 3 | const unstable_instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/named-export/page.tsx (3:26)",
+ "Set.forEach ",
+ ],
+ },
+ ],
+ "description": "Runtime data was accessed outside of
+
+ This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
+
+ To fix this:
+
+ Provide a fallback UI using around this component.
+
+ or
+
+ Move the Runtime data access into a deeper component wrapped in .
+
+ In either case this allows Next.js to stream its contents to the user when they request the page, while still providing an initial UI that is prerendered and prefetchable for instant navigations.
+
+ Learn more: https://nextjs.org/docs/messages/blocking-route",
+ "environmentLabel": "Server",
+ "label": "Blocking Route",
+ "source": "app/named-export/page.tsx (7:16) @ Page
+ > 7 | await cookies()
+ | ^",
+ "stack": [
+ "Page app/named-export/page.tsx (7:16)",
+ ],
+ }
+ `)
+ })
+
+ it('aliased export - export { instant as unstable_instant }', async () => {
+ const browser = await next.browser('/aliased-export')
+ await waitForValidation(await browser.url())
+ await expect(browser).toDisplayCollapsedRedbox(`
+ {
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/aliased-export/page.tsx (3:17) @ unstable_instant
+ > 3 | const instant = { prefetch: 'static' }
+ | ^",
+ "stack": [
+ "unstable_instant app/aliased-export/page.tsx (3:17)",
+ "Set.forEach ",
+ ],
+ },
+ ],
+ "description": "Runtime data was accessed outside of
+
+ This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
+
+ To fix this:
+
+ Provide a fallback UI using around this component.
+
+ or
+
+ Move the Runtime data access into a deeper component wrapped in .
+
+ In either case this allows Next.js to stream its contents to the user when they request the page, while still providing an initial UI that is prerendered and prefetchable for instant navigations.
+
+ Learn more: https://nextjs.org/docs/messages/blocking-route",
+ "environmentLabel": "Server",
+ "label": "Blocking Route",
+ "source": "app/aliased-export/page.tsx (7:16) @ Page
+ > 7 | await cookies()
+ | ^",
+ "stack": [
+ "Page app/aliased-export/page.tsx (7:16)",
+ ],
+ }
+ `)
+ })
+
+ it('re-export - export { unstable_instant } from "./config"', async () => {
+ const browser = await next.browser('/reexport')
+ await waitForValidation(await browser.url())
+ await expect(browser).toDisplayCollapsedRedbox(`
+ {
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/reexport/page.tsx (3:10) @ unstable_instant
+ > 3 | export { unstable_instant } from './config'
+ | ^",
+ "stack": [
+ "unstable_instant app/reexport/page.tsx (3:10)",
+ "Set.forEach ",
+ ],
+ },
+ ],
+ "description": "Runtime data was accessed outside of
+
+ This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
+
+ To fix this:
+
+ Provide a fallback UI using around this component.
+
+ or
+
+ Move the Runtime data access into a deeper component wrapped in .
+
+ In either case this allows Next.js to stream its contents to the user when they request the page, while still providing an initial UI that is prerendered and prefetchable for instant navigations.
+
+ Learn more: https://nextjs.org/docs/messages/blocking-route",
+ "environmentLabel": "Server",
+ "label": "Blocking Route",
+ "source": "app/reexport/page.tsx (6:16) @ Page
+ > 6 | await cookies()
+ | ^",
+ "stack": [
+ "Page app/reexport/page.tsx (6:16)",
+ ],
+ }
+ `)
+ })
+
+ it('indirect export - const instant = _instant; export { instant as unstable_instant }', async () => {
+ const browser = await next.browser('/indirect-export')
+ await waitForValidation(await browser.url())
+ // Ideally we'd be pointing at the original value declaration.
+ // We're not following declarations recursively mostly to keep the implementation simpler
+ // presuming that almost all configs are just `export const instant = ...`
+ await expect(browser).toDisplayCollapsedRedbox(`
+ {
+ "cause": [
+ {
+ "label": "Caused by: Instant Validation",
+ "message": " ",
+ "source": "app/indirect-export/page.tsx (4:17) @ unstable_instant
+ > 4 | const instant = _instant
+ | ^",
+ "stack": [
+ "unstable_instant app/indirect-export/page.tsx (4:17)",
+ "Set.forEach ",
+ ],
+ },
+ ],
+ "description": "Runtime data was accessed outside of
+
+ This delays the entire page from rendering, resulting in a slow user experience. Next.js uses this error to ensure your app loads instantly on every navigation. cookies(), headers(), and searchParams, are examples of Runtime data that can only come from a user request.
+
+ To fix this:
+
+ Provide a fallback UI using around this component.
+
+ or
+
+ Move the Runtime data access into a deeper component wrapped in .
+
+ In either case this allows Next.js to stream its contents to the user when they request the page, while still providing an initial UI that is prerendered and prefetchable for instant navigations.
+
+ Learn more: https://nextjs.org/docs/messages/blocking-route",
+ "environmentLabel": "Server",
+ "label": "Blocking Route",
+ "source": "app/indirect-export/page.tsx (8:16) @ Page
+ > 8 | await cookies()
+ | ^",
+ "stack": [
+ "Page app/indirect-export/page.tsx (8:16)",
+ ],
+ }
+ `)
+ })
+})
diff --git a/test/e2e/app-dir/instant-validation-causes/next.config.ts b/test/e2e/app-dir/instant-validation-causes/next.config.ts
new file mode 100644
index 0000000000000..0252f959c0b09
--- /dev/null
+++ b/test/e2e/app-dir/instant-validation-causes/next.config.ts
@@ -0,0 +1,11 @@
+import type { NextConfig } from 'next'
+
+const nextConfig: NextConfig = {
+ cacheComponents: true,
+ productionBrowserSourceMaps: true,
+ typescript: {
+ ignoreBuildErrors: true,
+ },
+}
+
+export default nextConfig
From aa6d7c2271092417de894c2d492ea01253b8117f Mon Sep 17 00:00:00 2001
From: "Sebastian \"Sebbie\" Silbermann"
Date: Fri, 20 Feb 2026 15:21:21 -0800
Subject: [PATCH 09/11] [devtools] Omit empty looking error messages (#90256)
If the trimmed error message has no length, we skip the line entirely to remove useless vertical space from the Redbox. Errors with no messages are assumed to only be relevant due to their stack.
---
.../errors/error-message/error-message.tsx | 4 +++
.../dev-overlay/container/errors.tsx | 5 ++++
.../container/runtime-error/error-cause.tsx | 5 +++-
.../instant-validation-causes.test.ts | 4 ---
.../instant-validation.test.ts | 17 ------------
test/lib/add-redbox-matchers.ts | 26 ++++++++++++-------
6 files changed, 30 insertions(+), 31 deletions(-)
diff --git a/packages/next/src/next-devtools/dev-overlay/components/errors/error-message/error-message.tsx b/packages/next/src/next-devtools/dev-overlay/components/errors/error-message/error-message.tsx
index f2b98e05cf4e1..92c20dcafeb1d 100644
--- a/packages/next/src/next-devtools/dev-overlay/components/errors/error-message/error-message.tsx
+++ b/packages/next/src/next-devtools/dev-overlay/components/errors/error-message/error-message.tsx
@@ -19,6 +19,10 @@ export function ErrorMessage({ errorMessage, errorType }: ErrorMessageProps) {
}
}, [errorMessage])
+ if (!errorMessage) {
+ return null
+ }
+
// The "Blocking Route" error message is specifically formatted to look nice
// in the overlay (rather than just passed through from the console), so we
// intentionally don't truncate it and rely on the scroll overflow instead.
diff --git a/packages/next/src/next-devtools/dev-overlay/container/errors.tsx b/packages/next/src/next-devtools/dev-overlay/container/errors.tsx
index 3768e40b061ee..7504d7ff28615 100644
--- a/packages/next/src/next-devtools/dev-overlay/container/errors.tsx
+++ b/packages/next/src/next-devtools/dev-overlay/container/errors.tsx
@@ -55,6 +55,11 @@ function GenericErrorDescription({ error }: { error: Error }) {
message = message.slice(envPrefix.length)
}
+ message = message.trim()
+ if (!message) {
+ return null
+ }
+
return (
<>
diff --git a/packages/next/src/next-devtools/dev-overlay/container/runtime-error/error-cause.tsx b/packages/next/src/next-devtools/dev-overlay/container/runtime-error/error-cause.tsx
index 64c0ec6542e73..8169c24ca0e5d 100644
--- a/packages/next/src/next-devtools/dev-overlay/container/runtime-error/error-cause.tsx
+++ b/packages/next/src/next-devtools/dev-overlay/container/runtime-error/error-cause.tsx
@@ -11,6 +11,7 @@ type ErrorCauseProps = {
export function ErrorCause({ cause, dialogResizerRef }: ErrorCauseProps) {
const frames = React.use(cause.frames())
+ const trimmedMessage = cause.error.message.trim()
const firstFrame = useMemo(() => {
const index = frames.findIndex(
@@ -29,7 +30,9 @@ export function ErrorCause({ cause, dialogResizerRef }: ErrorCauseProps) {
Caused by: {cause.error.name || 'Error'}
- {cause.error.message}
+ {trimmedMessage ? (
+ {trimmedMessage}
+ ) : null}
{firstFrame && (
{
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/named-export/page.tsx (3:26) @ unstable_instant
> 3 | const unstable_instant = { prefetch: 'static' }
| ^",
@@ -141,7 +140,6 @@ describe('instant validation causes', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/aliased-export/page.tsx (3:17) @ unstable_instant
> 3 | const instant = { prefetch: 'static' }
| ^",
@@ -186,7 +184,6 @@ describe('instant validation causes', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/reexport/page.tsx (3:10) @ unstable_instant
> 3 | export { unstable_instant } from './config'
| ^",
@@ -234,7 +231,6 @@ describe('instant validation causes', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/indirect-export/page.tsx (4:17) @ unstable_instant
> 4 | const instant = _instant
| ^",
diff --git a/test/e2e/app-dir/instant-validation/instant-validation.test.ts b/test/e2e/app-dir/instant-validation/instant-validation.test.ts
index 877649192a3ec..aab515d4d4d6d 100644
--- a/test/e2e/app-dir/instant-validation/instant-validation.test.ts
+++ b/test/e2e/app-dir/instant-validation/instant-validation.test.ts
@@ -189,7 +189,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/missing-suspense-around-runtime/page.tsx (3:33) @ unstable_instant
> 3 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -234,7 +233,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/missing-suspense-around-dynamic/page.tsx (3:33) @ unstable_instant
> 3 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -277,7 +275,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/runtime/missing-suspense-around-dynamic/page.tsx (4:33) @ unstable_instant
> 4 | export const unstable_instant = {
| ^",
@@ -322,7 +319,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/missing-suspense-around-dynamic-layout/layout.tsx (4:33) @ unstable_instant
> 4 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -367,7 +363,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/runtime/missing-suspense-around-dynamic-layout/layout.tsx (4:33) @ unstable_instant
> 4 | export const unstable_instant = {
| ^",
@@ -411,7 +406,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/missing-suspense-around-params/[param]/page.tsx (1:33) @ unstable_instant
> 1 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -465,7 +459,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/missing-suspense-around-search-params/page.tsx (1:33) @ unstable_instant
> 1 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -532,7 +525,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/suspense-too-high/page.tsx (3:33) @ unstable_instant
> 3 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -577,7 +569,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/runtime/suspense-too-high/page.tsx (4:33) @ unstable_instant
> 4 | export const unstable_instant = {
| ^",
@@ -707,7 +698,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/invalid-only-loading-around-dynamic/page.tsx (4:33) @ unstable_instant
> 4 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -759,7 +749,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/blocking-layout/missing-suspense-around-dynamic/page.tsx (3:33) @ unstable_instant
> 3 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -818,7 +807,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/invalid-blocking-inside-static/layout.tsx (1:33) @ unstable_instant
> 1 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -863,7 +851,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/runtime/invalid-blocking-inside-runtime/layout.tsx (3:33) @ unstable_instant
> 3 | export const unstable_instant = {
| ^",
@@ -909,7 +896,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/missing-suspense-in-parallel-route/page.tsx (3:33) @ unstable_instant
> 3 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -955,7 +941,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/missing-suspense-in-parallel-route/foo/page.tsx (1:33) @ unstable_instant
> 1 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -1001,7 +986,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/missing-suspense-in-parallel-route/bar/page.tsx (1:33) @ unstable_instant
> 1 | export const unstable_instant = { prefetch: 'static' }
| ^",
@@ -1049,7 +1033,6 @@ describe('instant validation', () => {
"cause": [
{
"label": "Caused by: Instant Validation",
- "message": " ",
"source": "app/suspense-in-root/static/invalid-client-data-blocks-validation/page.tsx (1:33) @ unstable_instant
> 1 | export const unstable_instant = {
| ^",
diff --git a/test/lib/add-redbox-matchers.ts b/test/lib/add-redbox-matchers.ts
index eb72703f1827e..700037007e7e9 100644
--- a/test/lib/add-redbox-matchers.ts
+++ b/test/lib/add-redbox-matchers.ts
@@ -75,7 +75,7 @@ interface ErrorSnapshotOptions {
interface SanitizedCauseEntry {
label: string | null
- message: string | null
+ message?: string
source: string | null
stack: string[]
}
@@ -83,7 +83,7 @@ interface SanitizedCauseEntry {
export interface ErrorSnapshot {
environmentLabel: string | null
label: string | null
- description: string | null
+ description?: string
componentStack?: string
cause?: SanitizedCauseEntry[]
source: string | null
@@ -239,11 +239,14 @@ async function createErrorSnapshot(
const snapshot: ErrorSnapshot = {
environmentLabel,
label: label ?? '',
- description: sanitizedDescription,
source: focusedSource,
stack: sanitizeStack(stack, next),
}
+ if (sanitizedDescription !== null) {
+ snapshot.description = sanitizedDescription
+ }
+
// Hydration diffs are only relevant to some specific errors
// so we hide them from the snapshots unless they are present.
if (componentStack !== null) {
@@ -252,12 +255,17 @@ async function createErrorSnapshot(
// Error.cause chain is only relevant when present.
if (cause !== null) {
- snapshot.cause = cause.map((entry) => ({
- label: entry.label,
- message: entry.message,
- source: focusSource(entry.source, next),
- stack: sanitizeStack(entry.stack, next) ?? [],
- }))
+ snapshot.cause = cause.map((entry) => {
+ const causeEntry: SanitizedCauseEntry = {
+ label: entry.label,
+ source: focusSource(entry.source, next),
+ stack: sanitizeStack(entry.stack, next) ?? [],
+ }
+ if (entry.message !== null) {
+ causeEntry.message = entry.message
+ }
+ return causeEntry
+ })
}
return snapshot
From b6c5adbd400cf10f86ed9ce952ea1bdd25aaaa39 Mon Sep 17 00:00:00 2001
From: nextjs-bot
Date: Fri, 20 Feb 2026 23:25:51 +0000
Subject: [PATCH 10/11] v16.2.0-canary.55
---
lerna.json | 2 +-
packages/create-next-app/package.json | 2 +-
packages/eslint-config-next/package.json | 4 ++--
packages/eslint-plugin-internal/package.json | 2 +-
packages/eslint-plugin-next/package.json | 2 +-
packages/font/package.json | 2 +-
packages/next-bundle-analyzer/package.json | 2 +-
packages/next-codemod/package.json | 2 +-
packages/next-env/package.json | 2 +-
packages/next-mdx/package.json | 2 +-
packages/next-plugin-storybook/package.json | 2 +-
packages/next-polyfill-module/package.json | 2 +-
packages/next-polyfill-nomodule/package.json | 2 +-
packages/next-routing/package.json | 2 +-
packages/next-rspack/package.json | 2 +-
packages/next-swc/package.json | 2 +-
packages/next/package.json | 14 +++++++-------
packages/react-refresh-utils/package.json | 2 +-
packages/third-parties/package.json | 4 ++--
pnpm-lock.yaml | 16 ++++++++--------
20 files changed, 35 insertions(+), 35 deletions(-)
diff --git a/lerna.json b/lerna.json
index d949357bb94b2..bc8c5e0e326cc 100644
--- a/lerna.json
+++ b/lerna.json
@@ -15,5 +15,5 @@
"registry": "https://registry.npmjs.org/"
}
},
- "version": "16.2.0-canary.54"
+ "version": "16.2.0-canary.55"
}
\ No newline at end of file
diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json
index 0028c4fb9ce92..a86b114b9ffce 100644
--- a/packages/create-next-app/package.json
+++ b/packages/create-next-app/package.json
@@ -1,6 +1,6 @@
{
"name": "create-next-app",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"keywords": [
"react",
"next",
diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json
index 9c2e15c7b3625..2c301663aac01 100644
--- a/packages/eslint-config-next/package.json
+++ b/packages/eslint-config-next/package.json
@@ -1,6 +1,6 @@
{
"name": "eslint-config-next",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"description": "ESLint configuration used by Next.js.",
"license": "MIT",
"repository": {
@@ -12,7 +12,7 @@
"dist"
],
"dependencies": {
- "@next/eslint-plugin-next": "16.2.0-canary.54",
+ "@next/eslint-plugin-next": "16.2.0-canary.55",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json
index 15ecb1a6c9934..7c6e6672b03d8 100644
--- a/packages/eslint-plugin-internal/package.json
+++ b/packages/eslint-plugin-internal/package.json
@@ -1,7 +1,7 @@
{
"name": "@next/eslint-plugin-internal",
"private": true,
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"description": "ESLint plugin for working on Next.js.",
"exports": {
".": "./src/eslint-plugin-internal.js"
diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json
index f2f4b20424751..3712a3af966a3 100644
--- a/packages/eslint-plugin-next/package.json
+++ b/packages/eslint-plugin-next/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/eslint-plugin-next",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"description": "ESLint plugin for Next.js.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/font/package.json b/packages/font/package.json
index 24207c8f143b8..96f196413c9ab 100644
--- a/packages/font/package.json
+++ b/packages/font/package.json
@@ -1,7 +1,7 @@
{
"name": "@next/font",
"private": true,
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"repository": {
"url": "vercel/next.js",
"directory": "packages/font"
diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json
index 3f4b034455878..e66564498cad2 100644
--- a/packages/next-bundle-analyzer/package.json
+++ b/packages/next-bundle-analyzer/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/bundle-analyzer",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"main": "index.js",
"types": "index.d.ts",
"license": "MIT",
diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json
index e2887e8295a53..6d447356f9f23 100644
--- a/packages/next-codemod/package.json
+++ b/packages/next-codemod/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/codemod",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"license": "MIT",
"repository": {
"type": "git",
diff --git a/packages/next-env/package.json b/packages/next-env/package.json
index 2f5835ba4038f..bd7448fb359ea 100644
--- a/packages/next-env/package.json
+++ b/packages/next-env/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/env",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"keywords": [
"react",
"next",
diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json
index fa2e0fc09e446..5b28c3bcccdd5 100644
--- a/packages/next-mdx/package.json
+++ b/packages/next-mdx/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/mdx",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"main": "index.js",
"license": "MIT",
"repository": {
diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json
index 846f56cb10b28..37a772efb4cdd 100644
--- a/packages/next-plugin-storybook/package.json
+++ b/packages/next-plugin-storybook/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/plugin-storybook",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"repository": {
"url": "vercel/next.js",
"directory": "packages/next-plugin-storybook"
diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json
index 82d58ad78d3e4..a85858302b8b3 100644
--- a/packages/next-polyfill-module/package.json
+++ b/packages/next-polyfill-module/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/polyfill-module",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)",
"main": "dist/polyfill-module.js",
"license": "MIT",
diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json
index 7512c0a9a8e1b..53899db012621 100644
--- a/packages/next-polyfill-nomodule/package.json
+++ b/packages/next-polyfill-nomodule/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/polyfill-nomodule",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"description": "A polyfill for non-dead, nomodule browsers.",
"main": "dist/polyfill-nomodule.js",
"license": "MIT",
diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json
index 9e78a6be165b2..9c36b5266608e 100644
--- a/packages/next-routing/package.json
+++ b/packages/next-routing/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/routing",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"keywords": [
"react",
"next",
diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json
index 371d8763e0a45..642bf916916ee 100644
--- a/packages/next-rspack/package.json
+++ b/packages/next-rspack/package.json
@@ -1,6 +1,6 @@
{
"name": "next-rspack",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"repository": {
"url": "vercel/next.js",
"directory": "packages/next-rspack"
diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json
index 1a8f81d3ef954..041457adbd67a 100644
--- a/packages/next-swc/package.json
+++ b/packages/next-swc/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/swc",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"private": true,
"files": [
"native/"
diff --git a/packages/next/package.json b/packages/next/package.json
index a0cb54c325bc8..4eeadb308937a 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -1,6 +1,6 @@
{
"name": "next",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"description": "The React Framework",
"main": "./dist/server/next.js",
"license": "MIT",
@@ -97,7 +97,7 @@
]
},
"dependencies": {
- "@next/env": "16.2.0-canary.54",
+ "@next/env": "16.2.0-canary.55",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -162,11 +162,11 @@
"@modelcontextprotocol/sdk": "1.18.1",
"@mswjs/interceptors": "0.23.0",
"@napi-rs/triples": "1.2.0",
- "@next/font": "16.2.0-canary.54",
- "@next/polyfill-module": "16.2.0-canary.54",
- "@next/polyfill-nomodule": "16.2.0-canary.54",
- "@next/react-refresh-utils": "16.2.0-canary.54",
- "@next/swc": "16.2.0-canary.54",
+ "@next/font": "16.2.0-canary.55",
+ "@next/polyfill-module": "16.2.0-canary.55",
+ "@next/polyfill-nomodule": "16.2.0-canary.55",
+ "@next/react-refresh-utils": "16.2.0-canary.55",
+ "@next/swc": "16.2.0-canary.55",
"@opentelemetry/api": "1.6.0",
"@playwright/test": "1.51.1",
"@rspack/core": "1.6.7",
diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json
index cf86f9a25d147..a8f1989752843 100644
--- a/packages/react-refresh-utils/package.json
+++ b/packages/react-refresh-utils/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/react-refresh-utils",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"description": "An experimental package providing utilities for React Refresh.",
"repository": {
"url": "vercel/next.js",
diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json
index 5fdc1913f62f2..b82acbcce11e7 100644
--- a/packages/third-parties/package.json
+++ b/packages/third-parties/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/third-parties",
- "version": "16.2.0-canary.54",
+ "version": "16.2.0-canary.55",
"repository": {
"url": "vercel/next.js",
"directory": "packages/third-parties"
@@ -26,7 +26,7 @@
"third-party-capital": "1.0.20"
},
"devDependencies": {
- "next": "16.2.0-canary.54",
+ "next": "16.2.0-canary.55",
"outdent": "0.8.0",
"prettier": "2.5.1",
"typescript": "5.9.2"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd46b112ad107..23c4a4dbbd605 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1011,7 +1011,7 @@ importers:
packages/eslint-config-next:
dependencies:
'@next/eslint-plugin-next':
- specifier: 16.2.0-canary.54
+ specifier: 16.2.0-canary.55
version: link:../eslint-plugin-next
eslint:
specifier: '>=9.0.0'
@@ -1088,7 +1088,7 @@ importers:
packages/next:
dependencies:
'@next/env':
- specifier: 16.2.0-canary.54
+ specifier: 16.2.0-canary.55
version: link:../next-env
'@swc/helpers':
specifier: 0.5.15
@@ -1216,19 +1216,19 @@ importers:
specifier: 1.2.0
version: 1.2.0
'@next/font':
- specifier: 16.2.0-canary.54
+ specifier: 16.2.0-canary.55
version: link:../font
'@next/polyfill-module':
- specifier: 16.2.0-canary.54
+ specifier: 16.2.0-canary.55
version: link:../next-polyfill-module
'@next/polyfill-nomodule':
- specifier: 16.2.0-canary.54
+ specifier: 16.2.0-canary.55
version: link:../next-polyfill-nomodule
'@next/react-refresh-utils':
- specifier: 16.2.0-canary.54
+ specifier: 16.2.0-canary.55
version: link:../react-refresh-utils
'@next/swc':
- specifier: 16.2.0-canary.54
+ specifier: 16.2.0-canary.55
version: link:../next-swc
'@opentelemetry/api':
specifier: 1.6.0
@@ -1943,7 +1943,7 @@ importers:
version: 1.0.20
devDependencies:
next:
- specifier: 16.2.0-canary.54
+ specifier: 16.2.0-canary.55
version: link:../next
outdent:
specifier: 0.8.0
From 22e46a40adf5f757422e2389066e963654ed7c0d Mon Sep 17 00:00:00 2001
From: Hayden Bleasel
Date: Fri, 20 Feb 2026 15:39:53 -0800
Subject: [PATCH 11/11] Migrate from react-markdown to Streamdown static in
EdgeDB example (#86435)
This pull request updates the blog post rendering in the EdgeDB example
to use our `streamdown` library in static mode instead of
`react-markdown` for Markdown parsing and rendering.
---------
Co-authored-by: Joseph
---
examples/with-edgedb/components/Post.tsx | 10 +++++-----
examples/with-edgedb/package.json | 5 ++++-
examples/with-edgedb/pages/blog/[id].tsx | 5 ++---
examples/with-edgedb/pages/index.tsx | 1 +
examples/with-edgedb/postcss.config.js | 8 ++++++++
examples/with-edgedb/styles/global.css | 3 +++
examples/with-edgedb/tailwind.config.js | 11 +++++++++++
7 files changed, 34 insertions(+), 9 deletions(-)
create mode 100644 examples/with-edgedb/postcss.config.js
create mode 100644 examples/with-edgedb/styles/global.css
create mode 100644 examples/with-edgedb/tailwind.config.js
diff --git a/examples/with-edgedb/components/Post.tsx b/examples/with-edgedb/components/Post.tsx
index cdcea5b0cd5c5..5ffdd867910a4 100644
--- a/examples/with-edgedb/components/Post.tsx
+++ b/examples/with-edgedb/components/Post.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import ReactMarkdown from "react-markdown";
+import { Streamdown } from "streamdown";
import Link from "next/link";
import { PostProps } from "../pages/blog/[id]";
@@ -11,9 +11,9 @@ const Post: React.FC<{ post: PostProps }> = ({ post }) => {
By {post.authorName}
-
+
{post.content || ""}
-
+
diff --git a/examples/with-edgedb/package.json b/examples/with-edgedb/package.json
index abdcf9f56d391..ac80c73217250 100644
--- a/examples/with-edgedb/package.json
+++ b/examples/with-edgedb/package.json
@@ -14,11 +14,14 @@
"next": "latest",
"react": "^18.0.0",
"react-dom": "^18.0.0",
- "react-markdown": "^8.0.2"
+ "streamdown": "^1.6.5"
},
"devDependencies": {
+ "autoprefixer": "^10.4.20",
"@types/node": "^16.11.26",
+ "postcss": "^8.4.49",
"prettier": "^2.6.2",
+ "tailwindcss": "^3.4.16",
"ts-node": "^10.7.0",
"typescript": "^4.6.3"
}
diff --git a/examples/with-edgedb/pages/blog/[id].tsx b/examples/with-edgedb/pages/blog/[id].tsx
index 837a80c5220b1..761b9143e7982 100644
--- a/examples/with-edgedb/pages/blog/[id].tsx
+++ b/examples/with-edgedb/pages/blog/[id].tsx
@@ -1,11 +1,10 @@
import React, { useState } from "react";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
+import { Streamdown } from "streamdown";
import Layout from "../../components/Layout";
import Router from "next/router";
import { client, e } from "../../client";
-import ReactMarkdown from "react-markdown";
-
async function update(
id: string,
data: { title?: string; content?: string },
@@ -61,7 +60,7 @@ const Post: React.FC = (props) => {
- {props.content || ""}
+ {props.content || ""}
);
diff --git a/examples/with-edgedb/pages/index.tsx b/examples/with-edgedb/pages/index.tsx
index 5d946c2913e39..d7f63381f2a3d 100644
--- a/examples/with-edgedb/pages/index.tsx
+++ b/examples/with-edgedb/pages/index.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import "../styles/global.css";
import { GetServerSideProps } from "next";
import Layout from "../components/Layout";
import Post from "../components/Post";
diff --git a/examples/with-edgedb/postcss.config.js b/examples/with-edgedb/postcss.config.js
new file mode 100644
index 0000000000000..3687d286ecd7c
--- /dev/null
+++ b/examples/with-edgedb/postcss.config.js
@@ -0,0 +1,8 @@
+// If you want to use other PostCSS plugins, see the following:
+// https://tailwindcss.com/docs/using-with-preprocessors
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/examples/with-edgedb/styles/global.css b/examples/with-edgedb/styles/global.css
new file mode 100644
index 0000000000000..b5c61c956711f
--- /dev/null
+++ b/examples/with-edgedb/styles/global.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/examples/with-edgedb/tailwind.config.js b/examples/with-edgedb/tailwind.config.js
new file mode 100644
index 0000000000000..df433f6b640fe
--- /dev/null
+++ b/examples/with-edgedb/tailwind.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ content: [
+ "./pages/**/*.{js,ts,jsx,tsx}",
+ "./components/**/*.{js,ts,jsx,tsx}",
+ "./node_modules/streamdown/dist/*.js", // For Streamdown
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};