diff --git a/packages/plugins/eslint-plugin-react-x/package.json b/packages/plugins/eslint-plugin-react-x/package.json index 1a07de4b5f..236524815e 100644 --- a/packages/plugins/eslint-plugin-react-x/package.json +++ b/packages/plugins/eslint-plugin-react-x/package.json @@ -59,6 +59,7 @@ "@typescript-eslint/types": "^8.27.0", "@typescript-eslint/utils": "^8.27.0", "compare-versions": "^6.1.1", + "is-immutable-type": "^5.0.1", "string-ts": "^2.2.1", "ts-pattern": "^5.6.2" }, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.spec.ts index 7643093caa..4cf6bf6727 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.spec.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.spec.ts @@ -428,6 +428,29 @@ ruleTesterWithTypes.run(RULE_NAME, rule, { /// /// + import { useState } from 'react'; + import './App.css'; + + export default function App( + props: Readonly> + ) { + const [count, setCount] = useState(0); + + return ( + <> +
+ +
+ + ); + } + `, + tsx` + /// + /// + import * as React from "react"; type DeepReadonly = Readonly<{[K in keyof T]: T[K] extends (number | string | symbol) ? Readonly : T[K] extends Array ? Readonly>> : DeepReadonly;}>; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts index f40ca2703b..08b137b1c7 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts @@ -1,9 +1,11 @@ import { useComponentCollector } from "@eslint-react/core"; import type { RuleContext, RuleFeature } from "@eslint-react/shared"; import { getConstrainedTypeAtLocation, isTypeReadonly } from "@typescript-eslint/type-utils"; -import { ESLintUtils } from "@typescript-eslint/utils"; +import { ESLintUtils, type ParserServicesWithTypeInformation } from "@typescript-eslint/utils"; import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import { getTypeImmutability, isImmutable, isReadonlyDeep, isReadonlyShallow, isUnknown } from "is-immutable-type"; import type { CamelCase } from "string-ts"; +import type ts from "typescript"; import { createRule } from "../utils"; @@ -45,7 +47,7 @@ export function create(context: RuleContext): RuleListener { continue; } const propsType = getConstrainedTypeAtLocation(services, props); - if (isTypeReadonly(services.program, propsType)) { + if (isTypeReadonlyLoose(services, propsType)) { continue; } context.report({ messageId: "preferReadOnlyProps", node: props }); @@ -53,3 +55,13 @@ export function create(context: RuleContext): RuleListener { }, }; } + +function isTypeReadonlyLoose(services: ParserServicesWithTypeInformation, type: ts.Type): boolean { + if (isTypeReadonly(services.program, type)) return true; + try { + const im = getTypeImmutability(services.program, type); + return isUnknown(im) || isImmutable(im) || isReadonlyShallow(im) || isReadonlyDeep(im); + } catch { + return true; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 001cc74723..2861f22475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1115,6 +1115,9 @@ importers: eslint: specifier: ^8.57.0 || ^9.0.0 version: 9.23.0(jiti@2.4.2) + is-immutable-type: + specifier: ^5.0.1 + version: 5.0.1(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2) string-ts: specifier: ^2.2.1 version: 2.2.1 @@ -1435,7 +1438,6 @@ packages: '@babel/plugin-proposal-private-methods@7.18.6': resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} engines: {node: '>=6.9.0'} - deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead. peerDependencies: '@babel/core': ^7.0.0-0 @@ -5107,6 +5109,12 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-immutable-type@5.0.1: + resolution: {integrity: sha512-LkHEOGVZZXxGl8vDs+10k3DvP++SEoYEAJLRk6buTFi6kD7QekThV7xHS0j6gpnUCQ0zpud/gMDGiV4dQneLTg==} + peerDependencies: + eslint: '*' + typescript: ^5.8.2 + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -5401,7 +5409,6 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} - deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -6619,6 +6626,11 @@ packages: resolution: {integrity: sha512-LcM3W5HEyzTaXUeQITV8ploUOGe+zuuoFYsCfPscFLhx3bZn2sSfHMKxsULVG/zA7an9UhReiHv4Kk/6QzlpXQ==} engines: {node: '>=18.0.0'} + ts-declaration-location@1.0.6: + resolution: {integrity: sha512-QwtM5UZ8S/NpDvSx4u2EHJgLx2+we7qN8sgyOia4nTpJlke3NO1s1Eb2ea/8IFlkc60b71SILGqWBzGGDnNeSw==} + peerDependencies: + typescript: ^5.8.2 + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -11107,6 +11119,16 @@ snapshots: is-hexadecimal@2.0.1: {} + is-immutable-type@5.0.1(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2): + dependencies: + '@typescript-eslint/type-utils': 8.27.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2) + eslint: 9.23.0(jiti@2.4.2) + ts-api-utils: 2.1.0(typescript@5.8.2) + ts-declaration-location: 1.0.6(typescript@5.8.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -13007,6 +13029,11 @@ snapshots: dependencies: typescript: 5.8.2 + ts-declaration-location@1.0.6(typescript@5.8.2): + dependencies: + minimatch: 9.0.5 + typescript: 5.8.2 + ts-interface-checker@0.1.13: {} ts-morph@25.0.1: