Skip to content

fix(eslint-rules): analyze base hook dependencies per symbol, not per file - #36498

Draft
Hotell wants to merge 1 commit into
microsoft:masterfrom
Hotell:fix/base-hook-rule-transitive-tracking
Draft

fix(eslint-rules): analyze base hook dependencies per symbol, not per file#36498
Hotell wants to merge 1 commit into
microsoft:masterfrom
Hotell:fix/base-hook-rule-transitive-tracking

Conversation

@Hotell

@Hotell Hotell commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Previous Behavior

@nx/workspace-base-hook-no-forbidden-runtime reported 0 violations across the whole repo, including the one it was written to catch:

useDropdownBase_unstable
  -> ./useButtonTriggerSlot          (relative)
    -> ../../utils/useTriggerSlot    (relative)
      -> @fluentui/react-tabster useTabsterAttributes
        -> useTabster -> tabster

Two defects caused this.

1. Imports were only tracked when the specifier exactly matched a watchedPackages allowlist.

const source = node.source.value;
if (typeof source !== 'string' || !trackedPackages.has(source)) {
  return; // everything else dropped
}

trackedPackages was {'@fluentui/react-tabster', 'tabster'}, so relative imports and every other package were never analyzed. The transitive machinery could only fire when the directly imported package was already @fluentui/react-tabster — i.e. only for the case a plain no-restricted-imports already covers. Subpath specifiers (tabster/sub) also slipped through, since the check compared the raw specifier rather than the package name.

2. Reach was computed from the file declaring the imported symbol, not the symbol.

A binding inherited every dependency of its defining module and of every barrel it was re-exported through. useButton.ts imports useARIAButtonProps from @fluentui/react-aria; because the react-aria barrel also exports useActiveDescendant, useButtonBase_unstable was reported as tabster-dependent. Same for a symbol referencing an unrelated sibling export of its own file.

New Behavior

Reach is computed per symbol. The walk starts at the symbol's own declaration and follows only the identifiers that declaration references. Alias hops resolve through barrels to the leaf declaration, so a clean export never inherits its siblings' dependencies. Forbidden runtimes are detected at module-specifier boundaries and via the leaf declaration's owning package (catching export { x } from 'tabster' chains that getAliasedSymbol collapses).

watchedPackages is removed — every import is analyzed, relative ones included.

Deliberate bounds: namespace bindings (import * as ns) and whole-module symbols stop at the specifier check, since they bind a module rather than a symbol. Without this, React.useRef walks into React's type graph and hits tabster's declaration-merged augmentations, producing cannot reference `*` from `react` because `*` depends on forbidden runtime `tabster`. Declaration files are traversed, so type coupling is still caught when a package resolves to built output.

forbiddenRuntimes stays ['tabster']. Wrapper packages are intentionally not banned: at symbol granularity, useOnKeyboardNavigationChange -> useKeyborgRef -> keyborg is legitimately clean, while useTabsterAttributes -> useTabster -> tabster is not. Banning @fluentui/react-tabster wholesale would reintroduce exactly the conflation this PR removes.

Findings

6 violations across 3 packages, each with a verified chain:

Location Symbol Enters forbidden runtime at
react-combobox useDropdown.tsx:75 useButtonTriggerSlot react-tabster/src/hooks/useTabster.ts
react-combobox useCombobox.tsx:76 useInputTriggerSlot react-tabster/src/hooks/useTabster.ts
react-tag-picker useTagPickerButton.tsx:37 useButtonTriggerSlot react-tabster/src/hooks/useTabster.ts
react-tag-picker useTagPickerInput.tsx:79 useInputTriggerSlot react-tabster/src/hooks/useTabster.ts
react-avatar useAvatarGroupPopover.tsx:74 AvatarGroupPopoverBaseProps / BaseState react-tabster/src/hooks/useModalAttributes.ts

Verified by reading react-tabster sources that everything keyborg-only is correctly silent — useActiveDescendant, useListboxSlot, Listbox, useFocusWithin, useIsNavigatingWithKeyboard, createFocusOutlineStyle.

These are reported, not fixed, in this PR. Follow-up needed; four of the six share one root cause (useTriggerSlot calling useTabsterAttributes).

Performance

The symbol walk is memoized per binding and per ts.Program. Measured on react-combobox (66 files) with TIMING=15 npx eslint src:

Rule Time (ms) Relative
@typescript-eslint/no-deprecated 1298.800 48.7%
react-hooks/static-components 738.624 27.7%
compat/compat 132.010 4.9%
@nx/workspace-base-hook-no-forbidden-runtime 87.984 3.3%
@typescript-eslint/naming-convention 70.976 2.7%
import/no-extraneous-dependencies 50.545 1.9%
react-hooks/rules-of-hooks 50.042 1.9%

End-to-end A/B on the same package:

Run Time
npx eslint src with rule off 8.97s
npx eslint src with rule on 9.16s

~190ms, ~2% of lint time. For context, ~4.7s of that 9s is fixed ESLint startup + TS Program construction (measured by linting a single file), and two unrelated rules account for 76% of all rule execution time.

Tests

98 passing. New coverage for the cases that previously had none:

  • relative multi-hop chain (./local-trigger -> ./local-heavy -> runtime) — the useDropdown shape
  • package barrel and local folder barrel: clean export valid, dirty sibling still reported
  • wrapper package with a benign export (useBenign -> useBenignRef -> benign dep) vs a deep one (useDeep -> useDeepInner -> runtime) — the keyborg/tabster split
  • relay package consuming only the benign export — the useActiveDescendant shape
  • clean sibling in the same file as a forbidden import
  • symbol declared in a .d.ts
  • unlisted bare package, subpath specifier

Existing type-leak fixtures were reshaped: HeavyType now genuinely embeds a forbidden-runtime type instead of merely living in a file that imports one.

Notes

  • No change file: tools/eslint-rules is private: true and packages/eslint-plugin is untouched. npx beachball check --branch master confirms.
  • packages/eslint-plugin/src/internal.js is unchanged ('error', no options). Merging this makes lint fail in 3 packages until the 6 findings are addressed.

Known limitations (follow-ups)

  • dynamic import() is not followed
  • side-effect imports (import './x') have no identifier to resolve, so they need a separate per-file pass
  • namespace member access (ns.foo) is not resolved; trivial to add, but needs a react/@types guard first or the React type-graph false positives return

Related Issue(s)

… file

`base-hook-no-forbidden-runtime` had two defects that made it miss the leaks it
exists to catch while reporting ones that were not real.

1. Imports were only tracked when the module specifier exactly matched a fixed
   `watchedPackages` allowlist. Relative imports and unlisted packages were never
   analyzed, so `useDropdownBase_unstable` -> `./useButtonTriggerSlot` ->
   `../../utils/useTriggerSlot` -> `@fluentui/react-tabster` went undetected.

2. Reach was computed from the *file* declaring the imported symbol. A binding
   therefore inherited every dependency of its defining module and of every barrel
   it was re-exported through, e.g. importing `useARIAButtonProps` from
   `@fluentui/react-aria` inherited `useActiveDescendant` -> tabster.

Reach is now computed from the symbol's own declaration, following only the
identifiers that declaration references. Alias hops resolve through barrels to the
leaf declaration, so a clean export no longer inherits its siblings' dependencies.
Forbidden runtimes are detected at module specifier boundaries and via the leaf
declaration's owning package. `watchedPackages` is removed; every import is analyzed.

The walk deliberately stops at namespace bindings (`import * as ns`) and whole-module
symbols, which bind an entire module rather than a symbol. Declaration files are
traversed, so type coupling is still detected when a package resolves to built output.

Also fixes subpath specifiers (`tabster/sub`) not being normalized to the package name,
and memoizes results per binding and per Program.

Repo-wide this moves the rule from 0 reported violations to 6, all with verified
chains into `tabster`. Measured cost is ~90ms per package (3.3% of rule time).
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

📊 Bundle size report

✅ No changes found

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Pull request demo site: URL

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