diff --git a/.changeset/fix-namespace-imports.md b/.changeset/fix-namespace-imports.md new file mode 100644 index 0000000..cf26b6b --- /dev/null +++ b/.changeset/fix-namespace-imports.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-react-render-types": patch +--- + +Fix namespace import support in @renders validation. Components accessed via `import * as NS from '...'` (e.g., ``) are now correctly validated by `valid-render-prop` and `valid-renders-jsdoc` rules. diff --git a/src/rules/valid-renders-jsdoc.ts b/src/rules/valid-renders-jsdoc.ts index 2b2a6db..9edcb57 100644 --- a/src/rules/valid-renders-jsdoc.ts +++ b/src/rules/valid-renders-jsdoc.ts @@ -154,12 +154,15 @@ export default createRule<[], MessageIds>({ return true; } - // For namespaced components like Menu.Item, check if the base (Menu) is available + // For namespaced components like Menu.Item, check if the full path resolves + // via the type checker. Simply checking the base name (Menu) is insufficient + // because it wouldn't catch typos like Menu.Itme. if (name.includes(".")) { - const baseName = name.split(".")[0]; - if (localComponents.has(baseName) || importedIdentifiers.has(baseName)) { + const typeId = crossFileResolver.getComponentTypeId(name); + if (typeId) { return true; } + // Fall through to the type-based resolution below as a last resort } // Use type-based resolution to check if the component is resolvable diff --git a/src/utils/cross-file-resolver.ts b/src/utils/cross-file-resolver.ts index 2e3ced2..9281312 100644 --- a/src/utils/cross-file-resolver.ts +++ b/src/utils/cross-file-resolver.ts @@ -257,6 +257,31 @@ export function createCrossFileResolver(options: CrossFileResolverOptions) { }); } } + + // Namespace imports: import * as NS from '...' + // Enumerate exported members as "NS.Member" entries + if (namedBindings && ts.isNamespaceImport(namedBindings)) { + const nsName = namedBindings.name.text; + const nsSymbol = typeChecker.resolveName( + nsName, + sourceFile, + ts.SymbolFlags.Value | ts.SymbolFlags.Alias, + /* excludeGlobals */ false + ); + if (nsSymbol) { + const nsType = typeChecker.getTypeOfSymbol(nsSymbol); + for (const prop of nsType.getProperties()) { + const memberName = prop.getName(); + // Only include PascalCase names (component-like) + if (/^[A-Z]/.test(memberName)) { + imports.set(`${nsName}.${memberName}`, { + importDeclaration: node, + originalName: memberName, + }); + } + } + } + } }); return imports; @@ -603,12 +628,32 @@ export function createCrossFileResolver(options: CrossFileResolverOptions) { // resolveImportSourceFile, because barrel/re-export files don't have the // annotation targets as local names in scope. let scopeNode: ts.Node = currentSourceFile; - const importedSymbol = typeChecker.resolveName( - localName, + + // Handle dotted names (e.g., "Components.NavItems" from namespace imports) + // by splitting on "." and resolving the base, then traversing members + const nameParts = localName.split("."); + const baseName = nameParts[0]; + let importedSymbol = typeChecker.resolveName( + baseName, currentSourceFile, ts.SymbolFlags.Value | ts.SymbolFlags.Alias, /* excludeGlobals */ false ); + if (importedSymbol && nameParts.length > 1) { + let currentType = typeChecker.getTypeOfSymbol(importedSymbol); + for (let i = 1; i < nameParts.length; i++) { + const prop = currentType.getProperty(nameParts[i]); + if (!prop) { + importedSymbol = undefined; + break; + } + if (i === nameParts.length - 1) { + importedSymbol = prop; + } else { + currentType = typeChecker.getTypeOfSymbol(prop); + } + } + } if (importedSymbol) { const decl = resolveSymbolToDeclaration(importedSymbol); if (decl) { @@ -641,22 +686,38 @@ export function createCrossFileResolver(options: CrossFileResolverOptions) { * JSDoc annotations from its property declarations. * Target type IDs are resolved from the source file's scope where the * annotation is defined, so consumers don't need to import target types. + * + * Handles dotted names (e.g., "ButtonGroup.Double") by resolving the base + * name and traversing member properties — necessary for namespace imports + * like `import * as ButtonGroup from './variants'` or + * `export * as ButtonGroup from './variants'`. */ function getExternalPropAnnotations( componentName: string ): Map | null { if (!currentSourceFile) return null; + // Split on '.' to handle namespace access (e.g., "ButtonGroup.Double") + const parts = componentName.split("."); + const baseName = parts[0]; + const symbol = typeChecker.resolveName( - componentName, + baseName, currentSourceFile, ts.SymbolFlags.Value | ts.SymbolFlags.Alias, /* excludeGlobals */ false ); if (!symbol) return null; - const type = typeChecker.getTypeOfSymbol(symbol); - const callSignatures = type.getCallSignatures(); + // For dotted names, traverse member properties to reach the target component + let componentType = typeChecker.getTypeOfSymbol(symbol); + for (let i = 1; i < parts.length; i++) { + const memberSymbol = componentType.getProperty(parts[i]); + if (!memberSymbol) return null; + componentType = typeChecker.getTypeOfSymbol(memberSymbol); + } + + const callSignatures = componentType.getCallSignatures(); if (callSignatures.length === 0) return null; const propsParam = callSignatures[0].getParameters()[0]; diff --git a/tests/fixtures/cross-file-props/namespace-barrel.ts b/tests/fixtures/cross-file-props/namespace-barrel.ts new file mode 100644 index 0000000..8f932fa --- /dev/null +++ b/tests/fixtures/cross-file-props/namespace-barrel.ts @@ -0,0 +1,8 @@ +export * from "./NavItem"; +export * from "./NavLink"; +export * from "./NavSection"; +export * from "./NavItems"; +export * from "./NavGroup"; +export * from "./Nav"; +export * from "./Sidebar"; +export * from "./DashboardLayout"; diff --git a/tests/rules/valid-render-prop-cross-file.test.ts b/tests/rules/valid-render-prop-cross-file.test.ts index f02454a..3566c0a 100644 --- a/tests/rules/valid-render-prop-cross-file.test.ts +++ b/tests/rules/valid-render-prop-cross-file.test.ts @@ -114,6 +114,31 @@ ruleTester.run("valid-render-prop (cross-file)", rule, { `, filename: path.resolve(fixturesDir, "consumer.tsx"), }, + // Component accessed via namespace import (import * as NS from ...) + { + name: "cross-file: @renders* via namespace import", + code: ` + import * as Components from "./namespace-barrel"; + + + + ; + `, + filename: path.resolve(fixturesDir, "consumer.tsx"), + }, + // Component accessed via namespace with union @renders* + { + name: "cross-file: union @renders* via namespace import", + code: ` + import * as Components from "./namespace-barrel"; + + + + + ; + `, + filename: path.resolve(fixturesDir, "consumer.tsx"), + }, ], invalid: [ // Unannotated component in cross-file @renders* children @@ -205,5 +230,48 @@ ruleTester.run("valid-render-prop (cross-file)", rule, { }, ], }, + // Unannotated component via namespace import in @renders* children + { + name: "cross-file: unannotated component via namespace import in @renders* children", + code: ` + import * as Components from "./namespace-barrel"; + + + + ; + `, + filename: path.resolve(fixturesDir, "consumer.tsx"), + errors: [ + { + messageId: "invalidRenderChildren", + data: { + expected: "NavItem", + actual: "Components.NavSection", + }, + }, + ], + }, + // Wrong element in @renders* named prop via namespace import + { + name: "cross-file: wrong element in @renders* named prop via namespace import", + code: ` + import * as Components from "./namespace-barrel"; + + bad}> +
+ ; + `, + filename: path.resolve(fixturesDir, "consumer.tsx"), + errors: [ + { + messageId: "invalidRenderProp", + data: { + propName: "navigation", + expected: "NavItem | NavSection", + actual: "div", + }, + }, + ], + }, ], }); diff --git a/tests/rules/valid-renders-jsdoc-cross-file.test.ts b/tests/rules/valid-renders-jsdoc-cross-file.test.ts new file mode 100644 index 0000000..23e8f56 --- /dev/null +++ b/tests/rules/valid-renders-jsdoc-cross-file.test.ts @@ -0,0 +1,76 @@ +import path from "node:path"; +import { RuleTester } from "@typescript-eslint/rule-tester"; +import * as vitest from "vitest"; +import rule from "../../src/rules/valid-renders-jsdoc.js"; + +// Configure rule tester to use vitest +RuleTester.afterAll = vitest.afterAll; +RuleTester.it = vitest.it; +RuleTester.itOnly = vitest.it.only; +RuleTester.itSkip = vitest.it.skip; +RuleTester.describe = vitest.describe; +RuleTester.describeSkip = vitest.describe.skip; + +const fixturesDir = path.resolve(__dirname, "../fixtures/cross-file-props"); + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { jsx: true }, + projectService: { + allowDefaultProject: ["consumer.tsx"], + defaultProject: "tsconfig.json", + }, + tsconfigRootDir: fixturesDir, + }, + }, +}); + +ruleTester.run("valid-renders-jsdoc (namespace imports)", rule, { + valid: [ + // Namespace import with valid member component + { + name: "valid namespaced component via namespace import", + code: ` + import * as Components from "./namespace-barrel"; + + interface MyProps { + /** @renders {Components.NavItem} */ + children: React.ReactNode; + } + + function MyComponent({ children }: MyProps) { + return
{children}
; + } + `, + filename: path.resolve(fixturesDir, "consumer.tsx"), + }, + ], + invalid: [ + // Namespace import with typo in member component name + { + name: "unresolved member in namespace import", + code: ` + import * as Components from "./namespace-barrel"; + + interface MyProps { + /** @renders {Components.NavItme} */ + children: React.ReactNode; + } + + function MyComponent({ children }: MyProps) { + return
{children}
; + } + `, + filename: path.resolve(fixturesDir, "consumer.tsx"), + errors: [ + { + messageId: "unresolvedComponent", + data: { + componentName: "Components.NavItme", + }, + }, + ], + }, + ], +});