Skip to content

feat(issues): New stack trace component#109428

Open
scttcper wants to merge 60 commits intomasterfrom
scttcper/new-stack-trace
Open

feat(issues): New stack trace component#109428
scttcper wants to merge 60 commits intomasterfrom
scttcper/new-stack-trace

Conversation

@scttcper
Copy link
Member

@scttcper scttcper commented Feb 26, 2026

New stack trace component (non-native):

  • no longer uses a mix of emotion and global styles
  • stories for many of the complex states
  • A few contexts instead of prop drilling
  • Frame content does not render until opened, speeding up issue details render speed on large 100+ frame stack traces.
  • The regular opportunities that show up when re-writting, like using our components
  • Styles are no longer shared with native frames which has made them historically difficult to touch, since you had to look at the variations of regular errors and native errors.

Components are now broken into IssueStackTrace and StackTrace. All fetch requests happen in IssueStackTrace and StackTrace components are somewhat designed to be compostable. It got tricky in there.

stories - https://sentry-9kfjix4ca.sentry.dev/stories/product/components/stacktrace/stacktrace/

- handles long filenames by wrapping to two lines
- handles mobile screen sizes a little better
- has two contexts instead of prop drilling
- does not use global styles
@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Feb 26, 2026
Move frame context and variables rendering further onto layout/text primitives
and wire frame context regions to chevron controls for better semantics.

Avoid work for collapsed frames by gating source context/highlighting inputs
behind expansion state.

Add keyboard-focused tests for chevron Enter/Space behavior and aria wiring.

Co-Authored-By: Claude <noreply@anthropic.com>
Extract frame context and variables rendering into reusable stacktrace units and tighten header/layout interactions for the new component API.

Add a coverage host that uses React Query query options and query cache, fetching coverage only for expanded frames while keeping the base StackTrace component data-source agnostic.
Fix Core StackTrace spec failures by adding default stacktrace-link mocks, making badge assertions resilient, and using explicit space key events for chevron keyboard behavior.

Also remove unused stacktrace exports and simplify coverage query eligibility checks without changing behavior.
scttcper and others added 16 commits February 27, 2026 13:59
Lift toolbar view state (view, isNewestFirst, isMinified) out of
per-exception StackTraceProviders into a single
StackTraceSharedViewContext. ChainedStackTrace now renders one
toolbar above all exceptions so switching App/Full/Raw or toggling
order affects all frames simultaneously.

- Add StackTraceSharedViewContext + StackTraceSharedViewProvider
- StackTraceProvider.Root falls back to local state when no shared
  context is present (existing single-trace usage unchanged)
- Toolbar sub-components read shared context first, then per-provider
  context, enabling standalone use in StackTraceSharedViewProvider

Co-Authored-By: Claude <noreply@anthropic.com>
mix-blend-mode: screen washed out coverage colors to near-white when
blending against background.secondary (a light gray). The old component
used var(--prism-highlight-background) which is dark enough for screen
blending to produce visible results.

Since FrameSourceLineNumber is a grid cell, its background already covers
the row highlight naturally — no blending is needed. Remove mix-blend-mode
and apply coverage colors directly, using the 200-level shades for active
lines to distinguish from the 100-level shades on inactive lines.

Co-Authored-By: Claude <noreply@anthropic.com>
Pull all frame actions (chevron, source link, source maps debugger,
hidden frames toggle) out of the monolithic FrameHeader into individual
files under frame/actions/, mirroring the toolbar/ pattern.

Expose them via StackTraceProvider.Frame.Actions.* so consumers can
compose exactly the actions they need. FrameHeader gains an `actions`
prop — when omitted it renders the same default set as before, so
all existing usage is unchanged.

Also extracts toolbar items (DisplayOptions, CopyButton, DownloadButton)
into toolbar/, adds ExceptionHeader as a standalone composable component,
and isolates hover state into a separate StackTraceFrameHoverContext so
only action components re-render on hover rather than the full frame tree.

Co-Authored-By: Claude <noreply@anthropic.com>
…ckTrace

Replace the standalone ChainedStackTrace component with IssueStackTrace,
a single cohesive component that handles both single and chained exceptions.
IssueStackTrace owns the InterimSection chrome (title, actions in header)
for both cases and provides a CopyButton that concatenates all stacktraces
in the chained case.

Move SharedViewRoot out of stackTraceProvider into issueStackTrace so the
provider stays a pure generic component with no knowledge of the issues
layer. Remove StackTraceSharedViewProvider export entirely.

Co-Authored-By: Claude <noreply@anthropic.com>
Remove lockAddress and threadId from the generic StackTraceProvider
context. These were ANR-specific concerns leaking into a generic
component.

Replace with a frameBadge render prop that lets callers inject
per-frame badges without the provider knowing about ANR. IssueStackTrace
now owns the ANR detection logic and passes a frameBadge callback that
computes the Suspect Frame badge for qualifying frames.

Also fixes the coverage line number aria-label for accessibility.

Co-Authored-By: Claude <noreply@anthropic.com>
…x minified story

- Document all StackTraceProviderProps with JSDoc comments
- Rename Display Options toggle from "Unsymbolicated" to "Minified" to
  match the internal value name and be more discoverable
- Update the minified story to use StackTraceProvider directly with
  defaultIsMinified so the feature is visible on load

Co-Authored-By: Claude <noreply@anthropic.com>
Introduce a dedicated view-state provider and require stack-trace consumers to
read from a single context source to remove cross-context fallbacks. Replace
hover context with explicit props, tighten shared type contracts, and document
context field guarantees so stack-trace APIs are easier to reason about.

Co-Authored-By: Claude <noreply@anthropic.com>
Made-with: Cursor
Move frame expansion state out of shared stacktrace context so toggling one frame
updates only per-row consumers. Also hoist project lookup to provider scope and use
set-based index membership in row building to cut repeated per-frame work.

Co-Authored-By: Claude <noreply@anthropic.com>
Made-with: Cursor
Restore StackTraceProvider.Frame to its public row-based API so composed frame
stories compile without internal expansion props. Also remove a dead nullable
context check in DownloadButton and align minifiedStacktrace docs with the new
view-state provider model.

Co-Authored-By: Claude <noreply@anthropic.com>
Made-with: Cursor
Split frame rendering and default actions into dedicated stack trace modules
and update the frame header to rely on CSS truncation with selectable inline
text. This keeps path suffixes visible, improves copy/paste behavior, and
stabilizes single-line and wrapped alignment.

Co-Authored-By: Claude <noreply@anthropic.com>
Made-with: Cursor
Use shared Text primitives and simplify inline metadata wrappers so the
frame title row keeps consistent spacing and baseline alignment.

Co-Authored-By: Claude <noreply@anthropic.com>
Made-with: Cursor
scttcper and others added 2 commits March 5, 2026 14:02
Render chevrons as non-interactive indicators and reserve slot width only when
visible rows include expandable frames. Keep action columns aligned across mixed
rows and add a Storybook example to verify the behavior quickly.

Co-Authored-By: Claude <noreply@anthropic.com>
Made-with: Cursor
scttcper and others added 2 commits March 12, 2026 14:06
Components is static data from SentryAppComponentsStore that never
changes within a stack trace lifecycle. Having it in context was just
indirect prop drilling. IssueSourceLinkAction now reads directly from
the store, and components is removed from the context, provider props,
and provider value.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
Made-with: Cursor
scttcper and others added 2 commits March 13, 2026 15:37
Use React Activity to preserve frame state across expand/collapse
and app/full view toggles instead of unmounting. Frames are lazily
rendered on first appearance and kept alive via Activity when hidden.

- Wrap FrameContent in Activity so expand/collapse preserves DOM
  and query cache state instead of remounting
- Compute allRows (all frames) alongside filtered rows, render from
  allRows with Activity mode based on current visibility
- Remove useEffect that reset hiddenFrameToggleMap on view change,
  toggle state now naturally persists across view switches
- Remove early return null from IssueStackTraceFrameContext since
  FrameContent handles its own visibility
- Update tests to use toBeVisible assertions since Activity keeps
  elements in the DOM with display:none

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@scttcper scttcper marked this pull request as ready for review March 13, 2026 23:09
@scttcper scttcper requested a review from a team as a code owner March 13, 2026 23:09
@scttcper scttcper requested a review from a team March 13, 2026 23:09
Comment on lines +139 to +142
allRows,
exceptionIndex,
event,
frameSourceMapDebuggerData,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The getDefaultPlatform function incorrectly prioritizes event.platform over the more specific frame.platform, which is a regression from the previous logic and inconsistent with the backend.
Severity: MEDIUM

Suggested Fix

In stackTraceProvider.tsx, modify the getDefaultPlatform function to prioritize the frame-level platform before falling back to the event-level platform. Change the return statement to return framePlatform ?? event.platform ?? 'other'; to align with the old implementation and backend logic.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: static/app/components/stackTrace/stackTraceProvider.tsx#L139-L142

Potential issue: The `getDefaultPlatform` function in the new stack trace provider
incorrectly determines the platform for rendering frames. It prioritizes the event-level
platform (`event.platform`) over the more specific frame-level platform
(`frame.platform`). This is a regression from the previous implementation and is
inconsistent with the backend's grouping logic, which both prioritize the frame's
platform. This can lead to incorrect platform-specific rendering for stack trace frames,
such as applying the wrong line number formatting or other platform-specific UI features
when a frame's platform differs from the overall event's platform.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Chained exception copy ignores minified toggle state
    • The chained-exception copy path now uses the same minified-versus-symbolicated stacktrace selection as the rendered raw view and is covered by a regression test.
  • ✅ Fixed: Hidden frame toggle state not reset on frame change
    • Hidden-frame expansion state is now scoped to the active stacktrace/view so switching variants resets stale groups immediately, with regression coverage added.

Create PR

Or push these changes by commenting:

@cursor push 359f8d2144
Preview (359f8d2144)
diff --git a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
--- a/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
+++ b/static/app/components/stackTrace/issueStackTrace/index.spec.tsx
@@ -407,6 +407,68 @@
     expect(firstIdx).toBeLessThan(secondIdx);
   });
 
+  it('copies unsymbolicated raw text for chained exceptions when minified view is active', async () => {
+    Object.assign(navigator, {
+      clipboard: {
+        writeText: jest.fn().mockResolvedValue(''),
+      },
+    });
+
+    const {event, stacktrace} = makeStackTraceData();
+    const symbolicatedStacktrace: StacktraceWithFrames = {
+      ...stacktrace,
+      frames: stacktrace.frames.map((frame, index) => ({
+        ...frame,
+        filename: `symbolicated/${index}.js`,
+      })),
+    };
+    const rawStacktrace: StacktraceWithFrames = {
+      ...symbolicatedStacktrace,
+      frames: symbolicatedStacktrace.frames.map((frame, index) => ({
+        ...frame,
+        filename: `minified/${index}.js`,
+      })),
+    };
+
+    render(
+      <IssueStackTrace
+        event={event}
+        values={[
+          {
+            type: 'RootError',
+            value: 'root cause',
+            module: 'app.main',
+            mechanism: {handled: false, type: 'generic'},
+            stacktrace: symbolicatedStacktrace,
+            rawStacktrace,
+            threadId: null,
+          },
+          {
+            type: 'NestedError',
+            value: 'nested cause',
+            module: 'app.nested',
+            mechanism: {handled: false, type: 'generic'},
+            stacktrace: symbolicatedStacktrace,
+            rawStacktrace,
+            threadId: null,
+          },
+        ]}
+      />
+    );
+
+    await userEvent.click(screen.getByRole('button', {name: 'Display options'}));
+    await userEvent.click(await screen.findByRole('option', {name: 'Unsymbolicated'}));
+
+    await userEvent.click(screen.getByRole('button', {name: 'Copy as'}));
+    await userEvent.click(await screen.findByText('Text'));
+
+    await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalled());
+
+    const copiedText = (navigator.clipboard.writeText as jest.Mock).mock.calls[0][0];
+    expect(copiedText).toContain('minified/0.js');
+    expect(copiedText).not.toContain('symbolicated/0.js');
+  });
+
   describe('standalone stacktrace prop', () => {
     it('renders frame rows for a standalone stacktrace', async () => {
       const {event, stacktrace} = makeStackTraceData();

diff --git a/static/app/components/stackTrace/issueStackTrace/index.tsx b/static/app/components/stackTrace/issueStackTrace/index.tsx
--- a/static/app/components/stackTrace/issueStackTrace/index.tsx
+++ b/static/app/components/stackTrace/issueStackTrace/index.tsx
@@ -77,6 +77,13 @@
   stacktrace: StacktraceType;
 }
 
+function getDisplayedRawStacktrace(
+  exc: IndexedExceptionValue,
+  isMinified: boolean
+): StacktraceType {
+  return isMinified ? (exc.rawStacktrace ?? exc.stacktrace) : exc.stacktrace;
+}
+
 /** Resolves symbolicated vs raw (minified) exception fields. */
 function resolveExceptionFields(exc: IndexedExceptionValue, isMinified: boolean) {
   return {
@@ -251,8 +258,10 @@
               exceptions
                 .map(exc =>
                   rawStacktraceContent({
-                    data: exc.stacktrace,
+                    data: getDisplayedRawStacktrace(exc, isMinified),
                     platform: event.platform,
+                    exception: exc,
+                    isMinified,
                   })
                 )
                 .join('\n\n')
@@ -268,9 +277,7 @@
               {exceptions
                 .map(exc =>
                   rawStacktraceContent({
-                    data: isMinified
-                      ? (exc.rawStacktrace ?? exc.stacktrace)
-                      : exc.stacktrace,
+                    data: getDisplayedRawStacktrace(exc, isMinified),
                     platform: event.platform,
                     exception: exc,
                     isMinified,

diff --git a/static/app/components/stackTrace/stackTrace.spec.tsx b/static/app/components/stackTrace/stackTrace.spec.tsx
--- a/static/app/components/stackTrace/stackTrace.spec.tsx
+++ b/static/app/components/stackTrace/stackTrace.spec.tsx
@@ -245,6 +245,47 @@
     expect(screen.getByRole('button', {name: 'Hide 1 frame'})).toBeInTheDocument();
   });
 
+  it('resets hidden frame toggles when switching stacktrace variants', async () => {
+    const {event, stacktrace} = makeStackTraceData();
+    const symbolicatedStacktrace: StacktraceWithFrames = {
+      ...stacktrace,
+      frames: [
+        {...stacktrace.frames[0]!, filename: 'symbolicated/hidden.js', inApp: false},
+        {...stacktrace.frames[1]!, filename: 'symbolicated/visible.js', inApp: false},
+        {...stacktrace.frames[2]!, filename: 'symbolicated/app.js', inApp: true},
+      ],
+    };
+    const minifiedStacktrace: StacktraceWithFrames = {
+      ...symbolicatedStacktrace,
+      frames: [
+        {...symbolicatedStacktrace.frames[0]!, filename: 'minified/hidden.js'},
+        {...symbolicatedStacktrace.frames[1]!, filename: 'minified/visible.js'},
+        {...symbolicatedStacktrace.frames[2]!, filename: 'minified/app.js'},
+      ],
+    };
+
+    render(
+      <TestStackTraceProvider
+        event={event}
+        stacktrace={symbolicatedStacktrace}
+        minifiedStacktrace={minifiedStacktrace}
+      >
+        <DisplayOptions />
+        <StackTraceFrames frameContextComponent={FrameContent} />
+      </TestStackTraceProvider>
+    );
+
+    await userEvent.click(screen.getByRole('button', {name: 'Show 1 more frame'}));
+    expect(screen.getByText('symbolicated/hidden.js')).toBeInTheDocument();
+
+    await userEvent.click(screen.getByRole('button', {name: 'Display options'}));
+    await userEvent.click(await screen.findByRole('option', {name: 'Unsymbolicated'}));
+
+    expect(screen.getByText('minified/hidden.js')).not.toBeVisible();
+    expect(screen.queryByRole('button', {name: 'Hide 1 frame'})).not.toBeInTheDocument();
+    expect(screen.getByRole('button', {name: 'Show 1 more frame'})).toBeInTheDocument();
+  });
+
   it('renders frame badges for in-app frames only', async () => {
     renderStackTrace();
 

diff --git a/static/app/components/stackTrace/stackTraceProvider.tsx b/static/app/components/stackTrace/stackTraceProvider.tsx
--- a/static/app/components/stackTrace/stackTraceProvider.tsx
+++ b/static/app/components/stackTrace/stackTraceProvider.tsx
@@ -45,12 +45,23 @@
   );
   const lastFrameIndex = useMemo(() => getLastFrameIndex(frames), [frames]);
 
-  const [hiddenFrameToggleMap, setHiddenFrameToggleMap] = useState(() =>
-    createInitialHiddenFrameToggleMap(frames, view === 'full')
+  const shouldIncludeSystemFrames = view === 'full';
+  const initialHiddenFrameToggleMap = useMemo(
+    () => createInitialHiddenFrameToggleMap(frames, shouldIncludeSystemFrames),
+    [frames, shouldIncludeSystemFrames]
   );
+  const [hiddenFrameToggleState, setHiddenFrameToggleState] = useState(() => ({
+    stacktrace: activeStacktrace,
+    view,
+    map: initialHiddenFrameToggleMap,
+  }));
+  const hiddenFrameToggleMap =
+    hiddenFrameToggleState.stacktrace === activeStacktrace &&
+    hiddenFrameToggleState.view === view
+      ? hiddenFrameToggleState.map
+      : initialHiddenFrameToggleMap;
 
   const platform = platformProp ?? getDefaultPlatform(activeStacktrace, event);
-  const shouldIncludeSystemFrames = view === 'full';
 
   const frameCountMap = useMemo(
     () => getFrameCountMap(frames, shouldIncludeSystemFrames),
@@ -129,10 +140,21 @@
       hiddenFrameToggleMap,
       lastFrameIndex,
       toggleHiddenFrames: (frameIndex: number) => {
-        setHiddenFrameToggleMap(prevState => ({
-          ...prevState,
-          [frameIndex]: !prevState[frameIndex],
-        }));
+        setHiddenFrameToggleState(prevState => {
+          const currentMap =
+            prevState.stacktrace === activeStacktrace && prevState.view === view
+              ? prevState.map
+              : initialHiddenFrameToggleMap;
+
+          return {
+            stacktrace: activeStacktrace,
+            view,
+            map: {
+              ...currentMap,
+              [frameIndex]: !currentMap[frameIndex],
+            },
+          };
+        });
       },
     }),
     [
@@ -144,12 +166,14 @@
       hasAnyExpandableFrames,
       hideSourceMapDebugger,
       hiddenFrameToggleMap,
+      initialHiddenFrameToggleMap,
       lastFrameIndex,
       meta,
       platform,
       project,
       rows,
       activeStacktrace,
+      view,
     ]
   );

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

scttcper and others added 2 commits March 13, 2026 16:26
…ptions

The chained-exception copy button always copied the symbolicated
stacktrace, ignoring the isMinified toggle. Now both single and
chained exception copy use the same minified-aware data source.

Also removes the CopyButton wrapper in favor of inline CopyAsDropdown,
and adds a test covering the unsymbolicated copy path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract shared sectionActions and narrow StackTraceProvider to wrap
only StackTraceFrames in the single-exception branch, matching the
multi-exception structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +48 to +50
const [hiddenFrameToggleMap, setHiddenFrameToggleMap] = useState(() =>
createInitialHiddenFrameToggleMap(frames, view === 'full')
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The hiddenFrameToggleMap state is not reset when frames change, causing stale indices when toggling between minified and symbolicated views.
Severity: MEDIUM

Suggested Fix

Reset the hiddenFrameToggleMap state whenever the frames prop changes. This can be achieved by using a useEffect hook that listens for changes to frames and calls setHiddenFrameToggleMap to re-initialize the state based on the new frames.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: static/app/components/stackTrace/stackTraceProvider.tsx#L48-L50

Potential issue: In the `StackTraceProvider` component, the `hiddenFrameToggleMap` state
is initialized only once when the component mounts. When the user toggles between
minified and symbolicated stack traces, the `frames` array changes, but
`hiddenFrameToggleMap` is not updated. This causes it to retain stale frame indices from
the previous stack trace. If the minified and symbolicated stack traces have different
frame counts or structures, any subsequent attempt to toggle hidden frames will use
incorrect indices, leading to the wrong frames being shown or hidden, or the toggle
having no effect.

- Import `isRepeatedFrame` and `getLastFrameIndex` from existing
  `events/interfaces/utils` instead of duplicating them in getRows.tsx
- Extract `toggleHiddenFrames` to `useCallback` to avoid recreating the
  closure on every context useMemo recomputation
- Fix `event: any` type to `Event` in FrameLocation props
- Deduplicate `leadsToApp`/`hasLeadHint` condition in frameHeader
- Reuse `formatFrameLocation` for inline suffix computation
- Move shared `RawStackTraceText` styled component to rawStackTrace.tsx
- Remove unnecessary wrapper `<div>` elements in ExceptionHeader and
  StackTraceFrames
- Inline visibility conditions in DefaultFrameActions and
  IssueFrameActions so rendering logic is visible at composition level
- Replace `.filter().flat()` with `.flatMap()` in getRows
The banner was rendered when `idx === 0`, but the exceptions array is
reversed when newest-first. Use `firstVisibleExceptionIndex` instead,
which accounts for both sort order and hidden exception groups.
Remove margin-bottom from FramesPanel and add a `borderless` prop that
strips border and border-radius for embedded contexts like hover
previews.
When a frame's absPath is an HTTP URL, show it as a clickable link in
the filename tooltip. Extracted tooltip rendering into a
FrameLocationTooltip subcomponent that owns the Tooltip, content, and
disabled logic.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant