From a105515766118da9ff7aa17967de326a9cc4a2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Born=C3=B6?= Date: Sun, 23 Oct 2022 09:39:30 +0200 Subject: [PATCH] Create root layout (#41523) Creates a root layout if it is missing in DEV when building the page in `next-app-loader`. If the page is in a route group, the layout will be created there. Otherwise it will create a layout directly under `/app`. Breaks the build if a page is found that's missing a root layout. ## 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 lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/build/entries.ts | 3 + .../build/webpack/loaders/next-app-loader.ts | 54 ++++- .../internal/container/RootLayoutError.tsx | 8 +- packages/next/lib/verifyRootLayout.ts | 108 ++++++++++ packages/next/server/dev/hot-reloader.ts | 6 + test/.stats-app/app/layout.js | 7 + test/e2e/app-dir/create-root-layout.test.ts | 192 ++++++++++++++++++ .../app-group-layout/(group1)/layout.js | 8 + .../app-group-layout/(group1)/path1/page.js | 3 + .../app-group-layout/(group2)/path2/page.js | 3 + .../app-dir/create-root-layout/app/page.js | 3 + .../app-dir/create-root-layout/next.config.js | 5 + 12 files changed, 392 insertions(+), 8 deletions(-) create mode 100644 packages/next/lib/verifyRootLayout.ts create mode 100644 test/.stats-app/app/layout.js create mode 100644 test/e2e/app-dir/create-root-layout.test.ts create mode 100644 test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/layout.js create mode 100644 test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/path1/page.js create mode 100644 test/e2e/app-dir/create-root-layout/app-group-layout/(group2)/path2/page.js create mode 100644 test/e2e/app-dir/create-root-layout/app/page.js create mode 100644 test/e2e/app-dir/create-root-layout/next.config.js diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index b35b6dfab2641..c43f1d9f8abc4 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -217,6 +217,9 @@ export function getAppEntry(opts: { appDir: string appPaths: string[] | null pageExtensions: string[] + isDev?: boolean + rootDir?: string + tsconfigPath?: string }) { return { import: `next-app-loader?${stringify(opts)}!`, diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 1cb65e7e84323..93df9c1d85147 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -1,8 +1,12 @@ import type webpack from 'webpack' +import chalk from 'next/dist/compiled/chalk' import type { ValueOf } from '../../../shared/lib/constants' import { NODE_RESOLVE_OPTIONS } from '../../webpack-config' import { getModuleBuildInfo } from './get-module-build-info' import { sep } from 'path' +import { verifyRootLayout } from '../../../lib/verifyRootLayout' +import * as Log from '../../../build/output/log' +import { APP_DIR_ALIAS } from '../../../lib/constants' export const FILE_TYPES = { layout: 'layout', @@ -35,6 +39,7 @@ async function createTreeCodeFromPath({ const splittedPath = pagePath.split(/[\\/]/) const appDirPrefix = splittedPath[0] const pages: string[] = [] + let rootLayout: string | undefined async function createSubtreePropsFromSegmentPath( segments: string[] @@ -82,6 +87,12 @@ async function createTreeCodeFromPath({ }) ) + if (!rootLayout) { + rootLayout = filePaths.find( + ([type, path]) => type === 'layout' && !!path + )?.[1] + } + props[parallelKey] = `[ '${parallelSegment}', ${subtree}, @@ -111,7 +122,7 @@ async function createTreeCodeFromPath({ } const tree = await createSubtreePropsFromSegmentPath([]) - return [`const tree = ${tree}.children;`, pages] + return [`const tree = ${tree}.children;`, pages, rootLayout] } function createAbsolutePath(appDir: string, pathToTurnAbsolute: string) { @@ -129,9 +140,20 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ appDir: string appPaths: string[] | null pageExtensions: string[] + rootDir?: string + tsconfigPath?: string + isDev?: boolean }> = async function nextAppLoader() { - const { name, appDir, appPaths, pagePath, pageExtensions } = - this.getOptions() || {} + const { + name, + appDir, + appPaths, + pagePath, + pageExtensions, + rootDir, + tsconfigPath, + isDev, + } = this.getOptions() || {} const buildInfo = getModuleBuildInfo((this as any)._module) buildInfo.route = { @@ -182,12 +204,36 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ } } - const [treeCode, pages] = await createTreeCodeFromPath({ + const [treeCode, pages, rootLayout] = await createTreeCodeFromPath({ pagePath, resolve: resolver, resolveParallelSegments, }) + if (!rootLayout) { + const errorMessage = `${chalk.bold( + pagePath.replace(`${APP_DIR_ALIAS}/`, '') + )} doesn't have a root layout. To fix this error, make sure every page has a root layout.` + + if (!isDev) { + // If we're building and missing a root layout, exit the build + Log.error(errorMessage) + process.exit(1) + } else { + // In dev we'll try to create a root layout + const createdRootLayout = await verifyRootLayout({ + appDir: appDir, + dir: rootDir!, + tsconfigPath: tsconfigPath!, + pagePath, + pageExtensions, + }) + if (!createdRootLayout) { + throw new Error(errorMessage) + } + } + } + const result = ` export ${treeCode} export const pages = ${JSON.stringify(pages)} diff --git a/packages/next/client/components/react-dev-overlay/internal/container/RootLayoutError.tsx b/packages/next/client/components/react-dev-overlay/internal/container/RootLayoutError.tsx index ae61be5d20b35..e9d9648b87f67 100644 --- a/packages/next/client/components/react-dev-overlay/internal/container/RootLayoutError.tsx +++ b/packages/next/client/components/react-dev-overlay/internal/container/RootLayoutError.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import React from 'react' import { Dialog, DialogBody, @@ -32,7 +32,7 @@ export const RootLayoutError: React.FC =

- Root layout error + Missing required tags

@@ -40,8 +40,8 @@ export const RootLayoutError: React.FC =

- This error occurred during the build process and can only be - dismissed by fixing the error. + This error and can only be dismissed by providing all + required tags.

diff --git a/packages/next/lib/verifyRootLayout.ts b/packages/next/lib/verifyRootLayout.ts new file mode 100644 index 0000000000000..b20e69accb149 --- /dev/null +++ b/packages/next/lib/verifyRootLayout.ts @@ -0,0 +1,108 @@ +import path from 'path' +import { promises as fs } from 'fs' +import chalk from 'next/dist/compiled/chalk' +import * as Log from '../build/output/log' +import { APP_DIR_ALIAS } from './constants' + +const globOrig = + require('next/dist/compiled/glob') as typeof import('next/dist/compiled/glob') +const glob = (cwd: string, pattern: string): Promise => { + return new Promise((resolve, reject) => { + globOrig(pattern, { cwd }, (err, files) => { + if (err) { + return reject(err) + } + resolve(files) + }) + }) +} + +function getRootLayout(isTs: boolean) { + if (isTs) { + return `export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + ) +} +` + } + + return `export default function RootLayout({ children }) { + return ( + + + {children} + + ) +} +` +} + +export async function verifyRootLayout({ + dir, + appDir, + tsconfigPath, + pagePath, + pageExtensions, +}: { + dir: string + appDir: string + tsconfigPath: string + pagePath: string + pageExtensions: string[] +}) { + try { + const layoutFiles = await glob( + 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 + + if (pageRouteGroup || !hasLayout) { + const resolvedTsConfigPath = path.join(dir, tsconfigPath) + const hasTsConfig = await fs.access(resolvedTsConfigPath).then( + () => true, + () => false + ) + + 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 : '', + `layout.${hasTsConfig ? 'tsx' : 'js'}` + ) + await fs.writeFile(rootLayoutPath, getRootLayout(hasTsConfig)) + 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.` + ) + '\n' + ) + + // Created root layout + return true + } + } catch (error) { + Log.error('Failed to create root layout', error) + } + + // Didn't create root layout + return false +} diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index 92f890f1971ec..607d871ac4c5b 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -624,6 +624,9 @@ export default class HotReloader { ), appDir: this.appDir!, pageExtensions: this.config.pageExtensions, + rootDir: this.dir, + isDev: true, + tsconfigPath: this.config.typescript.tsconfigPath, }).import : undefined @@ -702,6 +705,9 @@ export default class HotReloader { ), appDir: this.appDir!, pageExtensions: this.config.pageExtensions, + rootDir: this.dir, + isDev: true, + tsconfigPath: this.config.typescript.tsconfigPath, }) : relativeRequest, hasAppDir, diff --git a/test/.stats-app/app/layout.js b/test/.stats-app/app/layout.js new file mode 100644 index 0000000000000..803f17d863c8a --- /dev/null +++ b/test/.stats-app/app/layout.js @@ -0,0 +1,7 @@ +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/create-root-layout.test.ts b/test/e2e/app-dir/create-root-layout.test.ts new file mode 100644 index 0000000000000..66eb1e85e2eaa --- /dev/null +++ b/test/e2e/app-dir/create-root-layout.test.ts @@ -0,0 +1,192 @@ +import path from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import webdriver from 'next-webdriver' + +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 (process.env.NEXT_TEST_REACT_VERSION === '^17') { + it('should skip for react v17', () => {}) + return + } + let next: NextInstance + + if (isDev) { + describe('page.js', () => { + describe('root layout in app', () => { + beforeAll(async () => { + next = await createNext({ + files: { + 'app/page.js': new FileRef( + path.join(__dirname, 'create-root-layout/app/page.js') + ), + '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, '/') + + 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.' + ) + + expect(await next.readFile('app/layout.js')).toMatchInlineSnapshot(` + "export default function RootLayout({ children }) { + return ( + + + {children} + + ) + } + " + `) + }) + }) + + describe('root layout in route group', () => { + beforeAll(async () => { + next = await createNext({ + files: { + app: new FileRef( + path.join(__dirname, 'create-root-layout/app-group-layout') + ), + '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, '/path2') + + expect(await browser.elementById('page-text').text()).toBe( + 'Hello world 2' + ) + + 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.' + ) + + expect(await next.readFile('app/(group2)/layout.js')) + .toMatchInlineSnapshot(` + "export default function RootLayout({ children }) { + return ( + + + {children} + + ) + } + " + `) + }) + }) + }) + + describe('page.tsx', () => { + beforeAll(async () => { + next = await createNext({ + files: { + 'app/page.tsx': new FileRef( + path.join(__dirname, 'create-root-layout/app/page.js') + ), + 'next.config.js': new FileRef( + path.join(__dirname, 'create-root-layout/next.config.js') + ), + }, + dependencies: { + react: 'experimental', + 'react-dom': 'experimental', + typescript: 'latest', + '@types/react': 'latest', + '@types/node': 'latest', + }, + }) + }) + afterAll(() => next.destroy()) + + it('create root layout', async () => { + const outputIndex = next.cliOutput.length + const browser = await webdriver(next.url, '/') + + expect(await browser.elementById('page-text').text()).toBe( + 'Hello world!' + ) + + expect(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 default function RootLayout({ + children, + }: { + children: React.ReactNode + }) { + return ( + + + {children} + + ) + } + " + `) + }) + }) + } else { + describe('build', () => { + it('should break the build if a page is missing root layout', async () => { + const next = await createNext({ + skipStart: true, + files: { + 'app/page.js': new FileRef( + path.join(__dirname, 'create-root-layout/app/page.js') + ), + 'next.config.js': new FileRef( + path.join(__dirname, 'create-root-layout/next.config.js') + ), + }, + dependencies: { + react: 'experimental', + 'react-dom': 'experimental', + }, + }) + + await expect(next.start()).rejects.toThrow('next build failed') + expect(next.cliOutput).toInclude( + "page.js doesn't have a root layout. To fix this error, make sure every page has a root layout." + ) + await next.destroy() + }) + }) + } +}) 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-group-layout/(group1)/layout.js new file mode 100644 index 0000000000000..747270b45987a --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/layout.js @@ -0,0 +1,8 @@ +export default function RootLayout({ children }) { + return ( + + + {children} + + ) +} 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 new file mode 100644 index 0000000000000..2e60ca9ad6386 --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/app-group-layout/(group1)/path1/page.js @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000000000..4a8d52f9706a2 --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/app-group-layout/(group2)/path2/page.js @@ -0,0 +1,3 @@ +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/page.js new file mode 100644 index 0000000000000..a690aa4e6398a --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/app/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello world!

+} diff --git a/test/e2e/app-dir/create-root-layout/next.config.js b/test/e2e/app-dir/create-root-layout/next.config.js new file mode 100644 index 0000000000000..cfa3ac3d7aa94 --- /dev/null +++ b/test/e2e/app-dir/create-root-layout/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + appDir: true, + }, +}