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 cache life column in build output #76343

Draft
wants to merge 1 commit into
base: hl/test-for-build-output
Choose a base branch
from
Draft
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 @@ -2905,23 +2905,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
55 changes: 55 additions & 0 deletions packages/next/src/build/output/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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: 'min', 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 formatCacheControl(cacheControl: CacheControl): string {
const { revalidate, expire } = cacheControl

if (!revalidate) {
return ''
}

const readableRevalidate = humanReadableTimeRounded(revalidate)
const readableExpire = expire ? humanReadableTimeRounded(expire) : '∞'

return `${readableRevalidate} / ${readableExpire}`
}
58 changes: 35 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 { formatCacheControl } 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,9 @@ 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][] = []

let showCacheLife = false

const stats = await computeFromManifest(
{ build: buildManifest, app: appBuildManifest },
Expand All @@ -468,12 +470,17 @@ export async function printTreeView(
return
}

showCacheLife = filteredPages.some(
(page) => pageInfos.get(page)?.initialCacheControl?.revalidate
)

messages.push(
[
routerType === 'app' ? 'Route (app)' : 'Route (pages)',
'Size',
'First Load JS',
].map((entry) => underline(entry)) as [string, string, string]
showCacheLife ? 'Cache Life' : '',
].map((entry) => underline(entry)) as [string, string, string, string]
)

filteredPages.forEach((item, i, arr) => {
Expand Down Expand Up @@ -522,16 +529,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 +549,9 @@ export async function printTreeView(
? getPrettySize(pageInfo.totalSize, { strong: true })
: ''
: '',
showCacheLife && pageInfo?.initialCacheControl
? formatCacheControl(pageInfo.initialCacheControl)
: '',
])

const uniqueCssFiles =
Expand All @@ -569,6 +571,7 @@ export async function printTreeView(
`${contSymbol} ${innerSymbol} ${getCleanName(file)}`,
typeof size === 'number' ? getPrettySize(size) : '',
'',
'',
])
})
}
Expand Down Expand Up @@ -623,6 +626,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 +642,9 @@ export async function printTreeView(
}`,
'',
'',
showCacheLife && initialCacheControl
? formatCacheControl(initialCacheControl)
: '',
])
}
)
Expand All @@ -653,6 +663,7 @@ export async function printTreeView(
? getPrettySize(sharedFilesSize, { strong: true })
: '',
'',
'',
])
const sharedCssFiles: string[] = []
const sharedJsChunks = [
Expand Down Expand Up @@ -686,14 +697,20 @@ 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 +722,7 @@ export async function printTreeView(
list: lists.app,
})

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

pageInfos.set('/404', {
Expand Down Expand Up @@ -735,17 +752,18 @@ 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'],
stringLength: (str) => stripAnsi(str).length,
})
)
Expand All @@ -766,19 +784,13 @@ 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)',
'prerendered as static HTML with dynamic server-streamed content',
],
usedSymbols.has('ƒ') && ['ƒ', '(Dynamic)', `server-rendered on demand`],
showCacheLife && ['', '(Cache Life)', 'revalidate / expire'],
].filter((x) => x) as [string, string, string][],
{
align: ['l', 'l', 'l'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,39 +14,36 @@ 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 42 kB 42 kB
├ ƒ /api 42 kB 42 kB
├ ○ /api/force-static 42 kB 42 kB
├ ○ /app-static 42 kB 42 kB
├ ○ /cache-life 42 kB 42 kB
├ ƒ /dynamic 42 kB 42 kB
├ ◐ /ppr/[slug] 42 kB 42 kB
├ ├ /ppr/[slug]
├ ├ /ppr/[slug]
├ ├ /ppr/days
├ └ /ppr/weeks
└ ○ /revalidate 42 kB 42 kB
+ First Load JS shared by all 42 kB

Route (pages) Size First Load JS
┌ ƒ /api/hello 42 kB 42 kB
├ ● /gsp-revalidate (ISR: 300 Seconds) 42 kB 42 kB
├ ƒ /gssp 42 kB 42 kB
└ ○ /static 42 kB 42 kB
+ First Load JS shared by all 42 kB
"Route (app) Size First Load JS Cache Life
┌ ○ /_not-found 42 kB 42 kB
├ ƒ /api 42 kB 42 kB
├ ○ /api/force-static 42 kB 42 kB
├ ○ /app-static 42 kB 42 kB
├ ○ /cache-life 42 kB 42 kB 1 h / 1 d
├ ƒ /dynamic 42 kB 42 kB
├ ◐ /ppr/[slug] 42 kB 42 kB 1 w / 30 d
├ ├ /ppr/[slug] 1 w / 30 d
├ ├ /ppr/[slug] 1 w / 30 d
├ ├ /ppr/days 1 d / 1 w
├ └ /ppr/weeks 1 w / 30 d
└ ○ /revalidate 42 kB 42 kB 15 min / 1 y
+ First Load JS shared by all 42 kB

Route (pages) Size First Load JS Cache Life
┌ ƒ /api/hello 42 kB 42 kB
├ ● /gsp-revalidate 42 kB 42 kB 5 min / 1 y
├ ƒ /gssp 42 kB 42 kB
└ ○ /static 42 kB 42 kB
+ First Load JS shared by all 42 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"
ƒ (Dynamic) server-rendered on demand
(Cache Life) revalidate / expire"
`)
})
})
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
┌ ○ / 42 kB 42 kB
└ ○ /_not-found 42 kB 42 kB
+ First Load JS shared by all 42 kB

Route (pages) Size First Load JS
Route (pages) Size First Load JS
─ ○ /static 42 kB 42 kB
+ First Load JS shared by all 42 kB

Expand Down
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