Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/displayname-consistency.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@primer/react': patch
---

Normalise component `displayName` strings to the canonical `Parent.Slot` convention, and add `displayName` to compound sub-components and `forwardRef`-wrapped components that were missing it.

- Renames sub-component `displayName` strings that were using camelCase without a separator to the canonical `Parent.Slot` convention: `BannerPrimaryAction` → `Banner.PrimaryAction`, `BannerSecondaryAction` → `Banner.SecondaryAction`, `Summary` → `Details.Summary`, `TimelineItem|Body|Break` → `Timeline.Item|Body|Break`, `ParentLink|TitleArea` → `PageHeader.ParentLink|TitleArea`, `HorizontalDivider|VerticalDivider` → `PageLayout.HorizontalDivider|VerticalDivider`.
- Adds `displayName` to compound sub-components where the runtime function name doesn't match the canonical name users see (e.g. `Visual` → `Blankslate.Visual`, `SegmentedControlButton` → `SegmentedControl.Button`, `Panel` → `SelectPanel`, `FormControlCaption` → `FormControl.Caption`, etc.) and to `forwardRef`-wrapped components with anonymous inner arrow functions where DevTools would otherwise show `ForwardRef`.
- Adds a contributor skill at `.github/skills/display-name/SKILL.md` documenting when `displayName` is required vs redundant, the canonical naming convention, and how it interacts with the slot system.

No runtime behaviour changes.
4 changes: 4 additions & 0 deletions .github/copilot-instructions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -162,6 +162,10 @@ When working on UI components, always use the `primer-storybook` MCP tools to ac

When building, modifying, or wrapping compound components that use child-component "slots" (anything involving `__SLOT__`, `useSlots`, `isSlot`, or `asSlot`), reference the `.github/skills/slots/SKILL.md` file. It documents when to add slot markers, naming conventions for `Symbol(...)` descriptions, the public slot APIs, and common pitfalls.

## `displayName`

When authoring or reviewing a component and deciding whether to set `displayName`, reference the `.github/skills/display-name/SKILL.md` file. It covers when `displayName` is needed (compound sub-components, anonymous `forwardRef` wrappers, slot sub-components) vs when modern React's `Function.name` inference makes it redundant, plus the canonical `Parent.Slot` naming convention.

## Known Issues and Workarounds

**Timing Expectations:**
Expand Down
130 changes: 130 additions & 0 deletions .github/skills/display-name/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
---
name: display-name
description: "Use when: authoring or reviewing a component in Primer React, deciding whether to set `displayName`, and choosing the right string for it. Covers the criteria (when it's needed vs redundant), the canonical naming convention, and interaction with the slot system."
---

# `displayName` in Primer React

`Component.displayName` controls how the component appears in React DevTools, error stacks, and one of the slot system's dev-mode warnings. This skill documents when to set it, when not to, and how to name it.

## TL;DR

- **Set `displayName` whenever the runtime function name doesn't match the canonical name you want users to see** — i.e. compound sub-components (`Banner.PrimaryAction`) or `forwardRef` wrappers around anonymous arrow functions.
- **Skip `displayName` when the function/variable name already matches the canonical name.** Modern React + bundlers infer it from `Function.name` and assigning to a `const`. Adding `X.displayName = 'X'` is noise.
- **Always use `Parent.Slot` dot notation** for sub-components, never camelCase concatenation (`TimelineItem` ❌, `Timeline.Item` ✅).

## When to set `displayName`

Set it in these cases:

### 1. Compound sub-components where the function name doesn't match the canonical name

```tsx
// The function/variable name is the bare slot name, but DevTools should
// show the dot-notation compound name.
function Visual(...) { ... }
Visual.displayName = 'Blankslate.Visual'

const SegmentedControlButton = forwardRef(...)
SegmentedControlButton.displayName = 'SegmentedControl.Button'

const Panel = (...) => ...
Panel.displayName = 'SelectPanel' // exported as `SelectPanel`, not `Panel`
```

### 2. `forwardRef` (or `memo`) wrapping an anonymous arrow function

```tsx
// ❌ Without displayName, DevTools shows "ForwardRef"
// (variable-name inference works in React 18+ but is unreliable under minification).
const Octicon = React.forwardRef((props, ref) => ...)
Octicon.displayName = 'Octicon'
```

### 3. Sub-components in the slot system

When a child does not match a configured slot by its `__SLOT__` marker, the slot system compares the child's `displayName` with the slot component's `displayName` in development. A matching name produces a warning that recommends `asSlot(wrapper, ParentSlotComponent)` to copy the marker. Setting an explicit `displayName` on each slot sub-component keeps this warning useful.

## When you can skip `displayName`
Comment on lines +42 to +48

Skip it in these cases — adding it is pure noise:

### 1. Named function components whose name matches the canonical name

```tsx
// ✅ `Function.name === 'Pagehead'` already; DevTools picks it up.
export function Pagehead({...}) { ... }
// No displayName needed.

// Same for arrow functions assigned to a const:
export const VisuallyHidden = ({...}) => { ... }
// `VisuallyHidden.name === 'VisuallyHidden'` via variable-name inference.
```

### 2. `forwardRef` with a named inner function whose name matches

```tsx
// ✅ React 18+ uses the inner function name.
const Label = React.forwardRef(function Label(props, ref) { ... })
// No displayName needed.
```

### 3. `forwardRef` wrapping a `const` whose name matches (in React 18+)

```tsx
// In React 18 + modern bundlers, DevTools infers "Select" from the const assignment.
const Select = React.forwardRef<HTMLSelectElement, SelectProps>((props, ref) => ...)
// Skip displayName for the root, BUT set it on Select.Option / Select.OptGroup if
// those wrap something whose name differs.
```

> Caveat: under aggressive minification (terser `keep_fnames: false`), `Function.name` becomes a short hash. If you ship a minified bundle and want DevTools to remain accurate in production builds, you can set `displayName` defensively. Primer's published build does not currently minify function names, so we treat `displayName` as optional in the cases above.

## Naming convention

| Component shape | Convention | Example |
| --------------------------- | ----------------------- | ------------------------------------------ |
| Top-level (root) component | `'ComponentName'` | `'Dialog'`, `'ActionList'` |
| Sub-component of a compound | `'Parent.Slot'` | `'Dialog.Header'`, `'PageLayout.Pane'` |
| Nested sub-component | `'Parent.Slot.SubSlot'` | `'ActionList.GroupHeading.TrailingAction'` |

### Don't

- `'TimelineItem'` — use `'Timeline.Item'`.
- `'BannerPrimaryAction'` — use `'Banner.PrimaryAction'`.
- `'ParentLink'` (PageHeader) — use `'PageHeader.ParentLink'`.
- `'DEPRECATED_Tooltip'` or lifecycle annotations — handle deprecation via JSDoc/changesets, not the symbol/displayName.
- `'My-Component'` (kebab-case) or `'my.component'` (lowercase) — always PascalCase tokens separated by dots.

### Match the `Symbol(...)` description

If the component has a `__SLOT__` marker, its `Symbol(...)` description should match the `displayName` so DevTools and crash logs use the same canonical name:

```tsx
Header.displayName = 'Dialog.Header'
Header.__SLOT__ = Symbol('Dialog.Header')
```

See the `slots` skill for more on the slot system.

Slot matching uses symbol identity, not the symbol description. The separate dev-mode wrapper warning compares component `displayName` values.

## Checklist for a new compound component

1. Top-level component: usually skip `displayName` — let the function name carry through.
2. Each sub-component (e.g. `Foo.Bar`, `Foo.Baz`):
- Set `Sub.displayName = 'Foo.Bar'` after the declaration.
- If it's a slot sub-component, also set `Sub.__SLOT__ = Symbol('Foo.Bar')` with the matching description.
3. If you use `forwardRef`/`memo` with an anonymous arrow inner function, either:
- Promote to a named inner: `forwardRef(function Foo(props, ref) { ... })`, or
- Set `Foo.displayName = 'Foo'` explicitly.

## Quick decision tree

```
Is the function/variable name already the same as the canonical name?
├── Yes → Is it forwardRef(arrow)?
│ ├── Yes (anonymous inner) → Set displayName
│ └── No (named or const) → Skip; DevTools infers
└── No → Always set displayName (and match any __SLOT__ Symbol)
```
4 changes: 4 additions & 0 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,3 +504,7 @@ export const VerticalDivider = () => {
/>
)
}
ActionBarIconButton.displayName = 'ActionBar.IconButton'
ActionBarGroup.displayName = 'ActionBar.Group'
ActionBarMenu.displayName = 'ActionBar.Menu'
VerticalDivider.displayName = 'ActionBar.VerticalDivider'
1 change: 1 addition & 0 deletions packages/react/src/ActionList/Description.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,4 +84,5 @@ export const Description: FCWithSlotMarker<React.PropsWithChildren<ActionListDes
}
}

Description.displayName = 'ActionList.Description'
Description.__SLOT__ = Symbol('ActionList.Description')
1 change: 1 addition & 0 deletions packages/react/src/ActionList/Divider.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,4 +22,5 @@ export const Divider: FCWithSlotMarker<React.PropsWithChildren<ActionListDivider
)
}

Divider.displayName = 'ActionList.Divider'
Divider.__SLOT__ = Symbol('ActionList.Divider')
1 change: 1 addition & 0 deletions packages/react/src/ActionList/TrailingAction.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,4 +76,5 @@ export const TrailingAction = forwardRef(
},
) as PolymorphicForwardRefComponent<'button' | 'a', ActionListTrailingActionProps>

TrailingAction.displayName = 'ActionList.TrailingAction'
TrailingAction.__SLOT__ = Symbol('ActionList.TrailingAction')
4 changes: 2 additions & 2 deletions packages/react/src/Banner/Banner.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -273,7 +273,7 @@ const BannerPrimaryAction = forwardRef(({children, className, ...rest}, forwarde
)
}) as PolymorphicForwardRefComponent<'button', BannerPrimaryActionProps>

BannerPrimaryAction.displayName = 'BannerPrimaryAction'
BannerPrimaryAction.displayName = 'Banner.PrimaryAction'

export type BannerSecondaryActionProps = Omit<ButtonProps, 'variant'>

Expand All@@ -291,6 +291,6 @@ const BannerSecondaryAction = forwardRef(({children, className, ...rest}, forwar
)
}) as PolymorphicForwardRefComponent<'button', BannerSecondaryActionProps>

BannerSecondaryAction.displayName = 'BannerSecondaryAction'
BannerSecondaryAction.displayName = 'Banner.SecondaryAction'

export {BannerPrimaryAction, BannerSecondaryAction}
5 changes: 5 additions & 0 deletions packages/react/src/Blankslate/Blankslate.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,3 +145,8 @@ export type {
BlankslatePrimaryActionProps,
BlankslateSecondaryActionProps,
}
Visual.displayName = 'Blankslate.Visual'
Heading.displayName = 'Blankslate.Heading'
Description.displayName = 'Blankslate.Description'
PrimaryAction.displayName = 'Blankslate.PrimaryAction'
SecondaryAction.displayName = 'Blankslate.SecondaryAction'
2 changes: 2 additions & 0 deletions packages/react/src/DataTable/ErrorDialog.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,3 +37,5 @@ export function ErrorDialog({title = 'Error', children, onRetry, onDismiss}: Tab
</ConfirmationDialog>
)
}

ErrorDialog.displayName = 'Table.ErrorDialog'
2 changes: 2 additions & 0 deletions packages/react/src/DataTable/Pagination.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -385,3 +385,5 @@ function usePagination(config: PaginationConfig): PaginationResult {
selectNextPage,
}
}

Pagination.displayName = 'Table.Pagination'
14 changes: 14 additions & 0 deletions packages/react/src/DataTable/Table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -408,3 +408,17 @@ export {
TableCellPlaceholder,
TableSkeleton,
}

TableHead.displayName = 'Table.Head'
TableBody.displayName = 'Table.Body'
TableHeader.displayName = 'Table.Header'
TableSortHeader.displayName = 'Table.SortHeader'
TableRow.displayName = 'Table.Row'
TableCell.displayName = 'Table.Cell'
TableCellPlaceholder.displayName = 'Table.CellPlaceholder'
TableContainer.displayName = 'Table.Container'
TableTitle.displayName = 'Table.Title'
TableSubtitle.displayName = 'Table.Subtitle'
TableDivider.displayName = 'Table.Divider'
TableActions.displayName = 'Table.Actions'
TableSkeleton.displayName = 'Table.Skeleton'
Comment thread
Copilot marked this conversation as resolved.
2 changes: 1 addition & 1 deletion packages/react/src/Details/Details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ function Summary<As extends React.ElementType>({as, children, ...props}: Summary
</Component>
)
}
Summary.displayName = 'Summary'
Summary.displayName = 'Details.Summary'

export {Summary}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,3 +57,5 @@ export function FilteredActionListInput({
</div>
)
}

FilteredActionListInput.displayName = 'FilteredActionList.Input'
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,3 +52,5 @@ function LoadingSkeleton({rows = 10, ...props}: {rows: number}): JSX.Element {
</div>
)
}

FilteredActionListBodyLoader.displayName = 'FilteredActionList.BodyLoader'
1 change: 1 addition & 0 deletions packages/react/src/FormControl/FormControlCaption.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ function FormControlCaption({id, children, className, style}: FormControlCaption
)
}

FormControlCaption.displayName = 'FormControl.Caption'
FormControlCaption.__SLOT__ = Symbol('FormControl.Caption')

export {FormControlCaption}
1 change: 1 addition & 0 deletions packages/react/src/FormControl/FormControlLabel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ const FormControlLabel: FCWithSlotMarker<
)
}

FormControlLabel.displayName = 'FormControl.Label'
FormControlLabel.__SLOT__ = Symbol('FormControl.Label')

export default FormControlLabel
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ const FormControlLeadingVisual: FCWithSlotMarker<React.PropsWithChildren<{style?
)
}

FormControlLeadingVisual.displayName = 'FormControl.LeadingVisual'
FormControlLeadingVisual.__SLOT__ = Symbol('FormControl.LeadingVisual')

export default FormControlLeadingVisual
4 changes: 2 additions & 2 deletions packages/react/src/PageHeader/PageHeader.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,7 +232,7 @@ const ParentLink = React.forwardRef<HTMLAnchorElement, ParentLinkProps>(
)
},
) as PolymorphicForwardRefComponent<'a', ParentLinkProps>
ParentLink.displayName = 'ParentLink'
ParentLink.displayName = 'PageHeader.ParentLink'

// ContextBar
// Generic slot for any component above the title region. Use it for custom breadcrumbs and other navigation elements instead of ParentLink.
Expand DownExpand Up@@ -300,7 +300,7 @@ const TitleArea = React.forwardRef<HTMLDivElement, React.PropsWithChildren<Title
)
},
) as PolymorphicForwardRefComponent<'div', TitleAreaProps>
TitleArea.displayName = 'TitleArea'
TitleArea.displayName = 'PageHeader.TitleArea'

// PageHeader.LeadingAction and PageHeader.TrailingAction should only be visible on regular viewports.
// So they come as hidden on narrow viewports by default and their visibility can be managed by their `hidden` prop.
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,7 +178,7 @@ const HorizontalDivider = memo<React.PropsWithChildren<DividerProps>>(
},
)

HorizontalDivider.displayName = 'HorizontalDivider'
HorizontalDivider.displayName = 'PageLayout.HorizontalDivider'

type VerticalDividerProps = DividerProps & {
draggable?: boolean
Expand All@@ -200,7 +200,7 @@ const VerticalDivider = memo<React.PropsWithChildren<VerticalDividerProps>>(
},
)

VerticalDivider.displayName = 'VerticalDivider'
VerticalDivider.displayName = 'PageLayout.VerticalDivider'

type SidebarDividerProps = {
position: 'start' | 'end'
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/Pagehead/Pagehead.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,4 +12,5 @@ const Pagehead = ({as: BaseComponent = 'div', className, ...rest}: PageheadProps
export type PageheadProps = React.ComponentPropsWithoutRef<'div'> & {
as?: React.ElementType
}

export default Pagehead
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,4 +76,5 @@ const SegmentedControlButton: FCWithSlotMarker<React.PropsWithChildren<Segmented

export default SegmentedControlButton

SegmentedControlButton.displayName = 'SegmentedControl.Button'
SegmentedControlButton.__SLOT__ = Symbol('SegmentedControl.Button')
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,6 +64,7 @@ export const SegmentedControlIconButton: FCWithSlotMarker<React.PropsWithChildre
)
}

SegmentedControlIconButton.displayName = 'SegmentedControl.IconButton'
SegmentedControlIconButton.__SLOT__ = Symbol('SegmentedControl.IconButton')

export default SegmentedControlIconButton
2 changes: 2 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1079,6 +1079,8 @@ const SecondaryLink: React.FC<LinkButtonProps & ButtonProps> = props => {
)
}

Panel.displayName = 'SelectPanel'

export const SelectPanel = Object.assign(Panel, {
__SLOT__: Symbol('SelectPanel'),
SecondaryActionButton: SecondaryButton,
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/SelectPanel/SelectPanelMessage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,3 +59,5 @@ export const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({
</div>
)
}

SelectPanelMessage.displayName = 'SelectPanel.Message'
1 change: 1 addition & 0 deletions packages/react/src/Stack/Stack.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,3 +157,4 @@ const StackItem = forwardRef(({as: Component = 'div', children, grow, shrink, cl

export {Stack, StackItem}
export type {StackProps, StackItemProps}
StackItem.displayName = 'Stack.Item'
6 changes: 3 additions & 3 deletions packages/react/src/Timeline/Timeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ const TimelineItem = React.forwardRef<HTMLDivElement | HTMLLIElement, TimelineIt
},
)

TimelineItem.displayName = 'TimelineItem'
TimelineItem.displayName = 'Timeline.Item'

export type TimelineBadgeVariant = (typeof TimelineBadgeVariants)[number]

Expand DownExpand Up@@ -112,7 +112,7 @@ const TimelineBody = React.forwardRef<HTMLDivElement, TimelineBodyProps>(({class
return <div {...props} className={clsx(className, classes.TimelineBody)} ref={forwardRef} />
})

TimelineBody.displayName = 'TimelineBody'
TimelineBody.displayName = 'Timeline.Body'

export type TimelineBreakProps = {
/** Class name for custom styling */
Expand DownExpand Up@@ -144,7 +144,7 @@ const TimelineBreak = React.forwardRef<HTMLDivElement | HTMLLIElement, TimelineB
},
)

TimelineBreak.displayName = 'TimelineBreak'
TimelineBreak.displayName = 'Timeline.Break'

export type TimelineActionsProps = {
/** Class name for custom styling */
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/TooltipV2/Tooltip.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -409,4 +409,5 @@ export const Tooltip: ForwardRefExoticComponent<
},
)

Tooltip.displayName = 'Tooltip'
Tooltip.__SLOT__ = Symbol('Tooltip')
2 changes: 2 additions & 0 deletions packages/react/src/TopicTag/TopicTagGroup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,3 +14,5 @@ function TopicTagGroup({children, className, ...rest}: TopicTagGroupProps) {

export {TopicTagGroup}
export type {TopicTagGroupProps}

TopicTagGroup.displayName = 'TopicTag.Group'
Loading