Skip to content
Draft
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.

rules: {
'@nx/workspace-consistent-callback-type': 'error',
'@nx/workspace-consistent-base-hook': 'error',
'@nx/workspace-no-restricted-globals': restrictedGlobals.react,
'@nx/workspace-no-missing-jsx-pragma': ['error', { runtime: 'automatic' }],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type UseMenuTriggerBaseOptions = {
*
* @public
*/
// eslint-disable-next-line @nx/workspace-consistent-base-hook -- legacy: second param is `options` instead of `ref`; refactor pending
export const useMenuTriggerBase_unstable = (
props: MenuTriggerProps,
options?: UseMenuTriggerBaseOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const useTagGroupBase_unstable = (

const innerRef = React.useRef<HTMLElement>(undefined);
const { targetDocument } = useFluent();
// eslint-disable-next-line @nx/workspace-consistent-base-hook -- legacy: tabster usage should be moved out of base hook
const { findNextFocusable, findPrevFocusable } = useFocusFinders();

const [items, setItems] = useControllableState<Array<TagValue>>({
Expand Down Expand Up @@ -80,6 +81,7 @@ export const useTagGroupBase_unstable = (
}),
);

// eslint-disable-next-line @nx/workspace-consistent-base-hook -- legacy: tabster usage should be moved out of base hook
const arrowNavigationProps = useArrowNavigationGroup({
circular: true,
axis: 'both',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { Escape } from '@fluentui/keyboard-keys';
* @param props - props from this instance of Tooltip
*/
export const useTooltipBase_unstable = (props: TooltipBaseProps): TooltipBaseState => {
'use no memo';
('use no memo');

const context = useTooltipVisibility();
const isServerSideRender = useIsSSR();
Expand Down
2 changes: 2 additions & 0 deletions tools/eslint-rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
RULE_NAME as consistentCallbackTypeName,
rule as consistentCallbackType,
} from './rules/consistent-callback-type';
import { RULE_NAME as consistentBaseHookName, rule as consistentBaseHook } from './rules/consistent-base-hook';

/**
* Import your custom workspace rules at the top of this file.
Expand Down Expand Up @@ -32,6 +33,7 @@ module.exports = {
*/
rules: {
[consistentCallbackTypeName]: consistentCallbackType,
[consistentBaseHookName]: consistentBaseHook,
[noRestrictedGlobalsName]: noRestrictedGlobals,
[noMissingJsxPragmaName]: noMissingJsxPragma,
},
Expand Down
248 changes: 248 additions & 0 deletions tools/eslint-rules/rules/consistent-base-hook.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { rule, RULE_NAME } from './consistent-base-hook';

const ruleTester = new RuleTester();

ruleTester.run(RULE_NAME, rule, {
valid: [
// Valid base hook: 2 Identifier params, no forbidden imports.
{
code: `
import * as React from 'react';
export const useThingBase_unstable = (props, ref: React.Ref<HTMLElement>) => {
return { props, ref };
};
`,
},
// Valid base hook declared as FunctionDeclaration.
{
code: `
import { Ref } from 'react';
export function useThingBase_unstable(props, ref: Ref<HTMLElement>) {
return { props, ref };
}
`,
},
// Valid base hook with only `props` (ref is optional).
{
code: `
export const useThingBase_unstable = (props) => {
return { props };
};
`,
},
// Forbidden import exists but is only used by a non-base hook in the same file.
{
code: `
import { useArrowNavigationGroup } from '@fluentui/react-tabster';
export const useThingBase_unstable = (props, ref: React.Ref<HTMLElement>) => {
return { props, ref };
};
export const useThing_unstable = (props, ref) => {
const nav = useArrowNavigationGroup({});
return { ...useThingBase_unstable(props, ref), nav };
};
`,
},
// Non-base hook is not subject to the param-shape constraint.
{
code: `
export const useThing_unstable = (props, ref, extra) => {
return { props, ref, extra };
};
`,
},
// Allowlist opt-out: a specific imported name is permitted inside base hooks.
{
code: `
import { useFocusFinders } from '@fluentui/react-tabster';
export const useThingBase_unstable = (props, ref: React.Ref<HTMLElement>) => {
const finders = useFocusFinders();
return { props, ref, finders };
};
`,
options: [
{
forbiddenPackages: [{ name: '@fluentui/react-tabster', allow: ['useFocusFinders'] }],
},
],
},
// Default allowlist: `useFocusWithin` and `useFocusVisible` from `@fluentui/react-tabster` are permitted inside base hooks.
{
code: `
import { useFocusWithin, useFocusVisible } from '@fluentui/react-tabster';
export const useThingBase_unstable = (props, ref: React.Ref<HTMLSpanElement>) => {
useFocusVisible();
return { props, ref: useFocusWithin<HTMLSpanElement>() };
};
`,
},
// `keyborg` is not forbidden by default — bindings imported from it are allowed inside base hooks.
{
code: `
import { createKeyborg, KEYBORG_FOCUSIN } from 'keyborg';
export const useThingBase_unstable = (props, ref: React.Ref<HTMLElement>) => {
return { kb: createKeyborg(window), evt: KEYBORG_FOCUSIN };
};
`,
},
// Identifier with the same local name as a forbidden import alias does not collide via scope analysis.
{
code: `
import { useArrowNavigationGroup } from '@fluentui/react-tabster';
export const useThing_unstable = (props, ref) => {
return useArrowNavigationGroup({});
};
export const useThingBase_unstable = (props, ref: React.Ref<HTMLElement>) => {
const useArrowNavigationGroup = () => 1;
return { value: useArrowNavigationGroup() };
};
`,
},
],
invalid: [
// Too few params (0).
{
code: `
export const useThingBase_unstable = () => ({});
`,
errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThingBase_unstable', actual: 0 } }],
},
// Too many params.
{
code: `
export const useThingBase_unstable = (props, ref, extra) => ({ props, ref, extra });
`,
errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThingBase_unstable', actual: 3 } }],
},
// Wrong param names.
{
code: `
export const useThingBase_unstable = (p, r) => ({ p, r });
`,
errors: [
{
messageId: 'invalidParamName',
data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: 'p' },
},
{
messageId: 'invalidParamName',
data: { hookName: 'useThingBase_unstable', index: 2, expected: 'ref', actual: 'r' },
},
],
},
// ObjectPattern for `props` is not allowed.
{
code: `
export const useThingBase_unstable = ({ a }, ref: React.Ref<HTMLElement>) => ({ a, ref });
`,
errors: [
{
messageId: 'invalidParamName',
data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: '{ ... }' },
},
],
},
// `ref` parameter without a type annotation.
{
code: `
export const useThingBase_unstable = (props, ref) => ({ props, ref });
`,
errors: [
{
messageId: 'invalidRefType',
data: { hookName: 'useThingBase_unstable', actual: '<missing type annotation>' },
},
],
},
// `ref` parameter typed as something other than React.Ref.
{
code: `
export const useThingBase_unstable = (props, ref: HTMLElement) => ({ props, ref });
`,
errors: [
{
messageId: 'invalidRefType',
data: { hookName: 'useThingBase_unstable', actual: 'HTMLElement' },
},
],
},
// `ref` parameter typed as React.ForwardedRef (must be React.Ref).
{
code: `
export const useThingBase_unstable = (props, ref: React.ForwardedRef<HTMLElement>) => ({ props, ref });
`,
errors: [
{
messageId: 'invalidRefType',
data: { hookName: 'useThingBase_unstable', actual: 'React.ForwardedRef' },
},
],
},
// Body uses a binding imported from @fluentui/react-tabster.
{
code: `
import { useArrowNavigationGroup } from '@fluentui/react-tabster';
export const useThingBase_unstable = (props, ref: React.Ref<HTMLElement>) => {
const nav = useArrowNavigationGroup({});
return { props, ref, nav };
};
`,
errors: [
{
messageId: 'forbiddenPackageUsage',
data: {
hookName: 'useThingBase_unstable',
importedName: 'useArrowNavigationGroup',
package: '@fluentui/react-tabster',
},
},
],
},
// Body uses a binding imported from tabster, even when aliased.
{
code: `
import { getTabsterAttribute as gta } from 'tabster';
export function useThingBase_unstable(props, ref: React.Ref<HTMLElement>) {
return { attr: gta({}) };
}
`,
errors: [
{
messageId: 'forbiddenPackageUsage',
data: {
hookName: 'useThingBase_unstable',
importedName: 'getTabsterAttribute',
package: 'tabster',
},
},
],
},
// Allowlist excludes one name; siblings still error.
{
code: `
import { useFocusFinders, useArrowNavigationGroup } from '@fluentui/react-tabster';
export const useThingBase_unstable = (props, ref: React.Ref<HTMLElement>) => {
const finders = useFocusFinders();
const nav = useArrowNavigationGroup({});
return { props, ref, finders, nav };
};
`,
options: [
{
forbiddenPackages: [{ name: '@fluentui/react-tabster', allow: ['useFocusFinders'] }],
},
],
errors: [
{
messageId: 'forbiddenPackageUsage',
data: {
hookName: 'useThingBase_unstable',
importedName: 'useArrowNavigationGroup',
package: '@fluentui/react-tabster',
},
},
],
},
],
});
Loading