From 4dd2d78ad7e5f69ebb33ab1820c597ebd7199cfc Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Mon, 1 Dec 2025 17:31:29 +0800 Subject: [PATCH] Fix readonly type detection for class and interface extends, closes #1326 --- .../src/rules/prefer-read-only-props.spec.ts | 27 +++++++++++++++ .../src/rules/prefer-read-only-props.ts | 33 ++++++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) 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 d01a20fc2..732685fd0 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 @@ -727,5 +727,32 @@ ruleTesterWithTypes.run(RULE_NAME, rule, { } } `, + tsx` + type DeepReadOnly = { + readonly [P in keyof T]: T[P] extends (infer U)[] + ? ReadonlyArray> + : T[P] extends ReadonlyArray + ? ReadonlyArray> + : T[P] extends object + ? DeepReadOnly + : T[P]; + }; + + interface PressableProps { + testID: string; + } + + type ReadonlyPressableProps = DeepReadOnly; + + interface ComponentProps extends ReadonlyPressableProps { + readonly name: string; + } + + export function Component(props: ComponentProps) { + const { name, testID } = props; + + return
{name}
+ } + `, ], }); 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 40e3b1971..28dadb10c 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 @@ -5,6 +5,7 @@ import { ESLintUtils, type ParserServicesWithTypeInformation } from "@typescript 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 { isPropertyReadonlyInType } from "ts-api-utils"; import type ts from "typescript"; import { createRule } from "../utils"; @@ -37,7 +38,9 @@ export default createRule<[], MessageID>({ export function create(context: RuleContext): RuleListener { const services = ESLintUtils.getParserServices(context, false); + const checker = services.program.getTypeChecker(); const { ctx, listeners } = useComponentCollector(context); + return { ...listeners, "Program:exit"(program) { @@ -50,9 +53,11 @@ export function create(context: RuleContext): RuleListener { continue; } const propsType = getConstrainedTypeAtLocation(services, props); - if (isTypeReadonlyLoose(services, propsType)) { - continue; - } + if (isTypeReadonly(services.program, propsType)) continue; + // Handle edge case where isTypeReadonly cant detect some readonly or immutable types + if (isTypeReadonlyLoose(services, propsType)) continue; + // @see https://github.com/Rel1cx/eslint-react/issues/1326 + if (propsType.isClassOrInterface() && isClassOrInterfaceReadonlyLoose(checker, propsType)) continue; context.report({ messageId: "preferReadOnlyProps", node: props }); } }, @@ -60,7 +65,6 @@ 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); @@ -68,3 +72,24 @@ function isTypeReadonlyLoose(services: ParserServicesWithTypeInformation, type: return true; } } + +// TODO: A comprehensive test is required to verify that it works as expected +// @see https://github.com/Rel1cx/eslint-react/issues/1326 +function isClassOrInterfaceReadonlyLoose(checker: ts.TypeChecker, type: ts.Type) { + const baseTypes = type.getBaseTypes() ?? []; + const properties = type.getProperties(); + if (properties.length === 0) { + return true; + } + if (baseTypes.length === 0) { + return properties.every((property) => isPropertyReadonlyInType(type, property.getEscapedName(), checker)); + } + for (const property of properties) { + const propertyName = property.getEscapedName(); + if (isPropertyReadonlyInType(type, propertyName, checker)) continue; + else if (baseTypes.length > 0) { + return baseTypes.every((heritageType) => isPropertyReadonlyInType(heritageType, propertyName, checker)); + } + } + return true; +}