Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,35 @@ let __nextDevClientId = Math.round(Math.random() * 100 + Date.now())
let reloading = false
let startLatency: number | null = null

function onBeforeFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
let pendingHotUpdateWebpack = Promise.resolve()
let resolvePendingHotUpdateWebpack: () => void = () => {}
function setPendingHotUpdateWebpack() {
pendingHotUpdateWebpack = new Promise((resolve) => {
resolvePendingHotUpdateWebpack = () => {
resolve()
}
})
}

export function waitForWebpackRuntimeHotUpdate() {
return pendingHotUpdateWebpack
}

function handleBeforeHotUpdateWebpack(
dispatcher: Dispatcher,
hasUpdates: boolean
) {
if (hasUpdates) {
dispatcher.onBeforeRefresh()
}
}

function onFastRefresh(
function handleSuccessfulHotUpdateWebpack(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by rename. We only call this in Webpack implying there's no Fast Refresh in Turbopack. Fast Refresh is also a React feature that's enabled by HMR so it is a bit confusing to invoke that name at all.

dispatcher: Dispatcher,
sendMessage: (message: string) => void,
updatedModules: ReadonlyArray<string>
) {
resolvePendingHotUpdateWebpack()
dispatcher.onBuildOk()
reportHmrLatency(sendMessage, updatedModules)

Expand Down Expand Up @@ -159,6 +177,7 @@ function tryApplyUpdates(
dispatcher: Dispatcher
) {
if (!isUpdateAvailable() || !canApplyUpdates()) {
resolvePendingHotUpdateWebpack()
dispatcher.onBuildOk()
reportHmrLatency(sendMessage, [])
return
Expand Down Expand Up @@ -281,12 +300,16 @@ function processMessage(
} else {
tryApplyUpdates(
function onBeforeHotUpdate(hasUpdates: boolean) {
onBeforeFastRefresh(dispatcher, hasUpdates)
handleBeforeHotUpdateWebpack(dispatcher, hasUpdates)
},
function onSuccessfulHotUpdate(webpackUpdatedModules: string[]) {
// Only dismiss it when we're sure it's a hot update.
// Otherwise it would flicker right before the reload.
onFastRefresh(dispatcher, sendMessage, webpackUpdatedModules)
handleSuccessfulHotUpdateWebpack(
dispatcher,
sendMessage,
webpackUpdatedModules
)
},
sendMessage,
dispatcher
Expand Down Expand Up @@ -320,6 +343,9 @@ function processMessage(
}
case HMR_ACTIONS_SENT_TO_BROWSER.BUILDING: {
startLatency = Date.now()
if (!process.env.TURBOPACK) {
setPendingHotUpdateWebpack()
}
Comment on lines 344 to +348
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downside here is that we always get a hot update on client-side navigations as far as I can tell. Even if the runtime doesn't get new functionality, it does get updates to getFullHash so we'd be waiting on something we don't necessarily need.

"use strict";
/*
 * ATTENTION: An "eval-source-map" devtool has been used.
 * This devtool is neither made for production nor for readable output files.
 * It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
 * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
 * or disable the default devtool with "devtool: false".
 * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
 */
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ 	__webpack_require__.h = () => ("d60fe58ebbed83dc")
/******/ })();
/******/ 
/******/ }
);

console.log('[Fast Refresh] rebuilding')
break
}
Expand Down Expand Up @@ -426,6 +452,7 @@ function processMessage(
reloading = true
return window.location.reload()
}
resolvePendingHotUpdateWebpack()
startTransition(() => {
router.hmrRefresh()
dispatcher.onRefresh()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { callServer } from '../../app-call-server'
import { PrefetchKind } from './router-reducer-types'
import { hexHash } from '../../../shared/lib/hash'
import { waitForWebpackRuntimeHotUpdate } from '../react-dev-overlay/app/hot-reloader-client'

export interface FetchServerResponseOptions {
readonly flightRouterState: FlightRouterState
Expand Down Expand Up @@ -180,6 +181,14 @@ export async function fetchServerResponse(
return doMpaNavigation(responseUrl.toString())
}

// We may navigate to a page that requires a different Webpack runtime.
// In prod, every page will have the same Webpack runtime.
// In dev, the Webpack runtime is minimal for each page.
// We need to ensure the Webpack runtime is updated before executing client-side JS of the new page.
if (process.env.NODE_ENV !== 'production' && !process.env.TURBOPACK) {
await waitForWebpackRuntimeHotUpdate()
}

// Handle the `fetch` readable stream that can be unwrapped by `React.use`.
const response: NavigationFlightResponse = await createFromFetch(
Promise.resolve(res),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client'
// requires
import * as React from 'react'

export default function RuntimeChangesNewRuntimeFunctionalityPage() {
React.useEffect(() => {}, [])
return <div data-testid="new-runtime-functionality-page" />
}
9 changes: 9 additions & 0 deletions test/development/app-hmr/app/bundler-runtime-changes/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Link from 'next/link'

export default function RuntimeChangesPage() {
return (
<Link href="/bundler-runtime-changes/new-runtime-functionality">
Click me
</Link>
)
}
Binary file added test/development/app-hmr/app/favicon.ico
Binary file not shown.
70 changes: 69 additions & 1 deletion test/development/app-hmr/hmr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe(`app-dir-hmr`, () => {
'window.__TEST_NO_RELOAD === undefined'
)
// Used to be flaky but presumably no longer is.
// If this flakes again, please add the received value as a commnet.
// If this flakes again, please add the received value as a comment.
expect({ envValue, mpa }).toEqual({
envValue: 'ipad',
mpa: false,
Expand Down Expand Up @@ -245,5 +245,73 @@ describe(`app-dir-hmr`, () => {
it('should have no unexpected action error for hmr', async () => {
expect(next.cliOutput).not.toContain('Unexpected action')
})

it('can navigate cleanly to a page that requires a change in the Webpack runtime', async () => {
// This isn't a very accurate test since the Webpack runtime is somewhat an implementation detail.
// To ensure this is still valid, check the `*/webpack.*.hot-update.js` network response content when the navigation is triggered.
// If there is new functionality added, the test is still valid.
// If not, the test doesn't cover anything new.
// TODO: Enforce console.error assertions or MPA navigation assertions in all tests instead.
const browser = await next.browser('/bundler-runtime-changes')
await browser.eval('window.__TEST_NO_RELOAD = true')

await browser
.elementByCss('a')
.click()
.waitForElementByCss('[data-testid="new-runtime-functionality-page"]')

const logs = await browser.log()
// TODO: Should assert on all logs but these are cluttered with logs from our test utils (e.g. playwright tracing or webdriver)
if (process.env.TURBOPACK) {
// FIXME: logging "rebuilding" multiple times instead of closing it of with "done in"
// Should just not branch here and have the same logs as Webpack.
expect(logs).toEqual(
expect.arrayContaining([
{
message: '[Fast Refresh] rebuilding',
source: 'log',
},
{
message: '[Fast Refresh] rebuilding',
source: 'log',
},
{
message: '[Fast Refresh] rebuilding',
source: 'log',
},
])
)
expect(logs).not.toEqual(
expect.arrayContaining([
{
message: expect.stringContaining('[Fast Refresh] done in'),
source: 'log',
},
])
)
} else {
expect(logs).toEqual(
expect.arrayContaining([
{
message: '[Fast Refresh] rebuilding',
source: 'log',
},
{
message: expect.stringContaining('[Fast Refresh] done in'),
source: 'log',
},
])
)
expect(logs).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
source: 'error',
}),
])
)
}
// No MPA navigation triggered
expect(await browser.eval('window.__TEST_NO_RELOAD')).toEqual(true)
})
})
})
24 changes: 24 additions & 0 deletions test/development/app-hmr/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}