Files
next.js/test/e2e/app-dir/app-custom-cache-handler-errors/app-custom-cache-handler-errors.test.ts
Sebastian "Sebbie" SilbermannandGitHub 8e7eb0402d Prevent unhandled rejections when a "use cache" cache handler errors (#95985)
When a custom `"use cache"` cache handler's `get` method throws, the
error was logged up to four times for a single request: once as the
intended rendering error (`⨯ Error: ... { digest }`), and up to three
more times through the various `unhandledRejection` handlers (the
runtime's plain `console.error` in `process-error-handlers.ts`, the
router server's `Log.error`, and the dev overlay's log forwarding).

All the redundant logs stemmed from the same root cause:
`ResolvableSharedCacheResult` stores its deferred promise in the dedup
maps (`pendingCacheInvocations` / `crossRequestPendingCacheInvocations`)
so concurrent invocations can join the leader. That promise has no
consumer unless another invocation actually joins it, so when the
leader's cache access failed, the rejected promise was left behind
without a rejection handler, and Node.js reported it as an unhandled
rejection that fanned out to every installed handler.
2026-07-21 10:23:54 +02:00

138 lines
4.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { nextTestSetup } from 'e2e-utils'
import { hasErrorToast, retry, waitFor, waitForNoRedbox } from 'next-test-utils'
import stripAnsi from 'strip-ansi'
describe('app-custom-cache-handler-errors - get throws', () => {
const { next, isNextDev, isTurbopack, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
env: { CACHE_HANDLER_THROW_ON: 'get' },
})
if (skipped) {
return
}
it('surfaces the cache handler error', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser('/?input=x')
if (isNextDev) {
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "CustomCacheHandler.get failed",
"environmentLabel": "Prefetch",
"label": "Runtime Error",
"source": "app/page.tsx (19:14) @ CachedData
> 19 | return <p>{await getCachedData(input)}</p>
| ^",
"stack": [
"Object.get throwing-cache-handler.js (24:13)",
"CachedData app/page.tsx (19:14)",
],
}
`)
} else {
// TODO(veil): Webpack renders the cache handler frame as a file://
// URL, which the redbox matcher flags as unintended.
await expect(browser).toDisplayRedbox(`
{
"description": "CustomCacheHandler.get failed",
"environmentLabel": "Prefetch",
"label": "Runtime Error",
"source": "app/page.tsx (19:14) @ CachedData
> 19 | return <p>{await getCachedData(input)}</p>
| ^",
"stack": [
"<FIXME-file-protocol>",
"CachedData app/page.tsx (19:14)",
],
}
`)
}
}
await retry(async () => {
expect(next.cliOutput.slice(outputIndex)).toContain(
'⨯ Error: CustomCacheHandler.get failed'
)
})
// Give a potential unhandled rejection a chance to be reported before
// asserting its absence.
await waitFor(1000)
const cliOutput = stripAnsi(next.cliOutput.slice(outputIndex))
const errorBlock = isNextDev
? 'Error: CustomCacheHandler.get failed' +
'\n at Object.get (throwing-cache-handler.js:24:13)' +
'\n at async CachedData (app/page.tsx:19:14)' +
"\n 17 | const { input = 'default' } = await searchParams" +
'\n 18 |' +
'\n> 19 | return <p>{await getCachedData(input)}</p>' +
'\n | ^' +
'\n 20 | }' +
'\n 21 |' +
'\n 22 | export default function Page({ {' +
"\n digest: '"
: // In production, the frames below the cache handler frame are bundled
// code, and are therefore not stable across bundlers and builds.
'Error: CustomCacheHandler.get failed' +
'\n at Object.get (throwing-cache-handler.js:24:13)' +
'\n at '
expect(cliOutput).toContain('⨯ ' + errorBlock)
if (!isNextDev) {
expect(cliOutput).toContain(" digest: '")
}
// The error must be logged exactly once, as a rendering error, and in
// particular not additionally as an unhandled rejection.
expect(cliOutput).not.toContain('unhandledRejection')
expect(cliOutput.split(errorBlock).length - 1).toBe(1)
})
})
describe('app-custom-cache-handler-errors - set throws', () => {
const { next, isNextDev, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
env: { CACHE_HANDLER_THROW_ON: 'set' },
})
if (skipped) {
return
}
it('renders the page successfully', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser('/?input=x')
expect(await browser.elementByCss('p').text()).toBe(
'cached data for input: x'
)
if (isNextDev) {
// A throwing set() is currently completely silent in dev: the cache
// handler is called and its rejection is swallowed without being logged
// or reported to the dev overlay.
await waitForNoRedbox(browser)
expect(await hasErrorToast(browser)).toBe(false)
expect(next.cliOutput.slice(outputIndex)).not.toContain(
'CustomCacheHandler.set failed'
)
} else {
await retry(async () => {
expect(stripAnsi(next.cliOutput.slice(outputIndex))).toContain(
'Error: CustomCacheHandler.set failed' +
'\n at Object.set (throwing-cache-handler.js:50:13)' +
'\n at <unknown> ('
)
})
}
})
})