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
13 changes: 13 additions & 0 deletions .github/workflows/migration-status.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ on:
workflow_dispatch:

jobs:
merge-props:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: install dependencies
run: npm ci
- name: run migration script
run: node script/merge-props-migration-status.mts >> $GITHUB_STEP_SUMMARY
Comment thread
joshblack marked this conversation as resolved.

react-compiler:
runs-on: ubuntu-latest
steps:
Expand Down
133 changes: 133 additions & 0 deletions contributor-docs/adrs/adr-025-prop-merging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Prop merging conventions

📆 Date: 2026-07-28

## Status

| Stage | State |
| -------------- | ----------- |
| Status | Proposed ⚠️ |
| Implementation | Pending ⚠️ |

## Context

Primer components often set props on an element while also accepting those same
props from consumers. Relying on object spread order alone either replaces
component behavior and styling or prevents consumers from customizing supported
props. Inconsistent precedence also makes component APIs difficult to predict.

We need one convention for combining component-authored and consumer-authored
props, plus an implementation that applies it consistently.

## Decision

Components must intentionally merge any prop that both the component and
consumer can provide. Pass component props first and consumer props second to the
`mergeProps` utility:

```tsx
<button
{...mergeProps(
{
className: classes.Example,
onClick: handleClick,
style: defaultStyle,
},
props,
)}
/>
```

`mergeProps(componentProps, consumerProps)` applies these rules:

- Event handlers are composed in argument order. The component handler runs
first. The consumer handler runs only if the component handler does not set
`event.defaultPrevented`.
- `className` values are combined with `clsx`, with the component value first.
- `style` objects are shallowly merged, with the consumer value taking
precedence for duplicate properties.
- All other duplicate props use the consumer value.

If a component must control an attribute for correct behavior, it must not
Comment thread
joshblack marked this conversation as resolved.
expose that attribute unconditionally. Omit it from the public prop type, or use
a discriminated union that only exposes it in supported scenarios, rather than
accepting and silently overriding the consumer value.

For example:

```tsx
// Component-owned attribute: `type` is fixed internally and is not public API.
type IconButtonProps = Omit<React.ComponentPropsWithoutRef<'button'>, 'type'>
```

```tsx
// Conditional attributes: only expose anchor-only props when `as: 'a'`.
type ButtonLikeProps =
| ({
as?: 'button'
} & Omit<React.ComponentPropsWithoutRef<'button'>, 'href' | 'target' | 'rel'>)
| ({
as: 'a'
} & React.ComponentPropsWithoutRef<'a'>)
```

The utility is an implementation detail for building Primer React components
and is not part of the package's public API.

### Scenarios requiring separate handling

The general rules do not cover every form of composition:

- **Refs are not composed.** In React 19, `ref` is passed as a prop, but it still
requires separate composition. Use `useMergedRefs` when both the component
and consumer need the same element reference.
- **Styles are not deeply merged.** Nested objects and values such as CSS custom
property maps are replaced at the first duplicate property.
- **Cancellation is based on `defaultPrevented`.** The consumer handler is
skipped when the first argument's `defaultPrevented` value is truthy after the
component handler runs. Calling `stopPropagation()` alone does not stop the
consumer handler because both handlers run for the same React event.
- **Cancellation is synchronous and one-way.** The consumer cannot prevent the
component handler because it runs second, and an asynchronous call to
`preventDefault()` occurs too late. APIs that require consumer veto before
internal behavior need a separate cancellable callback design.
- **Errors stop composition.** If the component handler throws, the consumer
handler does not run.
- **`on*` callback props are treated as event handlers.** A callback whose name
starts with `on` and whose value is a function is composed with the same
cancellation behavior, even if it is not a DOM event handler.
- **Cancellation only considers the first argument.** All callback arguments
are forwarded, but only a `defaultPrevented` value on the first argument can
cancel the second callback. Zero-argument callbacks run both handlers.
- **Ordinary prop precedence includes `undefined`.** An explicitly present
consumer prop with an `undefined` value replaces the component value unless
the prop has special class name, style, or event-handler behavior.

## Consequences

Component behavior and base styling are preserved while consumers retain
predictable override points. A shared utility reduces hand-written merging logic
and makes precedence consistent across components.

The convention requires component authors to distinguish supported overrides
from attributes that the component must own. Event ordering also means consumer
handlers cannot cancel component work that has already happened.

## Alternatives

### Use prop spread order for every prop

This is concise, but replaces event handlers, class names, and complete style
objects instead of composing them.

### Always give component props precedence

This preserves implementation details but makes accepted consumer props
ineffective and creates a misleading API.

### Let consumers run event handlers first

This would let consumers prevent component behavior, but gives consumers control
over invariants and accessibility behavior that the component owns. APIs that
need a consumer veto should model it explicitly instead of changing the default
ordering for all handlers.
54 changes: 54 additions & 0 deletions contributor-docs/style.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ row before the </tbody></table> line.
- [Utilities](#utilities)
- [Props](#props)
- [Prefer applying component rest parameters to the root element rendered by a component](#prefer-applying-component-rest-parameters-to-the-root-element-rendered-by-a-component)
- [Merge shared props intentionally](#merge-shared-props-intentionally)
- [Prefer authoring callback prop types with arguments that can be extended](#prefer-authoring-callback-prop-types-with-arguments-that-can-be-extended)
- [Hooks](#hooks)
- [Prefer authoring hooks that accept a `ref` instead of returning a `ref` to apply](#prefer-authoring-hooks-that-accept-a-ref-instead-of-returning-a-ref-to-apply)
Expand Down Expand Up @@ -299,6 +300,59 @@ function Example({children, ...rest}: Props) {
</td></tr>
</tbody></table>

#### Merge shared props intentionally

When a component and a consumer can both provide a prop, define how those values
are merged instead of allowing the order of prop spreads to decide accidentally.
Use the following conventions:

- Event handlers run the component's handler first, then the consumer's handler
if the event has not been prevented.
- Class names are merged with `clsx`.
- Style objects apply the component's styles first and the consumer's styles
second so that the consumer can override them.
- Other attributes use the consumer's value. If an attribute must be controlled
by the component, do not expose it as a prop, or use types that only offer it
in the scenarios where the consumer can control it.

Use the `mergeProps` utility with component props first and consumer props
second:

```tsx
type Props = React.ComponentPropsWithoutRef<'button'>

function Example(props: Props) {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
performInternalAction(event)
}

return (
<button
{...mergeProps(
{
'data-component': 'Example',
className: classes.Example,
onClick: handleClick,
style: defaultStyle,
},
props,
)}
/>
)
}
```

When a component requires an attribute for correct behavior, remove it from the
consumer-facing type instead of silently overriding a value the API accepts:

```tsx
type Props = Omit<React.ComponentPropsWithoutRef<'button'>, 'type'>

function Example(props: Props) {
return <button {...props} type="button" />
}
```

#### Prefer authoring callback prop types with arguments that can be extended

When authoring callback props, it is helpful to structure the types for these
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {fileURLToPath} from 'node:url'
import {fixupConfigRules, fixupPluginRules} from '@eslint/compat'
import {FlatCompat} from '@eslint/eslintrc'
import js from '@eslint/js'
import primerConfig from '@primer/eslint-config'
Comment thread
joshblack marked this conversation as resolved.
import eslintReact from '@eslint-react/eslint-plugin'
import vitest from '@vitest/eslint-plugin'
import {defineConfig, globalIgnores} from 'eslint/config'
Expand Down Expand Up @@ -58,6 +59,7 @@ const config = defineConfig([
]),

js.configs.recommended,
primerConfig,
Comment thread
joshblack marked this conversation as resolved.

...fixupConfigRules([react.configs.flat.recommended, react.configs.flat['jsx-runtime']]),
reactHooks.configs.flat['recommended-latest'],
Expand Down
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions packages/eslint-config/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@primer/eslint-config",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"test": "vitest --run",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"eslint": "^10.7.0"
},
"dependencies": {
"@typescript-eslint/utils": "^8.59.1"
},
"devDependencies": {
"@primer/vitest-config": "^0.0.0",
"@typescript-eslint/parser": "8.59.1",
"eslint": "^10.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
}
9 changes: 9 additions & 0 deletions packages/eslint-config/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {describe, expect, test} from 'vitest'
import {config, plugin} from './index.ts'

describe('@primer/eslint-config', () => {
test('exports the prefer-merge-props rule disabled by default', () => {
expect(plugin.rules?.['prefer-merge-props']).toBeDefined()
expect(config.rules?.['primer/prefer-merge-props']).toBe('off')
})
})
24 changes: 24 additions & 0 deletions packages/eslint-config/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type {TSESLint} from '@typescript-eslint/utils'
import {preferMergeProps} from './rules/preferMergeProps.ts'

const plugin: TSESLint.FlatConfig.Plugin = {
meta: {
name: '@primer/eslint-config',
},
rules: {
'prefer-merge-props': preferMergeProps,
},
}

const config: TSESLint.FlatConfig.Config = {
name: '@primer/eslint-config',
plugins: {
primer: plugin,
},
rules: {
'primer/prefer-merge-props': 'off',
},
}

export {config, plugin, preferMergeProps}
export default config
Loading
Loading