Skip to content

Commit

Permalink
Show revalidate/expire columns in build output (#76343)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
unstubbable authored Feb 28, 2025
1 parent 3ae9d38 commit d43fc5f
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 60 deletions.
12 changes: 2 additions & 10 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2909,23 +2909,15 @@ 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
pageInfos.set(page, {
...(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) {
Expand Down
54 changes: 54 additions & 0 deletions packages/next/src/build/output/format.ts
Original file line number Diff line number Diff line change
@@ -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) : ''
}
88 changes: 65 additions & 23 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand All @@ -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) => {
Expand Down Expand Up @@ -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)})`
: ''
Expand All @@ -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 =
Expand All @@ -569,6 +594,8 @@ export async function printTreeView(
`${contSymbol} ${innerSymbol} ${getCleanName(file)}`,
typeof size === 'number' ? getPrettySize(size) : '',
'',
'',
'',
])
})
}
Expand Down Expand Up @@ -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
Expand All @@ -635,6 +666,12 @@ export async function printTreeView(
}`,
'',
'',
showRevalidate && initialCacheControl
? formatRevalidate(initialCacheControl)
: '',
showExpire && initialCacheControl
? formatExpire(initialCacheControl)
: '',
])
}
)
Expand All @@ -653,6 +690,8 @@ export async function printTreeView(
? getPrettySize(sharedFilesSize, { strong: true })
: '',
'',
'',
'',
])
const sharedCssFiles: string[] = []
const sharedJsChunks = [
Expand Down Expand Up @@ -686,14 +725,22 @@ export async function printTreeView(
return
}

messages.push([` ${innerSymbol} ${cleanName}`, getPrettySize(size), ''])
messages.push([
` ${innerSymbol} ${cleanName}`,
getPrettySize(size),
'',
'',
'',
])
})

if (restChunkCount > 0) {
messages.push([
` └ other shared chunks (total)`,
getPrettySize(restChunkSize),
'',
'',
'',
])
}
}
Expand All @@ -705,7 +752,7 @@ export async function printTreeView(
list: lists.app,
})

messages.push(['', '', ''])
messages.push(['', '', '', '', ''])
}

pageInfos.set('/404', {
Expand Down Expand Up @@ -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,
})
)
Expand All @@ -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)',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
`)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use cache'

import { unstable_cacheLife } from 'next/cache'

export default async function Page() {
unstable_cacheLife({ revalidate: 412, expire: 8940 })

return <p>hello world</p>
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { unstable_cacheLife } from 'next/cache'

export default async function Page() {
unstable_cacheLife('weeks')
unstable_cacheLife('hours')

return <p>hello world</p>
}

0 comments on commit d43fc5f

Please sign in to comment.