Skip to content
Closed
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: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.18.13",
"@types/semver": "^7.7.1",
"@typescript-eslint/eslint-plugin": "8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@vitest/coverage-v8": "^4.0.7",
"eslint": "^9.39.1",
"jsdom": "^27.1.0",
Expand Down
4 changes: 3 additions & 1 deletion packages/code-infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
"@octokit/oauth-methods": "^6.0.2",
"@octokit/rest": "^22.0.1",
"@pnpm/find-workspace-dir": "^1000.1.3",
"@typescript-eslint/types": "^8.47.0",
"@typescript-eslint/utils": "^8.47.0",
"babel-plugin-optimize-clsx": "^2.6.2",
"babel-plugin-react-compiler": "^1.0.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
Expand Down Expand Up @@ -109,7 +111,7 @@
"resolve-pkg-maps": "^1.0.0",
"semver": "^7.7.3",
"stylelint-config-standard": "^39.0.1",
"typescript-eslint": "^8.46.3",
"typescript-eslint": "^8.47.0",
"yargs": "^18.0.0"
},
"peerDependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/code-infra/src/eslint/material-ui/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ export function createCoreConfig(options = {}) {
'material-ui/no-empty-box': 'error',
'material-ui/no-styled-box': 'error',
'material-ui/straight-quotes': 'off',
'material-ui/add-undef-to-optional': 'off',

'react-hooks/exhaustive-deps': [
'error',
Expand Down
3 changes: 3 additions & 0 deletions packages/code-infra/src/eslint/material-ui/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import noRestrictedResolvedImports from './rules/no-restricted-resolved-imports.
import noStyledBox from './rules/no-styled-box.mjs';
import rulesOfUseThemeVariants from './rules/rules-of-use-theme-variants.mjs';
import straightQuotes from './rules/straight-quotes.mjs';
import addUndefToOptional from './rules/add-undef-to-optional.mjs';

export default /** @type {import('eslint').ESLint.Plugin} */ ({
meta: {
Expand All @@ -23,5 +24,7 @@ export default /** @type {import('eslint').ESLint.Plugin} */ ({
'straight-quotes': straightQuotes,
'disallow-react-api-in-server-components': disallowReactApiInServerComponents,
'no-restricted-resolved-imports': noRestrictedResolvedImports,
// Some descrepancies between TypeScript and ESLint types - casting to unknown
'add-undef-to-optional': /** @type {unknown} */ (addUndefToOptional),
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(
(name) =>
`https://github.com/mui/mui-public/blob/master/packages/code-infra/src/eslint/material-ui/rules/${name}.mjs`,
);

const RULE_NAME = 'add-undef-to-optional';

/**
* Checks whether the given type node includes 'undefined' either directly,
* via union, or via type references that eventually include 'undefined'.
* Treats 'any' and 'unknown' as including 'undefined' and skips React's ReactNode.
*
* @param {import('@typescript-eslint/types').TSESTree.TSTypeAnnotation['typeAnnotation'] | undefined} typeNode
* @param {Map<string, any>} typeDefinitions
* @returns {boolean}
*/
function souldCheckProperty(typeNode, typeDefinitions) {
if (!typeNode) {
return false;
}

switch (typeNode.type) {
case AST_NODE_TYPES.TSUnionType: {
return typeNode.types.some((t) => souldCheckProperty(t, typeDefinitions));
}
case AST_NODE_TYPES.TSUndefinedKeyword:
return true;
case AST_NODE_TYPES.TSAnyKeyword:
return true;
case AST_NODE_TYPES.TSUnknownKeyword:
return true;
case AST_NODE_TYPES.TSTypeReference: {
// Check if it's a reference to 'undefined' itself
if (
typeNode.typeName &&
typeNode.typeName.type === AST_NODE_TYPES.Identifier &&
typeNode.typeName.name === 'undefined'
) {
return true;
}
// Check if it's ReactNode (which already includes undefined)
if (typeNode.typeName) {
if (typeNode.typeName.type === AST_NODE_TYPES.Identifier) {
const typeName = typeNode.typeName.name;
// ReactNode already includes undefined
if (typeName === 'ReactNode') {
return true;
}
// If we have a local definition, check it
if (typeDefinitions.has(typeName)) {
const typeDefinition = typeDefinitions.get(typeName);
return souldCheckProperty(typeDefinition, typeDefinitions);
}
// If no local definition found, it's imported or built-in - require explicit | undefined
return false;
}
// Check for React.ReactNode
if (
typeNode.typeName.type === AST_NODE_TYPES.TSQualifiedName &&
typeNode.typeName.left.type === AST_NODE_TYPES.Identifier &&
typeNode.typeName.left.name === 'React' &&
typeNode.typeName.right.name === 'ReactNode'
) {
return true;
}
}
return false;
}
default:
return false;
}
}

export default createRule({
meta: {
docs: {
description: 'Ensures that optional properties include undefined in their type.',
},
messages: {
addUndefined:
'Optional property "{{ propName }}" type does not explicitly include undefined. Add "| undefined".',
},
type: 'suggestion',
fixable: 'code',
schema: [],
},
name: RULE_NAME,
defaultOptions: [],
create(context) {
const typeDefinitions = new Map();

return {
// Collect type alias definitions, ie, type Foo = ...
TSTypeAliasDeclaration(node) {
if (node.id && node.typeAnnotation) {
typeDefinitions.set(node.id.name, node.typeAnnotation);
}
},
// only checks optional properties in types/interfaces
TSPropertySignature(node) {
if (!node.optional || !node.typeAnnotation) {
return;
}
const typeNode = node.typeAnnotation.typeAnnotation;
if (!typeNode || souldCheckProperty(typeNode, typeDefinitions)) {
return;
}
const source = context.sourceCode;
context.report({
node: node.key ?? node,
messageId: 'addUndefined',
data: {
propName: source.getText(node.key),
},
fix(fixer) {
// wrap in parentheses to preserve precedence even for simple types
// prettier can handle formatting
return fixer.replaceText(typeNode, `(${source.getText(typeNode)}) | undefined`);
},
});
},
};
},
});
Loading
Loading