diff --git a/docs/rules/no-unmerged-classname.md b/docs/rules/no-unmerged-classname.md
new file mode 100644
index 0000000..02c253f
--- /dev/null
+++ b/docs/rules/no-unmerged-classname.md
@@ -0,0 +1,75 @@
+# Ensure className is merged with spread props (no-unmerged-classname)
+
+When using spread props (`{...rest}`, `{...props}`, etc.) along with a `className` prop, you should merge the className from the spread props with your custom className to avoid unintentionally overriding classes.
+
+## Rule details
+
+This rule warns when a component has spread props before a `className` prop, but the `className` doesn't appear to be merging values using a utility like `clsx()` or `classNames()`.
+
+👎 Examples of **incorrect** code for this rule:
+
+```jsx
+/* eslint primer-react/no-unmerged-classname: "error" */
+
+// ❌ className may override className from rest
+
+
+// ❌ className expression doesn't merge
+
+
+// ❌ Template literal doesn't merge with rest
+
+```
+
+👍 Examples of **correct** code for this rule:
+
+```jsx
+/* eslint primer-react/no-unmerged-classname: "error" */
+
+// ✅ Using clsx to merge className from rest
+
+
+// ✅ Using classNames to merge
+
+
+// ✅ Using cn utility to merge
+
+
+// ✅ className before spread (spread will override)
+
+
+// ✅ No spread props
+
+```
+
+## Why this matters
+
+When you spread props and then specify a className, you might be overriding a className that was passed in through the spread:
+
+```jsx
+// ❌ Bad: className from rest gets lost
+function MyComponent({className, ...rest}) {
+ return
+ // If rest contains { className: "important-class" }
+ // Result: className="my-custom-class" (important-class is lost!)
+}
+
+// ✅ Good: className from rest is preserved and merged
+function MyComponent({className, ...rest}) {
+ return
+ // If rest contains { className: "important-class" }
+ // Result: className="important-class my-custom-class" (both preserved!)
+}
+```
+
+## Options
+
+This rule has no configuration options.
+
+## When to use this rule
+
+Use this rule when your components accept and spread props that might contain a `className`. This is common in component libraries and wrapper components.
+
+## Related Rules
+
+- [spread-props-first](./spread-props-first.md) - Ensures spread props come before named props
diff --git a/docs/rules/no-unmerged-event-handler.md b/docs/rules/no-unmerged-event-handler.md
new file mode 100644
index 0000000..0bb4cc1
--- /dev/null
+++ b/docs/rules/no-unmerged-event-handler.md
@@ -0,0 +1,87 @@
+# Ensure event handlers are merged with spread props (no-unmerged-event-handler)
+
+When using spread props (`{...rest}`, `{...props}`, etc.) along with event handler props (like `onClick`, `onChange`, etc.), you should merge the event handler from the spread props with your custom handler to avoid unintentionally overriding event handlers.
+
+## Rule details
+
+This rule warns when a component has spread props before an event handler prop, but the event handler doesn't appear to be merging handlers using a utility like `compose()` or `composeEventHandlers()`.
+
+👎 Examples of **incorrect** code for this rule:
+
+```jsx
+/* eslint primer-react/no-unmerged-event-handler: "error" */
+
+// ❌ onClick may override onClick from rest
+
+
+// ❌ Arrow function doesn't merge
+ {}} />
+
+// ❌ onChange expression doesn't merge
+
+```
+
+👍 Examples of **correct** code for this rule:
+
+```jsx
+/* eslint primer-react/no-unmerged-event-handler: "error" */
+
+// ✅ Using compose to merge onClick from rest
+
+
+// ✅ Using composeEventHandlers to merge
+
+
+// ✅ Event handler before spread (spread will override)
+
+
+// ✅ No spread props
+
+```
+
+## Why this matters
+
+When you spread props and then specify an event handler, you might be overriding an event handler that was passed in through the spread:
+
+```jsx
+// ❌ Bad: onClick from rest gets lost
+function MyComponent({onClick, ...rest}) {
+ return console.log('clicked')} />
+ // If rest contains { onClick: importantHandler }
+ // Result: importantHandler never gets called!
+}
+
+// ✅ Good: onClick from rest is preserved and composed
+function MyComponent({onClick, ...rest}) {
+ return console.log('clicked'))} />
+ // If rest contains { onClick: importantHandler }
+ // Result: Both importantHandler and the log function get called!
+}
+```
+
+## Detected Event Handlers
+
+This rule checks for the following event handler props:
+
+- Mouse events: `onClick`, `onDoubleClick`, `onContextMenu`, `onMouseDown`, `onMouseUp`, `onMouseEnter`, `onMouseLeave`, `onMouseMove`, `onMouseOver`, `onMouseOut`
+- Touch events: `onTouchStart`, `onTouchEnd`, `onTouchMove`, `onTouchCancel`
+- Keyboard events: `onKeyDown`, `onKeyUp`, `onKeyPress`
+- Form events: `onChange`, `onSubmit`, `onInput`, `onInvalid`, `onSelect`
+- Focus events: `onFocus`, `onBlur`
+- Drag events: `onDrag`, `onDragEnd`, `onDragEnter`, `onDragExit`, `onDragLeave`, `onDragOver`, `onDragStart`, `onDrop`
+- Other events: `onScroll`, `onWheel`, `onLoad`, `onError`, `onAbort`
+- Media events: `onCanPlay`, `onCanPlayThrough`, `onDurationChange`, `onEmptied`, `onEncrypted`, `onEnded`, `onLoadedData`, `onLoadedMetadata`, `onLoadStart`, `onPause`, `onPlay`, `onPlaying`, `onProgress`, `onRateChange`, `onSeeked`, `onSeeking`, `onStalled`, `onSuspend`, `onTimeUpdate`, `onVolumeChange`, `onWaiting`
+- Animation/Transition events: `onAnimationStart`, `onAnimationEnd`, `onAnimationIteration`, `onTransitionEnd`
+
+## Options
+
+This rule has no configuration options.
+
+## When to use this rule
+
+Use this rule when your components accept and spread props that might contain event handlers. This is common in component libraries and wrapper components.
+
+## Related Rules
+
+- [spread-props-first](./spread-props-first.md) - Ensures spread props come before named props
+- [no-unmerged-classname](./no-unmerged-classname.md) - Ensures className is merged with spread props
diff --git a/src/index.js b/src/index.js
index 89b99bf..921921a 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'),
+ 'no-unmerged-classname': require('./rules/no-unmerged-classname'),
+ 'no-unmerged-event-handler': require('./rules/no-unmerged-event-handler'),
},
configs: {
recommended: require('./configs/recommended'),
diff --git a/src/rules/__tests__/no-unmerged-classname.test.js b/src/rules/__tests__/no-unmerged-classname.test.js
new file mode 100644
index 0000000..4b6aba2
--- /dev/null
+++ b/src/rules/__tests__/no-unmerged-classname.test.js
@@ -0,0 +1,71 @@
+const rule = require('../no-unmerged-classname')
+const {RuleTester} = require('eslint')
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+})
+
+ruleTester.run('no-unmerged-classname', rule, {
+ valid: [
+ // No spread props - OK
+ ` `,
+ // Spread but no className - OK
+ ` `,
+ // className before spread (spread overrides) - OK
+ ` `,
+ // className after spread with clsx - OK
+ ` `,
+ // className after spread with classNames - OK
+ ` `,
+ // className after spread with cn - OK
+ ` `,
+ // Multiple spreads but className with clsx - OK
+ ` `,
+ ],
+ invalid: [
+ // className after spread without merging - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedClassName',
+ },
+ ],
+ },
+ // className after spread with expression but not merging function - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedClassName',
+ },
+ ],
+ },
+ // className after spread with template literal - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedClassName',
+ },
+ ],
+ },
+ // Multiple spreads with className not merged - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedClassName',
+ },
+ ],
+ },
+ ],
+})
diff --git a/src/rules/__tests__/no-unmerged-event-handler.test.js b/src/rules/__tests__/no-unmerged-event-handler.test.js
new file mode 100644
index 0000000..241a06f
--- /dev/null
+++ b/src/rules/__tests__/no-unmerged-event-handler.test.js
@@ -0,0 +1,99 @@
+const rule = require('../no-unmerged-event-handler')
+const {RuleTester} = require('eslint')
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+})
+
+ruleTester.run('no-unmerged-event-handler', rule, {
+ valid: [
+ // No spread props - OK
+ ` `,
+ // Spread but no event handler - OK
+ ` `,
+ // Event handler before spread (spread overrides) - OK
+ ` `,
+ // Event handler after spread with compose - OK
+ ` `,
+ // Event handler after spread with composeEventHandlers - OK
+ ` `,
+ // Multiple spreads but event handler with compose - OK
+ ` `,
+ // Different event handlers with compose - OK
+ ` `,
+ ],
+ invalid: [
+ // onClick after spread without merging - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedEventHandler',
+ data: {handlerName: 'onClick'},
+ },
+ ],
+ },
+ // onClick after spread with arrow function - PROBLEM
+ {
+ code: ` {}} />`,
+ errors: [
+ {
+ messageId: 'noUnmergedEventHandler',
+ data: {handlerName: 'onClick'},
+ },
+ ],
+ },
+ // onChange after spread - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedEventHandler',
+ data: {handlerName: 'onChange'},
+ },
+ ],
+ },
+ // Multiple event handlers not merged - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedEventHandler',
+ data: {handlerName: 'onClick'},
+ },
+ {
+ messageId: 'noUnmergedEventHandler',
+ data: {handlerName: 'onFocus'},
+ },
+ ],
+ },
+ // onSubmit after spread - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedEventHandler',
+ data: {handlerName: 'onSubmit'},
+ },
+ ],
+ },
+ // Multiple spreads with event handler not merged - PROBLEM
+ {
+ code: ` `,
+ errors: [
+ {
+ messageId: 'noUnmergedEventHandler',
+ data: {handlerName: 'onClick'},
+ },
+ ],
+ },
+ ],
+})
diff --git a/src/rules/no-unmerged-classname.js b/src/rules/no-unmerged-classname.js
new file mode 100644
index 0000000..b70bda4
--- /dev/null
+++ b/src/rules/no-unmerged-classname.js
@@ -0,0 +1,70 @@
+module.exports = {
+ meta: {
+ type: 'problem',
+ schema: [],
+ messages: {
+ noUnmergedClassName:
+ 'className may not be merged correctly with spread props. Consider using clsx(className, "...") to merge className from spread props.',
+ },
+ },
+ create(context) {
+ return {
+ JSXOpeningElement(node) {
+ const attributes = node.attributes
+
+ // Check if there's a spread attribute
+ const hasSpreadAttribute = attributes.some(attr => attr.type === 'JSXSpreadAttribute')
+ if (!hasSpreadAttribute) {
+ return
+ }
+
+ // Check if there's a className attribute
+ const classNameAttr = attributes.find(
+ attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'className',
+ )
+ if (!classNameAttr) {
+ return
+ }
+
+ // Check if className comes after any spread attribute
+ const classNameIndex = attributes.findIndex(attr => attr === classNameAttr)
+ const hasSpreadBeforeClassName = attributes
+ .slice(0, classNameIndex)
+ .some(attr => attr.type === 'JSXSpreadAttribute')
+
+ if (hasSpreadBeforeClassName) {
+ // className comes after spread, so it would override - this is OK
+ // But we need to check if the className value is merging with the spread props
+ if (classNameAttr.value && classNameAttr.value.type === 'JSXExpressionContainer') {
+ const expression = classNameAttr.value.expression
+
+ // Check if it's a call to clsx or similar merging function
+ if (expression.type === 'CallExpression') {
+ const callee = expression.callee
+ // If it's calling clsx, classNames, or similar, assume it's merging correctly
+ if (
+ callee.type === 'Identifier' &&
+ (callee.name === 'clsx' || callee.name === 'classNames' || callee.name === 'cn')
+ ) {
+ return // This is likely merging correctly
+ }
+ }
+ }
+
+ // Check if className is a simple string literal (not merging)
+ if (
+ classNameAttr.value &&
+ (classNameAttr.value.type === 'Literal' ||
+ (classNameAttr.value.type === 'JSXExpressionContainer' &&
+ classNameAttr.value.expression.type !== 'CallExpression'))
+ ) {
+ context.report({
+ node: classNameAttr,
+ messageId: 'noUnmergedClassName',
+ })
+ }
+ }
+ },
+ }
+ },
+}
diff --git a/src/rules/no-unmerged-event-handler.js b/src/rules/no-unmerged-event-handler.js
new file mode 100644
index 0000000..2a16027
--- /dev/null
+++ b/src/rules/no-unmerged-event-handler.js
@@ -0,0 +1,137 @@
+// Common event handler prop names
+const EVENT_HANDLER_PROPS = [
+ 'onClick',
+ 'onChange',
+ 'onSubmit',
+ 'onFocus',
+ 'onBlur',
+ 'onKeyDown',
+ 'onKeyUp',
+ 'onKeyPress',
+ 'onMouseDown',
+ 'onMouseUp',
+ 'onMouseEnter',
+ 'onMouseLeave',
+ 'onMouseMove',
+ 'onMouseOver',
+ 'onMouseOut',
+ 'onTouchStart',
+ 'onTouchEnd',
+ 'onTouchMove',
+ 'onTouchCancel',
+ 'onScroll',
+ 'onWheel',
+ 'onDrag',
+ 'onDragEnd',
+ 'onDragEnter',
+ 'onDragExit',
+ 'onDragLeave',
+ 'onDragOver',
+ 'onDragStart',
+ 'onDrop',
+ 'onInput',
+ 'onInvalid',
+ 'onSelect',
+ 'onContextMenu',
+ 'onDoubleClick',
+ 'onAnimationStart',
+ 'onAnimationEnd',
+ 'onAnimationIteration',
+ 'onTransitionEnd',
+ 'onLoad',
+ 'onError',
+ 'onAbort',
+ 'onCanPlay',
+ 'onCanPlayThrough',
+ 'onDurationChange',
+ 'onEmptied',
+ 'onEncrypted',
+ 'onEnded',
+ 'onLoadedData',
+ 'onLoadedMetadata',
+ 'onLoadStart',
+ 'onPause',
+ 'onPlay',
+ 'onPlaying',
+ 'onProgress',
+ 'onRateChange',
+ 'onSeeked',
+ 'onSeeking',
+ 'onStalled',
+ 'onSuspend',
+ 'onTimeUpdate',
+ 'onVolumeChange',
+ 'onWaiting',
+]
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ schema: [],
+ messages: {
+ noUnmergedEventHandler:
+ 'Event handler {{handlerName}} may not be merged correctly with spread props. Consider using a compose function like compose({{handlerName}}, ...) to merge event handlers from spread props.',
+ },
+ },
+ create(context) {
+ return {
+ JSXOpeningElement(node) {
+ const attributes = node.attributes
+
+ // Check if there's a spread attribute
+ const hasSpreadAttribute = attributes.some(attr => attr.type === 'JSXSpreadAttribute')
+ if (!hasSpreadAttribute) {
+ return
+ }
+
+ // Check for event handler attributes
+ const eventHandlers = attributes.filter(
+ attr =>
+ attr.type === 'JSXAttribute' && attr.name && attr.name.name && EVENT_HANDLER_PROPS.includes(attr.name.name),
+ )
+
+ if (eventHandlers.length === 0) {
+ return
+ }
+
+ // Check each event handler
+ for (const handler of eventHandlers) {
+ const handlerIndex = attributes.findIndex(attr => attr === handler)
+
+ // Check if there's any spread attribute before this event handler
+ const hasSpreadBeforeHandler = attributes
+ .slice(0, handlerIndex)
+ .some(attr => attr.type === 'JSXSpreadAttribute')
+
+ if (hasSpreadBeforeHandler) {
+ // Event handler comes after spread, check if it's merging properly
+ if (handler.value && handler.value.type === 'JSXExpressionContainer') {
+ const expression = handler.value.expression
+
+ // Check if it's a call to compose or similar merging function
+ if (expression.type === 'CallExpression') {
+ const callee = expression.callee
+ // If it's calling compose or similar, assume it's merging correctly
+ if (
+ callee.type === 'Identifier' &&
+ (callee.name === 'compose' || callee.name === 'composeEventHandlers')
+ ) {
+ continue // This is likely merging correctly
+ }
+ }
+ }
+
+ // Event handler is not merging - report it
+ context.report({
+ node: handler,
+ messageId: 'noUnmergedEventHandler',
+ data: {
+ handlerName: handler.name.name,
+ },
+ })
+ }
+ }
+ },
+ }
+ },
+}