diff --git a/docs/rules/merge-spread-props-classname.md b/docs/rules/merge-spread-props-classname.md
new file mode 100644
index 0000000..bc19284
--- /dev/null
+++ b/docs/rules/merge-spread-props-classname.md
@@ -0,0 +1,75 @@
+# Ensure className is merged with spread props (merge-spread-props-classname)
+
+When using spread props (`{...rest}`, `{...props}`, etc.) before a `className` prop, the className should be merged using a utility like `clsx` to avoid unintentionally overriding the className from the spread props.
+
+## Rule details
+
+This rule enforces that when a JSX element has both spread props and a `className` prop, and the spread props come before the `className`, the className should use `clsx` or a similar utility to merge both class names.
+
+👎 Examples of **incorrect** code for this rule:
+
+```jsx
+/* eslint primer-react/merge-spread-props-classname: "error" */
+
+// ❌ className after spread - not merged
+
+
+// ❌ className expression after spread - not merged
+
+
+// ❌ Multiple spreads with className - not merged
+
+```
+
+👍 Examples of **correct** code for this rule:
+
+```jsx
+/* eslint primer-react/merge-spread-props-classname: "error" */
+
+// ✅ className merged with clsx
+
+
+// ✅ className merged with classnames
+
+
+// ✅ className before spread (spread will override, which is expected)
+
+
+// ✅ Only spread props
+
+
+// ✅ Only className
+
+```
+
+## Why this matters
+
+When you have spread props before a className, the className from the spread props can be overridden if you don't merge them properly:
+
+```jsx
+// ❌ Bad: className from rest gets overridden
+function MyComponent({className, ...rest}) {
+ return
+}
+
+// If called as:
+// Result: className="custom-class" (parent-class is lost!)
+
+// ✅ Good: Both classNames are merged
+function MyComponent({className, ...rest}) {
+ return
+}
+
+// If called as:
+// Result: className="parent-class custom-class" (both are applied!)
+```
+
+## Options
+
+This rule has no configuration options.
+
+## When to use autofix
+
+This rule includes an autofix that will automatically wrap your className in a `clsx()` call with the spread prop's className. The autofix is safe to use and will preserve your className logic while adding the merging behavior.
+
+Note: You'll need to import `clsx` in your file if it's not already imported.
diff --git a/docs/rules/merge-spread-props-event-handlers.md b/docs/rules/merge-spread-props-event-handlers.md
new file mode 100644
index 0000000..fcef997
--- /dev/null
+++ b/docs/rules/merge-spread-props-event-handlers.md
@@ -0,0 +1,91 @@
+# Ensure event handlers are merged with spread props (merge-spread-props-event-handlers)
+
+When using spread props (`{...rest}`, `{...props}`, etc.) before event handler props (like `onClick`, `onChange`, etc.), the event handlers should be merged using a utility like `compose` to avoid unintentionally overriding the event handler from the spread props.
+
+## Rule details
+
+This rule enforces that when a JSX element has both spread props and event handler props, and the spread props come before the event handlers, the event handlers should use `compose` or a similar utility to merge both handlers.
+
+👎 Examples of **incorrect** code for this rule:
+
+```jsx
+/* eslint primer-react/merge-spread-props-event-handlers: "error" */
+
+// ❌ onClick after spread - not merged
+
+
+// ❌ onChange after spread - not merged
+ {}} />
+
+// ❌ Multiple event handlers after spread - not merged
+
+```
+
+👍 Examples of **correct** code for this rule:
+
+```jsx
+/* eslint primer-react/merge-spread-props-event-handlers: "error" */
+
+// ✅ onClick merged with compose
+
+
+// ✅ onChange merged with compose
+
+
+// ✅ Event handler before spread (spread will override, which is expected)
+
+
+// ✅ Only spread props
+
+
+// ✅ Only event handler
+
+```
+
+## Why this matters
+
+When you have spread props before an event handler, the event handler from the spread props can be overridden if you don't merge them properly:
+
+```jsx
+// ❌ Bad: onClick from rest gets overridden
+function MyComponent({onClick, ...rest}) {
+ return
+}
+
+// If called as:
+// Result: Only handleClick runs (parentHandler is lost!)
+
+// ✅ Good: Both handlers are composed
+function MyComponent({onClick, ...rest}) {
+ return
+}
+
+// If called as:
+// Result: Both parentHandler and handleClick run in sequence!
+```
+
+## Supported event handlers
+
+This rule recognizes the following React event handler props:
+
+- Mouse events: `onClick`, `onMouseEnter`, `onMouseLeave`, `onMouseDown`, `onMouseUp`
+- Form events: `onChange`, `onSubmit`, `onInput`, `onSelect`
+- Focus events: `onFocus`, `onBlur`
+- Keyboard events: `onKeyDown`, `onKeyUp`, `onKeyPress`
+- Touch events: `onTouchStart`, `onTouchEnd`, `onTouchMove`, `onTouchCancel`
+- Pointer events: `onPointerDown`, `onPointerUp`, `onPointerMove`, etc.
+- And many more...
+
+## Options
+
+This rule has no configuration options.
+
+## When to use autofix
+
+This rule includes an autofix that will automatically wrap your event handler in a `compose()` call with the spread prop's event handler. The autofix is safe to use and will preserve your event handler logic while adding the composing behavior.
+
+Note: You'll need to import `compose` or a similar function in your file if it's not already imported. Common utilities include:
+
+- `compose` from utility libraries
+- `composeEventHandlers` from Radix UI
+- Custom composition utilities from your codebase
diff --git a/src/index.js b/src/index.js
index 89b99bf..3d3bec0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -21,6 +21,8 @@ module.exports = {
'enforce-css-module-default-import': require('./rules/enforce-css-module-default-import'),
'use-styled-react-import': require('./rules/use-styled-react-import'),
'spread-props-first': require('./rules/spread-props-first'),
+ 'merge-spread-props-classname': require('./rules/merge-spread-props-classname'),
+ 'merge-spread-props-event-handlers': require('./rules/merge-spread-props-event-handlers'),
},
configs: {
recommended: require('./configs/recommended'),
diff --git a/src/rules/__tests__/merge-spread-props-classname.test.js b/src/rules/__tests__/merge-spread-props-classname.test.js
new file mode 100644
index 0000000..441a1f0
--- /dev/null
+++ b/src/rules/__tests__/merge-spread-props-classname.test.js
@@ -0,0 +1,107 @@
+const rule = require('../merge-spread-props-classname')
+const {RuleTester} = require('eslint')
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+})
+
+ruleTester.run('merge-spread-props-classname', rule, {
+ valid: [
+ // No spread props
+ ``,
+ // Spread props but no className
+ ``,
+ // className before spread props (spread will override, which is expected)
+ ``,
+ // className already using clsx
+ ``,
+ // className already using classnames
+ ``,
+ // className already using classNames (capital N)
+ ``,
+ // className already using cn
+ ``,
+ // Multiple spreads but no className
+ ``,
+ ],
+ invalid: [
+ // className after spread with string literal
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeClassName',
+ },
+ ],
+ },
+ // className after spread with expression
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeClassName',
+ },
+ ],
+ },
+ // className after spread with template literal
+ {
+ code: '',
+ output: '',
+ errors: [
+ {
+ messageId: 'mergeClassName',
+ },
+ ],
+ },
+ // Multiple spreads with className after first spread
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeClassName',
+ },
+ ],
+ },
+ // className after spread with custom prop name
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeClassName',
+ },
+ ],
+ },
+ // Complex className expression
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeClassName',
+ },
+ ],
+ },
+ // className in the middle of spreads
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeClassName',
+ },
+ ],
+ },
+ ],
+})
diff --git a/src/rules/__tests__/merge-spread-props-event-handlers.test.js b/src/rules/__tests__/merge-spread-props-event-handlers.test.js
new file mode 100644
index 0000000..17c086d
--- /dev/null
+++ b/src/rules/__tests__/merge-spread-props-event-handlers.test.js
@@ -0,0 +1,140 @@
+const rule = require('../merge-spread-props-event-handlers')
+const {RuleTester} = require('eslint')
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+})
+
+ruleTester.run('merge-spread-props-event-handlers', rule, {
+ valid: [
+ // No spread props
+ ` {}} />`,
+ // Spread props but no event handlers
+ ``,
+ // Event handler before spread props (spread will override, which is expected)
+ ` {}} {...rest} />`,
+ // Event handler already using compose
+ ` {})} />`,
+ // Event handler already using composeEventHandlers
+ ``,
+ // Event handler already using composeHandlers
+ ``,
+ // Multiple spreads but no event handlers
+ ``,
+ // Non-event handler props with spread
+ ``,
+ ],
+ invalid: [
+ // onClick after spread with arrow function
+ {
+ code: ` {}} />`,
+ output: ` {})} />`,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // onClick after spread with named function
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // onChange after spread
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // Multiple event handlers after spread
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // Event handler after spread with custom prop name
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // onSubmit after spread
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // onFocus after spread
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // Event handler in the middle of spreads
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // Multiple spreads with event handler after first spread
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ // onKeyDown after spread
+ {
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ messageId: 'mergeEventHandler',
+ },
+ ],
+ },
+ ],
+})
diff --git a/src/rules/merge-spread-props-classname.js b/src/rules/merge-spread-props-classname.js
new file mode 100644
index 0000000..dae2171
--- /dev/null
+++ b/src/rules/merge-spread-props-classname.js
@@ -0,0 +1,108 @@
+module.exports = {
+ meta: {
+ type: 'problem',
+ fixable: 'code',
+ schema: [],
+ messages: {
+ mergeClassName:
+ 'When using spread props, className should be merged with clsx to avoid unintentional overrides. Use: className={{clsx({{spreadPropName}}.className, {{currentValue}})}}',
+ },
+ },
+ create(context) {
+ return {
+ JSXOpeningElement(node) {
+ const attributes = node.attributes
+
+ // Find spread props and className
+ const spreadProps = []
+ let classNameAttr = null
+
+ for (const attr of attributes) {
+ if (attr.type === 'JSXSpreadAttribute') {
+ spreadProps.push(attr)
+ } else if (attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'className') {
+ classNameAttr = attr
+ }
+ }
+
+ // Only report if we have both spread props and className
+ if (spreadProps.length === 0 || !classNameAttr) {
+ return
+ }
+
+ // Check if className comes after spread props
+ const classNameIndex = attributes.indexOf(classNameAttr)
+ const hasSpreadBeforeClassName = spreadProps.some(spread => {
+ return attributes.indexOf(spread) < classNameIndex
+ })
+
+ if (!hasSpreadBeforeClassName) {
+ return
+ }
+
+ // Check if className value is already using clsx or similar merging
+ const classNameValue = classNameAttr.value
+ if (classNameValue && classNameValue.type === 'JSXExpressionContainer') {
+ const expression = classNameValue.expression
+ // Check if it's a call expression with clsx, classnames, or similar
+ if (
+ expression.type === 'CallExpression' &&
+ expression.callee.type === 'Identifier' &&
+ (expression.callee.name === 'clsx' ||
+ expression.callee.name === 'classnames' ||
+ expression.callee.name === 'classNames' ||
+ expression.callee.name === 'cn')
+ ) {
+ // Already using a class merging utility, don't report
+ return
+ }
+ }
+
+ const sourceCode = context.sourceCode
+
+ // Get the name of the spread prop (e.g., "rest", "props", etc.)
+ const firstSpreadBeforeClassName = spreadProps
+ .filter(spread => attributes.indexOf(spread) < classNameIndex)
+ .sort((a, b) => attributes.indexOf(a) - attributes.indexOf(b))[0]
+
+ const spreadArgument = firstSpreadBeforeClassName.argument
+ const spreadPropName = spreadArgument.name || sourceCode.getText(spreadArgument)
+
+ // Get current className value as string
+ let currentValue = sourceCode.getText(classNameAttr.value)
+ if (classNameValue && classNameValue.type === 'JSXExpressionContainer') {
+ currentValue = sourceCode.getText(classNameValue.expression)
+ } else if (classNameValue && classNameValue.type === 'Literal') {
+ currentValue = `"${classNameValue.value}"`
+ }
+
+ // Don't provide a fix if currentValue is empty
+ if (!currentValue) {
+ context.report({
+ node: classNameAttr,
+ messageId: 'mergeClassName',
+ data: {
+ spreadPropName,
+ currentValue: '',
+ },
+ })
+ return
+ }
+
+ context.report({
+ node: classNameAttr,
+ messageId: 'mergeClassName',
+ data: {
+ spreadPropName,
+ currentValue,
+ },
+ fix(fixer) {
+ // Create the merged className expression
+ const mergedClassName = `{clsx(${spreadPropName}.className, ${currentValue})}`
+ return fixer.replaceText(classNameAttr.value, mergedClassName)
+ },
+ })
+ },
+ }
+ },
+}
diff --git a/src/rules/merge-spread-props-event-handlers.js b/src/rules/merge-spread-props-event-handlers.js
new file mode 100644
index 0000000..e77cfb3
--- /dev/null
+++ b/src/rules/merge-spread-props-event-handlers.js
@@ -0,0 +1,180 @@
+module.exports = {
+ meta: {
+ type: 'problem',
+ fixable: 'code',
+ schema: [],
+ messages: {
+ mergeEventHandler:
+ 'When using spread props, event handler {{handlerName}} should be merged with compose to avoid unintentional overrides. Use: {{handlerName}}={{compose({{spreadPropName}}.{{handlerName}}, {{currentHandler}})}}',
+ },
+ },
+ create(context) {
+ // List of common React event handler prop names
+ const eventHandlerNames = [
+ 'onClick',
+ 'onChange',
+ 'onSubmit',
+ 'onFocus',
+ 'onBlur',
+ 'onMouseEnter',
+ 'onMouseLeave',
+ 'onMouseDown',
+ 'onMouseUp',
+ 'onKeyDown',
+ 'onKeyUp',
+ 'onKeyPress',
+ 'onInput',
+ 'onScroll',
+ 'onWheel',
+ 'onDrag',
+ 'onDragEnd',
+ 'onDragEnter',
+ 'onDragExit',
+ 'onDragLeave',
+ 'onDragOver',
+ 'onDragStart',
+ 'onDrop',
+ 'onTouchCancel',
+ 'onTouchEnd',
+ 'onTouchMove',
+ 'onTouchStart',
+ 'onPointerDown',
+ 'onPointerMove',
+ 'onPointerUp',
+ 'onPointerCancel',
+ 'onPointerEnter',
+ 'onPointerLeave',
+ 'onPointerOver',
+ 'onPointerOut',
+ 'onSelect',
+ 'onLoad',
+ 'onError',
+ 'onAbort',
+ 'onCanPlay',
+ 'onCanPlayThrough',
+ 'onDurationChange',
+ 'onEmptied',
+ 'onEncrypted',
+ 'onEnded',
+ 'onLoadedData',
+ 'onLoadedMetadata',
+ 'onLoadStart',
+ 'onPause',
+ 'onPlay',
+ 'onPlaying',
+ 'onProgress',
+ 'onRateChange',
+ 'onSeeked',
+ 'onSeeking',
+ 'onStalled',
+ 'onSuspend',
+ 'onTimeUpdate',
+ 'onVolumeChange',
+ 'onWaiting',
+ ]
+
+ return {
+ JSXOpeningElement(node) {
+ const attributes = node.attributes
+
+ // Find spread props and event handlers
+ const spreadProps = []
+ const eventHandlers = []
+
+ for (const attr of attributes) {
+ if (attr.type === 'JSXSpreadAttribute') {
+ spreadProps.push(attr)
+ } else if (
+ attr.type === 'JSXAttribute' &&
+ attr.name &&
+ attr.name.name &&
+ eventHandlerNames.includes(attr.name.name)
+ ) {
+ eventHandlers.push(attr)
+ }
+ }
+
+ // Only report if we have both spread props and event handlers
+ if (spreadProps.length === 0 || eventHandlers.length === 0) {
+ return
+ }
+
+ const sourceCode = context.sourceCode
+
+ // Check each event handler
+ for (const handler of eventHandlers) {
+ const handlerIndex = attributes.indexOf(handler)
+ const hasSpreadBeforeHandler = spreadProps.some(spread => {
+ return attributes.indexOf(spread) < handlerIndex
+ })
+
+ if (!hasSpreadBeforeHandler) {
+ continue
+ }
+
+ // Check if handler value is already using compose or similar merging
+ const handlerValue = handler.value
+ if (handlerValue && handlerValue.type === 'JSXExpressionContainer') {
+ const expression = handlerValue.expression
+ // Check if it's a call expression with compose or similar
+ if (
+ expression.type === 'CallExpression' &&
+ expression.callee.type === 'Identifier' &&
+ (expression.callee.name === 'compose' ||
+ expression.callee.name === 'composeEventHandlers' ||
+ expression.callee.name === 'composeHandlers')
+ ) {
+ // Already using a handler composition utility, don't report
+ continue
+ }
+ }
+
+ // Get the name of the spread prop (e.g., "rest", "props", etc.)
+ const firstSpreadBeforeHandler = spreadProps
+ .filter(spread => attributes.indexOf(spread) < handlerIndex)
+ .sort((a, b) => attributes.indexOf(a) - attributes.indexOf(b))[0]
+
+ const spreadArgument = firstSpreadBeforeHandler.argument
+ const spreadPropName = spreadArgument.name || sourceCode.getText(spreadArgument)
+
+ // Get current handler value as string
+ let currentHandler = ''
+ if (handlerValue && handlerValue.type === 'JSXExpressionContainer') {
+ currentHandler = sourceCode.getText(handlerValue.expression)
+ }
+
+ const handlerName = handler.name.name
+
+ // Don't provide a fix if handlerValue is null/undefined or currentHandler is empty
+ if (!handlerValue || !currentHandler) {
+ context.report({
+ node: handler,
+ messageId: 'mergeEventHandler',
+ data: {
+ handlerName,
+ spreadPropName,
+ currentHandler: '',
+ },
+ })
+ continue
+ }
+
+ context.report({
+ node: handler,
+ messageId: 'mergeEventHandler',
+ data: {
+ handlerName,
+ spreadPropName,
+ currentHandler,
+ },
+ fix(fixer) {
+ // Create the merged event handler expression
+ const mergedHandler = `{compose(${spreadPropName}.${handlerName}, ${currentHandler})}`
+ return fixer.replaceText(handlerValue, mergedHandler)
+ },
+ })
+ }
+ },
+ }
+ },
+}