Repository: vercel/next.js
Pull request: [dev-overlay] fix duplicate re-render of errors

### What

When there's a same error throwing constantly, we should avoid unnecessary re-renders on the exact same error. In dev overlay we already have strategies that can dedupe the error. And we noticed the error is shown correctly but just the dialog got re-rendered with animations.

After digging deeper, noticed we are still adding the same runtime error into the error collection, just because each time it got the new "ID", as the "ID" itself is incremental based on actions. Each action will get a new error ID. Hence we're adding a condition in deduping. If we found that the new error already existed in the previous events, we'll return with the same previous events instance to tell dev overlay that errors are not changed.

Fixes NEXT-4493

#### After
https://github.com/user-attachments/assets/c1f004b5-2de6-445a-aad0-efdfd0ecb8c9

#### Before
https://github.com/user-attachments/assets/1249ed54-ba63-4eb6-a08a-64560ab4014a



Changed files:
filename: packages/next/src/client/components/react-dev-overlay/shared.ts
patch: @@ -232,8 +232,13 @@ export function useErrorOverlayReducer(
         getOwnerStack(event.error) !== getOwnerStack(pendingEvent.error)
       )
     })
-    pendingEvents.push(pendingEvent)
-    return pendingEvents
+    // If there's nothing filtered out, the event is a brand new error
+    if (pendingEvents.length === events.length) {
+      pendingEvents.push(pendingEvent)
+      return pendingEvents
+    }
+    // Otherwise remain the same events
+    return events
   }
 
   return useReducer((state: OverlayState, action: BusEvent): OverlayState => {

filename: test/development/app-dir/error-overlay/duplicate-runtime-error/app/layout.tsx
patch: @@ -0,0 +1,8 @@
+import { ReactNode } from 'react'
+export default function Root({ children }: { children: ReactNode }) {
+  return (
+    <html>
+      <body>{children}</body>
+    </html>
+  )
+}

filename: test/development/app-dir/error-overlay/duplicate-runtime-error/app/page.tsx
patch: @@ -0,0 +1,15 @@
+'use client'
+
+import { useEffect } from 'react'
+
+export default function Page() {
+  // set a timeout to throw an error each 2 seconds
+  useEffect(() => {
+    const interval = setInterval(() => {
+      throw new Error('This is a test error from the app directory')
+    }, 500)
+
+    return () => clearInterval(interval)
+  }, [])
+  return <p>page</p>
+}

filename: test/development/app-dir/error-overlay/duplicate-runtime-error/duplicate-runtime-error.test.ts
patch: @@ -0,0 +1,29 @@
+import { nextTestSetup } from 'e2e-utils'
+import { retry, waitFor } from 'next-test-utils'
+
+describe('duplicate-runtime-error', () => {
+  const { next } = nextTestSetup({
+    files: __dirname,
+  })
+
+  it('should not rerender the overlay for the same runtime error', async () => {
+    const browser = await next.browser('/')
+    const badge = await browser.elementByCss('[data-next-badge]')
+
+    // Wait until the badge is showing "errored"
+    await retry(async () => {
+      const errorProp = await badge.getAttribute('data-error')
+
+      expect(errorProp).toBe('true')
+    })
+
+    // Still present the same value after a while without re-rendersw.
+    // Should always display the badge, should not re-render by hiding it again
+    for (let i = 0; i < 40; i++) {
+      const errorProp = await badge.getAttribute('data-error')
+
+      expect(errorProp).toBe('true')
+      await waitFor(50)
+    }
+  })
+})

filename: test/development/app-dir/error-overlay/duplicate-runtime-error/next.config.js
patch: @@ -0,0 +1,6 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {}
+
+module.exports = nextConfig