@taiga-ui/eslint-plugin-experience-next 0.465.0 → 0.467.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +385 -21
- package/index.d.ts +18 -0
- package/index.esm.js +1592 -66
- package/package.json +1 -1
- package/rules/no-fully-untracked-effect.d.ts +5 -0
- package/rules/no-signal-reads-after-await-in-reactive-context.d.ts +5 -0
- package/rules/no-untracked-outside-reactive-context.d.ts +5 -0
- package/rules/no-useless-untracked.d.ts +5 -0
- package/rules/prefer-untracked-incidental-signal-reads.d.ts +30 -0
- package/rules/prefer-untracked-signal-getter.d.ts +5 -0
- package/rules/utils/angular-imports.d.ts +14 -0
- package/rules/utils/angular-signals.d.ts +45 -0
- package/rules/utils/ast-expressions.d.ts +7 -0
- package/rules/utils/ast-walk.d.ts +30 -0
- package/rules/utils/import-fix-helpers.d.ts +14 -0
- package/rules/utils/untracked-docs.d.ts +7 -0
package/index.esm.js
CHANGED
|
@@ -905,10 +905,16 @@ var recommended = defineConfig([
|
|
|
905
905
|
},
|
|
906
906
|
],
|
|
907
907
|
'@taiga-ui/experience-next/no-deep-imports-to-indexed-packages': 'error',
|
|
908
|
+
'@taiga-ui/experience-next/no-fully-untracked-effect': 'error',
|
|
908
909
|
'@taiga-ui/experience-next/no-implicit-public': 'error',
|
|
909
910
|
'@taiga-ui/experience-next/no-redundant-type-annotation': 'error',
|
|
911
|
+
'@taiga-ui/experience-next/no-signal-reads-after-await-in-reactive-context': 'error',
|
|
912
|
+
'@taiga-ui/experience-next/no-untracked-outside-reactive-context': 'error',
|
|
913
|
+
'@taiga-ui/experience-next/no-useless-untracked': 'error',
|
|
910
914
|
'@taiga-ui/experience-next/object-single-line': ['error', { printWidth: 90 }],
|
|
911
915
|
'@taiga-ui/experience-next/prefer-multi-arg-push': 'error',
|
|
916
|
+
'@taiga-ui/experience-next/prefer-untracked-incidental-signal-reads': 'error',
|
|
917
|
+
'@taiga-ui/experience-next/prefer-untracked-signal-getter': 'error',
|
|
912
918
|
'@taiga-ui/experience-next/short-tui-imports': 'error',
|
|
913
919
|
'@taiga-ui/experience-next/standalone-imports-sort': [
|
|
914
920
|
'error',
|
|
@@ -1706,7 +1712,7 @@ const config$2 = {
|
|
|
1706
1712
|
const MESSAGE_ID$5 = 'invalid-injection-token-description';
|
|
1707
1713
|
const ERROR_MESSAGE$3 = "InjectionToken's description should contain token's name";
|
|
1708
1714
|
const createRule$d = ESLintUtils.RuleCreator((name) => name);
|
|
1709
|
-
const rule$
|
|
1715
|
+
const rule$g = createRule$d({
|
|
1710
1716
|
create(context) {
|
|
1711
1717
|
return {
|
|
1712
1718
|
'NewExpression[callee.name="InjectionToken"]'(node) {
|
|
@@ -1774,7 +1780,7 @@ const DEFAULT_OPTIONS = {
|
|
|
1774
1780
|
projectName: String.raw `(?<=^@taiga-ui/)([-\w]+)`,
|
|
1775
1781
|
};
|
|
1776
1782
|
const createRule$c = ESLintUtils.RuleCreator((name) => name);
|
|
1777
|
-
const rule$
|
|
1783
|
+
const rule$f = createRule$c({
|
|
1778
1784
|
create(context) {
|
|
1779
1785
|
const { currentProject, deepImport, ignoreImports, importDeclaration, projectName, } = { ...DEFAULT_OPTIONS, ...context.options[0] };
|
|
1780
1786
|
const hasNonCodeExtension = (source) => {
|
|
@@ -2062,6 +2068,534 @@ function stripKnownExtensions(filePathOrSpecifier) {
|
|
|
2062
2068
|
return filePathOrSpecifier.replace(/\.(?:d\.ts|ts|tsx|js|jsx|mjs|cjs)$/, '');
|
|
2063
2069
|
}
|
|
2064
2070
|
|
|
2071
|
+
const ANGULAR_CORE$1 = '@angular/core';
|
|
2072
|
+
/**
|
|
2073
|
+
* Returns the local name bound to a named import from a given source.
|
|
2074
|
+
* Handles aliased imports: `import { untracked as ngUntracked } from '@angular/core'`
|
|
2075
|
+
* returns `'ngUntracked'` for `exportedName = 'untracked'`.
|
|
2076
|
+
*/
|
|
2077
|
+
function getLocalNameForImport(program, source, exportedName) {
|
|
2078
|
+
for (const node of program.body) {
|
|
2079
|
+
if (node.type !== AST_NODE_TYPES.ImportDeclaration ||
|
|
2080
|
+
node.source.value !== source) {
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
for (const spec of node.specifiers) {
|
|
2084
|
+
if (spec.type !== AST_NODE_TYPES.ImportSpecifier) {
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
const imported = spec.imported.type === AST_NODE_TYPES.Identifier
|
|
2088
|
+
? spec.imported.name
|
|
2089
|
+
: spec.imported.value;
|
|
2090
|
+
if (imported === exportedName) {
|
|
2091
|
+
return spec.local.name;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
return null;
|
|
2096
|
+
}
|
|
2097
|
+
function findAngularCoreImports(program) {
|
|
2098
|
+
return program.body.filter((node) => node.type === AST_NODE_TYPES.ImportDeclaration &&
|
|
2099
|
+
node.source.value === ANGULAR_CORE$1);
|
|
2100
|
+
}
|
|
2101
|
+
function findRuntimeAngularCoreImport(program) {
|
|
2102
|
+
return (findAngularCoreImports(program).find((node) => node.importKind !== 'type') ?? null);
|
|
2103
|
+
}
|
|
2104
|
+
function findAngularCoreImportSpecifier(program, exportedName) {
|
|
2105
|
+
for (const importDecl of findAngularCoreImports(program)) {
|
|
2106
|
+
for (const specifier of importDecl.specifiers) {
|
|
2107
|
+
if (specifier.type !== AST_NODE_TYPES.ImportSpecifier) {
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
const imported = specifier.imported.type === AST_NODE_TYPES.Identifier
|
|
2111
|
+
? specifier.imported.name
|
|
2112
|
+
: specifier.imported.value;
|
|
2113
|
+
if (imported === exportedName) {
|
|
2114
|
+
return { importDecl, specifier };
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
return null;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function isFunctionLike$1(node) {
|
|
2122
|
+
return (node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
2123
|
+
node.type === AST_NODE_TYPES.FunctionDeclaration ||
|
|
2124
|
+
node.type === AST_NODE_TYPES.FunctionExpression);
|
|
2125
|
+
}
|
|
2126
|
+
function getOrderedChildren(node) {
|
|
2127
|
+
if (isFunctionLike$1(node)) {
|
|
2128
|
+
const children = [
|
|
2129
|
+
...node.params,
|
|
2130
|
+
node.body,
|
|
2131
|
+
];
|
|
2132
|
+
return children.filter((child) => child !== undefined && child !== null);
|
|
2133
|
+
}
|
|
2134
|
+
if (node.type === AST_NODE_TYPES.BlockStatement) {
|
|
2135
|
+
return node.body;
|
|
2136
|
+
}
|
|
2137
|
+
if (node.type === AST_NODE_TYPES.Program) {
|
|
2138
|
+
return node.body;
|
|
2139
|
+
}
|
|
2140
|
+
if (node.type === AST_NODE_TYPES.IfStatement) {
|
|
2141
|
+
return node.alternate
|
|
2142
|
+
? [node.test, node.consequent, node.alternate]
|
|
2143
|
+
: [node.test, node.consequent];
|
|
2144
|
+
}
|
|
2145
|
+
if (node.type === AST_NODE_TYPES.ConditionalExpression) {
|
|
2146
|
+
return [node.test, node.consequent, node.alternate];
|
|
2147
|
+
}
|
|
2148
|
+
if (node.type === AST_NODE_TYPES.WhileStatement) {
|
|
2149
|
+
return [node.test, node.body];
|
|
2150
|
+
}
|
|
2151
|
+
if (node.type === AST_NODE_TYPES.DoWhileStatement) {
|
|
2152
|
+
return [node.body, node.test];
|
|
2153
|
+
}
|
|
2154
|
+
if (node.type === AST_NODE_TYPES.ForStatement) {
|
|
2155
|
+
const children = [
|
|
2156
|
+
node.init && 'type' in node.init ? node.init : null,
|
|
2157
|
+
node.test,
|
|
2158
|
+
node.update,
|
|
2159
|
+
node.body,
|
|
2160
|
+
];
|
|
2161
|
+
return children.filter((child) => child !== null);
|
|
2162
|
+
}
|
|
2163
|
+
if (node.type === AST_NODE_TYPES.ForInStatement ||
|
|
2164
|
+
node.type === AST_NODE_TYPES.ForOfStatement) {
|
|
2165
|
+
return [node.left, node.right, node.body];
|
|
2166
|
+
}
|
|
2167
|
+
const children = [];
|
|
2168
|
+
for (const key of Object.keys(node)) {
|
|
2169
|
+
if (key === 'parent') {
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
2172
|
+
const child = node[key];
|
|
2173
|
+
if (Array.isArray(child)) {
|
|
2174
|
+
for (const item of child) {
|
|
2175
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
2176
|
+
children.push(item);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
else if (child && typeof child === 'object' && 'type' in child) {
|
|
2181
|
+
children.push(child);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
return children;
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Walks the synchronous portion of a reactive scope.
|
|
2188
|
+
*
|
|
2189
|
+
* - Stops descending into nested function boundaries, since they run in their
|
|
2190
|
+
* own call context and should not be treated as reads/writes of the current
|
|
2191
|
+
* reactive scope.
|
|
2192
|
+
* - Traverses the argument of `await`, because it is evaluated synchronously,
|
|
2193
|
+
* then stops visiting subsequent sibling nodes in the current execution path.
|
|
2194
|
+
*/
|
|
2195
|
+
function walkSynchronousAst(root, visitor) {
|
|
2196
|
+
traverse(root, true);
|
|
2197
|
+
function traverse(node, isRoot = false) {
|
|
2198
|
+
if (visitor(node) === false) {
|
|
2199
|
+
return false;
|
|
2200
|
+
}
|
|
2201
|
+
if (!isRoot && isFunctionLike$1(node)) {
|
|
2202
|
+
return false;
|
|
2203
|
+
}
|
|
2204
|
+
if (node.type === AST_NODE_TYPES.AwaitExpression) {
|
|
2205
|
+
return traverse(node.argument, false) || true;
|
|
2206
|
+
}
|
|
2207
|
+
for (const child of getOrderedChildren(node)) {
|
|
2208
|
+
if (traverse(child, false)) {
|
|
2209
|
+
return true;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
return false;
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Walks nodes that run after an async boundary inside a reactive callback.
|
|
2217
|
+
*
|
|
2218
|
+
* The traversal is intentionally conservative:
|
|
2219
|
+
* - it never descends into nested function boundaries
|
|
2220
|
+
* - it propagates async state through straight-line code
|
|
2221
|
+
* - it does not propagate branch-local `await` from optional control-flow
|
|
2222
|
+
* bodies into later siblings, which avoids noisy false positives
|
|
2223
|
+
*/
|
|
2224
|
+
function walkAfterAsyncBoundaryAst(root, visitor) {
|
|
2225
|
+
traverse(root, true, false);
|
|
2226
|
+
function traverse(node, isRoot = false, afterBoundary = false) {
|
|
2227
|
+
if (afterBoundary) {
|
|
2228
|
+
visitor(node);
|
|
2229
|
+
}
|
|
2230
|
+
if (!isRoot && isFunctionLike$1(node)) {
|
|
2231
|
+
return false;
|
|
2232
|
+
}
|
|
2233
|
+
if (node.type === AST_NODE_TYPES.AwaitExpression) {
|
|
2234
|
+
traverse(node.argument, false, afterBoundary);
|
|
2235
|
+
return true;
|
|
2236
|
+
}
|
|
2237
|
+
if (node.type === AST_NODE_TYPES.BlockStatement ||
|
|
2238
|
+
node.type === AST_NODE_TYPES.Program) {
|
|
2239
|
+
let crossed = afterBoundary;
|
|
2240
|
+
for (const child of node.body) {
|
|
2241
|
+
const childCrossed = traverse(child, false, crossed);
|
|
2242
|
+
crossed = crossed || childCrossed;
|
|
2243
|
+
}
|
|
2244
|
+
return crossed && !afterBoundary;
|
|
2245
|
+
}
|
|
2246
|
+
if (node.type === AST_NODE_TYPES.IfStatement) {
|
|
2247
|
+
const crossedInTest = traverse(node.test, false, afterBoundary);
|
|
2248
|
+
const branchAfterBoundary = afterBoundary || crossedInTest;
|
|
2249
|
+
traverse(node.consequent, false, branchAfterBoundary);
|
|
2250
|
+
if (node.alternate) {
|
|
2251
|
+
traverse(node.alternate, false, branchAfterBoundary);
|
|
2252
|
+
}
|
|
2253
|
+
return crossedInTest && !afterBoundary;
|
|
2254
|
+
}
|
|
2255
|
+
let crossed = afterBoundary;
|
|
2256
|
+
for (const child of getOrderedChildren(node)) {
|
|
2257
|
+
const childCrossed = traverse(child, false, crossed);
|
|
2258
|
+
crossed = crossed || childCrossed;
|
|
2259
|
+
}
|
|
2260
|
+
return crossed && !afterBoundary;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* Shallow AST walker. Visits `root` and every descendant, skipping the
|
|
2265
|
+
* synthetic `parent` back-pointer to avoid cycles.
|
|
2266
|
+
*
|
|
2267
|
+
* If the visitor returns `false` for a node, that node's children are NOT
|
|
2268
|
+
* visited (prune / stop-descend). Any other return value (including `void`)
|
|
2269
|
+
* continues the walk.
|
|
2270
|
+
*/
|
|
2271
|
+
function walkAst(root, visitor) {
|
|
2272
|
+
if (visitor(root) === false) {
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
for (const key of Object.keys(root)) {
|
|
2276
|
+
if (key === 'parent') {
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
const child = root[key];
|
|
2280
|
+
if (Array.isArray(child)) {
|
|
2281
|
+
for (const item of child) {
|
|
2282
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
2283
|
+
walkAst(item, visitor);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
else if (child && typeof child === 'object' && 'type' in child) {
|
|
2288
|
+
walkAst(child, visitor);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
const ANGULAR_CORE = '@angular/core';
|
|
2294
|
+
const SIGNAL_WRITE_METHODS = new Set(['mutate', 'set', 'update']);
|
|
2295
|
+
const AFTER_RENDER_EFFECT_PHASES = new Map([
|
|
2296
|
+
['earlyRead', 'afterRenderEffect().earlyRead'],
|
|
2297
|
+
['mixedReadWrite', 'afterRenderEffect().mixedReadWrite'],
|
|
2298
|
+
['read', 'afterRenderEffect().read'],
|
|
2299
|
+
['write', 'afterRenderEffect().write'],
|
|
2300
|
+
]);
|
|
2301
|
+
function isReactiveCallback(node) {
|
|
2302
|
+
return (node?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
2303
|
+
node?.type === AST_NODE_TYPES.FunctionExpression);
|
|
2304
|
+
}
|
|
2305
|
+
function getPropertyName(property) {
|
|
2306
|
+
if (property.computed) {
|
|
2307
|
+
return null;
|
|
2308
|
+
}
|
|
2309
|
+
if (property.key.type === AST_NODE_TYPES.Identifier) {
|
|
2310
|
+
return property.key.name;
|
|
2311
|
+
}
|
|
2312
|
+
return typeof property.key.value === 'string' ? property.key.value : null;
|
|
2313
|
+
}
|
|
2314
|
+
function isAngularCoreCall(node, program, exportedName) {
|
|
2315
|
+
const localName = getLocalNameForImport(program, ANGULAR_CORE, exportedName);
|
|
2316
|
+
if (!localName) {
|
|
2317
|
+
return false;
|
|
2318
|
+
}
|
|
2319
|
+
return (node.callee.type === AST_NODE_TYPES.Identifier &&
|
|
2320
|
+
node.callee.name === localName &&
|
|
2321
|
+
node.arguments.length >= 1);
|
|
2322
|
+
}
|
|
2323
|
+
function appendFirstArgReactiveScope(scopes, call, kind) {
|
|
2324
|
+
const [arg] = call.arguments;
|
|
2325
|
+
if (!isReactiveCallback(arg)) {
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
scopes.push({ callback: arg, kind, owner: call, reportNode: call });
|
|
2329
|
+
}
|
|
2330
|
+
function appendObjectPropertyReactiveScopes(scopes, call, object, labels) {
|
|
2331
|
+
for (const property of object.properties) {
|
|
2332
|
+
if (property.type !== AST_NODE_TYPES.Property) {
|
|
2333
|
+
continue;
|
|
2334
|
+
}
|
|
2335
|
+
const name = getPropertyName(property);
|
|
2336
|
+
const label = name ? labels.get(name) : null;
|
|
2337
|
+
if (!label || !isReactiveCallback(property.value)) {
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
scopes.push({
|
|
2341
|
+
callback: property.value,
|
|
2342
|
+
kind: label,
|
|
2343
|
+
owner: call,
|
|
2344
|
+
reportNode: property,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
function isAngularEffectCall(node, program) {
|
|
2349
|
+
return isAngularCoreCall(node, program, 'effect');
|
|
2350
|
+
}
|
|
2351
|
+
function isAngularUntrackedCall(node, program) {
|
|
2352
|
+
return isAngularCoreCall(node, program, 'untracked');
|
|
2353
|
+
}
|
|
2354
|
+
function getReactiveScopes(node, program) {
|
|
2355
|
+
const scopes = [];
|
|
2356
|
+
const [arg] = node.arguments;
|
|
2357
|
+
if (isAngularEffectCall(node, program)) {
|
|
2358
|
+
appendFirstArgReactiveScope(scopes, node, 'effect()');
|
|
2359
|
+
}
|
|
2360
|
+
if (isAngularCoreCall(node, program, 'computed')) {
|
|
2361
|
+
appendFirstArgReactiveScope(scopes, node, 'computed()');
|
|
2362
|
+
}
|
|
2363
|
+
if (isAngularCoreCall(node, program, 'linkedSignal')) {
|
|
2364
|
+
appendFirstArgReactiveScope(scopes, node, 'linkedSignal()');
|
|
2365
|
+
if (arg?.type === AST_NODE_TYPES.ObjectExpression) {
|
|
2366
|
+
appendObjectPropertyReactiveScopes(scopes, node, arg, new Map([
|
|
2367
|
+
['computation', 'linkedSignal().computation'],
|
|
2368
|
+
['source', 'linkedSignal().source'],
|
|
2369
|
+
]));
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (isAngularCoreCall(node, program, 'resource')) {
|
|
2373
|
+
if (arg?.type === AST_NODE_TYPES.ObjectExpression) {
|
|
2374
|
+
appendObjectPropertyReactiveScopes(scopes, node, arg, new Map([
|
|
2375
|
+
['loader', 'resource().loader'],
|
|
2376
|
+
['params', 'resource().params'],
|
|
2377
|
+
]));
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
if (isAngularCoreCall(node, program, 'afterRenderEffect')) {
|
|
2381
|
+
appendFirstArgReactiveScope(scopes, node, 'afterRenderEffect()');
|
|
2382
|
+
if (arg?.type === AST_NODE_TYPES.ObjectExpression) {
|
|
2383
|
+
appendObjectPropertyReactiveScopes(scopes, node, arg, AFTER_RENDER_EFFECT_PHASES);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
return scopes;
|
|
2387
|
+
}
|
|
2388
|
+
function isNodeInsideSynchronousReactiveScope(node, callback) {
|
|
2389
|
+
let found = false;
|
|
2390
|
+
walkSynchronousAst(callback, (inner) => {
|
|
2391
|
+
if (inner !== node) {
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
found = true;
|
|
2395
|
+
return false;
|
|
2396
|
+
});
|
|
2397
|
+
return found;
|
|
2398
|
+
}
|
|
2399
|
+
function findEnclosingReactiveScope(node, program) {
|
|
2400
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
2401
|
+
if (current.type !== AST_NODE_TYPES.CallExpression) {
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
for (const scope of getReactiveScopes(current, program)) {
|
|
2405
|
+
if (isNodeInsideSynchronousReactiveScope(node, scope.callback)) {
|
|
2406
|
+
return scope;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* Returns true when the TypeScript type at `node` is an Angular signal type.
|
|
2414
|
+
* Uses duck-typing: callable type whose name contains "Signal", or whose
|
|
2415
|
+
* string-keyed properties include the Angular `ɵ` brand.
|
|
2416
|
+
*
|
|
2417
|
+
* Falls back to `false` on any error.
|
|
2418
|
+
*/
|
|
2419
|
+
function isSignalType(node, checker, esTreeNodeToTSNodeMap) {
|
|
2420
|
+
try {
|
|
2421
|
+
const tsNode = esTreeNodeToTSNodeMap.get(node);
|
|
2422
|
+
if (!tsNode) {
|
|
2423
|
+
return false;
|
|
2424
|
+
}
|
|
2425
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
2426
|
+
if (type.getCallSignatures().length === 0) {
|
|
2427
|
+
return false;
|
|
2428
|
+
}
|
|
2429
|
+
if (checker.typeToString(type).includes('Signal')) {
|
|
2430
|
+
return true;
|
|
2431
|
+
}
|
|
2432
|
+
const typeSymbol = type.getSymbol();
|
|
2433
|
+
const aliasSymbol = type.aliasSymbol;
|
|
2434
|
+
if (typeSymbol?.getName().includes('Signal') ||
|
|
2435
|
+
aliasSymbol?.getName().includes('Signal')) {
|
|
2436
|
+
return true;
|
|
2437
|
+
}
|
|
2438
|
+
return type
|
|
2439
|
+
.getProperties()
|
|
2440
|
+
.some((p) => p.name.startsWith('ɵ') || p.name === '__SIGNAL');
|
|
2441
|
+
}
|
|
2442
|
+
catch {
|
|
2443
|
+
return false;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
function isSignalReadCall(node, checker, esTreeNodeToTSNodeMap) {
|
|
2447
|
+
const { callee } = node;
|
|
2448
|
+
if (callee.type !== AST_NODE_TYPES.Identifier &&
|
|
2449
|
+
callee.type !== AST_NODE_TYPES.MemberExpression) {
|
|
2450
|
+
return false;
|
|
2451
|
+
}
|
|
2452
|
+
return isSignalType(callee, checker, esTreeNodeToTSNodeMap);
|
|
2453
|
+
}
|
|
2454
|
+
function isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap) {
|
|
2455
|
+
if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
|
|
2456
|
+
return false;
|
|
2457
|
+
}
|
|
2458
|
+
const { object, property } = node.callee;
|
|
2459
|
+
if (property.type !== AST_NODE_TYPES.Identifier) {
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
if (!SIGNAL_WRITE_METHODS.has(property.name)) {
|
|
2463
|
+
return false;
|
|
2464
|
+
}
|
|
2465
|
+
return isSignalType(object, checker, esTreeNodeToTSNodeMap);
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Returns true when `node` is a member expression `foo.bar` where `bar` is a
|
|
2469
|
+
* TypeScript getter. Getter accesses are opaque — the getter body can read
|
|
2470
|
+
* signals, so wrapping them in `untracked()` is justified.
|
|
2471
|
+
*/
|
|
2472
|
+
function isGetterMemberAccess(node, checker, esTreeNodeToTSNodeMap) {
|
|
2473
|
+
try {
|
|
2474
|
+
const tsNode = esTreeNodeToTSNodeMap.get(node);
|
|
2475
|
+
if (!tsNode) {
|
|
2476
|
+
return false;
|
|
2477
|
+
}
|
|
2478
|
+
const symbol = checker.getSymbolAtLocation(tsNode);
|
|
2479
|
+
if (!symbol) {
|
|
2480
|
+
return false;
|
|
2481
|
+
}
|
|
2482
|
+
return !!(symbol.flags & ts.SymbolFlags.GetAccessor);
|
|
2483
|
+
}
|
|
2484
|
+
catch {
|
|
2485
|
+
return false;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
/**
|
|
2489
|
+
* Walks `scopeNode` and collects all signal reads and writes within it,
|
|
2490
|
+
* NOT recursing into nested `untracked(...)` calls (their contents are
|
|
2491
|
+
* already hidden from tracking).
|
|
2492
|
+
*/
|
|
2493
|
+
function collectSignalUsages(scopeNode, checker, esTreeNodeToTSNodeMap, program) {
|
|
2494
|
+
const reads = [];
|
|
2495
|
+
const writes = [];
|
|
2496
|
+
walkSynchronousAst(scopeNode, (node) => {
|
|
2497
|
+
if (node.type !== AST_NODE_TYPES.CallExpression) {
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
// Skip contents of nested untracked — they are already isolated
|
|
2501
|
+
if (isAngularUntrackedCall(node, program)) {
|
|
2502
|
+
return false;
|
|
2503
|
+
}
|
|
2504
|
+
if (isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap)) {
|
|
2505
|
+
writes.push(node);
|
|
2506
|
+
for (const arg of node.arguments) {
|
|
2507
|
+
walkSynchronousAst(arg, (inner) => {
|
|
2508
|
+
if (inner.type === AST_NODE_TYPES.CallExpression &&
|
|
2509
|
+
isSignalReadCall(inner, checker, esTreeNodeToTSNodeMap)) {
|
|
2510
|
+
reads.push(inner);
|
|
2511
|
+
}
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
return false;
|
|
2515
|
+
}
|
|
2516
|
+
if (isSignalReadCall(node, checker, esTreeNodeToTSNodeMap)) {
|
|
2517
|
+
reads.push(node);
|
|
2518
|
+
}
|
|
2519
|
+
return;
|
|
2520
|
+
});
|
|
2521
|
+
return { reads, writes };
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
const ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL = 'https://angular.dev/guide/signals#reading-without-tracking-dependencies';
|
|
2525
|
+
const ANGULAR_SIGNALS_ASYNC_GUIDE_URL = 'https://angular.dev/guide/signals#reactive-context-and-async-operations';
|
|
2526
|
+
const UNTRACKED_RULES_README_URL = 'https://github.com/taiga-family/taiga-ui/blob/main/projects/eslint-plugin-experience-next/README.md';
|
|
2527
|
+
const createUntrackedRule = ESLintUtils.RuleCreator((name) => `${UNTRACKED_RULES_README_URL}#${name}`);
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* Collects signal reads that appear inside `untracked(...)` callbacks within
|
|
2531
|
+
* `root`, without descending into nested `untracked(...)` scopes.
|
|
2532
|
+
*/
|
|
2533
|
+
function collectReadsInsideUntracked(root, checker, esTreeNodeToTSNodeMap, program) {
|
|
2534
|
+
const reads = [];
|
|
2535
|
+
walkSynchronousAst(root, (node) => {
|
|
2536
|
+
if (node.type !== AST_NODE_TYPES.CallExpression) {
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
if (!isAngularUntrackedCall(node, program)) {
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
const [arg] = node.arguments;
|
|
2543
|
+
if (!arg ||
|
|
2544
|
+
(arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
2545
|
+
arg.type !== AST_NODE_TYPES.FunctionExpression)) {
|
|
2546
|
+
return false;
|
|
2547
|
+
}
|
|
2548
|
+
walkSynchronousAst(arg, (inner) => {
|
|
2549
|
+
if (inner.type === AST_NODE_TYPES.CallExpression &&
|
|
2550
|
+
isSignalReadCall(inner, checker, esTreeNodeToTSNodeMap)) {
|
|
2551
|
+
reads.push(inner);
|
|
2552
|
+
}
|
|
2553
|
+
});
|
|
2554
|
+
return false; // Do not descend into nested untracked from the outer walk
|
|
2555
|
+
});
|
|
2556
|
+
return reads;
|
|
2557
|
+
}
|
|
2558
|
+
const rule$e = createUntrackedRule({
|
|
2559
|
+
create(context) {
|
|
2560
|
+
const parserServices = ESLintUtils.getParserServices(context);
|
|
2561
|
+
const checker = parserServices.program.getTypeChecker();
|
|
2562
|
+
const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
|
|
2563
|
+
const { sourceCode } = context;
|
|
2564
|
+
const program = sourceCode.ast;
|
|
2565
|
+
return {
|
|
2566
|
+
CallExpression(node) {
|
|
2567
|
+
for (const scope of getReactiveScopes(node, program)) {
|
|
2568
|
+
const { reads: trackedReads } = collectSignalUsages(scope.callback, checker, esTreeNodeToTSNodeMap, program);
|
|
2569
|
+
if (trackedReads.length > 0) {
|
|
2570
|
+
continue;
|
|
2571
|
+
}
|
|
2572
|
+
const untrackedReads = collectReadsInsideUntracked(scope.callback, checker, esTreeNodeToTSNodeMap, program);
|
|
2573
|
+
if (untrackedReads.length === 0) {
|
|
2574
|
+
continue;
|
|
2575
|
+
}
|
|
2576
|
+
context.report({
|
|
2577
|
+
data: { kind: scope.kind },
|
|
2578
|
+
messageId: 'noTrackedReads',
|
|
2579
|
+
node: scope.reportNode,
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
},
|
|
2583
|
+
};
|
|
2584
|
+
},
|
|
2585
|
+
meta: {
|
|
2586
|
+
docs: {
|
|
2587
|
+
description: 'Disallow reactive callbacks where all signal reads are inside `untracked()`, leaving the callback without tracked dependencies',
|
|
2588
|
+
url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
|
|
2589
|
+
},
|
|
2590
|
+
messages: {
|
|
2591
|
+
noTrackedReads: 'This `{{ kind }}` callback has no tracked signal reads: every signal read is hidden inside `untracked()`, so changes will not re-run it. Move reads that should create dependencies outside `untracked()`. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
|
|
2592
|
+
},
|
|
2593
|
+
schema: [],
|
|
2594
|
+
type: 'problem',
|
|
2595
|
+
},
|
|
2596
|
+
name: 'no-fully-untracked-effect',
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2065
2599
|
const MESSAGE_ID$3 = 'no-href-with-router-link';
|
|
2066
2600
|
const ERROR_MESSAGE$1 = 'Do not use href and routerLink attributes together on the same element';
|
|
2067
2601
|
const config$1 = {
|
|
@@ -2108,7 +2642,7 @@ const config$1 = {
|
|
|
2108
2642
|
};
|
|
2109
2643
|
|
|
2110
2644
|
const createRule$a = ESLintUtils.RuleCreator((name) => name);
|
|
2111
|
-
const rule$
|
|
2645
|
+
const rule$d = createRule$a({
|
|
2112
2646
|
create(context) {
|
|
2113
2647
|
const checkImplicitPublic = (node) => {
|
|
2114
2648
|
const classRef = getClass(node);
|
|
@@ -2181,7 +2715,7 @@ function getClass(node) {
|
|
|
2181
2715
|
|
|
2182
2716
|
const createRule$9 = ESLintUtils.RuleCreator((name) => name);
|
|
2183
2717
|
const LEGACY_PEER_DEPS_PATTERN = /^legacy-peer-deps\s*=\s*true$/i;
|
|
2184
|
-
const rule$
|
|
2718
|
+
const rule$c = createRule$9({
|
|
2185
2719
|
create(context) {
|
|
2186
2720
|
return {
|
|
2187
2721
|
Program(node) {
|
|
@@ -2220,7 +2754,7 @@ const rule$7 = createRule$9({
|
|
|
2220
2754
|
});
|
|
2221
2755
|
|
|
2222
2756
|
const createRule$8 = ESLintUtils.RuleCreator((name) => name);
|
|
2223
|
-
const rule$
|
|
2757
|
+
const rule$b = createRule$8({
|
|
2224
2758
|
create(context) {
|
|
2225
2759
|
const services = ESLintUtils.getParserServices(context);
|
|
2226
2760
|
const checker = services.program.getTypeChecker();
|
|
@@ -2411,7 +2945,7 @@ function collectArrayExpressions(node) {
|
|
|
2411
2945
|
}
|
|
2412
2946
|
return result;
|
|
2413
2947
|
}
|
|
2414
|
-
const rule$
|
|
2948
|
+
const rule$a = createRule$7({
|
|
2415
2949
|
create(context) {
|
|
2416
2950
|
const parserServices = ESLintUtils.getParserServices(context);
|
|
2417
2951
|
const typeChecker = parserServices.program.getTypeChecker();
|
|
@@ -2507,6 +3041,54 @@ const rule$5 = createRule$7({
|
|
|
2507
3041
|
name: 'no-redundant-type-annotation',
|
|
2508
3042
|
});
|
|
2509
3043
|
|
|
3044
|
+
const rule$9 = createUntrackedRule({
|
|
3045
|
+
create(context) {
|
|
3046
|
+
const parserServices = ESLintUtils.getParserServices(context);
|
|
3047
|
+
const checker = parserServices.program.getTypeChecker();
|
|
3048
|
+
const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
|
|
3049
|
+
const { sourceCode } = context;
|
|
3050
|
+
const program = sourceCode.ast;
|
|
3051
|
+
return {
|
|
3052
|
+
CallExpression(node) {
|
|
3053
|
+
for (const scope of getReactiveScopes(node, program)) {
|
|
3054
|
+
const reported = new Set();
|
|
3055
|
+
walkAfterAsyncBoundaryAst(scope.callback, (inner) => {
|
|
3056
|
+
if (inner.type !== AST_NODE_TYPES.CallExpression ||
|
|
3057
|
+
!isSignalReadCall(inner, checker, esTreeNodeToTSNodeMap)) {
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
const key = String(inner.range);
|
|
3061
|
+
if (reported.has(key)) {
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
reported.add(key);
|
|
3065
|
+
context.report({
|
|
3066
|
+
data: {
|
|
3067
|
+
kind: scope.kind,
|
|
3068
|
+
name: sourceCode.getText(inner),
|
|
3069
|
+
},
|
|
3070
|
+
messageId: 'readAfterAwait',
|
|
3071
|
+
node: inner,
|
|
3072
|
+
});
|
|
3073
|
+
});
|
|
3074
|
+
}
|
|
3075
|
+
},
|
|
3076
|
+
};
|
|
3077
|
+
},
|
|
3078
|
+
meta: {
|
|
3079
|
+
docs: {
|
|
3080
|
+
description: 'Disallow signal reads that occur after `await` inside reactive callbacks, because Angular no longer tracks them as dependencies',
|
|
3081
|
+
url: ANGULAR_SIGNALS_ASYNC_GUIDE_URL,
|
|
3082
|
+
},
|
|
3083
|
+
messages: {
|
|
3084
|
+
readAfterAwait: '`{{ name }}` is read after `await` inside `{{ kind }}`. Angular only tracks synchronous signal reads, so this dependency will not be tracked. Read it before `await` and store the snapshot. See Angular guide: https://angular.dev/guide/signals#reactive-context-and-async-operations',
|
|
3085
|
+
},
|
|
3086
|
+
schema: [],
|
|
3087
|
+
type: 'problem',
|
|
3088
|
+
},
|
|
3089
|
+
name: 'no-signal-reads-after-await-in-reactive-context',
|
|
3090
|
+
});
|
|
3091
|
+
|
|
2510
3092
|
const createRule$6 = ESLintUtils.RuleCreator((name) => name);
|
|
2511
3093
|
function isStringLiteral(node) {
|
|
2512
3094
|
return (node.type === AST_NODE_TYPES.Literal &&
|
|
@@ -2571,7 +3153,7 @@ function hasTemplateLiteralAncestor(node) {
|
|
|
2571
3153
|
}
|
|
2572
3154
|
return false;
|
|
2573
3155
|
}
|
|
2574
|
-
const rule$
|
|
3156
|
+
const rule$8 = createRule$6({
|
|
2575
3157
|
create(context) {
|
|
2576
3158
|
const { sourceCode } = context;
|
|
2577
3159
|
let parserServices = null;
|
|
@@ -2630,49 +3212,620 @@ const rule$4 = createRule$6({
|
|
|
2630
3212
|
return;
|
|
2631
3213
|
}
|
|
2632
3214
|
context.report({
|
|
2633
|
-
fix: (fixer) => fixer.replaceText(node, allLiterals
|
|
2634
|
-
? buildMergedString(parts)
|
|
2635
|
-
: `\`${partsToTemplateContent(parts, getText)}\``),
|
|
2636
|
-
messageId: allLiterals ? 'mergeLiterals' : 'useTemplate',
|
|
2637
|
-
node,
|
|
3215
|
+
fix: (fixer) => fixer.replaceText(node, allLiterals
|
|
3216
|
+
? buildMergedString(parts)
|
|
3217
|
+
: `\`${partsToTemplateContent(parts, getText)}\``),
|
|
3218
|
+
messageId: allLiterals ? 'mergeLiterals' : 'useTemplate',
|
|
3219
|
+
node,
|
|
3220
|
+
});
|
|
3221
|
+
},
|
|
3222
|
+
TemplateLiteral(node) {
|
|
3223
|
+
// Tagged templates: changing quasis/expressions count alters behaviour
|
|
3224
|
+
if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) {
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3227
|
+
for (const [i, expr] of node.expressions.entries()) {
|
|
3228
|
+
if (expr.type === AST_NODE_TYPES.TemplateLiteral &&
|
|
3229
|
+
expr.parent.type !== AST_NODE_TYPES.TaggedTemplateExpression) {
|
|
3230
|
+
context.report({
|
|
3231
|
+
fix: (fixer) => fixer.replaceText(node, `\`${templateContent(node, (e, j) => (j === i ? templateContent(expr, wrapExpr) : wrapExpr(e)))}\``),
|
|
3232
|
+
messageId: 'flattenTemplate',
|
|
3233
|
+
node: expr,
|
|
3234
|
+
});
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
},
|
|
3238
|
+
};
|
|
3239
|
+
},
|
|
3240
|
+
meta: {
|
|
3241
|
+
docs: {
|
|
3242
|
+
description: 'Disallow string concatenation. Merge adjacent string literals into one; use template literals for string variables.',
|
|
3243
|
+
},
|
|
3244
|
+
fixable: 'code',
|
|
3245
|
+
messages: {
|
|
3246
|
+
flattenTemplate: 'Flatten nested template literal into its parent.',
|
|
3247
|
+
mergeLiterals: 'Merge string literals instead of concatenating them.',
|
|
3248
|
+
useTemplate: 'Use a template literal instead of string concatenation.',
|
|
3249
|
+
},
|
|
3250
|
+
schema: [],
|
|
3251
|
+
type: 'suggestion',
|
|
3252
|
+
},
|
|
3253
|
+
name: 'no-string-literal-concat',
|
|
3254
|
+
});
|
|
3255
|
+
|
|
3256
|
+
/** Returns the local alias for `untracked` from `@angular/core`, or null if not imported. */
|
|
3257
|
+
function findUntrackedAlias(program) {
|
|
3258
|
+
return getLocalNameForImport(program, '@angular/core', 'untracked');
|
|
3259
|
+
}
|
|
3260
|
+
/**
|
|
3261
|
+
* Builds fixer actions that add `untracked` to an existing `@angular/core` import,
|
|
3262
|
+
* or insert a new import declaration when none exists.
|
|
3263
|
+
*/
|
|
3264
|
+
function buildUntrackedImportFixes(program, fixer) {
|
|
3265
|
+
const coreImport = findRuntimeAngularCoreImport(program);
|
|
3266
|
+
if (!coreImport) {
|
|
3267
|
+
const firstStatement = program.body[0];
|
|
3268
|
+
if (!firstStatement) {
|
|
3269
|
+
return [];
|
|
3270
|
+
}
|
|
3271
|
+
return [
|
|
3272
|
+
fixer.insertTextBefore(firstStatement, "import { untracked } from '@angular/core';\n"),
|
|
3273
|
+
];
|
|
3274
|
+
}
|
|
3275
|
+
const namedSpecifiers = coreImport.specifiers.filter((specifier) => specifier.type === AST_NODE_TYPES.ImportSpecifier);
|
|
3276
|
+
if (namedSpecifiers.length > 0) {
|
|
3277
|
+
return [
|
|
3278
|
+
fixer.insertTextAfter(namedSpecifiers[namedSpecifiers.length - 1], ', untracked'),
|
|
3279
|
+
];
|
|
3280
|
+
}
|
|
3281
|
+
const defaultImport = coreImport.specifiers.find((specifier) => specifier.type === AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
3282
|
+
if (defaultImport) {
|
|
3283
|
+
return [fixer.insertTextAfter(defaultImport, ', { untracked }')];
|
|
3284
|
+
}
|
|
3285
|
+
return [
|
|
3286
|
+
fixer.insertTextAfter(coreImport, "\nimport { untracked } from '@angular/core';"),
|
|
3287
|
+
];
|
|
3288
|
+
}
|
|
3289
|
+
/**
|
|
3290
|
+
* Removes the `untracked` import specifier from `@angular/core`.
|
|
3291
|
+
* When it is the last specifier, removes the entire declaration.
|
|
3292
|
+
*/
|
|
3293
|
+
function buildImportRemovalFixes(program, fixer, sourceCode) {
|
|
3294
|
+
const match = findAngularCoreImportSpecifier(program, 'untracked');
|
|
3295
|
+
if (!match) {
|
|
3296
|
+
return [];
|
|
3297
|
+
}
|
|
3298
|
+
const { importDecl, specifier: untrackedSpec } = match;
|
|
3299
|
+
const namedSpecifiers = importDecl.specifiers.filter((s) => s.type === AST_NODE_TYPES.ImportSpecifier);
|
|
3300
|
+
if (importDecl.specifiers.length === 1) {
|
|
3301
|
+
return [fixer.remove(importDecl)];
|
|
3302
|
+
}
|
|
3303
|
+
const importText = sourceCode.getText(importDecl);
|
|
3304
|
+
const importStart = importDecl.range[0];
|
|
3305
|
+
const specStart = untrackedSpec.range[0];
|
|
3306
|
+
const specEnd = untrackedSpec.range[1];
|
|
3307
|
+
// Try to remove a trailing comma
|
|
3308
|
+
const textAfter = importText.slice(specEnd - importStart);
|
|
3309
|
+
const trailingComma = /^(\s*,)/.exec(textAfter);
|
|
3310
|
+
if (trailingComma) {
|
|
3311
|
+
return [fixer.removeRange([specStart, specEnd + trailingComma[1].length])];
|
|
3312
|
+
}
|
|
3313
|
+
// Try to remove a leading comma (last specifier)
|
|
3314
|
+
const textBefore = importText.slice(0, specStart - importStart);
|
|
3315
|
+
const leadingCommaIdx = textBefore.lastIndexOf(',');
|
|
3316
|
+
if (leadingCommaIdx !== -1) {
|
|
3317
|
+
return [fixer.removeRange([importStart + leadingCommaIdx, specEnd])];
|
|
3318
|
+
}
|
|
3319
|
+
if (namedSpecifiers.length === 1) {
|
|
3320
|
+
return [fixer.remove(untrackedSpec)];
|
|
3321
|
+
}
|
|
3322
|
+
return [fixer.remove(untrackedSpec)];
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
const IMPERATIVE_UNTRACKED_METHODS = new Set(['registerOnChange', 'writeValue']);
|
|
3326
|
+
function dedent$1(text, extraSpaces) {
|
|
3327
|
+
if (extraSpaces <= 0) {
|
|
3328
|
+
return text;
|
|
3329
|
+
}
|
|
3330
|
+
const prefix = ' '.repeat(extraSpaces);
|
|
3331
|
+
return text
|
|
3332
|
+
.split('\n')
|
|
3333
|
+
.map((line) => (line.startsWith(prefix) ? line.slice(extraSpaces) : line))
|
|
3334
|
+
.join('\n');
|
|
3335
|
+
}
|
|
3336
|
+
function isFunctionLike(node) {
|
|
3337
|
+
return (node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
3338
|
+
node.type === AST_NODE_TYPES.FunctionDeclaration ||
|
|
3339
|
+
node.type === AST_NODE_TYPES.FunctionExpression);
|
|
3340
|
+
}
|
|
3341
|
+
function getObjectPropertyName(node) {
|
|
3342
|
+
if (node.computed) {
|
|
3343
|
+
return null;
|
|
3344
|
+
}
|
|
3345
|
+
if (node.key.type === AST_NODE_TYPES.Identifier) {
|
|
3346
|
+
return node.key.name;
|
|
3347
|
+
}
|
|
3348
|
+
return typeof node.key.value === 'string' ? node.key.value : null;
|
|
3349
|
+
}
|
|
3350
|
+
function getEnclosingClassMember(node) {
|
|
3351
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
3352
|
+
if (current.type === AST_NODE_TYPES.MethodDefinition ||
|
|
3353
|
+
current.type === AST_NODE_TYPES.PropertyDefinition) {
|
|
3354
|
+
return current;
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
return null;
|
|
3358
|
+
}
|
|
3359
|
+
function getEnclosingFunction(node) {
|
|
3360
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
3361
|
+
if (isFunctionLike(current)) {
|
|
3362
|
+
return current;
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
return null;
|
|
3366
|
+
}
|
|
3367
|
+
function getClassMemberName(member) {
|
|
3368
|
+
if (member.key.type === AST_NODE_TYPES.Identifier) {
|
|
3369
|
+
return member.key.name;
|
|
3370
|
+
}
|
|
3371
|
+
if (member.key.type === AST_NODE_TYPES.Literal &&
|
|
3372
|
+
typeof member.key.value === 'string') {
|
|
3373
|
+
return member.key.value;
|
|
3374
|
+
}
|
|
3375
|
+
return null;
|
|
3376
|
+
}
|
|
3377
|
+
function hasNamedDecorator(node, name) {
|
|
3378
|
+
return node.decorators.some((decorator) => {
|
|
3379
|
+
const expression = decorator.expression;
|
|
3380
|
+
if (expression.type === AST_NODE_TYPES.Identifier) {
|
|
3381
|
+
return expression.name === name;
|
|
3382
|
+
}
|
|
3383
|
+
return (expression.type === AST_NODE_TYPES.CallExpression &&
|
|
3384
|
+
expression.callee.type === AST_NODE_TYPES.Identifier &&
|
|
3385
|
+
expression.callee.name === name);
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
function isPipeTransformMember(member) {
|
|
3389
|
+
if (getClassMemberName(member) !== 'transform') {
|
|
3390
|
+
return false;
|
|
3391
|
+
}
|
|
3392
|
+
return hasNamedDecorator(member.parent.parent, 'Pipe');
|
|
3393
|
+
}
|
|
3394
|
+
function isAllowedImperativeAngularContext(node) {
|
|
3395
|
+
const member = getEnclosingClassMember(node);
|
|
3396
|
+
const memberName = member ? getClassMemberName(member) : null;
|
|
3397
|
+
if (!member || !memberName) {
|
|
3398
|
+
return false;
|
|
3399
|
+
}
|
|
3400
|
+
return IMPERATIVE_UNTRACKED_METHODS.has(memberName) || isPipeTransformMember(member);
|
|
3401
|
+
}
|
|
3402
|
+
function isDirectCallbackArgument(fn) {
|
|
3403
|
+
const parent = fn.parent;
|
|
3404
|
+
if (fn.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
3405
|
+
fn.type !== AST_NODE_TYPES.FunctionExpression) {
|
|
3406
|
+
return false;
|
|
3407
|
+
}
|
|
3408
|
+
return ((parent.type === AST_NODE_TYPES.CallExpression ||
|
|
3409
|
+
parent.type === AST_NODE_TYPES.NewExpression) &&
|
|
3410
|
+
parent.arguments.includes(fn));
|
|
3411
|
+
}
|
|
3412
|
+
function getScopeRoot(node) {
|
|
3413
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
3414
|
+
if (current.type === AST_NODE_TYPES.Program || isFunctionLike(current)) {
|
|
3415
|
+
return current;
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
return node;
|
|
3419
|
+
}
|
|
3420
|
+
function isStoredCallbackUsedAsArgument(fn, checker, esTreeNodeToTSNodeMap) {
|
|
3421
|
+
const parent = fn.parent;
|
|
3422
|
+
if (parent.type !== AST_NODE_TYPES.VariableDeclarator ||
|
|
3423
|
+
parent.id.type !== AST_NODE_TYPES.Identifier) {
|
|
3424
|
+
return false;
|
|
3425
|
+
}
|
|
3426
|
+
const id = parent.id;
|
|
3427
|
+
const tsNode = esTreeNodeToTSNodeMap.get(id);
|
|
3428
|
+
if (!tsNode) {
|
|
3429
|
+
return false;
|
|
3430
|
+
}
|
|
3431
|
+
const symbol = checker.getSymbolAtLocation(tsNode);
|
|
3432
|
+
if (!symbol) {
|
|
3433
|
+
return false;
|
|
3434
|
+
}
|
|
3435
|
+
let found = false;
|
|
3436
|
+
const scopeRoot = getScopeRoot(parent);
|
|
3437
|
+
walkAst(scopeRoot, (node) => {
|
|
3438
|
+
if (node.type !== AST_NODE_TYPES.Identifier ||
|
|
3439
|
+
node === id ||
|
|
3440
|
+
node.name !== id.name) {
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
const referenceTsNode = esTreeNodeToTSNodeMap.get(node);
|
|
3444
|
+
const referenceSymbol = referenceTsNode
|
|
3445
|
+
? checker.getSymbolAtLocation(referenceTsNode)
|
|
3446
|
+
: null;
|
|
3447
|
+
if (referenceSymbol !== symbol) {
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
const usageParent = node.parent;
|
|
3451
|
+
if ((usageParent.type === AST_NODE_TYPES.CallExpression ||
|
|
3452
|
+
usageParent.type === AST_NODE_TYPES.NewExpression) &&
|
|
3453
|
+
usageParent.arguments.includes(node)) {
|
|
3454
|
+
found = true;
|
|
3455
|
+
return false;
|
|
3456
|
+
}
|
|
3457
|
+
return;
|
|
3458
|
+
});
|
|
3459
|
+
return found;
|
|
3460
|
+
}
|
|
3461
|
+
function isAllowedDeferredCallbackContext(node, checker, esTreeNodeToTSNodeMap) {
|
|
3462
|
+
const [arg] = node.arguments;
|
|
3463
|
+
if (!arg ||
|
|
3464
|
+
arg.type === AST_NODE_TYPES.SpreadElement ||
|
|
3465
|
+
(arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
3466
|
+
arg.type !== AST_NODE_TYPES.FunctionExpression)) {
|
|
3467
|
+
return false;
|
|
3468
|
+
}
|
|
3469
|
+
const fn = getEnclosingFunction(node);
|
|
3470
|
+
if (!fn) {
|
|
3471
|
+
return false;
|
|
3472
|
+
}
|
|
3473
|
+
return (isDirectCallbackArgument(fn) ||
|
|
3474
|
+
isStoredCallbackUsedAsArgument(fn, checker, esTreeNodeToTSNodeMap));
|
|
3475
|
+
}
|
|
3476
|
+
function isAngularInjectionTokenFactoryFunction(fn, program) {
|
|
3477
|
+
const parent = fn.parent;
|
|
3478
|
+
const injectionTokenName = getLocalNameForImport(program, '@angular/core', 'InjectionToken');
|
|
3479
|
+
if (!injectionTokenName ||
|
|
3480
|
+
parent.type !== AST_NODE_TYPES.Property ||
|
|
3481
|
+
getObjectPropertyName(parent) !== 'factory') {
|
|
3482
|
+
return false;
|
|
3483
|
+
}
|
|
3484
|
+
const objectExpression = parent.parent;
|
|
3485
|
+
return (objectExpression.type === AST_NODE_TYPES.ObjectExpression &&
|
|
3486
|
+
objectExpression.parent.type === AST_NODE_TYPES.NewExpression &&
|
|
3487
|
+
objectExpression.parent.arguments.includes(objectExpression) &&
|
|
3488
|
+
objectExpression.parent.callee.type === AST_NODE_TYPES.Identifier &&
|
|
3489
|
+
objectExpression.parent.callee.name === injectionTokenName);
|
|
3490
|
+
}
|
|
3491
|
+
function isAngularUseFactoryFunction(fn) {
|
|
3492
|
+
const parent = fn.parent;
|
|
3493
|
+
if (parent.type !== AST_NODE_TYPES.Property ||
|
|
3494
|
+
getObjectPropertyName(parent) !== 'useFactory') {
|
|
3495
|
+
return false;
|
|
3496
|
+
}
|
|
3497
|
+
const objectExpression = parent.parent;
|
|
3498
|
+
return (objectExpression.type === AST_NODE_TYPES.ObjectExpression &&
|
|
3499
|
+
objectExpression.properties.some((property) => property.type === AST_NODE_TYPES.Property &&
|
|
3500
|
+
getObjectPropertyName(property) === 'provide'));
|
|
3501
|
+
}
|
|
3502
|
+
function isReactiveOwnerCall(node, program) {
|
|
3503
|
+
return (node.type === AST_NODE_TYPES.CallExpression &&
|
|
3504
|
+
getReactiveScopes(node, program).length > 0);
|
|
3505
|
+
}
|
|
3506
|
+
function getFixableReactiveCall(node, program) {
|
|
3507
|
+
const [arg] = node.arguments;
|
|
3508
|
+
if (!arg ||
|
|
3509
|
+
arg.type === AST_NODE_TYPES.SpreadElement ||
|
|
3510
|
+
(arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
3511
|
+
arg.type !== AST_NODE_TYPES.FunctionExpression)) {
|
|
3512
|
+
return null;
|
|
3513
|
+
}
|
|
3514
|
+
if (isReactiveOwnerCall(arg.body, program)) {
|
|
3515
|
+
return arg.body;
|
|
3516
|
+
}
|
|
3517
|
+
if (arg.body.type !== AST_NODE_TYPES.BlockStatement || arg.body.body.length !== 1) {
|
|
3518
|
+
return null;
|
|
3519
|
+
}
|
|
3520
|
+
const [statement] = arg.body.body;
|
|
3521
|
+
if (statement?.type === AST_NODE_TYPES.ReturnStatement &&
|
|
3522
|
+
statement.argument &&
|
|
3523
|
+
isReactiveOwnerCall(statement.argument, program)) {
|
|
3524
|
+
return statement.argument;
|
|
3525
|
+
}
|
|
3526
|
+
if (node.parent.type === AST_NODE_TYPES.ExpressionStatement &&
|
|
3527
|
+
statement?.type === AST_NODE_TYPES.ExpressionStatement &&
|
|
3528
|
+
isReactiveOwnerCall(statement.expression, program)) {
|
|
3529
|
+
return statement.expression;
|
|
3530
|
+
}
|
|
3531
|
+
return null;
|
|
3532
|
+
}
|
|
3533
|
+
function isAllowedLazyAngularFactoryContext(node, program) {
|
|
3534
|
+
const fn = getEnclosingFunction(node);
|
|
3535
|
+
if (!fn || !getFixableReactiveCall(node, program)) {
|
|
3536
|
+
return false;
|
|
3537
|
+
}
|
|
3538
|
+
return (isAngularInjectionTokenFactoryFunction(fn, program) ||
|
|
3539
|
+
isAngularUseFactoryFunction(fn));
|
|
3540
|
+
}
|
|
3541
|
+
function buildReactiveCallReplacement(outerUntrackedCall, reactiveCall, sourceCode) {
|
|
3542
|
+
const text = sourceCode.getText(reactiveCall);
|
|
3543
|
+
if (reactiveCall.parent.type !== AST_NODE_TYPES.ExpressionStatement ||
|
|
3544
|
+
outerUntrackedCall.parent.type !== AST_NODE_TYPES.ExpressionStatement) {
|
|
3545
|
+
return text;
|
|
3546
|
+
}
|
|
3547
|
+
return dedent$1(text, reactiveCall.loc.start.column - outerUntrackedCall.parent.loc.start.column);
|
|
3548
|
+
}
|
|
3549
|
+
const rule$7 = createUntrackedRule({
|
|
3550
|
+
create(context) {
|
|
3551
|
+
const parserServices = ESLintUtils.getParserServices(context);
|
|
3552
|
+
const checker = parserServices.program.getTypeChecker();
|
|
3553
|
+
const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
|
|
3554
|
+
const { sourceCode } = context;
|
|
3555
|
+
const program = sourceCode.ast;
|
|
3556
|
+
const getUntrackedLocalName = () => findUntrackedAlias(program);
|
|
3557
|
+
function isUntrackedUsedElsewhere(localName, excludeNode) {
|
|
3558
|
+
let found = false;
|
|
3559
|
+
walkAst(program, (node) => {
|
|
3560
|
+
if (node.type === AST_NODE_TYPES.CallExpression &&
|
|
3561
|
+
node !== excludeNode &&
|
|
3562
|
+
node.callee.type === AST_NODE_TYPES.Identifier &&
|
|
3563
|
+
node.callee.name === localName) {
|
|
3564
|
+
found = true;
|
|
3565
|
+
return false;
|
|
3566
|
+
}
|
|
3567
|
+
return;
|
|
3568
|
+
});
|
|
3569
|
+
return found;
|
|
3570
|
+
}
|
|
3571
|
+
return {
|
|
3572
|
+
CallExpression(node) {
|
|
3573
|
+
if (!isAngularUntrackedCall(node, program)) {
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
if (findEnclosingReactiveScope(node, program)) {
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
if (isAllowedImperativeAngularContext(node)) {
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3582
|
+
if (isAllowedDeferredCallbackContext(node, checker, esTreeNodeToTSNodeMap)) {
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3585
|
+
if (isAllowedLazyAngularFactoryContext(node, program)) {
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
const reactiveCall = getFixableReactiveCall(node, program);
|
|
3589
|
+
context.report({
|
|
3590
|
+
fix: reactiveCall
|
|
3591
|
+
? (fixer) => {
|
|
3592
|
+
const fixes = [
|
|
3593
|
+
fixer.replaceText(node, buildReactiveCallReplacement(node, reactiveCall, sourceCode)),
|
|
3594
|
+
];
|
|
3595
|
+
const untrackedLocalName = getUntrackedLocalName();
|
|
3596
|
+
const stillUsed = untrackedLocalName !== null &&
|
|
3597
|
+
isUntrackedUsedElsewhere(untrackedLocalName, node);
|
|
3598
|
+
if (!stillUsed) {
|
|
3599
|
+
fixes.push(...buildImportRemovalFixes(program, fixer, sourceCode));
|
|
3600
|
+
}
|
|
3601
|
+
return fixes;
|
|
3602
|
+
}
|
|
3603
|
+
: undefined,
|
|
3604
|
+
messageId: 'outsideReactiveContext',
|
|
3605
|
+
node: node.callee.type === AST_NODE_TYPES.Identifier
|
|
3606
|
+
? node.callee
|
|
3607
|
+
: node,
|
|
2638
3608
|
});
|
|
2639
3609
|
},
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
3610
|
+
};
|
|
3611
|
+
},
|
|
3612
|
+
meta: {
|
|
3613
|
+
docs: {
|
|
3614
|
+
description: 'Disallow `untracked()` outside the synchronous body of a reactive callback, except for supported imperative Angular hooks, deferred callback wrappers, and lazy DI factories that may execute under an ambient reactive context',
|
|
3615
|
+
url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
|
|
3616
|
+
},
|
|
3617
|
+
fixable: 'code',
|
|
3618
|
+
messages: {
|
|
3619
|
+
outsideReactiveContext: '`untracked()` is used outside the synchronous body of a reactive callback and outside the supported imperative/deferred/lazy-factory exceptions, so it does not prevent dependency tracking and only adds noise. Remove it. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
|
|
3620
|
+
},
|
|
3621
|
+
schema: [],
|
|
3622
|
+
type: 'problem',
|
|
3623
|
+
},
|
|
3624
|
+
name: 'no-untracked-outside-reactive-context',
|
|
3625
|
+
});
|
|
3626
|
+
|
|
3627
|
+
/**
|
|
3628
|
+
* Removes `extraSpaces` leading spaces from every line of `text` that starts
|
|
3629
|
+
* with at least that many spaces.
|
|
3630
|
+
*/
|
|
3631
|
+
function dedent(text, extraSpaces) {
|
|
3632
|
+
if (extraSpaces <= 0) {
|
|
3633
|
+
return text;
|
|
3634
|
+
}
|
|
3635
|
+
const prefix = ' '.repeat(extraSpaces);
|
|
3636
|
+
return text
|
|
3637
|
+
.split('\n')
|
|
3638
|
+
.map((line) => (line.startsWith(prefix) ? line.slice(extraSpaces) : line))
|
|
3639
|
+
.join('\n');
|
|
3640
|
+
}
|
|
3641
|
+
/**
|
|
3642
|
+
* Builds the replacement text for the parent ExpressionStatement of an
|
|
3643
|
+
* `untracked(...)` call.
|
|
3644
|
+
*
|
|
3645
|
+
* - Block body: extracts inner statements and re-indents them to match the
|
|
3646
|
+
* parent ExpressionStatement's indentation.
|
|
3647
|
+
* - Expression body: returns `<expr>;` (no trailing double-semicolon).
|
|
3648
|
+
*
|
|
3649
|
+
* Returns null if the untracked argument is not a function expression.
|
|
3650
|
+
*/
|
|
3651
|
+
function buildReplacement(untrackedCall, parentStatement, sourceCode) {
|
|
3652
|
+
const [arg] = untrackedCall.arguments;
|
|
3653
|
+
if (!arg ||
|
|
3654
|
+
(arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
3655
|
+
arg.type !== AST_NODE_TYPES.FunctionExpression)) {
|
|
3656
|
+
return null;
|
|
3657
|
+
}
|
|
3658
|
+
const { body } = arg;
|
|
3659
|
+
if (body.type === AST_NODE_TYPES.BlockStatement) {
|
|
3660
|
+
const { body: stmts } = body;
|
|
3661
|
+
if (stmts.length === 0) {
|
|
3662
|
+
return null; // Nothing to replace with — let caller delete the statement
|
|
3663
|
+
}
|
|
3664
|
+
const parentColumn = parentStatement.loc.start.column;
|
|
3665
|
+
const firstStmtColumn = stmts[0].loc.start.column;
|
|
3666
|
+
const extra = firstStmtColumn - parentColumn;
|
|
3667
|
+
const indented = stmts.map((s) => dedent(sourceCode.getText(s), extra));
|
|
3668
|
+
return indented.join(`\n${''.padStart(parentColumn)}`);
|
|
3669
|
+
}
|
|
3670
|
+
// Expression body: arrow `() => expr` — just emit `expr;`
|
|
3671
|
+
return `${sourceCode.getText(body)};`;
|
|
3672
|
+
}
|
|
3673
|
+
function getAllCallExpressions(root) {
|
|
3674
|
+
const result = [];
|
|
3675
|
+
walkAst(root, (node) => {
|
|
3676
|
+
if (node.type === AST_NODE_TYPES.CallExpression) {
|
|
3677
|
+
result.push(node);
|
|
3678
|
+
}
|
|
3679
|
+
});
|
|
3680
|
+
return result;
|
|
3681
|
+
}
|
|
3682
|
+
function hasOpaqueSynchronousCalls(root, checker, esTreeNodeToTSNodeMap, program) {
|
|
3683
|
+
let found = false;
|
|
3684
|
+
walkSynchronousAst(root, (node) => {
|
|
3685
|
+
if (node.type === AST_NODE_TYPES.MemberExpression) {
|
|
3686
|
+
if (isGetterMemberAccess(node, checker, esTreeNodeToTSNodeMap)) {
|
|
3687
|
+
found = true;
|
|
3688
|
+
return false;
|
|
3689
|
+
}
|
|
3690
|
+
return;
|
|
3691
|
+
}
|
|
3692
|
+
if (node.type !== AST_NODE_TYPES.CallExpression) {
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3695
|
+
if (isAngularUntrackedCall(node, program) ||
|
|
3696
|
+
isSignalReadCall(node, checker, esTreeNodeToTSNodeMap) ||
|
|
3697
|
+
isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap)) {
|
|
3698
|
+
return;
|
|
3699
|
+
}
|
|
3700
|
+
found = true;
|
|
3701
|
+
return false;
|
|
3702
|
+
});
|
|
3703
|
+
return found;
|
|
3704
|
+
}
|
|
3705
|
+
const rule$6 = createUntrackedRule({
|
|
3706
|
+
create(context) {
|
|
3707
|
+
const parserServices = ESLintUtils.getParserServices(context);
|
|
3708
|
+
const checker = parserServices.program.getTypeChecker();
|
|
3709
|
+
const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
|
|
3710
|
+
const { sourceCode } = context;
|
|
3711
|
+
const program = sourceCode.ast;
|
|
3712
|
+
const getUntrackedLocalName = () => getLocalNameForImport(program, '@angular/core', 'untracked');
|
|
3713
|
+
function isUntrackedUsedElsewhere(localName, excludeNode) {
|
|
3714
|
+
return getAllCallExpressions(program).some((n) => n !== excludeNode &&
|
|
3715
|
+
n.callee.type === AST_NODE_TYPES.Identifier &&
|
|
3716
|
+
n.callee.name === localName);
|
|
3717
|
+
}
|
|
3718
|
+
function checkUntrackedCall(untrackedCall, kind) {
|
|
3719
|
+
const [arg] = untrackedCall.arguments;
|
|
3720
|
+
if (!arg ||
|
|
3721
|
+
(arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
3722
|
+
arg.type !== AST_NODE_TYPES.FunctionExpression)) {
|
|
3723
|
+
return;
|
|
3724
|
+
}
|
|
3725
|
+
const { reads } = collectSignalUsages(arg, checker, esTreeNodeToTSNodeMap, program);
|
|
3726
|
+
if (reads.length > 0) {
|
|
3727
|
+
// Snapshot reads inside reactive callbacks are a valid Angular
|
|
3728
|
+
// pattern even when the snapshot later influences branching.
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
if (hasOpaqueSynchronousCalls(arg, checker, esTreeNodeToTSNodeMap, program)) {
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
// Only fix when the parent is a plain ExpressionStatement so we can
|
|
3735
|
+
// replace statement-for-statement without breaking surrounding structure.
|
|
3736
|
+
const parent = untrackedCall.parent;
|
|
3737
|
+
const canFix = parent.type === AST_NODE_TYPES.ExpressionStatement;
|
|
3738
|
+
context.report({
|
|
3739
|
+
data: { kind },
|
|
3740
|
+
fix: canFix
|
|
3741
|
+
? (fixer) => {
|
|
3742
|
+
const parentStmt = parent;
|
|
3743
|
+
const replacement = buildReplacement(untrackedCall, parentStmt, sourceCode);
|
|
3744
|
+
if (replacement === null) {
|
|
3745
|
+
return null;
|
|
3746
|
+
}
|
|
3747
|
+
const untrackedLocalName = getUntrackedLocalName();
|
|
3748
|
+
const stillUsed = untrackedLocalName !== null &&
|
|
3749
|
+
isUntrackedUsedElsewhere(untrackedLocalName, untrackedCall);
|
|
3750
|
+
const fixes = [fixer.replaceText(parentStmt, replacement)];
|
|
3751
|
+
if (!stillUsed) {
|
|
3752
|
+
fixes.push(...buildImportRemovalFixes(program, fixer, sourceCode));
|
|
3753
|
+
}
|
|
3754
|
+
return fixes;
|
|
3755
|
+
}
|
|
3756
|
+
: undefined,
|
|
3757
|
+
messageId: 'uselessUntracked',
|
|
3758
|
+
node: untrackedCall,
|
|
3759
|
+
});
|
|
3760
|
+
}
|
|
3761
|
+
function walkForUntracked(root, kind) {
|
|
3762
|
+
walkSynchronousAst(root, (node) => {
|
|
3763
|
+
if (node.type !== AST_NODE_TYPES.CallExpression ||
|
|
3764
|
+
!isAngularUntrackedCall(node, program)) {
|
|
2643
3765
|
return;
|
|
2644
3766
|
}
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
3767
|
+
checkUntrackedCall(node, kind);
|
|
3768
|
+
// Do not descend — nested untracked bodies are separate scopes
|
|
3769
|
+
return false;
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
3772
|
+
return {
|
|
3773
|
+
CallExpression(node) {
|
|
3774
|
+
for (const scope of getReactiveScopes(node, program)) {
|
|
3775
|
+
walkForUntracked(scope.callback, scope.kind);
|
|
2654
3776
|
}
|
|
2655
3777
|
},
|
|
2656
3778
|
};
|
|
2657
3779
|
},
|
|
2658
3780
|
meta: {
|
|
2659
3781
|
docs: {
|
|
2660
|
-
description: 'Disallow
|
|
3782
|
+
description: 'Disallow `untracked()` wrappers inside reactive callbacks when they contain no signal reads and do not wrap opaque external code that may read signals',
|
|
3783
|
+
url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
|
|
2661
3784
|
},
|
|
2662
3785
|
fixable: 'code',
|
|
2663
3786
|
messages: {
|
|
2664
|
-
|
|
2665
|
-
mergeLiterals: 'Merge string literals instead of concatenating them.',
|
|
2666
|
-
useTemplate: 'Use a template literal instead of string concatenation.',
|
|
3787
|
+
uselessUntracked: 'This `untracked()` wrapper is unnecessary inside `{{ kind }}` because its callback contains no signal reads that need shielding from dependency tracking. Pure writes (`.set()`, `.update()`, `.mutate()`) do not require `untracked()`. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
|
|
2667
3788
|
},
|
|
2668
3789
|
schema: [],
|
|
2669
3790
|
type: 'suggestion',
|
|
2670
3791
|
},
|
|
2671
|
-
name: 'no-
|
|
3792
|
+
name: 'no-useless-untracked',
|
|
2672
3793
|
});
|
|
2673
3794
|
|
|
3795
|
+
/**
|
|
3796
|
+
* Strips TypeScript-only wrapper nodes that have no runtime meaning:
|
|
3797
|
+
* `as` casts, non-null assertions (`!`), type assertions (`<T>expr`), and
|
|
3798
|
+
* optional-chain wrappers. Iterates until no more wrappers are found.
|
|
3799
|
+
*/
|
|
3800
|
+
function unwrapExpression(expression) {
|
|
3801
|
+
let current = expression;
|
|
3802
|
+
let didUnwrap = true;
|
|
3803
|
+
while (didUnwrap) {
|
|
3804
|
+
didUnwrap = false;
|
|
3805
|
+
switch (current.type) {
|
|
3806
|
+
case AST_NODE_TYPES.ChainExpression:
|
|
3807
|
+
current = current.expression;
|
|
3808
|
+
didUnwrap = true;
|
|
3809
|
+
break;
|
|
3810
|
+
case AST_NODE_TYPES.TSAsExpression:
|
|
3811
|
+
current = current.expression;
|
|
3812
|
+
didUnwrap = true;
|
|
3813
|
+
break;
|
|
3814
|
+
case AST_NODE_TYPES.TSNonNullExpression:
|
|
3815
|
+
current = current.expression;
|
|
3816
|
+
didUnwrap = true;
|
|
3817
|
+
break;
|
|
3818
|
+
case AST_NODE_TYPES.TSTypeAssertion:
|
|
3819
|
+
current = current.expression;
|
|
3820
|
+
didUnwrap = true;
|
|
3821
|
+
break;
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
return current;
|
|
3825
|
+
}
|
|
3826
|
+
|
|
2674
3827
|
const createRule$5 = ESLintUtils.RuleCreator((name) => name);
|
|
2675
|
-
const rule$
|
|
3828
|
+
const rule$5 = createRule$5({
|
|
2676
3829
|
create(context, [{ printWidth }]) {
|
|
2677
3830
|
const sourceCode = context.sourceCode;
|
|
2678
3831
|
const getLineEndIndex = (lineStartIndex) => {
|
|
@@ -2712,32 +3865,6 @@ const rule$3 = createRule$5({
|
|
|
2712
3865
|
const [onlyProperty] = node.properties;
|
|
2713
3866
|
return onlyProperty ? !isForbiddenProperty(onlyProperty) : false;
|
|
2714
3867
|
};
|
|
2715
|
-
const unwrapExpression = (expression) => {
|
|
2716
|
-
let current = expression;
|
|
2717
|
-
let didUnwrap = true;
|
|
2718
|
-
while (didUnwrap) {
|
|
2719
|
-
didUnwrap = false;
|
|
2720
|
-
switch (current.type) {
|
|
2721
|
-
case AST_NODE_TYPES$1.ChainExpression:
|
|
2722
|
-
current = current.expression;
|
|
2723
|
-
didUnwrap = true;
|
|
2724
|
-
break;
|
|
2725
|
-
case AST_NODE_TYPES$1.TSAsExpression:
|
|
2726
|
-
current = current.expression;
|
|
2727
|
-
didUnwrap = true;
|
|
2728
|
-
break;
|
|
2729
|
-
case AST_NODE_TYPES$1.TSNonNullExpression:
|
|
2730
|
-
current = current.expression;
|
|
2731
|
-
didUnwrap = true;
|
|
2732
|
-
break;
|
|
2733
|
-
case AST_NODE_TYPES$1.TSTypeAssertion:
|
|
2734
|
-
current = current.expression;
|
|
2735
|
-
didUnwrap = true;
|
|
2736
|
-
break;
|
|
2737
|
-
}
|
|
2738
|
-
}
|
|
2739
|
-
return current;
|
|
2740
|
-
};
|
|
2741
3868
|
const getParenthesizedInner = (expression) => {
|
|
2742
3869
|
const anyExpression = expression;
|
|
2743
3870
|
if (anyExpression?.type === 'ParenthesizedExpression') {
|
|
@@ -3316,7 +4443,7 @@ function getPushCall(node) {
|
|
|
3316
4443
|
}
|
|
3317
4444
|
return call;
|
|
3318
4445
|
}
|
|
3319
|
-
const rule$
|
|
4446
|
+
const rule$4 = createRule$3({
|
|
3320
4447
|
create(context) {
|
|
3321
4448
|
const { sourceCode } = context;
|
|
3322
4449
|
function checkBody(statements) {
|
|
@@ -3394,6 +4521,398 @@ const rule$2 = createRule$3({
|
|
|
3394
4521
|
name: 'prefer-multi-arg-push',
|
|
3395
4522
|
});
|
|
3396
4523
|
|
|
4524
|
+
/**
|
|
4525
|
+
* prefer-untracked-incidental-signal-reads
|
|
4526
|
+
*
|
|
4527
|
+
* Conservatively flags signal reads inside reactive callbacks that appear to be
|
|
4528
|
+
* incidental — they provide only a snapshot value passed to a side-effecting
|
|
4529
|
+
* consumer such as a signal write or DOM imperative call — and therefore
|
|
4530
|
+
* probably should be wrapped in `untracked(...)`.
|
|
4531
|
+
*
|
|
4532
|
+
* Design principle: prefer false-negatives over false-positives.
|
|
4533
|
+
* This rule only reports patterns it can confidently identify as suspicious.
|
|
4534
|
+
*
|
|
4535
|
+
* Limitations
|
|
4536
|
+
* -----------
|
|
4537
|
+
* Whether a signal read *should* be tracked is a question of developer intent,
|
|
4538
|
+
* not syntax. The rule relies on heuristics (type-based signal detection,
|
|
4539
|
+
* structural pattern matching) and will inevitably produce:
|
|
4540
|
+
*
|
|
4541
|
+
* - False negatives: incidental reads that do not match the heuristics.
|
|
4542
|
+
* - False positives (rare): reads that look incidental but are intentional
|
|
4543
|
+
* dependencies (e.g. the developer intentionally re-runs the effect when
|
|
4544
|
+
* `mousePosition` changes to keep `position` in sync).
|
|
4545
|
+
*
|
|
4546
|
+
* Always review suggestions before accepting the fix. If the reported read
|
|
4547
|
+
* IS meant to be a tracked dependency, disable the rule for that line.
|
|
4548
|
+
*/
|
|
4549
|
+
const CONSOLE_METHODS = new Set([
|
|
4550
|
+
'assert',
|
|
4551
|
+
'debug',
|
|
4552
|
+
'dir',
|
|
4553
|
+
'dirxml',
|
|
4554
|
+
'error',
|
|
4555
|
+
'group',
|
|
4556
|
+
'groupCollapsed',
|
|
4557
|
+
'groupEnd',
|
|
4558
|
+
'info',
|
|
4559
|
+
'log',
|
|
4560
|
+
'table',
|
|
4561
|
+
'trace',
|
|
4562
|
+
'warn',
|
|
4563
|
+
]);
|
|
4564
|
+
const HIGH_CONFIDENCE_DOM_METHODS = new Set(['requestFullscreen']);
|
|
4565
|
+
const LIB_DOM_FILE_PATTERN = /(?:^|[\\/])lib\.dom(?:\.[^\\/]+)?\.d\.ts$/;
|
|
4566
|
+
/** Returns the source text for `untracked(() => signalGetter())`. */
|
|
4567
|
+
function buildUntrackedWrap(expr, sourceCode, untrackedAlias) {
|
|
4568
|
+
return `${untrackedAlias}(() => ${sourceCode.getText(expr.callee)}())`;
|
|
4569
|
+
}
|
|
4570
|
+
function unwrapUsageExpression(node) {
|
|
4571
|
+
let current = node;
|
|
4572
|
+
while (current.parent &&
|
|
4573
|
+
(current.parent.type === AST_NODE_TYPES.ChainExpression ||
|
|
4574
|
+
current.parent.type === AST_NODE_TYPES.TSAsExpression ||
|
|
4575
|
+
current.parent.type === AST_NODE_TYPES.TSNonNullExpression ||
|
|
4576
|
+
current.parent.type === AST_NODE_TYPES.TSTypeAssertion)) {
|
|
4577
|
+
current = current.parent;
|
|
4578
|
+
}
|
|
4579
|
+
return current;
|
|
4580
|
+
}
|
|
4581
|
+
function isNodeInside(node, ancestor) {
|
|
4582
|
+
for (let current = node; current; current = current.parent) {
|
|
4583
|
+
if (current === ancestor) {
|
|
4584
|
+
return true;
|
|
4585
|
+
}
|
|
4586
|
+
}
|
|
4587
|
+
return false;
|
|
4588
|
+
}
|
|
4589
|
+
function isNodeInsideAny(node, ancestors) {
|
|
4590
|
+
return ancestors.some((ancestor) => isNodeInside(node, ancestor));
|
|
4591
|
+
}
|
|
4592
|
+
function isStatementPositionCall(node) {
|
|
4593
|
+
const usage = unwrapUsageExpression(node);
|
|
4594
|
+
if (usage.parent?.type === AST_NODE_TYPES.ExpressionStatement) {
|
|
4595
|
+
return true;
|
|
4596
|
+
}
|
|
4597
|
+
return (usage.parent?.type === AST_NODE_TYPES.AwaitExpression &&
|
|
4598
|
+
usage.parent.parent.type === AST_NODE_TYPES.ExpressionStatement);
|
|
4599
|
+
}
|
|
4600
|
+
function isConsoleMethodCall(node) {
|
|
4601
|
+
if (node.callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
4602
|
+
node.callee.object.type !== AST_NODE_TYPES.Identifier ||
|
|
4603
|
+
node.callee.object.name !== 'console' ||
|
|
4604
|
+
node.callee.property.type !== AST_NODE_TYPES.Identifier) {
|
|
4605
|
+
return false;
|
|
4606
|
+
}
|
|
4607
|
+
return CONSOLE_METHODS.has(node.callee.property.name);
|
|
4608
|
+
}
|
|
4609
|
+
function isDomImperativeCall(node, checker, esTreeNodeToTSNodeMap) {
|
|
4610
|
+
if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
4611
|
+
node.callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
4612
|
+
HIGH_CONFIDENCE_DOM_METHODS.has(node.callee.property.name)) {
|
|
4613
|
+
return true;
|
|
4614
|
+
}
|
|
4615
|
+
const tsNode = esTreeNodeToTSNodeMap.get(node);
|
|
4616
|
+
if (!tsNode) {
|
|
4617
|
+
return false;
|
|
4618
|
+
}
|
|
4619
|
+
const signature = checker.getResolvedSignature(tsNode);
|
|
4620
|
+
const declaration = signature?.declaration;
|
|
4621
|
+
if (!declaration) {
|
|
4622
|
+
return false;
|
|
4623
|
+
}
|
|
4624
|
+
if (!LIB_DOM_FILE_PATTERN.test(declaration.getSourceFile().fileName)) {
|
|
4625
|
+
return false;
|
|
4626
|
+
}
|
|
4627
|
+
const returnType = checker.typeToString(checker.getReturnTypeOfSignature(signature));
|
|
4628
|
+
return returnType === 'void' || returnType === 'Promise<void>';
|
|
4629
|
+
}
|
|
4630
|
+
function isSuspiciousDomCallArgumentConsumer(node, checker, esTreeNodeToTSNodeMap, program) {
|
|
4631
|
+
if (isAngularUntrackedCall(node, program) ||
|
|
4632
|
+
isSignalReadCall(node, checker, esTreeNodeToTSNodeMap) ||
|
|
4633
|
+
isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap) ||
|
|
4634
|
+
!isStatementPositionCall(node) ||
|
|
4635
|
+
isConsoleMethodCall(node)) {
|
|
4636
|
+
return false;
|
|
4637
|
+
}
|
|
4638
|
+
return isDomImperativeCall(node, checker, esTreeNodeToTSNodeMap);
|
|
4639
|
+
}
|
|
4640
|
+
function isAliasDeclarationIdentifier(node) {
|
|
4641
|
+
return (node.type === AST_NODE_TYPES.Identifier &&
|
|
4642
|
+
node.parent.type === AST_NODE_TYPES.VariableDeclarator &&
|
|
4643
|
+
node.parent.id === node);
|
|
4644
|
+
}
|
|
4645
|
+
function aliasHasExternalUsage(alias, consumers, scope, checker, esTreeNodeToTSNodeMap) {
|
|
4646
|
+
const tsNode = esTreeNodeToTSNodeMap.get(alias);
|
|
4647
|
+
if (!tsNode) {
|
|
4648
|
+
return false;
|
|
4649
|
+
}
|
|
4650
|
+
const symbol = checker.getSymbolAtLocation(tsNode);
|
|
4651
|
+
if (!symbol) {
|
|
4652
|
+
return false;
|
|
4653
|
+
}
|
|
4654
|
+
let usedOutsideConsumers = false;
|
|
4655
|
+
walkSynchronousAst(scope.callback, (node) => {
|
|
4656
|
+
if (node.type !== AST_NODE_TYPES.Identifier ||
|
|
4657
|
+
node === alias ||
|
|
4658
|
+
isAliasDeclarationIdentifier(node) ||
|
|
4659
|
+
isNodeInsideAny(node, consumers)) {
|
|
4660
|
+
return;
|
|
4661
|
+
}
|
|
4662
|
+
const referenceTsNode = esTreeNodeToTSNodeMap.get(node);
|
|
4663
|
+
const referenceSymbol = referenceTsNode
|
|
4664
|
+
? checker.getSymbolAtLocation(referenceTsNode)
|
|
4665
|
+
: null;
|
|
4666
|
+
if (referenceSymbol !== symbol) {
|
|
4667
|
+
return;
|
|
4668
|
+
}
|
|
4669
|
+
usedOutsideConsumers = true;
|
|
4670
|
+
return false;
|
|
4671
|
+
});
|
|
4672
|
+
return usedOutsideConsumers;
|
|
4673
|
+
}
|
|
4674
|
+
/**
|
|
4675
|
+
* Collects all signal reads that appear as direct arguments to a high-confidence
|
|
4676
|
+
* incidental-read consumer inside a reactive scope, such as a writable-signal
|
|
4677
|
+
* write method or a DOM side-effect call in statement position.
|
|
4678
|
+
*
|
|
4679
|
+
* Only the topmost, direct argument reads are considered suspicious.
|
|
4680
|
+
* Nested reads (e.g. inside ternaries, function calls) are skipped because
|
|
4681
|
+
* they are harder to reason about safely.
|
|
4682
|
+
*/
|
|
4683
|
+
function resolveSignalReadAlias(expression, context, seen = new Set()) {
|
|
4684
|
+
const unwrapped = unwrapExpression(expression);
|
|
4685
|
+
if (unwrapped.type === AST_NODE_TYPES.CallExpression &&
|
|
4686
|
+
isSignalReadCall(unwrapped, context.checker, context.esTreeNodeToTSNodeMap)) {
|
|
4687
|
+
return unwrapped;
|
|
4688
|
+
}
|
|
4689
|
+
if (unwrapped.type !== AST_NODE_TYPES.Identifier) {
|
|
4690
|
+
return null;
|
|
4691
|
+
}
|
|
4692
|
+
const tsNode = context.esTreeNodeToTSNodeMap.get(unwrapped);
|
|
4693
|
+
if (!tsNode) {
|
|
4694
|
+
return null;
|
|
4695
|
+
}
|
|
4696
|
+
const symbol = context.checker.getSymbolAtLocation(tsNode);
|
|
4697
|
+
if (!symbol) {
|
|
4698
|
+
return null;
|
|
4699
|
+
}
|
|
4700
|
+
const symbolId = `${symbol.name}:${symbol.declarations?.[0]?.pos ?? -1}`;
|
|
4701
|
+
if (seen.has(symbolId)) {
|
|
4702
|
+
return null;
|
|
4703
|
+
}
|
|
4704
|
+
seen.add(symbolId);
|
|
4705
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
4706
|
+
const estreeDeclaration = context.tsNodeToESTreeNodeMap.get(declaration);
|
|
4707
|
+
if (estreeDeclaration?.type !== AST_NODE_TYPES.VariableDeclarator) {
|
|
4708
|
+
continue;
|
|
4709
|
+
}
|
|
4710
|
+
const variableDeclaration = estreeDeclaration.parent;
|
|
4711
|
+
if (variableDeclaration.kind !== 'const' ||
|
|
4712
|
+
!estreeDeclaration.init ||
|
|
4713
|
+
estreeDeclaration.range[0] > unwrapped.range[0] ||
|
|
4714
|
+
!isNodeInsideSynchronousReactiveScope(estreeDeclaration, context.scope.callback)) {
|
|
4715
|
+
continue;
|
|
4716
|
+
}
|
|
4717
|
+
const initializer = unwrapExpression(estreeDeclaration.init);
|
|
4718
|
+
if (initializer.type === AST_NODE_TYPES.CallExpression &&
|
|
4719
|
+
isSignalReadCall(initializer, context.checker, context.esTreeNodeToTSNodeMap)) {
|
|
4720
|
+
return initializer;
|
|
4721
|
+
}
|
|
4722
|
+
if (initializer.type === AST_NODE_TYPES.Identifier) {
|
|
4723
|
+
return resolveSignalReadAlias(initializer, context, seen);
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
return null;
|
|
4727
|
+
}
|
|
4728
|
+
function collectSuspiciousReads(scope, checker, esTreeNodeToTSNodeMap, tsNodeToESTreeNodeMap, program) {
|
|
4729
|
+
const suspicious = new Map();
|
|
4730
|
+
const aliasResolutionContext = {
|
|
4731
|
+
checker,
|
|
4732
|
+
esTreeNodeToTSNodeMap,
|
|
4733
|
+
scope,
|
|
4734
|
+
tsNodeToESTreeNodeMap,
|
|
4735
|
+
};
|
|
4736
|
+
walkSynchronousAst(scope.callback, (node) => {
|
|
4737
|
+
if (node.type !== AST_NODE_TYPES.CallExpression) {
|
|
4738
|
+
return;
|
|
4739
|
+
}
|
|
4740
|
+
// Skip already-untracked scopes
|
|
4741
|
+
if (isAngularUntrackedCall(node, program)) {
|
|
4742
|
+
return false;
|
|
4743
|
+
}
|
|
4744
|
+
if (!isWritableSignalWrite(node, checker, esTreeNodeToTSNodeMap) &&
|
|
4745
|
+
!isSuspiciousDomCallArgumentConsumer(node, checker, esTreeNodeToTSNodeMap, program)) {
|
|
4746
|
+
return;
|
|
4747
|
+
}
|
|
4748
|
+
// Inspect each direct argument of the consumer call for signal reads.
|
|
4749
|
+
// We intentionally do NOT recurse into nested expressions —
|
|
4750
|
+
// only top-level direct reads in the argument position are flagged.
|
|
4751
|
+
for (const arg of node.arguments) {
|
|
4752
|
+
if (arg.type === AST_NODE_TYPES.SpreadElement) {
|
|
4753
|
+
continue;
|
|
4754
|
+
}
|
|
4755
|
+
const read = resolveSignalReadAlias(arg, aliasResolutionContext);
|
|
4756
|
+
const unwrappedArg = unwrapExpression(arg);
|
|
4757
|
+
if (read) {
|
|
4758
|
+
const key = String(read.range);
|
|
4759
|
+
const existing = suspicious.get(key);
|
|
4760
|
+
const alias = unwrappedArg.type === AST_NODE_TYPES.Identifier ? unwrappedArg : null;
|
|
4761
|
+
if (existing) {
|
|
4762
|
+
if (!existing.consumers.includes(node)) {
|
|
4763
|
+
existing.consumers.push(node);
|
|
4764
|
+
}
|
|
4765
|
+
if (alias && !existing.aliases.includes(alias)) {
|
|
4766
|
+
existing.aliases.push(alias);
|
|
4767
|
+
}
|
|
4768
|
+
}
|
|
4769
|
+
else {
|
|
4770
|
+
suspicious.set(key, {
|
|
4771
|
+
aliases: alias ? [alias] : [],
|
|
4772
|
+
consumers: [node],
|
|
4773
|
+
read,
|
|
4774
|
+
});
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
}
|
|
4778
|
+
return false;
|
|
4779
|
+
});
|
|
4780
|
+
return [...suspicious.values()];
|
|
4781
|
+
}
|
|
4782
|
+
const rule$3 = createUntrackedRule({
|
|
4783
|
+
create(context) {
|
|
4784
|
+
const parserServices = ESLintUtils.getParserServices(context);
|
|
4785
|
+
const checker = parserServices.program.getTypeChecker();
|
|
4786
|
+
const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
|
|
4787
|
+
const tsNodeToESTreeNodeMap = parserServices.tsNodeToESTreeNodeMap;
|
|
4788
|
+
const { sourceCode } = context;
|
|
4789
|
+
const program = sourceCode.ast;
|
|
4790
|
+
function buildFix(read) {
|
|
4791
|
+
const untrackedAlias = findUntrackedAlias(program);
|
|
4792
|
+
const alreadyHasUntracked = untrackedAlias !== null;
|
|
4793
|
+
const wrapped = buildUntrackedWrap(read, sourceCode, untrackedAlias ?? 'untracked');
|
|
4794
|
+
if (alreadyHasUntracked) {
|
|
4795
|
+
return (fixer) => [fixer.replaceText(read, wrapped)];
|
|
4796
|
+
}
|
|
4797
|
+
return (fixer) => [
|
|
4798
|
+
fixer.replaceText(read, wrapped),
|
|
4799
|
+
...buildUntrackedImportFixes(program, fixer),
|
|
4800
|
+
];
|
|
4801
|
+
}
|
|
4802
|
+
return {
|
|
4803
|
+
CallExpression(node) {
|
|
4804
|
+
for (const scope of getReactiveScopes(node, program)) {
|
|
4805
|
+
const suspicious = collectSuspiciousReads(scope, checker, esTreeNodeToTSNodeMap, tsNodeToESTreeNodeMap, program);
|
|
4806
|
+
const { reads: trackedReads } = collectSignalUsages(scope.callback, checker, esTreeNodeToTSNodeMap, program);
|
|
4807
|
+
const suspiciousReads = new Set(suspicious.map(({ read }) => read));
|
|
4808
|
+
for (const { aliases, consumers, read } of suspicious) {
|
|
4809
|
+
const hasTrackedDependency = consumers.some((consumer) => trackedReads.some((trackedRead) => !suspiciousReads.has(trackedRead) &&
|
|
4810
|
+
!isNodeInside(trackedRead, consumer)));
|
|
4811
|
+
const hasExternalAliasUsage = aliases.some((alias) => aliasHasExternalUsage(alias, consumers, scope, checker, esTreeNodeToTSNodeMap));
|
|
4812
|
+
if (!hasTrackedDependency || hasExternalAliasUsage) {
|
|
4813
|
+
continue;
|
|
4814
|
+
}
|
|
4815
|
+
context.report({
|
|
4816
|
+
data: { kind: scope.kind, name: sourceCode.getText(read) },
|
|
4817
|
+
fix: buildFix(read),
|
|
4818
|
+
messageId: 'incidentalRead',
|
|
4819
|
+
node: read,
|
|
4820
|
+
});
|
|
4821
|
+
}
|
|
4822
|
+
}
|
|
4823
|
+
},
|
|
4824
|
+
};
|
|
4825
|
+
},
|
|
4826
|
+
meta: {
|
|
4827
|
+
docs: {
|
|
4828
|
+
description: 'Suggest wrapping likely-incidental signal reads with `untracked()` when they are used only as snapshot values in reactive callbacks that already have another tracked dependency, such as writable-signal writes or DOM side-effect calls',
|
|
4829
|
+
url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
|
|
4830
|
+
},
|
|
4831
|
+
fixable: 'code',
|
|
4832
|
+
messages: {
|
|
4833
|
+
incidentalRead: '`{{ name }}` looks like an incidental signal read inside `{{ kind }}`. If it is only providing a snapshot value and should not trigger a re-run, wrap it with `untracked()`. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
|
|
4834
|
+
},
|
|
4835
|
+
schema: [],
|
|
4836
|
+
type: 'suggestion',
|
|
4837
|
+
},
|
|
4838
|
+
name: 'prefer-untracked-incidental-signal-reads',
|
|
4839
|
+
});
|
|
4840
|
+
|
|
4841
|
+
function getReturnedExpression(node) {
|
|
4842
|
+
if (node.body.type !== AST_NODE_TYPES.BlockStatement) {
|
|
4843
|
+
return unwrapExpression(node.body);
|
|
4844
|
+
}
|
|
4845
|
+
if (node.body.body.length !== 1) {
|
|
4846
|
+
return null;
|
|
4847
|
+
}
|
|
4848
|
+
const statement = node.body.body[0];
|
|
4849
|
+
if (statement?.type !== AST_NODE_TYPES.ReturnStatement || !statement.argument) {
|
|
4850
|
+
return null;
|
|
4851
|
+
}
|
|
4852
|
+
return unwrapExpression(statement.argument);
|
|
4853
|
+
}
|
|
4854
|
+
function getWrappedSignalGetter(node, checker, esTreeNodeToTSNodeMap) {
|
|
4855
|
+
const [arg] = node.arguments;
|
|
4856
|
+
if (!arg || arg.type === AST_NODE_TYPES.SpreadElement) {
|
|
4857
|
+
return null;
|
|
4858
|
+
}
|
|
4859
|
+
if (arg.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
4860
|
+
arg.type !== AST_NODE_TYPES.FunctionExpression) {
|
|
4861
|
+
return null;
|
|
4862
|
+
}
|
|
4863
|
+
const body = getReturnedExpression(arg);
|
|
4864
|
+
if (body?.type !== AST_NODE_TYPES.CallExpression) {
|
|
4865
|
+
return null;
|
|
4866
|
+
}
|
|
4867
|
+
if (!isSignalReadCall(body, checker, esTreeNodeToTSNodeMap)) {
|
|
4868
|
+
return null;
|
|
4869
|
+
}
|
|
4870
|
+
const getter = unwrapExpression(body.callee);
|
|
4871
|
+
if (getter.type === AST_NODE_TYPES.MemberExpression &&
|
|
4872
|
+
isGetterMemberAccess(getter, checker, esTreeNodeToTSNodeMap)) {
|
|
4873
|
+
return null;
|
|
4874
|
+
}
|
|
4875
|
+
return isSignalType(getter, checker, esTreeNodeToTSNodeMap) ? body.callee : null;
|
|
4876
|
+
}
|
|
4877
|
+
const rule$2 = createUntrackedRule({
|
|
4878
|
+
create(context) {
|
|
4879
|
+
const parserServices = ESLintUtils.getParserServices(context);
|
|
4880
|
+
const checker = parserServices.program.getTypeChecker();
|
|
4881
|
+
const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
|
|
4882
|
+
const { sourceCode } = context;
|
|
4883
|
+
const program = sourceCode.ast;
|
|
4884
|
+
return {
|
|
4885
|
+
CallExpression(node) {
|
|
4886
|
+
if (!isAngularUntrackedCall(node, program)) {
|
|
4887
|
+
return;
|
|
4888
|
+
}
|
|
4889
|
+
const getter = getWrappedSignalGetter(node, checker, esTreeNodeToTSNodeMap);
|
|
4890
|
+
if (!getter) {
|
|
4891
|
+
return;
|
|
4892
|
+
}
|
|
4893
|
+
context.report({
|
|
4894
|
+
fix: (fixer) => fixer.replaceText(node, `${sourceCode.getText(node.callee)}(${sourceCode.getText(getter)})`),
|
|
4895
|
+
messageId: 'preferGetterForm',
|
|
4896
|
+
node,
|
|
4897
|
+
});
|
|
4898
|
+
},
|
|
4899
|
+
};
|
|
4900
|
+
},
|
|
4901
|
+
meta: {
|
|
4902
|
+
docs: {
|
|
4903
|
+
description: 'Prefer `untracked(signalGetter)` over `untracked(() => signalGetter())` for a single signal getter',
|
|
4904
|
+
url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
|
|
4905
|
+
},
|
|
4906
|
+
fixable: 'code',
|
|
4907
|
+
messages: {
|
|
4908
|
+
preferGetterForm: 'Pass single signal getters directly as `untracked(signalGetter)` instead of wrapping them in `untracked(() => signalGetter())`. See Angular guide: https://angular.dev/guide/signals#reading-without-tracking-dependencies',
|
|
4909
|
+
},
|
|
4910
|
+
schema: [],
|
|
4911
|
+
type: 'suggestion',
|
|
4912
|
+
},
|
|
4913
|
+
name: 'prefer-untracked-signal-getter',
|
|
4914
|
+
});
|
|
4915
|
+
|
|
3397
4916
|
function getImportedName(spec) {
|
|
3398
4917
|
if (spec.imported.type === AST_NODE_TYPES.Identifier) {
|
|
3399
4918
|
return spec.imported.name;
|
|
@@ -3869,19 +5388,25 @@ const plugin = {
|
|
|
3869
5388
|
'decorator-key-sort': config$3,
|
|
3870
5389
|
'flat-exports': flatExports,
|
|
3871
5390
|
'html-logical-properties': config$2,
|
|
3872
|
-
'injection-token-description': rule$
|
|
3873
|
-
'no-deep-imports': rule$
|
|
5391
|
+
'injection-token-description': rule$g,
|
|
5392
|
+
'no-deep-imports': rule$f,
|
|
3874
5393
|
'no-deep-imports-to-indexed-packages': noDeepImportsToIndexedPackages,
|
|
5394
|
+
'no-fully-untracked-effect': rule$e,
|
|
3875
5395
|
'no-href-with-router-link': config$1,
|
|
3876
|
-
'no-implicit-public': rule$
|
|
3877
|
-
'no-legacy-peer-deps': rule$
|
|
3878
|
-
'no-playwright-empty-fill': rule$
|
|
5396
|
+
'no-implicit-public': rule$d,
|
|
5397
|
+
'no-legacy-peer-deps': rule$c,
|
|
5398
|
+
'no-playwright-empty-fill': rule$b,
|
|
3879
5399
|
'no-project-as-in-ng-template': config,
|
|
3880
|
-
'no-redundant-type-annotation': rule$
|
|
3881
|
-
'no-
|
|
3882
|
-
'
|
|
5400
|
+
'no-redundant-type-annotation': rule$a,
|
|
5401
|
+
'no-signal-reads-after-await-in-reactive-context': rule$9,
|
|
5402
|
+
'no-string-literal-concat': rule$8,
|
|
5403
|
+
'no-untracked-outside-reactive-context': rule$7,
|
|
5404
|
+
'no-useless-untracked': rule$6,
|
|
5405
|
+
'object-single-line': rule$5,
|
|
3883
5406
|
'prefer-deep-imports': preferDeepImports,
|
|
3884
|
-
'prefer-multi-arg-push': rule$
|
|
5407
|
+
'prefer-multi-arg-push': rule$4,
|
|
5408
|
+
'prefer-untracked-incidental-signal-reads': rule$3,
|
|
5409
|
+
'prefer-untracked-signal-getter': rule$2,
|
|
3885
5410
|
'short-tui-imports': rule$1,
|
|
3886
5411
|
'standalone-imports-sort': standaloneImportsSort,
|
|
3887
5412
|
'strict-tui-doc-example': rule,
|
|
@@ -3897,6 +5422,7 @@ Object.assign(plugin.configs, {
|
|
|
3897
5422
|
recommended: [
|
|
3898
5423
|
{ files: ALL_TS_JS_FILES, plugins: { '@taiga-ui/experience-next': plugin } },
|
|
3899
5424
|
{ files: ['**/*.html'], plugins: { '@taiga-ui/experience-next': plugin } },
|
|
5425
|
+
{ files: ['**/.npmrc'], plugins: { '@taiga-ui/experience-next': plugin } },
|
|
3900
5426
|
...recommended,
|
|
3901
5427
|
],
|
|
3902
5428
|
['taiga-specific']: [
|