mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
co-authored by
devjiwonchoi
JJ Kasper
parent
df29b988c2
commit
8f162b138d
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user