Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-namespace-imports.md
Original file line number Diff line number Diff line change
@@ -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., `<NS.Sidebar>`) are now correctly validated by `valid-render-prop` and `valid-renders-jsdoc` rules.
9 changes: 6 additions & 3 deletions src/rules/valid-renders-jsdoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 66 additions & 5 deletions src/utils/cross-file-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, ResolvedRendersAnnotation> | 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];
Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/cross-file-props/namespace-barrel.ts
Original file line number Diff line number Diff line change
@@ -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";
68 changes: 68 additions & 0 deletions tests/rules/valid-render-prop-cross-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

<Components.Sidebar>
<Components.NavLink label="Home" />
</Components.Sidebar>;
`,
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";

<Components.Nav>
<Components.NavItems links={["Home", "About"]} />
<Components.NavGroup title="Admin" />
</Components.Nav>;
`,
filename: path.resolve(fixturesDir, "consumer.tsx"),
},
],
invalid: [
// Unannotated component in cross-file @renders* children
Expand Down Expand Up @@ -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";

<Components.Sidebar>
<Components.NavSection title="Reports" />
</Components.Sidebar>;
`,
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";

<Components.DashboardLayout navigation={<div>bad</div>}>
<div />
</Components.DashboardLayout>;
`,
filename: path.resolve(fixturesDir, "consumer.tsx"),
errors: [
{
messageId: "invalidRenderProp",
data: {
propName: "navigation",
expected: "NavItem | NavSection",
actual: "div",
},
},
],
},
],
});
76 changes: 76 additions & 0 deletions tests/rules/valid-renders-jsdoc-cross-file.test.ts
Original file line number Diff line number Diff line change
@@ -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 <div>{children}</div>;
}
`,
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 <div>{children}</div>;
}
`,
filename: path.resolve(fixturesDir, "consumer.tsx"),
errors: [
{
messageId: "unresolvedComponent",
data: {
componentName: "Components.NavItme",
},
},
],
},
],
});
Loading