blob: 2813b4ed4eb6c4510f3d2b27761a463b46dd84bf [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @file Rule to forbid typing a stateless UI.Widget view function's `output`
* parameter as `object` / `{}` or passing an empty object literal `{}` as the
* `output` argument to `this.#view(...)` / `DEFAULT_VIEW(...)`.
*/
import type {TSESTree} from '@typescript-eslint/utils';
import {
isViewCallCallee,
isViewFunction,
isViewFunctionType,
} from './utils/lit.ts';
import {createRule} from './utils/ruleCreator.ts';
type MessageIds = 'noEmptyViewOutputType'|'noEmptyViewOutputArgument';
type RuleOptions = [];
function isEmptyOutputTypeNode(node: TSESTree.TypeNode, emptyViewOutputNames: ReadonlySet<string>): boolean {
if (node.type === 'TSObjectKeyword') {
return true;
}
if (node.type === 'TSTypeLiteral' && node.members.length === 0) {
return true;
}
if (node.type === 'TSTypeReference' && node.typeName.type === 'Identifier') {
if (emptyViewOutputNames.has(node.typeName.name)) {
return true;
}
if (node.typeName.name === 'Record') {
const typeArgs = node.typeArguments?.params;
if (typeArgs?.length === 2 && typeArgs[1].type === 'TSNeverKeyword') {
return true;
}
}
}
return false;
}
export default createRule<RuleOptions, MessageIds>({
name: 'no-empty-view-output',
meta: {
type: 'problem',
docs: {
description:
'Forbid typing a stateless UI.Widget view function\'s `output` parameter as `object` / `{}` or passing `{}` as the `output` argument to a view call; use `undefined` instead.',
category: 'Possible Errors',
},
messages: {
noEmptyViewOutputType:
'Stateless view functions with no outputs must type their `output` parameter as `undefined` instead of an empty object type.',
noEmptyViewOutputArgument:
'Pass `undefined` instead of an empty object literal `{}` as the `output` argument to a view function.',
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create: function(context) {
const emptyViewOutputNames = new Set<string>();
const nonEmptyViewOutputNames = new Set<string>();
const parserServices = context.sourceCode.parserServices;
function isCalleeExpectingNonEmptyViewOutput(callee: TSESTree.Expression): boolean {
if (parserServices?.program && parserServices.esTreeNodeToTSNodeMap) {
const checker = parserServices.program.getTypeChecker();
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(callee);
if (tsNode) {
const calleeType = checker.getTypeAtLocation(tsNode);
const signatures = calleeType.getCallSignatures();
if (signatures.length > 0 && signatures[0].parameters.length >= 2) {
const secondParamType = checker.getTypeOfSymbolAtLocation(signatures[0].parameters[1], tsNode);
return secondParamType.getProperties().length > 0;
}
}
}
return nonEmptyViewOutputNames.size > 0;
}
function checkViewFunctionOutputParameter(
node: TSESTree.TSFunctionType|TSESTree.ArrowFunctionExpression|TSESTree.FunctionDeclaration|
TSESTree.FunctionExpression,
): void {
if (!isViewFunctionType(node) && !isViewFunction(node)) {
return;
}
const outputParam = node.params[1];
if (outputParam.type !== 'Identifier') {
return;
}
const outputType = outputParam.typeAnnotation?.typeAnnotation;
if (!outputType || !isEmptyOutputTypeNode(outputType, emptyViewOutputNames)) {
return;
}
context.report({
node: outputType,
messageId: 'noEmptyViewOutputType',
fix(fixer) {
return fixer.replaceText(outputType, 'undefined');
},
});
}
return {
Program(programNode) {
for (const stmt of programNode.body) {
const decl = stmt.type === 'ExportNamedDeclaration' ? stmt.declaration : stmt;
if (!decl) {
continue;
}
if (decl.type === 'TSInterfaceDeclaration' && decl.id.name.endsWith('ViewOutput')) {
if (decl.body.body.length === 0 && (!decl.extends || decl.extends.length === 0)) {
emptyViewOutputNames.add(decl.id.name);
} else {
nonEmptyViewOutputNames.add(decl.id.name);
}
} else if (decl.type === 'TSTypeAliasDeclaration' && decl.id.name.endsWith('ViewOutput')) {
if (isEmptyOutputTypeNode(decl.typeAnnotation, emptyViewOutputNames)) {
emptyViewOutputNames.add(decl.id.name);
} else if (decl.typeAnnotation.type !== 'TSUndefinedKeyword') {
nonEmptyViewOutputNames.add(decl.id.name);
}
}
}
},
TSFunctionType: checkViewFunctionOutputParameter,
ArrowFunctionExpression: checkViewFunctionOutputParameter,
FunctionDeclaration: checkViewFunctionOutputParameter,
FunctionExpression: checkViewFunctionOutputParameter,
CallExpression(node) {
if (node.arguments.length !== 3 || !isViewCallCallee(node.callee)) {
return;
}
const outputArg = node.arguments[1];
if (outputArg.type !== 'ObjectExpression' || outputArg.properties.length !== 0) {
return;
}
if (isCalleeExpectingNonEmptyViewOutput(node.callee)) {
return;
}
context.report({
node: outputArg,
messageId: 'noEmptyViewOutputArgument',
fix(fixer) {
return fixer.replaceText(outputArg, 'undefined');
},
});
},
};
},
});