Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show revalidate/expire columns in build output #76343

Merged
merged 4 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
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
Copy link
Member

Choose a reason for hiding this comment

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

maybe just me, I feel the left aligned was intended, visually more comfortable, and the JS column as the previous last column, it's right aligned.

Horizontally wide feels hard to read. Having two new columns feel like a lot more information to view and match visually. I quite like the 1st iteration where they stay together. We can explain "Revalidate / Expire" in title.

├ ƒ /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>
}
Loading