Repository: eslint-stylistic/eslint-stylistic
Pull request #744 (2025-03-28 17:59:49)

feat(jsx-sort-props): support custom reservedFirst and reservedLast
<!-- DO NOT IGNORE THE TEMPLATE!

Thank you for contributing!

Before submitting the PR, please make sure you do the following:

- Read the [Contributing Guide](https://eslint.style/contribute/guide).
- Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
- Provide a description in this PR that addresses **what** the PR is solving, or reference the issue that it solves (e.g. `fixes #123`).
- Ideally, include relevant tests that fail without this PR but pass with it.

-->

### Description

Now reservedFirst option only be limited by ['children', 'dangerouslySetInnerHTML', 'key', 'ref'], but for [vue-jsx-vapor](https://jsx-vapor.netlify.app/features/directives.html) or other framework based on jsx, they maybe have other reserved props or directives, such as `v-if`, `v-for`, `v-slot` ...

So can we support custom reserved props and add a reservedLast option for `v-slot`.
And these props will respect the order specified in the array:

```jsx
// ['error', { reservedFirst: ['v-if', 'v-for'], reservedLast: ['v-slot'] }]

// before
<App a v-for={i in 4} v-if={true} b v-slot={{ foo }} onClick={clickHandle} />

// after
<App v-if={true} v-for={i in 4} a b  onClick={clickHandle} v-slot={{ foo }} />
```

<!-- Please insert your description here and provide especially info about the "what" this PR is solving -->

### Linked Issues

### Additional context

<!-- e.g. is there anything you'd like reviewers to focus on? -->


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/eslint-stylistic/eslint-stylistic/pull/744?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=eslint-stylistic) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 77.23%. Comparing base [(`85eeaa1`)](https://app.codecov.io/gh/eslint-stylistic/eslint-stylistic/commit/85eeaa16f102190f04801a8bfa9675f6638b51a4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=eslint-stylistic) to head [(`4168191`)](https://app.codecov.io/gh/eslint-stylistic/eslint-stylistic/commit/4168191091b970f51983c1b0bf31c21b892ba6a9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=eslint-stylistic).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #744      +/-   ##
==========================================
+ Coverage   77.22%   77.23%   +0.01%     
==========================================
  Files         122      122              
  Lines       23957    23975      +18     
  Branches     4672     4687      +15     
==========================================
+ Hits        18500    18518      +18     
  Misses       5457     5457              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/eslint-stylistic/eslint-stylistic/pull/744?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=eslint-stylistic).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=eslint-stylistic).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
antfu: The test seems failing, could you have a look? Thanks

Patch:
diff --git a/packages/eslint-plugin/rules/jsx-sort-props/README._jsx_.md b/packages/eslint-plugin/rules/jsx-sort-props/README._jsx_.md
index 007f31b7..535236ee 100644
--- a/packages/eslint-plugin/rules/jsx-sort-props/README._jsx_.md
+++ b/packages/eslint-plugin/rules/jsx-sort-props/README._jsx_.md
@@ -120,7 +120,7 @@ When `true`, alphabetical order is **not** enforced:
 
 This can be a boolean or an array option.
 
-When `reservedFirst` is defined, React reserved props (`children`, `dangerouslySetInnerHTML` - **only for DOM components**, `key`, and `ref`) must be listed before all other props, but still respecting the alphabetical order:
+When `reservedFirst` is `true`, the default reserved props are [`children`, `dangerouslySetInnerHTML` (only for DOM components), `key`, `ref`]. These props must be listed before all other props, but still respecting the alphabetical order:
 
 ```jsx
 <Hello key={0} ref={johnRef} name="John">
@@ -128,7 +128,18 @@ When `reservedFirst` is defined, React reserved props (`children`, `dangerouslyS
 </Hello>
 ```
 
-If given as an array, the array's values will override the default list of reserved props. **Note**: the values in the array may only be a **subset** of React reserved props.
+If given as an array, the array's values will override the default list of reserved props.
+These props will respect the order specified in the array:
+
+```jsx
+// 'jsx-sort-props': [1, { reservedFirst: ['v-if', 'v-for'] }]
+
+// before
+<App a v-for={i in list} v-if={list} b />
+
+// after
+<App v-if={list} v-for={i in list} a b />
+```
 
 With `reservedFirst: ["key"]`, the following will **not** warn:
 
@@ -136,6 +147,21 @@ With `reservedFirst: ["key"]`, the following will **not** warn:
 <Hello key={'uuid'} name="John" ref={johnRef} />
 ```
 
+### `reservedLast`
+
+This can be an array option. These props must be listed after all other props.
+These will respect the order specified in the array:
+
+```jsx
+// 'jsx-sort-props': [1, { reservedLast: ['v-slot'] }]
+
+// before
+<App v-slot={{ foo }} onClick={onClick} />
+
+// after
+<App onClick={onClick} v-slot={{ foo }} />
+```
+
 ### `locale`
 
 Defaults to `"auto"`, meaning, the locale of the current environment.
diff --git a/packages/eslint-plugin/rules/jsx-sort-props/jsx-sort-props._jsx_.ts b/packages/eslint-plugin/rules/jsx-sort-props/jsx-sort-props._jsx_.ts
index f4fc2e25..f39a9564 100644
--- a/packages/eslint-plugin/rules/jsx-sort-props/jsx-sort-props._jsx_.ts
+++ b/packages/eslint-plugin/rules/jsx-sort-props/jsx-sort-props._jsx_.ts
@@ -15,8 +15,9 @@ interface JsxCompareOptions {
   shorthandLast: boolean
   multiline: 'first' | 'ignore' | 'last'
   noSortAlphabetically: boolean
-  reservedFirst: boolean | unknown[]
-  reservedList: unknown[]
+  reservedFirst: boolean | string[]
+  reservedList: string[]
+  reservedLast: string[]
   locale: string
 }
 
@@ -29,9 +30,9 @@ function isMultilineProp(node: ASTNode) {
 }
 
 const messages = {
-  noUnreservedProps: 'A customized reserved first list must only contain a subset of React reserved props. Remove: {{unreservedWords}}',
   listIsEmpty: 'A customized reserved first list must not be empty',
   listReservedPropsFirst: 'Reserved props must be listed before all other props',
+  listReservedPropsLast: 'Reserved props must be listed after all other props',
   listCallbacksLast: 'Callbacks must be listed after all other props',
   listShorthandFirst: 'Shorthand props must be listed before all other props',
   listShorthandLast: 'Shorthand props must be listed after all other props',
@@ -47,8 +48,8 @@ const RESERVED_PROPS_LIST = [
   'ref',
 ]
 
-function isReservedPropName(name: unknown, list: unknown[]) {
-  return list.includes(name)
+function getReservedPropIndex(name: string, list: string[]) {
+  return list.indexOf(name.split(':')[0])
 }
 
 let attributeMap: WeakMap<Tree.JSXAttribute, { end: number, hasComment: boolean }>
@@ -62,6 +63,8 @@ function shouldSortToEnd(node: Tree.JSXAttribute) {
 function contextCompare(a: Tree.JSXAttribute, b: Tree.JSXAttribute, options: JsxCompareOptions) {
   let aProp = getPropName(a)
   let bProp = getPropName(b)
+  const aPropNamespace = aProp.split(':')[0]
+  const bPropNamespace = bProp.split(':')[0]
 
   const aSortToEnd = shouldSortToEnd(a)
   const bSortToEnd = shouldSortToEnd(b)
@@ -72,13 +75,29 @@ function contextCompare(a: Tree.JSXAttribute, b: Tree.JSXAttribute, options: Jsx
     return -1
 
   if (options.reservedFirst) {
-    const aIsReserved = isReservedPropName(aProp, options.reservedList)
-    const bIsReserved = isReservedPropName(bProp, options.reservedList)
-    if (aIsReserved && !bIsReserved)
+    const aIndex = getReservedPropIndex(aProp, options.reservedList)
+    const bIndex = getReservedPropIndex(bProp, options.reservedList)
+    if (aIndex > -1 && bIndex === -1)
       return -1
 
-    if (!aIsReserved && bIsReserved)
+    if (aIndex === -1 && bIndex > -1)
       return 1
+
+    if (aIndex > -1 && bIndex > -1 && aPropNamespace !== bPropNamespace)
+      return aIndex > bIndex ? 1 : -1
+  }
+
+  if (options.reservedLast.length > 0) {
+    const aLastIndex = getReservedPropIndex(aProp, options.reservedLast)
+    const bLastIndex = getReservedPropIndex(bProp, options.reservedLast)
+    if (aLastIndex > -1 && bLastIndex === -1)
+      return 1
+
+    if (aLastIndex === -1 && bLastIndex > -1)
+      return -1
+
+    if (aLastIndex > -1 && bLastIndex > -1 && aPropNamespace !== bPropNamespace)
+      return aLastIndex > bLastIndex ? -1 : 1
   }
 
   if (options.callbacksLast) {
@@ -214,7 +233,7 @@ function getGroupsOfSortableAttributes(attributes: (Tree.JSXAttribute | Tree.JSX
   return sortableAttributeGroups
 }
 
-function generateFixerFunction(node: Tree.JSXOpeningElement, context: Readonly<RuleContext<MessageIds, RuleOptions>>, reservedList: unknown[]) {
+function generateFixerFunction(node: Tree.JSXOpeningElement, context: Readonly<RuleContext<MessageIds, RuleOptions>>, reservedList: string[]) {
   const sourceCode = context.sourceCode
   const attributes = node.attributes.slice(0)
   const configuration = context.options[0] || {}
@@ -225,6 +244,7 @@ function generateFixerFunction(node: Tree.JSXOpeningElement, context: Readonly<R
   const multiline = configuration.multiline || 'ignore'
   const noSortAlphabetically = configuration.noSortAlphabetically || false
   const reservedFirst = configuration.reservedFirst || false
+  const reservedLast = configuration.reservedLast || []
   const locale = configuration.locale || 'auto'
 
   // Sort props according to the context. Only supports ignoreCase.
@@ -239,6 +259,7 @@ function generateFixerFunction(node: Tree.JSXOpeningElement, context: Readonly<R
     noSortAlphabetically,
     reservedFirst,
     reservedList,
+    reservedLast,
     locale,
   }
   const sortableAttributeGroups = getGroupsOfSortableAttributes(attributes, context)
@@ -286,12 +307,6 @@ function generateFixerFunction(node: Tree.JSXOpeningElement, context: Readonly<R
 function validateReservedFirstConfig(context: Readonly<RuleContext<MessageIds, RuleOptions>>, reservedFirst: unknown[] | boolean) {
   if (reservedFirst) {
     if (Array.isArray(reservedFirst)) {
-      // Only allow a subset of reserved words in customized lists
-      const nonReservedWords = reservedFirst.filter(word => !isReservedPropName(
-        word,
-        RESERVED_PROPS_LIST,
-      ))
-
       if (reservedFirst.length === 0) {
         return function Report(decl: ASTNode | Tree.Token) {
           context.report({
@@ -300,17 +315,6 @@ function validateReservedFirstConfig(context: Readonly<RuleContext<MessageIds, R
           })
         }
       }
-      if (nonReservedWords.length > 0) {
-        return function Report(decl: ASTNode | Tree.Token) {
-          context.report({
-            node: decl,
-            messageId: 'noUnreservedProps',
-            data: {
-              unreservedWords: nonReservedWords.toString(),
-            },
-          })
-        }
-      }
     }
   }
 }
@@ -326,7 +330,7 @@ const reportedNodeAttributes = new WeakMap()
  * @param context The context of the rule
  * @param reservedList The list of reserved props
  */
-function reportNodeAttribute(nodeAttribute: Tree.JSXAttribute | Tree.JSXSpreadAttribute, errorType: MessageIds, node: Tree.JSXOpeningElement, context: Readonly<RuleContext<MessageIds, RuleOptions>>, reservedList: unknown[]) {
+function reportNodeAttribute(nodeAttribute: Tree.JSXAttribute | Tree.JSXSpreadAttribute, errorType: MessageIds, node: Tree.JSXOpeningElement, context: Readonly<RuleContext<MessageIds, RuleOptions>>, reservedList: string[]) {
   const errors = reportedNodeAttributes.get(nodeAttribute) || []
 
   if (errors.includes(errorType))
@@ -388,6 +392,9 @@ export default createRule<RuleOptions, MessageIds>({
         reservedFirst: {
           type: ['array', 'boolean'],
         },
+        reservedLast: {
+          type: 'array',
+        },
         locale: {
           type: 'string',
           default: 'auto',
@@ -408,6 +415,7 @@ export default createRule<RuleOptions, MessageIds>({
     const reservedFirst = configuration.reservedFirst || false
     const reservedFirstError = validateReservedFirstConfig(context, reservedFirst)
     const reservedList = Array.isArray(reservedFirst) ? reservedFirst : RESERVED_PROPS_LIST
+    const reservedLastList = configuration.reservedLast || []
     const locale = configuration.locale || 'auto'
 
     return {
@@ -425,6 +433,8 @@ export default createRule<RuleOptions, MessageIds>({
 
           let previousPropName = getPropName(memo)
           let currentPropName = getPropName(decl)
+          const previousReservedNamespace = previousPropName.split(':')[0]
+          const currentReservedNamespace = currentPropName.split(':')[0]
           const previousValue = (<Tree.JSXAttribute>memo).value
           const currentValue = decl.value
           const previousIsCallback = isCallbackPropName(previousPropName)
@@ -441,17 +451,52 @@ export default createRule<RuleOptions, MessageIds>({
               return memo
             }
 
-            const previousIsReserved = isReservedPropName(previousPropName, nodeReservedList)
-            const currentIsReserved = isReservedPropName(currentPropName, nodeReservedList)
+            const previousReservedIndex = getReservedPropIndex(previousPropName, nodeReservedList)
+            const currentReservedIndex = getReservedPropIndex(currentPropName, nodeReservedList)
 
-            if (previousIsReserved && !currentIsReserved)
+            if (previousReservedIndex > -1 && currentReservedIndex === -1)
               return decl
 
-            if (!previousIsReserved && currentIsReserved) {
+            if (
+              reservedFirst !== true && previousReservedIndex > currentReservedIndex
+              || previousReservedIndex === -1 && currentReservedIndex > -1
+            ) {
               reportNodeAttribute(decl, 'listReservedPropsFirst', node, context, nodeReservedList)
 
               return memo
             }
+
+            if (
+              previousReservedIndex > -1
+              && currentReservedIndex > -1
+              && currentReservedIndex > previousReservedIndex
+              && previousReservedNamespace !== currentReservedNamespace
+            ) {
+              return decl
+            }
+          }
+
+          if (reservedLastList.length > 0) {
+            const previousReservedIndex = getReservedPropIndex(previousPropName, reservedLastList)
+            const currentReservedIndex = getReservedPropIndex(currentPropName, reservedLastList)
+
+            if (previousReservedIndex === -1 && currentReservedIndex > -1)
+              return decl
+
+            if (previousReservedIndex < currentReservedIndex || previousReservedIndex > -1 && currentReservedIndex === -1) {
+              reportNodeAttribute(decl, 'listReservedPropsLast', node, context, nodeReservedList)
+
+              return memo
+            }
+
+            if (
+              previousReservedIndex > -1
+              && currentReservedIndex > -1
+              && currentReservedIndex > previousReservedIndex
+              && previousReservedNamespace !== currentReservedNamespace
+            ) {
+              return decl
+            }
           }
 
           if (callbacksLast) {
diff --git a/packages/eslint-plugin/rules/jsx-sort-props/types.d.ts b/packages/eslint-plugin/rules/jsx-sort-props/types.d.ts
index 71e659b6..b1a283d9 100644
--- a/packages/eslint-plugin/rules/jsx-sort-props/types.d.ts
+++ b/packages/eslint-plugin/rules/jsx-sort-props/types.d.ts
@@ -9,7 +9,8 @@ export interface JsxSortPropsSchema0 {
   multiline?: 'ignore' | 'first' | 'last'
   ignoreCase?: boolean
   noSortAlphabetically?: boolean
-  reservedFirst?: unknown[] | boolean
+  reservedFirst?: string[] | boolean
+  reservedLast?: string[]
   locale?: string
 }
 
@@ -17,9 +18,9 @@ export type JsxSortPropsRuleOptions = [JsxSortPropsSchema0?]
 
 export type RuleOptions = JsxSortPropsRuleOptions
 export type MessageIds =
-  | 'noUnreservedProps'
   | 'listIsEmpty'
   | 'listReservedPropsFirst'
+  | 'listReservedPropsLast'
   | 'listCallbacksLast'
   | 'listShorthandFirst'
   | 'listShorthandLast'


Test patch:
diff --git a/packages/eslint-plugin/rules/jsx-sort-props/jsx-sort-props._jsx_.test.ts b/packages/eslint-plugin/rules/jsx-sort-props/jsx-sort-props._jsx_.test.ts
index 4f2b6a50..45aac6a1 100644
--- a/packages/eslint-plugin/rules/jsx-sort-props/jsx-sort-props._jsx_.test.ts
+++ b/packages/eslint-plugin/rules/jsx-sort-props/jsx-sort-props._jsx_.test.ts
@@ -13,6 +13,10 @@ const expectedError: TestCaseError<MessageIds> = {
   messageId: 'sortPropsByAlpha',
   type: 'JSXIdentifier',
 }
+const expectedWithNamespaceError: TestCaseError<MessageIds> = {
+  messageId: 'sortPropsByAlpha',
+  nodeType: 'JSXNamespacedName',
+}
 const expectedCallbackError: TestCaseError<MessageIds> = {
   messageId: 'listCallbacksLast',
   type: 'JSXIdentifier',
@@ -37,13 +41,17 @@ const expectedReservedFirstError: TestCaseError<MessageIds> = {
   messageId: 'listReservedPropsFirst',
   type: 'JSXIdentifier',
 }
+const expectedReservedLastError: TestCaseError<MessageIds> = {
+  messageId: 'listReservedPropsLast',
+  type: 'JSXIdentifier',
+}
+const expectedReservedFirstWithNamespaceError: TestCaseError<MessageIds> = {
+  messageId: 'listReservedPropsFirst',
+  type: 'JSXNamespacedName',
+}
 const expectedEmptyReservedFirstError: TestCaseError<MessageIds> = {
   messageId: 'listIsEmpty',
 }
-const expectedInvalidReservedFirstError: TestCaseError<MessageIds> = {
-  messageId: 'noUnreservedProps',
-  data: { unreservedWords: 'notReserved' },
-}
 const callbacksLastArgs: RuleOptions = [{ callbacksLast: true }]
 const ignoreCaseAndCallbackLastArgs: RuleOptions = [
   {
@@ -83,7 +91,6 @@ const reservedFirstWithShorthandLast: RuleOptions = [
   },
 ]
 const reservedFirstAsEmptyArrayArgs: RuleOptions = [{ reservedFirst: [] }]
-const reservedFirstAsInvalidArrayArgs: RuleOptions = [{ reservedFirst: ['notReserved'] }]
 const multilineFirstArgs: RuleOptions = [{ multiline: 'first' }]
 const multilineAndShorthandFirstArgs: RuleOptions = [
   {
@@ -510,6 +517,30 @@ run<RuleOptions, MessageIds>({
       options: reservedFirstWithShorthandLast,
       output: '<App key="key" ref="ref" veryLastAttribute="yes" isShorthand />',
     },
+    {
+      code: '<App a v-for={i in 4} v-if={true} b />',
+      errors: [expectedReservedFirstError, expectedReservedFirstError],
+      options: [{ reservedFirst: ['v-if', 'v-for'] }],
+      output: '<App v-if={true} v-for={i in 4} a b />',
+    },
+    {
+      code: '<App v-slot={{ foo }} v-slots={{}} onClick={() => {}} />',
+      errors: [expectedReservedLastError, expectedReservedLastError],
+      options: [{ reservedLast: ['v-slot', 'v-slots'] }],
+      output: '<App onClick={() => {}} v-slots={{}} v-slot={{ foo }} />',
+    },
+    {
+      code: '<App a v-model:b={foo} b v-model={foo} c v-slot:b={{ foo }} v-slot:a={{ foo }} onClick={() => {}} />',
+      errors: [expectedReservedFirstWithNamespaceError, expectedReservedFirstError, expectedWithNamespaceError, expectedReservedLastError],
+      options: [{ reservedFirst: ['v-model'], reservedLast: ['v-slot'] }],
+      output: '<App v-model={foo} v-model:b={foo} a b c onClick={() => {}} v-slot:a={{ foo }} v-slot:b={{ foo }} />',
+    },
+    {
+      code: '<App a v-model:b={foo} b v-model={foo} c v-slot:b={{ foo }} v-slot:a={{ foo }} onClick={() => {}} />',
+      errors: [expectedReservedFirstWithNamespaceError, expectedReservedFirstError, expectedReservedLastError],
+      options: [{ noSortAlphabetically: true, reservedFirst: ['v-model'], reservedLast: ['v-slot'] }],
+      output: '<App v-model:b={foo} v-model={foo} a b c onClick={() => {}} v-slot:b={{ foo }} v-slot:a={{ foo }} />',
+    },
     {
       code: '<App a z onFoo onBar />;',
       errors: [expectedError],
@@ -592,7 +623,7 @@ run<RuleOptions, MessageIds>({
     {
       code: '<App key={3} children={<App />} />',
       options: reservedFirstAsArrayArgs,
-      errors: [expectedError],
+      errors: [expectedReservedFirstError],
       output: '<App children={<App />} key={3} />',
     },
     {
@@ -606,11 +637,6 @@ run<RuleOptions, MessageIds>({
       options: reservedFirstAsEmptyArrayArgs,
       errors: [expectedEmptyReservedFirstError],
     },
-    {
-      code: '<App key={5} />',
-      options: reservedFirstAsInvalidArrayArgs,
-      errors: [expectedInvalidReservedFirstError],
-    },
     {
       code: '<App onBar z />;',
       output: '<App z onBar />;',