From 724641c6a21705f7c28757406c7eae727bdc3213 Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Mon, 31 Aug 2026 09:06:58 -0700 Subject: [PATCH 1/8] Fix Turbopack HMR recovery after dropped connections (#97966) ### What? Track the latest applied Turbopack HMR 'hash' in the browser runtime and include it when subscriptions are restored after reconnecting. Reload the page when the client's hash differs from the server's current hash. Add an App Router development test that interrupts HMR traffic, misses an update, restores the connection, and verifies the page reloads into the current revision. ### Why? A browser that temporarily loses its HMR connection can miss updates and remain out of sync after reconnecting. ### How? Attach the current HMR hash to Turbopack connection and update messages. The runtime records the last hash it processed and sends it with each subscription. The development server compares that value against its current hash and requests a full reload on mismatch. --- .../dev/hot-reloader/app/hot-reloader-app.tsx | 6 ++ .../hot-reloader/pages/hot-reloader-pages.ts | 4 + .../src/server/dev/hot-reloader-turbopack.ts | 54 ++++++++---- .../next/src/server/dev/hot-reloader-types.ts | 11 ++- .../app/hmr-reconnect/page.js | 5 ++ test/development/app-hmr/hmr.test.ts | 82 ++++++++++++++++++- .../src/browser/dev/hmr-client/hmr-client.ts | 20 ++++- .../js/src/shared/runtime/dev-protocol.d.ts | 1 + 8 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 test/development/app-hmr/fixtures/default-template/app/hmr-reconnect/page.js diff --git a/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx b/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx index 05d0cd553909..0c24b6bd7aa7 100644 --- a/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx +++ b/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx @@ -386,6 +386,7 @@ export function processMessage( type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_CONNECTED, data: { sessionId: message.data.sessionId, + hmrVersion: message.data.hmrVersion, }, }) break @@ -396,6 +397,7 @@ export function processMessage( processTurbopackMessage({ type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_MESSAGE, data: message.data, + hmrVersion: message.hmrVersion, }) if (RuntimeErrorHandler.hadRuntimeError) { console.warn(REACT_REFRESH_FULL_RELOAD_FROM_ERROR) @@ -406,6 +408,10 @@ export function processMessage( } // TODO-APP: make server component change more granular case HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES: { + processTurbopackMessage({ + type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, + hmrVersion: message.hmrVersion, + }) turbopackHmr?.onServerComponentChanges() sendMessage( JSON.stringify({ diff --git a/packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts b/packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts index ace2164d4dc2..13d7eab74dd3 100644 --- a/packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts +++ b/packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts @@ -340,6 +340,9 @@ function processMessage(message: HmrMessageSentToBrowser) { return handleSuccess() } case HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES: { + for (const listener of turbopackMessageListeners) { + listener(message) + } turbopackHmr?.onServerComponentChanges() if (hasCompileErrors || RuntimeErrorHandler.hadRuntimeError) { window.location.reload() @@ -376,6 +379,7 @@ function processMessage(message: HmrMessageSentToBrowser) { listener({ type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_MESSAGE, data: message.data, + hmrVersion: message.hmrVersion, }) } if (RuntimeErrorHandler.hadRuntimeError) { diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index fdc757f67b9e..04f104a6eb12 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -821,7 +821,7 @@ export async function createHotReloaderTurbopack( } let hmrEventHappened = false - // A counter identifying the current version of the compiled output, included + // A counter identifying the current version of the server component output, included // by `"use cache"` in dev cache keys so that cached entries revalidate after // an edit. It advances once per HMR change event (for App Router pages that // is an RSC change, which is what a cached render depends on), independent of @@ -829,7 +829,9 @@ export async function createHotReloaderTurbopack( // messages: those are sent per connected client on every compilation, so // advancing there would both churn the hash without an edit and fail to // advance it at all when no client is connected. - let hmrHash = 0 + let serverComponentsHmrRefreshVersion = 0 + let clientHmrVersion = 0 + let clientHmrEventHappened = false // Undefined until the first entrypoints emission. That one has nothing to // compare against, so every route it lists would look added. let previousRouteKeys: Set | undefined @@ -919,6 +921,7 @@ export async function createHotReloaderTurbopack( sendToClient(client, { type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_MESSAGE, data: state.turbopackUpdates, + hmrVersion: String(clientHmrVersion), }) state.turbopackUpdates.length = 0 } @@ -928,14 +931,23 @@ export async function createHotReloaderTurbopack( const sendHmr: SendHmr = (id: string, message: HmrMessageSentToBrowser) => { pendingBuilding.flush() + + if (message.type === HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES) { + if (!clientHmrEventHappened) { + clientHmrVersion++ + clientHmrEventHappened = true + } + message.hmrVersion = String(clientHmrVersion) + } + + hmrEventHappened = true + for (const client of [ ...clientsWithoutHtmlRequestId, ...clientsByHtmlRequestId.values(), ]) { clientStates.get(client)?.messages.set(id, message) } - - hmrEventHappened = true sendEnqueuedMessagesDebounce() } @@ -954,6 +966,10 @@ export async function createHotReloaderTurbopack( clientStates.get(client)?.turbopackUpdates.push(payload) } + if (!clientHmrEventHappened) { + clientHmrVersion++ + clientHmrEventHappened = true + } hmrEventHappened = true sendEnqueuedMessagesDebounce() } @@ -984,7 +1000,10 @@ export async function createHotReloaderTurbopack( for await (const change of changed) { processIssues(currentEntryIssues, key, change, false, true) // TODO: Get an actual content hash from Turbopack. - const message = await createMessage(change, String(++hmrHash)) + const message = await createMessage( + change, + String(++serverComponentsHmrRefreshVersion) + ) if (message) { sendHmr(key, message) } @@ -1581,6 +1600,16 @@ export async function createHotReloaderTurbopack( // Turbopack messages switch (parsedData.type) { case 'turbopack-subscribe': + if ( + parsedData.hmrVersion !== undefined && + parsedData.hmrVersion !== String(clientHmrVersion) + ) { + sendToClient(client, { + type: HMR_MESSAGE_SENT_TO_BROWSER.RELOAD_PAGE, + data: 'HMR hash mismatch', + }) + break + } subscribeToClientHmrEvents(client, parsedData.path) break @@ -1597,7 +1626,7 @@ export async function createHotReloaderTurbopack( const turbopackConnectedMessage: TurbopackConnectedMessage = { type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_CONNECTED, - data: { sessionId }, + data: { sessionId, hmrVersion: String(clientHmrVersion) }, } sendToClient(client, turbopackConnectedMessage) @@ -1658,13 +1687,9 @@ export async function createHotReloaderTurbopack( }, getServerComponentsHmrRefreshHash() { - // Only the change subscription (an actual recompile) advances `hmrHash`; - // reloads and config invalidations don't, so the value stays stable - // across requests until a real edit. `sessionId` stands in for a key - // derived from the compiled implementation, which would let entries - // outlive a restart when the code didn't change (see the note on Action - // IDs in `use-cache-wrapper.ts`). - return `${sessionId}-${hmrHash}` + // Only a change subscription advances the refresh version, so reloads + // and config invalidations leave cache keys stable until a real edit. + return `${sessionId}-${serverComponentsHmrRefreshVersion}` }, sendToLegacyClients(action) { @@ -2105,7 +2130,7 @@ export async function createHotReloaderTurbopack( // Report the current version without advancing it: a completed // compilation is not itself an edit, and this hash is not // consumed by the Turbopack client. - hash: String(hmrHash), + hash: String(serverComponentsHmrRefreshVersion), errors: [...clientErrors.values()], warnings: [], }) @@ -2118,6 +2143,7 @@ export async function createHotReloaderTurbopack( Log.event(`Compiled in ${timeMessage}`) hmrEventHappened = false } + clientHmrEventHappened = false // Call onBeforeDeferredEntries after compilation completes during HMR // This ensures the callback is invoked even when non-deferred entries change diff --git a/packages/next/src/server/dev/hot-reloader-types.ts b/packages/next/src/server/dev/hot-reloader-types.ts index 1abf77da1652..f721695c6d46 100644 --- a/packages/next/src/server/dev/hot-reloader-types.ts +++ b/packages/next/src/server/dev/hot-reloader-types.ts @@ -65,6 +65,7 @@ export interface ServerErrorMessage { export interface TurbopackMessage { type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_MESSAGE data: TurbopackUpdate | TurbopackUpdate[] + hmrVersion: string } export interface BuildingMessage { @@ -117,6 +118,7 @@ export interface ReloadPageMessage { export interface ServerComponentChangesMessage { type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES + hmrVersion?: string } /** @@ -151,7 +153,7 @@ export interface DevPagesManifestUpdateMessage { export interface TurbopackConnectedMessage { type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_CONNECTED - data: { sessionId: number } + data: { sessionId: number; hmrVersion: string } } export interface AppIsrManifestMessage { @@ -232,10 +234,15 @@ export type TurbopackMessageSentToBrowser = | { type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_MESSAGE data: any + hmrVersion: string } | { type: HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_CONNECTED - data: { sessionId: number } + data: { sessionId: number; hmrVersion: string } + } + | { + type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES + hmrVersion?: string } export interface NextJsHotReloaderInterface { diff --git a/test/development/app-hmr/fixtures/default-template/app/hmr-reconnect/page.js b/test/development/app-hmr/fixtures/default-template/app/hmr-reconnect/page.js new file mode 100644 index 000000000000..4b5c671826c9 --- /dev/null +++ b/test/development/app-hmr/fixtures/default-template/app/hmr-reconnect/page.js @@ -0,0 +1,5 @@ +'use client' + +export default function Page() { + return

Initial

+} diff --git a/test/development/app-hmr/hmr.test.ts b/test/development/app-hmr/hmr.test.ts index 9c0c534c2332..8ad5e9e5b172 100644 --- a/test/development/app-hmr/hmr.test.ts +++ b/test/development/app-hmr/hmr.test.ts @@ -1,4 +1,4 @@ -import { FileRef, nextTestSetup } from 'e2e-utils' +import { FileRef, nextTestSetup, Playwright } from 'e2e-utils' import { retry, waitFor } from 'next-test-utils' import path from 'path' @@ -11,6 +11,86 @@ describe(`app-dir-hmr`, () => { }) describe('filesystem changes', () => { + // @force-gate turbopack + it('reloads the page if the server advanced while the client was disconnected', async () => { + const componentPath = 'app/hmr-reconnect/page.js' + const originalComponent = await next.readFile(componentPath) + let forwardHmrTraffic = true + let droppedHmrMessages = 0 + let reconnectHmr = () => {} + const subscriptionHashes: Array = [] + + const getHmrValue = (browser: Playwright) => + browser.eval(`document.querySelector('#hmr-value')?.textContent`) + + try { + const browser = await next.browser('/hmr-reconnect', { + async beforePageLoad(page) { + await page.routeWebSocket(/\/_next\/hmr/, (clientSocket) => { + const serverSocket = clientSocket.connectToServer() + function connectServerSocket() { + serverSocket.onMessage((message) => { + if (forwardHmrTraffic) { + clientSocket.send(message) + } else { + droppedHmrMessages++ + } + }) + } + connectServerSocket() + clientSocket.onMessage((message) => { + if (typeof message === 'string') { + const parsed = JSON.parse(message) + if (parsed.type === 'turbopack-subscribe') { + subscriptionHashes.push(parsed.hmrVersion) + } + } + serverSocket.send(message) + }) + reconnectHmr = () => serverSocket.close() + }) + }, + }) + await retry(async () => { + expect(await getHmrValue(browser)).toBe('Initial') + }) + + await next.patchFile( + componentPath, + originalComponent.replace('Initial', 'First edit') + ) + await retry(async () => { + expect(await getHmrValue(browser)).toBe('First edit') + }) + await browser.eval(`window.__hmrTestDocument = true`) + + forwardHmrTraffic = false + await next.patchFile( + componentPath, + originalComponent.replace('Initial', 'Second edit') + ) + await retry(async () => { + expect(droppedHmrMessages).toBeGreaterThan(0) + }) + expect(await getHmrValue(browser)).not.toBe('Second edit') + expect(await browser.eval(`window.__hmrTestDocument`)).toBe(true) + + forwardHmrTraffic = true + reconnectHmr() + await retry(async () => { + expect(subscriptionHashes.some((hash) => hash !== undefined)).toBe( + true + ) + }) + await retry(async () => { + expect(await getHmrValue(browser)).toBe('Second edit') + }, 10_000) + expect(await browser.eval(`window.__hmrTestDocument`)).toBeUndefined() + } finally { + await next.patchFile(componentPath, originalComponent) + } + }) + it('should not continously poll when hitting a not found page', async () => { let requestCount = 0 diff --git a/turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/dev/hmr-client/hmr-client.ts b/turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/dev/hmr-client/hmr-client.ts index 699af948c792..222832c8f511 100644 --- a/turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/dev/hmr-client/hmr-client.ts +++ b/turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/dev/hmr-client/hmr-client.ts @@ -7,10 +7,16 @@ type SendMessage = (msg: any) => void export type WebSocketMessage = | { type: 'turbopack-connected' + data?: { hmrVersion: string } } | { type: 'turbopack-message' data: Record + hmrVersion?: string + } + | { + type: 'server-component-changes' + hmrVersion?: string } export type ClientOptions = { @@ -23,6 +29,8 @@ export type ClientOptions = { export const TURBOPACK_CHUNK_UPDATE_LISTENERS_GLOBAL = 'TURBOPACK_CHUNK_UPDATE_LISTENERS' +let lastSeenHmrVersion: string | undefined + export function connect({ addMessageListener, sendMessage, @@ -32,9 +40,13 @@ export function connect({ addMessageListener((msg) => { switch (msg.type) { case 'turbopack-connected': + if (lastSeenHmrVersion === undefined && msg.data !== undefined) { + lastSeenHmrVersion = msg.data.hmrVersion + } handleSocketConnected(sendMessage) break - default: + case 'turbopack-message': + lastSeenHmrVersion = msg.hmrVersion try { if (Array.isArray(msg.data)) { for (let i = 0; i < msg.data.length; i++) { @@ -57,6 +69,11 @@ export function connect({ location.reload() } break + case 'server-component-changes': + lastSeenHmrVersion = msg.hmrVersion + break + default: + break } }) @@ -108,6 +125,7 @@ function subscribeToUpdates( sendJSON(sendMessage, { type: 'turbopack-subscribe', ...resource, + hmrVersion: lastSeenHmrVersion, }) return () => { diff --git a/turbopack/crates/turbopack-ecmascript-runtime/js/src/shared/runtime/dev-protocol.d.ts b/turbopack/crates/turbopack-ecmascript-runtime/js/src/shared/runtime/dev-protocol.d.ts index 9e646322acd2..cc04de5a5d47 100644 --- a/turbopack/crates/turbopack-ecmascript-runtime/js/src/shared/runtime/dev-protocol.d.ts +++ b/turbopack/crates/turbopack-ecmascript-runtime/js/src/shared/runtime/dev-protocol.d.ts @@ -98,6 +98,7 @@ type ResourceIdentifier = { type ClientMessageSubscribe = { type: 'turbopack-subscribe' + hmrVersion?: string } & ResourceIdentifier type ClientMessageUnsubscribe = { From ec107c16ddcfa7bc102249c29ce9d0d577bccc2e Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Mon, 31 Aug 2026 09:12:51 -0700 Subject: [PATCH 2/8] lazy server hmr (#96566) ## Summary Make Turbopack server HMR demand-driven. Server updates are now compiled and applied when the next relevant request writes an endpoint, instead of eagerly evaluating changed server modules after every file change. This replaces the aggregate server HMR subscription with an on-demand update API, while preserving incremental updates and falling back to full cache eviction when a restart is required. Test Plan: added an e2e test --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Will Binns-Smith <755844+wbinnssmith@users.noreply.github.com> --- crates/next-api/src/aggregate_hmr.rs | 473 +++++++++++++----- crates/next-api/src/app.rs | 24 +- crates/next-api/src/lib.rs | 2 +- crates/next-api/src/pages.rs | 1 + crates/next-api/src/project.rs | 104 +--- crates/next-api/src/route.rs | 1 + crates/next-api/src/versioned_content_map.rs | 27 +- .../src/next_api/endpoint.rs | 6 + .../src/next_api/project.rs | 210 ++++---- .../next/src/build/swc/generated-native.d.ts | 21 +- packages/next/src/build/swc/index.ts | 17 +- packages/next/src/build/swc/types.ts | 31 +- .../src/server/dev/hot-reloader-turbopack.ts | 241 +++++---- .../next/src/server/dev/turbopack-utils.ts | 23 - .../server-hmr/app/lazy-pages/0/page.tsx | 7 + .../server-hmr/app/lazy-pages/1/page.tsx | 7 + .../server-hmr/app/lazy-pages/2/page.tsx | 7 + .../server-hmr/app/lazy-pages/shared.ts | 1 + .../server-hmr/app/lazy-rebuild/page.tsx | 5 + .../server-hmr/app/lazy-rebuild/probe.js | 3 + .../app-dir/server-hmr/server-hmr.test.ts | 90 +++- .../src/chunk_list/version.rs | 101 ++-- .../node/entry/chunk_list_content.rs | 24 +- 23 files changed, 913 insertions(+), 513 deletions(-) create mode 100644 test/development/app-dir/server-hmr/app/lazy-pages/0/page.tsx create mode 100644 test/development/app-dir/server-hmr/app/lazy-pages/1/page.tsx create mode 100644 test/development/app-dir/server-hmr/app/lazy-pages/2/page.tsx create mode 100644 test/development/app-dir/server-hmr/app/lazy-pages/shared.ts create mode 100644 test/development/app-dir/server-hmr/app/lazy-rebuild/page.tsx create mode 100644 test/development/app-dir/server-hmr/app/lazy-rebuild/probe.js diff --git a/crates/next-api/src/aggregate_hmr.rs b/crates/next-api/src/aggregate_hmr.rs index 42833b9b3904..e9578098b90e 100644 --- a/crates/next-api/src/aggregate_hmr.rs +++ b/crates/next-api/src/aggregate_hmr.rs @@ -1,110 +1,100 @@ +use std::{ + fmt::Display, + sync::{Arc, LazyLock}, +}; + use anyhow::Result; +use serde::Serialize; use turbo_rcstr::RcStr; use turbo_tasks::{ - FxIndexMap, FxIndexSet, NonLocalValue, ReadRef, ResolvedVc, TraitRef, TryJoinIterExt, Vc, - debug::ValueDebugFormat, trace::TraceRawVcs, + FxIndexMap, FxIndexSet, NonLocalValue, ReadRef, ResolvedVc, TryJoinIterExt, Vc, + debug::ValueDebugFormat, + message_queue::{CompilationEvent, Severity}, + trace::TraceRawVcs, + turbo_tasks, }; -use turbo_tasks_fs::FileSystemPath; use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64}; -use turbopack_browser::ecmascript::list::content::EcmascriptDevChunkListContent; use turbopack_core::{ update_instruction::UpdateInstruction, - version::{PartialUpdate, Update, Version, VersionState, VersionedContent}, + version::{PartialUpdate, Update, Version}, }; use turbopack_ecmascript::chunk_list::{ merged_update::EcmascriptMergedUpdate, update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction}, + version::ChunkListVersion, +}; +use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::{ + EcmascriptBuildNodeChunkListContent, compute_update_from_version_operation, }; -use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::EcmascriptBuildNodeChunkListContent; - -use crate::versioned_content_map::VersionedContentMap; -#[derive(TraceRawVcs, PartialEq, Eq, ValueDebugFormat, NonLocalValue)] -pub struct HmrChunkWithContent { - pub path: RcStr, - pub content: ResolvedVc>, +#[derive(Clone, TraceRawVcs, PartialEq, Eq, ValueDebugFormat, NonLocalValue)] +pub struct ServerHmrChunkList { + pub relative_path: RcStr, + pub versioned_content: ResolvedVc, } #[turbo_tasks::value(transparent, serialization = "skip")] -pub struct HmrChunksWithContent(Vec); - -/// Whether `content` is a chunk list, i.e. an entry point of the chunk graph that -/// an HMR subscription can be anchored on. -/// -/// Note this must enumerate every chunk list content type. A new chunking context -/// that introduces one has to be added here, otherwise its chunks silently drop -/// out of the HMR subscription. -pub fn is_entry_chunk_list_content(content: ResolvedVc>) -> bool { - ResolvedVc::try_downcast_type::(content).is_some() - || ResolvedVc::try_downcast_type::(content).is_some() +#[derive(Clone)] +pub struct ServerHmrChunkLists(Vec); + +impl ServerHmrChunkLists { + pub fn new(chunk_lists: Vec) -> Self { + Self(chunk_lists) + } + + pub fn as_slice(&self) -> &[ServerHmrChunkList] { + &self.0 + } + + pub fn retain_entry_paths(&mut self, entry_paths: &FxIndexSet) { + self.0 + .retain(|chunk_list| entry_paths.contains(&chunk_list.relative_path)); + } } -/// Per-chunk versions keyed by path #[turbo_tasks::value(serialization = "skip", shared)] -pub struct AggregateHmrVersion { +#[derive(Debug)] +pub struct ServerHmrChunkListVersion { #[turbo_tasks(trace_ignore)] - pub versions: FxIndexMap>>, + pub versions_by_chunk_list_path: FxIndexMap>, } #[turbo_tasks::value_impl] -impl Version for AggregateHmrVersion { +impl Version for ServerHmrChunkListVersion { #[turbo_tasks::function] async fn id(&self) -> Result> { - let entries = self - .versions - .iter() - .map(|(path, version)| { - let path = path.clone(); - let version = TraitRef::cell(version.clone()); - async move { - let id = version.id().owned().await?; - anyhow::Ok((path, id)) - } - }) - .try_join() - .await?; - let mut hasher = Xxh3Hash64Hasher::new(); - hasher.write_value(entries.len()); - for (path, id) in entries { + hasher.write_value(self.versions_by_chunk_list_path.len()); + for (path, version) in &self.versions_by_chunk_list_path { hasher.write_value(path.as_str()); - hasher.write_value(id.as_str()); + hasher.write_value(version.id.as_str()); } Ok(Vc::cell(encode_base64(hasher.finish()).into())) } } -impl AggregateHmrVersion { - pub async fn from_map( - map: Vc, - root: FileSystemPath, - ) -> Result>> { - // An empty `versions` map behaves the same as `NotFoundVersion` would in - // `diff_chunks_against`, so no special case is needed here. - let chunks = map.hmr_chunks_in_path(root).await?; - Ok(Vc::upcast(Self::from_chunks(&chunks).await?)) - } - - pub async fn from_chunks(chunks: &[HmrChunkWithContent]) -> Result> { - let versions = chunks +impl ServerHmrChunkListVersion { + pub async fn from_chunk_lists(chunk_lists: &[ServerHmrChunkList]) -> Result { + let versions_by_chunk_list_path = chunk_lists .iter() - .map(|HmrChunkWithContent { path, content }| { - let path = path.clone(); - let content = *content; + .map(|chunk_list| { + let relative_path = chunk_list.relative_path.clone(); + let versioned_content = chunk_list.versioned_content; async move { - let version = content.version().into_trait_ref().await?; - anyhow::Ok((path, version)) + let version = versioned_content.version().await?; + anyhow::Ok((relative_path, version)) } }) .try_join() .await? .into_iter() .collect(); - Ok(Self { versions }.cell()) + Ok(Self { + versions_by_chunk_list_path, + }) } } -/// Aggregates per-entry HMR instructions into a single combined `ChunkListUpdate`. #[derive(Default)] pub struct ChunkListUpdateBuilder { chunks: FxIndexMap, @@ -138,77 +128,213 @@ impl ChunkListUpdateBuilder { self.chunks.is_empty() && self.merged.is_empty() } - pub fn build(self, to: TraitRef>) -> Update { - Update::Partial(PartialUpdate { - to, - instruction: ChunkListUpdate { - chunks: self.chunks, - merged: self.merged.into_iter().collect(), - } - .into_instruction(), - }) + pub fn build(self) -> UpdateInstruction { + ChunkListUpdate { + chunks: self.chunks, + merged: self.merged.into_iter().collect(), + } + .into_instruction() } } -/// Per-chunk [`Update`]s computed against an `AggregateHmrVersion` snapshot. -/// `has_new_chunks` is true when the current snapshot contains chunks absent -/// from `from` (e.g. a new endpoint was written); callers decide whether that -/// affects the batch shape. -pub struct DiffResult { - pub chunk_updates: Vec<(RcStr, ReadRef)>, - pub has_new_chunks: bool, +/// An update plus the baseline for the next pull. +#[derive(Debug, TraceRawVcs)] +pub enum ServerHmrUpdate { + /// No runtime update and the graph is equivalent. However, `to` may still advance the pull + /// version. + NoRuntimeUpdate { + to: Option>, + }, + FullReevaluation { + to: ReadRef, + }, + Partial { + to: ReadRef, + instruction: UpdateInstruction, + }, } -/// Diffs each chunk against the [`AggregateHmrVersion`] held by `from`. -/// -/// If `from` holds some other kind of `Version`, there's nothing meaningful to -/// diff against, so this returns no updates and leaves it to the caller to -/// decide what to do. -pub async fn diff_chunks_against( - chunks: &[HmrChunkWithContent], - from: Vc, -) -> Result { - if chunks.is_empty() { - return Ok(DiffResult { - chunk_updates: Vec::new(), - has_new_chunks: false, - }); - } - let from_resolved = from.get().to_resolved().await?; - let Some(from_aggregate) = ResolvedVc::try_downcast_type::(from_resolved) - else { - return Ok(DiffResult { - chunk_updates: Vec::new(), - has_new_chunks: false, - }); - }; - let from_aggregate = from_aggregate.await?; +struct DiffResult { + chunk_updates: Vec>, + membership: ChunkListMembershipChange, +} + +/// Chunk lists that appeared or vanished relative to the pull baseline. +#[derive(Default, Clone, Copy)] +struct ChunkListMembershipChange { + has_new: bool, + has_removed: bool, +} + +static TRACE_DIFFING: LazyLock = LazyLock::new(|| { + cfg!(debug_assertions) && std::env::var_os("NEXT_TEST_SERVER_HMR_DIFFING").is_some() +}); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ServerHmrChunkListDiffEvent { + #[serde(rename = "entryPath")] + chunk_list_path: RcStr, +} + +impl Display for ServerHmrChunkListDiffEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Diffing server HMR entry {}", self.chunk_list_path) + } +} + +impl CompilationEvent for ServerHmrChunkListDiffEvent { + fn type_name(&self) -> &'static str { + "ServerHmrEntryDiffEvent" + } + + fn severity(&self) -> Severity { + Severity::Trace + } - let mut has_new_chunks = false; - let chunk_updates = chunks + fn message(&self) -> String { + self.to_string() + } + + fn to_json(&self) -> String { + serde_json::to_string(self).expect("server HMR entry diff event serializes") + } +} + +async fn diff_chunks_against( + chunk_lists: &[ServerHmrChunkList], + from: &ServerHmrChunkListVersion, +) -> Result { + let current_chunk_list_paths = chunk_lists .iter() - .filter_map(|HmrChunkWithContent { path, content }| { - let Some(prev) = from_aggregate.versions.get(path).cloned() else { - has_new_chunks = true; - return None; - }; - Some((path.clone(), *content, TraitRef::cell(prev))) - }) - .map(async |(path, content, prev)| { - let update = content.update(prev).await?; - anyhow::Ok((path, update)) + .map(|chunk_list| &chunk_list.relative_path) + .collect::>(); + let has_removed_chunk_lists = from + .versions_by_chunk_list_path + .keys() + .any(|path| !current_chunk_list_paths.contains(path)); + let mut has_new_chunk_lists = false; + let chunk_updates = chunk_lists + .iter() + .filter_map( + |ServerHmrChunkList { + relative_path, + versioned_content, + }| { + if *TRACE_DIFFING { + turbo_tasks().send_compilation_event(Arc::new(ServerHmrChunkListDiffEvent { + chunk_list_path: relative_path.clone(), + })); + } + let Some(prev) = from.versions_by_chunk_list_path.get(relative_path).cloned() + else { + has_new_chunk_lists = true; + return None; + }; + Some((*versioned_content, prev)) + }, + ) + .map(|(content, prev)| async move { + compute_update_from_version_operation( + content, + turbo_tasks::TransientInstance::new(prev), + ) + .read_strongly_consistent() + .await }) .try_join() .await?; Ok(DiffResult { chunk_updates, - has_new_chunks, + membership: ChunkListMembershipChange { + has_new: has_new_chunk_lists, + has_removed: has_removed_chunk_lists, + }, }) } +enum ServerHmrChunkUpdate<'a> { + None, + Missing, + Total, + Partial(&'a UpdateInstruction), +} + +impl<'a> From<&'a Update> for ServerHmrChunkUpdate<'a> { + fn from(update: &'a Update) -> Self { + match update { + Update::None => Self::None, + Update::Missing => Self::Missing, + Update::Total(_) => Self::Total, + Update::Partial(PartialUpdate { instruction, .. }) => Self::Partial(instruction), + } + } +} + +fn classify_server_hmr_update<'a>( + chunk_updates: impl IntoIterator>, + membership: ChunkListMembershipChange, + to: ReadRef, +) -> ServerHmrUpdate { + if membership.has_removed { + return ServerHmrUpdate::FullReevaluation { to }; + } + + let mut builder = ChunkListUpdateBuilder::default(); + for update in chunk_updates { + match update { + ServerHmrChunkUpdate::None => {} + ServerHmrChunkUpdate::Missing | ServerHmrChunkUpdate::Total => { + return ServerHmrUpdate::FullReevaluation { to }; + } + ServerHmrChunkUpdate::Partial(instruction) => builder.add_instruction(instruction), + } + } + + // New chunks load on demand but must advance the baseline. + if builder.is_empty() { + return ServerHmrUpdate::NoRuntimeUpdate { + to: membership.has_new.then_some(to), + }; + } + + ServerHmrUpdate::Partial { + to, + instruction: builder.build(), + } +} + +/// Kept outside Turbo Tasks so old pull baselines cannot reactivate. +pub async fn compute_server_hmr_update( + chunk_lists: &[ServerHmrChunkList], + from: Option<&ServerHmrChunkListVersion>, + to: ReadRef, +) -> Result { + if chunk_lists.is_empty() { + return Ok(ServerHmrUpdate::NoRuntimeUpdate { to: None }); + } + + let Some(from) = from else { + return Ok(ServerHmrUpdate::NoRuntimeUpdate { to: Some(to) }); + }; + + let DiffResult { + chunk_updates, + membership, + } = diff_chunks_against(chunk_lists, from).await?; + + Ok(classify_server_hmr_update( + chunk_updates + .iter() + .map(|update| ServerHmrChunkUpdate::from(&**update)), + membership, + to, + )) +} + #[cfg(test)] mod tests { - use turbo_tasks::{FxIndexMap, FxIndexSet}; + use turbo_tasks::{FxIndexMap, FxIndexSet, ReadRef}; use turbopack_core::update_instruction::UpdateInstruction; use turbopack_ecmascript::chunk_list::{ merged_update::{ @@ -217,7 +343,34 @@ mod tests { update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction}, }; - use super::ChunkListUpdateBuilder; + use super::{ + ChunkListMembershipChange, ChunkListUpdateBuilder, ServerHmrChunkListVersion, + ServerHmrChunkUpdate, ServerHmrUpdate, classify_server_hmr_update, + }; + + fn version() -> ReadRef { + ReadRef::new_owned(ServerHmrChunkListVersion { + versions_by_chunk_list_path: Default::default(), + }) + } + + fn unchanged_membership() -> ChunkListMembershipChange { + ChunkListMembershipChange::default() + } + + fn added_chunk_lists() -> ChunkListMembershipChange { + ChunkListMembershipChange { + has_new: true, + has_removed: false, + } + } + + fn removed_chunk_lists() -> ChunkListMembershipChange { + ChunkListMembershipChange { + has_new: false, + has_removed: true, + } + } fn merged(chunk_path: &str) -> EcmascriptMergedUpdate { EcmascriptMergedUpdate { @@ -233,6 +386,88 @@ mod tests { } } + #[test] + fn unchanged_chunks_produce_no_runtime_update() { + assert!(matches!( + classify_server_hmr_update( + [ServerHmrChunkUpdate::None], + unchanged_membership(), + version() + ), + ServerHmrUpdate::NoRuntimeUpdate { to: None } + )); + } + + #[test] + fn missing_chunk_produces_full_reevaluation() { + assert!(matches!( + classify_server_hmr_update( + [ServerHmrChunkUpdate::Missing], + unchanged_membership(), + version() + ), + ServerHmrUpdate::FullReevaluation { .. } + )); + } + + #[test] + fn total_update_produces_full_reevaluation() { + assert!(matches!( + classify_server_hmr_update( + [ServerHmrChunkUpdate::Total], + unchanged_membership(), + version() + ), + ServerHmrUpdate::FullReevaluation { .. } + )); + } + + #[test] + fn partial_instructions_are_combined() { + let chunk_list = ChunkListUpdate { + chunks: FxIndexMap::from_iter([("a.js".into(), ChunkUpdate::Added)]), + merged: vec![], + } + .into_instruction(); + let merged_instruction = + UpdateInstruction::new(EcmascriptUpdateInstruction::Merged(merged("b.js"))); + + let ServerHmrUpdate::Partial { instruction, .. } = classify_server_hmr_update( + [ + ServerHmrChunkUpdate::Partial(&chunk_list), + ServerHmrChunkUpdate::Partial(&merged_instruction), + ], + unchanged_membership(), + version(), + ) else { + panic!("partial instructions should produce a partial aggregate update"); + }; + let instruction = instruction + .downcast_ref::() + .expect("aggregate instruction is ECMAScript"); + let EcmascriptUpdateInstruction::ChunkList(update) = instruction else { + panic!("aggregate instruction should be a chunk-list update"); + }; + assert_eq!(update.chunks["a.js"], ChunkUpdate::Added); + assert_eq!(update.merged, [merged("b.js")]); + } + + #[test] + fn new_chunk_lists_advance_baseline_without_runtime_update() { + assert!(matches!( + classify_server_hmr_update([], added_chunk_lists(), version()), + ServerHmrUpdate::NoRuntimeUpdate { to: Some(_) } + )); + } + + #[test] + fn removed_chunk_lists_produce_full_reevaluation() { + assert!(matches!( + classify_server_hmr_update([], removed_chunk_lists(), version()), + ServerHmrUpdate::FullReevaluation { .. } + )); + } + #[test] fn deduplicates_merged_updates_in_first_seen_order() { let first = merged("first.js"); diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index cb33963ff2dd..ad0afab16809 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -2168,14 +2168,26 @@ impl Endpoint for AppEndpoint { }; let written_endpoint = match *output.await? { - AppEndpointOutput::NodeJs { rsc_chunk, .. } => EndpointOutputPaths::NodeJs { - server_entry_path: node_root + AppEndpointOutput::NodeJs { rsc_chunk, .. } => { + let server_entry_path: RcStr = node_root .get_path_to(&*rsc_chunk.path().await?) .context("Node.js chunk entry path must be inside the node root")? - .into(), - server_paths, - client_paths, - }, + .into(); + let hmr_entry_path = server_entry_path + .strip_prefix("server/app/") + .unwrap_or(&server_entry_path) + .strip_suffix(".js") + .unwrap_or(&server_entry_path); + EndpointOutputPaths::NodeJs { + server_hmr_entry_paths: vec![ + format!("{hmr_entry_path}.js").into(), + format!("{hmr_entry_path}/client-components-ssr.js").into(), + ], + server_entry_path, + server_paths, + client_paths, + } + } AppEndpointOutput::Edge { .. } => EndpointOutputPaths::Edge { server_paths, client_paths, diff --git a/crates/next-api/src/lib.rs b/crates/next-api/src/lib.rs index f4e3a9d48d3a..a98f840ec964 100644 --- a/crates/next-api/src/lib.rs +++ b/crates/next-api/src/lib.rs @@ -2,7 +2,7 @@ #![feature(arbitrary_self_types_pointers)] #![feature(impl_trait_in_assoc_type)] -mod aggregate_hmr; +pub mod aggregate_hmr; pub mod analyze; mod app; mod asset_hashes_manifest; diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index bff0e3dd6616..cccd703dbbf7 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -1736,6 +1736,7 @@ impl Endpoint for PageEndpoint { EndpointOutputPaths::NodeJs { server_entry_path, + server_hmr_entry_paths: vec![], server_paths, client_paths, } diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 20b8200c0a67..631a6c91a99d 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -84,8 +84,7 @@ use turbopack_core::{ reference_type::{CommonJsReferenceSubType, ReferenceType}, resolve::{FindContextFileResult, find_context_file}, version::{ - NotFoundVersion, OptionVersionedContent, PartialUpdate, TotalUpdate, Update, Version, - VersionState, VersionedContent, + NotFoundVersion, OptionVersionedContent, Update, Version, VersionState, VersionedContent, }, }; #[cfg(all(feature = "process_pool", not(target_family = "wasm")))] @@ -96,7 +95,7 @@ use turbopack_node::worker_threads_backend; use turbopack_nodejs::{NodeJsChunkingContext, fs::NodeModulesPathMatcher}; use crate::{ - aggregate_hmr::{AggregateHmrVersion, ChunkListUpdateBuilder, DiffResult, diff_chunks_against}, + aggregate_hmr::ServerHmrChunkLists, app::{AppProject, OptionAppProject}, empty::EmptyEndpoint, entrypoints::Entrypoints, @@ -2538,98 +2537,25 @@ impl Project { } } - /// Aggregate counterpart to [`Self::hmr_version_state`]: one [`VersionState`] - /// covering every server HMR-eligible chunk. See [`Self::server_hmr_update`]. - #[turbo_tasks::function(session_dependent)] - pub async fn server_hmr_version_state(self: ResolvedVc) -> Result> { - #[tracing::instrument(level = "info", name = "get server HMR version", skip_all)] - #[turbo_tasks::function(operation, root)] - async fn server_hmr_version_operation( - this: ResolvedVc, - ) -> Result>> { - let Some(map) = this.await?.versioned_content_map else { - bail!("must be in dev mode to hmr") - }; - let root = this.server_hmr_root_path().owned().await?; - AggregateHmrVersion::from_map(*map, root).await - } - let version_op = server_hmr_version_operation(self); - - // INVALIDATION: untracked initial read; the subscription drives invalidation. - let state = VersionState::new( - version_op - .read_trait_strongly_consistent() - .untracked() - .await?, - ) - .await?; - Ok(state) - } - - /// Aggregate counterpart to [`Self::hmr_update`]: a single `Update` whose - /// combined `ChunkListUpdate` is the union of the server entry chunk diffs. - /// - /// Each tracked entry chunk's own update is a `ChunkListUpdate` (carrying - /// the module deltas for its shared chunks via the merger) or a bare - /// `EcmascriptMergedUpdate`; both are folded into one `ChunkListUpdate` that - /// the runtime applies exactly as it would a single chunk list. - /// - /// All-or-nothing restart: any chunk needing `Total`/`Missing` escalates - /// the whole batch to `Total` (the runtime can't partially restart). New - /// chunks absent from `from` are skipped; the runtime require()s them on - /// demand. + /// Server entry chunks shared by all pull baselines. #[turbo_tasks::function] - pub async fn server_hmr_update(self: Vc, from: Vc) -> Result> { + pub async fn server_hmr_chunks(self: Vc) -> Result> { let Some(map) = self.await?.versioned_content_map else { bail!("must be in dev mode to hmr") }; let root = self.server_hmr_root_path().owned().await?; - let chunks_versioned_content = map.hmr_chunks_in_path(root).await?; - - // No chunks to diff yet (e.g. before any endpoints have been written). - if chunks_versioned_content.is_empty() { - return Ok(Update::None.cell()); - } - - // Build `to` up front so we can return it on every escape hatch below. - let to_aggregate = AggregateHmrVersion::from_chunks(&chunks_versioned_content).await?; - let to_ref = Vc::upcast::>(to_aggregate) - .into_trait_ref() - .await?; - - let DiffResult { - chunk_updates, - has_new_chunks, - } = diff_chunks_against(&chunks_versioned_content, from).await?; - - // Nothing to apply, but `from` still needs to advance to `to`. Reaching - // here means `from` held a version we couldn't diff against (it wasn't an - // `AggregateHmrVersion`), so `diff_chunks_against` gave up and returned - // nothing. An empty `Partial` moves the subscription's state forward so - // the *next* change produces a real diff; returning `Total` instead would - // force a needless full re-evaluation. - if chunk_updates.is_empty() && !has_new_chunks { - return Ok(ChunkListUpdateBuilder::default().build(to_ref).cell()); - } - - let mut builder = ChunkListUpdateBuilder::default(); - for (_path, update) in chunk_updates { - match &*update { - Update::None => {} - Update::Missing | Update::Total(_) => { - return Ok(Update::Total(TotalUpdate { to: to_ref }).cell()); - } - Update::Partial(PartialUpdate { instruction, .. }) => { - builder.add_instruction(instruction); - } - } - } - - if builder.is_empty() && !has_new_chunks { - return Ok(Update::None.cell()); - } + Ok(map.server_hmr_chunks_in_path(root)) + } - Ok(builder.build(to_ref).cell()) + #[turbo_tasks::function] + pub async fn server_hmr_chunks_for_entries( + self: Vc, + entry_paths: Vec, + ) -> Result> { + let mut chunk_lists = + ServerHmrChunkLists::new(self.server_hmr_chunks().await?.as_slice().to_vec()); + chunk_lists.retain_entry_paths(&entry_paths.into_iter().collect()); + Ok(chunk_lists.cell()) } /// Gets a list of all client HMR chunk names that can be subscribed to. diff --git a/crates/next-api/src/route.rs b/crates/next-api/src/route.rs index 2aecbb5ce807..60017b70ab4b 100644 --- a/crates/next-api/src/route.rs +++ b/crates/next-api/src/route.rs @@ -294,6 +294,7 @@ pub enum EndpointOutputPaths { NodeJs { /// Relative to the root_path server_entry_path: RcStr, + server_hmr_entry_paths: Vec, server_paths: Vec, client_paths: Vec, }, diff --git a/crates/next-api/src/versioned_content_map.rs b/crates/next-api/src/versioned_content_map.rs index 863621d28830..986db4fc2cb9 100644 --- a/crates/next-api/src/versioned_content_map.rs +++ b/crates/next-api/src/versioned_content_map.rs @@ -14,10 +14,9 @@ use turbopack_core::{ source_map::GenerateSourceMap, version::OptionVersionedContent, }; +use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::EcmascriptBuildNodeChunkListContent; -use crate::aggregate_hmr::{ - HmrChunkWithContent, HmrChunksWithContent, is_entry_chunk_list_content, -}; +use crate::aggregate_hmr::{ServerHmrChunkList, ServerHmrChunkLists}; #[derive( Clone, TraceRawVcs, PartialEq, Eq, ValueDebugFormat, Debug, NonLocalValue, Encode, Decode, @@ -94,8 +93,8 @@ impl VersionedContentMap { #[turbo_tasks::value_impl] impl VersionedContentMap { /// Lists the aggregate-HMR *entry* chunks under `root` with their - /// [`VersionedContent`], sorted by path. Only entry-chunk-list content is - /// returned (see [`is_entry_chunk_list_content`]). Callers scope which + /// [`VersionedContent`], sorted by path. Only Node.js chunk-list content is + /// returned. Callers scope which /// entries are included by narrowing `root` (e.g. the aggregate server-HMR /// subscription passes `server/app` to include App Router entries only). /// @@ -106,10 +105,10 @@ impl VersionedContentMap { /// can shift the internals of the map, making iteration order different /// for the same set of paths. #[turbo_tasks::function(session_dependent)] - pub async fn hmr_chunks_in_path( + pub async fn server_hmr_chunks_in_path( self: Vc, root: FileSystemPath, - ) -> Result> { + ) -> Result> { let this = self.await?; // `State::get` returns a lock guard, which can't be held across the // awaits below, so snapshot the keys and release it. @@ -138,18 +137,20 @@ impl VersionedContentMap { // *Important*: only chunk lists are subscribed to. Individual chunks are already // covered by the chunk list that owns them, so including them here // would produce duplicate updates for the same change. - if !is_entry_chunk_list_content(content) { + let Some(versioned_content) = + ResolvedVc::try_downcast_type::(content) + else { return Ok(None); - } + }; - Ok(Some(HmrChunkWithContent { - path: name, - content, + Ok(Some(ServerHmrChunkList { + relative_path: name, + versioned_content, })) }) .try_flat_join() .await?; - chunks.sort_by(|a, b| a.path.cmp(&b.path)); + chunks.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); Ok(Vc::cell(chunks)) } diff --git a/crates/next-napi-bindings/src/next_api/endpoint.rs b/crates/next-napi-bindings/src/next_api/endpoint.rs index a178160698e2..a417f65ae41e 100644 --- a/crates/next-napi-bindings/src/next_api/endpoint.rs +++ b/crates/next-napi-bindings/src/next_api/endpoint.rs @@ -52,6 +52,7 @@ impl From for NapiAssetPath { pub struct NapiWrittenEndpoint { pub r#type: String, pub entry_path: Option, + pub server_hmr_entry_paths: Vec, pub client_paths: Vec, pub server_paths: Vec, pub config: NapiEndpointConfig, @@ -62,11 +63,16 @@ impl From> for NapiWrittenEndpoint { match written_endpoint { Some(EndpointOutputPaths::NodeJs { server_entry_path, + server_hmr_entry_paths, server_paths, client_paths, }) => Self { r#type: "nodejs".to_string(), entry_path: Some(server_entry_path.into_owned()), + server_hmr_entry_paths: server_hmr_entry_paths + .into_iter() + .map(String::from) + .collect(), client_paths: client_paths.into_iter().map(From::from).collect(), server_paths: server_paths.into_iter().map(From::from).collect(), ..Default::default() diff --git a/crates/next-napi-bindings/src/next_api/project.rs b/crates/next-napi-bindings/src/next_api/project.rs index a7888dbae2f9..e77462d9ecd3 100644 --- a/crates/next-napi-bindings/src/next_api/project.rs +++ b/crates/next-napi-bindings/src/next_api/project.rs @@ -19,6 +19,9 @@ use napi::{ }; use napi_derive::napi; use next_api::{ + aggregate_hmr::{ + ServerHmrChunkListVersion, ServerHmrChunkLists, ServerHmrUpdate, compute_server_hmr_update, + }, entrypoints::Entrypoints, next_server_nft::next_server_nft_assets, operation::{ @@ -1854,124 +1857,147 @@ async fn hmr_update_with_issues_operation( .cell()) } -/// Aggregate counterpart to [`project_hmr_update_operation`]. +#[turbo_tasks::value(serialization = "skip")] +struct ServerHmrSnapshotWithEffects { + chunk_lists: ReadRef, + version: ReadRef, + issues: Arc>>, + effects: Arc, +} + +#[turbo_tasks::value(serialization = "skip")] +struct ServerHmrSnapshot { + chunk_lists: ReadRef, + version: ReadRef, +} + #[turbo_tasks::function(operation, root)] -fn project_server_hmr_update_operation( +async fn project_server_hmr_snapshot_operation( project: ResolvedVc, - state: ResolvedVc, -) -> Vc { - project.server_hmr_update(*state) + entry_paths: Vec, +) -> Result> { + let chunk_lists = project.server_hmr_chunks_for_entries(entry_paths).await?; + let version = ServerHmrChunkListVersion::from_chunk_lists(chunk_lists.as_slice()) + .await? + .cell() + .await?; + Ok(ServerHmrSnapshot { + chunk_lists, + version, + } + .cell()) } -/// Aggregate counterpart to [`hmr_update_with_issues_operation`]. -#[tracing::instrument(level = "info", name = "server hmr subscription", skip_all)] +/// Snapshot only; diffing here would keep old baselines active. +#[tracing::instrument(level = "info", name = "server hmr snapshot", skip_all)] #[turbo_tasks::function(operation, root)] -async fn server_hmr_update_with_issues_operation( +async fn server_hmr_snapshot_with_effects_operation( project: ResolvedVc, - state: ResolvedVc, -) -> Result> { - tracing::info!("server hmr subscription"); - let update_op = project_server_hmr_update_operation(project, state); - // See `hmr_update_with_issues_operation`: the JS consumer relies on this - // read *throwing* on build-graph failures; don't swallow errors. - let update = update_op + entry_paths: Vec, +) -> Result> { + tracing::info!("server hmr snapshot"); + let snapshot_op = project_server_hmr_snapshot_operation(project, entry_paths); + // Build-graph failures must reach the JS recovery path. + let snapshot = snapshot_op .read_strongly_consistent() .final_read_hint() .await?; let filter = project.issue_filter().await?; - let issues = get_issues(update_op, &filter).await?; - let effects = Arc::new(take_effects(update_op).await?); - Ok(HmrUpdateWithIssues { - update, + let issues = get_issues(snapshot_op, &filter).await?; + let effects = Arc::new(take_effects(snapshot_op).await?); + Ok(ServerHmrSnapshotWithEffects { + chunk_lists: snapshot.chunk_lists.clone(), + version: snapshot.version.clone(), issues, effects, } .cell()) } -#[tracing::instrument( - level = "info", - name = "get server HMR events", - skip(env, project, func) -)] -#[napi(ts_return_type = "{ __napiType: \"RootTask\" }")] -pub fn project_server_hmr_events( - env: Env, +pub struct ServerHmrVersion(ReadRef); + +#[napi(object, object_from_js = false)] +pub struct NapiServerHmrUpdate { + #[napi(ts_type = "\"none\" | \"partial\" | \"restart\"")] + pub kind: String, + /// `unknown` forces the TypeScript boundary to narrow the payload. + #[napi(ts_type = "unknown")] + pub instruction: Option, + pub version: Option>, +} + +impl NapiServerHmrUpdate { + /// Flattens the union for napi; `swc/types.ts` restores it. + fn new(update: &ServerHmrUpdate) -> Result { + let (kind, to, instruction) = match update { + ServerHmrUpdate::NoRuntimeUpdate { to } => ("none", to.as_ref(), None), + ServerHmrUpdate::FullReevaluation { to } => ("restart", Some(to), None), + ServerHmrUpdate::Partial { to, instruction } => { + ("partial", Some(to), Some(instruction)) + } + }; + + Ok(Self { + kind: kind.into(), + instruction: instruction.map(serde_json::to_value).transpose()?, + version: to.map(|to| External::new(ServerHmrVersion(to.clone()))), + }) + } +} + +#[tracing::instrument(level = "info", name = "get server HMR update", skip_all)] +#[napi] +pub async fn project_get_server_hmr_update( #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: &External, - #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] - func: FunctionRef>, ()>, -) -> napi::Result> { + from: Option<&External>, + entry_paths: Vec, +) -> napi::Result> { let container = project.container; - // Sentinel resource id for the aggregated stream (no real chunk path). - let identifier_path: RcStr = rcstr!("__next_all_hmr__"); - subscribe( - project.turbopack_ctx.clone(), - &env, - &func, - move || async move { + let turbo_tasks = project.turbopack_ctx.turbo_tasks(); + let from = from.map(|from| from.0.clone()); + + let (project, read) = turbo_tasks + .run(async move { // HACK(bgw): Remove this unmark call unmark_top_level_task_may_leak_eventually_consistent_state(); - let project = container.project().to_resolved().await?; - let state = project.server_hmr_version_state().to_resolved().await?; - - let update_op = server_hmr_update_with_issues_operation(project, state); - // HACK(bgw): Remove this mark call mark_top_level_task(); - + let snapshot_op = server_hmr_snapshot_with_effects_operation(project, entry_paths); let read = - read_strongly_consistent_and_apply_effects(update_op, |v| &v.effects).await?; - - // HACK(bgw): Remove this unmark call - unmark_top_level_task_may_leak_eventually_consistent_state(); - - let HmrUpdateWithIssues { update, issues, .. } = &*read; - match &**update { - Update::Missing | Update::None => {} - Update::Total(TotalUpdate { to }) => { - state.set(to.clone()).await?; - } - Update::Partial(PartialUpdate { to, .. }) => { - state.set(to.clone()).await?; - } - } - Ok((Some(update.clone()), issues.clone())) - }, - move |ctx| { - let (update, issues) = ctx.value; - - let napi_issues = issues - .iter() - .map(|issue| NapiIssue::from(&**issue)) - .collect(); - let update_issues = issues - .iter() - .map(|issue| Issue::from(&**issue)) - .collect::>(); + read_strongly_consistent_and_apply_effects(snapshot_op, |v| &v.effects).await?; + Ok((project, read)) + }) + .await + .map_err(|e| napi::Error::from_reason(PrettyPrintError(&e.into()).to_string()))?; - let identifier = ResourceIdentifier { - path: identifier_path.clone(), - headers: None, - }; - let update = match update.as_deref() { - None | Some(Update::Missing) | Some(Update::Total(_)) => { - ClientUpdateInstruction::restart(&identifier, &update_issues) - } - Some(Update::Partial(update)) => ClientUpdateInstruction::partial( - &identifier, - &update.instruction, - &update_issues, - ), - Some(Update::None) => ClientUpdateInstruction::issues(&identifier, &update_issues), - }; + // Diffing must not remain active after the pull completes. + let (update, issues) = turbo_tasks + .run(async move { + // The snapshot's chunk-list `Vc`s are only valid while the project is held. + let _project_keep_alive = project; + let ServerHmrSnapshotWithEffects { + chunk_lists, + version, + issues, + .. + } = &*read; + let update = + compute_server_hmr_update(chunk_lists.as_slice(), from.as_deref(), version.clone()) + .await?; + Ok::<_, anyhow::Error>((update, issues.clone())) + }) + .await + .map_err(|e| napi::Error::from_reason(PrettyPrintError(&e.into()).to_string()))?; - Ok(TurbopackResult { - result: ctx.env.to_js_value(&update)?, - issues: napi_issues, - }) - }, - ) + Ok(TurbopackResult { + result: NapiServerHmrUpdate::new(&update) + .map_err(|error| napi::Error::from_reason(PrettyPrintError(&error).to_string()))?, + issues: issues + .iter() + .map(|issue| NapiIssue::from(&**issue)) + .collect(), + }) } #[tracing::instrument(level = "info", name = "get client HMR events", skip(env, project, func), fields(chunk_name = %chunk_name))] diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index acea8b1fb86b..a2d37ee7d299 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -2,7 +2,6 @@ import type { CompilationEvent, - NodeJsHmrUpdate, TurbopackResult, Update, UpdateMessage, @@ -10,6 +9,7 @@ import type { export type TurboTasks = { readonly __tag: unique symbol } export type ExternalEndpoint = { readonly __tag: unique symbol } +export type ServerHmrVersion = { readonly __tag: unique symbol } export type NextTurboTasks = { readonly __tag: unique symbol } export type RefCell<_T = unknown> = { readonly __tag: unique symbol } export type FlushGuard = { readonly __tag: unique symbol } @@ -494,6 +494,13 @@ export interface NapiRoute { dataEndpoint?: ExternalObject } +export interface NapiServerHmrUpdate { + kind: 'none' | 'partial' | 'restart' + /** `unknown` forces the TypeScript boundary to narrow the payload. */ + instruction?: unknown + version?: ExternalObject +} + export interface NapiSource { ident: RcStr filePath: RcStr @@ -552,6 +559,7 @@ export interface NapiWatchOptions { export interface NapiWrittenEndpoint { type: string entryPath?: string + serverHmrEntryPaths: Array clientPaths: Array serverPaths: Array config: NapiEndpointConfig @@ -625,6 +633,12 @@ export declare function projectGetAllCompilationIssues(project: { __napiType: 'Project' }): Promise> +export declare function projectGetServerHmrUpdate( + project: { __napiType: 'Project' }, + from: ExternalObject | undefined | null, + entryPaths: Array +): Promise> + export declare function projectGetSourceForAsset( project: { __napiType: 'Project' }, filePath: RcStr @@ -664,11 +678,6 @@ export declare function projectOnExit(project: { __napiType: 'Project' }): Promise -export declare function projectServerHmrEvents( - project: { __napiType: 'Project' }, - func: (err: Error, value: TurbopackResult) => void -): { __napiType: 'RootTask' } - /** * Runs `project_on_exit`, and then waits for turbo_tasks to gracefully shut down. * diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index 0be00aa9e3f9..4c581bd81c67 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -28,12 +28,13 @@ import type { Endpoint, HmrChunkNames, Lockfile, - NodeJsHmrUpdate, PartialProjectOptions, Project, ProjectOptions, RawEntrypoints, Route, + ServerHmrUpdate, + ServerHmrVersion, TurboEngineOptions, TurbopackResult, TurbopackStackFrame, @@ -758,10 +759,16 @@ function bindingToApi( })() } - serverHmrEvents(): AsyncIterableIterator> { - return subscribe(true, async (callback) => - binding.projectServerHmrEvents(this._nativeProject, callback) - ) + async getServerHmrUpdate( + from: ServerHmrVersion | undefined, + entryPaths: string[] + ): Promise> { + // napi cannot express the field correlation. + return binding.projectGetServerHmrUpdate( + this._nativeProject, + from, + entryPaths + ) as Promise> } clientHmrEvents( diff --git a/packages/next/src/build/swc/types.ts b/packages/next/src/build/swc/types.ts index 0c15c33039de..569959182b01 100644 --- a/packages/next/src/build/swc/types.ts +++ b/packages/next/src/build/swc/types.ts @@ -13,6 +13,7 @@ import type { TraceQueryOptions, TraceQueryResult, MemoryEvictionMode, + ServerHmrVersion as NativeServerHmrVersion, } from './generated-native' export type { TraceServerHandle, TraceQueryOptions, TraceQueryResult } @@ -254,19 +255,24 @@ export interface NodeJsChunkListUpdate { chunks?: Record } -export interface NodeJsPartialHmrUpdate extends BaseUpdate { +/** In-process update; unlike wire updates, it has no resource or issues. */ +export interface NodeJsPartialHmrUpdate { type: 'partial' instruction: NodeJsEcmascriptMergedUpdate | NodeJsChunkListUpdate } -export interface NodeJsRestartHmrUpdate { - type: 'restart' -} +/** Opaque baseline for the next pull. */ +export type ServerHmrVersion = ExternalObject -export type NodeJsHmrUpdate = - | IssuesUpdate - | NodeJsPartialHmrUpdate - | NodeJsRestartHmrUpdate +/** Restores the union flattened by napi. */ +export type ServerHmrUpdate = + | { kind: 'none'; version?: ServerHmrVersion } + | { kind: 'restart'; version: ServerHmrVersion } + | { + kind: 'partial' + version: ServerHmrVersion + instruction: NodeJsPartialHmrUpdate['instruction'] + } export interface HmrChunkNames { /** Relative paths to output chunks that can receive HMR updates (e.g., "server/chunks/ssr/..._.js") */ @@ -334,7 +340,10 @@ export interface Project { TurbopackResult > - serverHmrEvents(): AsyncIterableIterator> + getServerHmrUpdate( + from: ServerHmrVersion | undefined, + entryPaths: string[] + ): Promise> clientHmrEvents( identifier: string @@ -444,6 +453,8 @@ export type WrittenEndpoint = type: 'nodejs' /** The entry path for the endpoint. */ entryPath: string + /** Server HMR entry chunk lists owned by this endpoint. */ + serverHmrEntryPaths: string[] /** All client paths that have been written for the endpoint. */ clientPaths: string[] /** All server paths that have been written for the endpoint. */ @@ -452,6 +463,7 @@ export type WrittenEndpoint = } | { type: 'edge' + serverHmrEntryPaths: [] /** All client paths that have been written for the endpoint. */ clientPaths: string[] /** All server paths that have been written for the endpoint. */ @@ -460,6 +472,7 @@ export type WrittenEndpoint = } | { type: 'none' + serverHmrEntryPaths: [] clientPaths: [] serverPaths: [] config: EndpointConfig diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 04f104a6eb12..8d9a50158a80 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -26,8 +26,8 @@ import type { TurbopackResult, Project, Entrypoints, - NodeJsHmrUpdate, NodeJsPartialHmrUpdate, + ServerHmrVersion, } from '../../build/swc/types' import { createDefineEnv, getBindingsSync } from '../../build/swc' import * as Log from '../../build/output/log' @@ -236,95 +236,103 @@ function setupServerHmr( onApplied: (chunkPaths: string[]) => void | Promise } ) { - async function runSubscription() { - const subscription = project.serverHmrEvents() - - // Subscribing immediately emits one event describing the current state. - // There's no previous state to diff it against, so it never carries anything - // to apply. Drop it; real updates start with the second event. - await subscription.next() - - for await (const result of subscription) { - const update = result as NodeJsHmrUpdate - - // A 'restart' from the wire protocol means the update can't be applied - // incrementally, so we must fully re-evaluate all chunks from disk. This - // clears the module cache and notifies browsers to refetch RSC. - const requiresFullReEvaluation = update.type === 'restart' - if (requiresFullReEvaluation) { - await reEvaluateAllModulesExpensive() - continue - } - - if (update.type !== 'partial') { - continue - } + let pending = Promise.resolve() + // Each pull snapshots only the requested endpoint's entries. Keep independent + // baselines so building one route does not discard another route's version. + const versions = new Map() + let needsReEvaluation = false - // `EcmascriptMergedUpdate` is the only instruction the Node.js runtime - // knows how to apply; `ChunkListUpdate` is browser-only. Anything else is - // unknown to us, so ignore it rather than evicting the module cache. - const instruction = update.instruction - if ( - !instruction || - (instruction.type !== 'EcmascriptMergedUpdate' && - instruction.type !== 'ChunkListUpdate') - ) { - throw new Error( - `[Server HMR] unreachable: unexpected update instruction type ${(instruction as { type: string }).type}` - ) - } + async function recover() { + try { + await reEvaluateAllModulesExpensive() + versions.clear() + needsReEvaluation = false + } catch (error) { + console.error('[Server HMR] Re-evaluating modules failed:', error) + } + } - // No handler registered yet (before first request, or right after - // reEvaluateAllModulesExpensive()) — nothing live to update, so skip - // until the next request. - const handlers = globalThis.__turbopack_server_hmr_handlers__ - if (!handlers || handlers.size === 0) { - continue + function apply(entryPaths: string[]): Promise { + const applyPromise = pending.then(async () => { + if (needsReEvaluation) { + await recover() + return } - if (typeof __turbopack_server_hmr_apply__ === 'function') { - try { - __turbopack_server_hmr_apply__(update) - // The validation worker keeps its own copy of the module graph, and - // applies the same update to it. - mirrorModuleStateToDevValidationWorker({ type: 'apply', update }) - } catch { - // A matching runtime tried the apply and threw. Evict require.cache - // so the next request loads fresh, then skip onApplied. (A no-match - // update is a no-op and does not throw.) - await reEvaluateAllModulesExpensive() - continue + try { + const versionKey = [...entryPaths].sort().join('\0') + // `issues` is intentionally dropped: this pull scans project-wide chunk + // lists, so its issues may belong to an unrelated or removed route, and + // endpoint writes already report route-scoped issues. + const update = await project.getServerHmrUpdate( + versions.get(versionKey), + entryPaths + ) + if (update.version) { + versions.set(versionKey, update.version) } + switch (update.kind) { + case 'none': + return + case 'partial': { + const handlers = globalThis.__turbopack_server_hmr_handlers__ + if (!handlers || handlers.size === 0) return - const updatedChunkPaths = collectUpdatedChunkPaths(instruction) - // An empty partial only advances the version state (e.g. the seed - // transition or a new endpoint); nothing changed on disk, so don't - // invalidate manifests or ping browsers to refetch RSC. - if (updatedChunkPaths.length > 0) { - await onApplied(updatedChunkPaths) + const payload: NodeJsPartialHmrUpdate = { + type: 'partial', + instruction: update.instruction, + } + if (typeof __turbopack_server_hmr_apply__ === 'function') { + try { + __turbopack_server_hmr_apply__(payload) + mirrorModuleStateToDevValidationWorker({ + type: 'apply', + update: payload, + }) + + const updatedChunkPaths = collectUpdatedChunkPaths( + update.instruction + ) + if (updatedChunkPaths.length > 0) { + await onApplied(updatedChunkPaths) + } + return + } catch {} + } + break + } + case 'restart': + break + default: + throw new Error( + `Unknown server HMR update kind: ${(update as { kind: string }).kind}` + ) } - } else { - await reEvaluateAllModulesExpensive() + } catch (error) { + console.error( + '[Server HMR] Update failed, re-evaluating modules:', + error + ) } - } + + needsReEvaluation = true + await recover() + }) + pending = applyPromise + return applyPromise } - // Start listening for changes in background. Re-subscribe on error so - // server Fast Refresh continues working for the rest of the dev session. - // The delay keeps a persistently-failing subscription (which throws on the - // initial read) from hot-looping through reEvaluateAllModulesExpensive(). - ;(async () => { - for (;;) { - try { - await runSubscription() - return - } catch (err) { - console.error('[Server HMR] Subscription error, resubscribing:', err) - await reEvaluateAllModulesExpensive() - await new Promise((resolve) => setTimeout(resolve, 1000)) - } + function reset(): Promise { + const resetState = () => { + versions.clear() + needsReEvaluation = false } - })() + const resetPromise = pending.then(resetState, resetState) + pending = resetPromise + return resetPromise + } + + return { apply, reset } } function getSourceMapFromTurbopack( @@ -531,6 +539,7 @@ export async function createHotReloaderTurbopack( 'SlowFilesystemEvent', 'FilesystemSettlingEvent', 'TraceEvent', + 'ServerHmrEntryDiffEvent', ], parentSpan: hotReloaderSpan, }) @@ -557,6 +566,7 @@ export async function createHotReloaderTurbopack( await project.onExit() await lockfile?.unlock() }) + // Subscription detects route additions/removals; returned endpoints stay lazy. const entrypointsSubscription = project.entrypointsSubscribe() const currentWrittenEntrypoints: Map = new Map() @@ -663,6 +673,18 @@ export async function createHotReloaderTurbopack( } }, 500) + // Server HMR supports App Router entries on the Node.js runtime. + function participatesInServerHmr( + key: EntryKey, + writtenEndpoint: WrittenEndpoint + ): boolean { + return ( + !!serverFastRefresh && + splitEntryKey(key).type === 'app' && + writtenEndpoint.type !== 'edge' + ) + } + function clearRequireCache( key: EntryKey, writtenEndpoint: WrittenEndpoint, @@ -734,15 +756,7 @@ export async function createHotReloaderTurbopack( join(distDir, p) ) - const { type: entryType } = splitEntryKey(key) - - // Server HMR applies to App Router entries built with the Turbopack Node.js - // runtime: app pages and route handlers (including metadata routes). Edge - // routes, Pages Router pages, and middleware/instrumentation are excluded. - const usesServerHmr = - serverFastRefresh && - entryType === 'app' && - writtenEndpoint.type !== 'edge' + const usesServerHmr = participatesInServerHmr(key, writtenEndpoint) const serverChunksPrefix = SERVER_HMR_CHUNKS_DIR + sep const filesToDelete: string[] = [] @@ -859,7 +873,13 @@ export async function createHotReloaderTurbopack( let updateInProgress = false let pendingServerComponentChanges = false - function sendServerComponentChanges() { + // Tell browsers to refetch RSC (soft refresh, not full page reload). + // Skip while there are outstanding compilation errors: an RSC refetch would + // 500 and force a full-page navigation, losing client state (e.g. recovering + // from a syntax error). A subsequent successful compile/apply fires this + // again to refresh. + function notifyServerComponentChanges() { + if (hasCompilationErrors()) return sendHmr('server-component-changes', { type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, }) @@ -871,7 +891,7 @@ export async function createHotReloaderTurbopack( if (updateInProgress) { pendingServerComponentChanges = true } else { - sendServerComponentChanges() + notifyServerComponentChanges() } } @@ -1828,11 +1848,15 @@ export async function createHotReloaderTurbopack( }, async invalidate({ reloadAfterInvalidation }) { if (reloadAfterInvalidation) { + await serverHmr?.reset() + for (const [key, entrypoint] of currentWrittenEntrypoints) { clearRequireCache(key, entrypoint, { force: true }) } await clearAllModuleContexts() + // Not `notifyServerComponentChanges`: an invalidation must announce even + // while errors stand, since it is what drops the stale graph. this.send({ type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, }) @@ -1985,6 +2009,11 @@ export async function createHotReloaderTurbopack( } const finishBuilding = startBuilding(pathname, requestUrl, false) + // Set by `handleWrittenEndpoint` below, so the pull is gated on the + // same predicate as the require-cache handling rather than a second, + // coarser reading of `route.type`. + let shouldPullServerHmr = false + let serverHmrEntryPaths: string[] = [] try { await handleRouteType({ dev, @@ -2009,13 +2038,22 @@ export async function createHotReloaderTurbopack( handleWrittenEndpoint: (id, result, forceDeleteCache) => { currentWrittenEntrypoints.set(id, result) assetMapper.setPathsForKey(id, result.clientPaths) + shouldPullServerHmr ||= participatesInServerHmr(id, result) + if (result.serverHmrEntryPaths.length > 0) { + serverHmrEntryPaths = result.serverHmrEntryPaths + } return clearRequireCache(id, result, { force: forceDeleteCache, }) }, - serverFastRefresh, }, }) + + // The only server HMR pull, driven by the request being built — which + // is what makes evaluating a changed module lazy. + if (shouldPullServerHmr && serverHmrEntryPaths.length > 0) { + await serverHmr?.apply(serverHmrEntryPaths) + } } finally { finishBuilding() // Remove non-deferred entry from building set @@ -2077,7 +2115,7 @@ export async function createHotReloaderTurbopack( pendingBuilding.cancel() if (pendingServerComponentChanges) { pendingServerComponentChanges = false - sendServerComponentChanges() + notifyServerComponentChanges() } sendEnqueuedMessages() @@ -2163,20 +2201,9 @@ export async function createHotReloaderTurbopack( process.exit(1) }) - // Tell browsers to refetch RSC (soft refresh, not full page reload). - // Skip while there are outstanding compilation errors: an RSC refetch would - // 500 and force a full-page navigation, losing client state (e.g. recovering - // from a syntax error). A subsequent successful compile/apply fires this - // again to refresh. - function notifyServerComponentChanges() { - if (hasCompilationErrors()) return - hotReloader.send({ - type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, - }) - } - + let serverHmr: ReturnType | undefined if (serverFastRefresh) { - setupServerHmr(project, { + serverHmr = setupServerHmr(project, { reEvaluateAllModulesExpensive: async () => { // Evict every server-HMR-managed chunk from `require.cache`. // Trailing `sep` so e.g. `server/chunks-other/...` doesn't match. @@ -2205,8 +2232,6 @@ export async function createHotReloaderTurbopack( // validation worker cannot repair its own either, so it is dropped and // the next validation loads the build output afresh. dropDevValidationWorker() - - notifyServerComponentChanges() }, onApplied: (chunkPaths: string[]) => { // Clear the evalManifest() shared cache for each updated chunk so the @@ -2229,8 +2254,6 @@ export async function createHotReloaderTurbopack( filePaths: manifestPaths, evictModules: false, }) - - notifyServerComponentChanges() }, }) } diff --git a/packages/next/src/server/dev/turbopack-utils.ts b/packages/next/src/server/dev/turbopack-utils.ts index 8f28c208e1ba..89ae683c6c07 100644 --- a/packages/next/src/server/dev/turbopack-utils.ts +++ b/packages/next/src/server/dev/turbopack-utils.ts @@ -142,14 +142,6 @@ type HandleRouteTypeHooks = { handleWrittenEndpoint: HandleWrittenEndpoint subscribeToChanges: StartChangeSubscription handleServerComponentChanges?: () => void - // When Turbopack server fast refresh is enabled, the aggregate server-HMR - // subscription (setupServerHmr `onApplied` in hot-reloader-turbopack.ts) - // owns the browser refresh signal for app-page RSC changes and only fires - // after the server module cache is refreshed. In that mode the per-page - // `rscHmrEndpoint` subscription must NOT also send SERVER_COMPONENT_CHANGES, - // or every edit triggers two RSC refetches (the first immediately - // superseded). - serverFastRefresh?: boolean } export async function handleRouteType({ @@ -373,21 +365,6 @@ export async function handleRouteType({ } // Report the next compilation again readyIds?.delete(pathname) - // When server fast refresh is enabled, the aggregate server-HMR - // subscription sends SERVER_COMPONENT_CHANGES after applying the - // update in-process. Sending here too would double the refresh. - // - // But the aggregate subscription only fires when there is a live - // server-HMR handler registered (i.e. the page has rendered at - // least once). When recovering from a build error the page never - // rendered, so no handler exists, the aggregate stays silent, and - // this per-page send is the only thing that clears the redbox. - // Only suppress when a handler is actually live to own the refresh. - const hasLiveServerHmrHandler = - (globalThis.__turbopack_server_hmr_handlers__?.size ?? 0) > 0 - if (hooks?.serverFastRefresh && hasLiveServerHmrHandler) { - return - } hooks?.handleServerComponentChanges?.() }, (e) => { diff --git a/test/development/app-dir/server-hmr/app/lazy-pages/0/page.tsx b/test/development/app-dir/server-hmr/app/lazy-pages/0/page.tsx new file mode 100644 index 000000000000..c8a0d80fb0be --- /dev/null +++ b/test/development/app-dir/server-hmr/app/lazy-pages/0/page.tsx @@ -0,0 +1,7 @@ +import { revision } from '../shared' + +console.log('lazy-server-hmr-page-0 evaluated') + +export default function Page() { + return

0: {revision}

+} diff --git a/test/development/app-dir/server-hmr/app/lazy-pages/1/page.tsx b/test/development/app-dir/server-hmr/app/lazy-pages/1/page.tsx new file mode 100644 index 000000000000..f7f4a42575a9 --- /dev/null +++ b/test/development/app-dir/server-hmr/app/lazy-pages/1/page.tsx @@ -0,0 +1,7 @@ +import { revision } from '../shared' + +console.log('lazy-server-hmr-page-1 evaluated') + +export default function Page() { + return

1: {revision}

+} diff --git a/test/development/app-dir/server-hmr/app/lazy-pages/2/page.tsx b/test/development/app-dir/server-hmr/app/lazy-pages/2/page.tsx new file mode 100644 index 000000000000..8a725b819858 --- /dev/null +++ b/test/development/app-dir/server-hmr/app/lazy-pages/2/page.tsx @@ -0,0 +1,7 @@ +import { revision } from '../shared' + +console.log('lazy-server-hmr-page-2 evaluated') + +export default function Page() { + return

2: {revision}

+} diff --git a/test/development/app-dir/server-hmr/app/lazy-pages/shared.ts b/test/development/app-dir/server-hmr/app/lazy-pages/shared.ts new file mode 100644 index 000000000000..89486a623524 --- /dev/null +++ b/test/development/app-dir/server-hmr/app/lazy-pages/shared.ts @@ -0,0 +1 @@ +export const revision = 'rev-0' diff --git a/test/development/app-dir/server-hmr/app/lazy-rebuild/page.tsx b/test/development/app-dir/server-hmr/app/lazy-rebuild/page.tsx new file mode 100644 index 000000000000..9783fadcfd47 --- /dev/null +++ b/test/development/app-dir/server-hmr/app/lazy-rebuild/page.tsx @@ -0,0 +1,5 @@ +import { value } from './probe' + +export default function Page() { + return

{value}

+} diff --git a/test/development/app-dir/server-hmr/app/lazy-rebuild/probe.js b/test/development/app-dir/server-hmr/app/lazy-rebuild/probe.js new file mode 100644 index 000000000000..f01754055968 --- /dev/null +++ b/test/development/app-dir/server-hmr/app/lazy-rebuild/probe.js @@ -0,0 +1,3 @@ +console.log('lazy-rebuild-probe evaluated') + +export const value = 'initial' diff --git a/test/development/app-dir/server-hmr/server-hmr.test.ts b/test/development/app-dir/server-hmr/server-hmr.test.ts index b9c6cef09f59..eca9570018f4 100644 --- a/test/development/app-dir/server-hmr/server-hmr.test.ts +++ b/test/development/app-dir/server-hmr/server-hmr.test.ts @@ -1,17 +1,105 @@ import type { Response } from 'node-fetch' import { join } from 'path' import { nextTestSetup, FileRef } from 'e2e-utils' -import { retry } from 'next-test-utils' +import { retry, waitFor } from 'next-test-utils' describe('server-hmr', () => { const { next, isTurbopack, isNextDev } = nextTestSetup({ files: __dirname, + env: { NEXT_TEST_SERVER_HMR_DIFFING: '1' }, }) // Server HMR is a Turbopack-only feature, only available in dev mode const itTurbopackDev = isTurbopack && isNextDev ? it : it.skip describe('module preservation', () => { + itTurbopackDev( + 'does not evaluate a changed server module until the next request', + async () => { + const browser = await next.browser('/lazy-rebuild') + await retry(async () => { + expect(await browser.elementByCss('#value').text()).toBe('initial') + }) + + await browser.eval(() => { + const originalFetch = window.fetch + window.fetch = (input, init) => { + const headers = new Headers(init?.headers) + if (headers.get('next-hmr-refresh') === '1') { + ;(window as any).__didBlockHmrRequest = true + return new Promise(() => {}) + } + return originalFetch(input, init) + } + }) + + const evaluationMarker = 'lazy-rebuild-probe evaluated' + const outputLengthBeforePatch = next.cliOutput.length + + await next.patchFile('app/lazy-rebuild/probe.js', (content) => + content.replace( + "export const value = 'initial'", + "export const value = 'updated'" + ) + ) + + await retry(async () => { + expect( + await browser.eval(() => (window as any).__didBlockHmrRequest) + ).toBe(true) + }) + expect(next.cliOutput.slice(outputLengthBeforePatch)).not.toContain( + evaluationMarker + ) + + const response = await next.fetch('/lazy-rebuild') + expect(await response.text()).toContain('updated') + await retry(async () => { + expect(next.cliOutput.slice(outputLengthBeforePatch)).toContain( + evaluationMarker + ) + }) + } + ) + + itTurbopackDev( + 'only evaluates the last visited page when a shared module changes', + async () => { + const browser = await next.browser('/lazy-pages/0') + for (let page = 1; page < 3; page++) { + await browser.loadPage(`${next.url}/lazy-pages/${page}`) + } + expect(await browser.elementByCss('#value').text()).toBe('2: rev-0') + + const outputLengthBeforePatch = next.cliOutput.length + await next.patchFile('app/lazy-pages/shared.ts', (content) => + content.replace( + "export const revision = 'rev-0'", + "export const revision = 'rev-1'" + ) + ) + + await retry(async () => { + expect(await browser.elementByCss('#value').text()).toBe('2: rev-1') + }) + await waitFor(1000) + + const outputAfterPatch = next.cliOutput.slice(outputLengthBeforePatch) + const evaluatedPages = Array.from( + outputAfterPatch.matchAll(/lazy-server-hmr-page-(\d+) evaluated/g), + (match) => Number(match[1]) + ) + expect(new Set(evaluatedPages)).toEqual(new Set([2])) + const diffedPages = Array.from( + outputAfterPatch.matchAll( + /Diffing server HMR entry lazy-pages\/(\d+)\/page/g + ), + (match) => Number(match[1]) + ) + expect(new Set(diffedPages)).toEqual(new Set([2])) + } + ) + itTurbopackDev( 'does not re-evaluate an unmodified module when page module changes', async () => { diff --git a/turbopack/crates/turbopack-ecmascript/src/chunk_list/version.rs b/turbopack/crates/turbopack-ecmascript/src/chunk_list/version.rs index 87104fd1c777..d090d7d8724a 100644 --- a/turbopack/crates/turbopack-ecmascript/src/chunk_list/version.rs +++ b/turbopack/crates/turbopack-ecmascript/src/chunk_list/version.rs @@ -15,8 +15,10 @@ type VersionTraitRef = TraitRef>; /// their versions are merged. Other chunks are tracked by path. /// /// [`MergeableVersionedContent`]: turbopack_core::version::MergeableVersionedContent -#[turbo_tasks::value(serialization = "skip", shared)] +#[turbo_tasks::value(serialization = "skip", shared, eq = "manual")] +#[derive(Debug)] pub struct ChunkListVersion { + pub id: RcStr, /// A map from chunk path to its version. #[turbo_tasks(trace_ignore)] pub by_path: FxIndexMap, @@ -29,50 +31,65 @@ pub struct ChunkListVersion { pub by_merger: FxIndexMap>, VersionTraitRef>, } +// `id` is a hash over every tracked chunk version, so comparing it is equivalent to comparing +// the maps, which hold `VersionTraitRef`s that are not structurally comparable. +impl PartialEq for ChunkListVersion { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for ChunkListVersion {} + #[turbo_tasks::value_impl] impl Version for ChunkListVersion { #[turbo_tasks::function] async fn id(&self) -> Result> { - let by_path = { - let mut by_path = self - .by_path - .iter() - .map(|(path, version)| (path, TraitRef::cell(version.clone()))) - .map(async |(path, version)| { - let id = version.id().owned().await?; - Ok((path, id)) - }) - .try_join() - .await?; - by_path.sort(); - by_path - }; - let by_merger = { - let mut by_merger = self - .by_merger - .iter() - .map(|(_merger, version)| TraitRef::cell(version.clone()).id().owned()) - .try_join() - .await?; - by_merger.sort(); - by_merger - }; - let mut hasher = Xxh3Hash64Hasher::new(); - hasher.write_value(by_path.len()); - for (path, id) in by_path { - hasher.write_value(path); - hasher.write_value(id); - } - hasher.write_value(by_merger.len()); - for id in by_merger { - hasher.write_value(id); - } - let hash = hasher.finish(); - let hash = encode_base64(hash); - Ok(Vc::cell(hash.into())) + Ok(Vc::cell(self.id.clone())) } } +async fn chunk_list_version_id( + by_path: &FxIndexMap, + by_merger: &FxIndexMap>, VersionTraitRef>, +) -> Result { + let by_path = { + let mut by_path = by_path + .iter() + .map(|(path, version)| (path, TraitRef::cell(version.clone()))) + .map(async |(path, version)| { + let id = version.id().owned().await?; + Ok((path, id)) + }) + .try_join() + .await?; + by_path.sort(); + by_path + }; + let by_merger = { + let mut by_merger = by_merger + .iter() + .map(|(_merger, version)| TraitRef::cell(version.clone()).id().owned()) + .try_join() + .await?; + by_merger.sort(); + by_merger + }; + let mut hasher = Xxh3Hash64Hasher::new(); + hasher.write_value(by_path.len()); + for (path, id) in by_path { + hasher.write_value(path); + hasher.write_value(id); + } + hasher.write_value(by_merger.len()); + for id in by_merger { + hasher.write_value(id); + } + let hash = hasher.finish(); + let hash = encode_base64(hash); + Ok(hash.into()) +} + /// Computes a [`ChunkListVersion`] from a map of chunk paths to their /// [`VersionedContent`]. /// @@ -115,5 +132,11 @@ pub async fn compute_chunk_list_version( .into_iter() .collect(); - Ok(ChunkListVersion { by_path, by_merger }.cell()) + let id = chunk_list_version_id(&by_path, &by_merger).await?; + Ok(ChunkListVersion { + id, + by_path, + by_merger, + } + .cell()) } diff --git a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs index ccdabf4177be..dcb67ced285a 100644 --- a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs +++ b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs @@ -1,5 +1,5 @@ use anyhow::{Result, bail}; -use turbo_tasks::{FxIndexMap, ResolvedVc, TryJoinIterExt, Vc}; +use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, TransientInstance, TryJoinIterExt, Vc}; use turbo_tasks_fs::FileSystemPath; use turbopack_core::{ asset::{Asset, AssetContent}, @@ -54,6 +54,20 @@ pub struct EcmascriptBuildNodeChunkListContent { #[turbo_tasks::value_impl] impl EcmascriptBuildNodeChunkListContent { + #[turbo_tasks::function] + pub async fn compute_update_from_version( + self: Vc, + from: TransientInstance>, + ) -> Result> { + let to = self.version(); + update_chunk_list( + &self.await?.chunks_contents, + to, + ResolvedVc::upcast(ReadRef::resolved_cell((*from).clone())), + ) + .await + } + #[turbo_tasks::function] pub async fn new( chunking_context: ResolvedVc, @@ -143,3 +157,11 @@ impl VersionedContent for EcmascriptBuildNodeChunkListContent { update_chunk_list(&this.chunks_contents, to_version, from_version).await } } + +#[turbo_tasks::function(operation, root)] +pub fn compute_update_from_version_operation( + content: ResolvedVc, + from: TransientInstance>, +) -> Vc { + content.compute_update_from_version(from) +} From b48cb742713ada58646cc0d0afda1a26f07ba967 Mon Sep 17 00:00:00 2001 From: Joseph Date: Mon, 31 Aug 2026 19:06:39 +0200 Subject: [PATCH 3/8] docs: document create-next-app Cache Components prompt and flag (#97798) Follow up to: Add Cache Components option to create-next-app - #97695 --- docs/01-app/01-getting-started/01-installation.mdx | 3 ++- docs/01-app/03-api-reference/06-cli/create-next-app.mdx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/01-app/01-getting-started/01-installation.mdx b/docs/01-app/01-getting-started/01-installation.mdx index e078332b06fe..4cfa318dbb4b 100644 --- a/docs/01-app/01-getting-started/01-installation.mdx +++ b/docs/01-app/01-getting-started/01-installation.mdx @@ -86,7 +86,7 @@ On installation, you'll see the following prompts: ```txt filename="Terminal" What is your project named? my-app Would you like to use the recommended Next.js defaults? - Yes, use recommended defaults - TypeScript, ESLint, Tailwind CSS, App Router, AGENTS.md + Yes, use recommended defaults - TypeScript, ESLint, No React Compiler, Tailwind CSS, No src/ directory, App Router, No Cache Components, AGENTS.md No, reuse previous settings No, customize settings - Choose your own preferences ``` @@ -100,6 +100,7 @@ Would you like to use React Compiler? No / Yes Would you like to use Tailwind CSS? No / Yes Would you like your code inside a `src/` directory? No / Yes Would you like to use App Router? (recommended) No / Yes +Would you like to use Cache Components? No / Yes Would you like to customize the import alias (`@/*` by default)? No / Yes What import alias would you like configured? @/* Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? No / Yes diff --git a/docs/01-app/03-api-reference/06-cli/create-next-app.mdx b/docs/01-app/03-api-reference/06-cli/create-next-app.mdx index 6bb3f01d1ede..116c69442d30 100644 --- a/docs/01-app/03-api-reference/06-cli/create-next-app.mdx +++ b/docs/01-app/03-api-reference/06-cli/create-next-app.mdx @@ -38,6 +38,7 @@ The following options are available: | `--js` or `--javascript` | Initialize as a JavaScript project | | `--tailwind` | Initialize with Tailwind CSS config (default) | | `--react-compiler` | Initialize with React Compiler enabled | +| `--cache-components` | Initialize with Cache Components enabled | | `--eslint` | Initialize with ESLint config | | `--biome` | Initialize with Biome config | | `--no-linter` | Skip linter configuration | @@ -87,7 +88,7 @@ On installation, you'll see the following prompts: ```txt filename="Terminal" What is your project named? my-app Would you like to use the recommended Next.js defaults? - Yes, use recommended defaults - TypeScript, ESLint, Tailwind CSS, App Router, AGENTS.md + Yes, use recommended defaults - TypeScript, ESLint, No React Compiler, Tailwind CSS, No src/ directory, App Router, No Cache Components, AGENTS.md No, reuse previous settings No, customize settings - Choose your own preferences ``` @@ -101,6 +102,7 @@ Would you like to use React Compiler? No / Yes Would you like to use Tailwind CSS? No / Yes Would you like your code inside a `src/` directory? No / Yes Would you like to use App Router? (recommended) No / Yes +Would you like to use Cache Components? No / Yes Would you like to customize the import alias (`@/*` by default)? No / Yes What import alias would you like configured? @/* Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? No / Yes From adba1e3f2ad65514937b3c824255d826c8b6b295 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Mon, 31 Aug 2026 19:15:43 +0200 Subject: [PATCH 4/8] [PPF] Pass in original searchParams for private-cache pages (#98041) The handing for page components marked with `use cache: private` was assuming that searchParams can never hang in `prerender-runtime`, so it passed the serialized `innerSearchParams` to the page, which we expected to just be a resolved promise. This *used to* be correct when runtime prerenders always had search params available, because the serialized search params were always equivalent to `outerSearchParams`. However, for `partialPrefetching` we started using `prerender-runtime` for runtime shells, where search params are not available, and if a private cache awaits them, it needs to abort filling and become dynamic, same as when it awaits params. So we need to trigger `dynamicAccessAbortController` when `searchParams` is awaited: ```tsx export default async function Page({ searchParams }) { 'use cache: private' // this should trigger `dynamicAccessAbortController.abort()`, so we need the original instrumented // searchParams object instead of the serialized one await searchParams } ``` This is fixed by using the outer searchParams object in the `isPageSegmentFunction` codepath of `use-cache-wrapper` -- we're preserving the instrumented promise, so the cache prerender will abort as expected. (before this fix, a shell prefetch of such a page would get a deserialized hanging promise for `searchParams` and hang until it times out) 2 of the 3 newly added tests ("params in a public cache" and "params in a private cache") were already passing, because `params` was already being preserved, but i'm adding them here to prevent regressions. The "search params in a private cache" one was failing due to the cache timing out, and is now passing. I'm not covering "search params in public cache" because that's an error and is already tested elsewhere. --- .../src/server/use-cache/use-cache-wrapper.ts | 26 ++- .../app/page.tsx | 15 ++ .../[slug]/loading.tsx | 3 + .../params-in-private-cache/[slug]/page.tsx | 13 ++ .../params-in-public-cache/[slug]/loading.tsx | 3 + .../params-in-public-cache/[slug]/page.tsx | 13 ++ .../loading.tsx | 3 + .../search-params-in-private-cache/page.tsx | 12 ++ .../next.config.ts | 5 + .../runtime-prerender-cache-warming.test.ts | 152 ++++++++++++++++++ 10 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-private-cache/[slug]/loading.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-private-cache/[slug]/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-public-cache/[slug]/loading.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-public-cache/[slug]/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/search-params-in-private-cache/loading.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/search-params-in-private-cache/page.tsx diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 8b270ba3d373..6a873503938a 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -2058,18 +2058,16 @@ export async function cache( args = [props, ...otherOuterArgs] fn = { - [name]: async ( - { - params: _innerParams, - searchParams: innerSearchParams, - }: UseCachePageInnerProps, - ...otherInnerArgs: unknown[] - ) => + [name]: async (_: UseCachePageInnerProps, ...otherInnerArgs: unknown[]) => originalFn.apply(null, [ { - params: outerParams, + params: props.params, searchParams: - innerSearchParams ?? + // Preserve the original search params, if this cache can access them. + // Notably, in a runtime shell private caches can resolve, but search params + // will be hanging, and we need to preserve the original proxied promise object + // to trigger `dynamicAccessAbortSignal` when they're accessed. + props.searchParams ?? // For public caches, search params are omitted from the cache // key (and the serialized args) to avoid mismatches between // prerendering and resuming a cached page that does not @@ -2164,13 +2162,9 @@ export async function cache( switch (workUnitStore.type) { case 'prerender-runtime': - // We're currently only using `dynamicAccessAsyncStorage` for params, - // which are always available in a runtime prerender, so they will never hang, - // effectively making the tracking below a no-op. - // However, a runtime prerender shares a lot of the semantics with a static prerender, - // and might need to follow this codepath in the future - // if we start using `dynamicAccessAsyncStorage` for other APIs. - // + // A runtime prerender may be a runtime shell, which does not have access to + // params/searchParams, so we want to apply the same dynamic access logic + // as we do in static prerenders. // fallthrough case 'prerender': if (!isPageOrLayoutSegmentFunction) { diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/page.tsx index 5db8656dc31a..556c207dd611 100644 --- a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/page.tsx @@ -29,6 +29,21 @@ export default function Page() { /lazy-data-in-prefetch (prefetch=true) +
  • + + /params-in-public-cache/1 + +
  • +
  • + + /params-in-private-cache/1 + +
  • +
  • + + /search-params-in-private-cache?foo=bar + +
  • ) diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-private-cache/[slug]/loading.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-private-cache/[slug]/loading.tsx new file mode 100644 index 000000000000..98536fcb7760 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-private-cache/[slug]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
    Loading page...
    +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-private-cache/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-private-cache/[slug]/page.tsx new file mode 100644 index 000000000000..a117eaa7c1f9 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-private-cache/[slug]/page.tsx @@ -0,0 +1,13 @@ +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + 'use cache: private' + const { slug } = await params + return ( +
    +

    {`Slug: ${slug}`}

    +
    + ) +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-public-cache/[slug]/loading.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-public-cache/[slug]/loading.tsx new file mode 100644 index 000000000000..98536fcb7760 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-public-cache/[slug]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
    Loading page...
    +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-public-cache/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-public-cache/[slug]/page.tsx new file mode 100644 index 000000000000..fe715c36cb37 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/params-in-public-cache/[slug]/page.tsx @@ -0,0 +1,13 @@ +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + 'use cache' + const { slug } = await params + return ( +
    +

    {`Slug: ${slug}`}

    +
    + ) +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/search-params-in-private-cache/loading.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/search-params-in-private-cache/loading.tsx new file mode 100644 index 000000000000..98536fcb7760 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/search-params-in-private-cache/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
    Loading page...
    +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/search-params-in-private-cache/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/search-params-in-private-cache/page.tsx new file mode 100644 index 000000000000..d702f96beb06 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/search-params-in-private-cache/page.tsx @@ -0,0 +1,12 @@ +export default async function Page({ + searchParams, +}: { + searchParams: Promise> +}) { + 'use cache: private' + return ( +
    + +
    + ) +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/next.config.ts b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/next.config.ts index df6f304ab895..fc859b37e57e 100644 --- a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/next.config.ts +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/next.config.ts @@ -3,6 +3,11 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, + experimental: { + // If a cache hangs, error quickly. + // (this is relevant for tests that validate caches with hanging inputs) + useCacheTimeout: 10, + }, } export default nextConfig diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts index ce8cd3b174a2..e7848034381b 100644 --- a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts @@ -234,4 +234,156 @@ describe('runtime prerender cache warming', () => { // New in this request. expect(navigationLogs).toContain('cachedFn :: after navigation') }) + + describe('hanging props in cached pages in runtime shells', () => { + it('a public-cache page that awaits params becomes dynamic during a runtime shell prerender', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + + // Load the shell for the page. + // It contains a public cache that reads params, which are not available in a shell. + await act(async () => { + await browser + .elementByCss( + 'input[data-link-accordion="/params-in-public-cache/1"]' + ) + .click() + }, [ + // The Shell should only the fallback for the page, not the page itself -- + // The cache entry should be aborted as dynamic during cache filling, + // otherwise it would hang and time out. + { + includes: 'page-loading', + kind: 'runtime', + }, + { + includes: 'Slug:', + block: 'reject', + }, + ]) + + expectNoCacheFillTimeout(next.cliOutput) + + // Navigate. + await act(async () => { + await browser + .elementByCss('a[href="/params-in-public-cache/1"]') + .click() + // The page is blocking, so we should show loading.tsx instead. + expect(await browser.elementByCss('#page-loading').text()).toEqual( + 'Loading page...' + ) + }) + + // After navigating, the full cache entry (including the param) should be visible. + expect(await browser.elementByCss('#slug').text()).toEqual('Slug: 1') + }) + + it('a private-cache page that awaits params becomes dynamic during a runtime shell prerender', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + + // Load the shell for the page. + // It contains a private cache that reads params, which are not available in a shell. + await act(async () => { + await browser + .elementByCss( + 'input[data-link-accordion="/params-in-private-cache/1"]' + ) + .click() + }, [ + // The Shell should only the fallback for the page, not the page itself -- + // The cache entry should be aborted as dynamic during cache filling, + // otherwise it would hang and time out. + { + includes: 'page-loading', + kind: 'runtime', + }, + { + includes: 'Slug:', + block: 'reject', + }, + ]) + + expectNoCacheFillTimeout(next.cliOutput) + + // Navigate. + await act(async () => { + await browser + .elementByCss('a[href="/params-in-private-cache/1"]') + .click() + // The page is blocking, so we should show loading.tsx instead. + expect(await browser.elementByCss('#page-loading').text()).toEqual( + 'Loading page...' + ) + }) + + // After navigating, the full cache entry (including the param) should be visible. + expect(await browser.elementByCss('#slug').text()).toEqual('Slug: 1') + }) + + it('a private-cache page that awaits search params becomes dynamic during a runtime shell prerender', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + + // Load the shell for the page. + // It contains a private cache that reads params, which are not available in a shell. + await act(async () => { + await browser + .elementByCss( + 'input[data-link-accordion="/search-params-in-private-cache?foo=bar"]' + ) + .click() + }, [ + // The Shell should only the fallback for the page, not the page itself -- + // The cache entry should be aborted as dynamic during cache filling, + // otherwise it would hang and time out. + { + includes: 'page-loading', + kind: 'runtime', + }, + { + includes: 'Search:', + block: 'reject', + }, + ]) + + expectNoCacheFillTimeout(next.cliOutput) + + // Navigate. + await act(async () => { + await browser + .elementByCss('a[href="/search-params-in-private-cache?foo=bar"]') + .click() + // The page is blocking, so we should show loading.tsx instead. + expect(await browser.elementByCss('#page-loading').text()).toEqual( + 'Loading page...' + ) + }) + + // After navigating, the full cache entry (including the search params) should be visible. + expect(await browser.elementByCss('#search').text()).toEqual( + `Search: ${JSON.stringify({ foo: 'bar' })}` + ) + }) + }) }) + +function expectNoCacheFillTimeout(cliOutput: string) { + expect(cliOutput).not.toContain('Filling a cache during prerender timed out') +} From 2c752da34d79dd5cf64d7df7cdac45b1a8d80e00 Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Mon, 31 Aug 2026 10:56:56 -0700 Subject: [PATCH 5/8] devlow: use commander for cli argument parsing (#93860) This has devlow use `commander` to parse its arguments. There's a breaking change here to make it fit better into `commander`'s patterns without extra code. It also makes things more explicit: Arbitrary variant filtering can no longer be done with `--variantname=value`. Now that there are more flags, these share the namespace with these, so instead we use `--filter variantname=value`, or `-F variantname=value`. Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Will Binns-Smith <755844+wbinnssmith@users.noreply.github.com> --- .github/workflows/build_and_test.yml | 10 +- packages/devlow-bench/README.md | 43 +++-- packages/devlow-bench/package.json | 1 + packages/devlow-bench/src/cli.ts | 272 +++++++++++++-------------- pnpm-lock.yaml | 9 + 5 files changed, 180 insertions(+), 155 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 2b15ed43c144..8e7d9289ea83 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -381,12 +381,12 @@ jobs: fail-fast: false matrix: mode: - - '--turbopack=false' - - '--turbopack=true' + - '-F turbopack=false' + - '-F turbopack=true' selector: - - '--scenario=heavy-npm-deps-dev --page=homepage' - - '--scenario=heavy-npm-deps-build --page=homepage' - - '--scenario=heavy-npm-deps-build-turbo-cache-enabled --page=homepage' + - '--scenario=heavy-npm-deps-dev -F page=homepage' + - '--scenario=heavy-npm-deps-build -F page=homepage' + - '--scenario=heavy-npm-deps-build-turbo-cache-enabled -F page=homepage' permissions: contents: read id-token: write diff --git a/packages/devlow-bench/README.md b/packages/devlow-bench/README.md index 672f7fa74c0d..12e4fb64831a 100644 --- a/packages/devlow-bench/README.md +++ b/packages/devlow-bench/README.md @@ -10,19 +10,36 @@ npm install devlow-bench ## Usage -```bash -Usage: devlow-bench [options] -## Selecting scenarios - --scenario=, -s= Only run the scenario with the given name - --interactive, -i Select scenarios and variants interactively - --= Filter by any variant property defined in scenarios -## Output - --json=, -j= Write the results to the given path as JSON - --console Print the results to the console - --datadog[=] Upload the results to Datadog - (requires DATADOG_API_KEY environment variables) -## Help - --help, -h, -? Show this help +```text +Usage: devlow-bench [options] [command] + +Run developer-workflow benchmarks. + +Options: + -h, --help display help for command + +Commands: + run [options] [scenarios...] Run scenario files and report measurements. + compare Compare two snapshot CSVs side-by-side. + help [command] display help for command +``` + +`run` is the default command, so scenario paths can still be passed without +writing `run` explicitly. Its options include: + +```text +-s, --scenario Only run scenarios whose name matches the filter (repeatable). +-F, --filter Filter variants by property: key=value (repeatable). +-i, --interactive Select scenarios and variants interactively. +--n Run each variant N times. +--warmup Discard the first N runs before sampling. +--snapshot Override the snapshot CSV path. +--compare Print a comparison table after the run. +--baseline Select a comparison baseline; implies --compare. +-j, --json Write results as JSON. +--no-console Suppress console output. +--datadog [host] Upload results to Datadog. +--snowflake [batchUri] Upload results to Snowflake. ``` ## Scenarios diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json index 44f17f67c562..278791a560a2 100644 --- a/packages/devlow-bench/package.json +++ b/packages/devlow-bench/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@datadog/datadog-api-client": "^1.13.0", + "commander": "^14.0.3", "inquirer": "^9.2.7", "jstat": "^1.9.6", "minimist": "^1.2.8", diff --git a/packages/devlow-bench/src/cli.ts b/packages/devlow-bench/src/cli.ts index 6298b98fa4a8..1e851d566bda 100644 --- a/packages/devlow-bench/src/cli.ts +++ b/packages/devlow-bench/src/cli.ts @@ -1,51 +1,105 @@ -import minimist from 'minimist' -import { setCurrentScenarios } from './describe.js' +import { Command } from 'commander' import { join } from 'path' +import { pathToFileURL } from 'url' +import { groupRows, printComparison } from './compare.js' +import { setCurrentScenarios } from './describe.js' import { type Scenario, type ScenarioVariant, runScenarios } from './index.js' import compose from './interfaces/compose.js' -import { groupRows, printComparison } from './compare.js' import { readSnapshot, resolveCompareTarget } from './snapshot.js' -import { pathToFileURL } from 'url' -const SUBCOMMANDS = new Set(['run', 'compare']) +interface RunOptions { + scenario?: string[] + filter?: string[] + interactive?: boolean + n?: number + warmup?: number + // Path override for the always-on snapshot CSV. + // undefined means the default path of .devlow-bench/snapshots/.csv + snapshot?: string + compare?: boolean + baseline?: string + json?: string + console?: boolean + datadog?: string | boolean + snowflake?: string | boolean +} ;(async () => { - // Subcommand dispatch. `devlow-bench run [opts] scenario.mjs` and - // `devlow-bench compare ` are the explicit forms. A bare - // `devlow-bench