vercel/next.js · #100628

Don't block the dev overlay on the npm registry version check

sokra · merged Nov 9, 202618 files · 309 + / 120 −
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 = true
packages/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 = nextConfig
test/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 = nextConfig
test/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>+}