Files
Will Binns-SmithandGitHub 466cdfaa27 Turbopack server hmr: avoid complete clear on graph changes (#95546)
Currently, importing a new module causes a complete eviction (`clear()`)
and re-evaluation of server chunks, both in the Turbopack runtime's
module cache and Node's `require.cache`.

Here's what happens today:

- A new module is imported into a chunk's graph
- This changes its `availability_info`, which in dev for non-entry
chunks is encoded into the chunk's filepath
- `VersionedContentMap` works entirely on chunk paths. When we construct
instructions to transition a chunk into its new state, we fail to find
the prior state and fall back to `clear()` as described above.

An ideal version of this is a refactor that implements
`VersionedContentMap` on a per-module basis, not a per-chunk one. This
PR doesn't do that, but achieves consistent module-level updates when a
chunk's availability info changes.

It does this by implementing chunk lists for server entry chunks the
same way the client hmr implementation does: an entry chunk's version
aggregates the versions of its dependent chunks (including dynamically
imported ones), keyed by merger rather than path. Entry chunks do not
encode availability info in their paths, so they are insulated from
missing versions. Updates are applied in Node.js through the same shared
merged-update machinery in the unified hmr runtime that the client
already uses, plus a new `ChunkListUpdate` branch in the server hmr
client to unwrap the merged updates.

Details:

- The merged-update wire format (`EcmascriptMergedUpdate` etc.) moves
from turbopack-browser into shared `turbopack_ecmascript::chunk_list`,
along with runtime-agnostic `ChunkListVersion` and `update_chunk_list`
implementations.
- `turbopack-nodejs` gains a chunk content merger mirroring the browser
one. `EcmascriptBuildNodeEntryChunk`'s versioned content is now a
chunk-list content over its sync and async chunks. It is version-only
and never emitted; the entry chunk still inlines its loader calls.
- The aggregate server hmr subscription now tracks only entry chunks.
Shared chunks under `server/chunks/` ride the entry's `ChunkListUpdate`
as module deltas, so their content-hash paths no longer matter.
- The Node hmr client applies `ChunkListUpdate` by feeding each nested
merged update through the shared apply path. Modules that appear in an
"added" chunk but already exist in the module cache were moved by a
chunk rename and are treated as modified.
- On a successful partial apply, the hot reloader clears the manifest
cache for updated chunks and notifies browsers to refetch RSC, without
clearing `require.cache`.

### Test Plan

Adds a series of e2e tests to catch the described manual test plan in
both entry and dynamic chunks.
2026-07-28 13:49:30 -07:00

1692 lines
44 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.
/* eslint-env jest */
import { createSandbox } from 'development-sandbox'
import { FileRef, nextTestSetup } from 'e2e-utils'
import { getToastErrorCount, retry } from 'next-test-utils'
import path from 'path'
import { outdent } from 'outdent'
describe('ReactRefreshLogBox app', () => {
const { next, isTurbopack, isRspack } = nextTestSetup({
files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')),
skipStart: true,
patchFileDelay: 1000,
})
test('should strip whitespace correctly with newline', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
await session.patch(
'index.js',
outdent`
export default function Page() {
return (
<>
<p>index page</p>
<a onClick={() => {
throw new Error('idk')
}}>
click me
</a>
</>
)
}
`
)
await browser.elementByCss('a').click()
await expect(browser).toDisplayRedbox(
`"Expected Redbox but found no visible one."`
)
})
// https://github.com/pmmmwh/react-refresh-webpack-plugin/pull/3#issuecomment-554137807
test('module init error not shown', async () => {
// Start here:
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
// We start here.
await session.patch(
'index.js',
outdent`
import * as React from 'react';
class ClassDefault extends React.Component {
render() {
return <h1>Default Export</h1>;
}
}
export default ClassDefault;
`
)
expect(
await session.evaluate(() => document.querySelector('h1').textContent)
).toBe('Default Export')
// Add a throw in module init phase:
await session.patch(
'index.js',
outdent`
// top offset for snapshot
import * as React from 'react';
throw new Error('no')
class ClassDefault extends React.Component {
render() {
return <h1>Default Export</h1>;
}
}
export default ClassDefault;
`
)
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "no",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (3:7) @ module evaluation
> 3 | throw new Error('no')
| ^",
"stack": [
"module evaluation index.js (3:7)",
"module evaluation app/page.js (2:1)",
],
}
`)
} else if (isRspack) {
await expect(browser).toDisplayRedbox(`
{
"description": "no",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (3:7) @ eval
> 3 | throw new Error('no')
| ^",
"stack": [
"eval index.js (3:7)",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"eval ./app/page.js",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
[
{
"description": "no",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (3:7) @ eval
> 3 | throw new Error('no')
| ^",
"stack": [
"eval index.js (3:7)",
"<FIXME-next-dist-dir>",
"eval ./app/page.js",
"<FIXME-next-dist-dir>",
],
},
{
"description": "no",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (3:7) @ eval
> 3 | throw new Error('no')
| ^",
"stack": [
"eval index.js (3:7)",
"<FIXME-next-dist-dir>",
"eval ./app/page.js",
"<FIXME-next-dist-dir>",
],
},
]
`)
}
})
// https://github.com/pmmmwh/react-refresh-webpack-plugin/pull/3#issuecomment-554152127
test('boundaries', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
await session.write(
'FunctionDefault.js',
outdent`
export default function FunctionDefault() {
return <h2>hello</h2>
}
`
)
await session.patch(
'index.js',
outdent`
import FunctionDefault from './FunctionDefault.js'
import * as React from 'react'
class ErrorBoundary extends React.Component {
constructor() {
super()
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return {
hasError: true,
error
};
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
function App() {
return (
<ErrorBoundary fallback={<h2>error</h2>}>
<FunctionDefault />
</ErrorBoundary>
);
}
export default App;
`
)
expect(
await session.evaluate(() => document.querySelector('h2').textContent)
).toBe('hello')
await session.write(
'FunctionDefault.js',
`export default function FunctionDefault() { throw new Error('no'); }`
)
await expect(browser).toDisplayRedbox(
`"Expected Redbox but found no visible one."`
)
})
// TODO: investigate why this fails when running outside of the Next.js
// monorepo e.g. fails when using pnpm create next-app
// https://github.com/vercel/next.js/pull/23203
test.skip('internal package errors', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
// Make a react build-time error.
await session.patch(
'index.js',
outdent`
export default function FunctionNamed() {
return <div>{{}}</div>
}
`
)
await expect(browser).toDisplayRedbox()
})
test('unterminated JSX', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
await session.patch(
'index.js',
outdent`
export default () => {
return (
<div>
<p>lol</p>
</div>
)
}
`
)
await session.waitForNoRedbox()
await session.patch(
'index.js',
outdent`
export default () => {
return (
<div>
<p>lol</p>
div
)
}
`
)
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.js (7:1)
Error: Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?
> 7 | }
| ^",
"stack": [],
}
`)
} else if (isRspack) {
await expect({ browser, next }).toDisplayRedbox(`
{
"description": " ╰─▶ × Error: x Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.js
╰─▶ × Error: x Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?
│ ,-[7:1]
│ 4 | <p>lol</p>
│ 5 | div
│ 6 | )
│ 7 | }
│ : ^
│ \`----
│ x Expected '</', got '<eof>'
│ ,-[7:1]
│ 4 | <p>lol</p>
│ 5 | div
│ 6 | )
│ 7 | }
│ \`----
│
│
│ Caused by:
│ Syntax Error
Import trace for requested module:
./index.js
./app/page.js",
"stack": [],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"description": " x Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.js
Error: x Unexpected token. Did you mean \`{'}'}\` or \`&rbrace;\`?
,-[7:1]
4 | <p>lol</p>
5 | div
6 | )
7 | }
: ^
\`----
x Expected '</', got '<eof>'
,-[7:1]
4 | <p>lol</p>
5 | div
6 | )
7 | }
\`----
Caused by:
Syntax Error
Import trace for requested module:
./index.js
./app/page.js",
"stack": [],
}
`)
}
})
// Module trace is only available with webpack 5
test('conversion to class component (1)', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
await session.write(
'Child.js',
outdent`
export default function ClickCount() {
return <p>hello</p>
}
`
)
await session.patch(
'index.js',
outdent`
import Child from './Child';
export default function Home() {
return (
<div>
<Child />
</div>
)
}
`
)
await session.waitForNoRedbox()
expect(
await session.evaluate(() => document.querySelector('p').textContent)
).toBe('hello')
await session.patch(
'Child.js',
outdent`
import { Component } from 'react';
export default class ClickCount extends Component {
render() {
throw new Error()
}
}
`
)
if (isTurbopack) {
// TODO(veil): Possibly https://linear.app/vercel/issue/NEXT-4411
await expect(browser).toDisplayRedbox(`
{
"description": "",
"environmentLabel": null,
"label": "Runtime Error",
"source": "Child.js (4:11) @ ClickCount.render
> 4 | throw new Error()
| ^",
"stack": [
"ClickCount.render Child.js (4:11)",
"Home index.js (6:7)",
"Page app/page.js (4:10)",
],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"description": "",
"environmentLabel": null,
"label": "Runtime Error",
"source": "Child.js (4:11) @ ClickCount.render
> 4 | throw new Error()
| ^",
"stack": [
"ClickCount.render Child.js (4:11)",
"Home index.js (6:7)",
"Page app/page.js (4:10)",
],
}
`)
}
await session.patch(
'Child.js',
outdent`
import { Component } from 'react';
export default class ClickCount extends Component {
render() {
return <p>hello new</p>
}
}
`
)
await session.waitForNoRedbox()
expect(
await session.evaluate(() => document.querySelector('p').textContent)
).toBe('hello new')
})
test('css syntax errors', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
await session.write('index.module.css', `.button {}`)
await session.patch(
'index.js',
outdent`
import './index.module.css';
export default () => {
return (
<div>
<p>lol</p>
</div>
)
}
`
)
await session.waitForNoRedbox()
await session.patch('index.module.css', `.button`)
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "Parsing CSS source code failed",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.module.css (1:8)
Error: Parsing CSS source code failed
> 1 | .button
| ^",
"stack": [],
}
`)
} else if (isRspack) {
await expect({ browser, next }).toDisplayRedbox(`
{
"description": " ╰─▶ × SyntaxError",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.module.css
╰─▶ × SyntaxError
│
│ (1:1) <FIXME-project-root>/index.module.css Unknown word .button
│
│ > 1 | .button
│ | ^
│
Import trace for requested module:
./index.module.css
./index.js
./app/page.js",
"stack": [],
}
`)
} else {
await expect({ browser, next }).toDisplayRedbox(`
{
"description": "Syntax error: <FIXME-project-root>/index.module.css Unknown word .button",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.module.css (1:1)
Syntax error: <FIXME-project-root>/index.module.css Unknown word .button
> 1 | .button
| ^",
"stack": [],
}
`)
}
// Checks for selectors that can't be prefixed.
// Selector "button" is not pure (pure selectors must contain at least one local class or id)
await session.patch('index.module.css', `button {}`)
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "Transforming CSS failed",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.module.css
Error: Transforming CSS failed
Selector "button" is not pure. Pure selectors must contain at least one local class or id.
Import traces:
Client Component Browser:
./index.module.css [Client Component Browser]
./index.js [Client Component Browser]
./app/page.js [Client Component Browser]
./app/page.js [Server Component]
Client Component SSR:
./index.module.css [Client Component SSR]
./index.js [Client Component SSR]
./app/page.js [Client Component SSR]
./app/page.js [Server Component]",
"stack": [],
}
`)
} else if (isRspack) {
await expect(browser).toDisplayRedbox(`
{
"description": " ╰─▶ × CssSyntaxError",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.module.css
╰─▶ × CssSyntaxError
│
│ (1:1) Selector "button" is not pure (pure selectors must contain at least one local class or id)
│
│ > 1 | button {}
│ | ^
│
Import trace for requested module:
./index.module.css
./index.js
./app/page.js",
"stack": [],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"description": "Syntax error: Selector "button" is not pure (pure selectors must contain at least one local class or id)",
"environmentLabel": null,
"label": "Build Error",
"source": "./index.module.css (1:1)
Syntax error: Selector "button" is not pure (pure selectors must contain at least one local class or id)
> 1 | button {}
| ^",
"stack": [],
}
`)
}
})
it('logbox: anchors links in error messages', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
await session.patch(
'index.js',
outdent`
import { useCallback } from 'react'
export default function Index() {
const boom = useCallback(() => {
throw new Error('end https://nextjs.org')
}, [])
return (
<main>
<button onClick={boom}>Boom!</button>
</main>
)
}
`
)
await browser.elementByCss('button').click()
await expect(browser).toDisplayCollapsedRedbox(`
{
"description": "end https://nextjs.org",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (5:11) @ Index.useCallback[boom]
> 5 | throw new Error('end https://nextjs.org')
| ^",
"stack": [
"Index.useCallback[boom] index.js (5:11)",
"button <anonymous>",
"Index index.js (9:7)",
"Page app/page.js (4:10)",
],
}
`)
expect(
await session.evaluate(
() =>
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelectorAll('#nextjs__container_errors_desc a')
.length
)
).toBe(1)
expect(
await session.evaluate(
() =>
(
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelector(
'#nextjs__container_errors_desc a:nth-of-type(1)'
) as any
).href
)
).toMatchInlineSnapshot(`"https://nextjs.org/"`)
await session.patch(
'index.js',
outdent`
import { useCallback } from 'react'
export default function Index() {
const boom = useCallback(() => {
throw new Error('https://nextjs.org start')
}, [])
return (
<main>
<button onClick={boom}>Boom!</button>
</main>
)
}
`
)
await browser.elementByCss('button').click()
await expect(browser).toDisplayRedbox(`
{
"description": "https://nextjs.org start",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (5:11) @ Index.useCallback[boom]
> 5 | throw new Error('https://nextjs.org start')
| ^",
"stack": [
"Index.useCallback[boom] index.js (5:11)",
"button <anonymous>",
"Index index.js (9:7)",
"Page app/page.js (4:10)",
],
}
`)
expect(
await session.evaluate(
() =>
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelectorAll('#nextjs__container_errors_desc a')
.length
)
).toBe(1)
expect(
await session.evaluate(
() =>
(
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelector(
'#nextjs__container_errors_desc a:nth-of-type(1)'
) as any
).href
)
).toMatchInlineSnapshot(`"https://nextjs.org/"`)
await session.patch(
'index.js',
outdent`
import { useCallback } from 'react'
export default function Index() {
const boom = useCallback(() => {
throw new Error('middle https://nextjs.org end')
}, [])
return (
<main>
<button onClick={boom}>Boom!</button>
</main>
)
}
`
)
await browser.elementByCss('button').click()
await expect(browser).toDisplayRedbox(`
{
"description": "middle https://nextjs.org end",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (5:11) @ Index.useCallback[boom]
> 5 | throw new Error('middle https://nextjs.org end')
| ^",
"stack": [
"Index.useCallback[boom] index.js (5:11)",
"button <anonymous>",
"Index index.js (9:7)",
"Page app/page.js (4:10)",
],
}
`)
expect(
await session.evaluate(
() =>
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelectorAll('#nextjs__container_errors_desc a')
.length
)
).toBe(1)
expect(
await session.evaluate(
() =>
(
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelector(
'#nextjs__container_errors_desc a:nth-of-type(1)'
) as any
).href
)
).toMatchInlineSnapshot(`"https://nextjs.org/"`)
await session.patch(
'index.js',
outdent`
import { useCallback } from 'react'
export default function Index() {
const boom = useCallback(() => {
throw new Error('multiple https://nextjs.org links http://example.com')
}, [])
return (
<main>
<button onClick={boom}>Boom!</button>
</main>
)
}
`
)
await browser.elementByCss('button').click()
await expect(browser).toDisplayRedbox(`
{
"description": "multiple https://nextjs.org links http://example.com",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (5:11) @ Index.useCallback[boom]
> 5 | throw new Error('multiple https://nextjs.org links http://example.com')
| ^",
"stack": [
"Index.useCallback[boom] index.js (5:11)",
"button <anonymous>",
"Index index.js (9:7)",
"Page app/page.js (4:10)",
],
}
`)
expect(
await session.evaluate(
() =>
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelectorAll('#nextjs__container_errors_desc a')
.length
)
).toBe(2)
expect(
await session.evaluate(
() =>
(
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelector(
'#nextjs__container_errors_desc a:nth-of-type(1)'
) as any
).href
)
).toMatchInlineSnapshot(`"https://nextjs.org/"`)
expect(
await session.evaluate(
() =>
(
document
.querySelector('body nextjs-portal')
.shadowRoot.querySelector(
'#nextjs__container_errors_desc a:nth-of-type(2)'
) as any
).href
)
).toBe('http://example.com/')
})
// TODO-APP: Catch errors that happen before useEffect
test.skip('non-Error errors are handled properly', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
await session.patch(
'index.js',
outdent`
export default () => {
throw {'a': 1, 'b': 'x'};
return (
<div>hello</div>
)
}
`
)
await expect(browser).toDisplayRedbox()
// fix previous error
await session.patch(
'index.js',
outdent`
export default () => {
return (
<div>hello</div>
)
}
`
)
await session.waitForNoRedbox()
await session.patch(
'index.js',
outdent`
class Hello {}
export default () => {
throw Hello
return (
<div>hello</div>
)
}
`
)
await expect(browser).toDisplayRedbox()
// fix previous error
await session.patch(
'index.js',
outdent`
export default () => {
return (
<div>hello</div>
)
}
`
)
await session.waitForNoRedbox()
await session.patch(
'index.js',
outdent`
export default () => {
throw "string error"
return (
<div>hello</div>
)
}
`
)
await expect(browser).toDisplayRedbox()
// fix previous error
await session.patch(
'index.js',
outdent`
export default () => {
return (
<div>hello</div>
)
}
`
)
await session.waitForNoRedbox()
await session.patch(
'index.js',
outdent`
export default () => {
throw null
return (
<div>hello</div>
)
}
`
)
await expect(browser).toDisplayRedbox()
})
test('Should not show __webpack_exports__ when exporting anonymous arrow function', async () => {
await using sandbox = await createSandbox(next)
const { browser, session } = sandbox
await session.patch(
'index.js',
outdent`
export default () => {
if (typeof window !== 'undefined') {
throw new Error('test')
}
return null
}
`
)
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "test",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (3:11) @ {default export}
> 3 | throw new Error('test')
| ^",
"stack": [
"{default export} index.js (3:11)",
"Page app/page.js (4:10)",
],
}
`)
} else if (isRspack) {
await expect(browser).toDisplayRedbox(`
{
"description": "test",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (3:11) @ __rspack_default_export
> 3 | throw new Error('test')
| ^",
"stack": [
"__rspack_default_export index.js (3:11)",
"Page app/page.js (4:10)",
],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"description": "test",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (3:11) @ default
> 3 | throw new Error('test')
| ^",
"stack": [
"default index.js (3:11)",
"Page app/page.js (4:10)",
],
}
`)
}
})
test('Unhandled errors and rejections opens up in the minimized state', async () => {
await using sandbox = await createSandbox(next)
const { session, browser } = sandbox
const file = outdent`
export default function Index() {
//
setTimeout(() => {
throw new Error('Unhandled error')
}, 0)
setTimeout(() => {
Promise.reject(new Error('Undhandled rejection'))
}, 0)
return (
<>
<button
id="unhandled-error"
onClick={() => {
throw new Error('Unhandled error')
}}
>
Unhandled error
</button>
<button
id="unhandled-rejection"
onClick={() => {
Promise.reject(new Error('Undhandled rejection'))
}}
>
Unhandled rejection
</button>
</>
)
}
`
await session.patch('index.js', file)
// Unhandled error and rejection in setTimeout
await retry(async () => {
expect(await getToastErrorCount(browser)).toBe(2)
})
// Unhandled error in event handler
await browser.elementById('unhandled-error').click()
await retry(async () => {
expect(await getToastErrorCount(browser)).toBe(3)
})
// Unhandled rejection in event handler
await browser.elementById('unhandled-rejection').click()
await retry(async () => {
expect(await getToastErrorCount(browser)).toBe(4)
})
await session.waitForNoRedbox()
// Add Component error
await session.patch(
'index.js',
file.replace(
'//',
"if (typeof window !== 'undefined') throw new Error('Component error')"
)
)
// Render error should "win" and show up in fullscreen
await expect(browser).toDisplayRedbox(`
{
"description": "Component error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (2:44) @ Index
> 2 | if (typeof window !== 'undefined') throw new Error('Component error')
| ^",
"stack": [
"Index index.js (2:44)",
"Page app/page.js (4:10)",
],
}
`)
})
test('Call stack for client error', async () => {
await using sandbox = await createSandbox(
next,
new Map([
[
'app/page.js',
outdent`
'use client'
export default function Page() {
if (typeof window !== 'undefined') {
throw new Error('Client error')
}
return null
}
`,
],
])
)
const { browser } = sandbox
await expect(browser).toDisplayRedbox(`
{
"description": "Client error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/page.js (4:11) @ Page
> 4 | throw new Error('Client error')
| ^",
"stack": [
"Page app/page.js (4:11)",
],
}
`)
})
test('Call stack for server error', async () => {
await using sandbox = await createSandbox(
next,
new Map([
[
'app/page.js',
outdent`
export default function Page() {
throw new Error('Server error')
}
`,
],
])
)
const { browser } = sandbox
await expect(browser).toDisplayRedbox(`
{
"description": "Server error",
"environmentLabel": "Server",
"label": "Runtime Error",
"source": "app/page.js (2:9) @ Page
> 2 | throw new Error('Server error')
| ^",
"stack": [
"Page app/page.js (2:9)",
],
}
`)
})
test('should hide unrelated frames in stack trace with unknown anonymous calls', async () => {
await using sandbox = await createSandbox(
next,
new Map([
[
'app/page.js',
outdent`
export default function Page() {
try {
(function() {
throw new Error("This is an error from an anonymous function");
})();
} catch (e) {
throw e
}
}
`,
],
])
)
const { browser } = sandbox
// TODO(veil): Turbopack uses correct name
// TODO(veil): Column of 2nd frame should be 7
await expect(browser).toDisplayRedbox(`
{
"description": "This is an error from an anonymous function",
"environmentLabel": "Server",
"label": "Runtime Error",
"source": "app/page.js (4:13) @ ${isTurbopack ? '<anonymous>' : 'eval'}
> 4 | throw new Error("This is an error from an anonymous function");
| ^",
"stack": [
"${isTurbopack ? '<anonymous>' : 'eval'} app/page.js (4:13)",
"Page app/page.js (5:${isTurbopack ? '6' : '5'})",
],
}
`)
})
it('should hide unrelated frames in stack trace with nodejs internal calls', async () => {
await using sandbox = await createSandbox(
next,
new Map([
[
'app/page.js',
// Node.js will throw an error about the invalid URL since this is a server component
outdent`
export default function Page() {
new URL("/", "invalid");
}`,
],
])
)
const { browser } = sandbox
await expect(browser).toDisplayRedbox(`
{
"description": "Invalid URL",
"environmentLabel": "Server",
"label": "Runtime TypeError",
"source": "app/page.js (2:3) @ Page
> 2 | new URL("/", "invalid");
| ^",
"stack": [
"Page app/page.js (2:3)",
],
}
`)
})
test('Server component errors should open up in fullscreen', async () => {
await using sandbox = await createSandbox(
next,
new Map([
// Start with error
[
'app/page.js',
outdent`
export default function Page() {
throw new Error('Server component error')
return <p id="text">Hello world</p>
}
`,
],
])
)
const { session, browser } = sandbox
await expect(browser).toDisplayRedbox(
`
{
"description": "Server component error",
"environmentLabel": "Server",
"label": "<FIXME-excluded-label>",
"source": "app/page.js (2:9) @ Page
> 2 | throw new Error('Server component error')
| ^",
"stack": [
"Page app/page.js (2:9)",
],
}
`,
// FIXME: `label` is flaking between "Runtime Error" and "Recoverable Error"
{ label: false }
)
// Remove error
await session.patch(
'app/page.js',
outdent`
export default function Page() {
return <p id="text">Hello world</p>
}
`
)
expect(await browser.waitForElementByCss('#text').text()).toBe(
'Hello world'
)
await session.waitForNoRedbox()
// Re-add error
await session.patch(
'app/page.js',
outdent`
export default function Page() {
throw new Error('Server component error!')
return <p id="text">Hello world</p>
}
`
)
await expect(browser).toDisplayRedbox(
`
{
"description": "Server component error!",
"environmentLabel": "Server",
"label": "<FIXME-excluded-label>",
"source": "app/page.js (2:9) @ Page
> 2 | throw new Error('Server component error!')
| ^",
"stack": [
"Page app/page.js (2:9)",
],
}
`,
// FIXME: `label` is flaking between "Runtime Error" and "Recoverable Error"
{ label: false }
)
})
test('Import trace when module not found in layout', async () => {
await using sandbox = await createSandbox(
next,
new Map([['app/module.js', `import "non-existing-module"`]])
)
const { browser, session } = sandbox
await session.patch(
'app/layout.js',
outdent`
import "./module"
export default function RootLayout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
`
)
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "Module not found: Can't resolve 'non-existing-module'",
"environmentLabel": null,
"label": "Build Error",
"source": "./app/module.js (1:1)
Error: Module not found: Can't resolve 'non-existing-module'
> 1 | import "non-existing-module"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
"stack": [],
}
`)
} else if (isRspack) {
await expect({ browser, next }).toDisplayRedbox(`
{
"description": " × Module not found: Can't resolve 'non-existing-module' in '<FIXME-project-root>/app'",
"environmentLabel": null,
"label": "Build Error",
"source": "./app/module.js
× Module not found: Can't resolve 'non-existing-module' in '<FIXME-project-root>/app'
╭────
1 │ import "non-existing-module";
· ─────────────────────────────
╰────
Import trace for requested module:
./app/module.js
./app/layout.js",
"stack": [],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"description": "Module not found: Can't resolve 'non-existing-module'",
"environmentLabel": null,
"label": "Build Error",
"source": "./app/module.js (1:1)
Module not found: Can't resolve 'non-existing-module'
> 1 | import "non-existing-module"
| ^",
"stack": [],
}
`)
}
})
test("Can't resolve @import in CSS file", async () => {
await using sandbox = await createSandbox(
next,
new Map([
['app/styles1.css', '@import "./styles2.css"'],
['app/styles2.css', '@import "./boom.css"'],
])
)
const { browser, session } = sandbox
await session.patch(
'app/layout.js',
outdent`
import "./styles1.css"
export default function RootLayout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
`
)
// Wait for patch to apply and new error to show.
if (isTurbopack) {
await expect(browser).toDisplayRedbox(`
{
"description": "Module not found: Can't resolve './boom.css'",
"environmentLabel": null,
"label": "Build Error",
"source": "./app/styles2.css (1:1)
Error: Module not found: Can't resolve './boom.css'
> 1 | @import "./boom.css"
| ^",
"stack": [],
}
`)
} else if (isRspack) {
await expect({ browser, next }).toDisplayRedbox(`
{
"description": "Failed to compile",
"environmentLabel": null,
"label": "Build Error",
"source": "╰─▶ × Error: RspackResolver(NotFound("./boom.css"))",
"stack": [],
}
`)
} else {
await expect(browser).toDisplayRedbox(`
{
"description": "Module not found: Can't resolve './boom.css'",
"environmentLabel": null,
"label": "Build Error",
"source": "./app/styles2.css
Module not found: Can't resolve './boom.css'
https://nextjs.org/docs/messages/module-not-found
Import trace for requested module:
./app/styles1.css",
"stack": [],
}
`)
}
})
// TODO: The error overlay is not closed when restoring the working code.
for (const type of ['server' /* , 'client' */]) {
test(`${type} component can recover from error thrown in the module`, async () => {
await using sandbox = await createSandbox(next, undefined, '/' + type)
const { browser, session } = sandbox
await next.patchFile('index.js', "throw new Error('module error')")
await retry(async () => {
// Should use `await expect(browser).toDisplayRedbox()`
await session.waitForRedbox()
})
if (isRspack) {
await expect({ browser, next }).toDisplayRedbox(`
{
"code": "E394",
"description": "module error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (1:7) @ eval
> 1 | throw new Error('module error')
| ^",
"stack": [
"eval index.js (1:7)",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"eval ./app/server/page.js",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
],
}
`)
} else if (!isTurbopack) {
await expect({ browser, next }).toDisplayRedbox(`
{
"code": "E394",
"description": "module error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "index.js (1:7) @ eval
> 1 | throw new Error('module error')
| ^",
"stack": [
"eval index.js (1:7)",
"<FIXME-next-dist-dir>",
"eval ./app/server/page.js",
"<FIXME-next-dist-dir>",
],
}
`)
}
await next.patchFile(
'index.js',
'export default function Page() {return <p>hello world</p>}'
)
await session.waitForNoRedbox()
})
}
test('Should show error location for server actions in client component', async () => {
await using sandbox = await createSandbox(
next,
new Map([
[
'app/actions.ts',
`"use server";
export async function serverAction(a) {
throw new Error("server action was here");
}`,
],
[
'app/page.js',
`"use client";
import { serverAction } from "./actions";
export default function Home() {
return (
<>
<form action={serverAction}>
<button id="trigger-action">Submit</button>
</form>
</>
);
}`,
],
])
)
const { browser } = sandbox
await browser.elementByCss('#trigger-action').click()
await expect(browser).toDisplayRedbox(`
{
"description": "server action was here",
"environmentLabel": "Server",
"label": "Runtime Error",
"source": "app/actions.ts (4:9) @ serverAction
> 4 | throw new Error("server action was here");
| ^",
"stack": [
"serverAction app/actions.ts (4:9)",
"form <anonymous>",
"Home app/page.js (7:7)",
],
}
`)
})
test('Should show error location for server actions in server component', async () => {
await using sandbox = await createSandbox(
next,
new Map([
[
'app/actions.ts',
`"use server";
export async function serverAction(a) {
throw new Error("server action was here");
}`,
],
[
'app/page.js',
`import { serverAction } from "./actions";
export default function Home() {
return (
<>
<form action={serverAction}>
<button id="trigger-action">Submit</button>
</form>
</>
);
}`,
],
])
)
const { browser } = sandbox
await browser.elementByCss('#trigger-action').click()
await expect(browser).toDisplayRedbox(`
{
"description": "server action was here",
"environmentLabel": "Server",
"label": "Runtime Error",
"source": "app/actions.ts (4:9) @ serverAction
> 4 | throw new Error("server action was here");
| ^",
"stack": [
"serverAction app/actions.ts (4:9)",
"form <anonymous>",
"Home app/page.js (6:7)",
],
}
`)
})
test('should collapse bundler internal stack frames', async () => {
await using sandbox = await createSandbox(
next,
new Map([
[
'app/utils.ts',
`throw new Error('utils error')
export function foo(){}
`,
],
[
'app/page.js',
`"use client";
import { foo } from "./utils";
export default function Home() {
foo();
return "hello";
}`,
],
])
)
const { browser } = sandbox
if (isTurbopack) {
// FIXME: display the sourcemapped stack frames
await expect(browser).toDisplayRedbox(`
{
"description": "utils error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/utils.ts (1:7) @ module evaluation
> 1 | throw new Error('utils error')
| ^",
"stack": [
"module evaluation app/utils.ts (1:7)",
"module evaluation app/page.js (2:1)",
],
}
`)
} else if (isRspack) {
await expect(browser).toDisplayRedbox(`
{
"description": "utils error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/utils.ts (1:7) @ eval
> 1 | throw new Error('utils error')
| ^",
"stack": [
"eval app/utils.ts (1:7)",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"eval ./app/page.js",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
"<FIXME-next-dist-dir>",
],
}
`)
} else {
// FIXME: Webpack stack frames are not source mapped
await expect(browser).toDisplayRedbox(`
[
{
"description": "utils error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/utils.ts (1:7) @ eval
> 1 | throw new Error('utils error')
| ^",
"stack": [
"eval app/utils.ts (1:7)",
"<FIXME-next-dist-dir>",
"eval ./app/page.js",
"<FIXME-next-dist-dir>",
],
},
{
"description": "utils error",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/utils.ts (1:7) @ eval
> 1 | throw new Error('utils error')
| ^",
"stack": [
"eval app/utils.ts (1:7)",
"<FIXME-next-dist-dir>",
"eval ./app/page.js",
"<FIXME-next-dist-dir>",
],
},
]
`)
}
})
})