Skip to content

Commit 291ac66

Browse files
authored
fix: connect to the correct Next.js dev HMR endpoint per version (#17644)
Backport of #17632 and #17633 to `3.x`. ### What? Payload's config hot reload in development stopped working on Next.js 16.3. Adding a collection or a field had no effect until the dev server was restarted. Next.js 16.3 renamed the dev HMR WebSocket endpoint: - Old: `/_next/webpack-hmr` — [`router-server.ts#L859-L860` on `v16.2.7`](https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/lib/router-server.ts#L859-L860) - New: `/_next/hmr` — [`router-server.ts#L946-L947` on `v16.3.0`](https://github.com/vercel/next.js/blob/v16.3.0/packages/next/src/server/lib/router-server.ts#L946-L947) The client moved with it, in [`client/dev/hot-reloader/app/web-socket.ts`](https://github.com/vercel/next.js/blob/v16.3.0/packages/next/src/client/dev/hot-reloader/app/web-socket.ts). There is no alias in either direction: `/_next/webpack-hmr` appears only in old docs inside the 16.3.0 build, and `/_next/hmr` does not appear anywhere in 16.2.7. This branch also bumps the monorepo to Next.js 16.3.0. `3.x` was on a mix of `16.2.3` (root) and `16.2.6` (`test/`, `packages/next`). ### Why? Payload hardcoded `/_next/webpack-hmr`. On 16.3 the upgrade request no longer matched, so the socket never opened. The failure was invisible because the `onerror` handler and the surrounding `try` block both swallow errors, so `cached.reload` stayed `false` and the reload never ran. No type regeneration, no import map regeneration, and no client config cache clear. [Related Vercel PR](vercel/next.js#91415), which went into canary several months ago. ### How? Read the installed Next.js version and connect to the path that version serves: `/_next/hmr` at 16.3 and above, `/_next/webpack-hmr` below it. One socket, no configuration, correct across supported Next.js versions. ``` /_next/hmr => open /_next/webpack-hmr => no response after 8s /_next/not-a-real-path => no response after 8s ``` A wrong path never errors and never closes, so a fallback triggered by failure would hang forever on Next 16.2 and below and break HMR for every older version. `PAYLOAD_HMR_URL_OVERRIDE` keeps working and is still used verbatim, skipping version detection. ### Differences from the changes on `main` - `main` extracted a `defaultNextJsDevReloadStrategy` into `nextJsDevReloadStrategy.ts`. `3.x` has no `DevReloadStrategy` abstraction, so only the URL selection is extracted, as `getNextJsHMRURL.ts`. The socket handling stays inline in `getPayload`. - `main` sets `experimental.useTypeScriptCli: false` in both `next.config.mjs` files, because it aliases `typescript` to `@typescript/typescript6`, which ships no `tsc` bin for Next 16.3's new CLI mode to find. `3.x` uses plain `typescript@5.7.3`, so the alias problem does not exist and the setting is not needed. - `main` added `minimumReleaseAgeExclude` entries to `pnpm-workspace.yaml`. `3.x` does not set `minimumReleaseAge`. - `packages/payload` keeps `@next/env` at `^15.1.5` rather than pinning `16.3.0`, since that range also serves apps on Next 15. - Templates are unchanged, as on `main` (coming in later PR)
1 parent ec4f979 commit 291ac66

10 files changed

Lines changed: 689 additions & 149 deletions

File tree

docs/troubleshooting/troubleshooting.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,11 @@ This will ensure that the WebSocket connection uses the correct protocol (`wss:/
157157
Alternatively if more of your URL is dynamic, you can set the full URL for the WebSocket connection using the `PAYLOAD_HMR_URL_OVERRIDE` environment variable:
158158

159159
```
160-
PAYLOAD_HMR_URL_OVERRIDE=wss://localhost:3000/_next/webpack-hmr
160+
PAYLOAD_HMR_URL_OVERRIDE=wss://localhost:3000/_next/hmr
161161
```
162162

163+
You do not need to set this to match your Next.js version. Next.js serves HMR on `/_next/hmr` from version 16.3 onwards, and on `/_next/webpack-hmr` before that. Payload reads the installed Next.js version and connects to the correct path on its own.
164+
163165
## Database password encoding issues
164166

165167
You may see some generic errors such as the following when Payload is unable to connect to your database:

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@
179179
"@ai-sdk/openai": "3.0.30",
180180
"@axe-core/playwright": "4.11.0",
181181
"@libsql/client": "0.14.0",
182-
"@next/bundle-analyzer": "16.2.3",
182+
"@next/bundle-analyzer": "16.3.0",
183183
"@payloadcms/db-postgres": "workspace:*",
184184
"@payloadcms/eslint-config": "workspace:*",
185185
"@payloadcms/eslint-plugin": "workspace:*",
@@ -218,7 +218,7 @@
218218
"lint-staged": "15.2.7",
219219
"minimist": "1.2.8",
220220
"mongoose": "8.22.1",
221-
"next": "16.2.3",
221+
"next": "16.3.0",
222222
"node-gyp": "12.2.0",
223223
"open": "^10.1.0",
224224
"p-limit": "^5.0.0",

packages/next/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@
132132
"@babel/preset-env": "7.27.2",
133133
"@babel/preset-react": "7.27.1",
134134
"@babel/preset-typescript": "7.27.1",
135-
"@next/eslint-plugin-next": "16.2.6",
135+
"@next/eslint-plugin-next": "16.3.0",
136136
"@payloadcms/eslint-config": "workspace:*",
137137
"@types/busboy": "1.5.4",
138138
"@types/react": "19.2.14",

packages/payload/src/index.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ import { fieldAffectsData, type FlattenedBlock } from './fields/config/types.js'
146146
import { getJobsLocalAPI } from './queues/localAPI.js'
147147
import { _internal_jobSystemGlobals } from './queues/utilities/getCurrentDate.js'
148148
import { formatAdminURL } from './utilities/formatAdminURL.js'
149+
import { getNextJsHMRURL } from './utilities/getNextJsHMRURL.js'
149150
import { isNextBuild } from './utilities/isNextBuild.js'
150151
import { getLogger } from './utilities/logger.js'
151152
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
@@ -1232,18 +1233,7 @@ export const getPayload = async (
12321233
process.env.DISABLE_PAYLOAD_HMR !== 'true'
12331234
) {
12341235
try {
1235-
const port = process.env.PORT || '3000'
1236-
const hasHTTPS =
1237-
process.env.USE_HTTPS === 'true' || process.argv.includes('--experimental-https')
1238-
const protocol = hasHTTPS ? 'wss' : 'ws'
1239-
1240-
const path = '/_next/webpack-hmr'
1241-
// The __NEXT_ASSET_PREFIX env variable is set for both assetPrefix and basePath (tested in Next.js 15.1.6)
1242-
const prefix = process.env.__NEXT_ASSET_PREFIX ?? ''
1243-
1244-
cached.ws = new WebSocket(
1245-
process.env.PAYLOAD_HMR_URL_OVERRIDE ?? `${protocol}://localhost:${port}${prefix}${path}`,
1246-
)
1236+
cached.ws = new WebSocket(getNextJsHMRURL())
12471237

12481238
cached.ws.onmessage = (event) => {
12491239
if (cached.reload instanceof Promise) {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('./getNextVersion.js', () => ({ getNextVersion: vi.fn() }))
4+
5+
const { getNextVersion } = await import('./getNextVersion.js')
6+
const { getNextJsHMRURL } = await import('./getNextJsHMRURL.js')
7+
8+
const modernURL = 'ws://localhost:3000/_next/hmr'
9+
const legacyURL = 'ws://localhost:3000/_next/webpack-hmr'
10+
11+
describe('getNextJsHMRURL', () => {
12+
beforeEach(() => {
13+
vi.mocked(getNextVersion).mockReturnValue('16.3.0')
14+
vi.stubEnv('PAYLOAD_HMR_URL_OVERRIDE', undefined)
15+
vi.stubEnv('PORT', '3000')
16+
})
17+
18+
afterEach(() => {
19+
vi.unstubAllEnvs()
20+
})
21+
22+
it('should use the current HMR path on Next.js 16.3', () => {
23+
expect(getNextJsHMRURL()).toBe(modernURL)
24+
})
25+
26+
it('should use the current HMR path on Next.js versions above 16.3', () => {
27+
vi.mocked(getNextVersion).mockReturnValue('17.0.1')
28+
29+
expect(getNextJsHMRURL()).toBe(modernURL)
30+
})
31+
32+
it('should use the legacy HMR path on Next.js below 16.3', () => {
33+
vi.mocked(getNextVersion).mockReturnValue('16.2.7')
34+
35+
expect(getNextJsHMRURL()).toBe(legacyURL)
36+
})
37+
38+
it('should use the legacy HMR path on a Next.js 15 install', () => {
39+
vi.mocked(getNextVersion).mockReturnValue('15.5.0')
40+
41+
expect(getNextJsHMRURL()).toBe(legacyURL)
42+
})
43+
44+
it('should ignore pre-release identifiers when choosing the path', () => {
45+
vi.mocked(getNextVersion).mockReturnValue('16.3.0-canary.12')
46+
47+
expect(getNextJsHMRURL()).toBe(modernURL)
48+
})
49+
50+
it('should use the legacy HMR path on a pre-release below 16.3', () => {
51+
vi.mocked(getNextVersion).mockReturnValue('16.2.0-canary.5')
52+
53+
expect(getNextJsHMRURL()).toBe(legacyURL)
54+
})
55+
56+
it('should use the current HMR path when the Next.js version is unknown', () => {
57+
vi.mocked(getNextVersion).mockReturnValue(undefined)
58+
59+
expect(getNextJsHMRURL()).toBe(modernURL)
60+
})
61+
62+
it('should use PAYLOAD_HMR_URL_OVERRIDE whatever the version', () => {
63+
vi.mocked(getNextVersion).mockReturnValue(undefined)
64+
vi.stubEnv('PAYLOAD_HMR_URL_OVERRIDE', 'ws://localhost:4000/custom-hmr')
65+
66+
expect(getNextJsHMRURL()).toBe('ws://localhost:4000/custom-hmr')
67+
})
68+
69+
it('should use the wss protocol when HTTPS is enabled', () => {
70+
vi.stubEnv('USE_HTTPS', 'true')
71+
72+
expect(getNextJsHMRURL()).toBe('wss://localhost:3000/_next/hmr')
73+
})
74+
75+
it('should include the Next.js asset prefix', () => {
76+
vi.stubEnv('__NEXT_ASSET_PREFIX', '/base')
77+
78+
expect(getNextJsHMRURL()).toBe('ws://localhost:3000/base/_next/hmr')
79+
})
80+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { compareVersions, parseVersion } from './dependencies/versionUtils.js'
2+
import { getNextVersion } from './getNextVersion.js'
3+
4+
/** Next.js serves the dev HMR WebSocket on this path from 16.3 onwards. */
5+
const modernHMRPath = '/_next/hmr'
6+
7+
/** Next.js served the dev HMR WebSocket on this path before 16.3. */
8+
const legacyHMRPath = '/_next/webpack-hmr'
9+
10+
const firstModernHMRPathVersion = '16.3.0'
11+
12+
/**
13+
* Builds the URL of the Next.js dev HMR WebSocket, on the path served by the
14+
* installed Next.js version. `PAYLOAD_HMR_URL_OVERRIDE` is used verbatim.
15+
*/
16+
export const getNextJsHMRURL = (): string => {
17+
if (process.env.PAYLOAD_HMR_URL_OVERRIDE) {
18+
return process.env.PAYLOAD_HMR_URL_OVERRIDE
19+
}
20+
21+
const port = process.env.PORT || '3000'
22+
const hasHTTPS = process.env.USE_HTTPS === 'true' || process.argv.includes('--experimental-https')
23+
const protocol = hasHTTPS ? 'wss' : 'ws'
24+
// The __NEXT_ASSET_PREFIX env variable is set for both assetPrefix and basePath (tested in Next.js 15.1.6)
25+
const prefix = process.env.__NEXT_ASSET_PREFIX ?? ''
26+
27+
return `${protocol}://localhost:${port}${prefix}${getHMRPath()}`
28+
}
29+
30+
/**
31+
* Pre-release identifiers are ignored, so that a `16.3.0-canary` build maps to the 16.3 path.
32+
* An unreadable version uses the current path, as Payload being unable to resolve Next.js
33+
* normally means it is not running under Next.js, where this strategy does not apply.
34+
*/
35+
const getHMRPath = (): string => {
36+
const nextVersion = getNextVersion()
37+
38+
if (!nextVersion) {
39+
return modernHMRPath
40+
}
41+
42+
const mainVersion = parseVersion(nextVersion).parts.join('.')
43+
44+
return compareVersions(mainVersion, firstModernHMRPathVersion) === 'lower'
45+
? legacyHMRPath
46+
: modernHMRPath
47+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { getNextVersion } from './getNextVersion.js'
4+
5+
describe('getNextVersion', () => {
6+
afterEach(() => {
7+
vi.restoreAllMocks()
8+
})
9+
10+
it('should read the version of the installed Next.js', () => {
11+
expect(getNextVersion()).toMatch(/^\d+\.\d+\.\d+/)
12+
})
13+
14+
it('should return undefined when Next.js cannot be resolved', () => {
15+
vi.spyOn(process, 'cwd').mockReturnValue('/')
16+
17+
expect(getNextVersion()).toBeUndefined()
18+
})
19+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { readFileSync } from 'fs'
2+
3+
import { resolveFrom } from './dependencies/resolveFrom.js'
4+
5+
/**
6+
* Reads the version of the Next.js installed alongside the running app.
7+
* Returns undefined if Next.js cannot be resolved, e.g. because Payload runs
8+
* outside of Next.js or from a directory the app's dependencies are not visible from.
9+
*/
10+
export const getNextVersion = (): string | undefined => {
11+
try {
12+
const packageJSONPath = resolveFrom(process.cwd(), 'next/package.json', true)
13+
14+
if (!packageJSONPath) {
15+
return undefined
16+
}
17+
18+
const { version } = JSON.parse(readFileSync(packageJSONPath, 'utf-8'))
19+
20+
return typeof version === 'string' ? version : undefined
21+
} catch (_) {
22+
return undefined
23+
}
24+
}

0 commit comments

Comments
 (0)