Files
vercel__next.js/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts
David Alexandru Ilie ba9f073cc2 Add a Turbopack error for missing root layouts (#97639)
## Summary

Turbopack currently builds an App Router page without a root layout,
leaving the problem to surface later at runtime. Report it while
building the app structure instead:

`route/page.js doesn't have a root layout. To fix this error, make sure
every page has a root layout.`

The check uses the layouts already inherited during directory-tree
traversal, so route groups and apps with multiple root layouts keep
working. Route handlers are unaffected.

Webpack behavior is intentionally unchanged in this PR.

## Verification

- `HEADLESS=true pnpm test-dev-turbo
test/e2e/app-dir/create-root-layout/create-root-layout.test.ts`
- `HEADLESS=true pnpm test-start-turbo
test/e2e/app-dir/create-root-layout/create-root-layout.test.ts`
- `HEADLESS=true pnpm test-dev-webpack
test/e2e/app-dir/create-root-layout/create-root-layout.test.ts`
- `HEADLESS=true pnpm test-start-webpack
test/e2e/app-dir/create-root-layout/create-root-layout.test.ts`
- `HEADLESS=true pnpm test-dev-turbo
test/e2e/app-dir/root-layout/root-layout.test.ts`
2026-08-21 15:07:42 +01:00

264 lines
8.2 KiB
TypeScript

import path from 'path'
import { FileRef, nextTestSetup } from 'e2e-utils'
import { check, retry } from 'next-test-utils'
import stripAnsi from 'strip-ansi'
// Skip on Turbopack because the user should create the layout manually
;(process.env.IS_TURBOPACK_TEST ? describe.skip : describe)(
'app-dir create root layout',
() => {
const isDev = (global as any).isNextDev
if ((global as any).isNextDeploy) {
it('should skip next deploy for now', () => {})
return
}
if (isDev) {
describe('page.js', () => {
describe('root layout in app', () => {
const { next } = nextTestSetup({
files: {
app: new FileRef(path.join(__dirname, 'app')),
'next.config.js': new FileRef(
path.join(__dirname, 'next.config.js')
),
},
})
it('create root layout', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser('/route')
expect(await browser.elementById('page-text').text()).toBe(
'Hello world!'
)
await check(
() => stripAnsi(next.cliOutput.slice(outputIndex)),
/did not have a root layout/
)
expect(stripAnsi(next.cliOutput.slice(outputIndex))).toMatch(
'Your page app/route/page.js did not have a root layout. We created app/layout.js for you.'
)
expect(await next.readFile('app/layout.js')).toMatchInlineSnapshot(`
"export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
"
`)
})
})
describe('root layout in route group', () => {
const { next } = nextTestSetup({
files: {
app: new FileRef(path.join(__dirname, 'app-group-layout')),
'next.config.js': new FileRef(
path.join(__dirname, 'next.config.js')
),
},
})
it('create root layout', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser('/')
expect(await browser.elementById('page-text').text()).toBe(
'Hello world'
)
await check(
() => stripAnsi(next.cliOutput.slice(outputIndex)),
/did not have a root layout/
)
expect(stripAnsi(next.cliOutput.slice(outputIndex))).toInclude(
'Your page app/(group)/page.js did not have a root layout. We created app/(group)/layout.js for you.'
)
expect(await next.readFile('app/(group)/layout.js'))
.toMatchInlineSnapshot(`
"export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
"
`)
})
})
describe('find available dir', () => {
const { next } = nextTestSetup({
files: {
app: new FileRef(path.join(__dirname, 'app-find-available-dir')),
'next.config.js': new FileRef(
path.join(__dirname, 'next.config.js')
),
},
})
it('create root layout', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser('/route/second/inner')
expect(await browser.elementById('page-text').text()).toBe(
'Hello world'
)
await check(
() => stripAnsi(next.cliOutput.slice(outputIndex)),
/did not have a root layout/
)
expect(stripAnsi(next.cliOutput.slice(outputIndex))).toInclude(
'Your page app/(group)/route/second/inner/page.js did not have a root layout. We created app/(group)/route/second/layout.js for you.'
)
expect(await next.readFile('app/(group)/route/second/layout.js'))
.toMatchInlineSnapshot(`
"export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
"
`)
})
})
})
describe('page.tsx', () => {
const { next } = nextTestSetup({
files: {
'app/page.tsx': new FileRef(
path.join(__dirname, 'app/route/page.js')
),
'next.config.js': new FileRef(
path.join(__dirname, 'next.config.js')
),
},
})
it('create root layout', async () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser('/')
expect(await browser.elementById('page-text').text()).toBe(
'Hello world!'
)
await check(
() => stripAnsi(next.cliOutput.slice(outputIndex)),
/did not have a root layout/
)
expect(stripAnsi(next.cliOutput.slice(outputIndex))).toInclude(
'Your page app/page.tsx did not have a root layout. We created app/layout.tsx for you.'
)
expect(await next.readFile('app/layout.tsx')).toMatchInlineSnapshot(`
"export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
"
`)
})
})
} else {
describe('build', () => {
const { next } = nextTestSetup({
skipStart: true,
files: {
'app/page.js': new FileRef(
path.join(__dirname, 'app/route/page.js')
),
'next.config.js': new FileRef(
path.join(__dirname, 'next.config.js')
),
},
})
it('should break the build if a page is missing root layout', async () => {
await expect(next.start()).rejects.toThrow('next build failed')
expect(stripAnsi(next.cliOutput)).toInclude(
"page.js doesn't have a root layout. To fix this error, make sure every page has a root layout."
)
})
})
}
}
)
;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)(
'app-dir missing root layout',
() => {
const { next, isNextDev, skipped } = nextTestSetup({
files: {
app: new FileRef(path.join(__dirname, 'app')),
'next.config.js': new FileRef(path.join(__dirname, 'next.config.js')),
},
skipDeployment: true,
skipStart: true,
})
if (skipped) return
it('reports a compiler error without modifying the app', async () => {
if (isNextDev) {
await next.start()
const response = await next.fetch('/route')
expect(response.status).toBe(500)
await retry(async () => {
expect(stripAnsi(next.cliOutput)).toContain(
"route/page.js doesn't have a root layout. To fix this error, make sure every page has a root layout."
)
})
} else {
await expect(next.start()).rejects.toThrow('next build failed')
expect(stripAnsi(next.cliOutput)).toContain(
"route/page.js doesn't have a root layout. To fix this error, make sure every page has a root layout."
)
}
expect(await next.hasFile('app/layout.js')).toBe(false)
})
}
)