Migrate page breadcrumbs to BreadcrumbList
Migrate $0 (a file, a view directory, or the next unmigrated call site when omitted) off sentry/components/breadcrumbs.
The transformation
Legacy passes one flat array in which the last crumb is the current page (the component strips its to automatically). The new API splits that across two TopBar slots.
// Old — one array, leaf included
<Breadcrumbs crumbs={[{label: t('Monitors'), to: basePath}, {label: monitor.name}]} />
// New — parents in one slot, the current page in the other
<Fragment>
<TopBar.Slot name="breadcrumbs">
<BreadcrumbList items={[{type: 'link', label: t('Monitors'), to: basePath}]} />
</TopBar.Slot>
<TopBar.Slot name="title">
<BreadcrumbList.Title item={{type: 'page-title', label: monitor.name}} />
</TopBar.Slot>
</Fragment>
BreadcrumbList.Title renders no heading. The title outlet already wraps its children in <Heading as="h1"> — verify with grep -n 'Heading as="h1"' static/app/views/navigation/topBar.tsx. Never wrap BreadcrumbList.Title in a Heading, and never nest the breadcrumbs slot inside the title slot. Both mistakes are invisible: the outlet and the title item both use variant="inherit", so a nested heading looks identical and only fails an a11y audit.
⚠️ preservePageFilters survives a spread
The prop does not exist on BreadcrumbItemLinkProps. As a direct literal it is caught:
// error TS2353: 'preservePageFilters' does not exist in type 'LinkBreadcrumbItem'
items={[{type: 'link', label: 'Issues', to: '/issues/', preservePageFilters: true}]}
Through a spread it compiles clean — and spreading legacy crumbs is the migration idiom:
// Compiles. Ships a page that silently drops project/environment/date filters on click.
.map(crumb => ({type: 'link' as const, ...crumb}))
Excess-property checking only applies to fresh object literals, so a Crumb carrying preservePageFilters passes straight through to <Link>, which ignores it. No type error, no failing test. Destructure explicitly instead of spreading, then rebuild the query.
Note the stakes. A crumb that loses the flag does not merely fail to carry filters — it clears them. PageFiltersContainer reconciles its store against the URL on navigation, and an absent project reads as an empty selection rather than "unchanged". Replicating the flag is not polish; skipping it changes what the destination shows.
import {extractSelectionParameters} from 'sentry/components/pageFilters/parse';
// Preserve all six — project, environment, statsPeriod, start, end, utc.
// A legacy `to` is often a bare pathname string; restructure it into an
// object, as there is nowhere to hang a query otherwise.
const preserveAll = {
pathname: makeReleasesPathname({organization, path: '/'}),
query: extractSelectionParameters(location.query),
};
// Preserve some — spread, then override. Clearing `start`/`end` is required
// whenever you set `statsPeriod`, or an absolute range and a relative period
// both travel and the destination picks one.
const preserveSome = {
pathname: makeReleasesPathname({organization, path: '/'}),
query: {
...extractSelectionParameters(location.query),
statsPeriod: '24h',
start: undefined,
end: undefined,
},
};
Preserve nothing by leaving the bare pathname alone — that is what a crumb without the flag did, and migrating one is not an occasion to start preserving. When the crumb already has a to object, merge rather than replace: {...to, query: {...extractSelectionParameters(location.query), ...to.query}}.
Find the call sites that still pass it: grep -rln "preservePageFilters: true" static/app --include='*.tsx'.
Which API takes what
| Accepts | Shape | |
|---|---|---|
<BreadcrumbList items={...}/> |
'link' (label: string, to, leadingGraphic?), 'select-projects' |
array |
<BreadcrumbList.Title item={...}/> |
'page-title' (label: string, labelTooltip?, leadingGraphic?, pagination?, trailingActions?), 'editable-title' |
single object |
Title actions are a third union — one object, or an array whose absent entries are null:
trailingActions |
Required fields |
|---|---|
{type: 'copy'} |
text, label |
{type: 'menu'} |
items, triggerLabel |
{type: 'button'} |
element, typed ReactElement<ButtonProps | LinkButtonProps> |
Import is always import {BreadcrumbList} from '@sentry/scraps/breadcrumbList' — an alias onto static/app/components/core/. The barrel exports only BreadcrumbList and the type BreadcrumbTitleItem. type: 'link' requires label: string and a non-null to; leadingGraphic is optional.
Pick the call-site shape
Classify before editing — the shapes need different amounts of work, and three of them touch more than one file.
| Shape | Pattern | Extra work |
|---|---|---|
| A1 | <Breadcrumbs> inside TopBar.Slot name="title" |
Pure split of the slots. A JSX leaf label may still need full decomposition — see references/title-item.md. Note the element is sometimes assigned to a variable first (automations/detail.tsx) or aliased through styled(Breadcrumbs) (preprod/install/buildInstallHeader.tsx), so <Breadcrumbs may not appear inside the slot |
| A2 | A wrapper component rendered inside the caller's title slot |
Two-site edit. The wrapper returns the two-slot Fragment; the caller's wrapping title slot must be deleted, or breadcrumbs nests inside title and parent links render into the <h1> |
| B | <Breadcrumbs> in Layout.HeaderContent beside Layout.Title |
Fixes a live double-render. Layout.Title wins the title; the displaced leaf is usually a category descriptor (e.g. "Cron Monitor") that wants a real to not yet in the file. Exception below |
| C | Exported wrapper consumed by other pages — only when a consumer wraps it in a title slot; an exported header that owns its own slots is B or E |
Every consumer changes in the same PR. Per consumer: hoist any surrounding ternary above the slot, delete the wrapping slot, and place or drop every sibling node in that slot |
| E | <Breadcrumbs> beside a raw <Heading as="h1"> |
Delete the local heading, or the page ships two <h1>s |
If no row fits, do not force one. The table describes the headers present when this skill was written. Classify by what the file has — a Layout.Title, a raw heading, a wrapping title slot, an exported wrapper — and follow the closest row. If a header is structured unlike any of them, ask before restructuring it rather than guessing, and add a row here once the shape is settled.
views/performance/breadcrumb.tsx is type-only (import type {Crumb}, no JSX). Its work is deleting a legacy adapter, and it is blocked — see references/call-site-inventory.md.
Shape B has one exception with no leaf crumb at all, where applying "Layout.Title wins" mechanically renders the same text twice — see references/call-site-inventory.md.
Layout.Title is already a shim for TopBar.Slot name="title" (grep -n 'export function Title' static/app/components/layouts/thirds.tsx), which is why Shape B pages double-render today.
Build items
Parents only — the leaf became the title.
// Drop crumbs with no destination: `to` is required and non-nullable.
const items = parents.flatMap(c =>
c.to ? [{type: 'link' as const, label: c.label, to: c.to}] : []
);
An empty items renders nothing, and that is correct — the title slot still renders. Do not invent a parent link to avoid it. If the legacy code gated the trail on a length check, port the condition.
That does not conflict with Shape B's "give the displaced leaf a to" — they are different crumbs. A leaf that is the page name becomes the title and leaves items. A leaf that is a category descriptor ("Cron Monitor") is a real parent that was merely unlinked: look for a make*Pathname for that category beside the one you already import, and prefer the two-link trail. Drop it only when there is nowhere to point.
Per-page workflow
- Read the file. Find the crumb array; note whether its last element has a
to. - Classify the shape. For A2/C,
grep -rn '<WrapperName'now and list every sibling node in each consumer's title slot — consumers are part of this change, not a follow-up. Readreferences/call-site-inventory.mdonly if the file is a wrapper or appears on its hard-rows list; for a plain A1/B/E file the shape table above is enough. - Decide the title before writing anything. In priority order: (1)
Layout.Title's content, (2) a raw<Heading as="h1">sibling, (3) the last crumb. If the header renders no heading and the last crumb has noto, the last crumb is the title. If the choice is gated on a boolean, hoist that boolean above both slots and give each branch its ownBreadcrumbList.Title— never migrate one branch. Useeditable-titleif the name is user-editable, elsepage-title. - Build the title item. →
references/title-item.md - Build
itemsfrom what remains. Drop the leaf. Drop or re-point crumbs with noto. ReplacepreservePageFilters. Port any length guard. - Render both slots,
breadcrumbsthentitle. Wrap them in aFragmentwhen the component returns them directly. Then check what the old wrapper has left: if aLayout.HeaderorLayout.HeaderContentis now left holding onlyTopBar.Slotchildren, delete it. Slots render nothing in place, andLayout.Headeris a real<Grid as="header">with padding and a bottom border — leaving it ships an empty bordered strip above the page body. Keep it only if it still has non-slot children, such asLayout.HeaderTabs. - Delete only what the move orphaned — see the checklist guard below.
- Fix the specs. →
references/tests.md - Verify:
pnpm run typecheck(whole project, takes no paths),.venv/bin/prek run -q --files <files>,pnpm test-ci <spec>, and re-run the count — it must have decreased and still be at or above 4.
Step 3 precedes step 5 because it determines which crumbs are left. Building items first ships the leaf twice and forces a redo of both slots.
References
| Open when you need to | Read |
|---|---|
| Know what shape a file is, who consumes a wrapper, or what blocks a file | references/call-site-inventory.md |
| Build a title whose name is more than a plain string — a badge, tooltip, editable field, pagination, or actions | references/title-item.md |
| Write or fix a spec for a migrated page | references/tests.md |
Reference implementations
Already migrated, simplest first. Each answers one question.
| File | Answers |
|---|---|
views/detectors/components/details/common/header.tsx |
The minimal two-slot split |
views/explore/conversations/components/conversationsBreadcrumbs.tsx |
Replacing preservePageFilters; a standalone copy action |
views/performance/transactionSummary/transactionBreadcrumbs.tsx |
Mapping a shared crumb builder into typed items; leadingGraphic; a menu with a submenu |
views/performance/newTraceDetails/traceHeader/traceBreadcrumbs.tsx |
pagination; dropping unlinked crumbs with .flatMap |
views/dashboards/dashboardBreadcrumbTitle.tsx |
editable-title; one early return per page state |
views/explore/replays/detail/header/replayDetailsPageBreadcrumbs.tsx |
The richest title — pagination, leadingGraphic, and a nulled action array together |
views/issueDetails/header/issueIdBreadcrumb.tsx |
Building a title item outside JSX with as const satisfies |
Intentionally not migrated
Four importers keep the legacy component — its own spec, plus three call sites that render outside the page <h1> and need a <nav> landmark BreadcrumbList has no mode for. The count floors at 4; driving it to 0 destroys a landmark. The route-driven SettingsBreadcrumb system is out of scope too. Both lists are in references/call-site-inventory.md.
Rollout
The count is the progress state — no scratch file to keep in sync.
grep -rl "from 'sentry/components/breadcrumbs'" static/app | wc -l
Migrate one view area per PR. Split at roughly 50 changed files along ownership boundaries in @.github/CODEOWNERS. Title as ref(<area>): Migrate breadcrumbs to BreadcrumbList, or feat(<area>): ... if page actions moved into the title menu. There is no feature flag — ui-migration-breadcrumbs and useHasNewBreadcrumbs() were deleted, so every page flips for all users on merge. Some older reference PRs still show a flag fork; that pattern is dead code now.
Migration checklist
Each item is a grep or a compile, because every judgment-shaped check here was one an earlier draft got wrong.
- No
preservePageFilters:in the diff, and no spread of legacy crumbs into typed items —grepforpreservePageFilters:(with the colon: a bare grep also matches the comment explaining the replacement, so it flags a correct migration) and for...crumbinside a.mapthat producestype: 'link'. A spread compiles and silently drops page filters. - No wrapper is left holding only
TopBar.Slotchildren — slots render nothing in place, so a surviving padded/borderedLayout.Headerbecomes an empty strip above the page body. - The file has exactly one
TopBar.Slot name="title", zeroas="h1", zeroLayout.Title— a survivor of any of these double-renders the page name or ships two<h1>s. - No
TopBar.Slot name="breadcrumbs"inside aname="title"subtree — nesting renders parent links into the page heading, and looks correct because both usevariant="inherit". - Every
type: 'link'has a realto. An emptyitemsis fine and renders nothing; an invented parent link is not. - No
useHasNewBreadcrumbsorui-migration-breadcrumbs— both are dead repo-wide, so a flag fork can never render. -
labelis a plain string. Stringifying a JSX label deletes the badge, tooltip, or copy affordance instead of moving it. - Every
IdBadge/ProjectBadgeinleadingGraphicpassesdisableLink— the slot isaria-hidden, andIdBadge/ProjectBadgerender a focusable<a href>without it, putting a tabbable link inside a hidden subtree. Typechecks, passes tests, fails an axe audit. -
leadingGraphicavatars passavatarSize={16}with a<Placeholder width="16px" height="16px"/>fallback — the slot is a fixed 16×16, so 28px clips and a missing graphic shifts the title as data arrives. - A title item built outside JSX ends
as const satisfies BreadcrumbTitleItem— otherwisetypewidens tostringand the union stops narrowing. - Array
trailingActionsusenullfor absent entries; a lone action is a bare object, not a one-element array. - Every touched spec mounts
<TopBar />inside<TopBar.Slot.Provider>, or declares abreadcrumbsoutlet — a spec stubbing onlytitle/actions/feedbackrenders no parent crumbs and fails as if the component were broken. - The leaf is asserted absent from the trail, not just present as the heading.
- The count decreased and is still at or above 4.
Overflow collapse is deliberately absent from this list — jsdom never evaluates container queries. See references/tests.md.