Skip to content

Commit 99f2b56

Browse files
authored
feat(eslint-rules): add base-hook-signature rule (#36252)
1 parent 833fd4c commit 99f2b56

7 files changed

Lines changed: 806 additions & 1 deletion

File tree

tools/eslint-rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
RULE_NAME as consistentCallbackTypeName,
55
rule as consistentCallbackType,
66
} from './rules/consistent-callback-type';
7+
import { RULE_NAME as baseHookSignatureName, rule as baseHookSignature } from './rules/base-hook-signature';
78

89
/**
910
* Import your custom workspace rules at the top of this file.
@@ -32,6 +33,7 @@ module.exports = {
3233
*/
3334
rules: {
3435
[consistentCallbackTypeName]: consistentCallbackType,
36+
[baseHookSignatureName]: baseHookSignature,
3537
[noRestrictedGlobalsName]: noRestrictedGlobals,
3638
[noMissingJsxPragmaName]: noMissingJsxPragma,
3739
},
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Docs-only stub fixture for `base-hook-signature.spec.ts`.
2+
//
3+
// The rule never reads this file's contents — `RuleTester` always feeds source code in-memory.
4+
// This file exists purely so the fixture tree mirrors a real component folder layout:
5+
//
6+
// Orphan/
7+
// └── useOrphan.ts ← virtual filename used by tests
8+
//
9+
// Crucially, there is NO `useOrphanContextValuesBase.ts(x)` next to this file. That absence is
10+
// the whole point: tests that pass `filename: ORPHAN_FILENAME` assert "when no paired base
11+
// hook exists, the contract does NOT apply" — i.e. `useOrphanContextValues_unstable(state)`
12+
// is a legitimate non-wrapping hook and must not be flagged.
13+
export {};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Docs-only stub fixture for `base-hook-signature.spec.ts`.
2+
//
3+
// The rule never reads this file's contents — `RuleTester` always feeds source code in-memory.
4+
// This file exists purely so the fixture tree mirrors a real component folder layout:
5+
//
6+
// Sibling/
7+
// ├── useSibling.ts ← virtual filename used by tests
8+
// └── useSiblingBase.ts ← MUST exist; rule does `fs.statSync` to detect the pair
9+
//
10+
// Tests that pass `filename: SIBLING_FILENAME` assert "when a base hook exists next to me,
11+
// my signature is enforced".
12+
export {};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const useSiblingBase_unstable = (props: { a: number }, ref: React.Ref<HTMLElement>) => {
2+
return { props, ref };
3+
};
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import * as path from 'node:path';
2+
import { RuleTester } from '@typescript-eslint/rule-tester';
3+
import { rule, RULE_NAME } from './base-hook-signature';
4+
5+
const FIXTURE_ROOT = path.join(__dirname, '__fixtures__/base-hook-signature');
6+
// NOTE on fixture filenames below:
7+
// `RuleTester` always lints source code provided in-memory via the `code` field — it never
8+
// reads the file at `filename` from disk. The `filename` value is only used (a) as a label
9+
// in error messages and (b) by rules that perform their OWN filesystem lookups relative to it.
10+
//
11+
// `base-hook-signature` does exactly that: given a state-hook file `useFoo.ts(x)`, it calls
12+
// `fs.statSync` to check whether a sibling `useFooBase.ts(x)` exists in the same folder, and
13+
// only enforces the contract when a pair is detected.
14+
//
15+
// So for the fixture tree under `__fixtures__/base-hook-signature/src/components/`:
16+
// - The two stub files `Sibling/useSibling.ts` and `Orphan/useOrphan.ts` are docs-only —
17+
// their existence does NOT affect any assertion (the rule never reads them).
18+
// - What actually drives the test outcomes is the presence of `Sibling/useSiblingBase.ts`
19+
// (pair detected → contract enforced) and the absence of
20+
// `Orphan/useOrphanContextValuesBase.ts(x)` (no pair → contract NOT enforced).
21+
const SIBLING_FILENAME = path.join(FIXTURE_ROOT, 'src/components/Sibling/useSibling.ts');
22+
const ORPHAN_FILENAME = path.join(FIXTURE_ROOT, 'src/components/Orphan/useOrphan.ts');
23+
24+
const ruleTester = new RuleTester();
25+
26+
ruleTester.run(RULE_NAME, rule, {
27+
valid: [
28+
// Valid base hook: namespace import — `import * as React from 'react'` + `React.Ref<...>`.
29+
{
30+
code: `
31+
import * as React from 'react';
32+
export const useThingBase_unstable = (props: {}, ref: React.Ref<HTMLElement>) => {
33+
return { props, ref };
34+
};
35+
`,
36+
},
37+
// Valid base hook: named import — `import { Ref } from 'react'` + `Ref<...>` (FunctionDeclaration form).
38+
{
39+
code: `
40+
import { Ref } from 'react';
41+
export function useThingBase_unstable(props: {}, ref: Ref<HTMLElement>) {
42+
return { props, ref };
43+
}
44+
`,
45+
},
46+
// Valid base hook: default import — `import React from 'react'` + `React.Ref<...>`.
47+
{
48+
code: `
49+
import React from 'react';
50+
export const useThingBase_unstable = (props: {}, ref: React.Ref<HTMLElement>) => {
51+
return { props, ref };
52+
};
53+
`,
54+
},
55+
// Valid base hook with only \`props\` (ref is optional). \`props\` still needs a type annotation.
56+
{
57+
code: `
58+
export const useThingBase_unstable = (props: {}) => {
59+
return { props };
60+
};
61+
`,
62+
},
63+
// Non-base hook without a paired base hook is not subject to the contract.
64+
{
65+
code: `
66+
export const useThing_unstable = (props, ref, extra) => {
67+
return { props, ref, extra };
68+
};
69+
`,
70+
},
71+
// Pair detection (same file): a state hook `useThing_unstable` next to its base hook
72+
// `useThingBase_unstable` IS subject to the contract. Correct signature passes.
73+
{
74+
code: `
75+
import * as React from 'react';
76+
export const useThing_unstable = (props: {}, ref: React.Ref<HTMLElement>) => {
77+
return { props, ref };
78+
};
79+
export const useThingBase_unstable = (props: {}, ref: React.Ref<HTMLElement>) => {
80+
return { props, ref };
81+
};
82+
`,
83+
},
84+
// Pair detection (no sibling base hook on disk): `useOrphanContextValues_unstable(state)`
85+
// is NOT a paired wrapping hook and must NOT be flagged for its non-(props, ref) signature.
86+
// The Orphan folder has no `useOrphanContextValuesBase.ts(x)` next to it.
87+
{
88+
filename: ORPHAN_FILENAME,
89+
code: `
90+
export function useOrphanContextValues_unstable(state) {
91+
return { state };
92+
}
93+
`,
94+
},
95+
// Pair detection (sibling file): wrapping state hook lives in `useSibling.ts`, paired with
96+
// `useSiblingBase.ts` in the same folder. Correct (props, ref) signature passes.
97+
{
98+
filename: SIBLING_FILENAME,
99+
code: `
100+
import * as React from 'react';
101+
export const useSibling_unstable = (props: {}, ref: React.Ref<HTMLElement>) => {
102+
return { props, ref };
103+
};
104+
`,
105+
},
106+
// Re-export of a base hook from another module is valid. We can't inspect the params
107+
// of an identifier initializer, so we skip validation but accept it as a pairing marker.
108+
{
109+
code: `
110+
export const useThingBase_unstable = useThingBase;
111+
`,
112+
},
113+
// Re-export of an externally-imported base hook is also valid.
114+
{
115+
code: `
116+
import { useExternalBase_unstable } from 'external-lib';
117+
export const useThingBase_unstable = useExternalBase_unstable;
118+
`,
119+
},
120+
],
121+
invalid: [
122+
// Too few params (0).
123+
{
124+
code: `
125+
export const useThingBase_unstable = () => ({});
126+
`,
127+
errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThingBase_unstable', actual: 0 } }],
128+
},
129+
// Too many params.
130+
{
131+
code: `
132+
export const useThingBase_unstable = (props, ref, extra) => ({ props, ref, extra });
133+
`,
134+
errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThingBase_unstable', actual: 3 } }],
135+
},
136+
// Wrong param names.
137+
{
138+
code: `
139+
export const useThingBase_unstable = (p, r) => ({ p, r });
140+
`,
141+
errors: [
142+
{
143+
messageId: 'invalidParamName',
144+
data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: 'p' },
145+
},
146+
],
147+
},
148+
// ObjectPattern for \`props\` is not allowed.
149+
{
150+
code: `
151+
import * as React from 'react';
152+
export const useThingBase_unstable = ({ a }, ref: React.Ref<HTMLElement>) => ({ a, ref });
153+
`,
154+
errors: [
155+
{
156+
messageId: 'invalidParamName',
157+
data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: '{ ... }' },
158+
},
159+
],
160+
},
161+
// \`ref\` parameter without a type annotation. \`props\` is typed so this case stays focused
162+
// on the ref-type assertion (an untyped \`props\` would also trigger \`missingPropsType\`).
163+
{
164+
code: `
165+
export const useThingBase_unstable = (props: {}, ref) => ({ props, ref });
166+
`,
167+
errors: [
168+
{
169+
messageId: 'invalidRefType',
170+
data: { hookName: 'useThingBase_unstable', actual: '<missing type annotation>' },
171+
},
172+
],
173+
},
174+
// \`ref\` parameter typed as something other than React.Ref.
175+
{
176+
code: `
177+
export const useThingBase_unstable = (props: {}, ref: HTMLElement) => ({ props, ref });
178+
`,
179+
errors: [
180+
{
181+
messageId: 'invalidRefType',
182+
data: { hookName: 'useThingBase_unstable', actual: 'HTMLElement' },
183+
},
184+
],
185+
},
186+
// \`ref\` parameter typed as React.ForwardedRef (must be React.Ref).
187+
{
188+
code: `
189+
export const useThingBase_unstable = (props: {}, ref: React.ForwardedRef<HTMLElement>) => ({ props, ref });
190+
`,
191+
errors: [
192+
{
193+
messageId: 'invalidRefType',
194+
data: { hookName: 'useThingBase_unstable', actual: 'React.ForwardedRef' },
195+
},
196+
],
197+
},
198+
// \`Ref\` is a locally declared type alias, not imported from react.
199+
{
200+
code: `
201+
type Ref<T> = { current: T | null };
202+
export const useThingBase_unstable = (props: {}, ref: Ref<HTMLElement>) => ({ props, ref });
203+
`,
204+
errors: [
205+
{
206+
messageId: 'invalidRefType',
207+
data: { hookName: 'useThingBase_unstable', actual: 'Ref' },
208+
},
209+
],
210+
},
211+
// \`Ref\` imported from a non-react package is not accepted.
212+
{
213+
code: `
214+
import { Ref } from 'not-react';
215+
export const useThingBase_unstable = (props: {}, ref: Ref<HTMLElement>) => ({ props, ref });
216+
`,
217+
errors: [
218+
{
219+
messageId: 'invalidRefType',
220+
data: { hookName: 'useThingBase_unstable', actual: 'Ref' },
221+
},
222+
],
223+
},
224+
// \`React\` is a locally declared identifier, not the react module namespace.
225+
{
226+
code: `
227+
const React = { Ref: null };
228+
export const useThingBase_unstable = (props: {}, ref: React.Ref<HTMLElement>) => ({ props, ref });
229+
`,
230+
errors: [
231+
{
232+
messageId: 'invalidRefType',
233+
data: { hookName: 'useThingBase_unstable', actual: 'React.Ref' },
234+
},
235+
],
236+
},
237+
// Pair detection (same file): wrapping state hook with too many params is flagged because
238+
// its sibling base hook in the same file marks it as a paired wrapper. The base hook itself
239+
// is correctly typed so only the wrapping hook's error is asserted.
240+
{
241+
code: `
242+
import * as React from 'react';
243+
export const useThing_unstable = (props, ref, extra) => ({ props, ref, extra });
244+
export const useThingBase_unstable = (props: {}, ref: React.Ref<HTMLElement>) => ({ props, ref });
245+
`,
246+
errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThing_unstable', actual: 3 } }],
247+
},
248+
// Pair detection (sibling file): wrapping state hook in `useSibling.ts` is paired with
249+
// `useSiblingBase.ts` in the same folder. Wrong param names are flagged (stops at first).
250+
{
251+
filename: SIBLING_FILENAME,
252+
code: `
253+
import * as React from 'react';
254+
export const useSibling_unstable = (p, r: React.Ref<HTMLElement>) => ({ p, r });
255+
`,
256+
errors: [
257+
{
258+
messageId: 'invalidParamName',
259+
data: { hookName: 'useSibling_unstable', index: 1, expected: 'props', actual: 'p' },
260+
},
261+
],
262+
},
263+
// \`props\` parameter without a type annotation (would be inferred as \`any\` and fail
264+
// \`noImplicitAny\` under TS strict). Asserted on a base hook with only \`props\`.
265+
{
266+
code: `
267+
export const useThingBase_unstable = (props) => ({ props });
268+
`,
269+
errors: [{ messageId: 'missingPropsType', data: { hookName: 'useThingBase_unstable' } }],
270+
},
271+
// \`props\` without type annotation, even when a correctly-typed \`ref\` is present.
272+
// Demonstrates that the \`props\`-type check short-circuits before the \`ref\` check, so the
273+
// user sees the more fundamental problem first.
274+
{
275+
code: `
276+
import * as React from 'react';
277+
export const useThingBase_unstable = (props, ref: React.Ref<HTMLElement>) => ({ props, ref });
278+
`,
279+
errors: [{ messageId: 'missingPropsType', data: { hookName: 'useThingBase_unstable' } }],
280+
},
281+
// Base hook initialized to a number literal is invalid.
282+
{
283+
code: `
284+
export const useThingBase_unstable = 42;
285+
`,
286+
errors: [{ messageId: 'invalidBaseHookInit', data: { hookName: 'useThingBase_unstable', actual: '42' } }],
287+
},
288+
// Base hook initialized to an object literal is invalid.
289+
{
290+
code: `
291+
export const useThingBase_unstable = {};
292+
`,
293+
errors: [{ messageId: 'invalidBaseHookInit', data: { hookName: 'useThingBase_unstable', actual: '{}' } }],
294+
},
295+
// Base hook initialized to an array literal is invalid.
296+
{
297+
code: `
298+
export const useThingBase_unstable = [];
299+
`,
300+
errors: [{ messageId: 'invalidBaseHookInit', data: { hookName: 'useThingBase_unstable', actual: '[]' } }],
301+
},
302+
// Base hook initialized to a string literal is invalid.
303+
{
304+
code: `
305+
export const useThingBase_unstable = "not-a-function";
306+
`,
307+
errors: [
308+
{ messageId: 'invalidBaseHookInit', data: { hookName: 'useThingBase_unstable', actual: '"not-a-function"' } },
309+
],
310+
},
311+
],
312+
});

0 commit comments

Comments
 (0)