UNPKG

eslint-plugin-svelte

Version:
330 lines (329 loc) 12.4 kB
import { ReferenceTracker } from '@eslint-community/eslint-utils'; import { createRule } from '../utils/index.js'; import { findVariable } from '../utils/ast-utils.js'; import { traverseNodes } from 'svelte-eslint-parser'; /** * Get usage of `tick` */ function extractTickReferences(context) { const referenceTracker = new ReferenceTracker(context.sourceCode.scopeManager.globalScope); const a = referenceTracker.iterateEsmReferences({ svelte: { [ReferenceTracker.ESM]: true, tick: { [ReferenceTracker.CALL]: true } } }); return Array.from(a).map(({ node, path }) => { return { node: node, name: path[path.length - 1] }; }); } /** * Get usage of `setTimeout`, `setInterval`, `queueMicrotask` */ function extractTaskReferences(context) { const referenceTracker = new ReferenceTracker(context.sourceCode.scopeManager.globalScope); const a = referenceTracker.iterateGlobalReferences({ setTimeout: { [ReferenceTracker.CALL]: true }, setInterval: { [ReferenceTracker.CALL]: true }, queueMicrotask: { [ReferenceTracker.CALL]: true } }); return Array.from(a).map(({ node, path }) => { return { node: node, name: path[path.length - 1] }; }); } /** * If `node` is inside of `maybeAncestorNode`, return true. */ function isChildNode(maybeAncestorNode, node) { let parent = node.parent; while (parent) { if (parent === maybeAncestorNode) return true; parent = parent.parent; } return false; } /** * Return true if `node` is a function call. */ function isFunctionCall(node) { if (node.type !== 'Identifier') return false; const { parent } = node; if (parent?.type !== 'CallExpression') return false; return parent.callee.type === 'Identifier' && parent.callee.name === node.name; } /** * Return true if `node` is a reactive variable. */ function isReactiveVariableNode(reactiveVariableReferences, node) { if (node.type !== 'Identifier') return false; return reactiveVariableReferences.includes(node); } /** * e.g. foo.bar = baz + 1 * If node is `foo`, return true. * Otherwise, return false. */ function isNodeForAssign(node) { const { parent } = node; if (parent?.type === 'AssignmentExpression') { return parent.left.type === 'Identifier' && parent.left.name === node.name; } return (parent?.type === 'MemberExpression' && parent.parent?.type === 'AssignmentExpression' && parent.parent.left.type === 'MemberExpression' && parent.parent.left.object.type === 'Identifier' && parent.parent.left.object.name === node.name); } /** * Return true if `node` is inside of `then` or `catch`. */ function isPromiseThenOrCatchBody(node) { if (!getDeclarationBody(node)) return false; const { parent } = node; if (parent?.type !== 'CallExpression' || parent?.callee?.type !== 'MemberExpression') { return false; } const { property } = parent.callee; if (property?.type !== 'Identifier') return false; return ['then', 'catch'].includes(property.name); } /** * Get all reactive variable reference. */ function getReactiveVariableReferences(context) { const scopeManager = context.sourceCode.scopeManager; // Find the top-level (module or global) scope. // Any variable defined at the top-level (module scope or global scope) can be made reactive. const toplevelScope = scopeManager.globalScope?.childScopes.find((scope) => scope.type === 'module') || scopeManager.globalScope; if (!toplevelScope) { return []; } // Extracts all reactive references to variables defined in the top-level scope. const reactiveVariableNodes = []; for (const variable of toplevelScope.variables) { for (const reference of variable.references) { if (reference.identifier.type === 'Identifier' && !isFunctionCall(reference.identifier)) { reactiveVariableNodes.push(reference.identifier); } } } return reactiveVariableNodes; } /** * Get all tracked reactive variables. */ function getTrackedVariableNodes(reactiveVariableReferences, ast) { const reactiveVariableNodes = new Set(); for (const identifier of reactiveVariableReferences) { if ( // If the identifier is within the reactive statement range, // it is used within the reactive statement. ast.range[0] <= identifier.range[0] && identifier.range[1] <= ast.range[1]) { reactiveVariableNodes.add(identifier); } } return reactiveVariableNodes; } /** */ function getDeclarationBody(node, functionName) { if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && (!functionName || node.id.name === functionName)) { if (node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression') { return node.init.body; } } else if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier' && (!functionName || node.id?.name === functionName)) { return node.body; } else if (!functionName && node.type === 'ArrowFunctionExpression') { return node.body; } return null; } /** */ function getFunctionDeclarationNode(context, functionCall) { const variable = findVariable(context, functionCall); if (!variable) { return null; } for (const def of variable.defs) { if (def.type === 'FunctionName') { if (def.node.type === 'FunctionDeclaration') { return def.node.body; } } if (def.type === 'Variable') { if (def.node.init && (def.node.init.type === 'FunctionExpression' || def.node.init.type === 'ArrowFunctionExpression')) { return def.node.init.body; } } } return null; } /** * If the node is inside of a function, return true. * * e.g. `$: await foo()` * if `node` is `foo`, return false because reactive statement is not function. * * e.g. `const bar = () => foo()` * if `node` is `foo`, return true. * */ function isInsideOfFunction(node) { let parent = node; while (parent) { parent = parent.parent; if (!parent) break; if (parent.type === 'FunctionDeclaration' && parent.async) return true; if (parent.type === 'VariableDeclarator' && (parent.init?.type === 'FunctionExpression' || parent.init?.type === 'ArrowFunctionExpression') && parent.init?.async) { return true; } } return false; } /** Let's lint! */ function doLint(context, ast, callFuncIdentifiers, tickCallExpressions, taskReferences, reactiveVariableNames, reactiveVariableReferences, pIsSameTask) { const processed = new Set(); verifyInternal(ast, callFuncIdentifiers, pIsSameTask); /** verify for node */ function verifyInternal(ast, callFuncIdentifiers, pIsSameTask) { if (processed.has(ast)) { // Avoid infinite recursion with recursive references. return; } processed.add(ast); let isSameMicroTask = pIsSameTask; const differentMicroTaskEnterNodes = []; traverseNodes(ast, { enterNode(node) { // Promise.then() or Promise.catch() is called. if (isPromiseThenOrCatchBody(node)) { differentMicroTaskEnterNodes.push(node); isSameMicroTask = false; } // `tick`, `setTimeout`, `setInterval` , `queueMicrotask` is called for (const { node: callExpression } of [...tickCallExpressions, ...taskReferences]) { if (isChildNode(callExpression, node)) { differentMicroTaskEnterNodes.push(node); isSameMicroTask = false; } } // left side of await block if (node.parent?.type === 'AssignmentExpression' && node.parent?.right.type === 'AwaitExpression' && node.parent?.left === node) { differentMicroTaskEnterNodes.push(node); isSameMicroTask = false; } if (node.type === 'Identifier' && isFunctionCall(node)) { // traverse used functions body const functionDeclarationNode = getFunctionDeclarationNode(context, node); if (functionDeclarationNode) { verifyInternal(functionDeclarationNode, [...callFuncIdentifiers, node], isSameMicroTask); } } if (!isSameMicroTask) { if (isReactiveVariableNode(reactiveVariableReferences, node) && reactiveVariableNames.includes(node.name) && isNodeForAssign(node)) { context.report({ node, loc: node.loc, messageId: 'unexpected' }); callFuncIdentifiers.forEach((callFuncIdentifier) => { context.report({ node: callFuncIdentifier, loc: callFuncIdentifier.loc, messageId: 'unexpectedCall', data: { variableName: node.name } }); }); } } }, leaveNode(node) { if (node.type === 'AwaitExpression') { if (ast.parent?.type === 'SvelteReactiveStatement') { // MEMO: It checks that `await` is used in reactive statement directly or not. // If `await` is used in inner function of a reactive statement, result of `isInsideOfFunction` will be `true`. if (!isInsideOfFunction(node)) { isSameMicroTask = false; } } else { isSameMicroTask = false; } } if (differentMicroTaskEnterNodes.includes(node)) { isSameMicroTask = true; } } }); } } export default createRule('infinite-reactive-loop', { meta: { docs: { description: "Svelte runtime prevents calling the same reactive statement twice in a microtask. But between different microtask, it doesn't prevent.", category: 'Possible Errors', recommended: true }, schema: [], messages: { unexpected: 'Possibly it may occur an infinite reactive loop.', unexpectedCall: 'Possibly it may occur an infinite reactive loop because this function may update `{{variableName}}`.' }, type: 'suggestion', // Do not run this rule on Svelte 5 with runes. conditions: [ { svelteVersions: ['3/4'] }, { svelteVersions: ['5'], runes: [false, 'undetermined'] } ] }, create(context) { return { ['SvelteReactiveStatement']: (ast) => { const tickCallExpressions = extractTickReferences(context); const taskReferences = extractTaskReferences(context); const reactiveVariableReferences = getReactiveVariableReferences(context); const trackedVariableNodes = getTrackedVariableNodes(reactiveVariableReferences, ast); doLint(context, ast.body, [], tickCallExpressions, taskReferences, Array.from(trackedVariableNodes).map((node) => node.name), reactiveVariableReferences, true); } }; } });