Skip to content

Commit c2ed278

Browse files
feat(naming): match @rbxts/react component types in .ts files (#661)
* fix(test): scrub git env before `git init` in oxlintTargets fixtures Both fixtures ran `git init` with the ambient environment. Under a git hook, GIT_DIR points at the repository being committed, so `git init` re-initialised that repository instead of the temporary directory - and with no work tree in the environment it set `core.bare = true`, which breaks every later git command in the checkout and its worktrees. The temporary directory never became a repository either, so both assertions failed. Wrap the init calls in `withoutGitEnvironment`, the helper the assertions already use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(naming): match @rbxts/react component types in .ts, cover ComponentType Share the React selectors between the ts and tsx blocks; the ts block had none, so `readonly FallbackComponent: React.ComponentType<P>` in a .ts file fell through to the camelCase default. `ComponentType` is a union alias (`ComponentClass | FunctionComponent`) and the flawless type matcher splits unions before it compares names, so the alias symbol never matches. List both members, plus the exotic component types (memo/lazy/forwardRef) and Context. Component-typed classProperty/typeProperty accept camelCase or PascalCase; parameters and variables stay PascalCase-only, as tsx already required. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 694494e commit c2ed278

2 files changed

Lines changed: 60 additions & 48 deletions

File tree

src/eslint/configs/naming.ts

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ import type {
99

1010
const RBXTS_REACT = "@rbxts/react";
1111

12+
/**
13+
* Component-valued types from `@rbxts/react`.
14+
*
15+
* `ComponentType` is an alias for `ComponentClass | FunctionComponent`, and the
16+
* type matcher splits unions before matching, so both members have to be listed
17+
* for a `ComponentType`-annotated name to match.
18+
*/
19+
const REACT_COMPONENT_TYPES = [
20+
{ name: "ComponentClass", from: RBXTS_REACT },
21+
{ name: "Context", from: RBXTS_REACT },
22+
{ name: "ExoticComponent", from: RBXTS_REACT },
23+
{ name: "FC", from: RBXTS_REACT },
24+
{ name: "ForwardRefExoticComponent", from: RBXTS_REACT },
25+
{ name: "FunctionComponent", from: RBXTS_REACT },
26+
{ name: "LazyExoticComponent", from: RBXTS_REACT },
27+
{ name: "MemoExoticComponent", from: RBXTS_REACT },
28+
{ name: "NamedExoticComponent", from: RBXTS_REACT },
29+
];
30+
31+
const REACT_ELEMENT_RETURN_TYPES = [
32+
{ returns: { name: "Element", from: RBXTS_REACT } },
33+
{ returns: { name: "ReactNode", from: RBXTS_REACT } },
34+
];
35+
1236
export async function naming(
1337
options: NamingConfig & OptionsTypeScriptParserOptions & OptionsTypeScriptWithTypes = {},
1438
): Promise<Array<TypedFlatConfigItem>> {
@@ -53,6 +77,7 @@ export async function naming(
5377
format: null,
5478
selector: "import",
5579
},
80+
...(isRoblox ? reactSelectors() : []),
5681
{
5782
format: null,
5883
modifiers: ["destructured"],
@@ -240,52 +265,7 @@ export async function naming(
240265
leadingUnderscore: "allow",
241266
selector: "variable",
242267
},
243-
...(isRoblox
244-
? [
245-
{
246-
// React components and contexts
247-
// conventionally use PascalCase
248-
format: ["StrictPascalCase"],
249-
selector: ["parameter", "variable"],
250-
types: [
251-
{ name: "Context", from: RBXTS_REACT },
252-
{ name: "FC", from: RBXTS_REACT },
253-
{
254-
name: "FunctionComponent",
255-
from: RBXTS_REACT,
256-
},
257-
],
258-
},
259-
{
260-
// components typed as anonymous
261-
// functions (e.g. `() =>
262-
// React.ReactNode`) have no
263-
// symbol name to match, so match
264-
// by return type instead;
265-
// permissive since camelCase
266-
// helpers can also return
267-
// elements. typeMethod covers
268-
// function-typed interface
269-
// members
270-
format: ["strictCamelCase", "StrictPascalCase"],
271-
selector: ["parameter", "typeMethod", "variable"],
272-
types: [
273-
{
274-
returns: {
275-
name: "Element",
276-
from: RBXTS_REACT,
277-
},
278-
},
279-
{
280-
returns: {
281-
name: "ReactNode",
282-
from: RBXTS_REACT,
283-
},
284-
},
285-
],
286-
},
287-
]
288-
: []),
268+
...(isRoblox ? reactSelectors() : []),
289269

290270
{
291271
format: null,
@@ -424,3 +404,35 @@ export async function naming(
424404
: []),
425405
];
426406
}
407+
408+
/**
409+
* Selectors that let React component values carry component casing.
410+
*
411+
* @returns The naming-convention selectors for `@rbxts/react` types.
412+
*/
413+
function reactSelectors(): Array<Record<string, unknown>> {
414+
return [
415+
{
416+
// React components and contexts conventionally use PascalCase
417+
format: ["StrictPascalCase"],
418+
selector: ["parameter", "variable"],
419+
types: REACT_COMPONENT_TYPES,
420+
},
421+
{
422+
// Properties holding a component are named for the component, but
423+
// camelCase stays legal so existing props do not have to move
424+
format: ["strictCamelCase", "StrictPascalCase"],
425+
selector: ["classProperty", "typeProperty"],
426+
types: REACT_COMPONENT_TYPES,
427+
},
428+
{
429+
// components typed as anonymous functions (e.g. `() =>
430+
// React.ReactNode`) have no symbol name to match, so match by return
431+
// type instead; permissive since camelCase helpers can also return
432+
// elements. typeMethod covers function-typed interface members
433+
format: ["strictCamelCase", "StrictPascalCase"],
434+
selector: ["parameter", "typeMethod", "variable"],
435+
types: REACT_ELEMENT_RETURN_TYPES,
436+
},
437+
];
438+
}

test/lint-cli.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,7 @@ describe("oxlintTargets", () => {
633633
expect.assertions(1);
634634

635635
const directory = temporaryDirectory();
636-
execFileSync("git", ["init", "-q"], { cwd: directory });
636+
withoutGitEnvironment(() => execFileSync("git", ["init", "-q"], { cwd: directory }));
637637
fs.writeFileSync(path.join(directory, ".gitignore"), "src/typegen.d.ts\n");
638638

639639
// The only oxlint-eligible target is the one git ignores, so the set
@@ -650,7 +650,7 @@ describe("oxlintTargets", () => {
650650
expect.assertions(1);
651651

652652
const directory = temporaryDirectory();
653-
execFileSync("git", ["init", "-q"], { cwd: directory });
653+
withoutGitEnvironment(() => execFileSync("git", ["init", "-q"], { cwd: directory }));
654654
fs.writeFileSync(path.join(directory, ".gitignore"), "src/typegen.d.ts\n");
655655

656656
expect(

0 commit comments

Comments
 (0)