@taiga-ui/eslint-plugin-experience-next 0.468.0 → 0.470.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 CHANGED
@@ -53,9 +53,10 @@ export default [
53
53
  | no-playwright-empty-fill | Enforce `clear()` over `fill('')` in Playwright tests | ✅ | 🔧 | |
54
54
  | no-project-as-in-ng-template | `ngProjectAs` has no effect inside `<ng-template>` or dynamic outlets | ✅ | | |
55
55
  | no-redundant-type-annotation | Disallow redundant type annotations when the type is already inferred from the initializer | ✅ | 🔧 | |
56
- | no-signal-reads-after-await-in-reactive-context | Disallow signal reads after `await` inside reactive callbacks | ✅ | | |
56
+ | no-side-effects-in-computed | Disallow observable side effects inside Angular `computed()` callbacks | ✅ | | |
57
+ | no-signal-reads-after-await-in-reactive-context | Disallow bare signal reads after `await` inside reactive callbacks | ✅ | | |
57
58
  | no-string-literal-concat | Disallow string literal concatenation; merge adjacent literals into one | ✅ | 🔧 | |
58
- | no-untracked-outside-reactive-context | Disallow `untracked()` outside the synchronous body of reactive callbacks | ✅ | 🔧 | |
59
+ | no-untracked-outside-reactive-context | Disallow `untracked()` outside reactive callbacks, except explicit post-`await` snapshots | ✅ | 🔧 | |
59
60
  | no-useless-untracked | Disallow provably useless `untracked()` wrappers in reactive callbacks | ✅ | 🔧 | |
60
61
  | object-single-line | Enforce single-line formatting for single-property objects when it fits `printWidth` | ✅ | 🔧 | |
61
62
  | prefer-deep-imports | Allow deep imports of Taiga UI packages | | 🔧 | |
@@ -566,12 +567,44 @@ const doubled = computed(() => {
566
567
 
567
568
  ---
568
569
 
570
+ ## no-side-effects-in-computed
571
+
572
+ <sup>`✅ Recommended`</sup>
573
+
574
+ `computed()` should only derive a value from its inputs. This rule reports observable side effects inside Angular
575
+ `computed()` callbacks, including signal writes (`.set()`, `.update()`, `.mutate()`), assignments to captured state,
576
+ `++/--`, `delete`, and property mutations on objects that were not created inside the computation itself.
577
+
578
+ ```ts
579
+ // ❌ error
580
+ import {computed, signal} from '@angular/core';
581
+
582
+ const source = signal(0);
583
+ const target = signal(0);
584
+
585
+ const derived = computed(() => {
586
+ target.set(source() + 1);
587
+ return target();
588
+ });
589
+ ```
590
+
591
+ ```ts
592
+ // ✅ ok
593
+ import {computed, signal} from '@angular/core';
594
+
595
+ const source = signal(0);
596
+ const derived = computed(() => source() + 1);
597
+ ```
598
+
599
+ ---
600
+
569
601
  ## no-signal-reads-after-await-in-reactive-context
570
602
 
571
603
  <sup>`✅ Recommended`</sup>
572
604
 
573
- Angular tracks signal reads only in synchronous code. If a reactive callback crosses an async boundary, any signal read
574
- after `await` will not become a dependency.
605
+ Angular tracks signal reads only in synchronous code. If a reactive callback crosses an async boundary, any bare signal
606
+ read after `await` will not become a dependency. Snapshot before `await` when you need the earlier value, or make an
607
+ intentional post-`await` current-value read explicit with `untracked(...)`.
575
608
 
576
609
  ```ts
577
610
  // ❌ error
@@ -588,6 +621,14 @@ effect(async () => {
588
621
  });
589
622
  ```
590
623
 
624
+ ```ts
625
+ // ✅ ok — explicit current-value read after await
626
+ effect(async () => {
627
+ await this.fetchUser();
628
+ console.log(untracked(this.theme));
629
+ });
630
+ ```
631
+
591
632
  ---
592
633
 
593
634
  ## no-untracked-outside-reactive-context
@@ -595,14 +636,15 @@ effect(async () => {
595
636
  <sup>`✅ Recommended`</sup> <sup>`Fixable`</sup>
596
637
 
597
638
  `untracked()` usually only affects signal reads that happen inside the synchronous body of a reactive callback. In
598
- ordinary non-reactive code, nested callbacks, or code that runs after `await`, it usually does not prevent dependency
599
- tracking and only adds noise. This rule reports those cases, but intentionally allows a few imperative Angular escape
600
- hatches where `untracked()` can still be useful: `@Pipe().transform`, `ControlValueAccessor.writeValue`,
601
- `registerOnChange` including patched accessors such as `accessor.writeValue = (...) => {}`, callback-form wrappers used
602
- inside deferred scheduler / event-handler callbacks, and narrow lazy DI factory wrappers like
603
- `InjectionToken({factory})` / `useFactory` when they guard creation of a reactive owner such as `effect()` against an
604
- accidental ambient reactive context. For the narrow case `untracked(() => effect(...))` and similar outer wrappers
605
- around a reactive call in ordinary code, autofix removes only the useless outer `untracked()` wrapper.
639
+ ordinary non-reactive code or nested callbacks it usually does not prevent dependency tracking and only adds noise. This
640
+ rule reports those cases, but intentionally allows a few explicit escape hatches: post-`await` reads inside a reactive
641
+ callback when `untracked()` is used to document an intentional current-value snapshot, imperative Angular hooks such as
642
+ `@Pipe().transform`, `ControlValueAccessor.writeValue`, `registerOnChange` including patched accessors such as
643
+ `accessor.writeValue = (...) => {}`, callback-form wrappers used inside deferred scheduler / event-handler callbacks,
644
+ and narrow lazy DI factory wrappers like `InjectionToken({factory})` / `useFactory` when they guard creation of a
645
+ reactive owner such as `effect()` against an accidental ambient reactive context. For the narrow case
646
+ `untracked(() => effect(...))` and similar outer wrappers around a reactive call in ordinary code, autofix removes only
647
+ the useless outer `untracked()` wrapper.
606
648
 
607
649
  ```ts
608
650
  // ❌ error
@@ -624,6 +666,17 @@ effect(() => {
624
666
  const snapshot = computed(() => untracked(this.user));
625
667
  ```
626
668
 
669
+ ```ts
670
+ // ✅ ok — after await, untracked can mark an intentional current snapshot
671
+ effect(async () => {
672
+ await this.refresh();
673
+
674
+ if (untracked(this.user) !== previousUser) {
675
+ console.log('changed');
676
+ }
677
+ });
678
+ ```
679
+
627
680
  ```ts
628
681
  // ❌ error
629
682
  untracked(() => {
package/index.d.ts CHANGED
@@ -53,6 +53,9 @@ declare const plugin: {
53
53
  } | undefined)?], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
54
54
  name: string;
55
55
  };
56
+ 'no-side-effects-in-computed': import("@typescript-eslint/utils/ts-eslint").RuleModule<"sideEffectInComputed", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
57
+ name: string;
58
+ };
56
59
  'no-signal-reads-after-await-in-reactive-context': import("@typescript-eslint/utils/ts-eslint").RuleModule<"readAfterAwait", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
57
60
  name: string;
58
61
  };
package/index.esm.js CHANGED
@@ -908,6 +908,7 @@ var recommended = defineConfig([
908
908
  '@taiga-ui/experience-next/no-fully-untracked-effect': 'error',
909
909
  '@taiga-ui/experience-next/no-implicit-public': 'error',
910
910
  '@taiga-ui/experience-next/no-redundant-type-annotation': 'error',
911
+ '@taiga-ui/experience-next/no-side-effects-in-computed': 'error',
911
912
  '@taiga-ui/experience-next/no-signal-reads-after-await-in-reactive-context': 'error',
912
913
  '@taiga-ui/experience-next/no-untracked-outside-reactive-context': 'error',
913
914
  '@taiga-ui/experience-next/no-useless-untracked': 'error',
@@ -1333,8 +1334,8 @@ function intersect(a, b) {
1333
1334
  return a.some((type) => origin.has(type));
1334
1335
  }
1335
1336
 
1336
- const createRule$f = ESLintUtils.RuleCreator((name) => name);
1337
- var classPropertyNaming = createRule$f({
1337
+ const createRule$g = ESLintUtils.RuleCreator((name) => name);
1338
+ var classPropertyNaming = createRule$g({
1338
1339
  create(context, [configs]) {
1339
1340
  const parserServices = ESLintUtils.getParserServices(context);
1340
1341
  const typeChecker = parserServices.program.getTypeChecker();
@@ -1503,9 +1504,9 @@ function isExternalPureTuple(typeChecker, type) {
1503
1504
  return typeArgs.every((item) => isClassType(item));
1504
1505
  }
1505
1506
 
1506
- const createRule$e = ESLintUtils.RuleCreator((name) => name);
1507
+ const createRule$f = ESLintUtils.RuleCreator((name) => name);
1507
1508
  const MESSAGE_ID$7 = 'spreadArrays';
1508
- var flatExports = createRule$e({
1509
+ var flatExports = createRule$f({
1509
1510
  create(context) {
1510
1511
  const parserServices = ESLintUtils.getParserServices(context);
1511
1512
  const typeChecker = parserServices.program.getTypeChecker();
@@ -1711,8 +1712,8 @@ const config$2 = {
1711
1712
 
1712
1713
  const MESSAGE_ID$5 = 'invalid-injection-token-description';
1713
1714
  const ERROR_MESSAGE$3 = "InjectionToken's description should contain token's name";
1714
- const createRule$d = ESLintUtils.RuleCreator((name) => name);
1715
- const rule$g = createRule$d({
1715
+ const createRule$e = ESLintUtils.RuleCreator((name) => name);
1716
+ const rule$h = createRule$e({
1716
1717
  create(context) {
1717
1718
  return {
1718
1719
  'NewExpression[callee.name="InjectionToken"]'(node) {
@@ -1779,8 +1780,8 @@ const DEFAULT_OPTIONS = {
1779
1780
  importDeclaration: '^@taiga-ui*',
1780
1781
  projectName: String.raw `(?<=^@taiga-ui/)([-\w]+)`,
1781
1782
  };
1782
- const createRule$c = ESLintUtils.RuleCreator((name) => name);
1783
- const rule$f = createRule$c({
1783
+ const createRule$d = ESLintUtils.RuleCreator((name) => name);
1784
+ const rule$g = createRule$d({
1784
1785
  create(context) {
1785
1786
  const { currentProject, deepImport, ignoreImports, importDeclaration, projectName, } = { ...DEFAULT_OPTIONS, ...context.options[0] };
1786
1787
  const hasNonCodeExtension = (source) => {
@@ -1867,13 +1868,13 @@ const rule$f = createRule$c({
1867
1868
  name: 'no-deep-imports',
1868
1869
  });
1869
1870
 
1870
- const createRule$b = ESLintUtils.RuleCreator((name) => name);
1871
+ const createRule$c = ESLintUtils.RuleCreator((name) => name);
1871
1872
  const resolveCacheByOptions = new WeakMap();
1872
1873
  const nearestFileUpCache = new Map();
1873
1874
  const markerCache = new Map();
1874
1875
  const indexFileCache = new Map();
1875
1876
  const indexExportsCache = new Map();
1876
- var noDeepImportsToIndexedPackages = createRule$b({
1877
+ var noDeepImportsToIndexedPackages = createRule$c({
1877
1878
  create(context) {
1878
1879
  const parserServices = ESLintUtils.getParserServices(context);
1879
1880
  const program = parserServices.program;
@@ -2298,7 +2299,7 @@ const AFTER_RENDER_EFFECT_PHASES = new Map([
2298
2299
  ['read', 'afterRenderEffect().read'],
2299
2300
  ['write', 'afterRenderEffect().write'],
2300
2301
  ]);
2301
- function isReactiveCallback(node) {
2302
+ function isReactiveCallback$1(node) {
2302
2303
  return (node?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
2303
2304
  node?.type === AST_NODE_TYPES.FunctionExpression);
2304
2305
  }
@@ -2322,7 +2323,7 @@ function isAngularCoreCall(node, program, exportedName) {
2322
2323
  }
2323
2324
  function appendFirstArgReactiveScope(scopes, call, kind) {
2324
2325
  const [arg] = call.arguments;
2325
- if (!isReactiveCallback(arg)) {
2326
+ if (!isReactiveCallback$1(arg)) {
2326
2327
  return;
2327
2328
  }
2328
2329
  scopes.push({ callback: arg, kind, owner: call, reportNode: call });
@@ -2334,7 +2335,7 @@ function appendObjectPropertyReactiveScopes(scopes, call, object, labels) {
2334
2335
  }
2335
2336
  const name = getPropertyName(property);
2336
2337
  const label = name ? labels.get(name) : null;
2337
- if (!label || !isReactiveCallback(property.value)) {
2338
+ if (!label || !isReactiveCallback$1(property.value)) {
2338
2339
  continue;
2339
2340
  }
2340
2341
  scopes.push({
@@ -2396,6 +2397,16 @@ function isNodeInsideSynchronousReactiveScope(node, callback) {
2396
2397
  });
2397
2398
  return found;
2398
2399
  }
2400
+ function isNodeAfterAsyncBoundaryInReactiveScope(node, callback) {
2401
+ let found = false;
2402
+ walkAfterAsyncBoundaryAst(callback, (inner) => {
2403
+ if (inner !== node) {
2404
+ return;
2405
+ }
2406
+ found = true;
2407
+ });
2408
+ return found;
2409
+ }
2399
2410
  function findEnclosingReactiveScope(node, program) {
2400
2411
  for (let current = node.parent; current; current = current.parent) {
2401
2412
  if (current.type !== AST_NODE_TYPES.CallExpression) {
@@ -2409,6 +2420,19 @@ function findEnclosingReactiveScope(node, program) {
2409
2420
  }
2410
2421
  return null;
2411
2422
  }
2423
+ function findEnclosingReactiveScopeAfterAsyncBoundary(node, program) {
2424
+ for (let current = node.parent; current; current = current.parent) {
2425
+ if (current.type !== AST_NODE_TYPES.CallExpression) {
2426
+ continue;
2427
+ }
2428
+ for (const scope of getReactiveScopes(current, program)) {
2429
+ if (isNodeAfterAsyncBoundaryInReactiveScope(node, scope.callback)) {
2430
+ return scope;
2431
+ }
2432
+ }
2433
+ }
2434
+ return null;
2435
+ }
2412
2436
  /**
2413
2437
  * Returns true when the TypeScript type at `node` is an Angular signal type.
2414
2438
  * Uses duck-typing: callable type whose name contains "Signal", or whose
@@ -2555,7 +2579,7 @@ function collectReadsInsideUntracked(root, checker, esTreeNodeToTSNodeMap, progr
2555
2579
  });
2556
2580
  return reads;
2557
2581
  }
2558
- const rule$e = createUntrackedRule({
2582
+ const rule$f = createUntrackedRule({
2559
2583
  create(context) {
2560
2584
  const parserServices = ESLintUtils.getParserServices(context);
2561
2585
  const checker = parserServices.program.getTypeChecker();
@@ -2641,8 +2665,8 @@ const config$1 = {
2641
2665
  },
2642
2666
  };
2643
2667
 
2644
- const createRule$a = ESLintUtils.RuleCreator((name) => name);
2645
- const rule$d = createRule$a({
2668
+ const createRule$b = ESLintUtils.RuleCreator((name) => name);
2669
+ const rule$e = createRule$b({
2646
2670
  create(context) {
2647
2671
  const checkImplicitPublic = (node) => {
2648
2672
  const classRef = getClass(node);
@@ -2713,9 +2737,9 @@ function getClass(node) {
2713
2737
  return getClass(node.parent);
2714
2738
  }
2715
2739
 
2716
- const createRule$9 = ESLintUtils.RuleCreator((name) => name);
2740
+ const createRule$a = ESLintUtils.RuleCreator((name) => name);
2717
2741
  const LEGACY_PEER_DEPS_PATTERN = /^legacy-peer-deps\s*=\s*true$/i;
2718
- const rule$c = createRule$9({
2742
+ const rule$d = createRule$a({
2719
2743
  create(context) {
2720
2744
  return {
2721
2745
  Program(node) {
@@ -2753,8 +2777,8 @@ const rule$c = createRule$9({
2753
2777
  name: 'no-legacy-peer-deps',
2754
2778
  });
2755
2779
 
2756
- const createRule$8 = ESLintUtils.RuleCreator((name) => name);
2757
- const rule$b = createRule$8({
2780
+ const createRule$9 = ESLintUtils.RuleCreator((name) => name);
2781
+ const rule$c = createRule$9({
2758
2782
  create(context) {
2759
2783
  const services = ESLintUtils.getParserServices(context);
2760
2784
  const checker = services.program.getTypeChecker();
@@ -2919,7 +2943,7 @@ const config = {
2919
2943
  },
2920
2944
  };
2921
2945
 
2922
- const createRule$7 = ESLintUtils.RuleCreator((name) => name);
2946
+ const createRule$8 = ESLintUtils.RuleCreator((name) => name);
2923
2947
  function collectArrayExpressions(node) {
2924
2948
  const result = [];
2925
2949
  if (node.type === AST_NODE_TYPES.ArrayExpression) {
@@ -2945,7 +2969,7 @@ function collectArrayExpressions(node) {
2945
2969
  }
2946
2970
  return result;
2947
2971
  }
2948
- const rule$a = createRule$7({
2972
+ const rule$b = createRule$8({
2949
2973
  create(context) {
2950
2974
  const parserServices = ESLintUtils.getParserServices(context);
2951
2975
  const typeChecker = parserServices.program.getTypeChecker();
@@ -3041,6 +3065,207 @@ const rule$a = createRule$7({
3041
3065
  name: 'no-redundant-type-annotation',
3042
3066
  });
3043
3067
 
3068
+ /**
3069
+ * Strips TypeScript-only wrapper nodes that have no runtime meaning:
3070
+ * `as` casts, non-null assertions (`!`), type assertions (`<T>expr`), and
3071
+ * optional-chain wrappers. Iterates until no more wrappers are found.
3072
+ */
3073
+ function unwrapExpression(expression) {
3074
+ let current = expression;
3075
+ let didUnwrap = true;
3076
+ while (didUnwrap) {
3077
+ didUnwrap = false;
3078
+ switch (current.type) {
3079
+ case AST_NODE_TYPES.ChainExpression:
3080
+ current = current.expression;
3081
+ didUnwrap = true;
3082
+ break;
3083
+ case AST_NODE_TYPES.TSAsExpression:
3084
+ current = current.expression;
3085
+ didUnwrap = true;
3086
+ break;
3087
+ case AST_NODE_TYPES.TSNonNullExpression:
3088
+ current = current.expression;
3089
+ didUnwrap = true;
3090
+ break;
3091
+ case AST_NODE_TYPES.TSTypeAssertion:
3092
+ current = current.expression;
3093
+ didUnwrap = true;
3094
+ break;
3095
+ }
3096
+ }
3097
+ return current;
3098
+ }
3099
+
3100
+ const createRule$7 = ESLintUtils.RuleCreator((name) => name);
3101
+ function isReactiveCallback(node) {
3102
+ return (node?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
3103
+ node?.type === AST_NODE_TYPES.FunctionExpression);
3104
+ }
3105
+ function unwrapMutationTarget(node) {
3106
+ let current = node;
3107
+ while (current.type === AST_NODE_TYPES.TSAsExpression ||
3108
+ current.type === AST_NODE_TYPES.TSNonNullExpression ||
3109
+ current.type === AST_NODE_TYPES.TSTypeAssertion) {
3110
+ current = current.expression;
3111
+ }
3112
+ return current;
3113
+ }
3114
+ function collectMutationTargets(node) {
3115
+ const current = unwrapMutationTarget(node);
3116
+ switch (current.type) {
3117
+ case AST_NODE_TYPES.ArrayPattern:
3118
+ return current.elements.flatMap((element) => element ? collectMutationTargets(element) : []);
3119
+ case AST_NODE_TYPES.AssignmentPattern:
3120
+ return collectMutationTargets(current.left);
3121
+ case AST_NODE_TYPES.Identifier:
3122
+ return [current];
3123
+ case AST_NODE_TYPES.MemberExpression:
3124
+ return [current];
3125
+ case AST_NODE_TYPES.ObjectPattern:
3126
+ return current.properties.flatMap((property) => {
3127
+ if (property.type === AST_NODE_TYPES.RestElement) {
3128
+ return collectMutationTargets(property.argument);
3129
+ }
3130
+ return collectMutationTargets(property.value);
3131
+ });
3132
+ case AST_NODE_TYPES.RestElement:
3133
+ return collectMutationTargets(current.argument);
3134
+ default:
3135
+ return [];
3136
+ }
3137
+ }
3138
+ function getSymbolAtNode(node, checker, esTreeNodeToTSNodeMap) {
3139
+ const tsNode = esTreeNodeToTSNodeMap.get(node);
3140
+ if (!tsNode) {
3141
+ return null;
3142
+ }
3143
+ return checker.getSymbolAtLocation(tsNode) ?? null;
3144
+ }
3145
+ function isLocalIdentifier(node, context) {
3146
+ const symbol = getSymbolAtNode(node, context.checker, context.esTreeNodeToTSNodeMap);
3147
+ if (!symbol) {
3148
+ return false;
3149
+ }
3150
+ return (symbol.declarations ?? []).some((declaration) => {
3151
+ const estreeDeclaration = context.tsNodeToESTreeNodeMap.get(declaration);
3152
+ return (!!estreeDeclaration &&
3153
+ isNodeInsideSynchronousReactiveScope(estreeDeclaration, context.callback));
3154
+ });
3155
+ }
3156
+ function isLocallyCreatedExpression(node, context) {
3157
+ const expression = unwrapExpression(node);
3158
+ switch (expression.type) {
3159
+ case AST_NODE_TYPES.ArrayExpression:
3160
+ case AST_NODE_TYPES.NewExpression:
3161
+ case AST_NODE_TYPES.ObjectExpression:
3162
+ return true;
3163
+ case AST_NODE_TYPES.Identifier:
3164
+ return isLocalIdentifier(expression, context);
3165
+ case AST_NODE_TYPES.MemberExpression:
3166
+ return isLocallyCreatedExpression(expression.object, context);
3167
+ default:
3168
+ return false;
3169
+ }
3170
+ }
3171
+ function hasObservableMutationTarget(node, context) {
3172
+ return collectMutationTargets(node).some((target) => {
3173
+ if (target.type === AST_NODE_TYPES.Identifier) {
3174
+ return !isLocalIdentifier(target, context);
3175
+ }
3176
+ return !isLocallyCreatedExpression(target.object, context);
3177
+ });
3178
+ }
3179
+ function reportSideEffect(node, inspectionContext, report) {
3180
+ const key = String(node.range);
3181
+ if (inspectionContext.reported.has(key)) {
3182
+ return;
3183
+ }
3184
+ inspectionContext.reported.add(key);
3185
+ report(node);
3186
+ }
3187
+ function inspectComputedBody(root, inspectionContext, report) {
3188
+ walkSynchronousAst(root, (node) => {
3189
+ if (node.type === AST_NODE_TYPES.CallExpression) {
3190
+ if (isWritableSignalWrite(node, inspectionContext.checker, inspectionContext.esTreeNodeToTSNodeMap)) {
3191
+ reportSideEffect(node, inspectionContext, report);
3192
+ }
3193
+ if (isAngularUntrackedCall(node, inspectionContext.program)) {
3194
+ const [arg] = node.arguments;
3195
+ if (isReactiveCallback(arg)) {
3196
+ inspectComputedBody(arg, inspectionContext, report);
3197
+ }
3198
+ return false;
3199
+ }
3200
+ if (node.callee.type === AST_NODE_TYPES.ArrowFunctionExpression ||
3201
+ node.callee.type === AST_NODE_TYPES.FunctionExpression) {
3202
+ inspectComputedBody(node.callee, inspectionContext, report);
3203
+ return false;
3204
+ }
3205
+ }
3206
+ if (node.type === AST_NODE_TYPES.AssignmentExpression &&
3207
+ hasObservableMutationTarget(node.left, inspectionContext)) {
3208
+ reportSideEffect(node.left, inspectionContext, report);
3209
+ }
3210
+ if (node.type === AST_NODE_TYPES.UpdateExpression &&
3211
+ hasObservableMutationTarget(node.argument, inspectionContext)) {
3212
+ reportSideEffect(node.argument, inspectionContext, report);
3213
+ }
3214
+ if (node.type === AST_NODE_TYPES.UnaryExpression &&
3215
+ node.operator === 'delete' &&
3216
+ hasObservableMutationTarget(node.argument, inspectionContext)) {
3217
+ reportSideEffect(node.argument, inspectionContext, report);
3218
+ }
3219
+ return;
3220
+ });
3221
+ }
3222
+ const rule$a = createRule$7({
3223
+ create(context) {
3224
+ const parserServices = ESLintUtils.getParserServices(context);
3225
+ const checker = parserServices.program.getTypeChecker();
3226
+ const esTreeNodeToTSNodeMap = parserServices.esTreeNodeToTSNodeMap;
3227
+ const tsNodeToESTreeNodeMap = parserServices.tsNodeToESTreeNodeMap;
3228
+ const { sourceCode } = context;
3229
+ const program = sourceCode.ast;
3230
+ return {
3231
+ CallExpression(node) {
3232
+ for (const scope of getReactiveScopes(node, program)) {
3233
+ if (scope.kind !== 'computed()') {
3234
+ continue;
3235
+ }
3236
+ const inspectionContext = {
3237
+ callback: scope.callback,
3238
+ checker,
3239
+ esTreeNodeToTSNodeMap,
3240
+ program,
3241
+ reported: new Set(),
3242
+ tsNodeToESTreeNodeMap,
3243
+ };
3244
+ inspectComputedBody(scope.callback, inspectionContext, (reportNode) => {
3245
+ context.report({
3246
+ data: { expression: sourceCode.getText(reportNode) },
3247
+ messageId: 'sideEffectInComputed',
3248
+ node: reportNode,
3249
+ });
3250
+ });
3251
+ }
3252
+ },
3253
+ };
3254
+ },
3255
+ meta: {
3256
+ docs: {
3257
+ description: 'Disallow observable side effects inside `computed()` callbacks so derivations stay pure',
3258
+ url: 'https://angular.dev/guide/signals',
3259
+ },
3260
+ messages: {
3261
+ sideEffectInComputed: '`{{ expression }}` causes a side effect inside `computed()`. Keep computed derivations pure and move state changes to `effect()` or outside the computation.',
3262
+ },
3263
+ schema: [],
3264
+ type: 'problem',
3265
+ },
3266
+ name: 'no-side-effects-in-computed',
3267
+ });
3268
+
3044
3269
  const rule$9 = createUntrackedRule({
3045
3270
  create(context) {
3046
3271
  const parserServices = ESLintUtils.getParserServices(context);
@@ -3054,6 +3279,7 @@ const rule$9 = createUntrackedRule({
3054
3279
  const reported = new Set();
3055
3280
  walkAfterAsyncBoundaryAst(scope.callback, (inner) => {
3056
3281
  if (inner.type !== AST_NODE_TYPES.CallExpression ||
3282
+ isAngularUntrackedCall(inner, program) ||
3057
3283
  !isSignalReadCall(inner, checker, esTreeNodeToTSNodeMap)) {
3058
3284
  return;
3059
3285
  }
@@ -3077,11 +3303,11 @@ const rule$9 = createUntrackedRule({
3077
3303
  },
3078
3304
  meta: {
3079
3305
  docs: {
3080
- description: 'Disallow signal reads that occur after `await` inside reactive callbacks, because Angular no longer tracks them as dependencies',
3306
+ description: 'Disallow bare signal reads that occur after `await` inside reactive callbacks, because Angular no longer tracks them as dependencies',
3081
3307
  url: ANGULAR_SIGNALS_ASYNC_GUIDE_URL,
3082
3308
  },
3083
3309
  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',
3310
+ 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, or wrap the post-`await` read in `untracked(...)` when you intentionally need the current value at that point. See Angular guide: https://angular.dev/guide/signals#reactive-context-and-async-operations',
3085
3311
  },
3086
3312
  schema: [],
3087
3313
  type: 'problem',
@@ -3600,7 +3826,8 @@ const rule$7 = createUntrackedRule({
3600
3826
  if (!isAngularUntrackedCall(node, program)) {
3601
3827
  return;
3602
3828
  }
3603
- if (findEnclosingReactiveScope(node, program)) {
3829
+ if (findEnclosingReactiveScope(node, program) ||
3830
+ findEnclosingReactiveScopeAfterAsyncBoundary(node, program)) {
3604
3831
  return;
3605
3832
  }
3606
3833
  if (isAllowedImperativeAngularContext(node)) {
@@ -3638,12 +3865,12 @@ const rule$7 = createUntrackedRule({
3638
3865
  },
3639
3866
  meta: {
3640
3867
  docs: {
3641
- 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',
3868
+ description: 'Disallow `untracked()` outside reactive callbacks, except for synchronous reactive reads, explicit post-`await` snapshot reads, and supported imperative/deferred/lazy-factory Angular escape hatches',
3642
3869
  url: ANGULAR_SIGNALS_UNTRACKED_GUIDE_URL,
3643
3870
  },
3644
3871
  fixable: 'code',
3645
3872
  messages: {
3646
- 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',
3873
+ outsideReactiveContext: '`untracked()` is used outside a reactive callback and outside the supported post-`await` / 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',
3647
3874
  },
3648
3875
  schema: [],
3649
3876
  type: 'problem',
@@ -3819,38 +4046,6 @@ const rule$6 = createUntrackedRule({
3819
4046
  name: 'no-useless-untracked',
3820
4047
  });
3821
4048
 
3822
- /**
3823
- * Strips TypeScript-only wrapper nodes that have no runtime meaning:
3824
- * `as` casts, non-null assertions (`!`), type assertions (`<T>expr`), and
3825
- * optional-chain wrappers. Iterates until no more wrappers are found.
3826
- */
3827
- function unwrapExpression(expression) {
3828
- let current = expression;
3829
- let didUnwrap = true;
3830
- while (didUnwrap) {
3831
- didUnwrap = false;
3832
- switch (current.type) {
3833
- case AST_NODE_TYPES.ChainExpression:
3834
- current = current.expression;
3835
- didUnwrap = true;
3836
- break;
3837
- case AST_NODE_TYPES.TSAsExpression:
3838
- current = current.expression;
3839
- didUnwrap = true;
3840
- break;
3841
- case AST_NODE_TYPES.TSNonNullExpression:
3842
- current = current.expression;
3843
- didUnwrap = true;
3844
- break;
3845
- case AST_NODE_TYPES.TSTypeAssertion:
3846
- current = current.expression;
3847
- didUnwrap = true;
3848
- break;
3849
- }
3850
- }
3851
- return current;
3852
- }
3853
-
3854
4049
  const createRule$5 = ESLintUtils.RuleCreator((name) => name);
3855
4050
  const rule$5 = createRule$5({
3856
4051
  create(context, [{ printWidth }]) {
@@ -5415,16 +5610,17 @@ const plugin = {
5415
5610
  'decorator-key-sort': config$3,
5416
5611
  'flat-exports': flatExports,
5417
5612
  'html-logical-properties': config$2,
5418
- 'injection-token-description': rule$g,
5419
- 'no-deep-imports': rule$f,
5613
+ 'injection-token-description': rule$h,
5614
+ 'no-deep-imports': rule$g,
5420
5615
  'no-deep-imports-to-indexed-packages': noDeepImportsToIndexedPackages,
5421
- 'no-fully-untracked-effect': rule$e,
5616
+ 'no-fully-untracked-effect': rule$f,
5422
5617
  'no-href-with-router-link': config$1,
5423
- 'no-implicit-public': rule$d,
5424
- 'no-legacy-peer-deps': rule$c,
5425
- 'no-playwright-empty-fill': rule$b,
5618
+ 'no-implicit-public': rule$e,
5619
+ 'no-legacy-peer-deps': rule$d,
5620
+ 'no-playwright-empty-fill': rule$c,
5426
5621
  'no-project-as-in-ng-template': config,
5427
- 'no-redundant-type-annotation': rule$a,
5622
+ 'no-redundant-type-annotation': rule$b,
5623
+ 'no-side-effects-in-computed': rule$a,
5428
5624
  'no-signal-reads-after-await-in-reactive-context': rule$9,
5429
5625
  'no-string-literal-concat': rule$8,
5430
5626
  'no-untracked-outside-reactive-context': rule$7,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ui/eslint-plugin-experience-next",
3
- "version": "0.468.0",
3
+ "version": "0.470.0",
4
4
  "description": "An ESLint plugin to enforce a consistent code styles across taiga-ui projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,5 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ export declare const rule: ESLintUtils.RuleModule<"sideEffectInComputed", [], unknown, ESLintUtils.RuleListener> & {
3
+ name: string;
4
+ };
5
+ export default rule;
@@ -20,7 +20,9 @@ export declare function isAngularEffectCall(node: TSESTree.CallExpression, progr
20
20
  export declare function isAngularUntrackedCall(node: TSESTree.CallExpression, program: TSESTree.Program): boolean;
21
21
  export declare function getReactiveScopes(node: TSESTree.CallExpression, program: TSESTree.Program): ReactiveScope[];
22
22
  export declare function isNodeInsideSynchronousReactiveScope(node: TSESTree.Node, callback: ReactiveCallback): boolean;
23
+ export declare function isNodeAfterAsyncBoundaryInReactiveScope(node: TSESTree.Node, callback: ReactiveCallback): boolean;
23
24
  export declare function findEnclosingReactiveScope(node: TSESTree.Node, program: TSESTree.Program): ReactiveScope | null;
25
+ export declare function findEnclosingReactiveScopeAfterAsyncBoundary(node: TSESTree.Node, program: TSESTree.Program): ReactiveScope | null;
24
26
  /**
25
27
  * Returns true when the TypeScript type at `node` is an Angular signal type.
26
28
  * Uses duck-typing: callable type whose name contains "Signal", or whose