diff --git a/crates/napi/src/next_api/project.rs b/crates/napi/src/next_api/project.rs index c949c85050bed..f6f41b020a13d 100644 --- a/crates/napi/src/next_api/project.rs +++ b/crates/napi/src/next_api/project.rs @@ -893,6 +893,10 @@ pub fn project_hmr_identifiers_subscribe( ) } +struct NapiUpdateInfoOpts { + include_reasons: bool, +} + enum UpdateMessage { Start, End(UpdateInfo), @@ -904,8 +908,8 @@ struct NapiUpdateMessage { pub value: Option, } -impl From for NapiUpdateMessage { - fn from(update_message: UpdateMessage) -> Self { +impl NapiUpdateMessage { + fn from_update_message(update_message: UpdateMessage, opts: NapiUpdateInfoOpts) -> Self { match update_message { UpdateMessage::Start => NapiUpdateMessage { update_type: "start".to_string(), @@ -913,7 +917,7 @@ impl From for NapiUpdateMessage { }, UpdateMessage::End(info) => NapiUpdateMessage { update_type: "end".to_string(), - value: Some(info.into()), + value: Some(NapiUpdateInfo::from_update_info(info, opts)), }, } } @@ -923,13 +927,27 @@ impl From for NapiUpdateMessage { struct NapiUpdateInfo { pub duration: u32, pub tasks: u32, + /// A human-readable list of invalidation reasons (typically changed file paths) if known. Will + /// be `None` if [`NapiUpdateInfoOpts::include_reasons`] is `false` or if no reason was + /// specified (not every invalidation includes a reason). + pub reasons: Option, } -impl From for NapiUpdateInfo { - fn from(update_info: UpdateInfo) -> Self { +impl NapiUpdateInfo { + fn from_update_info(update_info: UpdateInfo, opts: NapiUpdateInfoOpts) -> Self { Self { - duration: update_info.duration.as_millis() as u32, - tasks: update_info.tasks as u32, + // u32::MAX in milliseconds is 49.71 days + duration: update_info + .duration + .as_millis() + .try_into() + .expect("update duration in milliseconds should not exceed u32::MAX"), + tasks: update_info + .tasks + .try_into() + .expect("number of tasks should not exceed u32::MAX"), + reasons: (opts.include_reasons && !update_info.reasons.is_empty()) + .then(|| update_info.reasons.to_string()), } } } @@ -949,12 +967,17 @@ impl From for NapiUpdateInfo { pub fn project_update_info_subscribe( #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: External, aggregation_ms: u32, + include_reasons: bool, func: JsFunction, ) -> napi::Result<()> { - let func: ThreadsafeFunction = func.create_threadsafe_function(0, |ctx| { - let message = ctx.value; - Ok(vec![NapiUpdateMessage::from(message)]) - })?; + let func: ThreadsafeFunction = + func.create_threadsafe_function(0, move |ctx| { + let message = ctx.value; + Ok(vec![NapiUpdateMessage::from_update_message( + message, + NapiUpdateInfoOpts { include_reasons }, + )]) + })?; let turbo_tasks = project.turbo_tasks.clone(); tokio::spawn(async move { loop { diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index afd4742289fb6..a381dec9c09ca 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -246,6 +246,12 @@ export interface NapiUpdateMessage { export interface NapiUpdateInfo { duration: number tasks: number + /** + * A human-readable list of invalidation reasons (typically changed file paths) if known. Will + * be `None` if [`NapiUpdateInfoOpts::include_reasons`] is `false` or if no reason was + * specified (not every invalidation includes a reason). + */ + reasons?: string } /** * Subscribes to lifecycle events of the compilation. @@ -263,6 +269,7 @@ export interface NapiUpdateInfo { export function projectUpdateInfoSubscribe( project: { __napiType: 'Project' }, aggregationMs: number, + includeReasons: boolean, func: (...args: any[]) => any ): void export interface StackFrame { diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index da0abf7c7773a..fbfbb9d940c82 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -40,6 +40,7 @@ import type { TurbopackResult, TurbopackStackFrame, Update, + UpdateInfoOpts, UpdateMessage, WrittenEndpoint, } from './types' @@ -761,11 +762,12 @@ function bindingToApi( return binding.projectGetSourceMap(this._nativeProject, filePath) } - updateInfoSubscribe(aggregationMs: number) { + updateInfoSubscribe(opts: UpdateInfoOpts) { return subscribe>(true, async (callback) => binding.projectUpdateInfoSubscribe( this._nativeProject, - aggregationMs, + opts.aggregationMs, + opts.includeReasons ?? false, callback ) ) diff --git a/packages/next/src/build/swc/types.ts b/packages/next/src/build/swc/types.ts index a206b86a13991..8c6a4df0b0363 100644 --- a/packages/next/src/build/swc/types.ts +++ b/packages/next/src/build/swc/types.ts @@ -4,8 +4,11 @@ import type { ExternalObject, NextTurboTasks, RefCell, + NapiUpdateInfo as UpdateInfo, } from './generated-native' +export type { NapiUpdateInfo as UpdateInfo } from './generated-native' + export interface Binding { isWasm: boolean turbo: { @@ -195,9 +198,9 @@ export type UpdateMessage = value: UpdateInfo } -export interface UpdateInfo { - duration: number - tasks: number +export interface UpdateInfoOpts { + aggregationMs: number + includeReasons?: boolean } export interface Project { @@ -220,7 +223,7 @@ export interface Project { ): Promise updateInfoSubscribe( - aggregationMs: number + opts?: UpdateInfoOpts ): AsyncIterableIterator> shutdown(): Promise diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 591872617a6e6..aea9890c4742a 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -93,6 +93,7 @@ const isTestMode = !!( process.env.__NEXT_TEST_MODE || process.env.DEBUG ) +const includeUpdateReasons = !!process.env.NEXT_TURBOPACK_INCLUDE_UPDATE_REASONS const sessionId = Math.floor(Number.MAX_SAFE_INTEGER * Math.random()) @@ -1018,13 +1019,21 @@ export async function createHotReloaderTurbopack( }) async function handleProjectUpdates() { - for await (const updateMessage of project.updateInfoSubscribe(30)) { + for await (const updateMessage of project.updateInfoSubscribe({ + aggregationMs: 30, + includeReasons: includeUpdateReasons, + })) { switch (updateMessage.updateType) { case 'start': { hotReloader.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.BUILDING }) break } case 'end': { + if (updateMessage.value.reasons) { + // debug: only populated when `process.env.NEXT_TURBOPACK_INCLUDE_UPDATE_REASONS` is set + // and a reason was supplied when the invalidation happened (not always true) + console.log('[Update Reasons]', updateMessage.value.reasons) + } sendEnqueuedMessages() function addErrors(