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
Comment on lines +10 to +12
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 on lines +16 to +19

react-compiler:
runs-on: ubuntu-latest
steps:
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'
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,

...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
214 changes: 214 additions & 0 deletions packages/eslint-config/src/rules/preferMergeProps.test.ts
Original file line number Diff line number Diff line change
@@ -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<RuleTester['run']>[1], {
valid: [
{
name: 'accepts component root props merged with mergeProps',
code: `function Example() { return <button {...mergeProps(componentProps, props)} /> }`,
},
{
name: 'accepts a namespaced mergeProps utility',
code: `function Example() { return <button {...utils.mergeProps(componentProps, props)} /> }`,
},
{
name: 'accepts props previously merged with mergeProps',
code: `
function Example() {
const mergedProps = mergeProps(componentProps, props)
return <button {...mergedProps} />
}
`,
},
{
name: 'ignores elements without spread props',
code: `function Example() { return <button type="button" /> }`,
},
{
name: 'ignores a component that only forwards consumer props',
code: `function Example(props) { return <button {...props} /> }`,
},
{
name: 'ignores a component that forwards consumer props and composes a ref separately',
code: `const Example = React.forwardRef((props, ref) => <button {...props} ref={ref} />)`,
},
{
name: 'ignores a component that only forwards props to a custom component',
code: `function Example(props) { return <Button {...props} /> }`,
},
{
name: 'ignores spread props on nested elements',
code: `function Example() { return <div><button {...props} /></div> }`,
},
{
name: 'ignores spread props inside a root fragment',
code: `function Example() { return <><button {...props} /></> }`,
},
{
name: 'ignores spread props on elements nested in conditional content',
code: `function Example() { return <div>{condition ? <button {...props} /> : null}</div> }`,
},
{
name: 'ignores spread props on elements nested in mapped content',
code: `function Example() { return <ul>{items.map(item => <li {...item} />)}</ul> }`,
},
{
name: 'ignores spread props on elements nested in a render prop',
code: `function Example() { return <Component render={() => <button {...props} />} /> }`,
},
{
name: 'ignores object spread expressions outside JSX',
code: `
const copiedProps = {...props}
const example = <button type="button" />
`,
},
{
name: 'ignores JSX assigned outside a component',
code: `const example = <button {...props} />`,
},
{
name: 'ignores JSX returned by a lowercase helper',
code: `function renderButton() { return <button {...props} /> }`,
},
{
name: 'ignores JSX returned by an unrelated callback',
code: `const buttons = items.map(props => <button {...props} />)`,
},
{
name: 'ignores component roots in test files',
filename: 'Example.test.jsx',
code: `function Example() { return <button {...props} /> }`,
},
{
name: 'ignores component roots in story files',
filename: 'Example.stories.jsx',
code: `function Example() { return <button {...props} /> }`,
},
{
name: 'ignores component roots in test directories',
filename: '__tests__/Example.jsx',
code: `function Example() { return <button {...props} /> }`,
},
],
invalid: [
{
name: 'reports direct props spread on a function component root',
code: `function Example(props) { return <button {...props} type="button" /> }`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports rest props composed with an explicit handler',
code: `
function Example({onClick, ...rest}) {
return <button {...rest} type="button" onClick={event => onClick?.(event)} />
}
`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports each unmerged spread on a component root',
code: `function Example() { return <button {...componentProps} {...props} /> }`,
errors: [{messageId: 'preferMergeProps'}, {messageId: 'preferMergeProps'}],
},
{
name: 'reports component roots in separate return branches',
code: `
function Example({foo, ...rest}) {
if (foo) {
return <a {...rest} href="#" />
}
return <button {...rest} type="button" />
}
`,
errors: [{messageId: 'preferMergeProps'}, {messageId: 'preferMergeProps'}],
},
{
name: 'reports component roots in conditional expression branches',
code: `function Example() { return condition ? <button type="button" {...props} /> : <a href="#" {...props} /> }`,
errors: [{messageId: 'preferMergeProps'}, {messageId: 'preferMergeProps'}],
},
{
name: 'reports unmerged props on a root custom component',
code: `function Example() { return <Button variant="primary" {...props} /> }`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports unmerged props on a root member component',
code: `function Example() { return <ActionList.Item role="menuitem" {...props} /> }`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports spread props returned from a function call',
code: `function Example() { return <button type="button" {...getProps()} /> }`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports props copied into an object expression',
code: `function Example() { return <button type="button" {...{...props}} /> }`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports only the unmerged spread alongside merged props',
code: `function Example() { return <button {...mergeProps(componentProps, props)} {...otherProps} /> }`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports props on component roots in logical expressions',
code: `function Example() { return condition && <button type="button" {...props} /> }`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports props on a concise arrow component root',
code: `const Example = props => <button type="button" {...props} />`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports props on a forwardRef component root',
code: `const Example = React.forwardRef((props, ref) => <button type="button" {...props} ref={ref} />)`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports props on an asserted forwardRef component root',
code: `
const Example = React.forwardRef(
(props, ref) => <button type="button" {...props} ref={ref} />
) as PolymorphicForwardRefComponent
`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports props on a forwardRef component root using satisfies',
code: `
const Example = React.forwardRef(
(props, ref) => <button type="button" {...props} ref={ref} />
) satisfies PolymorphicForwardRefComponent
`,
errors: [{messageId: 'preferMergeProps'}],
},
{
name: 'reports props on a class component root',
code: `class Example extends React.Component { render() { return <button type="button" {...this.props} /> } }`,
errors: [{messageId: 'preferMergeProps'}],
},
],
})
Loading
Loading