Fix: Client should auto reload after server restarts (#83971)

Port the implementation from pages router to app router.

https://github.com/vercel/next.js/blob/27515900b31bfad83c3ecc65ed2e5d020ec289a6/packages/next/src/client/dev/hot-reloader/pages/websocket.ts

---------

Co-authored-by: devjiwonchoi <[email protected]>
Co-authored-by: JJ Kasper <[email protected]>
This commit is contained in:
Jude Gao
2025-09-23 07:45:13 +02:00
committed by GitHub
co-authored by devjiwonchoi JJ Kasper
parent df29b988c2
commit 8f162b138d
10 changed files with 260 additions and 31 deletions
@@ -17,6 +17,12 @@ import {
logQueue,
} from '../../../../next-devtools/userspace/app/forward-logs'
import { InvariantError } from '../../../../shared/lib/invariant-error'
import { WEB_SOCKET_MAX_RECONNECTIONS } from '../../../../lib/constants'
let reconnections = 0
let reloading = false
let serverSessionId: number | null = null
let mostRecentCompilationHash: string | null = null
export function createWebSocket(
assetPrefix: string,
@@ -28,45 +34,120 @@ export function createWebSocket(
)
}
const webSocket = new window.WebSocket(
`${getSocketUrl(assetPrefix)}/_next/webpack-hmr?id=${self.__next_r}`
)
webSocket.binaryType = 'arraybuffer'
if (isTerminalLoggingEnabled) {
webSocket.addEventListener('open', () => {
logQueue.onSocketReady(webSocket)
})
}
let webSocket: WebSocket
let timer: ReturnType<typeof setTimeout>
const sendMessage = (data: string) => {
if (webSocket.readyState === webSocket.OPEN) {
if (webSocket && webSocket.readyState === webSocket.OPEN) {
webSocket.send(data)
}
}
const processTurbopackMessage = createProcessTurbopackMessage(sendMessage)
webSocket.addEventListener('message', (event) => {
try {
const message: HmrMessageSentToBrowser =
event.data instanceof ArrayBuffer
? parseBinaryMessage(event.data)
: JSON.parse(event.data)
processMessage(
message,
sendMessage,
processTurbopackMessage,
staticIndicatorState
)
} catch (err: unknown) {
reportInvalidHmrMessage(event, err)
function init() {
if (webSocket) {
webSocket.close()
}
})
return webSocket
const newWebSocket = new window.WebSocket(
`${getSocketUrl(assetPrefix)}/_next/webpack-hmr?id=${self.__next_r}`
)
newWebSocket.binaryType = 'arraybuffer'
function handleOnline() {
if (isTerminalLoggingEnabled) {
logQueue.onSocketReady(newWebSocket)
}
reconnections = 0
window.console.log('[HMR] connected')
}
function handleMessage(event: MessageEvent) {
// While the page is reloading, don't respond to any more messages.
if (reloading) {
return
}
try {
const message: HmrMessageSentToBrowser =
event.data instanceof ArrayBuffer
? parseBinaryMessage(event.data)
: JSON.parse(event.data)
// Check for server restart in Turbopack mode
if (message.type === HMR_MESSAGE_SENT_TO_BROWSER.TURBOPACK_CONNECTED) {
if (
serverSessionId !== null &&
serverSessionId !== message.data.sessionId
) {
// Either the server's session id has changed and it's a new server, or
// it's been too long since we disconnected and we should reload the page.
window.location.reload()
reloading = true
return
}
serverSessionId = message.data.sessionId
}
// Track webpack compilation hash for server restart detection
if (
message.type === HMR_MESSAGE_SENT_TO_BROWSER.SYNC &&
'hash' in message
) {
// If we had previously reconnected and the hash changed, the server may have restarted
if (
mostRecentCompilationHash !== null &&
mostRecentCompilationHash !== message.hash
) {
window.location.reload()
reloading = true
return
}
mostRecentCompilationHash = message.hash
}
processMessage(
message,
sendMessage,
processTurbopackMessage,
staticIndicatorState
)
} catch (err: unknown) {
reportInvalidHmrMessage(event, err)
}
}
function handleDisconnect() {
newWebSocket.onerror = null
newWebSocket.onclose = null
newWebSocket.close()
reconnections++
// After 25 reconnects we'll want to reload the page as it indicates the dev server is no longer running.
if (reconnections > WEB_SOCKET_MAX_RECONNECTIONS) {
reloading = true
window.location.reload()
return
}
clearTimeout(timer)
// Try again after 5 seconds
timer = setTimeout(init, reconnections > 5 ? 5000 : 1000)
}
newWebSocket.onopen = handleOnline
newWebSocket.onerror = handleDisconnect
newWebSocket.onclose = handleDisconnect
newWebSocket.onmessage = handleMessage
webSocket = newWebSocket
return newWebSocket
}
return init()
}
export function createProcessTurbopackMessage(
@@ -7,6 +7,7 @@ import {
type HmrMessageSentToBrowser,
} from '../../../../server/dev/hot-reloader-types'
import { getSocketUrl } from '../get-socket-url'
import { WEB_SOCKET_MAX_RECONNECTIONS } from '../../../../lib/constants'
let source: WebSocket
@@ -28,6 +29,8 @@ let reloading = false
let serverSessionId: number | null = null
export function connectHMR(options: { path: string; assetPrefix: string }) {
let timer: ReturnType<typeof setTimeout>
function init() {
if (source) source.close()
@@ -71,14 +74,13 @@ export function connectHMR(options: { path: string; assetPrefix: string }) {
}
}
let timer: ReturnType<typeof setTimeout>
function handleDisconnect() {
source.onerror = null
source.onclose = null
source.close()
reconnections++
// After 25 reconnects we'll want to reload the page as it indicates the dev server is no longer running.
if (reconnections > 25) {
if (reconnections > WEB_SOCKET_MAX_RECONNECTIONS) {
reloading = true
window.location.reload()
return
+2
View File
@@ -100,6 +100,8 @@ export const SERVER_RUNTIME: Record<string, ServerRuntime> = {
nodejs: 'nodejs',
}
export const WEB_SOCKET_MAX_RECONNECTIONS = 12
/**
* The names of the webpack layers. These layers are the primitives for the
* webpack chunks.
@@ -0,0 +1,7 @@
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}
@@ -0,0 +1,5 @@
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
@@ -0,0 +1,37 @@
import { nextTestSetup } from 'e2e-utils'
import { check } from 'next-test-utils'
import { join } from 'path'
describe('app-dir watch-config-file', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixture'),
})
it('should output config file change and restart server for app router', async () => {
await check(async () => next.cliOutput, /ready/i)
await check(async () => {
await next.patchFile(
'next.config.js',
`
console.log(${Date.now()})
const nextConfig = {
reactStrictMode: true,
async redirects() {
return [
{
source: '/about',
destination: '/',
permanent: false,
},
]
},
}
module.exports = nextConfig`
)
return next.cliOutput
}, /Found a change in next\.config\.js\. Restarting the server to apply the changes\.\.\./)
await check(() => next.fetch('/about').then((res) => res.status), 200)
})
})
@@ -0,0 +1,16 @@
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p id="counter-value">Count: {count}</p>
<button id="increment-button" onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
)
}
@@ -0,0 +1,10 @@
import { Counter } from './counter'
export default function Page() {
return (
<div>
<p>hello world</p>
<Counter />
</div>
)
}
@@ -0,0 +1,66 @@
import { FileRef, nextTestSetup, createNext } from 'e2e-utils'
import { retry, waitFor } from 'next-test-utils'
import path from 'path'
describe('app-dir server restart', () => {
const { next } = nextTestSetup({
files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')),
patchFileDelay: 1000,
})
it('should reload the page when the server restarts', async () => {
const browser = await next.browser('/')
await retry(async () => {
expect(await browser.elementByCss('body').text()).toMatch(/hello world/)
})
// Verify the counter is at 0 initially
expect(await browser.elementById('counter-value').text()).toBe('Count: 0')
// Click increment button to change state
await browser.elementById('increment-button').click()
expect(await browser.elementById('counter-value').text()).toBe('Count: 1')
// Click again to make it 2
await browser.elementById('increment-button').click()
expect(await browser.elementById('counter-value').text()).toBe('Count: 2')
// Set up reload detection before stopping the server
let reloadPromise = new Promise((resolve) => {
browser.on('request', (req) => {
if (req.url().endsWith('/')) {
resolve(req.url())
}
})
})
const appPort = next.appPort
await next.destroy()
// Start a new server instance on the same port
const secondNext = await createNext({
files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')),
env: next.env,
forcedPort: appPort,
})
// Wait for the new server to be ready
await waitFor(1000)
// Wait for the browser to reload
await reloadPromise
// Verify the page content is still available after reload
await retry(async () => {
expect(await browser.elementByCss('body').text()).toMatch(/hello world/)
})
// IMPORTANT: Verify the counter state is reset to 0 after reload
// This proves the page actually reloaded and didn't just reconnect
await retry(async () => {
expect(await browser.elementById('counter-value').text()).toBe('Count: 0')
})
await secondNext.destroy()
})
})