@ospm/eslint-plugin-react-signals-hooks
Version:
ESLint plugin for React Signals hooks - enforces best practices, performance optimizations, and integration patterns for @preact/signals-react usage in React projects
1,141 lines • 199 kB
JavaScript
import { AST_NODE_TYPES, ESLintUtils, } from '@typescript-eslint/utils';
import { PerformanceOperations } from './utils/performance-constants.js';
import { endPhase, startPhase, recordMetric, startTracking, trackOperation, createPerformanceTracker, DEFAULT_PERFORMANCE_BUDGET, PerformanceLimitExceededError, } from './utils/performance.js';
import { buildSuffixRegex, hasSignalSuffix } from './utils/suffix.js';
import { getRuleDocUrl } from './utils/urls.js';
function getObservedFormatted(depKey, optionalChains, dependencies) {
const dep = dependencies.get(depKey);
if (typeof dep !== 'undefined' && dep.observedFormatted && dep.observedFormatted.size > 0) {
// Prefer the shortest formatted variant (fewer optionals are usually shorter)
let best = null;
dep.observedFormatted.forEach((v) => {
if (best === null || v.length < best.length) {
best = v;
}
});
if (typeof best === 'string') {
return best;
}
}
return formatDependency(depKey, optionalChains);
}
function projectOptionalChains(path, optionalChains) {
const projected = new Map();
const members = path.split('.');
let soFar = '';
for (let i = 0; i < members.length; i++) {
soFar =
i === 0 && typeof members[0] === 'string'
? members[0]
: // eslint-disable-next-line security/detect-object-injection
`${soFar}.${members[i]}`;
const val = optionalChains.get(soFar);
if (typeof val !== 'undefined') {
projected.set(soFar, val);
}
}
return projected;
}
function memoizeWithWeakMap(fn, map) {
return (arg, componentScope, pureScopes) => {
if (map.has(arg)) {
return map.get(arg) ?? false;
}
const result = fn(arg, componentScope, pureScopes);
map.set(arg, result);
return result;
};
}
function isUseEffectEventIdentifier(node, perfKey) {
trackOperation(perfKey, PerformanceOperations.hookCheck);
if (node.type !== AST_NODE_TYPES.Identifier) {
return false;
}
const { name } = node;
return name === 'useEffectEvent' || name === 'experimental_useEffectEvent';
}
function isSignalIdentifier(node, perfKey) {
trackOperation(perfKey, PerformanceOperations.signalCheck);
// Match bare identifiers: signal, computed, effect
if (node.type === AST_NODE_TYPES.Identifier) {
const { name } = node;
return ['signal', 'computed', 'effect'].includes(name);
}
// Match namespaced creators: e.g., signals.signal(), ReactSignals.computed(), etc.
if (node.type === AST_NODE_TYPES.MemberExpression &&
!node.computed &&
node.property.type === AST_NODE_TYPES.Identifier) {
return ['signal', 'computed', 'effect'].includes(node.property.name);
}
return false;
}
const suffixByPerfKey = new Map();
function isSignalVariable(node, perfKey) {
trackOperation(perfKey, PerformanceOperations.signalCheck);
if (node.type !== AST_NODE_TYPES.Identifier) {
return false;
}
const suffixRegex = suffixByPerfKey.get(perfKey) ?? buildSuffixRegex('Signal');
return hasSignalSuffix(node.name, suffixRegex);
}
function isSignalDependency(dependency, perfKey) {
trackOperation(perfKey, PerformanceOperations.signalCheck);
const suffixRegex = suffixByPerfKey.get(perfKey) ?? buildSuffixRegex('Signal');
// Treat names ending with the configured suffix as signals.
// Handle both full path and the base identifier before the first '.'.
if (hasSignalSuffix(dependency, suffixRegex)) {
return true;
}
const base = dependency.split('.')[0] ?? '';
return base !== '' && hasSignalSuffix(base, suffixRegex);
}
function isSignalValueAccess(node, context) {
// Check if this is a direct signal.value access
if (node.type === AST_NODE_TYPES.MemberExpression &&
!node.computed &&
node.property.type === AST_NODE_TYPES.Identifier &&
node.property.name === 'value' &&
node.object.type === AST_NODE_TYPES.Identifier &&
hasSignalSuffix(node.object.name, buildSuffixRegex(typeof context.options[0]?.suffix === 'string' && context.options[0].suffix.length > 0
? context.options[0].suffix
: 'Signal'))) {
const ancestors = context.sourceCode.getAncestors(node);
const parent = ancestors[ancestors.length - 1];
// Check if this is part of an assignment operation (like countSignal.value++)
if (typeof parent !== 'undefined') {
if (parent.type === AST_NODE_TYPES.UpdateExpression ||
(parent.type === AST_NODE_TYPES.AssignmentExpression &&
['=', '+=', '-=', '*=', '/=', '%='].includes(parent.operator))) {
return false;
}
}
return true;
}
return false;
}
function isNodeLike(val) {
return (typeof val === 'object' &&
val !== null &&
!Array.isArray(val) &&
'type' in val &&
typeof val.type === 'string');
}
function isSameIdentifier(a, b) {
return ((a.type === AST_NODE_TYPES.Identifier || a.type === AST_NODE_TYPES.JSXIdentifier) &&
a.type === b.type &&
a.name === b.name &&
// !!a.range &&
// !!b.range &&
a.range[0] === b.range[0] &&
a.range[1] === b.range[1]);
}
function isAncestorNodeOf(a, b) {
return /* !!a.range && !!b.range && */ a.range[0] <= b.range[0] && a.range[1] >= b.range[1];
}
function fastFindReferenceWithParent(start, target) {
const queue = [start];
let item;
while (queue.length) {
item = queue.shift();
if (!item) {
continue;
}
if (isSameIdentifier(item, target)) {
return item;
}
if (!isAncestorNodeOf(item, target)) {
continue;
}
for (const key in item) {
if (key === 'parent') {
continue;
}
const value = item[key];
if (isNodeLike(value) && isNodeLike(item)) {
value.parent = item;
queue.push(value);
}
else if (Array.isArray(value) && isNodeLike(item)) {
for (const val of value) {
if (isNodeLike(val)) {
val.parent = item;
queue.push(val);
}
}
}
}
}
return null;
}
function analyzePropertyChain(node, optionalChains, context, perfKey) {
try {
trackOperation(perfKey, PerformanceOperations.nodeProcessing);
if (node.type === AST_NODE_TYPES.Identifier || node.type === AST_NODE_TYPES.JSXIdentifier) {
const result = node.name;
if (optionalChains) {
optionalChains.set(result, false);
}
return result;
}
if (node.type === AST_NODE_TYPES.MemberExpression) {
if (!node.computed) {
// If this member expression is a method call (e.g., obj.method(...)),
// we should NOT include the method name itself in the dependency path.
// Only the object (`obj`) should be tracked as the dependency.
// Example: matrixSignal.value[rowIndex]?.reduce(...) -> track up to matrixSignal.value[rowIndex]
const isCalleeMethodCall = typeof node.parent !== 'undefined' &&
node.parent.type === AST_NODE_TYPES.CallExpression &&
node.parent.callee === node;
const objectPath = analyzePropertyChain(node.object, optionalChains, context, perfKey);
if (isCalleeMethodCall) {
markNode(node, optionalChains, objectPath, perfKey);
return objectPath;
}
const result = `${objectPath}.${analyzePropertyChain(node.property, null, context, perfKey)}`;
markNode(node, optionalChains, result, perfKey);
return result;
}
const object = analyzePropertyChain(node.object, optionalChains, context, perfKey);
let computedResult;
// Handle different types of computed properties
if (node.property.type === AST_NODE_TYPES.Literal &&
typeof node.property.value === 'string') {
computedResult = node.property.value;
}
else if (node.property.type === AST_NODE_TYPES.TemplateLiteral &&
node.property.quasis.length === 1) {
computedResult =
node.property.quasis[0]?.value.cooked ?? node.property.quasis[0]?.value.raw;
}
else {
return context.sourceCode.getText(node);
}
// Handle computed property access result
const result = `${object}[${computedResult}]`;
markNode(node, optionalChains, result, perfKey);
return result;
}
const fallback = context.sourceCode.getText(node);
return fallback;
}
catch (error) {
if (error instanceof PerformanceLimitExceededError) {
trackOperation(perfKey, PerformanceOperations.analyzePropertyChainFailed);
return error instanceof Error ? error.message : JSON.stringify(error);
}
throw error;
}
}
function formatDependency(path, optionalChains) {
// eslint-disable-next-line optimize-regex/optimize-regex
path = path.replace(/\[\*\]/g, '');
const members = path.split('.');
let finalPath = '';
for (let i = 0; i < members.length; i++) {
if (i !== 0) {
const pathSoFar = members.slice(0, i + 1).join('.');
const isOptional = optionalChains.get(pathSoFar) === true;
finalPath += isOptional ? '?.' : '.';
}
// eslint-disable-next-line security/detect-object-injection
finalPath += members[i];
}
return finalPath;
}
// Returns true if every computed segment in a dependency path is a numeric literal index, e.g.,
// "arr[0]", "matrix[1].x", "a[2][3]". Dynamic indices (identifiers/expressions) return false.
function hasOnlyNumericComputed(path) {
// Quick reject
if (!path.includes('[')) {
return false;
}
// Match all bracketed segments
// eslint-disable-next-line optimize-regex/optimize-regex
const matches = path.match(/\[[^\]]+\]/g);
if (!matches || matches.length === 0) {
return false;
}
// All segments must be strictly digits
return matches.every((seg) => {
// eslint-disable-next-line optimize-regex/optimize-regex
return /^\[(?:\d+)\]$/.test(seg);
});
}
function joinEnglish(arr) {
let s = '';
for (let i = 0; i < arr.length; i++) {
// eslint-disable-next-line security/detect-object-injection
s += arr[i];
if (i === 0 && arr.length === 2) {
s += ' and ';
}
else if (i === arr.length - 2 && arr.length > 2) {
s += ', and ';
}
else if (i < arr.length - 1) {
s += ', ';
}
}
return s;
}
function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath, perfKey) {
node.children.forEach((child, key) => {
const path = keyToPath(key);
const isSignalPath = isSignalDependency(path, perfKey) || isSignalDependency(key, perfKey);
const hasValueAccess = path.endsWith('.value') || key.endsWith('.value');
const isAnySignalType = isSignalPath || hasValueAccess;
if (child.isSatisfiedRecursively) {
if (child.isSubtreeUsed || child.isUsed) {
satisfyingPaths.add(path);
}
if (isAnySignalType) {
return;
}
}
else if (child.isUsed ||
(isAnySignalType && child.isSubtreeUsed) ||
(!isAnySignalType && child.isSubtreeUsed && child.children.size === 0)) {
// If a root signal base is missing but its .value is used, prefer not to add the base
if (!path.includes('.') && isSignalDependency(path, perfKey)) {
const valueChild = child.children.get('value');
if (valueChild && (valueChild.isUsed || valueChild.isSubtreeUsed)) {
return;
}
}
missingPaths.add(path);
return;
}
scanTreeRecursively(child, missingPaths, satisfyingPaths, (childKey) => `${path}.${childKey}`, perfKey);
});
}
function getWarningMessage(deps, singlePrefix, label, fixVerb, optionalChains, dependencies) {
if (deps.size === 0) {
return null;
}
return `${(deps.size > 1 ? '' : `${singlePrefix} `) + label} ${deps.size > 1 ? 'dependencies' : 'dependency'}: ${joinEnglish(Array.from(deps)
.sort()
.map((name) => {
return `'${getObservedFormatted(name, optionalChains, dependencies)}'`;
}))}. Either ${fixVerb} ${deps.size > 1 ? 'them' : 'it'} or remove the dependency array.`;
}
function collectRecommendations({ dependencies, declaredDependencies, stableDependencies, externalDependencies, isEffect, reactiveHookName, context, perfKey, }) {
const depTree = createDepTree();
function createDepTree() {
return {
isUsed: false,
isSatisfiedRecursively: false,
isSubtreeUsed: false,
children: new Map(),
};
}
dependencies.forEach(({ isStable, references }, key) => {
if (isStable) {
stableDependencies.add(key);
}
for (const reference of references) {
if (reference.writeExpr) {
const staleAssignments = new Set();
staleAssignments.add(key);
if (getSeverity('useEffectEventInDependencyArray', context.options[0]) !== 'off') {
context.report({
node: reference.writeExpr ?? reference.identifier,
data: {
eventName: key,
hookName: reactiveHookName,
},
messageId: 'useEffectEventInDependencyArray',
suggest: [
{
messageId: 'removeDependency',
data: { dependency: key },
fix(fixer) {
const [start, end] = reference.identifier.range;
return fixer.removeRange([start, end + 1]);
},
},
],
});
}
}
}
});
dependencies.forEach((_, key) => {
if (key.endsWith('.value')) {
const signalName = key.slice(0, -6);
if (isSignalDependency(signalName, perfKey)) {
externalDependencies.delete(signalName);
externalDependencies.delete(key);
}
}
else if (key.includes('.value[')) {
const valueIndex = key.indexOf('.value[');
if (valueIndex !== -1) {
const signalName = key.slice(0, valueIndex);
if (isSignalDependency(signalName, perfKey)) {
externalDependencies.delete(signalName);
externalDependencies.delete(key);
}
}
}
else if (isSignalDependency(key, perfKey)) {
externalDependencies.delete(key);
}
});
dependencies.forEach((_, key) => {
if (isSignalDependency(key, perfKey)) {
return;
}
// Skip dynamic computed members only when the base is a signal.
// Always allow static numeric indices like arr[0]. For non-signal bases, allow dynamic too.
if (key.includes('[') && key.includes(']') && !hasOnlyNumericComputed(key)) {
const base = key.split('.')[0] ?? '';
if (base !== '' && isSignalDependency(base, perfKey)) {
return;
}
}
const parts = key.split('.');
if (parts.length > 2 && parts[1] === 'value') {
const signalName = parts[0];
if (typeof signalName === 'undefined') {
return;
}
if (!isSignalDependency(signalName, perfKey)) {
return;
}
const dependency = dependencies.get(key);
const isAssignmentOnly = dependency && dependency.hasReads === false;
const hasInnerScopeComputedProperty = dependency && dependency.hasInnerScopeComputedProperty === true;
if (!declaredDependencies.some(({ key: depKey }) => depKey === `${signalName}.value`) &&
(isAssignmentOnly === true || hasInnerScopeComputedProperty === true)) {
dependencies.delete(key);
}
}
});
dependencies.forEach((_, key) => {
getOrCreateNodeByPath(depTree, key).isUsed = true;
markAllParentsByPath(depTree, key, (parent) => {
parent.isSubtreeUsed = true;
});
});
for (const { key } of declaredDependencies) {
// Do not normalize optional chaining here; tree splitting handles both '.' and '?.'
getOrCreateNodeByPath(depTree, key).isSatisfiedRecursively = true;
// Also mark all parents as satisfied to avoid reporting intermediate segments as missing
markAllParentsByPath(depTree, key, (parent) => {
parent.isSatisfiedRecursively = true;
});
}
for (const key of stableDependencies) {
getOrCreateNodeByPath(depTree, key).isSatisfiedRecursively = true;
}
function getOrCreateNodeByPath(rootNode, path) {
const keys = splitPathMembers(path);
let node = rootNode;
for (const key of keys) {
let child = node.children.get(key);
if (!child) {
child = createDepTree();
node.children.set(key, child);
}
node = child;
}
return node;
}
function markAllParentsByPath(rootNode, path, fn) {
const keys = splitPathMembers(path);
let node = rootNode;
for (const key of keys) {
const child = node.children.get(key);
if (!child) {
return;
}
fn(child);
node = child;
}
}
const importedSignals = new Set();
dependencies.forEach((_, key) => {
if (key.endsWith('.value')) {
const signalName = key.slice(0, -6);
if (isSignalDependency(signalName, perfKey)) {
importedSignals.add(key);
externalDependencies.delete(signalName);
externalDependencies.delete(key);
}
}
else if (key.includes('.value[')) {
const valueIndex = key.indexOf('.value[');
if (valueIndex !== -1) {
const signalName = key.slice(0, valueIndex);
if (isSignalDependency(signalName, perfKey)) {
importedSignals.add(key);
externalDependencies.delete(signalName);
externalDependencies.delete(key);
}
}
}
else if (isSignalDependency(key, perfKey)) {
const valueKey = `${key}.value`;
const isBaseValueDeclared = declaredDependencies.some(({ key: depKey }) => {
return depKey === valueKey;
});
const dependency = dependencies.get(valueKey);
const isAssignmentOnly = dependency && dependency.hasReads === false;
const hasInnerScopeComputedProperty = dependency && dependency.hasInnerScopeComputedProperty === true;
if (!isBaseValueDeclared &&
(isAssignmentOnly === true || hasInnerScopeComputedProperty === true)) {
dependencies.delete(valueKey);
}
const node = getOrCreateNodeByPath(depTree, key);
node.isSatisfiedRecursively = true;
externalDependencies.delete(key);
}
});
dependencies.forEach((_, key) => {
if (isSignalDependency(key, perfKey)) {
return;
}
// Skip dynamic computed members only when the base is a signal.
// Always allow static numeric indices like arr[0]. For non-signal bases, allow dynamic too.
if (key.includes('[') && key.includes(']') && !hasOnlyNumericComputed(key)) {
const base = key.split('.')[0] ?? '';
if (base !== '' && isSignalDependency(base, perfKey)) {
return;
}
}
const parts = key.split('.');
if (parts.length > 2 && parts[1] === 'value') {
const signalName = parts[0];
if (typeof signalName !== 'string') {
return;
}
if (!isSignalDependency(signalName, perfKey)) {
return;
}
const baseValueKey = `${signalName}.value`;
const isBaseValueDeclared = declaredDependencies.some(({ key: depKey }) => {
return depKey === baseValueKey;
});
const dependency = dependencies.get(key);
const isAssignmentOnly = dependency && dependency.hasReads === false;
const hasInnerScopeComputedProperty = dependency && dependency.hasInnerScopeComputedProperty === true;
if (!isBaseValueDeclared &&
(isAssignmentOnly === true || hasInnerScopeComputedProperty === true)) {
dependencies.delete(key);
}
}
});
const missingDependencies = new Set();
const satisfyingDependencies = new Set();
dependencies.forEach((_, key) => {
if (key.endsWith('.value')) {
const signalName = key.slice(0, -6);
if (isSignalDependency(signalName, perfKey)) {
const node = getOrCreateNodeByPath(depTree, signalName);
node.isSatisfiedRecursively = true;
externalDependencies.delete(signalName);
externalDependencies.delete(key);
}
}
else if (key.includes('.value[')) {
const valueIndex = key.indexOf('.value[');
if (valueIndex !== -1) {
const signalName = key.slice(0, valueIndex);
if (isSignalDependency(signalName, perfKey)) {
const node = getOrCreateNodeByPath(depTree, signalName);
node.isSatisfiedRecursively = true;
externalDependencies.delete(signalName);
externalDependencies.delete(key);
}
}
}
else if (isSignalDependency(key, perfKey)) {
const node = getOrCreateNodeByPath(depTree, key);
node.isSatisfiedRecursively = true;
externalDependencies.delete(key);
}
});
dependencies.forEach((_, key) => {
if (!isSignalDependency(key, perfKey)) {
if (key.includes('[') && key.includes(']')) {
return;
}
const parts = key.split('.');
if (parts.length > 2 && parts[1] === 'value') {
const signalName = parts[0];
if (typeof signalName !== 'string') {
return;
}
if (!isSignalDependency(signalName, perfKey)) {
return;
}
const baseValueKey = `${signalName}.value`;
const isBaseValueDeclared = declaredDependencies.some(({ key: depKey }) => {
return depKey === baseValueKey;
});
const dependency = dependencies.get(key);
const isAssignmentOnly = dependency && dependency.hasReads === false;
const hasInnerScopeComputedProperty = dependency && dependency.hasInnerScopeComputedProperty === true;
if (!isBaseValueDeclared &&
(isAssignmentOnly === true || hasInnerScopeComputedProperty === true)) {
dependencies.delete(key);
}
}
}
});
const declaredSignals = new Set();
declaredDependencies.forEach(({ key }) => {
if (key.endsWith('.value')) {
const signalName = key.slice(0, -6);
if (isSignalDependency(signalName, perfKey)) {
declaredSignals.add(signalName);
}
}
else if (key.includes('.value[')) {
const valueIndex = key.indexOf('.value[');
if (valueIndex !== -1) {
const signalName = key.slice(0, valueIndex);
if (isSignalDependency(signalName, perfKey)) {
declaredSignals.add(signalName);
}
}
}
else if (isSignalDependency(key, perfKey)) {
declaredSignals.add(key);
}
});
importedSignals.forEach((signal) => {
if (signal.includes('.value[')) {
const isComputedPropertyDeclared = declaredDependencies.some(({ key: depKey }) => {
return depKey === signal;
});
if (!isComputedPropertyDeclared) {
const dependency = dependencies.get(signal);
// const hasInnerScopeComputedProperty =
// dependency && dependency.hasInnerScopeComputedProperty === true;
const isAssignmentOnly = dependency && dependency.hasReads === false;
// If it's a read, require it even if it involves inner-scope computed property
if (isAssignmentOnly !== true) {
missingDependencies.add(signal);
}
const valueIndex = signal.indexOf('.value[');
if (valueIndex !== -1) {
const signalName = signal.slice(0, valueIndex);
if (!isSignalDependency(signalName, perfKey)) {
return;
}
const valueNode = getOrCreateNodeByPath(depTree, signalName);
valueNode.isUsed = true;
valueNode.isSubtreeUsed = true;
if (signal.includes('.', valueIndex + 7)) {
const baseSignalSatisfied = getOrCreateNodeByPath(depTree, signalName);
baseSignalSatisfied.isSatisfiedRecursively = true;
}
}
}
return;
}
else if (signal.endsWith('.value')) {
const isValueDeclared = declaredDependencies.some(({ key }) => {
return key === signal;
});
if (!isValueDeclared) {
const dependency = dependencies.get(signal);
// const hasInnerScopeComputedProperty =
// dependency && dependency.hasInnerScopeComputedProperty === true;
const isAssignmentOnly = dependency && dependency.hasReads === false;
// If it's a read of .value, require it regardless of inner-scope computed property
if (isAssignmentOnly !== true) {
missingDependencies.add(signal);
}
const valueNode = getOrCreateNodeByPath(depTree, signal);
valueNode.isUsed = true;
valueNode.isSubtreeUsed = true;
}
return;
}
const valueAccessKey = `${signal}.value`;
const hasValueAccess = dependencies.has(valueAccessKey);
if (hasValueAccess) {
const isValueDeclared = declaredDependencies.some(({ key }) => {
return key === valueAccessKey || key.startsWith(`${valueAccessKey}[`);
});
if (!isValueDeclared) {
// Check if this dependency is only used for assignments
const dependency = dependencies.get(valueAccessKey);
// const hasInnerScopeComputedProperty =
// dependency && dependency.hasInnerScopeComputedProperty === true;
const isAssignmentOnly = dependency && dependency.hasReads === false;
// Require base .value when it's read, even if there are inner-scope computed members
if (isAssignmentOnly !== true) {
missingDependencies.add(valueAccessKey);
}
const valueNode = getOrCreateNodeByPath(depTree, valueAccessKey);
valueNode.isUsed = true;
valueNode.isSubtreeUsed = true;
}
return;
}
const node = getOrCreateNodeByPath(depTree, signal);
node.isUsed = true;
node.isSubtreeUsed = true;
const isDeclared = declaredDependencies.some(({ key }) => {
return key === signal;
}) || declaredSignals.has(signal);
if (!isDeclared) {
const dependency = dependencies.get(signal);
const isAssignmentOnly = dependency &&
(dependency.hasReads === false || dependency.isComputedAssignmentOnly === true);
let hasAssignmentOnlyComputedMembers = false;
let hasDeepPropertyChains = false;
if (isSignalDependency(signal, perfKey)) {
const deepPropertyChains = Array.from(dependencies.keys()).filter((key) => {
return key.startsWith(`${signal}.value[`) && key.includes('.neighbors.');
});
hasDeepPropertyChains = deepPropertyChains.length > 0;
const computedMembers = Array.from(dependencies.keys()).filter((key) => {
return key.startsWith(`${signal}.value[`);
});
hasAssignmentOnlyComputedMembers = computedMembers.every((key) => {
const dep = dependencies.get(key);
return typeof dep !== 'undefined' && dep.hasReads === false;
});
}
if (isAssignmentOnly !== true &&
hasAssignmentOnlyComputedMembers !== true &&
!hasDeepPropertyChains) {
missingDependencies.add(signal);
}
}
});
dependencies.forEach((depInfo, index) => {
if (index.includes('.value[') && isSignalDependency(index.split('.')[0] ?? '', perfKey)) {
const valueIndex = index.indexOf('.value[');
if (valueIndex !== -1) {
const isComputedPropertyDeclared = declaredDependencies.some(({ key }) => key === index);
if (!isComputedPropertyDeclared &&
depInfo.hasInnerScopeComputedProperty !== true &&
depInfo.hasReads !== false) {
missingDependencies.add(index);
}
}
}
});
const depDump = Array.from(dependencies.entries()).map(([k, v]) => {
return {
key: k,
hasReads: v.hasReads === true,
isStable: v.isStable === true,
isSignal: isSignalDependency(k, perfKey),
};
});
const bases = new Set(depDump
.map((d) => {
return d.key.split('.')[0];
})
.filter(Boolean));
const treeDump = {};
bases.forEach((b) => {
const node = getOrCreateNodeByPath(depTree, b);
// eslint-disable-next-line security/detect-object-injection
treeDump[b] = Array.from(node.children.keys());
});
scanTreeRecursively(depTree, missingDependencies, satisfyingDependencies, (key) => {
return key;
}, perfKey);
// Drop false positives: if a missing path is already declared (optionals treated as equal)
if (missingDependencies.size > 0 && declaredDependencies.length > 0) {
for (const m of Array.from(missingDependencies)) {
if (declaredDependencies.some(({ key }) => pathsEquivalent(key, m))) {
missingDependencies.delete(m);
}
}
}
// Prune missing dependencies that are unsafe to suggest:
// - dynamic/computed indexing (e.g., foo.value[bar])
// - dependencies marked with hasInnerScopeComputedProperty
{
const toRemove = [];
missingDependencies.forEach((m) => {
// Keep static numeric indices like arr[0]. For dynamic indices, only drop when base is a signal.
if (m.includes('[') && !hasOnlyNumericComputed(m)) {
const base = m.split('.')[0] ?? '';
if (base !== '' && isSignalDependency(base, perfKey)) {
toRemove.push(m);
return;
}
}
const dep = dependencies.get(m);
if (dep && dep.hasInnerScopeComputedProperty === true) {
const base = m.split('.')[0];
if (typeof base === 'string' && base !== '' && isSignalDependency(base, perfKey)) {
toRemove.push(m);
}
}
});
for (const m of toRemove) {
missingDependencies.delete(m);
}
}
// Accumulators for dependency recommendations
const suggestedDependencies = [];
const addedPaths = new Set();
const addedRootPaths = new Set();
for (const base of Array.from(missingDependencies)) {
if (!base.includes('.') && !isSignalDependency(base, perfKey)) {
const hasDeeperMissing = Array.from(missingDependencies).some((d) => {
return d.startsWith(`${base}.`);
});
const hasAnyDeclaredDeeper = declaredDependencies.some(({ key }) => {
return key.startsWith(`${base}.`);
});
if (hasDeeperMissing || hasAnyDeclaredDeeper) {
const hasExistingDeepMissing = Array.from(missingDependencies).some((m) => {
return m.startsWith(`${base}.`);
});
if (!hasExistingDeepMissing) {
const deepReadKeys = [];
dependencies.forEach((depInfo, depKey) => {
if (depKey.startsWith(`${base}.`) &&
depInfo.hasReads === true &&
depInfo.hasInnerScopeComputedProperty !== true &&
// allow static numeric indices like base[0].x; for dynamic, allow if base is not a signal
!(depKey.includes('[') &&
!hasOnlyNumericComputed(depKey) &&
(() => {
const root = depKey.split('.')[0];
return (typeof root === 'string' && root !== '' && isSignalDependency(root, perfKey));
})())) {
deepReadKeys.push(depKey);
}
});
for (const dk of deepReadKeys) {
if (!addedPaths.has(dk) && !addedRootPaths.has(dk)) {
suggestedDependencies.push(dk);
addedPaths.add(dk);
const dkRoot = dk.split('.')[0];
if (dk.includes('.') && typeof dkRoot === 'string' && dkRoot !== '') {
addedRootPaths.add(dkRoot);
}
}
}
}
missingDependencies.delete(base);
}
}
}
/* suggestedDependencies declared above */
const unnecessaryDependencies = new Set();
const duplicateDependencies = new Set();
const incompleteDependencies = new Set();
const redundantDependencies = new Set();
const declaredDepsMap = new Map();
declaredDependencies.forEach(({ key }) => {
declaredDepsMap.set(key, true);
});
for (const { key } of declaredDependencies) {
if (!isSignalDependency(key, perfKey) && key.includes('.')) {
const baseKey = key.replace(/\?\./g, '.');
const depInfo = dependencies.get(baseKey);
const node = getOrCreateNodeByPath(depTree, baseKey);
const isUsed = (typeof depInfo !== 'undefined' && depInfo.hasReads) || node.isUsed || node.isSubtreeUsed;
if (isUsed) {
satisfyingDependencies.add(key);
// Also satisfy the base key used internally by the dep tree so it doesn't get marked missing
satisfyingDependencies.add(baseKey);
}
}
}
declaredDependencies.forEach(({ key }) => {
if (isSignalDependency(key, perfKey) && !key.includes('.')) {
const valueKey = `${key}.value`;
if (missingDependencies.has(valueKey)) {
incompleteDependencies.add(key);
satisfyingDependencies.delete(key);
}
else if (declaredDepsMap.has(valueKey)) {
redundantDependencies.add(key);
satisfyingDependencies.delete(key);
}
}
if (key.includes('.value[') && isSignalDependency(key.split('.')[0] ?? '', perfKey)) {
const valueIndex = key.indexOf('.value[');
if (valueIndex !== -1) {
const closingBracket = key.indexOf(']', valueIndex);
if (closingBracket !== -1) {
const baseComputedExpression = key.slice(0, closingBracket + 1);
if (declaredDepsMap.has(baseComputedExpression) && baseComputedExpression !== key) {
const isDeepPropertyChain = key.length > baseComputedExpression.length && key.charAt(closingBracket + 1) === '.';
if (isDeepPropertyChain) {
const hasOtherDeepPropertyChains = Array.from(declaredDepsMap.keys()).some((declaredKey) => {
return (declaredKey.startsWith(baseComputedExpression) &&
declaredKey !== baseComputedExpression &&
declaredKey !== key);
});
if (hasOtherDeepPropertyChains) {
// Only mark as redundant if the base computed expression is not directly read
const baseDependency = dependencies.get(baseComputedExpression);
const isDirectlyUsed = baseDependency && baseDependency.hasReads === true;
if (isDirectlyUsed !== true) {
redundantDependencies.add(baseComputedExpression);
satisfyingDependencies.delete(baseComputedExpression);
}
}
}
}
}
}
}
if (!key.includes('.') && !isSignalDependency(key, perfKey)) {
const hasDeepPropertyUsage = Array.from(missingDependencies).some((dep) => {
return dep.startsWith(`${key}.`) && dep !== key;
});
if (hasDeepPropertyUsage) {
// If the base is directly used (e.g., in guards like `if (x == null || x === 'loading') return ...`),
// allow listing the base dependency without requiring deep property chains.
const baseDependency = dependencies.get(key);
if ((typeof baseDependency !== 'undefined' && baseDependency.hasReads === true) !== true) {
incompleteDependencies.add(key);
satisfyingDependencies.delete(key);
}
}
else if (Array.from(declaredDepsMap.keys()).some((declaredKey) => {
return declaredKey.startsWith(`${key}.`) && declaredKey !== key;
})) {
// Check if the base dependency is directly used (not just through deeper properties)
const baseDependency = dependencies.get(key);
if (!(typeof baseDependency !== 'undefined' && baseDependency.hasReads === true)) {
redundantDependencies.add(key);
satisfyingDependencies.delete(key);
}
}
}
});
declaredDependencies.forEach(({ key }) => {
if (satisfyingDependencies.has(key)) {
if (suggestedDependencies.includes(key)) {
duplicateDependencies.add(key);
}
else {
suggestedDependencies.push(key);
}
return;
}
const isSignalDep = isSignalDependency(key, perfKey) || key.endsWith('.value');
const dependency = dependencies.get(key);
const isAssignmentOnly = isSignalDep && dependency && dependency.hasReads === false;
if (isAssignmentOnly === true) {
unnecessaryDependencies.add(key);
}
else if (incompleteDependencies.has(key) || redundantDependencies.has(key)) {
unnecessaryDependencies.add(key);
}
else if ((isEffect && !key.endsWith('.current') && !externalDependencies.has(key)) ||
isSignalDep ||
(!isEffect && !externalDependencies.has(key))) {
if (!suggestedDependencies.includes(key)) {
suggestedDependencies.push(key);
}
}
else {
unnecessaryDependencies.add(key);
}
});
if (missingDependencies.size > 0) {
const toDelete = [];
missingDependencies.forEach((m) => {
if (!m.includes('.')) {
return;
}
const base = m.split('.')[0] ?? '';
if (base === '' || isSignalDependency(base, perfKey)) {
return;
}
const baseDep = dependencies.get(base);
const isBaseDeclared = declaredDepsMap.has(base);
if (isBaseDeclared && baseDep && baseDep.hasReads === true) {
toDelete.push(m);
satisfyingDependencies.add(base);
incompleteDependencies.delete(base);
}
});
for (const m of toDelete) {
missingDependencies.delete(m);
}
}
const missingDepsArray = Array.from(missingDependencies);
missingDepsArray.sort((a, b) => {
return b.length - a.length;
});
const baseMissing = Array.from(missingDependencies).filter((m) => {
return !m.includes('.') && !isSignalDependency(m, perfKey);
});
if (baseMissing.length > 0) {
// Helper to collect leaf usage from dep tree
const collectUsedLeaves = (node, path, acc) => {
if (node.children.size === 0) {
acc.push({
path,
isUsed: node.isUsed === true,
isSubtreeUsed: node.isSubtreeUsed === true,
});
return;
}
node.children.forEach((child, key) => {
collectUsedLeaves(child, `${path}.${key}`, acc);
});
};
for (const base of baseMissing) {
const deepReads = [];
dependencies.forEach((dep, key) => {
if (key.startsWith(`${base}.`) && dep.hasReads === true)
deepReads.push(key);
});
const leafUsage = [];
const baseNode = getOrCreateNodeByPath(depTree, base);
baseNode.children.forEach((child, key) => {
collectUsedLeaves(child, `${base}.${key}`, leafUsage);
});
}
}
for (const key of missingDepsArray) {
const rootPath = key.split('.')[0];
const isChildPath = key.includes('.');
if (!isSignalDependency(key, perfKey) &&
!key.endsWith('.value') &&
!isChildPath &&
declaredDependencies.some(({ key: depKey }) => {
return depKey.startsWith(`${key}.`);
}) &&
missingDepsArray.some((dep) => {
return dep.startsWith(`${key}.`);
})) {
const hasExistingDeepMissing = missingDepsArray.some((m) => {
return m.startsWith(`${key}.`);
});
if (!hasExistingDeepMissing) {
const deepReadKeys = [];
dependencies.forEach((depInfo, depKey) => {
if (depKey.startsWith(`${key}.`) && depInfo.hasReads === true) {
deepReadKeys.push(depKey);
}
});
for (const dk of deepReadKeys) {
if (!addedPaths.has(dk) && !addedRootPaths.has(dk)) {
suggestedDependencies.push(dk);
addedPaths.add(dk);
const dkRoot = dk.split('.')[0];
if (dk.includes('.') && typeof dkRoot === 'string' && dkRoot !== '') {
addedRootPaths.add(dkRoot);
}
}
}
}
continue;
}
if (key.endsWith('.value')) {
const signalName = key.slice(0, -6);
if (isSignalDependency(signalName, perfKey)) {
addedRootPaths.add(signalName);
suggestedDependencies.push(key);
addedPaths.add(key);
continue;
}
}
// Avoid suggesting any dynamic or inner-scope-computed dependency
const keyDep = dependencies.get(key);
if (!addedPaths.has(key) &&
!addedRootPaths.has(key) &&
// allow static numeric indices like base[0].x; for dynamic, allow if base is not a signal
!(key.includes('[') &&
!hasOnlyNumericComputed(key) &&
(() => {
const root = key.split('.')[0];
return typeof root === 'string' && root !== '' && isSignalDependency(root, perfKey);
})()) &&
!(keyDep && keyDep.hasInnerScopeComputedProperty === true)) {
suggestedDependencies.push(key);
addedPaths.add(key);
if (isChildPath && typeof rootPath === 'string' && rootPath !== '') {
addedRootPaths.add(rootPath);
}
}
}
// Normalize: replace non-signal base suggestions with deep properties if present
{
const toRemove = new Set();
const toAdd = [];
for (const s of suggestedDependencies) {
if (!s.includes('.') && !s.endsWith('.value') && !isSignalDependency(s, perfKey)) {
const deepMissingDependencies = Array.from(missingDependencies).filter((m) => {
return m.startsWith(`${s}.`);
});
const deepReads = [];
if (deepMissingDependencies.length === 0) {
dependencies.forEach((depInfo, depKey) => {
if (depKey.startsWith(`${s}.`) &&
depInfo.hasReads === true &&
depInfo.hasInnerScopeComputedProperty !== true &&
// allow static numeric indices like base[0].x; for dynamic, allow if base is not a signal
!(depKey.includes('[') &&
!hasOnlyNumericComputed(depKey) &&
(() => {
const root = depKey.split('.')[0];
return (typeof root === 'string' && root !== '' && isSignalDependency(root, perfKey));
})())) {
deepReads.push(depKey);
}
});
}
const replacements = deepMissingDependencies.length > 0 ? deepMissingDependencies : deepReads;
if (replacements.length > 0) {
toRemove.add(s);
for (const r of replacements) {
if (!suggestedDependencies.includes(r)) {
toAdd.push(r);
}
}
}
}
}
if (toRemove.size > 0 || toAdd.length > 0) {
const filtered = suggestedDependencies.filter((s) => {
return !toRemove.has(s);
});
suggestedDependencies.length = 0;
suggestedDependencies.push(...filtered, ...toAdd);
}
}
return {
suggestedDependencies,
unnecessaryDependencies,
duplicateDependencies,
missingDependencies,
};
}
function getConstructionExpressionType(node) {
switch (node.type) {
case AST_NODE_TYPES.ObjectExpression: {
return 'object';
}
case AST_NODE_TYPES.ArrayExpression: {
return 'array';
}
case AST_NODE_TYPES.ArrowFunctionExpression:
case AST_NODE_TYPES.FunctionExpression: {
return 'function';
}
case AST_NODE_TYPES.ClassExpression: {
return 'class';
}
case AST_NODE_TYPES.ConditionalExpression: {
if (getConstructionExpressionType(node.consequent) != null ||
getConstructionExpressionType(node.alternate) != null) {
return 'conditional';
}
return null;
}
case AST_NODE_TYPES.LogicalExpression: {
if (getConstructionExpressionType(node.left) != null ||
getConstructionExpressionType(node.right) != null) {
return '