Repository: vercel/next.js
Pull request: Prevent unhandled rejection filter from being bundled into the server runtime

The unhandled-rejection module was being bundled into server.runtime.prod.js AND loaded as a standalone file, causing installUnhandledRejectionFilter() to run twice. Each instance captured the other's handler as an underlying listener,
  creating mutual recursion that overflowed the stack on any unhandled rejection.

  Fix: Rename to .external.tsx so the webpack config externalizes it from the server runtime bundle. It is now only loaded once from the standalone file by node-environment.ts.

  Also adds two defensive measures:

  - A global symbol guard that noops if a second installation is ever attempted from a separate module evaluation (e.g. Jest's module system re-evaluating an already-loaded module in the same process)
  - A reentrancy guard on the handler that noops if an underlying listener synchronously re-emits 'unhandledRejection'

Changed files:
filename: packages/next/errors.json
patch: @@ -1064,5 +1064,6 @@
   "1063": "`connection` must not be used within a Client Component. Next.js should be preventing `connection` from being included in Client Components statically, but did not in this case.",
   "1064": "createServerParamsForRoute should not be called in client contexts.",
   "1065": "createServerPathnameForMetadata should not be called in client contexts.",
-  "1066": "createServerSearchParamsForServerPage should not be called in a client validation."
+  "1066": "createServerSearchParamsForServerPage should not be called in a client validation.",
+  "1067": "The Next.js unhandled rejection filter is being installed more than once. This is a bug in Next.js."
 }

filename: packages/next/src/server/node-environment-extensions/unhandled-rejection.external.test.ts
patch: @@ -148,7 +148,7 @@ describe('unhandled-rejection filter', () => {
   describe('environment variable configuration', () => {
     it('should install filter by default', async () => {
       async function testForWorker() {
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         reportResult({
           type: 'count',
@@ -167,7 +167,7 @@ describe('unhandled-rejection filter', () => {
     it('should not install filter when disabled', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'disabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         reportResult({
           type: 'count',
@@ -186,7 +186,7 @@ describe('unhandled-rejection filter', () => {
     it('should install filter rejections when environment variable is enabled', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         reportResult({
           type: 'count',
@@ -205,7 +205,7 @@ describe('unhandled-rejection filter', () => {
     it('should install filter rejections when environment variable is enabled in debug mode', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'debug'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         reportResult({
           type: 'count',
@@ -229,7 +229,7 @@ describe('unhandled-rejection filter', () => {
           originalWarn(...args)
         }
 
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const filterListener = process.listeners('unhandledRejection')[0]
         process.removeListener('unhandledRejection', filterListener)
@@ -258,7 +258,7 @@ describe('unhandled-rejection filter', () => {
           originalWarn(...args)
         }
 
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const filterListener = process.listeners('unhandledRejection')[0]
         process.off('unhandledRejection', filterListener)
@@ -287,7 +287,7 @@ describe('unhandled-rejection filter', () => {
           originalWarn(...args)
         }
 
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const filterListener = process.listeners('unhandledRejection')[0]
         process.removeAllListeners()
@@ -317,7 +317,7 @@ describe('unhandled-rejection filter', () => {
           originalWarn(...args)
         }
 
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const filterListener = process.listeners('unhandledRejection')[0]
         process.removeAllListeners()
@@ -335,7 +335,7 @@ describe('unhandled-rejection filter', () => {
     it('should suppress rejections from aborted prerender contexts', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = '1'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const {
           workUnitAsyncStorage,
@@ -403,7 +403,7 @@ describe('unhandled-rejection filter', () => {
     it('should suppress rejections from aborted prerender-client contexts', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = '1'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const {
           workUnitAsyncStorage,
@@ -471,7 +471,7 @@ describe('unhandled-rejection filter', () => {
     it('should suppress rejections from aborted prerender-runtime contexts', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = '1'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const {
           workUnitAsyncStorage,
@@ -539,7 +539,7 @@ describe('unhandled-rejection filter', () => {
     it('should pass through rejections from non-aborted prerender contexts', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = '1'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const {
           workUnitAsyncStorage,
@@ -582,7 +582,7 @@ describe('unhandled-rejection filter', () => {
     it('should call console.error when no handlers are present', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = '1'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         console.error = (...args: Array<any>) => {
           reportResult({ type: 'error-log', message: args.join(' ') })
@@ -603,7 +603,7 @@ describe('unhandled-rejection filter', () => {
     it('should handle process.once listeners correctly', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         let callCount = 0
 
@@ -638,7 +638,7 @@ describe('unhandled-rejection filter', () => {
     it('should handle process.removeListener correctly', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const handler1 = (reason: unknown) => {
           reportResult({ type: 'uhr', reason: `[1]: ${String(reason)}` })
@@ -708,7 +708,7 @@ describe('unhandled-rejection filter', () => {
     it('should uninstall filter when removeAllListeners() is called without arguments', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const {
           workUnitAsyncStorage,
@@ -770,7 +770,7 @@ describe('unhandled-rejection filter', () => {
     it('should not uninstall filter when removeAllListeners("unhandledRejection") is called', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const {
           workUnitAsyncStorage,
@@ -829,7 +829,7 @@ describe('unhandled-rejection filter', () => {
       // during event handling.
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const onceHandler = (reason: unknown) => {
           reportResult({ type: 'uhr', reason: `once: ${String(reason)}` })
@@ -935,7 +935,7 @@ describe('unhandled-rejection filter', () => {
         const originalNames = originalMethods.map((m) => m.name)
 
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const patchedMethods = [
           process.on,
@@ -995,7 +995,7 @@ describe('unhandled-rejection filter', () => {
     it('should handle errors thrown by user handlers gracefully', async () => {
       async function testForWorker() {
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const {
           workUnitAsyncStorage,
@@ -1039,7 +1039,7 @@ describe('unhandled-rejection filter', () => {
         })
 
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         const {
           workUnitAsyncStorage,
@@ -1081,7 +1081,7 @@ describe('unhandled-rejection filter', () => {
         })
 
         process.env.NEXT_UNHANDLED_REJECTION_FILTER = 'enabled'
-        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+        require('next/dist/server/node-environment-extensions/unhandled-rejection.external')
 
         process.removeAllListeners('unhandledRejection')
 

filename: packages/next/src/server/node-environment-extensions/unhandled-rejection.external.tsx
patch: @@ -116,6 +116,11 @@ type ListenerMetadata = {
   once: boolean
 }
 
+// We use a global symbol to detect if the filter has already been installed.
+// If two instances of this module are loaded, each captures the other's handler
+// as an underlying listener, creating mutual recursion that overflows the stack.
+// We error defensively rather than silently degrading.
+const FILTER_INSTALLED_KEY = Symbol.for('next.unhandledRejectionFilter')
 let filterInstalled = false
 
 // We store the proxied listeners for unhandled rejections here.
@@ -194,10 +199,10 @@ const MACGUFFIN_EVENT = 'Next.UnhandledRejectionFilter.MacguffinEvent'
  * This should be called once during server startup to install the global filter.
  */
 function installUnhandledRejectionFilter(): void {
-  if (filterInstalled) {
-    warnWithTrace?.(
-      'Unexpected subsequent filter installation. This is a bug in Next.js'
-    )
+  if ((globalThis as any)[FILTER_INSTALLED_KEY] || filterInstalled) {
+    // Already installed by another evaluation of this module in the same
+    // process (e.g., Jest's module system re-evaluating an already-loaded
+    // module). Safe to skip since the filter is already active.
     return
   }
 
@@ -526,6 +531,7 @@ You can debug event listener operations by running Next.js with \`NEXT_UNHANDLED
   )
 
   filterInstalled = true
+  ;(globalThis as any)[FILTER_INSTALLED_KEY] = true
 }
 
 /**
@@ -578,10 +584,18 @@ function uninstallUnhandledRejectionFilter(): void {
 /**
  * The filtering handler that decides whether to suppress or delegate unhandled rejections.
  */
+let handlingRejection = false
+
 function filteringUnhandledRejectionHandler(
   reason: any,
   promise: Promise<any>
 ): void {
+  if (handlingRejection) {
+    // An underlying listener synchronously re-emitted 'unhandledRejection'.
+    // Re-entering the listener loop would overflow the stack.
+    return
+  }
+
   const capturedListenerMetadata = Array.from(listenerMetadata)
 
   const workUnitStore = workUnitAsyncStorage.getStore()
@@ -622,6 +636,7 @@ function filteringUnhandledRejectionHandler(
     // do not automatically terminate the process.
     console.error('Unhandled Rejection:', reason)
   } else {
+    handlingRejection = true
     try {
       for (const meta of capturedListenerMetadata) {
         if (meta.once) {
@@ -640,6 +655,8 @@ function filteringUnhandledRejectionHandler(
       setImmediate(() => {
         throw error
       })
+    } finally {
+      handlingRejection = false
     }
   }
 }

filename: packages/next/src/server/node-environment.ts
patch: @@ -12,7 +12,7 @@ import './node-environment-extensions/console-file'
 import './node-environment-extensions/console-exit'
 import './node-environment-extensions/console-dim.external'
 
-import './node-environment-extensions/unhandled-rejection'
+import './node-environment-extensions/unhandled-rejection.external'
 import './node-environment-extensions/random'
 import './node-environment-extensions/date'
 import './node-environment-extensions/web-crypto'

filename: test/production/next-server-nft/next-server-nft.test.ts
patch: @@ -479,6 +479,7 @@ async function readNormalizedNFT(next, name) {
            "/node_modules/next/dist/server/load-manifest.external.js",
            "/node_modules/next/dist/server/node-environment-extensions/console-dim.external.js",
            "/node_modules/next/dist/server/node-environment-extensions/fast-set-immediate.external.js",
+           "/node_modules/next/dist/server/node-environment-extensions/unhandled-rejection.external.js",
            "/node_modules/next/dist/server/response-cache/types.js",
            "/node_modules/next/dist/server/route-modules/app-page/module.compiled.js",
            "/node_modules/next/dist/server/route-modules/app-page/vendored/contexts/app-router-context.js",