From 6b8a606b6ef777c0ec4a34fa6b240ad81768285f Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:10:24 +0000 Subject: [PATCH 1/3] fix(replays): Prevent React crash by stringifying plain objects in frame descriptions --- .../utils/replays/getFrameDetails.spec.tsx | 47 +++++++++++++++++++ static/app/utils/replays/getFrameDetails.tsx | 16 +++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/static/app/utils/replays/getFrameDetails.spec.tsx b/static/app/utils/replays/getFrameDetails.spec.tsx index ec343d1cd950..fbc5a6f17bba 100644 --- a/static/app/utils/replays/getFrameDetails.spec.tsx +++ b/static/app/utils/replays/getFrameDetails.spec.tsx @@ -20,4 +20,51 @@ describe('getFrameDetails', () => { expect(getFrameDetails(frame).colorGraphicsToken).toBe(color); }); }); + + describe('defaultMapper (unknown frame types)', () => { + it('serializes a plain-object frame.data to a string when message is absent', () => { + // A frame whose category has no specific mapper, so defaultMapper is used. + // frame.data is a plain object — this must not crash React rendering. + const frame: any = { + op: 'unknown.custom', + category: 'unknown.custom', + timestamp: new Date('2024-01-01T00:00:00Z').getTime() / 1000, + offsetMs: 0, + timestampMs: 0, + message: undefined, + data: {type: 'foo', level: 'info', offsetMs: 0}, + }; + const {description} = getFrameDetails(frame); + // Must be a string, not a plain object, so React can render it safely + expect(typeof description).toBe('string'); + }); + + it('prefers frame.message over frame.data when both are present', () => { + const frame: any = { + op: 'unknown.custom', + category: 'unknown.custom', + timestamp: new Date('2024-01-01T00:00:00Z').getTime() / 1000, + offsetMs: 0, + timestampMs: 0, + message: 'hello', + data: {type: 'foo'}, + }; + const {description} = getFrameDetails(frame); + expect(description).toBe('hello'); + }); + + it('returns empty string when both message and data are absent', () => { + const frame: any = { + op: 'unknown.custom', + category: 'unknown.custom', + timestamp: new Date('2024-01-01T00:00:00Z').getTime() / 1000, + offsetMs: 0, + timestampMs: 0, + message: undefined, + data: undefined, + }; + const {description} = getFrameDetails(frame); + expect(description).toBe(''); + }); + }); }); diff --git a/static/app/utils/replays/getFrameDetails.tsx b/static/app/utils/replays/getFrameDetails.tsx index 1145f272bc11..c9adf9f4a75a 100644 --- a/static/app/utils/replays/getFrameDetails.tsx +++ b/static/app/utils/replays/getFrameDetails.tsx @@ -226,14 +226,14 @@ const MAPPER_FOR_FRAME: Record Details> = { }), 'ui.swipe': (frame: SwipeFrame) => ({ colorGraphicsToken: 'accent', - description: frame.data, + description: toDescription(frame.data), tabKey: TabKey.BREADCRUMBS, title: 'User Swipe', icon: , }), 'ui.scroll': (frame: ScrollFrame) => ({ colorGraphicsToken: 'accent', - description: frame.data, + description: toDescription(frame.data), tabKey: TabKey.BREADCRUMBS, title: 'User Scroll', icon: , @@ -464,11 +464,21 @@ const MAPPER_FOR_FRAME: Record Details> = { }), }; +// Safely convert an unknown value to something React can render. +// Plain objects cannot be React children and will cause a render crash, so +// serialize them to a JSON string instead. +function toDescription(value: unknown): ReactNode { + if (value !== null && typeof value === 'object') { + return JSON.stringify(value); + } + return (value as ReactNode) ?? ''; +} + // Keep this lowercase. React Compiler treats capitalized functions that create // JSX as components, and this one is called from event handlers. const defaultMapper = (frame: any): Details => ({ colorGraphicsToken: 'neutral', - description: frame.message ?? frame.data ?? '', + description: toDescription(frame.message ?? frame.data), tabKey: TabKey.BREADCRUMBS, title: toTitleCase(defaultTitle(frame)), icon: , From 04fd2a232125fe77f12b12df4f8e66c731545104 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:24:47 +0000 Subject: [PATCH 2/3] fix(replays): Allow object descriptions in frame details --- .../utils/replays/getFrameDetails.spec.tsx | 30 +++++++++---------- static/app/utils/replays/getFrameDetails.tsx | 20 +++++-------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/static/app/utils/replays/getFrameDetails.spec.tsx b/static/app/utils/replays/getFrameDetails.spec.tsx index fbc5a6f17bba..ec68254e3306 100644 --- a/static/app/utils/replays/getFrameDetails.spec.tsx +++ b/static/app/utils/replays/getFrameDetails.spec.tsx @@ -22,35 +22,36 @@ describe('getFrameDetails', () => { }); describe('defaultMapper (unknown frame types)', () => { - it('serializes a plain-object frame.data to a string when message is absent', () => { - // A frame whose category has no specific mapper, so defaultMapper is used. - // frame.data is a plain object — this must not crash React rendering. + it('uses frame.message when it is a string', () => { const frame: any = { op: 'unknown.custom', category: 'unknown.custom', timestamp: new Date('2024-01-01T00:00:00Z').getTime() / 1000, offsetMs: 0, timestampMs: 0, - message: undefined, - data: {type: 'foo', level: 'info', offsetMs: 0}, + message: 'hello', + data: {type: 'foo'}, }; - const {description} = getFrameDetails(frame); - // Must be a string, not a plain object, so React can render it safely - expect(typeof description).toBe('string'); + expect(getFrameDetails(frame).description).toBe('hello'); }); - it('prefers frame.message over frame.data when both are present', () => { + it('falls back to frame.data when frame.message is a non-string object', () => { + // frame.message could be a non-string at runtime (malformed frame data). + // ?? only guards null/undefined, so without a typeof check the whole frame + // object could become the description. We guard with typeof so the plain + // object falls through to frame.data instead. + const dataPayload = {type: 'foo', level: 'info', offsetMs: 0}; const frame: any = { op: 'unknown.custom', category: 'unknown.custom', timestamp: new Date('2024-01-01T00:00:00Z').getTime() / 1000, offsetMs: 0, timestampMs: 0, - message: 'hello', - data: {type: 'foo'}, + message: {nested: 'object'}, // non-string at runtime + data: dataPayload, }; - const {description} = getFrameDetails(frame); - expect(description).toBe('hello'); + // Should fall back to frame.data, not use the non-string frame.message + expect(getFrameDetails(frame).description).toBe(dataPayload); }); it('returns empty string when both message and data are absent', () => { @@ -63,8 +64,7 @@ describe('getFrameDetails', () => { message: undefined, data: undefined, }; - const {description} = getFrameDetails(frame); - expect(description).toBe(''); + expect(getFrameDetails(frame).description).toBe(''); }); }); }); diff --git a/static/app/utils/replays/getFrameDetails.tsx b/static/app/utils/replays/getFrameDetails.tsx index c9adf9f4a75a..a89304aadcd4 100644 --- a/static/app/utils/replays/getFrameDetails.tsx +++ b/static/app/utils/replays/getFrameDetails.tsx @@ -226,14 +226,14 @@ const MAPPER_FOR_FRAME: Record Details> = { }), 'ui.swipe': (frame: SwipeFrame) => ({ colorGraphicsToken: 'accent', - description: toDescription(frame.data), + description: frame.data, tabKey: TabKey.BREADCRUMBS, title: 'User Swipe', icon: , }), 'ui.scroll': (frame: ScrollFrame) => ({ colorGraphicsToken: 'accent', - description: toDescription(frame.data), + description: frame.data, tabKey: TabKey.BREADCRUMBS, title: 'User Scroll', icon: , @@ -464,21 +464,15 @@ const MAPPER_FOR_FRAME: Record Details> = { }), }; -// Safely convert an unknown value to something React can render. -// Plain objects cannot be React children and will cause a render crash, so -// serialize them to a JSON string instead. -function toDescription(value: unknown): ReactNode { - if (value !== null && typeof value === 'object') { - return JSON.stringify(value); - } - return (value as ReactNode) ?? ''; -} - // Keep this lowercase. React Compiler treats capitalized functions that create // JSX as components, and this one is called from event handlers. const defaultMapper = (frame: any): Details => ({ colorGraphicsToken: 'neutral', - description: toDescription(frame.message ?? frame.data), + // frame.message may be a non-string object at runtime (e.g. a malformed frame). + // The ?? operator only guards null/undefined, so we guard with typeof first. + // Passing a plain object is fine: BreadcrumbItem routes it to BreadcrumbStructuredData + // which renders it as an interactive inspector via StructuredEventData. + description: (typeof frame.message === 'string' ? frame.message : undefined) ?? frame.data ?? '', tabKey: TabKey.BREADCRUMBS, title: toTitleCase(defaultTitle(frame)), icon: , From b844b09eea250acd89a6933f5eeeebb466d75bb8 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:26:22 +0000 Subject: [PATCH 3/3] :hammer_and_wrench: apply pre-commit fixes --- static/app/utils/replays/getFrameDetails.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/app/utils/replays/getFrameDetails.tsx b/static/app/utils/replays/getFrameDetails.tsx index a89304aadcd4..4109410c854c 100644 --- a/static/app/utils/replays/getFrameDetails.tsx +++ b/static/app/utils/replays/getFrameDetails.tsx @@ -472,7 +472,8 @@ const defaultMapper = (frame: any): Details => ({ // The ?? operator only guards null/undefined, so we guard with typeof first. // Passing a plain object is fine: BreadcrumbItem routes it to BreadcrumbStructuredData // which renders it as an interactive inspector via StructuredEventData. - description: (typeof frame.message === 'string' ? frame.message : undefined) ?? frame.data ?? '', + description: + (typeof frame.message === 'string' ? frame.message : undefined) ?? frame.data ?? '', tabKey: TabKey.BREADCRUMBS, title: toTitleCase(defaultTitle(frame)), icon: ,