Skip to content

Commit bc51d9b

Browse files
authored
[Cache Components] Allow hiding logs after abort (#84579)
When Next.js prerenders with Cache Components enabled it will abort prerenders before they have completed. It is common for user code to log in a catch block and with this new prerendering behavior these logs will trigger frequently for cases where the rejection only happened because we are abandoning the work. This is analagous to treating a fetch rejection as an error even when you pass an abortSignal into the fetch and then abort it. Currently when logs occur after an aborted prerender we dim the log output to signify that the log line is not particuarly important. This is useful and for folks who want to see everything output by their program this is potentially the best behavior. However many log collectors don't support colors and have no way of contextualizing the dimmed log which can lead to them appearing to be more significant than they are. To combat this we expose a new experimental flag that allows you to completely disable logs after aborting. This means the log lines you see are much higher signal. The implementation is somewhat complex because we install the environment extensions as early as possible before any user code can run but we can't install the hiding behavior until we have a config in scope. I've added the hiding installation to next-server and the worker exportPages entrypoints. I believe this is sufficient to cover all contexts in which we will prerender.
1 parent a89f854 commit bc51d9b

File tree

24 files changed

+1247
-714
lines changed

24 files changed

+1247
-714
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,5 +871,6 @@
871871
"870": "refresh can only be called from within a Server Action. See more info here: https://nextjs.org/docs/app/api-reference/functions/refresh",
872872
"871": "Image with src \"%s\" is using a query string which is not configured in images.localPatterns.\\nRead more: https://nextjs.org/docs/messages/next-image-unconfigured-localpatterns",
873873
"872": "updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag",
874-
"873": "Invalid profile provided \"%s\" must be configured under cacheLife in next.config or be \"max\""
874+
"873": "Invalid profile provided \"%s\" must be configured under cacheLife in next.config or be \"max\"",
875+
"874": "Expected not to install Node.js global behaviors in the edge runtime."
875876
}

packages/next/src/export/worker.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import type { PagesRenderContext, PagesSharedContext } from '../server/render'
5050
import type { AppSharedContext } from '../server/app-render/app-render'
5151
import { MultiFileWriter } from '../lib/multi-file-writer'
5252
import { createRenderResumeDataCache } from '../server/resume-data-cache/resume-data-cache'
53+
import { installGlobalBehaviors } from '../server/node-environment-extensions/global-behaviors'
5354
;(globalThis as any).__NEXT_DATA__ = {
5455
nextExport: true,
5556
}
@@ -336,6 +337,8 @@ export async function exportPages(
336337
renderResumeDataCachesByPage = {},
337338
} = input
338339

340+
installGlobalBehaviors(nextConfig)
341+
339342
if (nextConfig.experimental.enablePrerenderSourceMaps) {
340343
try {
341344
// Same as `next dev`

packages/next/src/server/config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export const experimentalSchema = {
346346
])
347347
.optional(),
348348
lockDistDir: z.boolean().optional(),
349+
hideLogsAfterAbort: z.boolean().optional(),
349350
}
350351

351352
export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>

packages/next/src/server/config-shared.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,14 @@ export interface ExperimentalConfig {
817817
* @default true
818818
*/
819819
lockDistDir?: boolean
820+
821+
/**
822+
* Hide logs that occur after a render has already aborted.
823+
* This can help reduce noise in the console when dealing with aborted renders.
824+
*
825+
* @default false
826+
*/
827+
hideLogsAfterAbort?: boolean
820828
}
821829

822830
export type ExportPathMap = {
@@ -1499,6 +1507,7 @@ export const defaultConfig = Object.freeze({
14991507
lockDistDir: true,
15001508
isolatedDevBuild: true,
15011509
middlewareClientMaxBodySize: 10_485_760, // 10MB
1510+
hideLogsAfterAbort: false,
15021511
},
15031512
htmlLimitedBots: undefined,
15041513
bundlePagesRouterDependencies: false,

packages/next/src/server/next-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import {
123123
RouterServerContextSymbol,
124124
routerServerGlobal,
125125
} from './lib/router-utils/router-server-context'
126+
import { installGlobalBehaviors } from './node-environment-extensions/global-behaviors'
126127

127128
export * from './base-server'
128129

@@ -273,6 +274,8 @@ export default class NextNodeServer extends BaseServer<
273274
// Initialize super class
274275
super(options)
275276

277+
installGlobalBehaviors(this.nextConfig)
278+
276279
const isDev = options.dev ?? false
277280
this.isDev = isDev
278281
this.sriEnabled = Boolean(options.conf.experimental?.sri?.algorithm)

packages/next/src/server/node-environment-extensions/console-dim.external.tsx

Lines changed: 89 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ export function registerGetCacheSignal(getSignal: GetCacheSignal): void {
1111
cacheSignals.push(getSignal)
1212
}
1313

14+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we may use later and want parity with the HIDDEN_STYLE value
15+
const DIMMED_STYLE = 'dimmed'
16+
const HIDDEN_STYLE = 'hidden'
17+
18+
type LogStyle = typeof DIMMED_STYLE | typeof HIDDEN_STYLE
19+
20+
let currentAbortedLogsStyle: LogStyle = 'dimmed'
21+
export function setAbortedLogsStyle(style: LogStyle) {
22+
currentAbortedLogsStyle = style
23+
}
24+
1425
type InterceptableConsoleMethod =
1526
| 'error'
1627
| 'assert'
@@ -175,75 +186,89 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
175186
const wrapperMethod = function (this: typeof console, ...args: any[]) {
176187
const consoleStore = consoleAsyncStorage.getStore()
177188

178-
if (consoleStore?.dim === true) {
179-
return applyWithDimming.call(
180-
this,
181-
consoleStore,
182-
originalMethod,
183-
methodName,
184-
args
185-
)
186-
} else {
187-
// First we see if there is a cache signal for our current scope. If we're in a client render it'll
188-
// come from the client React cacheSignal implementation. If we are in a server render it'll come from
189-
// the server React cacheSignal implementation. Any particular console call will be in one, the other, or neither
190-
// scope and these signals return null if you are out of scope so this can be called from a single global patch
191-
// and still work properly.
192-
for (let i = 0; i < cacheSignals.length; i++) {
193-
const signal = cacheSignals[i]() // try to get a signal from registered functions
194-
if (signal) {
195-
// We are in a React Server render and can consult the React cache signal to determine if logs
196-
// are now dimmable.
197-
if (signal.aborted) {
198-
return applyWithDimming.call(
199-
this,
200-
consoleStore,
201-
originalMethod,
202-
methodName,
203-
args
204-
)
205-
} else {
206-
return originalMethod.apply(this, args)
189+
// First we see if there is a cache signal for our current scope. If we're in a client render it'll
190+
// come from the client React cacheSignal implementation. If we are in a server render it'll come from
191+
// the server React cacheSignal implementation. Any particular console call will be in one, the other, or neither
192+
// scope and these signals return null if you are out of scope so this can be called from a single global patch
193+
// and still work properly.
194+
for (let i = 0; i < cacheSignals.length; i++) {
195+
const signal = cacheSignals[i]() // try to get a signal from registered functions
196+
if (signal) {
197+
// We are in a React Server render and can consult the React cache signal to determine if logs
198+
// are now dimmable.
199+
if (signal.aborted) {
200+
if (currentAbortedLogsStyle === HIDDEN_STYLE) {
201+
return
207202
}
203+
return applyWithDimming.call(
204+
this,
205+
consoleStore,
206+
originalMethod,
207+
methodName,
208+
args
209+
)
210+
} else if (consoleStore?.dim === true) {
211+
return applyWithDimming.call(
212+
this,
213+
consoleStore,
214+
originalMethod,
215+
methodName,
216+
args
217+
)
218+
} else {
219+
return originalMethod.apply(this, args)
208220
}
209221
}
210-
// We need to fall back to checking the work unit store for two reasons.
211-
// 1. Client React does not yet implement cacheSignal (it always returns null)
212-
// 2. route.ts files aren't rendered with React but do have prerender semantics
213-
// TODO in the future we should be able to remove this once there is a runnable cache
214-
// scope independent of actual React rendering.
215-
const workUnitStore = workUnitAsyncStorage.getStore()
216-
switch (workUnitStore?.type) {
217-
case 'prerender':
218-
case 'prerender-runtime':
219-
// These can be hit in a route handler. In the future we can use potential React.createCache API
220-
// to create a cache scope for arbitrary computation and can move over to cacheSignal exclusively.
221-
// fallthrough
222-
case 'prerender-client':
223-
// This is a react-dom/server render and won't have a cacheSignal until React adds this for the client world.
224-
const renderSignal = workUnitStore.renderSignal
225-
if (renderSignal.aborted) {
226-
return applyWithDimming.call(
227-
this,
228-
consoleStore,
229-
originalMethod,
230-
methodName,
231-
args
232-
)
233-
} else {
234-
return originalMethod.apply(this, args)
222+
}
223+
224+
// We need to fall back to checking the work unit store for two reasons.
225+
// 1. Client React does not yet implement cacheSignal (it always returns null)
226+
// 2. route.ts files aren't rendered with React but do have prerender semantics
227+
// TODO in the future we should be able to remove this once there is a runnable cache
228+
// scope independent of actual React rendering.
229+
const workUnitStore = workUnitAsyncStorage.getStore()
230+
switch (workUnitStore?.type) {
231+
case 'prerender':
232+
case 'prerender-runtime':
233+
// These can be hit in a route handler. In the future we can use potential React.createCache API
234+
// to create a cache scope for arbitrary computation and can move over to cacheSignal exclusively.
235+
// fallthrough
236+
case 'prerender-client':
237+
// This is a react-dom/server render and won't have a cacheSignal until React adds this for the client world.
238+
const renderSignal = workUnitStore.renderSignal
239+
if (renderSignal.aborted) {
240+
if (currentAbortedLogsStyle === HIDDEN_STYLE) {
241+
return
235242
}
236-
case 'prerender-legacy':
237-
case 'prerender-ppr':
238-
case 'cache':
239-
case 'unstable-cache':
240-
case 'private-cache':
241-
case 'request':
242-
case undefined:
243+
return applyWithDimming.call(
244+
this,
245+
consoleStore,
246+
originalMethod,
247+
methodName,
248+
args
249+
)
250+
}
251+
// intentional fallthrough
252+
case 'prerender-legacy':
253+
case 'prerender-ppr':
254+
case 'cache':
255+
case 'unstable-cache':
256+
case 'private-cache':
257+
case 'request':
258+
case undefined:
259+
if (consoleStore?.dim === true) {
260+
return applyWithDimming.call(
261+
this,
262+
consoleStore,
263+
originalMethod,
264+
methodName,
265+
args
266+
)
267+
} else {
243268
return originalMethod.apply(this, args)
244-
default:
245-
workUnitStore satisfies never
246-
}
269+
}
270+
default:
271+
workUnitStore satisfies never
247272
}
248273
}
249274
if (originalName) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Unlike most files in the node-environment-extensions folder this one is not
3+
* an extension itself but it exposes a function to install config based global
4+
* behaviors that should be loaded whenever a Node Server or Node Worker are created.
5+
*/
6+
import type { NextConfigComplete } from '../config-shared'
7+
import { InvariantError } from '../../shared/lib/invariant-error'
8+
9+
import { setAbortedLogsStyle } from './console-dim.external'
10+
11+
export function installGlobalBehaviors(config: NextConfigComplete) {
12+
if (process.env.NEXT_RUNTIME === 'edge') {
13+
throw new InvariantError(
14+
'Expected not to install Node.js global behaviors in the edge runtime.'
15+
)
16+
}
17+
18+
if (config.experimental?.hideLogsAfterAbort === true) {
19+
setAbortedLogsStyle('hidden')
20+
} else {
21+
setAbortedLogsStyle('dimmed')
22+
}
23+
}

0 commit comments

Comments
 (0)