diff --git a/packages/next/lib/verifyRootLayout.ts b/packages/next/lib/verifyRootLayout.ts index b20e69accb149c2..144184b784f6541 100644 --- a/packages/next/lib/verifyRootLayout.ts +++ b/packages/next/lib/verifyRootLayout.ts @@ -26,7 +26,7 @@ function getRootLayout(isTs: boolean) { }) { return ( - + {children} ) @@ -37,7 +37,7 @@ function getRootLayout(isTs: boolean) { return `export default function RootLayout({ children }) { return ( - + {children} ) @@ -45,6 +45,19 @@ function getRootLayout(isTs: boolean) { ` } +function getHead() { + return `export default function Head() { + return ( + <> + + + + + ) +} +` +} + 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('/') + + // 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 (pageRouteGroup || !hasLayout) { + 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' ) diff --git a/test/e2e/app-dir/create-root-layout.test.ts b/test/e2e/app-dir/create-root-layout.test.ts index 66eb1e85e2eaa5c..da4eeab95e6404c 100644 --- a/test/e2e/app-dir/create-root-layout.test.ts +++ b/test/e2e/app-dir/create-root-layout.test.ts @@ -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 ( - - - {children} - - ) - } - " - `) + "export default function RootLayout({ children }) { + return ( + + + {children} + + ) + } + " + `) + + expect(await next.readFile('app/head.js')).toMatchInlineSnapshot(` + "export default function Head() { + return ( + <> + + + + + ) + } + " + `) }) }) @@ -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 ( + + + {children} + + ) + } + " + `) + + expect(await next.readFile('app/(group)/head.js')) .toMatchInlineSnapshot(` - "export default function RootLayout({ children }) { - return ( - - - {children} - + "export default function Head() { + return ( + <> + + + + + ) + } + " + `) + }) + }) + + 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 ( + + + {children} + + ) + } + " + `) + + expect(await next.readFile('app/(group)/route/second/head.js')) + .toMatchInlineSnapshot(` + "export default function Head() { + return ( + <> + + + + + ) + } + " + `) }) }) }) @@ -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,25 +242,42 @@ 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(` - "export default function RootLayout({ - children, - }: { - children: React.ReactNode - }) { - return ( - - - {children} - - ) - } - " - `) + "export default function RootLayout({ + children, + }: { + children: React.ReactNode + }) { + return ( + + + {children} + + ) + } + " + `) + + expect(await next.readFile('app/head.tsx')).toMatchInlineSnapshot(` + "export default function Head() { + return ( + <> + + + + + ) + } + " + `) }) }) } 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') diff --git a/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/head.js b/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/head.js new file mode 100644 index 000000000000000..772e640e75cefac --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/head.js @@ -0,0 +1,9 @@ +export default function Head() { + return ( + <> + + + + + ) +} diff --git a/test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/layout.js b/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/layout.js similarity index 85% rename from test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/layout.js rename to test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/layout.js index 747270b45987a2b..803f17d863c8ad8 100644 --- a/test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/layout.js +++ b/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/layout.js @@ -1,7 +1,6 @@ export default function RootLayout({ children }) { return ( - {children} ) diff --git a/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/page.js b/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/page.js new file mode 100644 index 000000000000000..faaea962365bb7e --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/first/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello world

+} diff --git a/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/second/inner/page.js b/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/second/inner/page.js new file mode 100644 index 000000000000000..faaea962365bb7e --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/app-find-available-dir/(group)/route/second/inner/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello world

+} diff --git a/test/e2e/app-dir/create-root-layout/app-group-layout/(group)/page.js b/test/e2e/app-dir/create-root-layout/app-group-layout/(group)/page.js new file mode 100644 index 000000000000000..faaea962365bb7e --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/app-group-layout/(group)/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello world

+} diff --git a/test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/path1/page.js b/test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/path1/page.js deleted file mode 100644 index 2e60ca9ad638683..000000000000000 --- a/test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/path1/page.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function Page() { - return

Hello world 1

-} diff --git a/test/e2e/app-dir/create-root-layout/app-group-layout/(group2)/path2/page.js b/test/e2e/app-dir/create-root-layout/app-group-layout/(group2)/path2/page.js deleted file mode 100644 index 4a8d52f9706a252..000000000000000 --- a/test/e2e/app-dir/create-root-layout/app-group-layout/(group2)/path2/page.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function Page() { - return

Hello world 2

-} diff --git a/test/e2e/app-dir/create-root-layout/app/page.js b/test/e2e/app-dir/create-root-layout/app/route/page.js similarity index 100% rename from test/e2e/app-dir/create-root-layout/app/page.js rename to test/e2e/app-dir/create-root-layout/app/route/page.js