feat(preprod): Add snapshot diff comparison UI#109403
feat(preprod): Add snapshot diff comparison UI#109403NicoHinderling merged 11 commits intomasterfrom
Conversation
|
🚨 Warning: This pull request contains Frontend and Backend changes! It's discouraged to make changes to Sentry's Frontend and Backend in a single pull request. The Frontend and Backend are not atomically deployed. If the changes are interdependent of each other, they must be separated into two pull requests and be made forward or backwards compatible, such that the Backend or Frontend can be safely deployed independently. Have questions? Please ask in the |
bf0b0d7 to
3a78519
Compare
d183176 to
23457f7
Compare
3a78519 to
ea0acde
Compare
23457f7 to
221e217
Compare
static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Blob URL memory leak due to stale closure
- I switched blob URL tracking to a ref and revoke the current URL in effect teardown so created object URLs are always cleaned up.
- ✅ Fixed: Polling never stops for terminal comparison states
- Polling is now enabled only when the comparison state is PENDING or PROCESSING, so it stops in SUCCESS and FAILED terminal states.
- ✅ Fixed: Renamed items mislabeled as "Unchanged" in status
- I added an explicit renamed branch in the status label mapping so renamed items render as Renamed instead of Unchanged.
- ✅ Fixed: Stale diff overlay persists when switching between pairs
- The diff effect now clears diffMaskUrl at the start and when no diff URL exists, preventing old overlays from persisting across pair switches.
Or push these changes by commenting:
@cursor push 31e99c3926
Preview (31e99c3926)
diff --git a/static/app/views/preprod/snapshots/header/snapshotDevTools.tsx b/static/app/views/preprod/snapshots/header/snapshotDevTools.tsx
--- a/static/app/views/preprod/snapshots/header/snapshotDevTools.tsx
+++ b/static/app/views/preprod/snapshots/header/snapshotDevTools.tsx
@@ -49,13 +49,16 @@
const [recompareError, setRecompareError] = useState<string | null>(null);
const clientRef = useRef(new Client());
+ const isComparisonInProgress =
+ comparisonState === 'PENDING' || comparisonState === 'PROCESSING';
+
useEffect(() => {
- if (comparisonState) {
+ if (isComparisonInProgress) {
setPolling(true);
} else {
setPolling(false);
}
- }, [comparisonState]);
+ }, [isComparisonInProgress]);
useEffect(() => {
if (!polling) {
diff --git a/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.tsx b/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.tsx
--- a/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.tsx
+++ b/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.tsx
@@ -1,4 +1,4 @@
-import {useEffect, useState} from 'react';
+import {useEffect, useRef, useState} from 'react';
import styled from '@emotion/styled';
import {Image} from '@sentry/scraps/image';
@@ -24,6 +24,7 @@
overlayColor,
}: DiffImageDisplayProps) {
const [diffMaskUrl, setDiffMaskUrl] = useState<string | null>(null);
+ const currentDiffMaskUrlRef = useRef<string | null>(null);
const baseImageUrl = `${imageBaseUrl}${pair.base_image.key}/`;
const headImageUrl = `${imageBaseUrl}${pair.head_image.key}/`;
@@ -32,6 +33,12 @@
: null;
useEffect(() => {
+ if (currentDiffMaskUrlRef.current) {
+ URL.revokeObjectURL(currentDiffMaskUrlRef.current);
+ currentDiffMaskUrlRef.current = null;
+ }
+ setDiffMaskUrl(null);
+
if (!diffImageUrl) {
return undefined;
}
@@ -40,17 +47,19 @@
.then(r => r.blob())
.then(blob => {
if (!cancelled) {
- setDiffMaskUrl(URL.createObjectURL(blob));
+ const nextDiffMaskUrl = URL.createObjectURL(blob);
+ currentDiffMaskUrlRef.current = nextDiffMaskUrl;
+ setDiffMaskUrl(nextDiffMaskUrl);
}
})
.catch(() => {});
return () => {
cancelled = true;
- if (diffMaskUrl) {
- URL.revokeObjectURL(diffMaskUrl);
+ if (currentDiffMaskUrlRef.current) {
+ URL.revokeObjectURL(currentDiffMaskUrlRef.current);
+ currentDiffMaskUrlRef.current = null;
}
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [diffImageUrl]);
const diffPercent = pair.diff === null ? null : `${(pair.diff * 100).toFixed(1)}%`;
diff --git a/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx b/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx
--- a/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx
+++ b/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx
@@ -117,7 +117,9 @@
? t('Added')
: selectedItem.type === 'removed'
? t('Removed')
- : t('Unchanged');
+ : selectedItem.type === 'renamed'
+ ? t('Renamed')
+ : t('Unchanged');
return (
<Flex direction="column" gap="0" padding="0" height="100%" width="100%">
static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.tsx
Show resolved
Hide resolved
static/app/views/preprod/snapshots/main/snapshotMainContent.tsx
Outdated
Show resolved
Hide resolved
static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.tsx
Show resolved
Hide resolved
ea0acde to
dc4b148
Compare
d20cb6e to
f82fff2
Compare
dc4b148 to
89d8b7b
Compare
| return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`; | ||
| } | ||
|
|
||
| type Props = { |
There was a problem hiding this comment.
Nit: match our convention of interface and naming the props per-component, in this case SnapshotDevToolsProps
There was a problem hiding this comment.
Changed to interface SnapshotDevToolsProps.
— Claude Code
static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx
Outdated
Show resolved
Hide resolved
| <ConstrainedImage src={headImageUrl} alt={t('Current Branch')} /> | ||
| {showOverlay && diffMaskUrl && ( | ||
| <DiffOverlay | ||
| style={{ |
There was a problem hiding this comment.
DiffOverlay is already a styled component, why not move these styles into the styled(span) definition below?
There was a problem hiding this comment.
Moved all inline styles into the styled component definition using transient props ($overlayColor, $maskUrl).
— Claude Code
| const image = selectedItem.image; | ||
| const displayName = image.display_name ?? image.image_file_name; | ||
| const imageUrl = `${imageBaseUrl}${image.key}/`; | ||
| const statusLabel = |
There was a problem hiding this comment.
I personally hate if/else chaining like this, it makes it really hard to follow the logic IMO. Could you either extract this into a util or try to clean it up, maybe with a switch statement?
There was a problem hiding this comment.
Replaced ternary chain with a STATUS_LABELS Record lookup map.
— Claude Code
| ); | ||
| })} | ||
| {filteredGroups.size === 0 && !hasNextPage && !isFetchingNextPage && ( | ||
| {isDiffMode && groupedItems |
There was a problem hiding this comment.
I also dislike if/else display logic like this, especially with really large blocks of components/UI. I'd recommend extracting some of this UI into a new component, or grouping the logic to be more explicit, like isGroupedDiff = isDiffMode && groupedItems then using that to display or not isGroupedDiff && (<ui>) !isGroupedDiff && (<other ui>)
https://github.com/zakirullin/cognitive-load is a good explainer of alot of my dislike for logic like this, it's quite hard to understand and follow
There was a problem hiding this comment.
Extracted isGroupedDiff = isDiffMode && groupedItems !== null and use it directly instead of the inline conditional.
— Claude Code
| } | ||
|
|
||
| const SidebarItem = styled('div')<{isSelected: boolean}>` | ||
| const SearchContainer = styled('div')` |
There was a problem hiding this comment.
This shouldn't be a styled component, we should use the Layout primitives, in this case Stack and use the padding props
There was a problem hiding this comment.
Replaced SearchContainer with <Flex padding="xl" paddingBottom="0">.
— Claude Code
| overflow: hidden; | ||
| `; | ||
|
|
||
| const MainPanel = styled('div')` |
There was a problem hiding this comment.
I don't think this or SidebarPanel need to be styled components, I think everything can be done with our layout primitives https://sentry.sentry.io/stories/core/composition/
There was a problem hiding this comment.
Replaced both SidebarPanel and MainPanel with Flex primitives. Kept DragHandle as styled component due to hover/active states.
— Claude Code
dc6719d to
4aba878
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
static/app/views/preprod/snapshots/main/snapshotMainContent.tsx
Outdated
Show resolved
Hide resolved
4aba878 to
951d713
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| stateLabel = t('Done'); | ||
| } else { | ||
| stateLabel = t('No comparison'); | ||
| } |
There was a problem hiding this comment.
Missing explicit SUCCESS state in comparison label logic
Medium Severity
The stateLabel if-else chain explicitly handles PROCESSING, PENDING, and FAILED from the ComparisonState enum, but omits SUCCESS. It instead relies on the optional comparisonCompletedAt field being truthy to implicitly show "Done." If the API returns state: SUCCESS without a completed_at timestamp (both fields are optional in SnapshotComparisonRunInfo), the label incorrectly falls through to "No comparison" instead of "Done."




Summary
Adds the frontend diff comparison UI for the preprod snapshot viewer, enabling side-by-side visual comparison of snapshot images between a base artifact and the current branch.
New components:
DiffImageDisplay— side-by-side base vs current branch image view with diff mask overlay supportSnapshotDevTools— dev tools panel showing comparison state, polling status, duration, and a "Re-run Comparison" buttonSidebar improvements:
DisclosureComparison toolbar:
Type additions:
SnapshotComparisonRunInfo,ComparisonState,DiffStatus, andSidebarItemdiscriminated unionLegal Boilerplate
Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.