Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/perf-test-react-components/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
"@typescript-eslint/explicit-module-boundary-types": "off",
"no-console": "off",
"@nx/workspace-no-restricted-globals": "off",
"@nx/workspace-enforce-use-client": "off"
"@fluentui/react-components/enforce-use-client": "off"
}
}
2 changes: 1 addition & 1 deletion apps/public-docsite-v9/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"@typescript-eslint/explicit-module-boundary-types": "off",
"import/no-extraneous-dependencies": ["error", { "packageDir": [".", "../.."] }],
"@typescript-eslint/no-deprecated": "off",
"@nx/workspace-enforce-use-client": "off"
"@fluentui/react-components/enforce-use-client": "off"
}
}
2 changes: 1 addition & 1 deletion apps/rit-tests-v9/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"root": true,
"ignorePatterns": ["!**/*", "src/react-18/**/*", "src/react-17/**/*"],
"rules": {
"@nx/workspace-enforce-use-client": "off",
"@fluentui/react-components/enforce-use-client": "off",
"@nx/workspace-no-restricted-globals": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"import/no-extraneous-dependencies": "off",
Expand Down
2 changes: 1 addition & 1 deletion apps/vr-tests-react-components/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"import/no-extraneous-dependencies": ["error", { "packageDir": [".", "../.."] }],
"@nx/workspace-no-restricted-globals": "off",
"@typescript-eslint/no-deprecated": "off",
"@nx/workspace-enforce-use-client": "off"
"@fluentui/react-components/enforce-use-client": "off"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "chore: update enforce-use-client eslint rule name",
"packageName": "@fluentui/babel-preset-global-context",
"email": "[email protected]",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add enforce-use-client workspace rule",
"packageName": "@fluentui/eslint-plugin-react-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "chore: update enforce-use-client eslint rule name",
"packageName": "@fluentui/react-storybook-addon",
"email": "[email protected]",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "chore: update enforce-use-client eslint rule name",
"packageName": "@fluentui/react-storybook-addon-export-to-sandbox",
"email": "[email protected]",
"dependentChangeType": "none"
}
1 change: 1 addition & 0 deletions packages/eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dependencies": {
"@eslint/compat": "1.3.0",
"@griffel/eslint-plugin": "^2.0.0",
"@fluentui/eslint-plugin-react-components": "^0.1.4",
"@rnx-kit/eslint-plugin": "^0.8.4",
"@nx/eslint-plugin": "20.8.1",
"@typescript-eslint/type-utils": "^8.46.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/flat-configs/react/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const griffelPlugin = require('@griffel/eslint-plugin');
const configHelpers = require('../../utils/configHelpers');
const { fixupPluginRules } = require('@eslint/compat');
const { defineConfig } = require('eslint/config');
const reactComponentsPlugin = require('@fluentui/eslint-plugin-react-components');

/** @type { import("eslint").Linter.Config } */
module.exports = defineConfig(
Expand All @@ -16,6 +17,7 @@ module.exports = defineConfig(
'@griffel': fixupPluginRules(/** @type {any} */ (griffelPlugin)),
'jsx-a11y': jsxA11yPlugin,
'react-hooks': reactHooksPlugin,
'@fluentui/react-components': reactComponentsPlugin,
},
languageOptions: {
parserOptions: {
Expand Down
4 changes: 4 additions & 0 deletions packages/eslint-plugin/src/flat-configs/react/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ module.exports = defineConfig(
},
],
'@fluentui/ban-instanceof-html-element': ['error'],
'@fluentui/react-components/enforce-use-client': ['error'],
'@fluentui/no-context-default-value': [
'error',
{
Expand Down Expand Up @@ -78,6 +79,7 @@ module.exports = defineConfig(
},
],
'react-compiler/react-compiler': 'off',
'@fluentui/react-components/enforce-use-client': 'off',
},
},
{
Expand All @@ -98,13 +100,15 @@ module.exports = defineConfig(
],
},
],
'@fluentui/react-components/enforce-use-client': 'off',
},
},

{
files: ['**/*.test.{ts,tsx}'],
rules: {
'react-compiler/react-compiler': 'off',
'@fluentui/react-components/enforce-use-client': 'off',
},
},
__internal.overrides.react,
Expand Down
1 change: 0 additions & 1 deletion packages/eslint-plugin/src/internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ const __internal = {
'@nx/workspace-consistent-callback-type': 'error',
'@nx/workspace-no-restricted-globals': restrictedGlobals.react,
'@nx/workspace-no-missing-jsx-pragma': ['error', { runtime: 'automatic' }],
'@nx/workspace-enforce-use-client': 'error',
},
}
: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module.exports = [
...fluentPlugin.configs['flat/node'],
{
rules: {
'@nx/workspace-enforce-use-client': 'off',
'@fluentui/react-components/enforce-use-client': 'off',
},
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,63 @@ import { DefaultButton } from '@fluentui/react';
const Component = () => <DefaultButton>...</DefaultButton>;
```

### enforce-use-client

Ensures that source files using client-only React features begin with the top-level `'use client'` directive, and flags files that include the directive unnecessarily.

The rule looks for any of the following client-only features:

- React client hooks and APIs (e.g. `useState`, `useEffect`, `useRef`, `forwardRef`, `memo`)
- Custom hooks (functions whose name starts with `use` and are not in the safe set: `use`, `useId`)
- JSX event handler props (properties starting with `on` followed by a capital letter, like `onClick`)
- Direct references to browser globals (`window`, `document`, `navigator`, `localStorage`, `sessionStorage`, `history`, `location`)

If at least one feature is present, the directive must be the very first statement in the file. If no features are found, any existing `'use client'` directive will be reported as unnecessary and auto-fixed.

#### ❌ Don't (missing directive)

```ts
import * as React from 'react';

export function MyComponent() {
const [value, setValue] = React.useState('');
return <button onClick={() => setValue('clicked')}>{value}</button>;
}
```

#### ✅ Do (directive present)

```ts
'use client';
import * as React from 'react';

export function MyComponent() {
const [value, setValue] = React.useState('');
return <button onClick={() => setValue('clicked')}>{value}</button>;
}
```

#### ❌ Don't (unnecessary directive)

```ts
'use client';
// Pure utilities – no client-only APIs
export function add(a: number, b: number) {
return a + b;
}
```

#### ✅ Do (directive removed)

```ts
// Pure utilities – no client-only APIs
export function add(a: number, b: number) {
return a + b;
}
```

No options – enable to enforce consistent usage of the directive.

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,34 @@

```ts

import type { ESLint } from 'eslint';
import { RuleListener } from '@typescript-eslint/utils/dist/ts-eslint';
import { RuleModule } from '@typescript-eslint/utils/dist/ts-eslint';

// @public (undocumented)
export const plugin: {
meta: {
name: string;
version: string;
export const configs: {
recommended: {
plugins: string[];
rules: {};
};
configs: {
recommended: {
plugins: string[];
rules: {};
'flat/recommended': {
plugins: {
[x: string]: ESLint.Plugin;
};
rules: {};
};
rules: {
"prefer-fluentui-v9": RuleModule<"replaceFluent8With9" | "replaceIconWithJsx" | "replaceStackWithFlex" | "replaceFocusZoneWithTabster", {}[], unknown, RuleListener>;
};
};

// @public (undocumented)
export const meta: {
name: string;
version: string;
};

// @public (undocumented)
export const rules: {
"enforce-use-client": RuleModule<"missingUseClient" | "unnecessaryUseClient", [], unknown, RuleListener>;
"prefer-fluentui-v9": RuleModule<"replaceFluent8With9" | "replaceIconWithJsx" | "replaceStackWithFlex" | "replaceFocusZoneWithTabster", {}[], unknown, RuleListener>;
};

// (No @packageDocumentation comment for this package)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
"@swc/helpers": "^0.5.1"
},
"peerDependencies": {
"typescript-eslint": "^8.46.2",
"eslint": "^8.0.0",
"typescript": "^5.0.0"
"typescript-eslint": ">= 8.46.2",
"eslint": ">= 8.0.0",
"typescript": ">= 5.0.0"
},
"exports": {
".": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,44 @@
import type { ESLint } from 'eslint';

import { name, version } from '../package.json';
import { RULE_NAME as enforceUseClientName, rule as enforceUseClient } from './rules/enforce-use-client';
import { RULE_NAME as preferFluentUIV9Name, rule as preferFluentUIV9 } from './rules/prefer-fluentui-v9';

const allRules = {
export const meta = {
name,
version,
};
export const rules = {
[enforceUseClientName]: enforceUseClient,
[preferFluentUIV9Name]: preferFluentUIV9,
};

const configs = {
const recommendedRules = {
// Add rules to the recommended config here in the future
};

export const configs = {
recommended: {
plugins: [name],
rules: {
// add all recommended rules here
},
rules: recommendedRules,
},
'flat/recommended': {
// Define plugins as an object to satisfy ESLint v9 flat config format
// the actual plugin will be assigned later to avoid circular dependencies
plugins: { [name]: {} as ESLint.Plugin },
rules: recommendedRules,
},
};

// Plugin definition
export const plugin = {
meta: {
name,
version,
},
const plugin = {
meta,
configs,
rules: allRules,
rules,
};

// Flat config for eslint v9
Object.assign(configs, {
flat: {
recommended: {
plugins: { [name]: plugin },
rules: configs.recommended.rules,
},
},
});
configs['flat/recommended'].plugins = {
[name]: plugin as unknown as ESLint.Plugin,
};

module.exports = plugin;
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ESLintUtils, AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';

// NOTE: The rule will be available in ESLint configs as "@nx/workspace-enforce-use-client"
import { createRule } from './utils/create-rule';

// NOTE: The rule will be available in ESLint configs as "@fluentui/react-components/enforce-use-client"
export const RULE_NAME = 'enforce-use-client';

type MessageIds = 'missingUseClient' | 'unnecessaryUseClient';
Expand Down Expand Up @@ -103,7 +105,7 @@ const isPotentialCustomHook = (name: string): boolean =>
/**
* ESLint rule configuration and metadata
*/
export const rule = ESLintUtils.RuleCreator(() => __filename)<[], MessageIds>({
export const rule = createRule<[], MessageIds>({
name: RULE_NAME,
meta: {
type: 'problem',
Expand Down Expand Up @@ -192,7 +194,9 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)<[], MessageIds>({
* Check function calls for React APIs and custom hooks
*/
CallExpression(node: TSESTree.CallExpression) {
if (shouldSkipAnalysis()) return;
if (shouldSkipAnalysis()) {
return;
}

if (node.callee.type === AST_NODE_TYPES.Identifier) {
const name = node.callee.name;
Expand All @@ -218,7 +222,9 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)<[], MessageIds>({
* Check JSX attributes for event handlers
*/
JSXAttribute(node: TSESTree.JSXAttribute) {
if (shouldSkipAnalysis()) return;
if (shouldSkipAnalysis()) {
return;
}

if (node.name.type === AST_NODE_TYPES.JSXIdentifier && isEventHandler(node.name.name)) {
recordFirstClientFeature('event_handler', node.name.name, node);
Expand All @@ -229,7 +235,9 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)<[], MessageIds>({
* Check member expressions for browser APIs
*/
MemberExpression(node: TSESTree.MemberExpression) {
if (shouldSkipAnalysis()) return;
if (shouldSkipAnalysis()) {
return;
}

if (node.object.type === AST_NODE_TYPES.Identifier && BROWSER_GLOBALS.has(node.object.name)) {
recordFirstClientFeature('browser_api', node.object.name, node);
Expand Down Expand Up @@ -275,10 +283,14 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)<[], MessageIds>({
}

// If there are no client features and no directive, nothing to do
if (!hasClientFeatures) return;
if (!hasClientFeatures) {
return;
}

// Already has correct directive
if (ruleState.topDirectivePresent) return;
if (ruleState.topDirectivePresent) {
return;
}

// Report error on the specific problematic API call for better DX
const clientFeatureDetection = ruleState.firstClientFeature!;
Expand Down
Loading