-
Notifications
You must be signed in to change notification settings - Fork 673
docs: add prop merging ADR and adoption tooling #8248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
5309f15
Document prop merging conventions
Copilot e4cdd21
Add prop merging utility and ADR
Copilot 559da1a
Remove ADR reference from style guide
Copilot 0b7f90c
Mark prop merging ADR as proposed
Copilot b52a24a
Add merge props ESLint migration rule
Copilot 8718d74
Document React 19 ref prop behavior
Copilot a1d6f7a
Expand merge props rule coverage
Copilot 083546e
Clarify function spread test name
Copilot c7f8c93
Scope merge props rule to component roots
Copilot 3d6e467
Handle typed component wrappers
Copilot 8866914
docs: add typed prop-gating examples to ADR-025
Copilot 1bc6f64
Refine merge props migration reporting
joshblack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.