diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 5e218507825f3..25391ee07c073 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2909,11 +2909,7 @@ export default async function build( ...(pageInfos.get(route.pathname) as PageInfo), hasPostponed, hasEmptyPrelude, - // TODO: Enable the following line to show "ISR" status in build - // output. Requires different presentation to also work for app - // router routes. - // See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH - // initialCacheControl: cacheControl, + initialCacheControl: cacheControl, }) // update the page (eg /blog/[slug]) to also have the postpone metadata @@ -2921,11 +2917,7 @@ export default async function build( ...(pageInfos.get(page) as PageInfo), hasPostponed, hasEmptyPrelude, - // TODO: Enable the following line to show "ISR" status in build - // output. Requires different presentation to also work for app - // router routes. - // See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH - // initialCacheControl: cacheControl, + initialCacheControl: cacheControl, }) if (cacheControl.revalidate !== 0) { diff --git a/packages/next/src/build/output/format.ts b/packages/next/src/build/output/format.ts new file mode 100644 index 0000000000000..dcf58e5f1d7af --- /dev/null +++ b/packages/next/src/build/output/format.ts @@ -0,0 +1,54 @@ +import type { CacheControl } from '../../server/lib/cache-control' + +const timeUnits = [ + { label: 'y', seconds: 31536000 }, + { label: 'w', seconds: 604800 }, + { label: 'd', seconds: 86400 }, + { label: 'h', seconds: 3600 }, + { label: 'm', seconds: 60 }, + { label: 's', seconds: 1 }, +] + +function humanReadableTimeRounded(seconds: number): string { + // Find the largest fitting unit. + let candidateIndex = timeUnits.length - 1 + for (let i = 0; i < timeUnits.length; i++) { + if (seconds >= timeUnits[i].seconds) { + candidateIndex = i + break + } + } + + const candidate = timeUnits[candidateIndex] + const value = seconds / candidate.seconds + const isExact = Number.isInteger(value) + + // For days and weeks only, check if using the next smaller unit yields an + // exact result. + if (!isExact && (candidate.label === 'd' || candidate.label === 'w')) { + const nextUnit = timeUnits[candidateIndex + 1] + const nextValue = seconds / nextUnit.seconds + + if (Number.isInteger(nextValue)) { + return `${nextValue}${nextUnit.label}` + } + } + + if (isExact) { + return `${value}${candidate.label}` + } + + return `≈${Math.round(value)}${candidate.label}` +} + +export function formatRevalidate(cacheControl: CacheControl): string { + const { revalidate } = cacheControl + + return revalidate ? humanReadableTimeRounded(revalidate) : '' +} + +export function formatExpire(cacheControl: CacheControl): string { + const { expire } = cacheControl + + return expire ? humanReadableTimeRounded(expire) : '' +} diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 36e6c25e8df85..1d01f03286db9 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -82,6 +82,7 @@ import { buildAppStaticPaths } from './static-paths/app' import { buildPagesStaticPaths } from './static-paths/pages' import type { PrerenderedRoute } from './static-paths/types' import type { CacheControl } from '../server/lib/cache-control' +import { formatExpire, formatRevalidate } from './output/format' export type ROUTER_TYPE = 'pages' | 'app' @@ -347,7 +348,6 @@ export interface PageInfo { */ isRoutePPREnabled: boolean ssgPageRoutes: string[] | null - // TODO: initialCacheControl should be set per prerendered route. initialCacheControl: CacheControl | undefined pageDuration: number | undefined ssgPageDurations: number[] | undefined @@ -447,7 +447,7 @@ export async function printTreeView( // Collect all the symbols we use so we can print the icons out. const usedSymbols = new Set() - const messages: [string, string, string][] = [] + const messages: [string, string, string, string, string][] = [] const stats = await computeFromManifest( { build: buildManifest, app: appBuildManifest }, @@ -468,12 +468,39 @@ export async function printTreeView( return } + let showRevalidate = false + let showExpire = false + + for (const page of filteredPages) { + const cacheControl = pageInfos.get(page)?.initialCacheControl + + if (cacheControl?.revalidate) { + showRevalidate = true + } + + if (cacheControl?.expire) { + showExpire = true + } + + if (showRevalidate && showExpire) { + break + } + } + messages.push( [ routerType === 'app' ? 'Route (app)' : 'Route (pages)', 'Size', 'First Load JS', - ].map((entry) => underline(entry)) as [string, string, string] + showRevalidate ? 'Revalidate' : '', + showExpire ? 'Expire' : '', + ].map((entry) => underline(entry)) as [ + string, + string, + string, + string, + string, + ] ) filteredPages.forEach((item, i, arr) => { @@ -522,16 +549,8 @@ export async function printTreeView( usedSymbols.add(symbol) - // TODO: Rework this to be usable for app router routes. - // See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH - if (pageInfo?.initialCacheControl?.revalidate) usedSymbols.add('ISR') - messages.push([ - `${border} ${symbol} ${ - pageInfo?.initialCacheControl?.revalidate - ? `${item} (ISR: ${pageInfo?.initialCacheControl.revalidate} Seconds)` - : item - }${ + `${border} ${symbol} ${item}${ totalDuration > MIN_DURATION ? ` (${getPrettyDuration(totalDuration)})` : '' @@ -550,6 +569,12 @@ export async function printTreeView( ? getPrettySize(pageInfo.totalSize, { strong: true }) : '' : '', + showRevalidate && pageInfo?.initialCacheControl + ? formatRevalidate(pageInfo.initialCacheControl) + : '', + showExpire && pageInfo?.initialCacheControl + ? formatExpire(pageInfo.initialCacheControl) + : '', ]) const uniqueCssFiles = @@ -569,6 +594,8 @@ export async function printTreeView( `${contSymbol} ${innerSymbol} ${getCleanName(file)}`, typeof size === 'number' ? getPrettySize(size) : '', '', + '', + '', ]) }) } @@ -623,6 +650,10 @@ export async function printTreeView( routes.forEach( ({ route, duration, avgDuration }, index, { length }) => { const innerSymbol = index === length - 1 ? '└' : '├' + + const initialCacheControl = + pageInfos.get(route)?.initialCacheControl + messages.push([ `${contSymbol} ${innerSymbol} ${route}${ duration > MIN_DURATION @@ -635,6 +666,12 @@ export async function printTreeView( }`, '', '', + showRevalidate && initialCacheControl + ? formatRevalidate(initialCacheControl) + : '', + showExpire && initialCacheControl + ? formatExpire(initialCacheControl) + : '', ]) } ) @@ -653,6 +690,8 @@ export async function printTreeView( ? getPrettySize(sharedFilesSize, { strong: true }) : '', '', + '', + '', ]) const sharedCssFiles: string[] = [] const sharedJsChunks = [ @@ -686,7 +725,13 @@ export async function printTreeView( return } - messages.push([` ${innerSymbol} ${cleanName}`, getPrettySize(size), '']) + messages.push([ + ` ${innerSymbol} ${cleanName}`, + getPrettySize(size), + '', + '', + '', + ]) }) if (restChunkCount > 0) { @@ -694,6 +739,8 @@ export async function printTreeView( ` └ other shared chunks (total)`, getPrettySize(restChunkSize), '', + '', + '', ]) } } @@ -705,7 +752,7 @@ export async function printTreeView( list: lists.app, }) - messages.push(['', '', '']) + messages.push(['', '', '', '', '']) } pageInfos.set('/404', { @@ -735,17 +782,19 @@ export async function printTreeView( .map(gzipSize ? fsStatGzip : fsStat) ) - messages.push(['', '', '']) + messages.push(['', '', '', '', '']) messages.push([ 'ƒ Middleware', getPrettySize(sum(middlewareSizes), { strong: true }), '', + '', + '', ]) } print( textTable(messages, { - align: ['l', 'l', 'r'], + align: ['l', 'r', 'r', 'r', 'r'], stringLength: (str) => stripAnsi(str).length, }) ) @@ -766,13 +815,6 @@ export async function printTreeView( '(SSG)', `prerendered as static HTML (uses ${cyan(staticFunctionInfo)})`, ], - usedSymbols.has('ISR') && [ - '', - '(ISR)', - `incremental static regeneration (uses revalidate in ${cyan( - staticFunctionInfo - )})`, - ], usedSymbols.has('◐') && [ '◐', '(Partial Prerender)', diff --git a/test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts b/test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts index 53f8fe411952b..11ff33f383148 100644 --- a/test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts +++ b/test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts @@ -14,37 +14,34 @@ describe('build-output-tree-view', () => { beforeAll(() => next.build()) it('should show info about prerendered and dynamic routes in a tree view', async () => { - // TODO: Show cache info (revalidate/expire) for app router, and use the - // same for pages router instead of the ISR addendum. - // TODO: Fix double-listing of the /ppr/[slug] fallback. expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(` - "Route (app) Size First Load JS - ┌ ○ /_not-found N/A kB N/A kB - ├ ƒ /api N/A kB N/A kB - ├ ○ /api/force-static N/A kB N/A kB - ├ ○ /app-static N/A kB N/A kB - ├ ○ /cache-life N/A kB N/A kB - ├ ƒ /dynamic N/A kB N/A kB - ├ ◐ /ppr/[slug] N/A kB N/A kB - ├ ├ /ppr/[slug] - ├ ├ /ppr/[slug] - ├ ├ /ppr/days - ├ └ /ppr/weeks - └ ○ /revalidate N/A kB N/A kB - + First Load JS shared by all N/A kB + "Route (app) Size First Load JS Revalidate Expire + ┌ ○ /_not-found N/A kB N/A kB + ├ ƒ /api N/A kB N/A kB + ├ ○ /api/force-static N/A kB N/A kB + ├ ○ /app-static N/A kB N/A kB + ├ ○ /cache-life-custom N/A kB N/A kB ≈7m ≈2h + ├ ○ /cache-life-hours N/A kB N/A kB 1h 1d + ├ ƒ /dynamic N/A kB N/A kB + ├ ◐ /ppr/[slug] N/A kB N/A kB 1w 30d + ├ ├ /ppr/[slug] 1w 30d + ├ ├ /ppr/[slug] 1w 30d + ├ ├ /ppr/days 1d 1w + ├ └ /ppr/weeks 1w 30d + └ ○ /revalidate N/A kB N/A kB 15m 1y + + First Load JS shared by all N/A kB - Route (pages) Size First Load JS - ┌ ƒ /api/hello N/A kB N/A kB - ├ ● /gsp-revalidate (ISR: 300 Seconds) N/A kB N/A kB - ├ ƒ /gssp N/A kB N/A kB - └ ○ /static N/A kB N/A kB - + First Load JS shared by all N/A kB + Route (pages) Size First Load JS Revalidate Expire + ┌ ƒ /api/hello N/A kB N/A kB + ├ ● /gsp-revalidate N/A kB N/A kB 5m 1y + ├ ƒ /gssp N/A kB N/A kB + └ ○ /static N/A kB N/A kB + + First Load JS shared by all N/A kB ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses generateStaticParams) - (ISR) incremental static regeneration (uses revalidate in generateStaticParams) ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content ƒ (Dynamic) server-rendered on demand" `) @@ -64,12 +61,12 @@ describe('build-output-tree-view', () => { it('should show info about prerendered routes in a compact tree view', async () => { expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(` - "Route (app) Size First Load JS + "Route (app) Size First Load JS ┌ ○ / N/A kB N/A kB └ ○ /_not-found N/A kB N/A kB + First Load JS shared by all N/A kB - Route (pages) Size First Load JS + Route (pages) Size First Load JS ─ ○ /static N/A kB N/A kB + First Load JS shared by all N/A kB diff --git a/test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life-custom/page.tsx b/test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life-custom/page.tsx new file mode 100644 index 0000000000000..f4776c4d57d8a --- /dev/null +++ b/test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life-custom/page.tsx @@ -0,0 +1,9 @@ +'use cache' + +import { unstable_cacheLife } from 'next/cache' + +export default async function Page() { + unstable_cacheLife({ revalidate: 412, expire: 8940 }) + + return
hello world
+} diff --git a/test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life/page.tsx b/test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life-hours/page.tsx similarity index 81% rename from test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life/page.tsx rename to test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life-hours/page.tsx index adc131c07ce15..47ece35cdee7a 100644 --- a/test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life/page.tsx +++ b/test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life-hours/page.tsx @@ -3,7 +3,7 @@ import { unstable_cacheLife } from 'next/cache' export default async function Page() { - unstable_cacheLife('weeks') + unstable_cacheLife('hours') returnhello world
}