Skip to content

feat(eslint-rules): add consistent-base-hook rule for v9 base hooks#36236

Draft
Hotell wants to merge 15 commits into
microsoft:masterfrom
Hotell:tools/ws-lint/consistent-base-hook
Draft

feat(eslint-rules): add consistent-base-hook rule for v9 base hooks#36236
Hotell wants to merge 15 commits into
microsoft:masterfrom
Hotell:tools/ws-lint/consistent-base-hook

Conversation

@Hotell
Copy link
Copy Markdown
Contributor

@Hotell Hotell commented May 21, 2026

Summary

Adds a new workspace ESLint rule — @nx/workspace-consistent-base-hook — that enforces the v9 "base hook" API contract for any function whose name matches use<Name>Base_unstable, and extends the signature contract to the paired wrapping state hook use<Name>_unstable via pair detection.

The rule encodes architectural rules we currently rely on tribal knowledge / PR review to enforce, namely:

  1. Base hooks have a deterministic signature so callers (the wrapping use<Name>_unstable hook) can compose them mechanically.
  2. The wrapping state hook in the same component folder must match that signature, so signature drift between base + wrapper is impossible.
  3. Base hooks must remain free of dependencies on focus/keyboard runtime packages (tabster by default). That responsibility belongs to the wrapping hook.

Detection of (3) is fully automatic and covers two threat models:

  • Runtime coupling — a value reference inside the base hook resolves to a binding whose defining module transitively imports a forbidden runtime via value (non type-only) imports.
  • API-surface coupling — a type reference inside the base hook signature resolves to a binding whose defining module transitively reaches a forbidden runtime via either value or type imports. A type alias can still tie the public API of the base hook to the forbidden runtime even when no runtime code is pulled.

Detection works without a hand-curated allow/deny list: each imported symbol is resolved to its defining source file via typescript-eslint ParserServices + the TS Program, then the module's import graph is walked and the appropriate reach set (value-only vs value+type) is consulted. Results are memoized per ts.Program × file with cycle protection.

Rule contract

For any function matching /^use[A-Z]\w*Base_unstable$/ OR any wrapping state hook use<Name>_unstable whose paired base hook (use<Name>Base_unstable) exists in the same file or as a sibling file (.ts/.tsx) in the same directory:

  • Parameters: 1 or 2 positional.
    • 1st param: required, Identifier named props.
    • 2nd param: optional, Identifier named ref, typed as React.Ref<...> (or Ref<...> imported from react).

For base hooks only (additional body/signature check):

  • Body & signature: must not reference any binding (value or type) whose import either lives in a forbiddenRuntimes package or transitively pulls one through a watchedPackages package.

Detection is scope-based (via sourceCode.getDeclaredVariables), so a locally-declared identifier that happens to share a name with a forbidden import is not flagged.

Pair detection (state hook coverage)

Across all 85 useXBase_unstable declarations in packages/react-components/**:

  • 82 are co-located with their wrapping state hook in the same file.
  • 3 outliers live as sibling files in the same component folder: react-tooltip (useTooltipBase.tsxuseTooltip.tsx), react-field (useFieldBase.tsxuseField.tsx), react-menu/MenuItem (useMenuItemBase.tsxuseMenuItem.tsx).

The pair lookup checks same-file first (zero IO), then falls back to fs.statSync on useX.ts / useX.tsx in path.dirname(filename) with per-rule-instance caching. Wrapping hooks that exist without a base hook (e.g., useTabListContextValues_unstable, useFooStyles_unstable) are not flagged — pair detection is strictly anchored on BASE_HOOK_NAME_PATTERN, eliminating false positives on other _unstable hooks.

Options

{
  /**
   * Packages whose imported symbols are analyzed transitively.
   * A symbol imported from one of these is allowed in a base hook only if its defining
   * source file does not reach any `forbiddenRuntimes` package — through value imports
   * for value references, through value OR type imports for type references.
   */
  watchedPackages?: string[];
  /**
   * Runtime packages whose presence in the transitive import graph of a referenced
   * symbol is forbidden inside base hooks. Direct imports are forbidden too.
   */
  forbiddenRuntimes?: string[];
  /**
   * When `true`, type-only imports are exempt from BOTH direct forbidden-runtime checks
   * AND transitive watched-package reach checks (a type can never pull runtime code at
   * execution time). Symmetric across both threat models. Default: `false`.
   */
  allowTypeImports?: boolean;
}

Defaults: { watchedPackages: ['@fluentui/react-tabster'], forbiddenRuntimes: ['tabster'], allowTypeImports: false }.

Message ids

id when
invalidParamCount params.length is not 1 or 2 (fires for base hooks and paired state hooks)
invalidParamName param 1 is not Identifier props, or param 2 is not Identifier ref (fires for base hooks and paired state hooks)
invalidRefType ref is present but missing a type annotation or not typed as React.Ref<...> (fires for base hooks and paired state hooks)
forbiddenRuntimeDirect base hook references a binding imported directly from a forbiddenRuntimes package (including type-only imports when allowTypeImports: false)
forbiddenRuntimeReach base hook references a binding from a watchedPackages package whose defining module transitively imports a forbiddenRuntimes package; the failing path is reported via viaFile. Fires for value references (value-import graph) and for type references (value + type-import graph)
typedServicesUnavailable one-shot diagnostic at Program:exit: a watched-package reference was encountered but transitive analysis was skipped because TypeScript type information was unavailable

All reports point at the function identifier (or the failing reference), so a single eslint-disable-next-line above the export line suppresses cleanly.

Untyped fallback

If typed services are unavailable, the rule degrades gracefully — it still catches direct imports from forbiddenRuntimes via plain scope analysis, skips the transitive walk for watched-package references, and emits a single typedServicesUnavailable diagnostic per file so the misconfiguration is visible rather than silent.

Wiring

Enabled as error in packages/eslint-plugin/src/internal.js under the React override, scoped to v9 source files (excludes *.test.*, *.spec.*, *.cy.*, *.stories.*). The hosting flat/react preset enables parserOptions.projectService: true, so the typed Program-backed analysis runs in production.

Test coverage

tools/eslint-rules/rules/consistent-base-hook.spec.ts75 cases, split into two RuleTester instances:

  • Untyped (param-shape + scope + pair detection): valid arrow/function-declaration form, 1-param form, Ref<> import, React.Ref<> namespace, non-base hook unaffected, shadowing handled, keyborg allowed by default, same-file paired correct signature, sibling-file paired correct signature, orphan …ContextValues_unstable not flagged; invalid 0/3 params, wrong names, ObjectPattern props, missing/non-React.Ref ref type, React.ForwardedRef, same-file pair state hook with 3 params, sibling-file pair state hook with wrong param names, and the typedServicesUnavailable one-shot warning.
  • Typed transitive: stub fixture monorepo under tools/eslint-rules/rules/__fixtures__/consistent-base-hook/ with watched-pkg, heavy-runtime, light-helper, cyclic-pkg. Covers:
    • direct runtime imports flagged (runHeavy, aliased runHeavy as go);
    • watched-pkg value reach (useHeavy) flagged with viaFile;
    • watched-pkg value reach to a clean module (useLight) allowed;
    • cyclic re-export graph handled without infinite loop;
    • direct type-only imports from forbidden runtime (HeavyOptions) flagged by default;
    • transitive type-leakage via watched pkg (HeavyType, both top-level and per-specifier type) flagged with viaFile;
    • indirect type leakage via HeavyWrapper whose defining file (watched-pkg/index.ts) value-re-exports ./heavyviaFile correctly points at index.ts;
    • watched-pkg type-only of a clean type (LightOptions) allowed;
    • symmetric allowTypeImports: true exempts both direct forbidden type imports AND transitive watched type imports;
    • non-base-hook in the same file unaffected.

Verification

  • yarn nx run eslint-rules:test --testPathPatterns=consistent-base-hook75/75 pass.
  • yarn nx run-many -t lint --projects=tag:vNext --skip-nx-cache165/165 projects pass, zero consistent-base-hook errors.

Performance (per-rule, TIMING=all ESLint CLI)

Cold runs (--skip-nx-cache), measured via TIMING=all nx run <pkg>:lint. Even with the new value+type transitive analysis the rule stays at low single-digit milliseconds per package because:

  • the WeakMap<ts.Program, Map<file, { value, all }>> cache fills both reach sets in a single DFS pass, amortizing across all files in a lint run;
  • type-only imports from watched packages are skipped at tracking time when allowTypeImports: true, so the analysis pays zero cost when irrelevant;
  • a scope pre-filter in the visitor short-circuits any reference whose declaration is not a tracked import (the typical case);
  • pair detection caches sibling-file fs.statSync results per rule instance, so each component directory pays at most one syscall per linted run.
Package consistent-base-hook time Total lint time
react-button 0.672 ms 5.24 s
react-tabster 0.670 ms 6.28 s
react-checkbox 1.547 ms 4.30 s
react-slider 1.601 ms 4.11 s
react-menu 1.940 ms 9.42 s
react-rating 1.989 ms 4.65 s
react-tags 3.035 ms 7.66 s

For context on react-menu: top rule cost is @typescript-eslint/no-deprecated at 2076 ms (54.8%) and react-hooks/static-components at 802 ms (21.2%). consistent-base-hook is roughly ~1000× cheaper than no-deprecated and ~400× cheaper than static-components.

Pre-existing violators

A handful of legacy base hooks across react-checkbox, react-menu (useMenuTrigger), react-radio, react-rating, react-slider, react-switch, react-tags don't yet conform (mostly: untyped/non-React.Ref ref param, or direct tabster runtime usage via useFocusFinders / useArrowNavigationGroup etc.). These are suppressed with line-scoped eslint-disable-next-line comments so the rule can ship as error for new code. Follow-up refactors will remove the suppressions one package at a time.

useTooltipBase, useMenuBase, and useAvatarGroupPopoverBase (1-param hooks) are valid under the relaxed contract and required no suppression.

The pair-detection extension surfaced exactly one new violation: useTagGroupBase_unstable accepts a 3rd options argument used internally by useTagGroup_unstable. Suppressed inline with explanation.

Commits (kept decoupled)

  1. feat(eslint-rules): add consistent-base-hook rule — initial rule + spec + registration
  2. chore(eslint-plugin): enable consistent-base-hook for v9 sources — plugin wiring
  3. Suppression cleanups + iterative expansions of the legacy hand-curated allowlist
  4. feat(eslint-rules): auto-detect forbidden runtime deps via TS Program in consistent-base-hook — replaces the hand-curated allowlist with per-symbol transitive value-import analysis powered by typescript-eslint ParserServices + TS Program
  5. feat(eslint-rules): add allowTypeImports option to consistent-base-hook — type-only imports from forbiddenRuntimes packages are disallowed by default, opt-in to skip them
  6. feat(eslint-rules): detect type-leakage through watched packages in consistent-base-hook — extends transitive analysis to type references in the hook signature; symmetric allowTypeImports semantics; new typedServicesUnavailable one-shot diagnostic when typed services are missing
  7. feat(eslint-rules): extend consistent-base-hook with paired state hook signature detection(props, ref) contract is now enforced on wrapping use<Name>_unstable hooks when a paired base hook exists in the same file or as a sibling .ts/.tsx file in the same directory

Follow-ups (not in this PR)

  • Split into two focused rules — base-hook-signature (signature + pair detection) and base-hook-no-forbidden-runtime (transitive analysis) — with the transitive-reach / module-resolver / tracked-imports helpers extracted as reusable utilities.
  • Refactor each suppressed base hook to actually conform (move tabster usage to the wrapping use<Name>_unstable hook), then drop the suppressions.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 21, 2026

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-headless-components-preview
react-headless-components-preview: entire library
165.054 kB
47.665 kB
165.095 kB
47.67 kB
41 B
5 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
global-context
createContext
510 B
328 B
global-context
createContextSelector
531 B
335 B
keyboard-key
keyboard-key package
3.746 kB
1.928 kB
react
ActivityItem
71.22 kB
23.347 kB
react
Announced
38.472 kB
13.275 kB
react
Autofill
15.42 kB
4.766 kB
react
Breadcrumb
200.805 kB
59.601 kB
react
Button
194.354 kB
55.886 kB
react
ButtonGrid
179.242 kB
53.891 kB
react
Calendar
121.162 kB
36.83 kB
react
Callout
84.299 kB
27.593 kB
react
Check
53.206 kB
17.835 kB
react
Checkbox
59.978 kB
19.874 kB
react
ChoiceGroup
65.488 kB
21.465 kB
react
ChoiceGroupOption
58.769 kB
19.353 kB
react
Coachmark
92.7 kB
29.305 kB
react
Color
7.789 kB
3.127 kB
react
ColorPicker
134.97 kB
42.125 kB
react
ComboBox
250.687 kB
71.515 kB
react
CommandBar
201.861 kB
59.387 kB
react
ContextualMenu
154.229 kB
47.566 kB
react
DatePicker
183.251 kB
55.892 kB
react
DateTimeUtilities
5.244 kB
1.849 kB
react
DetailsList
229.929 kB
65.81 kB
react
Dialog
210.16 kB
62.358 kB
react
Divider
19.588 kB
6.84 kB
react
DocumentCard
215.843 kB
63.666 kB
react
DragDrop
8.343 kB
2.724 kB
react
DraggableZone
34.28 kB
11.488 kB
react
Dropdown
233.151 kB
67.962 kB
react
ExtendedPicker
96.823 kB
27.866 kB
react
Fabric
41.728 kB
14.343 kB
react
Facepile
209.377 kB
62.375 kB
react
FloatingPicker
240.865 kB
68.222 kB
react
FocusTrapZone
16.99 kB
5.891 kB
react
FocusZone
55.1 kB
17.451 kB
react
Grid
179.242 kB
53.891 kB
react
GroupedList
135.035 kB
40.67 kB
react
GroupedListV2
122.659 kB
37.758 kB
react
HoverCard
96.784 kB
30.688 kB
react
Icon
51.887 kB
17.263 kB
react
Icons
66.339 kB
24.385 kB
react
Image
46.901 kB
15.695 kB
react
Keytip
81.301 kB
26.677 kB
react
KeytipData
14.05 kB
4.583 kB
react
KeytipLayer
103.089 kB
31.9 kB
react
Keytips
105.873 kB
32.904 kB
react
Label
38.324 kB
13.241 kB
react
Layer
48.089 kB
16.348 kB
react
Link
39.665 kB
13.653 kB
react
List
39.346 kB
12.454 kB
react
MarqueeSelection
74.49 kB
22.402 kB
react
MessageBar
189.388 kB
56.33 kB
react
Modal
93.738 kB
30.223 kB
react
Nav
186.825 kB
55.723 kB
react
OverflowSet
33.354 kB
11.282 kB
react
Overlay
40.885 kB
14.077 kB
react
Panel
200.327 kB
59.336 kB
react
Persona
114.591 kB
36.435 kB
react
PersonaCoin
114.591 kB
36.435 kB
react
PersonaPresence
58.076 kB
19.372 kB
react
Pickers
297.91 kB
82.996 kB
react
Pivot
187.734 kB
56.5 kB
react
Popup
12.312 kB
4.197 kB
react
Positioning
22.764 kB
7.683 kB
react
PositioningContainer
73.445 kB
23.685 kB
react
ProgressIndicator
39.477 kB
13.528 kB
react
Rating
82.086 kB
26.09 kB
react
Fluent UI React (entire library)
1.019 MB
283.183 kB
react
ResizeGroup
13.35 kB
4.379 kB
react
ResponsiveMode
8.13 kB
2.966 kB
react
ScrollablePane
55.541 kB
17.718 kB
react
SearchBox
187.63 kB
55.936 kB
react
SelectableOption
724 B
413 B
react
SelectedItemsList
231.35 kB
67.176 kB
react
Selection
42.418 kB
12.26 kB
react
Separator
35.365 kB
12.132 kB
react
Shimmer
49.249 kB
16.258 kB
react
ShimmeredDetailsList
240.71 kB
68.549 kB
react
Slider
57.627 kB
19.198 kB
react
SpinButton
191.297 kB
57.006 kB
react
Spinner
41.759 kB
14.468 kB
react
Stack
42.039 kB
14.389 kB
react
Sticky
32.577 kB
10.488 kB
react
Styling
46.033 kB
15.135 kB
react
SwatchColorPicker
189.637 kB
57.417 kB
react
TeachingBubble
204.648 kB
60.317 kB
react
Text
36.886 kB
12.806 kB
react
TextField
80.798 kB
25.308 kB
react
Theme
43.486 kB
14.168 kB
react
ThemeGenerator
12.384 kB
4.116 kB
react
TimePicker
240.515 kB
69.311 kB
react
Toggle
46.201 kB
15.957 kB
react
Tooltip
87.073 kB
28.151 kB
react
Utilities
82.938 kB
25.15 kB
react
Viewport
23.872 kB
7.642 kB
react
WeeklyDayPicker
101.348 kB
31.644 kB
react
WindowProvider
1.059 kB
541 B
react-accordion
Accordion (including children components)
91.136 kB
28.716 kB
react-aria
ARIA - useARIAButtonProps
1.354 kB
648 B
react-aria
ARIA - AriaLiveAnnouncer
3.293 kB
1.507 kB
react-avatar
Avatar
47.215 kB
14.859 kB
react-avatar
AvatarGroup
16.316 kB
6.528 kB
react-avatar
AvatarGroupItem
60.361 kB
18.763 kB
react-badge
Badge
22.978 kB
7.355 kB
react-badge
CounterBadge
23.698 kB
7.61 kB
react-badge
PresenceBadge
22.938 kB
8.301 kB
react-breadcrumb
@fluentui/react-breadcrumb - package
102.926 kB
28.76 kB
react-button
Button
32.684 kB
8.504 kB
react-button
CompoundButton
39.562 kB
9.861 kB
react-button
MenuButton
37.584 kB
9.898 kB
react-button
SplitButton
46.383 kB
11.555 kB
react-button
ToggleButton
52.344 kB
10.635 kB
react-calendar-compat
Calendar Compat
138.004 kB
37.657 kB
react-card
Card - All
93.06 kB
26.955 kB
react-card
Card
85.658 kB
25.044 kB
react-card
CardFooter
11.516 kB
4.606 kB
react-card
CardHeader
14.047 kB
5.435 kB
react-card
CardPreview
11.597 kB
4.716 kB
react-charting
AreaChart
302.827 kB
94.751 kB
react-charting
ChartHoverCard
37.196 kB
12.7 kB
react-charting
DeclarativeChart
677.243 kB
191.438 kB
react-charting
DonutChart
203.703 kB
63.757 kB
react-charting
GanttChart
282.793 kB
88.76 kB
react-charting
GaugeChart
197.055 kB
61.221 kB
react-charting
GroupedVerticalBarChart
294.747 kB
91.91 kB
react-charting
HeatMapChart
285.643 kB
89.439 kB
react-charting
HorizontalBarChart
127.266 kB
39.944 kB
react-charting
HorizontalBarChartWithAxis
293.933 kB
91.166 kB
react-charting
Legends
151.481 kB
46.399 kB
react-charting
LineChart
332.434 kB
101.789 kB
react-charting
MultiStackedBarChart
181.933 kB
55.389 kB
react-charting
PieChart
134.305 kB
42.299 kB
react-charting
PolarChart
235.149 kB
74.293 kB
react-charting
SankeyChart
158.002 kB
49.166 kB
react-charting
ScatterChart
289 kB
91.071 kB
react-charting
Sparkline
87.616 kB
29.671 kB
react-charting
StackedBarChart
175.618 kB
52.99 kB
react-charting
TreeChart
84.809 kB
26.636 kB
react-charting
VerticalBarChart
303.585 kB
93.172 kB
react-charting
VerticalStackedBarChart
300.791 kB
92.923 kB
react-charts
AreaChart
401.77 kB
125.325 kB
react-charts
DeclarativeChart
752.718 kB
219.767 kB
react-charts
DonutChart
312.178 kB
96.155 kB
react-charts
FunnelChart
303.731 kB
92.988 kB
react-charts
GanttChart
384.859 kB
119.806 kB
react-charts
GaugeChart
311.611 kB
95.553 kB
react-charts
GroupedVerticalBarChart
392.728 kB
122.511 kB
react-charts
HeatMapChart
386.946 kB
120.706 kB
react-charts
HorizontalBarChart
291.905 kB
88.605 kB
react-charts
HorizontalBarChartWithAxis
63 B
83 B
react-charts
Legends
231.58 kB
69.461 kB
react-charts
LineChart
413.088 kB
128.366 kB
react-charts
PolarChart
340.572 kB
106.052 kB
react-charts
SankeyChart
211.914 kB
67.836 kB
react-charts
ScatterChart
392.471 kB
122.487 kB
react-charts
Sparkline
80.503 kB
26.644 kB
react-charts
VerticalBarChart
429.215 kB
127.37 kB
react-charts
VerticalStackedBarChart
398.933 kB
123.933 kB
react-checkbox
Checkbox
29.624 kB
10.537 kB
react-color-picker
ColorArea
43.436 kB
15.728 kB
react-color-picker
ColorPicker
15.005 kB
6.057 kB
react-color-picker
ColorSlider
38.424 kB
14.209 kB
react-combobox
Combobox (including child components)
101.09 kB
33.224 kB
react-combobox
Dropdown (including child components)
100.858 kB
33.003 kB
react-components
react-components: Button, FluentProvider & webLightTheme
66.328 kB
19.02 kB
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
226.19 kB
67.909 kB
react-components
react-components: FluentProvider & webLightTheme
39.525 kB
13.113 kB
react-components
react-components: entire library
1.293 MB
324.817 kB
react-datepicker-compat
DatePicker Compat
212.369 kB
61.552 kB
react-dialog
Dialog (including children components)
90.204 kB
27.895 kB
react-divider
Divider
15.08 kB
5.41 kB
react-field
Field
21.127 kB
7.887 kB
react-image
Image
12.349 kB
5.023 kB
react-input
Input
25.019 kB
8.175 kB
react-jsx-runtime
Classic Pragma
1.101 kB
550 B
react-jsx-runtime
JSX Dev Runtime
1.914 kB
908 B
react-jsx-runtime
JSX Runtime
1.314 kB
601 B
react-label
Label
11.696 kB
4.74 kB
react-link
Link
14.914 kB
5.939 kB
react-list
List
74.655 kB
23.01 kB
react-list
ListItem
98.104 kB
29.845 kB
react-menu
Menu (including children components)
159.755 kB
50.626 kB
react-menu
Menu (including selectable components)
162.933 kB
51.253 kB
react-message-bar
MessageBar (all components)
22.306 kB
8.184 kB
react-motion
@fluentui/react-motion - createMotionComponent()
4.339 kB
1.881 kB
react-motion
@fluentui/react-motion - createPresenceComponent()
6.091 kB
2.501 kB
react-motion
@fluentui/react-motion - PresenceGroup
1.727 kB
823 B
react-overflow
hooks only
11.966 kB
4.565 kB
react-persona
Persona
54.17 kB
16.793 kB
react-popover
Popover
125.741 kB
40.448 kB
react-portal
Portal
12.731 kB
4.963 kB
react-portal-compat
PortalCompatProvider
5.567 kB
2.237 kB
react-positioning
usePositioning
28.889 kB
10.158 kB
react-positioning
useSafeZoneArea
12.445 kB
5 kB
react-progress
ProgressBar
19.127 kB
7.436 kB
react-provider
FluentProvider
18.911 kB
7.278 kB
react-radio
Radio
27.012 kB
8.731 kB
react-radio
RadioGroup
12.774 kB
5.184 kB
react-select
Select
24.903 kB
8.951 kB
react-slider
Slider
32.259 kB
11.103 kB
react-spinbutton
SpinButton
32.544 kB
10.615 kB
react-spinner
Spinner
22.441 kB
7.35 kB
react-swatch-picker
@fluentui/react-swatch-picker - package
92.162 kB
27.267 kB
react-switch
Switch
32.256 kB
10.183 kB
react-table
DataGrid
147.069 kB
43.605 kB
react-table
Table (Primitives only)
36.931 kB
12.324 kB
react-table
Table as DataGrid
118.7 kB
33.348 kB
react-table
Table (Selection only)
65.492 kB
18.622 kB
react-table
Table (Sort only)
64.135 kB
18.232 kB
react-tag-picker
@fluentui/react-tag-picker - package
173.435 kB
54.047 kB
react-tags
InteractionTag
12.464 kB
4.95 kB
react-tags
Tag
28.389 kB
8.911 kB
react-tags
TagGroup
69.871 kB
21.442 kB
react-teaching-popover
TeachingPopover
101.1 kB
31.859 kB
react-text
Text - Default
14.036 kB
5.461 kB
react-text
Text - Wrappers
17.195 kB
5.772 kB
react-textarea
Textarea
23.409 kB
8.452 kB
react-theme
Single theme token import
69 B
89 B
react-theme
Teams: all themes
37.985 kB
7.895 kB
react-theme
Teams: Light theme
20.803 kB
5.851 kB
react-timepicker-compat
TimePicker
104.049 kB
34.748 kB
react-toast
Toast (including Toaster)
90.593 kB
28.142 kB
react-tooltip
Tooltip
53.183 kB
18.848 kB
react-tree
FlatTree
135.901 kB
40.456 kB
react-tree
PersonaFlatTree
137.729 kB
40.969 kB
react-tree
PersonaTree
133.79 kB
39.742 kB
react-tree
Tree
131.968 kB
39.259 kB
react-utilities
SSRProvider
180 B
160 B
🤖 This report was generated against 9317e51771e26f6142b72bb6f689aa9731218273

@github-actions
Copy link
Copy Markdown

Pull request demo site: URL

@@ -35,6 +35,7 @@ const __internal = {
/** @type {import('eslint').Linter.RulesRecord} */
Copy link
Copy Markdown

@github-actions github-actions Bot May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Charts-DonutChart 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic.default.chromium.png 5581 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png 413 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default - RTL.submenus open.chromium.png 599 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 505 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 839 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 89 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 95 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 41 Changed
vr-tests-react-components/Skeleton converged 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Skeleton converged.Opaque Skeleton with rectangle - Dark Mode.default.chromium.png 9 Changed
vr-tests-react-components/TagPicker 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.disabled input hover.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled - High Contrast.disabled input hover.chromium.png 1319 Changed
vr-tests-react-components/TagPicker.disabled.chromium.png 677 Changed
vr-tests-web-components/Badge 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-web-components/Badge. - Dark Mode.normal.chromium.png 443 Changed
vr-tests-web-components/MenuList 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-web-components/MenuList. - RTL.1st selected.chromium_2.png 39384 Changed
vr-tests-web-components/MenuList. - RTL.normal.chromium_1.png 39083 Changed
vr-tests-web-components/MenuList. - RTL.2nd selected.chromium_3.png 38816 Changed
vr-tests-web-components/RadioGroup 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-web-components/RadioGroup. - Dark Mode.normal.chromium_1.png 89 Changed
vr-tests/Callout 8 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests/Callout.Right center.default.chromium.png 2117 Changed
vr-tests/Callout.Gap space 25.default.chromium.png 2195 Changed
vr-tests/Callout.Left bottom edge.default.chromium.png 3182 Changed
vr-tests/Callout.Right top edge.default.chromium.png 1126 Changed
vr-tests/Callout.No beak.default.chromium.png 2192 Changed
vr-tests/Callout.Root.default.chromium.png 2195 Changed
vr-tests/Callout.Top center.default.chromium.png 2127 Changed
vr-tests/Callout.Top auto edge.default.chromium.png 2212 Changed
vr-tests/Keytip 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests/Keytip.Disabled.default.chromium.png 26 Changed
vr-tests/Keytip.Root.default.chromium.png 51 Changed
vr-tests/Pivot - Overflow 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests/Pivot - Overflow.Tabs - RTL.chromium.png 4471 Changed
vr-tests/react-charting-GaugeChart 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests/react-charting-GaugeChart.Basic.default.chromium.png 2 Changed
vr-tests/react-charting-LineChart 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests/react-charting-LineChart.Multiple - Dark Mode.default.chromium.png 181 Changed
vr-tests/react-charting-LineChart.Multiple - RTL.default.chromium.png 200 Changed
vr-tests/react-charting-VerticalBarChart 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests/react-charting-VerticalBarChart.Basic - Secondary Y Axis.default.chromium.png 3 Changed

There were 3 duplicate changes discarded. Check the build logs for more information.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new workspace ESLint rule to enforce the v9 “base hook” contract (use<Name>Base_unstable) and wires it into the repo’s React lint override, including typed (TS Program-backed) transitive runtime-dependency detection.

Changes:

  • Added consistent-base-hook ESLint rule with typed (transitive import graph) and untyped fallbacks, plus a fixture-based typed test suite.
  • Registered and enabled the rule in the workspace rules index and the packages/eslint-plugin internal React override.
  • Added targeted suppressions for known legacy violations and adjusted lint tsconfig to exclude rule fixtures.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tools/eslint-rules/tsconfig.lint.json Excludes __fixtures__ from the eslint-rules TS lint project.
tools/eslint-rules/rules/consistent-callback-type.ts Makes TSESTree a type-only import (no runtime change).
tools/eslint-rules/rules/consistent-base-hook.ts Implements the new rule (param contract + forbidden runtime dependency detection, including typed transitive analysis + caching).
tools/eslint-rules/rules/consistent-base-hook.spec.ts Adds RuleTester coverage for both untyped and typed (fixture-backed) paths.
tools/eslint-rules/rules/fixtures/consistent-base-hook/tsconfig.json Fixture TS project for typed RuleTester cases (path-mapped stub packages).
tools/eslint-rules/rules/fixtures/consistent-base-hook/stubs/watched-pkg/light.ts Stub “watched” package module that stays clear of forbidden runtime.
tools/eslint-rules/rules/fixtures/consistent-base-hook/stubs/watched-pkg/index.ts Stub watched package barrel (includes type-only re-exports).
tools/eslint-rules/rules/fixtures/consistent-base-hook/stubs/watched-pkg/heavy.ts Stub watched module that imports the forbidden runtime to trigger reach detection.
tools/eslint-rules/rules/fixtures/consistent-base-hook/stubs/light-helper/index.ts Stub helper package used by the “light” watched module.
tools/eslint-rules/rules/fixtures/consistent-base-hook/stubs/heavy-runtime/index.ts Stub forbidden runtime package for typed tests.
tools/eslint-rules/rules/fixtures/consistent-base-hook/stubs/cyclic-pkg/index.ts Stub package with cyclic re-exports to validate cycle safety.
tools/eslint-rules/rules/fixtures/consistent-base-hook/stubs/cyclic-pkg/b.ts Part of cyclic stub graph used by cycle-safety test.
tools/eslint-rules/rules/fixtures/consistent-base-hook/stubs/cyclic-pkg/a.ts Part of cyclic stub graph used by cycle-safety test.
tools/eslint-rules/rules/fixtures/consistent-base-hook/src/test.ts Placeholder file so typed RuleTester filenames exist in the fixture Program.
tools/eslint-rules/rules/fixtures/consistent-base-hook/src/dummy.ts Additional placeholder fixture source file.
tools/eslint-rules/index.ts Registers the new workspace rule export.
packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipBase.tsx Adjusts the useTooltipBase_unstable “use no memo” directive statement.
packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts Adds scoped suppressions for legacy tabster usage inside a base hook.
packages/eslint-plugin/src/internal.js Enables @nx/workspace-consistent-base-hook under the internal React override.

Comment thread tools/eslint-rules/rules/consistent-base-hook.ts
Hotell added 14 commits May 26, 2026 12:39
Enforces the v9 base hook API contract for any function matching
`use<Name>Base_unstable`:

- 1 or 2 positional parameters; first must be named `props`,
  optional second must be named `ref` and typed as `React.Ref<...>`.
- No references to bindings imported from configured forbidden
  packages (defaults to `@fluentui/react-tabster`, `tabster`,
  `keyborg`). Forbidden-package detection is scope-based, so
  local shadowing is handled correctly.

Options:
  { forbiddenPackages?: Array<string | { name: string; allow?: string[] }> }
…legacy base hooks

Pre-existing base hooks that don't yet conform to the new contract
(missing/non-React.Ref-typed `ref` param, or references to
tabster/keyborg bindings) are suppressed with line-scoped
`eslint-disable-next-line` comments so the rule can ship as
`error` for new code. Refactors to remove the suppressions will
follow.

Affected packages:
- react-checkbox, react-menu, react-radio, react-rating,
  react-slider, react-switch, react-tags, react-tooltip
…e-hook

`useFocusWithin` from `@fluentui/react-tabster` is a pure ref-attaching
utility with no global side effects; it's safe to use inside base hooks.
Added to the default allowlist so callers don't need to opt-out per-site.
…essions

`useFocusWithin` is now part of the rule's default allowlist, so the
line-scoped suppressions in react-checkbox, react-radio, react-rating,
react-slider, and react-switch are no longer needed.
…se-hook

Same rationale as `useFocusWithin`: `useFocusVisible` from
`@fluentui/react-tabster` is safe to use inside base hooks.
…yborg from forbidden list

- Remove `keyborg` from the default forbidden packages list. Base hooks
  may freely depend on `keyborg` since it carries no tabster runtime.
- Expand the default allowlist for `@fluentui/react-tabster` with APIs
  that internally depend only on `keyborg` (no `tabster` runtime):
    - useKeyboardNavAttribute
    - useIsNavigatingWithKeyboard
    - useSetKeyboardNavigation
    - useOnKeyboardNavigationChange
    - applyFocusVisiblePolyfill
    - KEYBORG_FOCUSIN / KeyborgFocusInEvent (re-exports from `keyborg`)
…ions

`useIsNavigatingWithKeyboard`, `KEYBORG_FOCUSIN`, and `KeyborgFocusInEvent`
are now part of the rule's default allowlist.
… in consistent-base-hook

Replace the hand-curated allow/forbidden package lists with per-symbol transitive value-import analysis powered by typescript-eslint ParserServices and the TypeScript Program. The rule now traces each base-hook import back to its defining source file and walks non-type-only re-exports/imports to detect whether the symbol's module graph reaches any configured forbidden runtime package.

Options shape: { watchedPackages?: string[]; forbiddenRuntimes?: string[] }. Defaults: watchedPackages=['@fluentui/react-tabster'], forbiddenRuntimes=['tabster']. New message ids: forbiddenRuntimeDirect, forbiddenRuntimeReach. Type-only imports are skipped. When typed services are unavailable, only direct forbidden-runtime imports are reported.
By default (allowTypeImports: false) type-only imports from forbiddenRuntimes packages are disallowed inside base hooks, to keep the base hook's public API fully decoupled from those packages. Setting allowTypeImports: true restores the previous behavior of skipping type-only imports. Type-only imports from watched packages are unaffected (types cannot transitively pull runtime).
…; fix tooltip directive

- isReactRefTypeAnnotation now resolves the Ref/React identifier via scope analysis and requires it to be imported from 'react'. Adds tests for local aliases, non-react imports, and shadowed React.- useTooltipBase_unstable: use bare 'use no memo' directive prologue instead of parenthesized expression so React Compiler detects it.
…onsistent-base-hook

Extend the rule's transitive analysis so type references in a base hook
signature are also checked against forbidden runtimes when they originate
from a watched package whose defining module reaches a forbidden runtime
(via either value or type imports).

- Track type-only imports from watched packages (previously skipped).
- Per-Program cache now stores { value, all } reach sets, filled in a
  single DFS pass so the new analysis adds no extra resolution work.
- visitScope picks the reach set based on reference.isTypeReference:
  value refs use value reach (runtime coupling), type refs use all
  reach (API-surface coupling).
- allowTypeImports is now symmetric: when true, type-only imports are
  exempt from BOTH direct forbidden checks AND transitive watched
  reach checks.
- New tests cover symmetric exemption, type-only watched -> clean
  module (allowed), and indirect type leakage via HeavyWrapper whose
  defining file value-re-exports the heavy module.
@Hotell Hotell force-pushed the tools/ws-lint/consistent-base-hook branch from 1429791 to 1084d65 Compare May 26, 2026 10:40
@Hotell Hotell closed this May 26, 2026
@Hotell Hotell reopened this May 26, 2026
…k signature detection

Wrapping state hooks (`use<X>_unstable`) that pair with a base hook
(`use<X>Base_unstable`) must also follow the `(props, ref)` signature
contract. Pair detection checks the same file first, then sibling
files (`.ts`/`.tsx`) in the same directory.

Suppress the violation on `useTagGroupBase_unstable`, which takes a
third `options` argument used internally by `useTagGroup_unstable`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants