Skip to content

Commit eb23134

Browse files
authored
Wait for pending Webpack Hot Updates before evaluating JS from RSC responses (#67673)
1 parent cad3a51 commit eb23134

File tree

7 files changed

+150
-5
lines changed

7 files changed

+150
-5
lines changed

packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx

+31-4
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,35 @@ let __nextDevClientId = Math.round(Math.random() * 100 + Date.now())
4747
let reloading = false
4848
let startLatency: number | null = null
4949

50-
function onBeforeFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
50+
let pendingHotUpdateWebpack = Promise.resolve()
51+
let resolvePendingHotUpdateWebpack: () => void = () => {}
52+
function setPendingHotUpdateWebpack() {
53+
pendingHotUpdateWebpack = new Promise((resolve) => {
54+
resolvePendingHotUpdateWebpack = () => {
55+
resolve()
56+
}
57+
})
58+
}
59+
60+
export function waitForWebpackRuntimeHotUpdate() {
61+
return pendingHotUpdateWebpack
62+
}
63+
64+
function handleBeforeHotUpdateWebpack(
65+
dispatcher: Dispatcher,
66+
hasUpdates: boolean
67+
) {
5168
if (hasUpdates) {
5269
dispatcher.onBeforeRefresh()
5370
}
5471
}
5572

56-
function onFastRefresh(
73+
function handleSuccessfulHotUpdateWebpack(
5774
dispatcher: Dispatcher,
5875
sendMessage: (message: string) => void,
5976
updatedModules: ReadonlyArray<string>
6077
) {
78+
resolvePendingHotUpdateWebpack()
6179
dispatcher.onBuildOk()
6280
reportHmrLatency(sendMessage, updatedModules)
6381

@@ -159,6 +177,7 @@ function tryApplyUpdates(
159177
dispatcher: Dispatcher
160178
) {
161179
if (!isUpdateAvailable() || !canApplyUpdates()) {
180+
resolvePendingHotUpdateWebpack()
162181
dispatcher.onBuildOk()
163182
reportHmrLatency(sendMessage, [])
164183
return
@@ -281,12 +300,16 @@ function processMessage(
281300
} else {
282301
tryApplyUpdates(
283302
function onBeforeHotUpdate(hasUpdates: boolean) {
284-
onBeforeFastRefresh(dispatcher, hasUpdates)
303+
handleBeforeHotUpdateWebpack(dispatcher, hasUpdates)
285304
},
286305
function onSuccessfulHotUpdate(webpackUpdatedModules: string[]) {
287306
// Only dismiss it when we're sure it's a hot update.
288307
// Otherwise it would flicker right before the reload.
289-
onFastRefresh(dispatcher, sendMessage, webpackUpdatedModules)
308+
handleSuccessfulHotUpdateWebpack(
309+
dispatcher,
310+
sendMessage,
311+
webpackUpdatedModules
312+
)
290313
},
291314
sendMessage,
292315
dispatcher
@@ -320,6 +343,9 @@ function processMessage(
320343
}
321344
case HMR_ACTIONS_SENT_TO_BROWSER.BUILDING: {
322345
startLatency = Date.now()
346+
if (!process.env.TURBOPACK) {
347+
setPendingHotUpdateWebpack()
348+
}
323349
console.log('[Fast Refresh] rebuilding')
324350
break
325351
}
@@ -426,6 +452,7 @@ function processMessage(
426452
reloading = true
427453
return window.location.reload()
428454
}
455+
resolvePendingHotUpdateWebpack()
429456
startTransition(() => {
430457
router.hmrRefresh()
431458
dispatcher.onRefresh()

packages/next/src/client/components/router-reducer/fetch-server-response.ts

+9
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { callServer } from '../../app-call-server'
3030
import { PrefetchKind } from './router-reducer-types'
3131
import { hexHash } from '../../../shared/lib/hash'
32+
import { waitForWebpackRuntimeHotUpdate } from '../react-dev-overlay/app/hot-reloader-client'
3233

3334
export interface FetchServerResponseOptions {
3435
readonly flightRouterState: FlightRouterState
@@ -180,6 +181,14 @@ export async function fetchServerResponse(
180181
return doMpaNavigation(responseUrl.toString())
181182
}
182183

184+
// We may navigate to a page that requires a different Webpack runtime.
185+
// In prod, every page will have the same Webpack runtime.
186+
// In dev, the Webpack runtime is minimal for each page.
187+
// We need to ensure the Webpack runtime is updated before executing client-side JS of the new page.
188+
if (process.env.NODE_ENV !== 'production' && !process.env.TURBOPACK) {
189+
await waitForWebpackRuntimeHotUpdate()
190+
}
191+
183192
// Handle the `fetch` readable stream that can be unwrapped by `React.use`.
184193
const response: NavigationFlightResponse = await createFromFetch(
185194
Promise.resolve(res),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use client'
2+
// requires
3+
import * as React from 'react'
4+
5+
export default function RuntimeChangesNewRuntimeFunctionalityPage() {
6+
React.useEffect(() => {}, [])
7+
return <div data-testid="new-runtime-functionality-page" />
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Link from 'next/link'
2+
3+
export default function RuntimeChangesPage() {
4+
return (
5+
<Link href="/bundler-runtime-changes/new-runtime-functionality">
6+
Click me
7+
</Link>
8+
)
9+
}
14.7 KB
Binary file not shown.

test/development/app-hmr/hmr.test.ts

+69-1
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe(`app-dir-hmr`, () => {
9999
'window.__TEST_NO_RELOAD === undefined'
100100
)
101101
// Used to be flaky but presumably no longer is.
102-
// If this flakes again, please add the received value as a commnet.
102+
// If this flakes again, please add the received value as a comment.
103103
expect({ envValue, mpa }).toEqual({
104104
envValue: 'ipad',
105105
mpa: false,
@@ -245,5 +245,73 @@ describe(`app-dir-hmr`, () => {
245245
it('should have no unexpected action error for hmr', async () => {
246246
expect(next.cliOutput).not.toContain('Unexpected action')
247247
})
248+
249+
it('can navigate cleanly to a page that requires a change in the Webpack runtime', async () => {
250+
// This isn't a very accurate test since the Webpack runtime is somewhat an implementation detail.
251+
// To ensure this is still valid, check the `*/webpack.*.hot-update.js` network response content when the navigation is triggered.
252+
// If there is new functionality added, the test is still valid.
253+
// If not, the test doesn't cover anything new.
254+
// TODO: Enforce console.error assertions or MPA navigation assertions in all tests instead.
255+
const browser = await next.browser('/bundler-runtime-changes')
256+
await browser.eval('window.__TEST_NO_RELOAD = true')
257+
258+
await browser
259+
.elementByCss('a')
260+
.click()
261+
.waitForElementByCss('[data-testid="new-runtime-functionality-page"]')
262+
263+
const logs = await browser.log()
264+
// TODO: Should assert on all logs but these are cluttered with logs from our test utils (e.g. playwright tracing or webdriver)
265+
if (process.env.TURBOPACK) {
266+
// FIXME: logging "rebuilding" multiple times instead of closing it of with "done in"
267+
// Should just not branch here and have the same logs as Webpack.
268+
expect(logs).toEqual(
269+
expect.arrayContaining([
270+
{
271+
message: '[Fast Refresh] rebuilding',
272+
source: 'log',
273+
},
274+
{
275+
message: '[Fast Refresh] rebuilding',
276+
source: 'log',
277+
},
278+
{
279+
message: '[Fast Refresh] rebuilding',
280+
source: 'log',
281+
},
282+
])
283+
)
284+
expect(logs).not.toEqual(
285+
expect.arrayContaining([
286+
{
287+
message: expect.stringContaining('[Fast Refresh] done in'),
288+
source: 'log',
289+
},
290+
])
291+
)
292+
} else {
293+
expect(logs).toEqual(
294+
expect.arrayContaining([
295+
{
296+
message: '[Fast Refresh] rebuilding',
297+
source: 'log',
298+
},
299+
{
300+
message: expect.stringContaining('[Fast Refresh] done in'),
301+
source: 'log',
302+
},
303+
])
304+
)
305+
expect(logs).not.toEqual(
306+
expect.arrayContaining([
307+
expect.objectContaining({
308+
source: 'error',
309+
}),
310+
])
311+
)
312+
}
313+
// No MPA navigation triggered
314+
expect(await browser.eval('window.__TEST_NO_RELOAD')).toEqual(true)
315+
})
248316
})
249317
})
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2017",
4+
"lib": ["dom", "dom.iterable", "esnext"],
5+
"allowJs": true,
6+
"skipLibCheck": true,
7+
"strict": false,
8+
"noEmit": true,
9+
"incremental": true,
10+
"module": "esnext",
11+
"esModuleInterop": true,
12+
"moduleResolution": "node",
13+
"resolveJsonModule": true,
14+
"isolatedModules": true,
15+
"jsx": "preserve",
16+
"plugins": [
17+
{
18+
"name": "next"
19+
}
20+
]
21+
},
22+
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
23+
"exclude": ["node_modules"]
24+
}

0 commit comments

Comments
 (0)