Skip to content

Commit d43fc5f

Browse files
authored
Show revalidate/expire columns in build output (#76343)
For pages router ISR pages, we are showing the revalidate times in the build output tree view, e.g.: ``` Route (pages) Size First Load JS ┌ ○ /404 190 B 92.6 kB └ ● /my-isr-page (ISR: 300 Seconds) 291 B 92.7 kB ``` For app router pages, this info is currently missing. With this PR, we're not only adding the revalidate times for app router routes, but also showing the expire times as well. Those may be configured in the Next.js config [`expireTime`](https://nextjs.org/docs/app/api-reference/config/next-config-js/expireTime), or via [cache profiles](https://nextjs.org/docs/app/api-reference/functions/cacheLife) in `"use cache"` functions. Both values are moved into separate `Revalidate` and `Expire` columns and are shown in a human-readable format. **After:** <img width="708" alt="two columns" src="https://github.com/user-attachments/assets/3d6dff7b-7df1-43b2-ae33-802227a3cf88" /> **Before:** <img width="788" alt="before" src="https://github.com/user-attachments/assets/19d5bc2c-c39f-4d44-97e0-ddcb72de3d82" /> closes NAR-96
1 parent 3ae9d38 commit d43fc5f

File tree

6 files changed

+154
-60
lines changed

6 files changed

+154
-60
lines changed

packages/next/src/build/index.ts

+2-10
Original file line numberDiff line numberDiff line change
@@ -2909,23 +2909,15 @@ export default async function build(
29092909
...(pageInfos.get(route.pathname) as PageInfo),
29102910
hasPostponed,
29112911
hasEmptyPrelude,
2912-
// TODO: Enable the following line to show "ISR" status in build
2913-
// output. Requires different presentation to also work for app
2914-
// router routes.
2915-
// See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH
2916-
// initialCacheControl: cacheControl,
2912+
initialCacheControl: cacheControl,
29172913
})
29182914

29192915
// update the page (eg /blog/[slug]) to also have the postpone metadata
29202916
pageInfos.set(page, {
29212917
...(pageInfos.get(page) as PageInfo),
29222918
hasPostponed,
29232919
hasEmptyPrelude,
2924-
// TODO: Enable the following line to show "ISR" status in build
2925-
// output. Requires different presentation to also work for app
2926-
// router routes.
2927-
// See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH
2928-
// initialCacheControl: cacheControl,
2920+
initialCacheControl: cacheControl,
29292921
})
29302922

29312923
if (cacheControl.revalidate !== 0) {
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { CacheControl } from '../../server/lib/cache-control'
2+
3+
const timeUnits = [
4+
{ label: 'y', seconds: 31536000 },
5+
{ label: 'w', seconds: 604800 },
6+
{ label: 'd', seconds: 86400 },
7+
{ label: 'h', seconds: 3600 },
8+
{ label: 'm', seconds: 60 },
9+
{ label: 's', seconds: 1 },
10+
]
11+
12+
function humanReadableTimeRounded(seconds: number): string {
13+
// Find the largest fitting unit.
14+
let candidateIndex = timeUnits.length - 1
15+
for (let i = 0; i < timeUnits.length; i++) {
16+
if (seconds >= timeUnits[i].seconds) {
17+
candidateIndex = i
18+
break
19+
}
20+
}
21+
22+
const candidate = timeUnits[candidateIndex]
23+
const value = seconds / candidate.seconds
24+
const isExact = Number.isInteger(value)
25+
26+
// For days and weeks only, check if using the next smaller unit yields an
27+
// exact result.
28+
if (!isExact && (candidate.label === 'd' || candidate.label === 'w')) {
29+
const nextUnit = timeUnits[candidateIndex + 1]
30+
const nextValue = seconds / nextUnit.seconds
31+
32+
if (Number.isInteger(nextValue)) {
33+
return `${nextValue}${nextUnit.label}`
34+
}
35+
}
36+
37+
if (isExact) {
38+
return `${value}${candidate.label}`
39+
}
40+
41+
return `≈${Math.round(value)}${candidate.label}`
42+
}
43+
44+
export function formatRevalidate(cacheControl: CacheControl): string {
45+
const { revalidate } = cacheControl
46+
47+
return revalidate ? humanReadableTimeRounded(revalidate) : ''
48+
}
49+
50+
export function formatExpire(cacheControl: CacheControl): string {
51+
const { expire } = cacheControl
52+
53+
return expire ? humanReadableTimeRounded(expire) : ''
54+
}

packages/next/src/build/utils.ts

+65-23
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import { buildAppStaticPaths } from './static-paths/app'
8282
import { buildPagesStaticPaths } from './static-paths/pages'
8383
import type { PrerenderedRoute } from './static-paths/types'
8484
import type { CacheControl } from '../server/lib/cache-control'
85+
import { formatExpire, formatRevalidate } from './output/format'
8586

8687
export type ROUTER_TYPE = 'pages' | 'app'
8788

@@ -347,7 +348,6 @@ export interface PageInfo {
347348
*/
348349
isRoutePPREnabled: boolean
349350
ssgPageRoutes: string[] | null
350-
// TODO: initialCacheControl should be set per prerendered route.
351351
initialCacheControl: CacheControl | undefined
352352
pageDuration: number | undefined
353353
ssgPageDurations: number[] | undefined
@@ -447,7 +447,7 @@ export async function printTreeView(
447447
// Collect all the symbols we use so we can print the icons out.
448448
const usedSymbols = new Set()
449449

450-
const messages: [string, string, string][] = []
450+
const messages: [string, string, string, string, string][] = []
451451

452452
const stats = await computeFromManifest(
453453
{ build: buildManifest, app: appBuildManifest },
@@ -468,12 +468,39 @@ export async function printTreeView(
468468
return
469469
}
470470

471+
let showRevalidate = false
472+
let showExpire = false
473+
474+
for (const page of filteredPages) {
475+
const cacheControl = pageInfos.get(page)?.initialCacheControl
476+
477+
if (cacheControl?.revalidate) {
478+
showRevalidate = true
479+
}
480+
481+
if (cacheControl?.expire) {
482+
showExpire = true
483+
}
484+
485+
if (showRevalidate && showExpire) {
486+
break
487+
}
488+
}
489+
471490
messages.push(
472491
[
473492
routerType === 'app' ? 'Route (app)' : 'Route (pages)',
474493
'Size',
475494
'First Load JS',
476-
].map((entry) => underline(entry)) as [string, string, string]
495+
showRevalidate ? 'Revalidate' : '',
496+
showExpire ? 'Expire' : '',
497+
].map((entry) => underline(entry)) as [
498+
string,
499+
string,
500+
string,
501+
string,
502+
string,
503+
]
477504
)
478505

479506
filteredPages.forEach((item, i, arr) => {
@@ -522,16 +549,8 @@ export async function printTreeView(
522549

523550
usedSymbols.add(symbol)
524551

525-
// TODO: Rework this to be usable for app router routes.
526-
// See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH
527-
if (pageInfo?.initialCacheControl?.revalidate) usedSymbols.add('ISR')
528-
529552
messages.push([
530-
`${border} ${symbol} ${
531-
pageInfo?.initialCacheControl?.revalidate
532-
? `${item} (ISR: ${pageInfo?.initialCacheControl.revalidate} Seconds)`
533-
: item
534-
}${
553+
`${border} ${symbol} ${item}${
535554
totalDuration > MIN_DURATION
536555
? ` (${getPrettyDuration(totalDuration)})`
537556
: ''
@@ -550,6 +569,12 @@ export async function printTreeView(
550569
? getPrettySize(pageInfo.totalSize, { strong: true })
551570
: ''
552571
: '',
572+
showRevalidate && pageInfo?.initialCacheControl
573+
? formatRevalidate(pageInfo.initialCacheControl)
574+
: '',
575+
showExpire && pageInfo?.initialCacheControl
576+
? formatExpire(pageInfo.initialCacheControl)
577+
: '',
553578
])
554579

555580
const uniqueCssFiles =
@@ -569,6 +594,8 @@ export async function printTreeView(
569594
`${contSymbol} ${innerSymbol} ${getCleanName(file)}`,
570595
typeof size === 'number' ? getPrettySize(size) : '',
571596
'',
597+
'',
598+
'',
572599
])
573600
})
574601
}
@@ -623,6 +650,10 @@ export async function printTreeView(
623650
routes.forEach(
624651
({ route, duration, avgDuration }, index, { length }) => {
625652
const innerSymbol = index === length - 1 ? '└' : '├'
653+
654+
const initialCacheControl =
655+
pageInfos.get(route)?.initialCacheControl
656+
626657
messages.push([
627658
`${contSymbol} ${innerSymbol} ${route}${
628659
duration > MIN_DURATION
@@ -635,6 +666,12 @@ export async function printTreeView(
635666
}`,
636667
'',
637668
'',
669+
showRevalidate && initialCacheControl
670+
? formatRevalidate(initialCacheControl)
671+
: '',
672+
showExpire && initialCacheControl
673+
? formatExpire(initialCacheControl)
674+
: '',
638675
])
639676
}
640677
)
@@ -653,6 +690,8 @@ export async function printTreeView(
653690
? getPrettySize(sharedFilesSize, { strong: true })
654691
: '',
655692
'',
693+
'',
694+
'',
656695
])
657696
const sharedCssFiles: string[] = []
658697
const sharedJsChunks = [
@@ -686,14 +725,22 @@ export async function printTreeView(
686725
return
687726
}
688727

689-
messages.push([` ${innerSymbol} ${cleanName}`, getPrettySize(size), ''])
728+
messages.push([
729+
` ${innerSymbol} ${cleanName}`,
730+
getPrettySize(size),
731+
'',
732+
'',
733+
'',
734+
])
690735
})
691736

692737
if (restChunkCount > 0) {
693738
messages.push([
694739
` └ other shared chunks (total)`,
695740
getPrettySize(restChunkSize),
696741
'',
742+
'',
743+
'',
697744
])
698745
}
699746
}
@@ -705,7 +752,7 @@ export async function printTreeView(
705752
list: lists.app,
706753
})
707754

708-
messages.push(['', '', ''])
755+
messages.push(['', '', '', '', ''])
709756
}
710757

711758
pageInfos.set('/404', {
@@ -735,17 +782,19 @@ export async function printTreeView(
735782
.map(gzipSize ? fsStatGzip : fsStat)
736783
)
737784

738-
messages.push(['', '', ''])
785+
messages.push(['', '', '', '', ''])
739786
messages.push([
740787
'ƒ Middleware',
741788
getPrettySize(sum(middlewareSizes), { strong: true }),
742789
'',
790+
'',
791+
'',
743792
])
744793
}
745794

746795
print(
747796
textTable(messages, {
748-
align: ['l', 'l', 'r'],
797+
align: ['l', 'r', 'r', 'r', 'r'],
749798
stringLength: (str) => stripAnsi(str).length,
750799
})
751800
)
@@ -766,13 +815,6 @@ export async function printTreeView(
766815
'(SSG)',
767816
`prerendered as static HTML (uses ${cyan(staticFunctionInfo)})`,
768817
],
769-
usedSymbols.has('ISR') && [
770-
'',
771-
'(ISR)',
772-
`incremental static regeneration (uses revalidate in ${cyan(
773-
staticFunctionInfo
774-
)})`,
775-
],
776818
usedSymbols.has('◐') && [
777819
'◐',
778820
'(Partial Prerender)',

test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts

+23-26
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,34 @@ describe('build-output-tree-view', () => {
1414
beforeAll(() => next.build())
1515

1616
it('should show info about prerendered and dynamic routes in a tree view', async () => {
17-
// TODO: Show cache info (revalidate/expire) for app router, and use the
18-
// same for pages router instead of the ISR addendum.
19-
2017
// TODO: Fix double-listing of the /ppr/[slug] fallback.
2118

2219
expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(`
23-
"Route (app) Size First Load JS
24-
┌ ○ /_not-found N/A kB N/A kB
25-
├ ƒ /api N/A kB N/A kB
26-
├ ○ /api/force-static N/A kB N/A kB
27-
├ ○ /app-static N/A kB N/A kB
28-
├ ○ /cache-life N/A kB N/A kB
29-
├ ƒ /dynamic N/A kB N/A kB
30-
├ ◐ /ppr/[slug] N/A kB N/A kB
31-
├ ├ /ppr/[slug]
32-
├ ├ /ppr/[slug]
33-
├ ├ /ppr/days
34-
├ └ /ppr/weeks
35-
└ ○ /revalidate N/A kB N/A kB
36-
+ First Load JS shared by all N/A kB
20+
"Route (app) Size First Load JS Revalidate Expire
21+
┌ ○ /_not-found N/A kB N/A kB
22+
├ ƒ /api N/A kB N/A kB
23+
├ ○ /api/force-static N/A kB N/A kB
24+
├ ○ /app-static N/A kB N/A kB
25+
├ ○ /cache-life-custom N/A kB N/A kB ≈7m ≈2h
26+
├ ○ /cache-life-hours N/A kB N/A kB 1h 1d
27+
├ ƒ /dynamic N/A kB N/A kB
28+
├ ◐ /ppr/[slug] N/A kB N/A kB 1w 30d
29+
├ ├ /ppr/[slug] 1w 30d
30+
├ ├ /ppr/[slug] 1w 30d
31+
├ ├ /ppr/days 1d 1w
32+
├ └ /ppr/weeks 1w 30d
33+
└ ○ /revalidate N/A kB N/A kB 15m 1y
34+
+ First Load JS shared by all N/A kB
3735
38-
Route (pages) Size First Load JS
39-
┌ ƒ /api/hello N/A kB N/A kB
40-
├ ● /gsp-revalidate (ISR: 300 Seconds) N/A kB N/A kB
41-
├ ƒ /gssp N/A kB N/A kB
42-
└ ○ /static N/A kB N/A kB
43-
+ First Load JS shared by all N/A kB
36+
Route (pages) Size First Load JS Revalidate Expire
37+
┌ ƒ /api/hello N/A kB N/A kB
38+
├ ● /gsp-revalidate N/A kB N/A kB 5m 1y
39+
├ ƒ /gssp N/A kB N/A kB
40+
└ ○ /static N/A kB N/A kB
41+
+ First Load JS shared by all N/A kB
4442
4543
○ (Static) prerendered as static content
4644
● (SSG) prerendered as static HTML (uses generateStaticParams)
47-
(ISR) incremental static regeneration (uses revalidate in generateStaticParams)
4845
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
4946
ƒ (Dynamic) server-rendered on demand"
5047
`)
@@ -64,12 +61,12 @@ describe('build-output-tree-view', () => {
6461

6562
it('should show info about prerendered routes in a compact tree view', async () => {
6663
expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(`
67-
"Route (app) Size First Load JS
64+
"Route (app) Size First Load JS
6865
┌ ○ / N/A kB N/A kB
6966
└ ○ /_not-found N/A kB N/A kB
7067
+ First Load JS shared by all N/A kB
7168
72-
Route (pages) Size First Load JS
69+
Route (pages) Size First Load JS
7370
─ ○ /static N/A kB N/A kB
7471
+ First Load JS shared by all N/A kB
7572
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use cache'
2+
3+
import { unstable_cacheLife } from 'next/cache'
4+
5+
export default async function Page() {
6+
unstable_cacheLife({ revalidate: 412, expire: 8940 })
7+
8+
return <p>hello world</p>
9+
}

test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life/page.tsx renamed to test/production/app-dir/build-output-tree-view/fixtures/mixed/app/cache-life-hours/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { unstable_cacheLife } from 'next/cache'
44

55
export default async function Page() {
6-
unstable_cacheLife('weeks')
6+
unstable_cacheLife('hours')
77

88
return <p>hello world</p>
99
}

0 commit comments

Comments
 (0)