vercel/next.js · #100628
Don't block the dev overlay on the npm registry version check
packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx4 + / 0 −
@@ -313,6 +313,10 @@ export function processMessage( dispatcher.onVulnerabilityInsight(message.hasVulnerabilityInsight) break }+ case HMR_MESSAGE_SENT_TO_BROWSER.VERSION_INFO: {+ dispatcher.onVersionInfo(message.versionInfo)+ break+ } case HMR_MESSAGE_SENT_TO_BROWSER.BUILT: case HMR_MESSAGE_SENT_TO_BROWSER.SYNC: { dispatcher.buildingIndicatorHide()packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts4 + / 0 −
@@ -302,6 +302,10 @@ function processMessage(message: HmrMessageSentToBrowser) { dispatcher.onVulnerabilityInsight(message.hasVulnerabilityInsight) break }+ case HMR_MESSAGE_SENT_TO_BROWSER.VERSION_INFO: {+ dispatcher.onVersionInfo(message.versionInfo)+ break+ } case HMR_MESSAGE_SENT_TO_BROWSER.BUILT: case HMR_MESSAGE_SENT_TO_BROWSER.SYNC: { dispatcher.buildingIndicatorHide()packages/next/src/client/page-bootstrap.ts1 + / 0 −
@@ -121,6 +121,7 @@ export function pageBootstrap(assetPrefix: string) { case HMR_MESSAGE_SENT_TO_BROWSER.ISR_MANIFEST: case HMR_MESSAGE_SENT_TO_BROWSER.DEVTOOLS_CONFIG: case HMR_MESSAGE_SENT_TO_BROWSER.VULNERABILITY_INSIGHT:+ case HMR_MESSAGE_SENT_TO_BROWSER.VERSION_INFO: case HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK: case HMR_MESSAGE_SENT_TO_BROWSER.REQUEST_CURRENT_ERROR_STATE: case HMR_MESSAGE_SENT_TO_BROWSER.RUNTIME_ERRORS:packages/next/src/server/dev/hot-middleware.ts16 + / 1 −
@@ -26,6 +26,7 @@ import type ws from 'next/dist/compiled/ws' import type { DevToolsConfig } from '../../next-devtools/dev-overlay/shared' import { isMiddlewareFilename } from '../../build/utils' import type { VersionInfo } from './parse-version-info'+import { getPendingVersionInfo } from './hot-reloader-shared-utils' import type { HmrMessageSentToBrowser, RuntimeErrorStateMessage,@@ -91,15 +92,29 @@ export class WebpackHotMiddleware { null private serverLatestStats: { ts: number; stats: webpack.Stats } | null = null private hasVulnerabilityInsight = false+ private versionInfo: VersionInfo = getPendingVersionInfo() constructor( compilers: webpack.Compiler[],- private versionInfo: VersionInfo,+ versionInfo: Promise<VersionInfo>, private devtoolsFrontendUrl: string | undefined, private config: NextConfigComplete, private devToolsConfig: DevToolsConfig, hasVulnerabilityInsight: Promise<boolean> ) {+ // Clients that connected before the registry responded received the+ // placeholder in `sync`. Later clients get the resolved info in `sync`.+ versionInfo+ .then((resolvedVersionInfo) => {+ this.versionInfo = resolvedVersionInfo+ this.publish({+ type: HMR_MESSAGE_SENT_TO_BROWSER.VERSION_INFO,+ versionInfo: resolvedVersionInfo,+ })+ })+ .catch((error) => {+ console.error(error)+ }) hasVulnerabilityInsight.then((enabled) => { if (enabled) { this.hasVulnerabilityInsight = truepackages/next/src/server/dev/hot-reloader-shared-utils.ts13 + / 3 −
@@ -5,12 +5,22 @@ import { parseVersionInfo, type VersionInfo } from './parse-version-info' export const matchNextPageBundleRequest = getPathMatch( '/_next/static/chunks/pages/:path*.js(\\.map|)' )+/**+ * Version info to send while the registry request from `getVersionInfo()`+ * is still pending. Never await that request before talking to the browser.+ * A slow registry would otherwise delay the dev overlay.+ */+export function getPendingVersionInfo(): VersionInfo {+ return {+ installed: process.env.__NEXT_VERSION as string,+ staleness: 'unknown',+ }+}+ export async function getVersionInfo(): Promise<VersionInfo> {- let installed = '0.0.0'+ const installed = process.env.__NEXT_VERSION as string try {- installed = require('next/package.json').version- let response try {packages/next/src/server/dev/hot-reloader-turbopack.ts48 + / 23 −
@@ -139,9 +139,11 @@ import { setReactDebugChannelForHtmlRequest, } from './debug-channel' import {+ getPendingVersionInfo, getVersionInfo, matchNextPageBundleRequest, } from './hot-reloader-shared-utils'+import type { VersionInfo } from './parse-version-info' import { getMcpMiddleware } from '../mcp/get-mcp-middleware' import { formatCompilationIssues } from '../mcp/tools/utils/format-compilation-issues' import {@@ -1499,16 +1501,35 @@ export async function createHotReloaderTurbopack( }) }) - let versionInfoCached: ReturnType<typeof getVersionInfo> | undefined- // This fetch, even though not awaited, is not kicked off eagerly because the first `fetch()` in+ let resolvedVersionInfo: VersionInfo | undefined+ let versionInfoRequested = false+ // This fetch is not kicked off eagerly because the first `fetch()` in // Node.js adds roughly 20ms main-thread blocking to load the SSL certificate cache // We don't want that blocking time to be in the hot path for the `ready in` logging.- // Instead, the fetch is kicked off lazily when the first `getVersionInfoCached()` is called.- const getVersionInfoCached = (): ReturnType<typeof getVersionInfo> => {- if (!versionInfoCached) {- versionInfoCached = getVersionInfo()- }- return versionInfoCached+ // Instead, `startVersionInfoRequest()` kicks it off lazily when the first HMR+ // client connects. The registry request is never awaited: until it resolves,+ // clients get a placeholder in `sync`, and every connected client receives+ // the resolved info in a `versionInfo` message. Otherwise a slow registry+ // would delay `sync`, and with it the whole dev overlay.+ const startVersionInfoRequest = () => {+ if (versionInfoRequested) return+ versionInfoRequested = true+ getVersionInfo()+ .then((versionInfo) => {+ resolvedVersionInfo = versionInfo+ for (const client of [+ ...clientsWithoutHtmlRequestId,+ ...clientsByHtmlRequestId.values(),+ ]) {+ sendToClient(client, {+ type: HMR_MESSAGE_SENT_TO_BROWSER.VERSION_INFO,+ versionInfo,+ })+ }+ })+ .catch((error) => {+ console.error(error)+ }) } let devtoolsFrontendUrl: string | undefined@@ -1830,34 +1851,38 @@ export async function createHotReloaderTurbopack( } sendToClient(client, turbopackConnectedMessage) - const errors: CompilationError[] = []-- for (const entryIssues of currentEntryIssues.values()) {- for (const issue of entryIssues.values()) {- if (issue.severity !== 'warning') {- errors.push({- message: formatIssue(issue),- })- } else {- printNonFatalIssue(issue)- }- }- }+ startVersionInfoRequest() if (devIndicatorServerState.disabledUntil < Date.now()) { devIndicatorServerState.disabledUntil = 0 } ;(async function () {- const versionInfo = await getVersionInfoCached() const devToolsConfig = await getDevToolsConfig(distDir) + // Collect the issues only after the await so that `sync` reflects the+ // current build state. Stale errors here would replace newer ones on+ // the client.+ const errors: CompilationError[] = []++ for (const entryIssues of currentEntryIssues.values()) {+ for (const issue of entryIssues.values()) {+ if (issue.severity !== 'warning') {+ errors.push({+ message: formatIssue(issue),+ })+ } else {+ printNonFatalIssue(issue)+ }+ }+ }+ const syncMessage: SyncMessage = { type: HMR_MESSAGE_SENT_TO_BROWSER.SYNC, errors, warnings: [], hash: '',- versionInfo,+ versionInfo: resolvedVersionInfo ?? getPendingVersionInfo(), hasVulnerabilityInsight, debug: { devtoolsFrontendUrl,packages/next/src/server/dev/hot-reloader-types.ts13 + / 0 −
@@ -42,6 +42,7 @@ export const enum HMR_MESSAGE_SENT_TO_BROWSER { DEV_INDICATOR = 'devIndicator', DEVTOOLS_CONFIG = 'devtoolsConfig', VULNERABILITY_INSIGHT = 'vulnerabilityInsight',+ VERSION_INFO = 'versionInfo', REQUEST_CURRENT_ERROR_STATE = 'requestCurrentErrorState', RUNTIME_ERRORS = 'runtimeErrors', REQUEST_PAGE_METADATA = 'requestPageMetadata',@@ -271,6 +272,17 @@ export interface VulnerabilityInsightMessage { hasVulnerabilityInsight: boolean } +/**+ * Sent when the version staleness info resolves after the client already+ * received a placeholder in the `sync` message. The registry request+ * behind it is never awaited before `sync`, so a slow registry cannot delay+ * the dev overlay.+ */+export interface VersionInfoMessage {+ type: HMR_MESSAGE_SENT_TO_BROWSER.VERSION_INFO+ versionInfo: VersionInfo+}+ export type HmrMessageSentToBrowser = | TurbopackMessage | TurbopackConnectedMessage@@ -297,6 +309,7 @@ export type HmrMessageSentToBrowser = | CacheIndicatorMessage | RequestInsightsUpdateMessage | VulnerabilityInsightMessage+ | VersionInfoMessage export type BinaryHmrMessageSentToBrowser = Extract< HmrMessageSentToBrowser,packages/next/src/server/dev/hot-reloader-webpack.ts12 + / 5 −
@@ -103,6 +103,7 @@ import { type ReactDebugChannelForBrowser, } from './debug-channel' import {+ getPendingVersionInfo, getVersionInfo, matchNextPageBundleRequest, } from './hot-reloader-shared-utils'@@ -257,10 +258,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { protected appDir?: string private telemetry: Telemetry private resetFetch: () => void- private versionInfo: VersionInfo = {- staleness: 'unknown',- installed: '0.0.0',- }+ // Resolves once the registry lookup settles, with the placeholder info if it+ // fails. Never awaited during start-up so a slow registry cannot delay the+ // dev server or the dev overlay.+ private versionInfo!: Promise<VersionInfo> private devtoolsFrontendUrl: string | undefined private reloadAfterInvalidation: boolean = false private isSrcDir: boolean@@ -879,7 +880,13 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { const startSpan = this.hotReloaderSpan.traceChild('start') startSpan.stop() // Stop immediately to create an artificial parent span - this.versionInfo = await this.tracedGetVersionInfo(startSpan)+ // Not awaited, so a slow registry doesn't delay startup. The catch is+ // attached right away: nothing subscribes to this promise until the hot+ // middleware is created.+ this.versionInfo = this.tracedGetVersionInfo(startSpan).catch((error) => {+ console.error(error)+ return getPendingVersionInfo()+ }) const inspectorURLRaw = inspector.url() if (inspectorURLRaw !== undefined) {test/development/acceptance-app/fixtures/version-staleness/app/layout.jsadded8 + / 0 −
@@ -0,0 +1,8 @@+export default function RootLayout({ children }) {+ return (+ <html>+ <head></head>+ <body>{children}</body>+ </html>+ )+}test/development/acceptance-app/fixtures/version-staleness/app/page.jsadded5 + / 0 −
@@ -0,0 +1,5 @@+'use client'+import Component from '../index'+export default function Page() {+ return <Component />+}test/development/acceptance-app/fixtures/version-staleness/index.jsadded1 + / 0 −
@@ -0,0 +1 @@+export default () => 'new sandbox'test/development/acceptance-app/fixtures/version-staleness/next.config.jsadded35 + / 0 −
@@ -0,0 +1,35 @@+// Mocks the package registry that the dev server queries for version staleness+// (see `getVersionInfo()` in `packages/next/src/server/dev/hot-reloader-shared-utils.ts`).+// The registry comes from the package manager config, so any URL ending in the+// dist-tags path is intercepted. The fake dist-tags are a newer major than any+// installed version, so the installed `next` is always reported as outdated,+// without network access.+const DIST_TAGS_PATH = '/-/package/next/dist-tags'++if (!globalThis.__mockRegistryInstalled) {+ globalThis.__mockRegistryInstalled = true+ const originalFetch = globalThis.fetch++ globalThis.fetch = async function (input, init) {+ const url =+ typeof input === 'string' || input instanceof URL+ ? String(input)+ : input.url++ if (!new URL(url).pathname.endsWith(DIST_TAGS_PATH)) {+ return originalFetch.call(this, input, init)+ }++ return new Response(+ JSON.stringify({ latest: '999.0.0', canary: '999.0.0-canary.0' }),+ { headers: { 'content-type': 'application/json' } }+ )+ }+}++/**+ * @type {import('next').NextConfig}+ */+const nextConfig = {}++module.exports = nextConfigtest/development/acceptance-app/version-staleness.test.ts28 + / 88 −
@@ -1,34 +1,41 @@ /* eslint-env jest */ import { createSandbox } from 'development-sandbox' import { FileRef, nextTestSetup, type Playwright } from 'e2e-utils'+import { retry } from 'next-test-utils' import path from 'path' import { outdent } from 'outdent' -function getStaleness(browser: Playwright) {- return browser- .waitForElementByCss('.nextjs-container-build-error-version-status')- .text()-}+const bundlerName = process.env.IS_TURBOPACK_TEST+ ? 'Turbopack'+ : process.env.NEXT_RSPACK+ ? 'Rspack'+ : 'Webpack' describe('Error Overlay version staleness', () => {+ // The fixture's next.config.js mocks the package registry with a newer major+ // version, so the installed version is always reported as outdated. const { next } = nextTestSetup({- files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')),+ files: new FileRef(path.join(__dirname, 'fixtures', 'version-staleness')), skipStart: true, }) - it('should show version staleness in runtime error', async () => {- // Set next to outdated version- const nextPackageJson = JSON.parse(+ async function expectOutdatedStaleness(browser: Playwright) {+ const { version } = JSON.parse( await next.readFile('node_modules/next/package.json') )- nextPackageJson.version = '1.0.0'+ // The registry lookup is not awaited before the overlay is shown, so the+ // staleness can arrive after the installed version is already displayed.+ await retry(async () => {+ expect(+ await browser+ .waitForElementByCss('.nextjs-container-build-error-version-status')+ .text()+ ).toBe(`Next.js ${version} (outdated)\n${bundlerName}`)+ })+ } - await using sandbox = await createSandbox(- next,- new Map([- ['node_modules/next/package.json', JSON.stringify(nextPackageJson)],- ])- )+ it('should show version staleness in runtime error', async () => {+ await using sandbox = await createSandbox(next) const { session, browser } = sandbox await session.patch( 'app/page.js',@@ -46,37 +53,11 @@ describe('Error Overlay version staleness', () => { await session.openRedbox() - if (process.env.IS_TURBOPACK_TEST) {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 1.0.0 (outdated)- Turbopack"- `)- } else if (process.env.NEXT_RSPACK) {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 1.0.0 (outdated)- Rspack"- `)- } else {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 1.0.0 (outdated)- Webpack"- `)- }+ await expectOutdatedStaleness(browser) }) it('should show version staleness in render error', async () => {- // Set next to outdated version- const nextPackageJson = JSON.parse(- await next.readFile('node_modules/next/package.json')- )- nextPackageJson.version = '2.0.0'-- await using sandbox = await createSandbox(- next,- new Map([- ['node_modules/next/package.json', JSON.stringify(nextPackageJson)],- ])- )+ await using sandbox = await createSandbox(next) const { session, browser } = sandbox await session.patch( 'app/page.js',@@ -88,37 +69,11 @@ describe('Error Overlay version staleness', () => { ` ) - if (process.env.IS_TURBOPACK_TEST) {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 2.0.0 (outdated)- Turbopack"- `)- } else if (process.env.NEXT_RSPACK) {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 2.0.0 (outdated)- Rspack"- `)- } else {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 2.0.0 (outdated)- Webpack"- `)- }+ await expectOutdatedStaleness(browser) }) it('should show version staleness in build error', async () => {- // Set next to outdated version- const nextPackageJson = JSON.parse(- await next.readFile('node_modules/next/package.json')- )- nextPackageJson.version = '3.0.0'-- await using sandbox = await createSandbox(- next,- new Map([- ['node_modules/next/package.json', JSON.stringify(nextPackageJson)],- ])- )+ await using sandbox = await createSandbox(next) const { session, browser } = sandbox await session.patch( 'app/page.js',@@ -127,21 +82,6 @@ describe('Error Overlay version staleness', () => { ` ) - if (process.env.IS_TURBOPACK_TEST) {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 3.0.0 (outdated)- Turbopack"- `)- } else if (process.env.NEXT_RSPACK) {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 3.0.0 (outdated)- Rspack"- `)- } else {- expect(await getStaleness(browser)).toMatchInlineSnapshot(`- "Next.js 3.0.0 (outdated)- Webpack"- `)- }+ await expectOutdatedStaleness(browser) }) })test/development/app-dir/dev-overlay-pending-version-info/app/layout.tsxadded9 + / 0 −
@@ -0,0 +1,9 @@+import type { ReactNode } from 'react'++export default function Root({ children }: { children: ReactNode }) {+ return (+ <html>+ <body>{children}</body>+ </html>+ )+}test/development/app-dir/dev-overlay-pending-version-info/app/page.tsxadded3 + / 0 −
@@ -0,0 +1,3 @@+export default function Page() {+ return <p>hello world</p>+}test/development/app-dir/dev-overlay-pending-version-info/dev-overlay-pending-version-info.test.tsadded64 + / 0 −
@@ -0,0 +1,64 @@+import { nextTestSetup, type Playwright } from 'e2e-utils'+import { getRedboxDescription, retry, waitForRedbox } from 'next-test-utils'++// The fixture's next.config.js makes the dev server's registry request+// (used for the version staleness indicator) hang until the test creates the+// `release-registry` marker file.++function getVersionStatus(browser: Playwright) {+ return browser+ .waitForElementByCss('.nextjs-container-build-error-version-status')+ .text()+}++describe.each([+ {+ router: 'app router',+ pathname: '/',+ file: 'app/page.tsx',+ content: 'hello world',+ },+ {+ router: 'pages router',+ pathname: '/pages-router',+ file: 'pages/pages-router.tsx',+ content: 'hello pages',+ },+])(+ 'dev-overlay-pending-version-info ($router)',+ ({ pathname, file, content }) => {+ const { next } = nextTestSetup({+ files: __dirname,+ })++ it('shows build errors while the registry request is pending and updates the version once it resolves', async () => {+ const browser = await next.browser(pathname)+ expect(await browser.elementByCss('p').text()).toBe(content)++ await next.patchFile(file, (source) =>+ source!.replace(+ 'export default function Page',+ "import 'does-not-exist'\n\nexport default function Page"+ )+ )++ await waitForRedbox(browser)+ expect(await getRedboxDescription(browser)).toBe(+ "Module not found: Can't resolve 'does-not-exist'"+ )+ expect(await getVersionStatus(browser)).toContain('(unknown)')++ await next.patchFile('release-registry', '')++ await retry(async () => {+ expect(await getVersionStatus(browser)).toContain('(outdated)')+ }, 10_000)++ // A late version update must not clear the current build error.+ await waitForRedbox(browser)+ expect(await getRedboxDescription(browser)).toBe(+ "Module not found: Can't resolve 'does-not-exist'"+ )+ })+ }+)test/development/app-dir/dev-overlay-pending-version-info/next.config.jsadded42 + / 0 −
@@ -0,0 +1,42 @@+const fs = require('fs')+const path = require('path')++// Simulates a package registry that does not respond: the dev server's+// version-staleness request hangs until the test creates the marker file.+// The fake dist-tags then mark the installed version as outdated. The registry+// comes from the package manager config, so any URL ending in the dist-tags+// path is intercepted.+const DIST_TAGS_PATH = '/-/package/next/dist-tags'+const releaseMarker = path.join(__dirname, 'release-registry')++if (!globalThis.__hangingRegistryInstalled) {+ globalThis.__hangingRegistryInstalled = true+ const originalFetch = globalThis.fetch++ globalThis.fetch = async function (input, init) {+ const url =+ typeof input === 'string' || input instanceof URL+ ? String(input)+ : input.url++ if (!new URL(url).pathname.endsWith(DIST_TAGS_PATH)) {+ return originalFetch.call(this, input, init)+ }++ while (!fs.existsSync(releaseMarker)) {+ await new Promise((resolve) => setTimeout(resolve, 100))+ }++ return new Response(+ JSON.stringify({ latest: '999.0.0', canary: '999.0.0-canary.0' }),+ { headers: { 'content-type': 'application/json' } }+ )+ }+}++/**+ * @type {import('next').NextConfig}+ */+const nextConfig = {}++module.exports = nextConfigtest/development/app-dir/dev-overlay-pending-version-info/pages/pages-router.tsxadded3 + / 0 −
@@ -0,0 +1,3 @@+export default function Page() {+ return <p>hello pages</p>+}