From 17d7e59339123e6d395f1714a7c359a5f1d4b6fe Mon Sep 17 00:00:00 2001
From: JJ Kasper
Date: Wed, 18 Aug 2021 06:01:02 -0500
Subject: [PATCH] Fix HMR when custom _app or _document is removed (#28227)
This adds the fallback webpack alias handling to handle a custom `_app` or `_document` being removed in development gracefully.
## Bug
- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [x] Errors have helpful link attached, see `contributing.md`
Fixes: https://github.com/vercel/next.js/issues/27888
---
packages/next/build/webpack-config.ts | 52 +++++--
.../app-document-remove-hmr/pages/_app.js | 8 ++
.../pages/_document.js | 23 ++++
.../app-document-remove-hmr/pages/index.js | 3 +
.../test/index.test.js | 128 ++++++++++++++++++
5 files changed, 205 insertions(+), 9 deletions(-)
create mode 100644 test/integration/app-document-remove-hmr/pages/_app.js
create mode 100644 test/integration/app-document-remove-hmr/pages/_document.js
create mode 100644 test/integration/app-document-remove-hmr/pages/index.js
create mode 100644 test/integration/app-document-remove-hmr/test/index.test.js
diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts
index 4e85f9b13df83bb..0a5c5bc90c598a6 100644
--- a/packages/next/build/webpack-config.ts
+++ b/packages/next/build/webpack-config.ts
@@ -413,6 +413,28 @@ export default async function getBaseWebpackConfig(
resolvedBaseUrl = path.resolve(dir, jsConfig.compilerOptions.baseUrl)
}
+ let customAppFile: string | null = await findPageFile(
+ pagesDir,
+ '/_app',
+ config.pageExtensions
+ )
+ let customAppFileExt = customAppFile ? path.extname(customAppFile) : null
+ if (customAppFile) {
+ customAppFile = path.resolve(path.join(pagesDir, customAppFile))
+ }
+
+ let customDocumentFile: string | null = await findPageFile(
+ pagesDir,
+ '/_document',
+ config.pageExtensions
+ )
+ let customDocumentFileExt = customDocumentFile
+ ? path.extname(customDocumentFile)
+ : null
+ if (customDocumentFile) {
+ customDocumentFile = path.resolve(path.join(pagesDir, customDocumentFile))
+ }
+
function getReactProfilingInProduction() {
if (reactProductionProfiling) {
return {
@@ -454,6 +476,27 @@ export default async function getBaseWebpackConfig(
],
alias: {
next: NEXT_PROJECT_ROOT,
+
+ // fallback to default _app when custom is removed
+ ...(dev && customAppFileExt && isWebpack5
+ ? {
+ [`${PAGES_DIR_ALIAS}/_app${customAppFileExt}`]: [
+ path.join(pagesDir, `_app${customAppFileExt}`),
+ 'next/dist/pages/_app.js',
+ ],
+ }
+ : {}),
+
+ // fallback to default _document when custom is removed
+ ...(dev && customDocumentFileExt && isWebpack5
+ ? {
+ [`${PAGES_DIR_ALIAS}/_document${customDocumentFileExt}`]: [
+ path.join(pagesDir, `_document${customDocumentFileExt}`),
+ 'next/dist/pages/_document.js',
+ ],
+ }
+ : {}),
+
[PAGES_DIR_ALIAS]: pagesDir,
[DOT_NEXT_ALIAS]: distDir,
...getOptimizedAliases(isServer),
@@ -647,15 +690,6 @@ export default async function getBaseWebpackConfig(
const crossOrigin = config.crossOrigin
- let customAppFile: string | null = await findPageFile(
- pagesDir,
- '/_app',
- config.pageExtensions
- )
- if (customAppFile) {
- customAppFile = path.resolve(path.join(pagesDir, customAppFile))
- }
-
const conformanceConfig = Object.assign(
{
ReactSyncScriptsConformanceCheck: {
diff --git a/test/integration/app-document-remove-hmr/pages/_app.js b/test/integration/app-document-remove-hmr/pages/_app.js
new file mode 100644
index 000000000000000..11e56041b984b4e
--- /dev/null
+++ b/test/integration/app-document-remove-hmr/pages/_app.js
@@ -0,0 +1,8 @@
+export default function MyApp({ Component, pageProps }) {
+ return (
+ <>
+ custom _app
+
+ >
+ )
+}
diff --git a/test/integration/app-document-remove-hmr/pages/_document.js b/test/integration/app-document-remove-hmr/pages/_document.js
new file mode 100644
index 000000000000000..8a14e8e67143036
--- /dev/null
+++ b/test/integration/app-document-remove-hmr/pages/_document.js
@@ -0,0 +1,23 @@
+import Document, { Html, Head, Main, NextScript } from 'next/document'
+
+class MyDocument extends Document {
+ static async getInitialProps(ctx) {
+ const initialProps = await Document.getInitialProps(ctx)
+ return { ...initialProps }
+ }
+
+ render() {
+ return (
+
+
+
+ custom _document
+
+
+
+
+ )
+ }
+}
+
+export default MyDocument
diff --git a/test/integration/app-document-remove-hmr/pages/index.js b/test/integration/app-document-remove-hmr/pages/index.js
new file mode 100644
index 000000000000000..08263e34c35fd22
--- /dev/null
+++ b/test/integration/app-document-remove-hmr/pages/index.js
@@ -0,0 +1,3 @@
+export default function Page() {
+ return index page
+}
diff --git a/test/integration/app-document-remove-hmr/test/index.test.js b/test/integration/app-document-remove-hmr/test/index.test.js
new file mode 100644
index 000000000000000..dd5b850ede58673
--- /dev/null
+++ b/test/integration/app-document-remove-hmr/test/index.test.js
@@ -0,0 +1,128 @@
+/* eslint-env jest */
+
+import fs from 'fs-extra'
+import { join } from 'path'
+import webdriver from 'next-webdriver'
+import { killApp, findPort, launchApp, check } from 'next-test-utils'
+
+jest.setTimeout(1000 * 60 * 2)
+
+const appDir = join(__dirname, '../')
+const appPage = join(appDir, 'pages/_app.js')
+const indexPage = join(appDir, 'pages/index.js')
+const documentPage = join(appDir, 'pages/_document.js')
+
+let appPort
+let app
+
+describe('_app removal HMR', () => {
+ beforeAll(async () => {
+ appPort = await findPort()
+ app = await launchApp(appDir, appPort)
+ })
+ afterAll(() => killApp(app))
+
+ it('should HMR when _app is removed', async () => {
+ let indexContent = await fs.readFile(indexPage)
+ try {
+ const browser = await webdriver(appPort, '/')
+
+ const html = await browser.eval('document.documentElement.innerHTML')
+ expect(html).toContain('custom _app')
+
+ await fs.rename(appPage, appPage + '.bak')
+
+ await check(async () => {
+ const html = await browser.eval('document.documentElement.innerHTML')
+ return html.includes('index page') && !html.includes('custom _app')
+ ? 'success'
+ : html
+ }, 'success')
+
+ await fs.writeFile(
+ indexPage,
+ `
+ export default function Page() {
+ return index page updated
+ }
+ `
+ )
+
+ await check(async () => {
+ const html = await browser.eval('document.documentElement.innerHTML')
+ return html.indexOf('index page updated') &&
+ !html.includes('custom _app')
+ ? 'success'
+ : html
+ }, 'success')
+
+ await fs.rename(appPage + '.bak', appPage)
+
+ await check(async () => {
+ const html = await browser.eval('document.documentElement.innerHTML')
+ return html.includes('index page updated') &&
+ html.includes('custom _app')
+ ? 'success'
+ : html
+ }, 'success')
+ } finally {
+ await fs.writeFile(indexPage, indexContent)
+
+ if (await fs.pathExists(appPage + '.bak')) {
+ await fs.rename(appPage + '.bak', appPage)
+ }
+ }
+ })
+
+ it('should HMR when _document is removed', async () => {
+ let indexContent = await fs.readFile(indexPage)
+ try {
+ const browser = await webdriver(appPort, '/')
+
+ const html = await browser.eval('document.documentElement.innerHTML')
+ expect(html).toContain('custom _document')
+
+ await fs.rename(documentPage, documentPage + '.bak')
+
+ await check(async () => {
+ const html = await browser.eval('document.documentElement.innerHTML')
+ return html.includes('index page') && !html.includes('custom _document')
+ ? 'success'
+ : html
+ }, 'success')
+
+ await fs.writeFile(
+ indexPage,
+ `
+ export default function Page() {
+ return index page updated
+ }
+ `
+ )
+
+ await check(async () => {
+ const html = await browser.eval('document.documentElement.innerHTML')
+ return html.indexOf('index page updated') &&
+ !html.includes('custom _document')
+ ? 'success'
+ : html
+ }, 'success')
+
+ await fs.rename(documentPage + '.bak', documentPage)
+
+ await check(async () => {
+ const html = await browser.eval('document.documentElement.innerHTML')
+ return html.includes('index page updated') &&
+ html.includes('custom _document')
+ ? 'success'
+ : html
+ }, 'success')
+ } finally {
+ await fs.writeFile(indexPage, indexContent)
+
+ if (await fs.pathExists(documentPage + '.bak')) {
+ await fs.rename(documentPage + '.bak', documentPage)
+ }
+ }
+ })
+})