Repository: vercel/next.js
Pull request: [devtools] storybook: port utils to be reusable

Ported utils of errors and useOverlayReducer to be reusable, e.g., DevTools panel Issues tab.

Changed files:
filename: packages/next/src/next-devtools/dev-overlay/container/errors.stories.tsx
patch: @@ -2,8 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'
 
 import { Errors } from './errors'
 import { withShadowPortal } from '../storybook/with-shadow-portal'
-import type { ReadyRuntimeError } from '../utils/get-error-by-type'
 import { lorem } from '../utils/lorem'
+import { runtimeErrors } from '../storybook/errors'
 
 const meta: Meta<typeof Errors> = {
   component: Errors,
@@ -16,144 +16,6 @@ const meta: Meta<typeof Errors> = {
 export default meta
 type Story = StoryObj<typeof Errors>
 
-const originalCodeFrame = (message: string) => {
-  return `\u001b[0m \u001b[90m 1 \u001b[39m \u001b[36mexport\u001b[39m \u001b[36mdefault\u001b[39m \u001b[36mfunction\u001b[39m \u001b[33mHome\u001b[39m() {\u001b[0m
-\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 2 \u001b[39m   \u001b[36mthrow\u001b[39m \u001b[36mnew\u001b[39m \u001b[33mError\u001b[39m(\u001b[32m'${message}'\u001b[39m)\u001b[0m
-\u001b[0m \u001b[90m   \u001b[39m         \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m
-\u001b[0m \u001b[90m 3 \u001b[39m   \u001b[36mreturn\u001b[39m \u001b[33m<\u001b[39m\u001b[33mdiv\u001b[39m\u001b[33m>\u001b[39m\u001b[33mWelcome to my Next.js application! This is a longer piece of text that will demonstrate text wrapping behavior in the code frame.\u001b[39m\u001b[33m<\u001b[39m\u001b[33m/\u001b[39m\u001b[33mdiv\u001b[39m\u001b[33m>\u001b[39m\u001b[0m
-\u001b[0m \u001b[90m 4 \u001b[39m }\u001b[0m
-\u001b[0m \u001b[90m 5 \u001b[39m\u001b[0m`
-}
-
-const sourceStackFrame = {
-  file: 'app/page.tsx',
-  methodName: 'Home',
-  arguments: [],
-  lineNumber: 2,
-  column: 9,
-}
-
-const originalStackFrame = {
-  file: 'app/page.tsx',
-  methodName: 'Home',
-  arguments: [],
-  lineNumber: 2,
-  column: 9,
-  ignored: false,
-}
-
-const frame = {
-  originalStackFrame: {
-    file: './app/page.tsx',
-    methodName: 'MyComponent',
-    arguments: [],
-    lineNumber: 10,
-    column: 5,
-    ignored: false,
-  },
-  sourceStackFrame: {
-    file: './app/page.tsx',
-    methodName: 'MyComponent',
-    arguments: [],
-    lineNumber: 10,
-    column: 5,
-  },
-  originalCodeFrame: 'export default function MyComponent() {',
-  error: false,
-  reason: null,
-  external: false,
-  ignored: false,
-}
-
-const ignoredFrame = {
-  ...frame,
-  ignored: true,
-}
-
-const runtimeErrors: ReadyRuntimeError[] = [
-  {
-    id: 1,
-    runtime: true,
-    error: new Error('First error message'),
-    frames: () =>
-      Promise.resolve([
-        frame,
-        {
-          ...frame,
-          originalStackFrame: {
-            ...frame.originalStackFrame,
-            methodName: 'ParentComponent',
-            lineNumber: 5,
-          },
-        },
-        {
-          ...frame,
-          originalStackFrame: {
-            ...frame.originalStackFrame,
-            methodName: 'GrandparentComponent',
-            lineNumber: 1,
-          },
-        },
-        ...Array(20).fill(ignoredFrame),
-      ]),
-    type: 'runtime',
-  },
-  {
-    id: 2,
-    runtime: true,
-    error: new Error('Second error message'),
-    frames: () =>
-      Promise.resolve([
-        {
-          error: true,
-          reason: 'Second error message',
-          external: false,
-          ignored: false,
-          sourceStackFrame,
-          originalStackFrame,
-          originalCodeFrame: originalCodeFrame('Second error message'),
-        },
-      ]),
-    type: 'runtime',
-  },
-  {
-    id: 3,
-    runtime: true,
-    error: new Error('Third error message'),
-    frames: () =>
-      Promise.resolve([
-        {
-          error: true,
-          reason: 'Third error message',
-          external: false,
-          ignored: false,
-          sourceStackFrame,
-          originalStackFrame,
-          originalCodeFrame: originalCodeFrame('Third error message'),
-        },
-      ]),
-    type: 'runtime',
-  },
-  {
-    id: 4,
-    runtime: true,
-    error: new Error('typeof window !== undefined'),
-    frames: () =>
-      Promise.resolve([
-        {
-          error: true,
-          reason: 'typeof window !== undefined',
-          external: false,
-          ignored: false,
-          sourceStackFrame,
-          originalStackFrame,
-          originalCodeFrame: originalCodeFrame('typeof window !== undefined'),
-        },
-      ]),
-    type: 'runtime',
-  },
-]
-
 export const Default: Story = {
   args: {
     getSquashedHydrationErrorDetails: () => null,

filename: packages/next/src/next-devtools/dev-overlay/dev-overlay.stories.tsx
patch: @@ -1,22 +1,15 @@
 import type { Meta, StoryObj } from '@storybook/react'
-import type { DispatcherEvent, OverlayState } from './shared'
+import type { OverlayState } from './shared'
 
 // @ts-expect-error
 import imgApp from './app.png'
 
-import { useReducer } from 'react'
 import { DevOverlay } from './dev-overlay'
+import { errors } from './storybook/errors'
 import {
-  ACTION_DEVTOOLS_POSITION,
-  ACTION_DEVTOOLS_PANEL_CLOSE,
-  ACTION_DEVTOOLS_PANEL_TOGGLE,
-  ACTION_ERROR_OVERLAY_CLOSE,
-  ACTION_ERROR_OVERLAY_OPEN,
-  ACTION_ERROR_OVERLAY_TOGGLE,
-  ACTION_DEVTOOLS_SCALE,
-  NEXT_DEV_TOOLS_SCALE,
-  INITIAL_OVERLAY_STATE,
-} from './shared'
+  storybookDefaultOverlayState,
+  useStorybookOverlayReducer,
+} from './storybook/use-overlay-reducer'
 
 const meta: Meta<typeof DevOverlay> = {
   component: DevOverlay,
@@ -40,104 +33,18 @@ const meta: Meta<typeof DevOverlay> = {
 export default meta
 type Story = StoryObj<typeof DevOverlay>
 
-const initialState: OverlayState = {
-  ...INITIAL_OVERLAY_STATE,
-  routerType: 'app',
-  showIndicator: true,
-  errors: [
-    {
-      id: 1,
-      error: Object.assign(new Error('First error message'), {
-        __NEXT_ERROR_CODE: 'E001',
-      }),
-      componentStackFrames: [
-        {
-          file: 'app/page.tsx',
-          component: 'Home',
-          lineNumber: 10,
-          column: 5,
-          canOpenInEditor: true,
-        },
-      ],
-      frames: [
-        {
-          file: 'app/page.tsx',
-          methodName: 'Home',
-          arguments: [],
-          lineNumber: 10,
-          column: 5,
-        },
-      ],
-      type: 'runtime',
-    },
-    {
-      id: 2,
-      error: Object.assign(new Error('Second error message'), {
-        __NEXT_ERROR_CODE: 'E002',
-      }),
-      frames: [],
-      type: 'runtime',
-    },
-    {
-      id: 3,
-      error: Object.assign(new Error('Third error message'), {
-        __NEXT_ERROR_CODE: 'E003',
-      }),
-      frames: [],
-      type: 'runtime',
-    },
-  ],
-  versionInfo: {
-    installed: '15.2.0',
-    staleness: 'fresh',
-  },
-  isErrorOverlayOpen: false,
-  isDevToolsPanelOpen: false,
-  devToolsPosition: 'bottom-left',
-  scale: NEXT_DEV_TOOLS_SCALE.Medium,
-}
-
-function useOverlayReducer() {
-  return useReducer<OverlayState, [DispatcherEvent]>(
-    (state, action): OverlayState => {
-      switch (action.type) {
-        case ACTION_ERROR_OVERLAY_CLOSE: {
-          return { ...state, isErrorOverlayOpen: false }
-        }
-        case ACTION_ERROR_OVERLAY_OPEN: {
-          return { ...state, isErrorOverlayOpen: true }
-        }
-        case ACTION_ERROR_OVERLAY_TOGGLE: {
-          return { ...state, isErrorOverlayOpen: !state.isErrorOverlayOpen }
-        }
-        case ACTION_DEVTOOLS_PANEL_TOGGLE: {
-          return { ...state, isDevToolsPanelOpen: !state.isDevToolsPanelOpen }
-        }
-        case ACTION_DEVTOOLS_PANEL_CLOSE: {
-          return { ...state, isDevToolsPanelOpen: false }
-        }
-        case ACTION_DEVTOOLS_POSITION: {
-          return { ...state, devToolsPosition: action.devToolsPosition }
-        }
-        case ACTION_DEVTOOLS_SCALE: {
-          return { ...state, scale: action.scale }
-        }
-        default: {
-          return state
-        }
-      }
-    },
-    initialState
-  )
-}
-
 function getNoSquashedHydrationErrorDetails() {
   return null
 }
 
+const initialState: OverlayState = {
+  ...storybookDefaultOverlayState,
+  errors,
+}
+
 export const Default: Story = {
   render: function DevOverlayStory() {
-    const [state, dispatch] = useOverlayReducer()
+    const [state, dispatch] = useStorybookOverlayReducer(initialState)
     return (
       <>
         <img
@@ -171,7 +78,7 @@ export const WithPanel: Story = {
     }
   },
   render: function DevOverlayStory() {
-    const [state, dispatch] = useOverlayReducer()
+    const [state, dispatch] = useStorybookOverlayReducer(initialState)
     return (
       <>
         <img

filename: packages/next/src/next-devtools/dev-overlay/storybook/errors.ts
patch: @@ -0,0 +1,184 @@
+import type { SupportedErrorEvent } from '../container/runtime-error/render-error'
+import type { ReadyRuntimeError } from '../utils/get-error-by-type'
+
+const originalCodeFrame = (message: string) => {
+  return `\u001b[0m \u001b[90m 1 \u001b[39m \u001b[36mexport\u001b[39m \u001b[36mdefault\u001b[39m \u001b[36mfunction\u001b[39m \u001b[33mHome\u001b[39m() {\u001b[0m
+\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 2 \u001b[39m   \u001b[36mthrow\u001b[39m \u001b[36mnew\u001b[39m \u001b[33mError\u001b[39m(\u001b[32m'${message}'\u001b[39m)\u001b[0m
+\u001b[0m \u001b[90m   \u001b[39m         \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m
+\u001b[0m \u001b[90m 3 \u001b[39m   \u001b[36mreturn\u001b[39m \u001b[33m<\u001b[39m\u001b[33mdiv\u001b[39m\u001b[33m>\u001b[39m\u001b[33mWelcome to my Next.js application! This is a longer piece of text that will demonstrate text wrapping behavior in the code frame.\u001b[39m\u001b[33m<\u001b[39m\u001b[33m/\u001b[39m\u001b[33mdiv\u001b[39m\u001b[33m>\u001b[39m\u001b[0m
+\u001b[0m \u001b[90m 4 \u001b[39m }\u001b[0m
+\u001b[0m \u001b[90m 5 \u001b[39m\u001b[0m`
+}
+
+const sourceStackFrame = {
+  file: 'app/page.tsx',
+  methodName: 'Home',
+  arguments: [],
+  lineNumber: 2,
+  column: 9,
+}
+
+const originalStackFrame = {
+  file: 'app/page.tsx',
+  methodName: 'Home',
+  arguments: [],
+  lineNumber: 2,
+  column: 9,
+  ignored: false,
+}
+
+const frame = {
+  originalStackFrame: {
+    file: './app/page.tsx',
+    methodName: 'MyComponent',
+    arguments: [],
+    lineNumber: 10,
+    column: 5,
+    ignored: false,
+  },
+  sourceStackFrame: {
+    file: './app/page.tsx',
+    methodName: 'MyComponent',
+    arguments: [],
+    lineNumber: 10,
+    column: 5,
+  },
+  originalCodeFrame: 'export default function MyComponent() {',
+  error: false,
+  reason: null,
+  external: false,
+  ignored: false,
+}
+
+const ignoredFrame = {
+  ...frame,
+  ignored: true,
+}
+
+export const errors: SupportedErrorEvent[] = [
+  {
+    id: 1,
+    error: Object.assign(new Error('First error message'), {
+      __NEXT_ERROR_CODE: 'E001',
+    }),
+    componentStackFrames: [
+      {
+        file: 'app/page.tsx',
+        component: 'Home',
+        lineNumber: 10,
+        column: 5,
+        canOpenInEditor: true,
+      },
+    ],
+    frames: [
+      {
+        file: 'app/page.tsx',
+        methodName: 'Home',
+        arguments: [],
+        lineNumber: 10,
+        column: 5,
+      },
+    ],
+    type: 'runtime',
+  },
+  {
+    id: 2,
+    error: Object.assign(new Error('Second error message'), {
+      __NEXT_ERROR_CODE: 'E002',
+    }),
+    frames: [],
+    type: 'runtime',
+  },
+  {
+    id: 3,
+    error: Object.assign(new Error('Third error message'), {
+      __NEXT_ERROR_CODE: 'E003',
+    }),
+    frames: [],
+    type: 'runtime',
+  },
+]
+
+export const runtimeErrors: ReadyRuntimeError[] = [
+  {
+    id: 1,
+    runtime: true,
+    error: new Error('First error message'),
+    frames: () =>
+      Promise.resolve([
+        frame,
+        {
+          ...frame,
+          originalStackFrame: {
+            ...frame.originalStackFrame,
+            methodName: 'ParentComponent',
+            lineNumber: 5,
+          },
+        },
+        {
+          ...frame,
+          originalStackFrame: {
+            ...frame.originalStackFrame,
+            methodName: 'GrandparentComponent',
+            lineNumber: 1,
+          },
+        },
+        ...Array(20).fill(ignoredFrame),
+      ]),
+    type: 'runtime',
+  },
+  {
+    id: 2,
+    runtime: true,
+    error: new Error('Second error message'),
+    frames: () =>
+      Promise.resolve([
+        {
+          error: true,
+          reason: 'Second error message',
+          external: false,
+          ignored: false,
+          sourceStackFrame,
+          originalStackFrame,
+          originalCodeFrame: originalCodeFrame('Second error message'),
+        },
+      ]),
+    type: 'console',
+  },
+  {
+    id: 3,
+    runtime: true,
+    error: new Error('Third error message'),
+    frames: () =>
+      Promise.resolve([
+        {
+          error: true,
+          reason: 'Third error message',
+          external: false,
+          ignored: false,
+          sourceStackFrame,
+          originalStackFrame,
+          originalCodeFrame: originalCodeFrame('Third error message'),
+        },
+      ]),
+    type: 'recoverable',
+  },
+  {
+    id: 4,
+    runtime: true,
+    error: new Error('typeof window !== undefined'),
+    frames: () =>
+      Promise.resolve([
+        {
+          error: true,
+          reason: 'typeof window !== undefined',
+          external: false,
+          ignored: false,
+          sourceStackFrame,
+          originalStackFrame,
+          originalCodeFrame: originalCodeFrame('typeof window !== undefined'),
+        },
+      ]),
+    type: 'runtime',
+  },
+]

filename: packages/next/src/next-devtools/dev-overlay/storybook/use-overlay-reducer.ts
patch: @@ -0,0 +1,58 @@
+import type { DispatcherEvent, OverlayState } from '../shared'
+
+import { useReducer } from 'react'
+import {
+  ACTION_DEVTOOLS_POSITION,
+  ACTION_DEVTOOLS_PANEL_CLOSE,
+  ACTION_DEVTOOLS_PANEL_TOGGLE,
+  ACTION_ERROR_OVERLAY_CLOSE,
+  ACTION_ERROR_OVERLAY_OPEN,
+  ACTION_ERROR_OVERLAY_TOGGLE,
+  ACTION_DEVTOOLS_SCALE,
+  INITIAL_OVERLAY_STATE,
+} from '../shared'
+
+export const storybookDefaultOverlayState: OverlayState = {
+  ...INITIAL_OVERLAY_STATE,
+  routerType: 'app',
+  isErrorOverlayOpen: true,
+  showIndicator: true,
+  versionInfo: {
+    installed: '15.4.0',
+    staleness: 'fresh',
+  },
+}
+
+export function useStorybookOverlayReducer(initialState?: OverlayState) {
+  return useReducer<OverlayState, [DispatcherEvent]>(
+    (state, action): OverlayState => {
+      switch (action.type) {
+        case ACTION_ERROR_OVERLAY_CLOSE: {
+          return { ...state, isErrorOverlayOpen: false }
+        }
+        case ACTION_ERROR_OVERLAY_OPEN: {
+          return { ...state, isErrorOverlayOpen: true }
+        }
+        case ACTION_ERROR_OVERLAY_TOGGLE: {
+          return { ...state, isErrorOverlayOpen: !state.isErrorOverlayOpen }
+        }
+        case ACTION_DEVTOOLS_PANEL_TOGGLE: {
+          return { ...state, isDevToolsPanelOpen: !state.isDevToolsPanelOpen }
+        }
+        case ACTION_DEVTOOLS_PANEL_CLOSE: {
+          return { ...state, isDevToolsPanelOpen: false }
+        }
+        case ACTION_DEVTOOLS_POSITION: {
+          return { ...state, devToolsPosition: action.devToolsPosition }
+        }
+        case ACTION_DEVTOOLS_SCALE: {
+          return { ...state, scale: action.scale }
+        }
+        default: {
+          return state
+        }
+      }
+    },
+    initialState || storybookDefaultOverlayState
+  )
+}

filename: packages/next/tsconfig.build.json
patch: @@ -15,6 +15,7 @@
     // Don't generate types for internal files.
     "./**/*.test.ts",
     "./**/*.test.tsx",
-    "./**/*.stories.tsx"
+    "./**/*.stories.tsx",
+    "./**/storybook/**/*"
   ]
 }