Skip to content

Commit 17d7e59

Browse files
authored
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: #27888
1 parent 51559f5 commit 17d7e59

File tree

5 files changed

+205
-9
lines changed

5 files changed

+205
-9
lines changed

packages/next/build/webpack-config.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,28 @@ export default async function getBaseWebpackConfig(
413413
resolvedBaseUrl = path.resolve(dir, jsConfig.compilerOptions.baseUrl)
414414
}
415415

416+
let customAppFile: string | null = await findPageFile(
417+
pagesDir,
418+
'/_app',
419+
config.pageExtensions
420+
)
421+
let customAppFileExt = customAppFile ? path.extname(customAppFile) : null
422+
if (customAppFile) {
423+
customAppFile = path.resolve(path.join(pagesDir, customAppFile))
424+
}
425+
426+
let customDocumentFile: string | null = await findPageFile(
427+
pagesDir,
428+
'/_document',
429+
config.pageExtensions
430+
)
431+
let customDocumentFileExt = customDocumentFile
432+
? path.extname(customDocumentFile)
433+
: null
434+
if (customDocumentFile) {
435+
customDocumentFile = path.resolve(path.join(pagesDir, customDocumentFile))
436+
}
437+
416438
function getReactProfilingInProduction() {
417439
if (reactProductionProfiling) {
418440
return {
@@ -454,6 +476,27 @@ export default async function getBaseWebpackConfig(
454476
],
455477
alias: {
456478
next: NEXT_PROJECT_ROOT,
479+
480+
// fallback to default _app when custom is removed
481+
...(dev && customAppFileExt && isWebpack5
482+
? {
483+
[`${PAGES_DIR_ALIAS}/_app${customAppFileExt}`]: [
484+
path.join(pagesDir, `_app${customAppFileExt}`),
485+
'next/dist/pages/_app.js',
486+
],
487+
}
488+
: {}),
489+
490+
// fallback to default _document when custom is removed
491+
...(dev && customDocumentFileExt && isWebpack5
492+
? {
493+
[`${PAGES_DIR_ALIAS}/_document${customDocumentFileExt}`]: [
494+
path.join(pagesDir, `_document${customDocumentFileExt}`),
495+
'next/dist/pages/_document.js',
496+
],
497+
}
498+
: {}),
499+
457500
[PAGES_DIR_ALIAS]: pagesDir,
458501
[DOT_NEXT_ALIAS]: distDir,
459502
...getOptimizedAliases(isServer),
@@ -647,15 +690,6 @@ export default async function getBaseWebpackConfig(
647690

648691
const crossOrigin = config.crossOrigin
649692

650-
let customAppFile: string | null = await findPageFile(
651-
pagesDir,
652-
'/_app',
653-
config.pageExtensions
654-
)
655-
if (customAppFile) {
656-
customAppFile = path.resolve(path.join(pagesDir, customAppFile))
657-
}
658-
659693
const conformanceConfig = Object.assign(
660694
{
661695
ReactSyncScriptsConformanceCheck: {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function MyApp({ Component, pageProps }) {
2+
return (
3+
<>
4+
<p>custom _app</p>
5+
<Component {...pageProps} />
6+
</>
7+
)
8+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Document, { Html, Head, Main, NextScript } from 'next/document'
2+
3+
class MyDocument extends Document {
4+
static async getInitialProps(ctx) {
5+
const initialProps = await Document.getInitialProps(ctx)
6+
return { ...initialProps }
7+
}
8+
9+
render() {
10+
return (
11+
<Html>
12+
<Head />
13+
<body>
14+
<p>custom _document</p>
15+
<Main />
16+
<NextScript />
17+
</body>
18+
</Html>
19+
)
20+
}
21+
}
22+
23+
export default MyDocument
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <p>index page</p>
3+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/* eslint-env jest */
2+
3+
import fs from 'fs-extra'
4+
import { join } from 'path'
5+
import webdriver from 'next-webdriver'
6+
import { killApp, findPort, launchApp, check } from 'next-test-utils'
7+
8+
jest.setTimeout(1000 * 60 * 2)
9+
10+
const appDir = join(__dirname, '../')
11+
const appPage = join(appDir, 'pages/_app.js')
12+
const indexPage = join(appDir, 'pages/index.js')
13+
const documentPage = join(appDir, 'pages/_document.js')
14+
15+
let appPort
16+
let app
17+
18+
describe('_app removal HMR', () => {
19+
beforeAll(async () => {
20+
appPort = await findPort()
21+
app = await launchApp(appDir, appPort)
22+
})
23+
afterAll(() => killApp(app))
24+
25+
it('should HMR when _app is removed', async () => {
26+
let indexContent = await fs.readFile(indexPage)
27+
try {
28+
const browser = await webdriver(appPort, '/')
29+
30+
const html = await browser.eval('document.documentElement.innerHTML')
31+
expect(html).toContain('custom _app')
32+
33+
await fs.rename(appPage, appPage + '.bak')
34+
35+
await check(async () => {
36+
const html = await browser.eval('document.documentElement.innerHTML')
37+
return html.includes('index page') && !html.includes('custom _app')
38+
? 'success'
39+
: html
40+
}, 'success')
41+
42+
await fs.writeFile(
43+
indexPage,
44+
`
45+
export default function Page() {
46+
return <p>index page updated</p>
47+
}
48+
`
49+
)
50+
51+
await check(async () => {
52+
const html = await browser.eval('document.documentElement.innerHTML')
53+
return html.indexOf('index page updated') &&
54+
!html.includes('custom _app')
55+
? 'success'
56+
: html
57+
}, 'success')
58+
59+
await fs.rename(appPage + '.bak', appPage)
60+
61+
await check(async () => {
62+
const html = await browser.eval('document.documentElement.innerHTML')
63+
return html.includes('index page updated') &&
64+
html.includes('custom _app')
65+
? 'success'
66+
: html
67+
}, 'success')
68+
} finally {
69+
await fs.writeFile(indexPage, indexContent)
70+
71+
if (await fs.pathExists(appPage + '.bak')) {
72+
await fs.rename(appPage + '.bak', appPage)
73+
}
74+
}
75+
})
76+
77+
it('should HMR when _document is removed', async () => {
78+
let indexContent = await fs.readFile(indexPage)
79+
try {
80+
const browser = await webdriver(appPort, '/')
81+
82+
const html = await browser.eval('document.documentElement.innerHTML')
83+
expect(html).toContain('custom _document')
84+
85+
await fs.rename(documentPage, documentPage + '.bak')
86+
87+
await check(async () => {
88+
const html = await browser.eval('document.documentElement.innerHTML')
89+
return html.includes('index page') && !html.includes('custom _document')
90+
? 'success'
91+
: html
92+
}, 'success')
93+
94+
await fs.writeFile(
95+
indexPage,
96+
`
97+
export default function Page() {
98+
return <p>index page updated</p>
99+
}
100+
`
101+
)
102+
103+
await check(async () => {
104+
const html = await browser.eval('document.documentElement.innerHTML')
105+
return html.indexOf('index page updated') &&
106+
!html.includes('custom _document')
107+
? 'success'
108+
: html
109+
}, 'success')
110+
111+
await fs.rename(documentPage + '.bak', documentPage)
112+
113+
await check(async () => {
114+
const html = await browser.eval('document.documentElement.innerHTML')
115+
return html.includes('index page updated') &&
116+
html.includes('custom _document')
117+
? 'success'
118+
: html
119+
}, 'success')
120+
} finally {
121+
await fs.writeFile(indexPage, indexContent)
122+
123+
if (await fs.pathExists(documentPage + '.bak')) {
124+
await fs.rename(documentPage + '.bak', documentPage)
125+
}
126+
}
127+
})
128+
})

0 commit comments

Comments
 (0)