Skip to content

feat(preprod): Add snapshot diff comparison UI#109403

Merged
NicoHinderling merged 11 commits intomasterfrom
02-25-feat_preprod_wip_web_stuff
Mar 3, 2026
Merged

feat(preprod): Add snapshot diff comparison UI#109403
NicoHinderling merged 11 commits intomasterfrom
02-25-feat_preprod_wip_web_stuff

Conversation

@NicoHinderling
Copy link
Contributor

@NicoHinderling NicoHinderling commented Feb 25, 2026

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 support
  • SnapshotDevTools — dev tools panel showing comparison state, polling status, duration, and a "Re-run Comparison" button

Sidebar improvements:

  • Collapsible sections grouped by diff status (Modified, Added, Removed, Renamed, Unchanged) using Disclosure
  • Diff percentage shown per changed image
  • Resizable sidebar panel via drag handle

Comparison toolbar:

  • Summary counts (changed, added, removed, renamed, unchanged)
  • Toggle diff overlay on/off with configurable color picker

Type additions:

  • SnapshotComparisonRunInfo, ComparisonState, DiffStatus, and SidebarItem discriminated union

Legal 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.

@github-actions github-actions bot added Scope: Frontend Automatically applied to PRs that change frontend components Scope: Backend Automatically applied to PRs that change backend components labels Feb 25, 2026
@github-actions
Copy link
Contributor

🚨 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 #discuss-dev-infra channel.

@NicoHinderling NicoHinderling force-pushed the 02-25-feat_preprod_wip_web_stuff branch from bf0b0d7 to 3a78519 Compare February 25, 2026 23:40
@NicoHinderling NicoHinderling force-pushed the nh/add-endpoint-logic-to-trigger-task branch from d183176 to 23457f7 Compare February 25, 2026 23:40
@NicoHinderling NicoHinderling changed the base branch from nh/add-endpoint-logic-to-trigger-task to graphite-base/109403 February 25, 2026 23:52
@NicoHinderling NicoHinderling force-pushed the 02-25-feat_preprod_wip_web_stuff branch from 3a78519 to ea0acde Compare March 2, 2026 18:23
@NicoHinderling NicoHinderling marked this pull request as ready for review March 2, 2026 18:24
@NicoHinderling NicoHinderling requested a review from a team as a code owner March 2, 2026 18:24
@NicoHinderling NicoHinderling changed the title feat(preprod): wip web stuff feat(preprod): Add snapshot diff comparison UI Mar 2, 2026
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 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.

Create PR

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%">
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@NicoHinderling NicoHinderling force-pushed the 02-25-feat_preprod_wip_web_stuff branch from ea0acde to dc4b148 Compare March 2, 2026 18:47
@NicoHinderling NicoHinderling changed the base branch from graphite-base/109403 to 02-27-feat_preprod_add_comparisonruninfo_data_to_response_new_extra_frontend_models March 2, 2026 18:47
@NicoHinderling NicoHinderling force-pushed the 02-27-feat_preprod_add_comparisonruninfo_data_to_response_new_extra_frontend_models branch from d20cb6e to f82fff2 Compare March 2, 2026 18:55
@NicoHinderling NicoHinderling force-pushed the 02-25-feat_preprod_wip_web_stuff branch from dc4b148 to 89d8b7b Compare March 2, 2026 18:55
return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
}

type Props = {
Copy link
Member

Choose a reason for hiding this comment

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

Nit: match our convention of interface and naming the props per-component, in this case SnapshotDevToolsProps

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed to interface SnapshotDevToolsProps.

— Claude Code

<ConstrainedImage src={headImageUrl} alt={t('Current Branch')} />
{showOverlay && diffMaskUrl && (
<DiffOverlay
style={{
Copy link
Member

Choose a reason for hiding this comment

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

DiffOverlay is already a styled component, why not move these styles into the styled(span) definition below?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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 =
Copy link
Member

@rbro112 rbro112 Mar 3, 2026

Choose a reason for hiding this comment

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

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Replaced ternary chain with a STATUS_LABELS Record lookup map.

— Claude Code

);
})}
{filteredGroups.size === 0 && !hasNextPage && !isFetchingNextPage && (
{isDiffMode && groupedItems
Copy link
Member

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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')`
Copy link
Member

Choose a reason for hiding this comment

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

This shouldn't be a styled component, we should use the Layout primitives, in this case Stack and use the padding props

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Replaced SearchContainer with <Flex padding="xl" paddingBottom="0">.

— Claude Code

overflow: hidden;
`;

const MainPanel = styled('div')`
Copy link
Member

Choose a reason for hiding this comment

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

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/

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Replaced both SidebarPanel and MainPanel with Flex primitives. Kept DragHandle as styled component due to hover/active states.

— Claude Code

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 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

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 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');
}
Copy link
Contributor

Choose a reason for hiding this comment

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

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."

Fix in Cursor Fix in Web

@NicoHinderling NicoHinderling merged commit 38f0963 into master Mar 3, 2026
61 checks passed
@NicoHinderling NicoHinderling deleted the 02-25-feat_preprod_wip_web_stuff branch March 3, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants