Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 13 additions & 33 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ import { turbopackBuild } from './turbopack-build'
import { isFileSystemCacheEnabledForBuild } from '../shared/lib/turbopack/utils'
import { inlineStaticEnv } from '../lib/inline-static-env'
import { populateStaticEnv } from '../lib/static-env'
import { durationToString, hrtimeDurationToString } from './duration-to-string'
import { durationToString } from './duration-to-string'
import { traceGlobals } from '../trace/shared'
import { extractNextErrorCode } from '../lib/error-telemetry-utils'
import { runAfterProductionCompile } from './after-production-compile'
Expand Down Expand Up @@ -1876,7 +1876,6 @@ export default async function build(
traceMemoryUsage('Finished type checking', nextBuildSpan)
}

const collectingPageDataStart = process.hrtime()
const postCompileSpinner = createSpinner('Collecting page data')

const buildManifestPath = path.join(distDir, BUILD_MANIFEST)
Expand Down Expand Up @@ -2438,10 +2437,6 @@ export default async function build(
})

if (postCompileSpinner) {
const collectingPageDataEnd = process.hrtime(collectingPageDataStart)
postCompileSpinner.setText(
`Collecting page data in ${hrtimeDurationToString(collectingPageDataEnd)}`
)
postCompileSpinner.stopAndPersist()
}
traceMemoryUsage('Finished collecting page data', nextBuildSpan)
Expand Down Expand Up @@ -3950,15 +3945,6 @@ export default async function build(
.traceAsyncFn(() => writeManifest(routesManifestPath, routesManifest))
}

const finalizingPageOptimizationStart = process.hrtime()
const postBuildSpinner = createSpinner('Finalizing page optimization')
let buildTracesSpinner
let buildTracesStart
if (buildTracesPromise) {
buildTracesStart = process.hrtime()
buildTracesSpinner = createSpinner('Collecting build traces')
}

// ensure the worker is not left hanging
worker.end()

Expand Down Expand Up @@ -4104,17 +4090,15 @@ export default async function build(
})
}

let buildTracesSpinner
if (buildTracesPromise) {
buildTracesSpinner = createSpinner('Collecting build traces')
}

await buildTracesPromise

if (buildTracesSpinner) {
if (buildTracesStart) {
const buildTracesEnd = process.hrtime(buildTracesStart)
buildTracesSpinner.setText(
`Collecting build traces in ${hrtimeDurationToString(buildTracesEnd)}`
)
}
buildTracesSpinner.stopAndPersist()
buildTracesSpinner = undefined
}

if (isCompileMode) {
Expand All @@ -4124,6 +4108,7 @@ export default async function build(
}

if (config.output === 'export') {
const spinner = createSpinner('Outputting static export')
await nextBuildSpan
.traceChild('output-export-full-static-export')
.traceAsyncFn(async () => {
Expand All @@ -4136,13 +4121,16 @@ export default async function build(
appDirOnly
)
})

spinner.stopAndPersist()
}

// This should come after output: export handling but before
// output: standalone, in the future output: standalone might
// not be allowed if an adapter with onBuildComplete is configured
const adapterPath = config.experimental.adapterPath
if (adapterPath) {
const spinner = createSpinner('Finalizing build with adapter')
await nextBuildSpan
.traceChild('adapter-handle-build-complete')
.traceAsyncFn(async () => {
Expand All @@ -4167,9 +4155,11 @@ export default async function build(
requiredServerFiles: requiredServerFilesManifest.files,
})
})
spinner.stopAndPersist()
}

if (config.output === 'standalone') {
const spinner = createSpinner('Generating standalone output')
await nextBuildSpan
.traceChild('output-standalone')
.traceAsyncFn(async () => {
Expand All @@ -4188,19 +4178,9 @@ export default async function build(
appDir
)
})
spinner.stopAndPersist()
}

if (postBuildSpinner) {
const finalizingPageOptimizationEnd = process.hrtime(
finalizingPageOptimizationStart
)
postBuildSpinner.setText(
`Finalizing page optimization in ${hrtimeDurationToString(finalizingPageOptimizationEnd)}`
)
postBuildSpinner.stopAndPersist()
}
console.log()

if (debugOutput) {
nextBuildSpan
.traceChild('print-custom-routes')
Expand Down
21 changes: 5 additions & 16 deletions packages/next/src/build/progress.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import * as Log from '../build/output/log'
import { hrtimeDurationToString } from './duration-to-string'
import createSpinner from './spinner'

function divideSegments(number: number, segments: number): number[] {
Expand All @@ -16,8 +14,6 @@ function divideSegments(number: number, segments: number): number[] {
}

export const createProgress = (total: number, label: string) => {
const progressStart = process.hrtime()

const segments = divideSegments(total, 4)

if (total === 0) {
Expand Down Expand Up @@ -57,7 +53,7 @@ export const createProgress = (total: number, label: string) => {
// - per fully generated segment, or
// - per minute
// when not showing the spinner
if (!progressSpinner) {
if (!process.env.IS_TTY) {
currentSegmentCount++

if (currentSegmentCount === currentSegmentTotal) {
Expand All @@ -72,22 +68,15 @@ export const createProgress = (total: number, label: string) => {

const isFinished = curProgress === total
const message = `${label} (${curProgress}/${total})`
if (progressSpinner && !isFinished) {
progressSpinner.setText(message)
} else {
progressSpinner?.stop()
if (isFinished) {
const progressEnd = process.hrtime(progressStart)
Log.event(`${message} in ${hrtimeDurationToString(progressEnd)}`)
} else {
Log.info(`${message} ${process.stdout.isTTY ? '\n' : '\r'}`)
}
progressSpinner.setText(message)
if (isFinished) {
progressSpinner.stopAndPersist()
}
}

const clear = () => {
if (
progressSpinner &&
process.stdout.isTTY &&
// Ensure only reset and clear once to avoid set operation overflow in ora
progressSpinner.isSpinning
) {
Expand Down
59 changes: 38 additions & 21 deletions packages/next/src/build/spinner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ora from 'next/dist/compiled/ora'
import * as Log from './output/log'
import { hrtimeBigIntDurationToString } from './duration-to-string'

const dotsSpinner = {
frames: ['.', '..', '...'],
Expand All @@ -11,12 +12,11 @@ export default function createSpinner(
options: ora.Options = {},
logFn: (...data: any[]) => void = console.log
) {
let spinner: undefined | (ora.Ora & { setText: (text: string) => void })

let prefixText = ` ${Log.prefixes.info} ${text} `
const progressStart = process.hrtime.bigint()

if (process.stdout.isTTY) {
spinner = ora({
let spinner = ora({
text: undefined,
prefixText,
spinner: dotsSpinner,
Expand All @@ -35,16 +35,16 @@ export default function createSpinner(
const logHandle = (method: any, args: any[]) => {
// Enter a new line before logging new message, to avoid
// the new message shows up right after the spinner in the same line.
const isInProgress = spinner?.isSpinning
if (spinner && isInProgress) {
const isInProgress = spinner.isSpinning
if (isInProgress) {
// Reset the current running spinner to empty line by `\r`
spinner.prefixText = '\r'
spinner.text = '\r'
spinner.clear()
origStop()
}
method(...args)
if (spinner && isInProgress) {
if (isInProgress) {
spinner.start()
}
}
Expand All @@ -61,29 +61,46 @@ export default function createSpinner(
spinner.setText = (newText) => {
text = newText
prefixText = ` ${Log.prefixes.info} ${newText} `
spinner!.prefixText = prefixText
return spinner!
spinner.prefixText = prefixText
return spinner
}
spinner.stop = () => {
origStop()
resetLog()
return spinner!
return spinner
}
spinner.stopAndPersist = () => {
// Add \r at beginning to reset the current line of loading status text
const suffixText = `\r ${Log.prefixes.event} ${text} `
if (spinner) {
spinner.text = suffixText
} else {
logFn(suffixText)
}
const duration = process.hrtime.bigint() - progressStart
text = `${text} in ${hrtimeBigIntDurationToString(duration)}`
prefixText = ` ${Log.prefixes.info} ${text} `
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
prefixText = ` ${Log.prefixes.info} ${text} `
prefixText = ` ${Log.prefixes.event} ${text} `

The TTY spinner's stopAndPersist() method uses the wrong prefix when displaying the completion message, showing a space character (info prefix) instead of a checkmark (event prefix).

View Details

Analysis

Incorrect prefix in spinner stopAndPersist() shows space instead of checkmark

What fails: The TTY spinner's stopAndPersist() method in packages/next/src/build/spinner.ts (line 75) uses Log.prefixes.info (a space character) instead of Log.prefixes.event (a checkmark ✓) when displaying the completion message.

How to reproduce: The spinner is used during the build process when collecting build traces. When the build spinner completes, the message should display with a green checkmark prefix. Currently it displays with a space prefix instead. This is verified by the test expectations in test/e2e/app-dir/cache-components-errors/cache-components-console-patch.test.ts which expects: [<timestamp>] ✓ Collecting build traces in 3.1s

Result: The final spinner message displays Collecting build traces in 3.1s (with space prefix) instead of ✓ Collecting build traces in 3.1s (with checkmark prefix).

Expected: When stopAndPersist() is called on a completed spinner operation, it should use Log.prefixes.event (green checkmark) to indicate successful completion, consistent with the codebase patterns and test expectations defined in packages/next/src/build/output/log.ts.

Fix applied: Changed line 75 in packages/next/src/build/spinner.ts from Log.prefixes.info to Log.prefixes.event in the stopAndPersist() method's TTY branch.

spinner.prefixText = prefixText
origStopAndPersist()
resetLog()
return spinner!
return spinner
}
} else if (prefixText || text) {
logFn(prefixText ? prefixText + '...' : text)
}
return spinner
} else {
text = ` ${Log.prefixes.info} ${text} `
logFn(text)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logFn(text)
logFn(text + '...')

The non-TTY spinner output is missing the "..." suffix that indicates the operation is in progress, causing the output format to not match the test expectations and PR description.

View Details

Analysis

Missing "..." suffix in non-TTY spinner output

What fails: The non-TTY spinner mode in createSpinner() logs the operation message without the "..." suffix, causing it to not visually indicate that the operation is in progress.

How to reproduce:

import createSpinner from 'packages/next/src/build/spinner'

const outputs: string[] = [];
const spinner = createSpinner('Collecting build traces', {}, (text) => {
  outputs.push(text);
});

// In non-TTY environment:
// Expected: " ℹ Collecting build traces ..."
// Actual: " ℹ Collecting build traces "

Root cause: Commit 74df2f6 (fixup) removed the '...' suffix that was originally in line 83 of the non-TTY branch. The old code was logFn(prefixText ? prefixText + '...' : text) but was changed to logFn(text) without the dots.

Why this is a bug: In TTY mode, the spinner uses animated frames ['.', '..', '...'] to indicate progress. In non-TTY mode, there is no animation, so the static output should include the dots to maintain visual consistency and clearly indicate the operation is in progress. The original implementation (commit 79f9651) intentionally included the dots for this reason.

Expected behavior: Non-TTY output should display "Collecting build traces ..." with the dots appended to indicate the operation is in progress, matching the original intent and TTY mode behavior.

Fix applied: Append '...' to the logged text in non-TTY mode: logFn(text + '...') instead of logFn(text).


return spinner
// @ts-ignore
let spinner = {
isSpinning: false,
prefixText: '',
text: '',
clear: '',
setText(newText: string) {
text = ` ${Log.prefixes.info} ${newText}`
logFn(text)
},
stop: () => spinner,
stopAndPersist: () => {
const duration = process.hrtime.bigint() - progressStart
logFn(`${text} in ${hrtimeBigIntDurationToString(duration)}`)
return spinner!
},
} as ora.Ora & { setText: (text: string) => void }

return spinner
}
}
7 changes: 1 addition & 6 deletions packages/next/src/build/type-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Worker } from '../lib/worker'
import createSpinner from './spinner'
import { eventTypeCheckCompleted } from '../telemetry/events'
import isError from '../lib/is-error'
import { hrtimeDurationToString } from './duration-to-string'

/**
* typescript will be loaded in "next/lib/verify-typescript-setup" and
Expand Down Expand Up @@ -127,11 +126,7 @@ export async function startTypeChecking({
)

if (typeCheckingSpinner) {
typeCheckingSpinner.stop()

createSpinner(
`Finished TypeScript${ignoreTypeScriptErrors ? ' config validation' : ''} in ${hrtimeDurationToString(typeCheckEnd)}`
)?.stopAndPersist()
typeCheckingSpinner.stopAndPersist()
}

if (!ignoreTypeScriptErrors && verifyResult) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,16 @@ describe('Cache Components Errors', () => {
if (isTurbopack) {
expect(output).toMatchInlineSnapshot(`
"[<timestamp>] This is a console log from a server component page
[<timestamp>] This is a console log from a server component page
[<timestamp>]"
[<timestamp>] This is a console log from a server component page"
`)
} else {
expect(output).toMatchInlineSnapshot(`
"[<timestamp>] This is a console log from a server component page
[<timestamp>] This is a console log from a server component page
[<timestamp>] Collecting build traces ...
[<timestamp>]"
`)
"[<timestamp>] This is a console log from a server component page
[<timestamp>] This is a console log from a server component page
[<timestamp>] Collecting build traces ...
[<timestamp>] ✓ Collecting build traces in 3.1s
[<timestamp>]"
`)
}
})
}
Expand Down
4 changes: 2 additions & 2 deletions test/integration/polyfills/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ describe('Polyfills', () => {
it('should contain generated page count in output', async () => {
expect(output).toContain('Generating static pages (0/5)')
expect(output).toContain('Generating static pages (5/5)')
// we should only have 1 segment and the initial message logged out
expect(output.match(/Generating static pages/g).length).toBe(5)
// we should have 4 segments, the initial message, and the final one with time
expect(output.match(/Generating static pages/g).length).toBe(6)
})
}
)
Expand Down
43 changes: 23 additions & 20 deletions test/production/app-dir/build-output-debug/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,36 @@ describe('next build --debug', () => {
output = stripAnsi(next.cliOutput)
})

const str = `


Redirects
┌ source: /:path+/
├ destination: /:path+
└ permanent: true
it('should log Redirects above Route(app)', async () => {
let data = output
.slice(output.indexOf('Redirects\n'), output.indexOf('○ (Static)'))
.trim()

┌ source: /redirects
├ destination: /
└ permanent: true
expect(data).toMatchInlineSnapshot(`
"Redirects
┌ source: /:path+/
├ destination: /:path+
└ permanent: true

┌ source: /redirects
├ destination: /
└ permanent: true

Headers
┌ source: /
└ headers:
└ x-custom-headers: headers

Headers
┌ source: /
└ headers:
└ x-custom-headers: headers

Rewrites
┌ source: /rewrites
└ destination: /

Rewrites
┌ source: /rewrites
└ destination: /

Route (app)`

it('should log Redirects above Route(app)', async () => {
expect(output).toContain(str)
Route (app)
┌ ○ /
└ ○ /_not-found"
`)
})
})
Loading
Loading