Skip to content
Open
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 number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ npm run lint:fix # Auto-fix linting issues

When working on UI components, always use the `primer-storybook` MCP tools to access Storybook's component and documentation knowledge before answering or taking any action. Reference the `.github/skills/storybook/SKILL.md` file for detailed instructions on using the Storybook MCP effectively and accurately.

## `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
128 changes: 128 additions & 0 deletions .github/skills/display-name/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
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 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

The slot system's dev-mode `displayName`-mismatch warning compares `child.type.displayName` against the slot component's `displayName`. Wrappers built with `asSlot` inherit the marker; setting an explicit `displayName` on each slot sub-component keeps the warning useful even when the wrapper's `Function.name` is generic.

## 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, the `Symbol(...)` description **must match** the `displayName`. The slot system's `displayName`-mismatch warning depends on this contract:

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

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

## 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 number Diff line number Diff line change
Expand Up @@ -473,3 +473,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 number Diff line number Diff line change
Expand Up @@ -82,4 +82,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 number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -71,4 +71,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 number Diff line number Diff line change
Expand Up @@ -276,7 +276,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 @@ -294,6 +294,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 number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ export function ErrorDialog({title = 'Error', children, onRetry, onDismiss}: Tab
</ConfirmationDialog>
)
}

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

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

Table.displayName = 'DataTable.Table'
TableHead.displayName = 'DataTable.Head'
TableBody.displayName = 'DataTable.Body'
TableHeader.displayName = 'DataTable.Header'
TableRow.displayName = 'DataTable.Row'
TableCell.displayName = 'DataTable.Cell'
TableCellPlaceholder.displayName = 'DataTable.CellPlaceholder'
TableContainer.displayName = 'DataTable.Container'
TableTitle.displayName = 'DataTable.Title'
TableSubtitle.displayName = 'DataTable.Subtitle'
TableDivider.displayName = 'DataTable.Divider'
TableActions.displayName = 'DataTable.Actions'
TableSkeleton.displayName = 'DataTable.Skeleton'
Comment on lines +412 to +424
2 changes: 1 addition & 1 deletion packages/react/src/Details/Details.tsx
Original file line number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -57,3 +57,5 @@ export function FilteredActionListInput({
</div>
)
}

FilteredActionListInput.displayName = 'FilteredActionList.Input'
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -164,7 +164,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 Down Expand Up @@ -220,7 +220,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 number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ const HorizontalDivider = memo<React.PropsWithChildren<DividerProps>>(
},
)

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

type VerticalDividerProps = DividerProps & {
draggable?: boolean
Expand All @@ -191,7 +191,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 number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ const SegmentedControlButton: FCWithSlotMarker<React.PropsWithChildren<Segmented

export default SegmentedControlButton

SegmentedControlButton.displayName = 'SegmentedControl.Button'
SegmentedControlButton.__SLOT__ = Symbol('SegmentedControl.Button')
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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 number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,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 number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -155,3 +155,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 number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const TimelineItem = React.forwardRef<HTMLDivElement, TimelineItemProps>(
},
)

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

export type TimelineBadgeVariant =
| 'accent'
Expand Down Expand Up @@ -87,7 +87,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 All @@ -98,7 +98,7 @@ const TimelineBreak = React.forwardRef<HTMLDivElement, TimelineBreakProps>(({cla
return <div {...props} className={clsx(className, classes.TimelineBreak)} ref={forwardRef} />
})

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

export default Object.assign(Timeline, {
Item: TimelineItem,
Expand Down
1 change: 0 additions & 1 deletion packages/react/src/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ const Tooltip = React.forwardRef(function Tooltip(
Tooltip.alignments = ['left', 'right']

Tooltip.directions = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']

Tooltip.__SLOT__ = Symbol('DEPRECATED_Tooltip')

export default Tooltip
1 change: 0 additions & 1 deletion packages/react/src/TooltipV2/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,5 +399,4 @@ export const Tooltip: ForwardRefExoticComponent<
)
},
)

Tooltip.__SLOT__ = Symbol('Tooltip')
Loading
Loading