mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
Improve the RSC error messages to be more comprehensive. x-ref: https://github.com/vercel/next.js/pull/89688#discussion_r2865847895 --------- Co-authored-by: Claude Haiku 4.5 <[email protected]>
582 lines
21 KiB
TypeScript
582 lines
21 KiB
TypeScript
import { FileRef, nextTestSetup } from 'e2e-utils'
|
||
import path from 'path'
|
||
import { createSandbox } from 'development-sandbox'
|
||
import { outdent } from 'outdent'
|
||
|
||
describe('Error overlay - RSC build errors', () => {
|
||
const { next, isTurbopack } = nextTestSetup({
|
||
files: new FileRef(path.join(__dirname, 'fixtures', 'rsc-build-errors')),
|
||
skipStart: true,
|
||
})
|
||
|
||
it('should throw an error when getServerSideProps is used', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/client-with-errors/get-server-side-props'
|
||
)
|
||
const { session } = sandbox
|
||
const pageFile = 'app/client-with-errors/get-server-side-props/page.js'
|
||
const content = await next.readFile(pageFile)
|
||
const uncomment = content.replace(
|
||
'// export function getServerSideProps',
|
||
'export function getServerSideProps'
|
||
)
|
||
await session.patch(pageFile, uncomment)
|
||
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
'"getServerSideProps" is not supported in app/'
|
||
)
|
||
})
|
||
|
||
it('should throw an error when metadata export is used in client components', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/client-with-errors/metadata-export'
|
||
)
|
||
const { session } = sandbox
|
||
const pageFile = 'app/client-with-errors/metadata-export/page.js'
|
||
const content = await next.readFile(pageFile)
|
||
|
||
// Add `metadata` error
|
||
let uncomment = content.replace(
|
||
'// export const metadata',
|
||
'export const metadata'
|
||
)
|
||
await session.patch(pageFile, uncomment)
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
'You are attempting to export "metadata" from a component marked with "use client", which is disallowed.'
|
||
)
|
||
|
||
// Restore file
|
||
await session.patch(pageFile, content)
|
||
await session.waitForNoRedbox()
|
||
|
||
// Add `generateMetadata` error
|
||
uncomment = content.replace(
|
||
'// export async function generateMetadata',
|
||
'export async function generateMetadata'
|
||
)
|
||
await session.patch(pageFile, uncomment)
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
'You are attempting to export "generateMetadata" from a component marked with "use client", which is disallowed.'
|
||
)
|
||
|
||
// Fix the error again to test error overlay works with hmr rebuild
|
||
await session.patch(pageFile, content)
|
||
await session.waitForNoRedbox()
|
||
})
|
||
|
||
it('should throw an error when metadata exports are used together in server components', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/server-with-errors/metadata-export'
|
||
)
|
||
const { session } = sandbox
|
||
const pageFile = 'app/server-with-errors/metadata-export/page.js'
|
||
const content = await next.readFile(pageFile)
|
||
const uncomment = content.replace(
|
||
'// export async function generateMetadata',
|
||
'export async function generateMetadata'
|
||
)
|
||
|
||
await session.patch(pageFile, uncomment)
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
'"metadata" and "generateMetadata" cannot be exported at the same time, please keep one of them.'
|
||
)
|
||
})
|
||
|
||
// TODO: investigate flakey test case
|
||
it.skip('should throw an error when getStaticProps is used', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/client-with-errors/get-static-props'
|
||
)
|
||
const { session } = sandbox
|
||
const pageFile = 'app/client-with-errors/get-static-props/page.js'
|
||
const content = await next.readFile(pageFile)
|
||
const uncomment = content.replace(
|
||
'// export function getStaticProps',
|
||
'export function getStaticProps'
|
||
)
|
||
await session.patch(pageFile, uncomment)
|
||
await next.patchFile(pageFile, content)
|
||
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
'"getStaticProps" is not supported in app/'
|
||
)
|
||
})
|
||
|
||
it('should throw an error when "use client" is on the top level but after other expressions', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/swc/use-client'
|
||
)
|
||
const { session } = sandbox
|
||
const pageFile = 'app/swc/use-client/page.js'
|
||
const content = await next.readFile(pageFile)
|
||
const uncomment = content.replace("// 'use client'", "'use client'")
|
||
await next.patchFile(pageFile, uncomment)
|
||
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
'directive must be placed before other expressions'
|
||
)
|
||
})
|
||
|
||
it('should throw an error when "Component" is imported in server components', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/server-with-errors/class-component'
|
||
)
|
||
const { session } = sandbox
|
||
const pageFile = 'app/server-with-errors/class-component/page.js'
|
||
const content = await next.readFile(pageFile)
|
||
const uncomment = content.replace(
|
||
"// import { Component } from 'react'",
|
||
"import { Component } from 'react'"
|
||
)
|
||
await session.patch(pageFile, uncomment)
|
||
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
`You’re importing a class component. It only works in a Client Component`
|
||
)
|
||
})
|
||
|
||
it('should allow to use and handle rsc poisoning client-only', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/server-with-errors/client-only-in-server'
|
||
)
|
||
const { session } = sandbox
|
||
const file =
|
||
'app/server-with-errors/client-only-in-server/client-only-lib.js'
|
||
const content = await next.readFile(file)
|
||
const uncomment = content.replace(
|
||
"// import 'client-only'",
|
||
"import 'client-only'"
|
||
)
|
||
await next.patchFile(file, uncomment)
|
||
|
||
await session.waitForRedbox()
|
||
if (isTurbopack) {
|
||
// TODO: fix the issue ordering.
|
||
// turbopack emits the resolve issue first instead of the transform issue.
|
||
expect(await session.getRedboxSource()).toMatchInlineSnapshot(`
|
||
"./app/server-with-errors/client-only-in-server/client-only-lib.js (1:1)
|
||
You're importing a component that imports client-only. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.
|
||
Learn more: https://nextjs.org/docs/app/building-your-application/rendering
|
||
> 1 | import 'client-only'
|
||
| ^^^^^^^^^^^^^^^^^^^^
|
||
2 |
|
||
3 | export default function ClientOnlyLib() {
|
||
4 | return 'client-only-lib'
|
||
|
||
Ecmascript file had an error
|
||
|
||
Import trace:
|
||
Server Component:
|
||
./app/server-with-errors/client-only-in-server/client-only-lib.js
|
||
./app/server-with-errors/client-only-in-server/page.js"
|
||
`)
|
||
} else {
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
`You're importing a component that imports client-only. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.`
|
||
)
|
||
}
|
||
})
|
||
|
||
const invalidReactServerApis = [
|
||
'Component',
|
||
'createContext',
|
||
'createFactory',
|
||
'PureComponent',
|
||
'useDeferredValue',
|
||
'useEffect',
|
||
'useEffectEvent',
|
||
'useImperativeHandle',
|
||
'useInsertionEffect',
|
||
'useLayoutEffect',
|
||
'useReducer',
|
||
'useRef',
|
||
'useState',
|
||
'useSyncExternalStore',
|
||
'useTransition',
|
||
'useOptimistic',
|
||
'useActionState',
|
||
]
|
||
for (const api of invalidReactServerApis) {
|
||
it(`should error when ${api} from react is used in server component`, async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
`/server-with-errors/react-apis/${api.toLowerCase()}`
|
||
)
|
||
const { session } = sandbox
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
// `Component` has a custom error message
|
||
api === 'Component'
|
||
? `You’re importing a class component. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.`
|
||
: `You're importing a module that depends on \`${api}\` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the \`"use client"\` directive.`
|
||
)
|
||
})
|
||
}
|
||
|
||
const invalidReactDomServerApis = [
|
||
'flushSync',
|
||
'unstable_batchedUpdates',
|
||
'useFormStatus',
|
||
'useFormState',
|
||
]
|
||
for (const api of invalidReactDomServerApis) {
|
||
it(`should error when ${api} from react-dom is used in server component`, async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
`/server-with-errors/react-dom-apis/${api.toLowerCase()}`
|
||
)
|
||
const { session } = sandbox
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
`You're importing a module that depends on \`${api}\` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the \`"use client"\` directive.`
|
||
)
|
||
})
|
||
}
|
||
|
||
it('should error when unstable_catchError from next/error is used in a server component', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
new Map([
|
||
[
|
||
'app/page.js',
|
||
outdent`
|
||
import { unstable_catchError } from 'next/error'
|
||
|
||
export default function Page() {
|
||
return 'Hello world'
|
||
}
|
||
`,
|
||
],
|
||
])
|
||
)
|
||
|
||
const { session } = sandbox
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
'You\'re importing a module that depends on `unstable_catchError` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.'
|
||
)
|
||
})
|
||
|
||
test.each([
|
||
['middleware.js', 'export function middleware() {}'],
|
||
['proxy.js', 'export function proxy() {}'],
|
||
['instrumentation.js', 'export function register() {}'],
|
||
])(
|
||
'should error when unstable_catchError from next/error is imported in %s',
|
||
async (entryFile, exportCode) => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
new Map([
|
||
[
|
||
'app/page.js',
|
||
outdent`
|
||
export default function Page() {
|
||
return 'Hello world'
|
||
}
|
||
`,
|
||
],
|
||
[
|
||
entryFile,
|
||
outdent`
|
||
import { unstable_catchError } from 'next/error'
|
||
${exportCode}
|
||
`,
|
||
],
|
||
])
|
||
)
|
||
|
||
const { session } = sandbox
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
'You\'re importing a module that depends on `unstable_catchError` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.'
|
||
)
|
||
}
|
||
)
|
||
|
||
it('should allow to use and handle rsc poisoning server-only', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/client-with-errors/server-only-in-client'
|
||
)
|
||
const { session } = sandbox
|
||
const file =
|
||
'app/client-with-errors/server-only-in-client/server-only-lib.js'
|
||
const content = await next.readFile(file)
|
||
const uncomment = content.replace(
|
||
"// import 'server-only'",
|
||
"import 'server-only'"
|
||
)
|
||
|
||
await session.patch(file, uncomment)
|
||
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
`You're importing a module that depends on "server-only" into a React Client Component module. This API is only available in Server Components but one of its parents is marked with "use client", so this module is also a Client Component.`
|
||
)
|
||
})
|
||
|
||
describe("importing 'next/cache' APIs in a client component", () => {
|
||
test.each(['revalidatePath', 'revalidateTag', 'cacheLife', 'cacheTag'])(
|
||
'%s is not allowed',
|
||
async (api) => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
`/server-with-errors/next-cache-in-client/${api.toLowerCase()}`
|
||
)
|
||
const { session } = sandbox
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
`You're importing a module that depends on "${api}" into a React Client Component module. This API is only available in Server Components but one of its parents is marked with "use client", so this module is also a Client Component.`
|
||
)
|
||
}
|
||
)
|
||
|
||
test.each([
|
||
'unstable_cache', // useless in client, but doesn't technically error
|
||
'unstable_noStore', // no-op in client, but allowed for legacy reasons
|
||
])('%s is allowed', async (api) => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
`/server-with-errors/next-cache-in-client/${api.toLowerCase()}`
|
||
)
|
||
const { session } = sandbox
|
||
await session.waitForNoRedbox()
|
||
})
|
||
})
|
||
|
||
describe('next/root-params', () => {
|
||
const isCacheComponentsEnabled =
|
||
process.env.__NEXT_CACHE_COMPONENTS === 'true'
|
||
it("importing 'next/root-params' when experimental.rootParams is not enabled", async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
`/server-with-errors/next-root-params/without-flag`
|
||
)
|
||
const { session } = sandbox
|
||
await session.waitForRedbox()
|
||
if (!isCacheComponentsEnabled) {
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
`'next/root-params' can only be imported when \`experimental.rootParams\` is enabled.`
|
||
)
|
||
} else {
|
||
// in cacheComponents we auto-enable 'next/root-params', so we should get an error about using a non-existent getter instead.
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
isTurbopack
|
||
? `Export whatever doesn't exist in target module`
|
||
: `Attempted import error: 'whatever' is not exported from 'next/root-params' (imported as 'whatever').`
|
||
)
|
||
}
|
||
})
|
||
|
||
it("importing 'next/root-params' in a client component", async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
// if cacheComponents is not enabled, the import is guarded behind an experimental flag
|
||
isCacheComponentsEnabled
|
||
? new Map()
|
||
: new Map([
|
||
[
|
||
'next.config.js',
|
||
outdent`
|
||
module.exports = { experimental: { rootParams: true } }
|
||
`,
|
||
],
|
||
]),
|
||
`/server-with-errors/next-root-params/in-client`
|
||
)
|
||
const { session } = sandbox
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
`You're importing a module that depends on "next/root-params" into a React Client Component module. This API is only available in Server Components but one of its parents is marked with "use client", so this module is also a Client Component.`
|
||
)
|
||
})
|
||
|
||
it("importing 'next/root-params' in a client component in a way that bypasses import analysis", async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
// if cacheComponents is not enabled, the import is guarded behind an experimental flag
|
||
isCacheComponentsEnabled
|
||
? new Map()
|
||
: new Map([
|
||
[
|
||
'next.config.js',
|
||
outdent`
|
||
module.exports = { experimental: { rootParams: true } }
|
||
`,
|
||
],
|
||
]),
|
||
`/server-with-errors/next-root-params/in-client-await-import`
|
||
)
|
||
const { session } = sandbox
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxSource()).toInclude(
|
||
`'next/root-params' cannot be imported from a Client Component module. It should only be used from a Server Component.`
|
||
)
|
||
})
|
||
})
|
||
|
||
it('should error for invalid undefined module retuning from next dynamic', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/client-with-errors/dynamic'
|
||
)
|
||
const { session } = sandbox
|
||
const file = 'app/client-with-errors/dynamic/page.js'
|
||
const content = await next.readFile(file)
|
||
await session.patch(
|
||
file,
|
||
content.replace('() => <p>hello dynamic world</p>', 'undefined')
|
||
)
|
||
|
||
await session.waitForRedbox()
|
||
expect(await session.getRedboxDescription()).toInclude(
|
||
`Element type is invalid. Received a promise that resolves to: undefined. Lazy element type must resolve to a class or function.`
|
||
)
|
||
})
|
||
|
||
it('should throw an error when error file is a server component', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/server-with-errors/error-file'
|
||
)
|
||
const { session } = sandbox
|
||
// Remove "use client"
|
||
await session.patch(
|
||
'app/server-with-errors/error-file/error.js',
|
||
'export default function Error() {}'
|
||
)
|
||
|
||
await session.waitForRedbox()
|
||
await expect(session.getRedboxSource()).resolves.toMatch(
|
||
/must be a Client \n| Component/
|
||
)
|
||
if (process.env.IS_TURBOPACK_TEST) {
|
||
expect(next.normalizeTestDirContent(await session.getRedboxSource()))
|
||
.toMatchInlineSnapshot(`
|
||
"./app/server-with-errors/error-file/error.js (1:1)
|
||
app/server-with-errors/error-file/error.js must be a Client Component. Add the "use client" directive the top of the file to resolve this issue.
|
||
Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
|
||
> 1 | export default function Error() {}
|
||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||
|
||
Ecmascript file had an error"
|
||
`)
|
||
} else {
|
||
await expect(session.getRedboxSource()).resolves.toMatch(
|
||
/Add the "use client"/
|
||
)
|
||
|
||
// TODO: investigate flakey snapshot due to spacing below
|
||
// expect(next.normalizeTestDirContent(await session.getRedboxSource()))
|
||
// .toMatchInlineSnapshot(`
|
||
// "./app/server-with-errors/error-file/error.js
|
||
// Error: x TEST_DIR/app/server-with-errors/error-file/error.js must be a Client
|
||
// | Component. Add the "use client" directive the top of the file to resolve this issue.
|
||
// | Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
|
||
// |
|
||
// |
|
||
// ,----
|
||
// 1 | export default function Error() {}
|
||
// : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||
// \`----
|
||
|
||
// Import trace for requested module:
|
||
// ./app/server-with-errors/error-file/error.js"
|
||
// `)
|
||
}
|
||
})
|
||
|
||
it('should throw an error when error file is a server component with empty error file', async () => {
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/server-with-errors/error-file'
|
||
)
|
||
const { session } = sandbox
|
||
// Empty file
|
||
await session.patch('app/server-with-errors/error-file/error.js', '')
|
||
|
||
await session.waitForRedbox()
|
||
await expect(session.getRedboxSource()).resolves.toMatch(
|
||
/Add the "use client"/
|
||
)
|
||
|
||
// TODO: investigate flakey snapshot due to spacing below
|
||
// expect(next.normalizeTestDirContent(await session.getRedboxSource()))
|
||
// .toMatchInlineSnapshot(n`
|
||
// "./app/server-with-errors/error-file/error.js
|
||
// ReactServerComponentsError:
|
||
|
||
// ./app/server-with-errors/error-file/error.js must be a Client Component. Add the "use client" directive the top of the file to resolve this issue.
|
||
|
||
// ,-[TEST_DIR/app/server-with-errors/error-file/error.js:1:1]
|
||
// 1 |
|
||
// : ^
|
||
// \`----
|
||
|
||
// Import path:
|
||
// ./app/server-with-errors/error-file/error.js"
|
||
// `)
|
||
})
|
||
|
||
it('should freeze parent resolved metadata to avoid mutating in generateMetadata', async () => {
|
||
const pagePath = 'app/metadata/mutate/page.js'
|
||
const content = outdent`
|
||
export default function page(props) {
|
||
return <p>mutate</p>
|
||
}
|
||
|
||
export async function generateMetadata(props, parent) {
|
||
const parentMetadata = await parent
|
||
parentMetadata.x = 1
|
||
return {
|
||
...parentMetadata,
|
||
}
|
||
}
|
||
`
|
||
|
||
await using sandbox = await createSandbox(
|
||
next,
|
||
undefined,
|
||
'/metadata/mutate'
|
||
)
|
||
const { session } = sandbox
|
||
await session.patch(pagePath, content)
|
||
|
||
await session.waitForRedbox()
|
||
|
||
expect(await session.getRedboxDescription()).toContain(
|
||
'Cannot add property x, object is not extensible'
|
||
)
|
||
})
|
||
})
|