Skip to content

Commit 2f3f74b

Browse files
bundle-analyzer: add compare view with treemap diff (#93521)
Adds a compare mode to the bundle analyzer. When the user picks a historical snapshot from the new baseline picker in the top bar, the UI switches into compare mode: routes are diff'd by name and size, and the per-route view shows a `DiffTreemap` that colors each source tile by its change status (added/removed/grew/shrank/unchanged). The compare sidebar shows the import chain for the selected tile on either the baseline ("A") or current ("B") side. Route totals are fetched in parallel so the route picker can sort by largest impact and classify routes as `changed`/`identical` rather than just name-presence. <!-- NEXT_JS_LLM_PR --> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Will Binns-Smith <755844+wbinnssmith@users.noreply.github.com>
1 parent da6e8b6 commit 2f3f74b

13 files changed

Lines changed: 2631 additions & 418 deletions

‎apps/bundle-analyzer/app/page.tsx‎

Lines changed: 323 additions & 233 deletions
Large diffs are not rendered by default.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
'use client'
2+
3+
import useSWR from 'swr'
4+
import {
5+
Check,
6+
ChevronsUpDown,
7+
GitCompareArrows,
8+
Loader,
9+
X,
10+
} from 'lucide-react'
11+
import { useState } from 'react'
12+
import { Button } from '@/components/ui/button'
13+
import {
14+
Command,
15+
CommandEmpty,
16+
CommandGroup,
17+
CommandInput,
18+
CommandItem,
19+
CommandList,
20+
} from '@/components/ui/command'
21+
import {
22+
Popover,
23+
PopoverContent,
24+
PopoverTrigger,
25+
} from '@/components/ui/popover'
26+
import { cn, jsonFetcher } from '@/lib/utils'
27+
import { NetworkError } from '@/lib/errors'
28+
import {
29+
formatRelativeTime,
30+
formatSnapshotLabel,
31+
type HistoryIndex,
32+
type SnapshotMetadata,
33+
} from '@/lib/snapshot'
34+
35+
interface BaselinePickerProps {
36+
/** Selected historical snapshot id, or null for the control's default. */
37+
selectedSnapshotId: string | null
38+
onSelectionChange: (snapshot: SnapshotMetadata | null) => void
39+
excludedSnapshotId?: string | null
40+
prefix?: string
41+
placeholder?: string
42+
clearLabel?: string
43+
}
44+
45+
/**
46+
* Top-bar control that lets the user pick one historical analyze snapshot.
47+
* Two instances can select arbitrary A and B snapshots for comparison.
48+
*
49+
* Snapshots are loaded from `history/history.json` — written by
50+
* `writeAnalyzeSnapshot` after each `next build --experimental-analyze`.
51+
*/
52+
export function BaselinePicker({
53+
selectedSnapshotId,
54+
onSelectionChange,
55+
excludedSnapshotId,
56+
prefix = 'vs',
57+
placeholder = 'Compare with…',
58+
clearLabel = 'Stop comparing',
59+
}: BaselinePickerProps) {
60+
const [open, setOpen] = useState(false)
61+
62+
const {
63+
data: history,
64+
isLoading,
65+
error,
66+
} = useSWR<HistoryIndex>('history/history.json', jsonFetcher, {
67+
// History rarely changes during a session — avoid spamming refetches.
68+
revalidateOnFocus: false,
69+
revalidateOnReconnect: false,
70+
// Treat 404 as "no history yet" rather than an error so users on fresh
71+
// installs see a graceful disabled state instead of a red banner.
72+
shouldRetryOnError: false,
73+
})
74+
75+
// Metadata for the *current* build, so we can exclude its corresponding
76+
// entry from the history picker (you can't compare a build with itself).
77+
const { data: currentMetadata } = useSWR<SnapshotMetadata>(
78+
'data/metadata.json',
79+
jsonFetcher,
80+
{
81+
revalidateOnFocus: false,
82+
revalidateOnReconnect: false,
83+
shouldRetryOnError: false,
84+
}
85+
)
86+
87+
const allSnapshots = history?.snapshots ?? []
88+
// Filter out the current build's snapshot — comparing to self is a no-op.
89+
const snapshots = allSnapshots.filter(
90+
(snapshot) =>
91+
snapshot.id !== currentMetadata?.id && snapshot.id !== excludedSnapshotId
92+
)
93+
const selected =
94+
selectedSnapshotId != null
95+
? snapshots.find((s) => s.id === selectedSnapshotId)
96+
: null
97+
98+
// Surface only fatal/network problems; missing history files are not errors.
99+
const hasError = error instanceof NetworkError
100+
101+
const isEmpty = !isLoading && snapshots.length === 0
102+
// When the only snapshot on disk is the current build itself, surface a
103+
// distinct label/tooltip telling the user to run another build.
104+
const isOnlyCurrentBuild =
105+
!isLoading && allSnapshots.length > 0 && snapshots.length === 0
106+
107+
let triggerText: React.ReactNode
108+
if (isLoading) {
109+
triggerText = 'Loading history…'
110+
} else if (selected) {
111+
triggerText = (
112+
<span className="flex items-center gap-1.5 truncate">
113+
<span className="text-muted-foreground text-xs">{prefix}</span>
114+
<span className="font-mono truncate">
115+
{formatSnapshotLabel(selected)}
116+
</span>
117+
</span>
118+
)
119+
} else {
120+
triggerText = placeholder
121+
}
122+
123+
return (
124+
<div className="flex items-center gap-1">
125+
<Popover open={open} onOpenChange={setOpen}>
126+
<PopoverTrigger asChild>
127+
<Button
128+
variant="outline"
129+
role="combobox"
130+
aria-expanded={open}
131+
disabled={isLoading || hasError}
132+
className="min-w-44 max-w-72 justify-between text-sm"
133+
title={hasError ? 'Unable to load build history' : undefined}
134+
>
135+
<div className="flex items-center min-w-0">
136+
{isLoading ? (
137+
<Loader className="mr-2 h-3.5 w-3.5 inline animate-spin" />
138+
) : (
139+
<GitCompareArrows className="mr-2 h-3.5 w-3.5 inline" />
140+
)}
141+
<span className="truncate">{triggerText}</span>
142+
</div>
143+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
144+
</Button>
145+
</PopoverTrigger>
146+
<PopoverContent className="w-96 p-0">
147+
<Command>
148+
{snapshots.length > 0 ? (
149+
<CommandInput placeholder="Search snapshots…" className="h-9" />
150+
) : null}
151+
<CommandList>
152+
{isOnlyCurrentBuild ? (
153+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
154+
<div className="font-medium text-foreground">
155+
No prior builds yet
156+
</div>
157+
<div className="mt-1">
158+
Run{' '}
159+
<code className="rounded bg-muted px-1 py-0.5 text-xs">
160+
next experimental-analyze
161+
</code>{' '}
162+
again to capture a baseline you can compare against.
163+
</div>
164+
</div>
165+
) : isEmpty ? (
166+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
167+
<div className="font-medium text-foreground">
168+
No build history
169+
</div>
170+
<div className="mt-1">
171+
Run{' '}
172+
<code className="rounded bg-muted px-1 py-0.5 text-xs">
173+
next build --experimental-analyze
174+
</code>{' '}
175+
to start collecting snapshots.
176+
</div>
177+
</div>
178+
) : (
179+
<CommandEmpty>No snapshots found.</CommandEmpty>
180+
)}
181+
<CommandGroup>
182+
{snapshots.map((snapshot) => (
183+
<CommandItem
184+
key={snapshot.id}
185+
value={`${snapshot.id} ${snapshot.gitBranch ?? ''} ${snapshot.gitShortSha ?? ''} ${snapshot.gitMessage ?? ''} ${snapshot.baselineName ?? ''}`}
186+
onSelect={() => {
187+
onSelectionChange(snapshot)
188+
setOpen(false)
189+
}}
190+
>
191+
<Check
192+
className={cn(
193+
'mr-2 h-4 w-4',
194+
selectedSnapshotId === snapshot.id
195+
? 'opacity-100'
196+
: 'opacity-0'
197+
)}
198+
/>
199+
<SnapshotRow snapshot={snapshot} />
200+
</CommandItem>
201+
))}
202+
</CommandGroup>
203+
</CommandList>
204+
</Command>
205+
</PopoverContent>
206+
</Popover>
207+
208+
{selected != null && (
209+
<Button
210+
variant="ghost"
211+
size="icon"
212+
aria-label={clearLabel}
213+
onClick={() => onSelectionChange(null)}
214+
className="h-8 w-8"
215+
>
216+
<X className="h-3.5 w-3.5" />
217+
</Button>
218+
)}
219+
</div>
220+
)
221+
}
222+
223+
/** A single row in the snapshot picker list. */
224+
function SnapshotRow({ snapshot }: { snapshot: SnapshotMetadata }) {
225+
return (
226+
<div className="flex flex-col min-w-0 flex-1">
227+
<div className="flex items-center gap-2 min-w-0">
228+
<span className="font-mono text-sm truncate">
229+
{formatSnapshotLabel(snapshot)}
230+
</span>
231+
{snapshot.gitDirty ? (
232+
<span
233+
title="Working tree was dirty when this snapshot was taken"
234+
className="text-xs text-amber-600 dark:text-amber-400"
235+
>
236+
dirty
237+
</span>
238+
) : null}
239+
</div>
240+
{snapshot.gitMessage ? (
241+
<div className="text-xs text-foreground/70 truncate">
242+
{snapshot.gitMessage}
243+
</div>
244+
) : null}
245+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
246+
<span>{formatRelativeTime(snapshot.createdAt)}</span>
247+
<span aria-hidden>·</span>
248+
<span>
249+
{snapshot.routeCount} route
250+
{snapshot.routeCount === 1 ? '' : 's'}
251+
</span>
252+
{snapshot.nextVersion ? (
253+
<>
254+
<span aria-hidden>·</span>
255+
<span>v{snapshot.nextVersion}</span>
256+
</>
257+
) : null}
258+
</div>
259+
</div>
260+
)
261+
}

0 commit comments

Comments
 (0)