Skip to content

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

Draft
Hotell wants to merge 8 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 8 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.

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. Base hooks must remain free of dependencies on focus/keyboard infrastructure packages (@fluentui/react-tabster, tabster, keyborg); that responsibility belongs to the wrapping hook.

Rule contract

For any function matching /^use[A-Z]\w*Base_unstable$/:

  • 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).
  • Body: must not reference any binding imported from a forbidden 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.

Options

{
  forbiddenPackages?: Array<string | { name: string; allow?: string[] }>;
}

Defaults to ['@fluentui/react-tabster', 'tabster', 'keyborg']. Object form supports an allow list to whitelist specific named exports (e.g. utilities that don't bring focus-management runtime behavior).

Message ids

id when
invalidParamCount params.length is not 1 or 2
invalidParamName param #1 is not Identifier props, or param #2 is not Identifier ref
invalidRefType ref is present but missing a type annotation or not typed as React.Ref<...>
forbiddenPackageUsage base hook body references a binding imported from a forbidden package

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

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.*).

Pre-existing violators

8 legacy base hooks across react-checkbox, react-menu (useMenuTrigger), react-radio, react-rating, react-slider, react-switch, react-tags, react-tooltip don't yet conform (mostly: untyped/non-React.Ref ref param, or direct tabster/keyborg usage). 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.

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

Test coverage

tools/eslint-rules/rules/consistent-base-hook.spec.ts — 18 cases:

  • Valid (7): arrow + function-declaration form, 1-param form, Ref<> from react import, React.Ref<> via namespace, non-base hook not flagged, shadowing handled, allowlist opt-out.
  • Invalid (11): 0 params, 3 params, wrong names, ObjectPattern props, missing ref type, non-React.Ref ref type, React.ForwardedRef, each of the 3 default forbidden packages, allowlist excludes one name only.

Verification

  • yarn nx run eslint-rules:test --testPathPatterns=consistent-base-hook → 18/18 pass.
  • yarn nx run-many -t lint --projects=tag:vNext --skip-nx-cache → no consistent-base-hook errors, no failed tasks.

Commits (kept decoupled)

  1. feat(eslint-rules): add consistent-base-hook rule — rule + spec + registration
  2. chore(eslint-plugin): enable consistent-base-hook for v9 sources — plugin wiring
  3. chore(react-components): suppress consistent-base-hook violations in legacy base hooks — line-scoped disables

Follow-ups (not in this PR)

  • Refactor each suppressed base hook to actually conform (move tabster/keyborg usage to the wrapping use<Name>_unstable hook).
  • Consider extending the contract to also require the wrapping use<Name>_unstable hook signature in a sibling rule.

Hotell added 3 commits May 21, 2026 14:33
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
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 21, 2026

📊 Bundle size report

✅ No changes found

@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 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic.default.chromium.png 5581 Changed
vr-tests-react-components/Charts-DonutChart.Dynamic - Dark Mode.default.chromium.png 7530 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.chromium.png 968 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 308 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 80 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 78 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 93 Changed
vr-tests-react-components/SwatchPicker Converged 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/SwatchPicker Converged.Default.default.chromium.png 679 Changed
vr-tests-react-components/TagPicker 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled.disabled input hover.chromium.png 677 Changed
vr-tests-web-components/Accordion 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-web-components/Accordion. - Dark Mode.normal.chromium_1.png 3154 Changed
vr-tests-web-components/MenuList 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-web-components/MenuList. - RTL.2nd selected.chromium.png 17 Changed
vr-tests-web-components/RadioGroup 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-web-components/RadioGroup. - Dark Mode.2nd selected.chromium_3.png 119 Changed
vr-tests/Callout 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests/Callout.Top center.default.chromium.png 2127 Changed
vr-tests/Callout.Top auto edge.default.chromium.png 2212 Changed
vr-tests/react-charting-LineChart 4 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests/react-charting-LineChart.Events.default.chromium.png 1 Changed
vr-tests/react-charting-LineChart.Multiple - RTL.default.chromium.png 200 Changed
vr-tests/react-charting-LineChart.Multiple.default.chromium.png 192 Changed
vr-tests/react-charting-LineChart.Multiple - Dark Mode.default.chromium.png 181 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 5 duplicate changes discarded. Check the build logs for more information.

Hotell added 5 commits May 21, 2026 15:42
…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.
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.

1 participant