Also create head when creating root layout (#42571)
When creating a root layout automatically in dev - also create a default head file. This PR also makes sure more cases are covered when creating root layouts by trying to find an available dir as close to ` app/` as possible that won't affect other layouts. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
This commit is contained in:
parent
81890c8cfb
commit
25d5d8c2e9
10 changed files with 247 additions and 66 deletions
|
@ -26,7 +26,7 @@ function getRootLayout(isTs: boolean) {
|
|||
}) {
|
||||
return (
|
||||
<html>
|
||||
<head></head>
|
||||
<head />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
|
@ -37,7 +37,7 @@ function getRootLayout(isTs: boolean) {
|
|||
return `export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head></head>
|
||||
<head />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
|
@ -45,6 +45,19 @@ function getRootLayout(isTs: boolean) {
|
|||
`
|
||||
}
|
||||
|
||||
function getHead() {
|
||||
return `export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<title></title>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export async function verifyRootLayout({
|
||||
dir,
|
||||
appDir,
|
||||
|
@ -63,15 +76,38 @@ export async function verifyRootLayout({
|
|||
appDir,
|
||||
`**/layout.{${pageExtensions.join(',')}}`
|
||||
)
|
||||
const hasLayout = layoutFiles.length !== 0
|
||||
|
||||
const normalizedPagePath = pagePath.replace(`${APP_DIR_ALIAS}/`, '')
|
||||
const firstSegmentValue = normalizedPagePath.split('/')[0]
|
||||
const pageRouteGroup = firstSegmentValue.startsWith('(')
|
||||
? firstSegmentValue
|
||||
: undefined
|
||||
const pagePathSegments = normalizedPagePath.split('/')
|
||||
|
||||
if (pageRouteGroup || !hasLayout) {
|
||||
// Find an available dir to place the layout file in, the layout file can't affect any other layout.
|
||||
// Place the layout as close to app/ as possible.
|
||||
let availableDir: string | undefined
|
||||
|
||||
if (layoutFiles.length === 0) {
|
||||
// If there's no other layout file we can place the layout file in the app dir.
|
||||
// However, if the page is within a route group directly under app (e.g. app/(routegroup)/page.js)
|
||||
// prefer creating the root layout in that route group.
|
||||
const firstSegmentValue = pagePathSegments[0]
|
||||
availableDir = firstSegmentValue.startsWith('(') ? firstSegmentValue : ''
|
||||
} else {
|
||||
pagePathSegments.pop() // remove the page from segments
|
||||
|
||||
let currentSegments: string[] = []
|
||||
for (const segment of pagePathSegments) {
|
||||
currentSegments.push(segment)
|
||||
// Find the dir closest to app/ where a layout can be created without affecting other layouts.
|
||||
if (
|
||||
!layoutFiles.some((file) =>
|
||||
file.startsWith(currentSegments.join('/'))
|
||||
)
|
||||
) {
|
||||
availableDir = currentSegments.join('/')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof availableDir === 'string') {
|
||||
const resolvedTsConfigPath = path.join(dir, tsconfigPath)
|
||||
const hasTsConfig = await fs.access(resolvedTsConfigPath).then(
|
||||
() => true,
|
||||
|
@ -80,19 +116,35 @@ export async function verifyRootLayout({
|
|||
|
||||
const rootLayoutPath = path.join(
|
||||
appDir,
|
||||
// If the page is within a route group directly under app (e.g. app/(routegroup)/page.js)
|
||||
// prefer creating the root layout in that route group. Otherwise create the root layout in the app root.
|
||||
pageRouteGroup ? pageRouteGroup : '',
|
||||
availableDir,
|
||||
`layout.${hasTsConfig ? 'tsx' : 'js'}`
|
||||
)
|
||||
await fs.writeFile(rootLayoutPath, getRootLayout(hasTsConfig))
|
||||
const headPath = path.join(
|
||||
appDir,
|
||||
availableDir,
|
||||
`head.${hasTsConfig ? 'tsx' : 'js'}`
|
||||
)
|
||||
const hasHead = await fs.access(headPath).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
|
||||
if (!hasHead) {
|
||||
await fs.writeFile(headPath, getHead())
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`\nYour page ${chalk.bold(
|
||||
`app/${normalizedPagePath}`
|
||||
)} did not have a root layout, we created ${chalk.bold(
|
||||
`app${rootLayoutPath.replace(appDir, '')}`
|
||||
)} for you.`
|
||||
)}${
|
||||
!hasHead
|
||||
? ` and ${chalk.bold(`app${headPath.replace(appDir, '')}`)}`
|
||||
: ''
|
||||
} for you.`
|
||||
) + '\n'
|
||||
)
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import path from 'path'
|
|||
import { createNext, FileRef } from 'e2e-utils'
|
||||
import { NextInstance } from 'test/lib/next-modes/base'
|
||||
import webdriver from 'next-webdriver'
|
||||
import { check } from 'next-test-utils'
|
||||
|
||||
describe('app-dir create root layout', () => {
|
||||
const isDev = (global as any).isNextDev
|
||||
|
@ -23,9 +24,7 @@ describe('app-dir create root layout', () => {
|
|||
beforeAll(async () => {
|
||||
next = await createNext({
|
||||
files: {
|
||||
'app/page.js': new FileRef(
|
||||
path.join(__dirname, 'create-root-layout/app/page.js')
|
||||
),
|
||||
app: new FileRef(path.join(__dirname, 'create-root-layout/app')),
|
||||
'next.config.js': new FileRef(
|
||||
path.join(__dirname, 'create-root-layout/next.config.js')
|
||||
),
|
||||
|
@ -40,27 +39,44 @@ describe('app-dir create root layout', () => {
|
|||
|
||||
it('create root layout', async () => {
|
||||
const outputIndex = next.cliOutput.length
|
||||
const browser = await webdriver(next.url, '/')
|
||||
const browser = await webdriver(next.url, '/route')
|
||||
|
||||
expect(await browser.elementById('page-text').text()).toBe(
|
||||
'Hello world!'
|
||||
)
|
||||
|
||||
expect(next.cliOutput.slice(outputIndex)).toInclude(
|
||||
'Your page app/page.js did not have a root layout, we created app/layout.js for you.'
|
||||
await check(
|
||||
() => next.cliOutput.slice(outputIndex),
|
||||
/did not have a root layout/
|
||||
)
|
||||
expect(next.cliOutput.slice(outputIndex)).toMatch(
|
||||
'Your page app/route/page.js did not have a root layout, we created app/layout.js and app/head.js for you.'
|
||||
)
|
||||
|
||||
expect(await next.readFile('app/layout.js')).toMatchInlineSnapshot(`
|
||||
"export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head></head>
|
||||
<head />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
"
|
||||
`)
|
||||
|
||||
expect(await next.readFile('app/head.js')).toMatchInlineSnapshot(`
|
||||
"export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<title></title>
|
||||
<meta content=\\"width=device-width, initial-scale=1\\" name=\\"viewport\\" />
|
||||
<link rel=\\"icon\\" href=\\"/favicon.ico\\" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -85,28 +101,113 @@ describe('app-dir create root layout', () => {
|
|||
|
||||
it('create root layout', async () => {
|
||||
const outputIndex = next.cliOutput.length
|
||||
const browser = await webdriver(next.url, '/path2')
|
||||
const browser = await webdriver(next.url, '/')
|
||||
|
||||
expect(await browser.elementById('page-text').text()).toBe(
|
||||
'Hello world 2'
|
||||
'Hello world'
|
||||
)
|
||||
|
||||
await check(
|
||||
() => next.cliOutput.slice(outputIndex),
|
||||
/did not have a root layout/
|
||||
)
|
||||
expect(next.cliOutput.slice(outputIndex)).toInclude(
|
||||
'Your page app/(group2)/path2/page.js did not have a root layout, we created app/(group2)/layout.js for you.'
|
||||
'Your page app/(group)/page.js did not have a root layout, we created app/(group)/layout.js and app/(group)/head.js for you.'
|
||||
)
|
||||
|
||||
expect(await next.readFile('app/(group2)/layout.js'))
|
||||
expect(await next.readFile('app/(group)/layout.js'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head></head>
|
||||
<head />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
"
|
||||
`)
|
||||
|
||||
expect(await next.readFile('app/(group)/head.js'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<title></title>
|
||||
<meta content=\\"width=device-width, initial-scale=1\\" name=\\"viewport\\" />
|
||||
<link rel=\\"icon\\" href=\\"/favicon.ico\\" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('find available dir', () => {
|
||||
beforeAll(async () => {
|
||||
next = await createNext({
|
||||
files: {
|
||||
app: new FileRef(
|
||||
path.join(
|
||||
__dirname,
|
||||
'create-root-layout/app-find-available-dir'
|
||||
)
|
||||
),
|
||||
'next.config.js': new FileRef(
|
||||
path.join(__dirname, 'create-root-layout/next.config.js')
|
||||
),
|
||||
},
|
||||
dependencies: {
|
||||
react: 'experimental',
|
||||
'react-dom': 'experimental',
|
||||
},
|
||||
})
|
||||
})
|
||||
afterAll(() => next.destroy())
|
||||
|
||||
it('create root layout', async () => {
|
||||
const outputIndex = next.cliOutput.length
|
||||
const browser = await webdriver(next.url, '/route/second/inner')
|
||||
|
||||
expect(await browser.elementById('page-text').text()).toBe(
|
||||
'Hello world'
|
||||
)
|
||||
|
||||
await check(
|
||||
() => next.cliOutput.slice(outputIndex),
|
||||
/did not have a root layout/
|
||||
)
|
||||
expect(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 and app/(group)/route/second/head.js for you.'
|
||||
)
|
||||
|
||||
expect(await next.readFile('app/(group)/route/second/layout.js'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
"
|
||||
`)
|
||||
|
||||
expect(await next.readFile('app/(group)/route/second/head.js'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<title></title>
|
||||
<meta content=\\"width=device-width, initial-scale=1\\" name=\\"viewport\\" />
|
||||
<link rel=\\"icon\\" href=\\"/favicon.ico\\" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -116,7 +217,7 @@ describe('app-dir create root layout', () => {
|
|||
next = await createNext({
|
||||
files: {
|
||||
'app/page.tsx': new FileRef(
|
||||
path.join(__dirname, 'create-root-layout/app/page.js')
|
||||
path.join(__dirname, 'create-root-layout/app/route/page.js')
|
||||
),
|
||||
'next.config.js': new FileRef(
|
||||
path.join(__dirname, 'create-root-layout/next.config.js')
|
||||
|
@ -141,8 +242,12 @@ describe('app-dir create root layout', () => {
|
|||
'Hello world!'
|
||||
)
|
||||
|
||||
await check(
|
||||
() => next.cliOutput.slice(outputIndex),
|
||||
/did not have a root layout/
|
||||
)
|
||||
expect(next.cliOutput.slice(outputIndex)).toInclude(
|
||||
'Your page app/page.tsx did not have a root layout, we created app/layout.tsx for you.'
|
||||
'Your page app/page.tsx did not have a root layout, we created app/layout.tsx and app/head.tsx for you.'
|
||||
)
|
||||
|
||||
expect(await next.readFile('app/layout.tsx')).toMatchInlineSnapshot(`
|
||||
|
@ -153,13 +258,26 @@ describe('app-dir create root layout', () => {
|
|||
}) {
|
||||
return (
|
||||
<html>
|
||||
<head></head>
|
||||
<head />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
"
|
||||
`)
|
||||
|
||||
expect(await next.readFile('app/head.tsx')).toMatchInlineSnapshot(`
|
||||
"export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<title></title>
|
||||
<meta content=\\"width=device-width, initial-scale=1\\" name=\\"viewport\\" />
|
||||
<link rel=\\"icon\\" href=\\"/favicon.ico\\" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
|
@ -169,7 +287,7 @@ describe('app-dir create root layout', () => {
|
|||
skipStart: true,
|
||||
files: {
|
||||
'app/page.js': new FileRef(
|
||||
path.join(__dirname, 'create-root-layout/app/page.js')
|
||||
path.join(__dirname, 'create-root-layout/app/route/page.js')
|
||||
),
|
||||
'next.config.js': new FileRef(
|
||||
path.join(__dirname, 'create-root-layout/next.config.js')
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<title></title>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head></head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="page-text">Hello world</p>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="page-text">Hello world</p>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="page-text">Hello world</p>
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function Page() {
|
||||
return <p id="page-text">Hello world 1</p>
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function Page() {
|
||||
return <p id="page-text">Hello world 2</p>
|
||||
}
|
Loading…
Reference in a new issue