mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
0bdea6782d
Adds a table view to compare mode, switchable from the treemap via a toggle in the top bar. The `DiffTable` lists every source file with its A/B sizes, delta, and env badges, and supports text search. Removed sources (which can't appear in the treemap since they don't exist in the current build) are only visible here. The view toggle defaults to Table when entering compare mode and resets to Treemap when leaving it. <!-- 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>
466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useSidebarResize } from '@/lib/use-sidebar-resize'
|
|
import { CompareSidebar } from '@/components/sidebar'
|
|
import { DiffTable } from '@/components/diff-table'
|
|
import { DiffTreemap } from '@/components/diff-treemap'
|
|
import { TreemapSkeleton } from '@/components/ui/skeleton'
|
|
import { StatCard, CountCard } from '@/components/stat-cards'
|
|
import { CompareView, Environment } from '@/components/top-bar'
|
|
import { AnalyzeData, ModulesData } from '@/lib/analyze-data'
|
|
import {
|
|
diffRoutesWithSizes,
|
|
formatDelta,
|
|
type DiffSummary,
|
|
type SourceDiffRow,
|
|
} from '@/lib/diff'
|
|
import { formatSnapshotLabel, type SnapshotMetadata } from '@/lib/snapshot'
|
|
import { cn, formatBytes } from '@/lib/utils'
|
|
|
|
export interface CompareLayoutProps {
|
|
baselineSnapshot: SnapshotMetadata
|
|
comparisonSnapshot: SnapshotMetadata | null
|
|
compareView: CompareView
|
|
selectedRoute: string | null
|
|
comparisonRouteCount: number | null
|
|
routeDiff: ReturnType<typeof diffRoutesWithSizes> | null
|
|
sourceDiff: DiffSummary<SourceDiffRow> | null
|
|
analyzeData: AnalyzeData | null
|
|
baselineAnalyzeData: AnalyzeData | null
|
|
isAnalyzeLoading: boolean
|
|
isBaselineAnalyzeLoading: boolean
|
|
baselineAnalyzeError: unknown | null
|
|
compressed: boolean
|
|
searchQuery: string
|
|
compareSelectedKey: string | null
|
|
setCompareSelectedKey: (key: string | null) => void
|
|
modulesData: ModulesData | null
|
|
baselineModulesData: ModulesData | null
|
|
moduleDepthMap: Map<number, number>
|
|
baselineModuleDepthMap: Map<number, number>
|
|
environmentFilter: Environment
|
|
}
|
|
|
|
/**
|
|
* Renders the comparison view (treemap + table) with a context strip at the
|
|
* top showing the two builds being compared. The "before" build (A) is
|
|
* rendered on the left and the selected "after" build (B) on the right, matching the user's mental model.
|
|
*
|
|
* Route selection lives in the top-bar route picker (`RouteTypeahead`),
|
|
* which also renders per-route deltas in compare mode.
|
|
*/
|
|
export function CompareLayout({
|
|
baselineSnapshot,
|
|
comparisonSnapshot,
|
|
compareView,
|
|
selectedRoute,
|
|
comparisonRouteCount,
|
|
routeDiff,
|
|
sourceDiff,
|
|
analyzeData,
|
|
baselineAnalyzeData,
|
|
isAnalyzeLoading,
|
|
isBaselineAnalyzeLoading,
|
|
baselineAnalyzeError,
|
|
compressed,
|
|
searchQuery,
|
|
compareSelectedKey,
|
|
setCompareSelectedKey,
|
|
modulesData,
|
|
baselineModulesData,
|
|
moduleDepthMap,
|
|
baselineModuleDepthMap,
|
|
environmentFilter,
|
|
}: CompareLayoutProps) {
|
|
const { sidebarWidth, startResizing } = useSidebarResize()
|
|
|
|
return (
|
|
<div className="flex h-full w-full min-w-0 flex-col">
|
|
{/*
|
|
Stats header. On wide viewports the Route stats sit next to the App
|
|
stats so the user can compare route-level and app-level deltas
|
|
without scrolling. Route is on the left because it's the primary
|
|
scope the user's focused on; App is reference context.
|
|
On narrow viewports the two strips stack vertically (route above
|
|
app) and fall back to the original layout.
|
|
*/}
|
|
<div className="flex flex-none flex-col border-b border-border xl:flex-row xl:items-stretch">
|
|
<RouteStatsCard
|
|
selectedRoute={selectedRoute}
|
|
sourceDiff={sourceDiff}
|
|
isAnalyzeLoading={isAnalyzeLoading}
|
|
isBaselineAnalyzeLoading={isBaselineAnalyzeLoading}
|
|
baselineAnalyzeError={baselineAnalyzeError}
|
|
compressed={compressed}
|
|
className="xl:min-w-0 xl:flex-1 xl:basis-0 xl:border-r xl:border-border"
|
|
/>
|
|
<CompareContextStrip
|
|
baselineSnapshot={baselineSnapshot}
|
|
comparisonRouteCount={comparisonRouteCount}
|
|
routeDiff={routeDiff}
|
|
compressed={compressed}
|
|
className="xl:min-w-0 xl:flex-1 xl:basis-0"
|
|
/>
|
|
</div>
|
|
|
|
{/*
|
|
Per-route source diff + sidebar. The sidebar slides in when a row or
|
|
treemap tile is selected and shows the import chain for that source.
|
|
*/}
|
|
<div className="flex flex-1 min-h-0">
|
|
<div className="flex flex-1 min-w-0 flex-col">
|
|
<ComparePerRoutePanel
|
|
compareView={compareView}
|
|
selectedRoute={selectedRoute}
|
|
sourceDiff={sourceDiff}
|
|
analyzeData={analyzeData}
|
|
baselineAnalyzeData={baselineAnalyzeData}
|
|
isAnalyzeLoading={isAnalyzeLoading}
|
|
isBaselineAnalyzeLoading={isBaselineAnalyzeLoading}
|
|
compressed={compressed}
|
|
searchQuery={searchQuery}
|
|
baselineSnapshot={baselineSnapshot}
|
|
comparisonSnapshot={comparisonSnapshot}
|
|
compareSelectedKey={compareSelectedKey}
|
|
onCompareSelectedKeyChange={setCompareSelectedKey}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="flex-none w-1 bg-border hover:bg-primary cursor-col-resize transition-colors"
|
|
onMouseDown={startResizing}
|
|
aria-label="Resize sidebar"
|
|
/>
|
|
<CompareSidebar
|
|
selectedKey={compareSelectedKey}
|
|
sourceDiff={sourceDiff}
|
|
analyzeData={analyzeData}
|
|
baselineAnalyzeData={baselineAnalyzeData}
|
|
modulesData={modulesData}
|
|
baselineModulesData={baselineModulesData}
|
|
moduleDepthMap={moduleDepthMap}
|
|
baselineModuleDepthMap={baselineModuleDepthMap}
|
|
environmentFilter={environmentFilter}
|
|
sidebarWidth={sidebarWidth}
|
|
aLabel={formatSnapshotLabel(baselineSnapshot)}
|
|
bLabel={
|
|
comparisonSnapshot
|
|
? formatSnapshotLabel(comparisonSnapshot)
|
|
: 'Latest'
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Strip across the top of the compare view that labels the two builds and
|
|
* shows headline stats (total routes, added, removed, changed, identical,
|
|
* total size). Modeled after the vite bundle-stats compare summary cards:
|
|
* each card uses an `A → B` arrow with a delta chip, so size/count changes
|
|
* are scannable at a glance.
|
|
*
|
|
* A/B label pills below identify which snapshot is which.
|
|
*/
|
|
function CompareContextStrip({
|
|
baselineSnapshot,
|
|
comparisonRouteCount,
|
|
routeDiff,
|
|
compressed,
|
|
className,
|
|
}: {
|
|
baselineSnapshot: SnapshotMetadata
|
|
comparisonRouteCount: number | null
|
|
routeDiff: ReturnType<typeof diffRoutesWithSizes> | null
|
|
compressed: boolean
|
|
className?: string
|
|
}) {
|
|
const baselineRoutes = baselineSnapshot.routeCount
|
|
const comparisonRoutes =
|
|
comparisonRouteCount ??
|
|
routeDiff?.rows.filter((r) => r.status !== 'removed').length ??
|
|
null
|
|
|
|
const totalA = routeDiff
|
|
? compressed
|
|
? routeDiff.totalCompressedA
|
|
: routeDiff.totalA
|
|
: null
|
|
const totalB = routeDiff
|
|
? compressed
|
|
? routeDiff.totalCompressedB
|
|
: routeDiff.totalB
|
|
: null
|
|
const sizeDelta = totalA != null && totalB != null ? totalB - totalA : null
|
|
|
|
const counts = routeDiff?.counts
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex flex-none flex-col gap-2 bg-muted/30 px-4 py-3',
|
|
className
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
App
|
|
</span>
|
|
<span aria-hidden className="h-px flex-1 bg-border" />
|
|
</div>
|
|
<div className="flex flex-wrap items-stretch gap-2">
|
|
<StatCard
|
|
label="Total routes"
|
|
a={baselineRoutes}
|
|
b={comparisonRoutes}
|
|
deltaTone="neutral"
|
|
/>
|
|
<StatCard
|
|
label="Total size"
|
|
a={totalA}
|
|
b={totalB}
|
|
formatValue={formatBytes}
|
|
deltaValue={sizeDelta}
|
|
formatDeltaValue={(d) => formatDelta(d)}
|
|
deltaTone="size"
|
|
/>
|
|
{counts ? (
|
|
<>
|
|
<CountCard label="Routes added" value={counts.added} tone="added" />
|
|
<CountCard
|
|
label="Routes removed"
|
|
value={counts.removed}
|
|
tone="removed"
|
|
/>
|
|
<CountCard
|
|
label="Routes changed"
|
|
value={counts.changed}
|
|
tone="changed"
|
|
/>
|
|
<CountCard
|
|
label="Routes identical"
|
|
value={counts.identical}
|
|
tone="identical"
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Route-scope stats card shown alongside the app-wide stats in the compare
|
|
* view header. Handles the various "no data" states (no route picked yet,
|
|
* loading, missing data) inline so the header always occupies a consistent
|
|
* slot in the layout.
|
|
*/
|
|
function RouteStatsCard({
|
|
selectedRoute,
|
|
sourceDiff,
|
|
isAnalyzeLoading,
|
|
isBaselineAnalyzeLoading,
|
|
baselineAnalyzeError,
|
|
compressed,
|
|
className,
|
|
}: {
|
|
selectedRoute: string | null
|
|
sourceDiff: DiffSummary<SourceDiffRow> | null
|
|
isAnalyzeLoading: boolean
|
|
isBaselineAnalyzeLoading: boolean
|
|
baselineAnalyzeError: unknown | null
|
|
compressed: boolean
|
|
className?: string
|
|
}) {
|
|
const isLoading = isAnalyzeLoading || isBaselineAnalyzeLoading
|
|
|
|
const body = !selectedRoute ? (
|
|
<div className="pt-1 text-xs text-muted-foreground">
|
|
Select a route above to see per-source changes.
|
|
</div>
|
|
) : isLoading ? (
|
|
<div className="pt-1 text-xs text-muted-foreground">Loading…</div>
|
|
) : !sourceDiff ? (
|
|
<div className="pt-1 text-xs text-muted-foreground">
|
|
No data for this route.
|
|
</div>
|
|
) : (
|
|
<RouteStatsBody
|
|
selectedRoute={selectedRoute}
|
|
sourceDiff={sourceDiff}
|
|
baselineAnalyzeError={baselineAnalyzeError}
|
|
compressed={compressed}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<div className={cn('flex flex-none flex-col gap-2 px-4 py-3', className)}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Route
|
|
</span>
|
|
<span aria-hidden className="h-px flex-1 bg-border" />
|
|
</div>
|
|
{body}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Inner body for {@link RouteStatsCard} when source diff data is available.
|
|
* Extracted so the stats card can render a placeholder cleanly when no data
|
|
* is ready yet without nesting the data-dependent hooks/derivations above.
|
|
*/
|
|
function RouteStatsBody({
|
|
selectedRoute: _selectedRoute,
|
|
sourceDiff,
|
|
baselineAnalyzeError: _baselineAnalyzeError,
|
|
compressed,
|
|
}: {
|
|
selectedRoute: string
|
|
sourceDiff: DiffSummary<SourceDiffRow>
|
|
baselineAnalyzeError: unknown | null
|
|
compressed: boolean
|
|
}) {
|
|
const routeSizeA = compressed
|
|
? sourceDiff.totalCompressedA
|
|
: sourceDiff.totalA
|
|
const routeSizeB = compressed
|
|
? sourceDiff.totalCompressedB
|
|
: sourceDiff.totalB
|
|
const routeSizeDelta = routeSizeB - routeSizeA
|
|
const sourceCounts = sourceDiff.counts
|
|
|
|
return (
|
|
<>
|
|
{/*
|
|
Per-route stat strip. Mirrors the layout of the app-wide strip
|
|
but at route scope, so the user reads two parallel summaries:
|
|
one for the whole build, one drilled into the selected route.
|
|
The source-level counters use a different label prefix
|
|
("Sources added" vs. the app-wide "Routes added") to make the
|
|
unit unambiguous.
|
|
*/}
|
|
<div className="flex flex-wrap items-stretch gap-2 pt-1">
|
|
<StatCard
|
|
label="Total size"
|
|
a={routeSizeA}
|
|
b={routeSizeB}
|
|
formatValue={formatBytes}
|
|
deltaValue={routeSizeDelta}
|
|
formatDeltaValue={(d) => formatDelta(d)}
|
|
deltaTone="size"
|
|
/>
|
|
<CountCard
|
|
label="Sources added"
|
|
value={sourceCounts.added}
|
|
tone="added"
|
|
/>
|
|
<CountCard
|
|
label="Sources removed"
|
|
value={sourceCounts.removed}
|
|
tone="removed"
|
|
/>
|
|
<CountCard
|
|
label="Sources changed"
|
|
value={sourceCounts.changed}
|
|
tone="changed"
|
|
/>
|
|
<CountCard
|
|
label="Sources identical"
|
|
value={sourceCounts.identical}
|
|
tone="identical"
|
|
/>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Per-route comparison panel. Shows either the diff treemap or the diff table,
|
|
* controlled by `compareView`. Handles missing-route states (the route is new
|
|
* in comparison build B, or was removed) with friendly messaging.
|
|
*/
|
|
function ComparePerRoutePanel({
|
|
compareView,
|
|
selectedRoute,
|
|
sourceDiff,
|
|
analyzeData,
|
|
baselineAnalyzeData,
|
|
isAnalyzeLoading,
|
|
isBaselineAnalyzeLoading,
|
|
compressed,
|
|
searchQuery,
|
|
baselineSnapshot,
|
|
comparisonSnapshot,
|
|
compareSelectedKey,
|
|
onCompareSelectedKeyChange,
|
|
}: {
|
|
compareView: CompareView
|
|
selectedRoute: string | null
|
|
sourceDiff: DiffSummary<SourceDiffRow> | null
|
|
analyzeData: AnalyzeData | null
|
|
baselineAnalyzeData: AnalyzeData | null
|
|
isAnalyzeLoading: boolean
|
|
isBaselineAnalyzeLoading: boolean
|
|
compressed: boolean
|
|
searchQuery: string
|
|
baselineSnapshot: SnapshotMetadata
|
|
comparisonSnapshot: SnapshotMetadata | null
|
|
compareSelectedKey: string | null
|
|
onCompareSelectedKeyChange: (key: string | null) => void
|
|
}) {
|
|
if (!selectedRoute) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
|
Select a route above to see per-source changes.
|
|
</div>
|
|
)
|
|
}
|
|
if (isAnalyzeLoading || isBaselineAnalyzeLoading) {
|
|
return (
|
|
<div className="p-4">
|
|
<TreemapSkeleton />
|
|
</div>
|
|
)
|
|
}
|
|
if (!sourceDiff) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
|
No data for this route.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 min-h-0 flex-col">
|
|
{compareView === CompareView.Treemap ? (
|
|
<DiffTreemap
|
|
summary={sourceDiff}
|
|
useCompressed={compressed}
|
|
analyzeData={analyzeData}
|
|
baselineAnalyzeData={baselineAnalyzeData}
|
|
selectedKey={compareSelectedKey}
|
|
onSelectKey={onCompareSelectedKeyChange}
|
|
/>
|
|
) : (
|
|
<DiffTable
|
|
summary={sourceDiff}
|
|
useCompressed={compressed}
|
|
nameHeading="Source"
|
|
aHeading={formatSnapshotLabel(baselineSnapshot)}
|
|
bHeading={
|
|
comparisonSnapshot
|
|
? formatSnapshotLabel(comparisonSnapshot)
|
|
: 'Latest'
|
|
}
|
|
searchQuery={searchQuery}
|
|
selectedKey={compareSelectedKey}
|
|
onRowSelect={(row) => onCompareSelectedKeyChange(row.key)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|