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 (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head></head>
|
<head />
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
@ -37,7 +37,7 @@ function getRootLayout(isTs: boolean) {
|
||||||
return `export default function RootLayout({ children }) {
|
return `export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head></head>
|
<head />
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</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({
|
export async function verifyRootLayout({
|
||||||
dir,
|
dir,
|
||||||
appDir,
|
appDir,
|
||||||
|
@ -63,15 +76,38 @@ export async function verifyRootLayout({
|
||||||
appDir,
|
appDir,
|
||||||
`**/layout.{${pageExtensions.join(',')}}`
|
`**/layout.{${pageExtensions.join(',')}}`
|
||||||
)
|
)
|
||||||
const hasLayout = layoutFiles.length !== 0
|
|
||||||
|
|
||||||
const normalizedPagePath = pagePath.replace(`${APP_DIR_ALIAS}/`, '')
|
const normalizedPagePath = pagePath.replace(`${APP_DIR_ALIAS}/`, '')
|
||||||
const firstSegmentValue = normalizedPagePath.split('/')[0]
|
const pagePathSegments = normalizedPagePath.split('/')
|
||||||
const pageRouteGroup = firstSegmentValue.startsWith('(')
|
|
||||||
? firstSegmentValue
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
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 resolvedTsConfigPath = path.join(dir, tsconfigPath)
|
||||||
const hasTsConfig = await fs.access(resolvedTsConfigPath).then(
|
const hasTsConfig = await fs.access(resolvedTsConfigPath).then(
|
||||||
() => true,
|
() => true,
|
||||||
|
@ -80,19 +116,35 @@ export async function verifyRootLayout({
|
||||||
|
|
||||||
const rootLayoutPath = path.join(
|
const rootLayoutPath = path.join(
|
||||||
appDir,
|
appDir,
|
||||||
// If the page is within a route group directly under app (e.g. app/(routegroup)/page.js)
|
availableDir,
|
||||||
// prefer creating the root layout in that route group. Otherwise create the root layout in the app root.
|
|
||||||
pageRouteGroup ? pageRouteGroup : '',
|
|
||||||
`layout.${hasTsConfig ? 'tsx' : 'js'}`
|
`layout.${hasTsConfig ? 'tsx' : 'js'}`
|
||||||
)
|
)
|
||||||
await fs.writeFile(rootLayoutPath, getRootLayout(hasTsConfig))
|
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(
|
console.log(
|
||||||
chalk.green(
|
chalk.green(
|
||||||
`\nYour page ${chalk.bold(
|
`\nYour page ${chalk.bold(
|
||||||
`app/${normalizedPagePath}`
|
`app/${normalizedPagePath}`
|
||||||
)} did not have a root layout, we created ${chalk.bold(
|
)} did not have a root layout, we created ${chalk.bold(
|
||||||
`app${rootLayoutPath.replace(appDir, '')}`
|
`app${rootLayoutPath.replace(appDir, '')}`
|
||||||
)} for you.`
|
)}${
|
||||||
|
!hasHead
|
||||||
|
? ` and ${chalk.bold(`app${headPath.replace(appDir, '')}`)}`
|
||||||
|
: ''
|
||||||
|
} for you.`
|
||||||
) + '\n'
|
) + '\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import path from 'path'
|
||||||
import { createNext, FileRef } from 'e2e-utils'
|
import { createNext, FileRef } from 'e2e-utils'
|
||||||
import { NextInstance } from 'test/lib/next-modes/base'
|
import { NextInstance } from 'test/lib/next-modes/base'
|
||||||
import webdriver from 'next-webdriver'
|
import webdriver from 'next-webdriver'
|
||||||
|
import { check } from 'next-test-utils'
|
||||||
|
|
||||||
describe('app-dir create root layout', () => {
|
describe('app-dir create root layout', () => {
|
||||||
const isDev = (global as any).isNextDev
|
const isDev = (global as any).isNextDev
|
||||||
|
@ -23,9 +24,7 @@ describe('app-dir create root layout', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
next = await createNext({
|
next = await createNext({
|
||||||
files: {
|
files: {
|
||||||
'app/page.js': new FileRef(
|
app: new FileRef(path.join(__dirname, 'create-root-layout/app')),
|
||||||
path.join(__dirname, 'create-root-layout/app/page.js')
|
|
||||||
),
|
|
||||||
'next.config.js': new FileRef(
|
'next.config.js': new FileRef(
|
||||||
path.join(__dirname, 'create-root-layout/next.config.js')
|
path.join(__dirname, 'create-root-layout/next.config.js')
|
||||||
),
|
),
|
||||||
|
@ -40,27 +39,44 @@ describe('app-dir create root layout', () => {
|
||||||
|
|
||||||
it('create root layout', async () => {
|
it('create root layout', async () => {
|
||||||
const outputIndex = next.cliOutput.length
|
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(
|
expect(await browser.elementById('page-text').text()).toBe(
|
||||||
'Hello world!'
|
'Hello world!'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(next.cliOutput.slice(outputIndex)).toInclude(
|
await check(
|
||||||
'Your page app/page.js did not have a root layout, we created app/layout.js for you.'
|
() => 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(`
|
expect(await next.readFile('app/layout.js')).toMatchInlineSnapshot(`
|
||||||
"export default function RootLayout({ children }) {
|
"export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head></head>
|
<head />
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</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 () => {
|
it('create root layout', async () => {
|
||||||
const outputIndex = next.cliOutput.length
|
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(
|
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(
|
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(`
|
.toMatchInlineSnapshot(`
|
||||||
"export default function RootLayout({ children }) {
|
"export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head></head>
|
<head />
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</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({
|
next = await createNext({
|
||||||
files: {
|
files: {
|
||||||
'app/page.tsx': new FileRef(
|
'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(
|
'next.config.js': new FileRef(
|
||||||
path.join(__dirname, 'create-root-layout/next.config.js')
|
path.join(__dirname, 'create-root-layout/next.config.js')
|
||||||
|
@ -141,25 +242,42 @@ describe('app-dir create root layout', () => {
|
||||||
'Hello world!'
|
'Hello world!'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await check(
|
||||||
|
() => next.cliOutput.slice(outputIndex),
|
||||||
|
/did not have a root layout/
|
||||||
|
)
|
||||||
expect(next.cliOutput.slice(outputIndex)).toInclude(
|
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(`
|
expect(await next.readFile('app/layout.tsx')).toMatchInlineSnapshot(`
|
||||||
"export default function RootLayout({
|
"export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head></head>
|
<head />
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</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 {
|
} else {
|
||||||
|
@ -169,7 +287,7 @@ describe('app-dir create root layout', () => {
|
||||||
skipStart: true,
|
skipStart: true,
|
||||||
files: {
|
files: {
|
||||||
'app/page.js': new FileRef(
|
'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(
|
'next.config.js': new FileRef(
|
||||||
path.join(__dirname, 'create-root-layout/next.config.js')
|
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 }) {
|
export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head></head>
|
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</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