diff --git a/.github/workflows/migration-status.yml b/.github/workflows/migration-status.yml
index 9b74e5e6f78..d2cf2599c1f 100644
--- a/.github/workflows/migration-status.yml
+++ b/.github/workflows/migration-status.yml
@@ -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
+
react-compiler:
runs-on: ubuntu-latest
steps:
diff --git a/contributor-docs/adrs/adr-025-prop-merging.md b/contributor-docs/adrs/adr-025-prop-merging.md
new file mode 100644
index 00000000000..497888f448d
--- /dev/null
+++ b/contributor-docs/adrs/adr-025-prop-merging.md
@@ -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
+
+```
+
+`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
+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, 'type'>
+```
+
+```tsx
+// Conditional attributes: only expose anchor-only props when `as: 'a'`.
+type ButtonLikeProps =
+ | ({
+ as?: 'button'
+ } & Omit, '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.
diff --git a/contributor-docs/style.md b/contributor-docs/style.md
index c9dabb0db1a..8bffb03f3fd 100644
--- a/contributor-docs/style.md
+++ b/contributor-docs/style.md
@@ -68,6 +68,7 @@ row before the 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)
@@ -299,6 +300,59 @@ function Example({children, ...rest}: Props) {
+#### 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) => {
+ performInternalAction(event)
+ }
+
+ return (
+
+ )
+}
+```
+
+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, 'type'>
+
+function Example(props: Props) {
+ return
+}
+```
+
#### Prefer authoring callback prop types with arguments that can be extended
When authoring callback props, it is helpful to structure the types for these
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 22647924d8e..7092b2f8d56 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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'
import eslintReact from '@eslint-react/eslint-plugin'
import vitest from '@vitest/eslint-plugin'
import {defineConfig, globalIgnores} from 'eslint/config'
@@ -58,6 +59,7 @@ const config = defineConfig([
]),
js.configs.recommended,
+ primerConfig,
...fixupConfigRules([react.configs.flat.recommended, react.configs.flat['jsx-runtime']]),
reactHooks.configs.flat['recommended-latest'],
diff --git a/package-lock.json b/package-lock.json
index a6eb789498a..52ae83deda9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6607,6 +6607,10 @@
"resolved": "packages/doc-gen",
"link": true
},
+ "node_modules/@primer/eslint-config": {
+ "resolved": "packages/eslint-config",
+ "link": true
+ },
"node_modules/@primer/mcp": {
"resolved": "packages/mcp",
"link": true
@@ -28757,6 +28761,23 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "packages/eslint-config": {
+ "name": "@primer/eslint-config",
+ "version": "0.0.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"
+ },
+ "peerDependencies": {
+ "eslint": "^10.7.0"
+ }
+ },
"packages/mcp": {
"name": "@primer/mcp",
"version": "0.5.1",
diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json
new file mode 100644
index 00000000000..3bfbee50bb4
--- /dev/null
+++ b/packages/eslint-config/package.json
@@ -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"
+ }
+}
diff --git a/packages/eslint-config/src/index.test.ts b/packages/eslint-config/src/index.test.ts
new file mode 100644
index 00000000000..a85314c3b18
--- /dev/null
+++ b/packages/eslint-config/src/index.test.ts
@@ -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')
+ })
+})
diff --git a/packages/eslint-config/src/index.ts b/packages/eslint-config/src/index.ts
new file mode 100644
index 00000000000..c1d31e635e3
--- /dev/null
+++ b/packages/eslint-config/src/index.ts
@@ -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
diff --git a/packages/eslint-config/src/rules/preferMergeProps.test.ts b/packages/eslint-config/src/rules/preferMergeProps.test.ts
new file mode 100644
index 00000000000..7fccc3aa386
--- /dev/null
+++ b/packages/eslint-config/src/rules/preferMergeProps.test.ts
@@ -0,0 +1,214 @@
+import {RuleTester} from 'eslint'
+import tsParser from '@typescript-eslint/parser'
+import {describe, it} from 'vitest'
+import {preferMergeProps} from './preferMergeProps.ts'
+
+RuleTester.describe = describe
+RuleTester.it = it
+RuleTester.itOnly = it
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 'latest',
+ parser: tsParser,
+ sourceType: 'module',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+})
+
+ruleTester.run('prefer-merge-props', preferMergeProps as unknown as Parameters[1], {
+ valid: [
+ {
+ name: 'accepts component root props merged with mergeProps',
+ code: `function Example() { return }`,
+ },
+ {
+ name: 'accepts a namespaced mergeProps utility',
+ code: `function Example() { return }`,
+ },
+ {
+ name: 'accepts props previously merged with mergeProps',
+ code: `
+ function Example() {
+ const mergedProps = mergeProps(componentProps, props)
+ return
+ }
+ `,
+ },
+ {
+ name: 'ignores elements without spread props',
+ code: `function Example() { return }`,
+ },
+ {
+ name: 'ignores a component that only forwards consumer props',
+ code: `function Example(props) { return }`,
+ },
+ {
+ name: 'ignores a component that forwards consumer props and composes a ref separately',
+ code: `const Example = React.forwardRef((props, ref) => )`,
+ },
+ {
+ name: 'ignores a component that only forwards props to a custom component',
+ code: `function Example(props) { return }`,
+ },
+ {
+ name: 'ignores spread props on nested elements',
+ code: `function Example() { return }`,
+ },
+ {
+ name: 'ignores spread props inside a root fragment',
+ code: `function Example() { return <>> }`,
+ },
+ {
+ name: 'ignores spread props on elements nested in conditional content',
+ code: `function Example() { return
{condition ? : null}
}`,
+ },
+ {
+ name: 'ignores spread props on elements nested in mapped content',
+ code: `function Example() { return
{items.map(item => )}
}`,
+ },
+ {
+ name: 'ignores spread props on elements nested in a render prop',
+ code: `function Example() { return } /> }`,
+ },
+ {
+ name: 'ignores object spread expressions outside JSX',
+ code: `
+ const copiedProps = {...props}
+ const example =
+ `,
+ },
+ {
+ name: 'ignores JSX assigned outside a component',
+ code: `const example = `,
+ },
+ {
+ name: 'ignores JSX returned by a lowercase helper',
+ code: `function renderButton() { return }`,
+ },
+ {
+ name: 'ignores JSX returned by an unrelated callback',
+ code: `const buttons = items.map(props => )`,
+ },
+ {
+ name: 'ignores component roots in test files',
+ filename: 'Example.test.jsx',
+ code: `function Example() { return }`,
+ },
+ {
+ name: 'ignores component roots in story files',
+ filename: 'Example.stories.jsx',
+ code: `function Example() { return }`,
+ },
+ {
+ name: 'ignores component roots in test directories',
+ filename: '__tests__/Example.jsx',
+ code: `function Example() { return }`,
+ },
+ ],
+ invalid: [
+ {
+ name: 'reports direct props spread on a function component root',
+ code: `function Example(props) { return }`,
+ errors: [{messageId: 'preferMergeProps'}],
+ },
+ {
+ name: 'reports rest props composed with an explicit handler',
+ code: `
+ function Example({onClick, ...rest}) {
+ return