@mehshekari/eslint-plugin-gsap-motion 4.2.1 → 4.4.2

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
@@ -90,11 +90,14 @@ so a finding waived that way still fails `gsap-motion audit`.
90
90
  | `missing-reduced-motion` | warn | An animating file with no `prefers-reduced-motion` branch |
91
91
  | `barrel-import` | warn | Importing from `gsap/all`, which pulls in every plugin |
92
92
  | `tween-per-frame` | warn | A tween created every frame — in `onUpdate`, the ticker, an Observer callback, `useFrame` or a `requestAnimationFrame` loop |
93
+ | `allocation-per-frame` | warn | A Three.js vector, colour, matrix or quaternion built every frame — in `useFrame`, the ticker, `onUpdate` or a `requestAnimationFrame` loop |
94
+ | `layout-read-per-frame` | info | A layout read — `getBoundingClientRect`, `offsetWidth`, `getComputedStyle` — in a callback that runs every frame |
93
95
  | `paint-property` | warn | Animating `filter`, `backdropFilter` or `boxShadow`, which repaints the element every frame |
94
96
  | `ungated-hover` | warn | A hover animation with no `(hover: hover)` gate, which a tap starts and nothing ends |
95
97
  | `delay-chain` | warn | Three or more tweens in one scope sequenced by `delay`, which is a timeline nobody can retime |
96
98
  | `unowned-loop` | info | An infinite repeat that nothing pauses, which keeps the ticker busy off screen |
97
99
  | `matchmedia-never-runs` | warn | A matchMedia callback that branches on reduced motion, when every condition needs it |
100
+ | `context-safe-misuse` | warn | A callback made context-safe with the context itself, which runs it at once instead |
98
101
  | `stacked-from` | warn | A timeline's `from` or `fromTo` built more than once against the same target |
99
102
  | `not-parsed` | warn | A file that uses GSAP and that the audit's parser could not read, so no other rule checked it |
100
103
 
package/lib/ast.mjs CHANGED
@@ -214,26 +214,75 @@ export function tweenMethod(node) {
214
214
  return allowed.has(method) ? method : null;
215
215
  }
216
216
 
217
- const argumentObject = (call, index) => {
218
- const node = unwrap(call.arguments[index]);
219
- return node?.type === "ObjectExpression" ? node : null;
220
- };
217
+ /**
218
+ * The object literal bound to `name` by a `const`, when the file binds that
219
+ * name exactly once. Two bindings of one name, in different functions, would
220
+ * need scope to tell apart; a wrong guess is a false finding, so neither is
221
+ * read.
222
+ */
223
+ function constObject(root, name) {
224
+ const bound = findAll(
225
+ root,
226
+ (node) => node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.id.name === name,
227
+ );
228
+ if (bound.length !== 1 || !bound[0].init) return null;
229
+ const declaration = parentOf(root, bound[0]);
230
+ if (declaration?.kind !== "const") return null;
231
+ const init = unwrap(bound[0].init);
232
+ return init.type === "ObjectExpression" ? init : null;
233
+ }
234
+
235
+ /** `node`'s properties, re-anchored at `site`: a finding lands where the tween uses them. */
236
+ const anchoredAt = (properties, site) =>
237
+ properties.map((property) => ({ ...property, start: site.start, end: site.end }));
238
+
239
+ /**
240
+ * The object a vars argument stands for: a literal, or a `const` bound to one,
241
+ * with any spread of such a `const` unfolded in place. Vars written once and
242
+ * shared between a `fromTo`'s ends are how a real film's credits hid two of
243
+ * seven filter tweens from the audit.
244
+ */
245
+ function resolveObject(root, node, seen = new Set()) {
246
+ let target = unwrap(node);
247
+ let site = null;
248
+ if (target?.type === "Identifier" && root) {
249
+ site = target;
250
+ target = constObject(root, target.name);
251
+ }
252
+ if (target?.type !== "ObjectExpression" || seen.has(target)) return null;
253
+ seen.add(target);
254
+
255
+ let unfolded = false;
256
+ const properties = target.properties.flatMap((property) => {
257
+ if (property.type !== "SpreadElement") return [property];
258
+ const spread = resolveObject(root, property.argument, seen);
259
+ if (!spread) return [property];
260
+ unfolded = true;
261
+ return anchoredAt(spread.properties, property);
262
+ });
263
+ /** A literal with nothing to unfold is returned as it is, not as a copy. */
264
+ if (site) return { ...target, start: site.start, end: site.end, properties: anchoredAt(properties, site) };
265
+ return unfolded ? { ...target, properties } : target;
266
+ }
267
+
268
+ const argumentObject = (call, index, root) => resolveObject(root, call.arguments[index]);
221
269
 
222
270
  /**
223
271
  * The vars object that holds a tween's own settings — `repeat`, `ease`,
224
272
  * `yoyo`: the second argument, a `fromTo`'s third, a timeline's first. `null`
225
- * when it is not an object literal.
273
+ * when it is neither an object literal nor a `const` bound to one. Pass the
274
+ * file's `root` to follow a `const`; without it, only literals are read.
226
275
  */
227
- export const ownVars = (call, method) =>
228
- argumentObject(call, method === "fromTo" ? 2 : method === "timeline" ? 0 : 1);
276
+ export const ownVars = (call, method, root) =>
277
+ argumentObject(call, method === "fromTo" ? 2 : method === "timeline" ? 0 : 1, root);
229
278
 
230
279
  /**
231
280
  * Every vars object a tween call carries, as object literals: both of a
232
281
  * `fromTo`'s, a timeline's own, or the tween's.
233
282
  */
234
- export function varsObjects(call, method) {
283
+ export function varsObjects(call, method, root) {
235
284
  const indexes = method === "fromTo" ? [1, 2] : method === "timeline" ? [0] : [1];
236
- return indexes.map((index) => argumentObject(call, index)).filter(Boolean);
285
+ return indexes.map((index) => argumentObject(call, index, root)).filter(Boolean);
237
286
  }
238
287
 
239
288
  const FUNCTIONS = new Set([
package/lib/rules.mjs CHANGED
@@ -49,6 +49,8 @@ import {
49
49
  const HINT = {
50
50
  matchMediaNeverRuns:
51
51
  "A matchMedia callback runs only while at least one of its named conditions matches, and a visitor with no reduced-motion preference matches none of these — so the branch for them is dead code and nothing animates. Add `motion: \"(prefers-reduced-motion: no-preference)\"` beside it, or use two separate `add` calls.",
52
+ contextSafeMisuse:
53
+ "The callback of `gsap.matchMedia().add`, `gsap.context` and `useGSAP` receives `(context, contextSafe)`: take the second argument, as in `mm.add(query, (context, contextSafe) => …)`, and wrap the delayed callback in it. `context.add(fn)` runs `fn` straight away instead.",
52
54
  stackedFrom:
53
55
  "A `from` or `fromTo` applies its start state the moment it is built. Built more than once against the same element, the last one wins, and holds that element at its start state until the playhead reaches it — invisible, if the start state hides it. Pass `immediateRender: false`, and set the element's first state yourself with `gsap.set`.",
54
56
  orphanTween:
@@ -59,6 +61,10 @@ const HINT = {
59
61
  "Keep it under a name and revert it when the component goes: `onUnmounted` in Vue, the function returned from `onMount` or `$effect` in Svelte, the `useEffect` cleanup in React, `astro:before-swap` in Astro. Everything created inside the context goes with it.",
60
62
  tweenPerFrame:
61
63
  "A tween per frame allocates an object per frame, and each one fights the last. Create a `gsap.quickTo()` or `quickSetter()` once outside the callback and call it inside — or, for scroll, scrub a timeline instead of writing values by hand.",
64
+ allocationPerFrame:
65
+ "Every frame's object is garbage by the next, and the collector reclaims it in a pause that lands as a dropped frame. Build it once, outside the callback — at module scope, or in a ref or useMemo — and reuse it with `.set()` or `.copy()`.",
66
+ layoutReadPerFrame:
67
+ "If anything has changed layout since the last frame, this read makes the browser compute it now, every frame. Measure once and again only on resize — a ScrollTrigger's `onRefresh`, or a ResizeObserver — and read the cached value here.",
62
68
  paintProperty:
63
69
  "Filters and shadows repaint the layer every frame, and the cost grows with the painted area. Animate `opacity` on a pre-blurred or pre-shadowed copy, or move a transform instead, and measure before defending anything else.",
64
70
  unownedLoop:
@@ -102,6 +108,7 @@ const HINT = {
102
108
  const MESSAGE = {
103
109
  easedLoop: "An infinite repeat with an ease other than `none`.",
104
110
  tweenPerFrame: "A tween is created here on every frame.",
111
+ layoutReadPerFrame: "Layout is read here on every frame.",
105
112
  unownedLoop: "An infinite repeat that nothing pauses.",
106
113
  ungatedHover: "A hover animation with no `(hover: hover)` gate.",
107
114
  delayChain: "Three or more tweens in this scope are sequenced by `delay`.",
@@ -171,10 +178,19 @@ function layoutHint(property, percent) {
171
178
  const swap = TRANSFORM_FOR[property];
172
179
  return swap
173
180
  ? `Use \`${swap}\` instead — a compositor-only property.` +
181
+ (property === "height" ? ` ${HEIGHT_CAVEAT}` : "") +
174
182
  (percent ? ` ${PERCENT_CAVEAT}` : "")
175
183
  : "Prefer transform and opacity; they never touch layout.";
176
184
  }
177
185
 
186
+ /**
187
+ * A scale squashes what is inside. An accordion's text is the usual case, and
188
+ * a real project kept its `height` tween for exactly that reason, which the
189
+ * hint above alone would have argued against.
190
+ */
191
+ const HEIGHT_CAVEAT =
192
+ "Unless what is inside must keep its shape — an accordion's text: then transition `grid-template-rows` from `0fr` to `1fr`, or use Flip.";
193
+
178
194
  /** Whether a transform's from-value, as written, leaves the element untransformed. */
179
195
  function identityTransform(property, value) {
180
196
  const v = value.trim().replace(/^["'`]|["'`]$/g, "");
@@ -587,6 +603,36 @@ const PAINT_PROPERTIES = new Set([
587
603
  "boxShadow",
588
604
  ]);
589
605
 
606
+ /** Three's math types: what a render loop allocates when it should reuse. */
607
+ const THREE_MATH = new Set([
608
+ "Vector2",
609
+ "Vector3",
610
+ "Vector4",
611
+ "Quaternion",
612
+ "Euler",
613
+ "Matrix3",
614
+ "Matrix4",
615
+ "Color",
616
+ "Box3",
617
+ "Sphere",
618
+ "Ray",
619
+ "Plane",
620
+ "Spherical",
621
+ ]);
622
+
623
+ /** Reads that return layout, and so make the browser compute it if it is stale. */
624
+ const LAYOUT_READ_METHODS = new Set(["getBoundingClientRect", "getClientRects"]);
625
+ const LAYOUT_READ_PROPERTIES = new Set([
626
+ "offsetWidth",
627
+ "offsetHeight",
628
+ "offsetTop",
629
+ "offsetLeft",
630
+ "clientWidth",
631
+ "clientHeight",
632
+ "scrollWidth",
633
+ "scrollHeight",
634
+ ]);
635
+
590
636
  const HOVER_EVENTS = new Set([
591
637
  "pointerenter",
592
638
  "pointerover",
@@ -685,6 +731,45 @@ function stopped(ast, name) {
685
731
  );
686
732
  }
687
733
 
734
+ /**
735
+ * Every `add` call on a `gsap.matchMedia()`: made on it directly, or on a name
736
+ * the file binds it to, at declaration or later.
737
+ */
738
+ function matchMediaAdds(file) {
739
+ const media = new Set(
740
+ findAll(
741
+ file.ast,
742
+ (node) =>
743
+ (node.type === "VariableDeclarator" &&
744
+ node.id.type === "Identifier" &&
745
+ /**
746
+ * `let tl;` has no initializer, and `calleeName` does not accept
747
+ * null. Every fixture initialised its declarations, so 133 passing
748
+ * tests missed this; the first real project threw on it and took
749
+ * the whole audit down with it.
750
+ */
751
+ Boolean(node.init) &&
752
+ calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
753
+ (node.type === "AssignmentExpression" &&
754
+ node.left.type === "Identifier" &&
755
+ calleeName(unwrap(node.right)) === "gsap.matchMedia"),
756
+ ).map((node) => (node.type === "VariableDeclarator" ? node.id.name : node.left.name)),
757
+ );
758
+
759
+ const onMatchMedia = (call) => {
760
+ const callee = unwrap(call.callee);
761
+ if (callee?.type !== "MemberExpression") return false;
762
+ const object = unwrap(callee.object);
763
+ if (object?.type === "Identifier") return media.has(object.name);
764
+ return calleeName(object) === "gsap.matchMedia";
765
+ };
766
+
767
+ return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node));
768
+ }
769
+
770
+ /** Nodes a value passes through unchanged on its way to whatever uses it. */
771
+ const TRANSPARENT = new Set(["ParenthesizedExpression", "ChainExpression", "TSAsExpression", "TSNonNullExpression"]);
772
+
688
773
  export const RULES = [
689
774
  // --- Lifecycle ------------------------------------------------------------
690
775
  {
@@ -968,7 +1053,7 @@ export const RULES = [
968
1053
  return findAll(file.ast, animates).flatMap(
969
1054
  (call) => {
970
1055
  const seen = new Set();
971
- return varsObjects(call, tweenMethod(call))
1056
+ return varsObjects(call, tweenMethod(call), file.ast)
972
1057
  .flatMap((vars) => vars.properties)
973
1058
  .filter((property) => {
974
1059
  const key = keyName(property);
@@ -1046,7 +1131,7 @@ export const RULES = [
1046
1131
  test(file) {
1047
1132
  return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap(
1048
1133
  (call) => {
1049
- const vars = ownVars(call, tweenMethod(call));
1134
+ const vars = ownVars(call, tweenMethod(call), file.ast);
1050
1135
  if (!vars) return [];
1051
1136
  const repeat = propertyOf(vars, "repeat");
1052
1137
  if (!repeat || numberValue(repeat.value) !== -1) return [];
@@ -1360,6 +1445,91 @@ export const RULES = [
1360
1445
  return findings;
1361
1446
  },
1362
1447
  },
1448
+ {
1449
+ id: "allocation-per-frame",
1450
+ level: "warn",
1451
+ description:
1452
+ "A Three.js vector, colour, matrix or quaternion built every frame — in `useFrame`, the ticker, `onUpdate` or a `requestAnimationFrame` loop",
1453
+ /**
1454
+ * Only Three's math types, by name. Any `new` in a frame allocates, but a
1455
+ * `Set` or a `Map` there is usually deliberate and rarely hot; these are
1456
+ * the objects a render loop builds by the thousand, once per object per
1457
+ * frame, and each has a `set` or `copy` that makes the allocation pointless.
1458
+ */
1459
+ test(file) {
1460
+ const scopes = perFrameScopes(file.ast);
1461
+ if (scopes.length === 0) return [];
1462
+
1463
+ const built = findAll(file.ast, (node) => {
1464
+ if (node.type !== "NewExpression") return false;
1465
+ const name = dottedName(unwrap(node.callee))?.split(".").at(-1);
1466
+ return THREE_MATH.has(name);
1467
+ });
1468
+
1469
+ const reported = new Set();
1470
+ return built.flatMap((node) => {
1471
+ if (reported.has(node.start) || !scopes.some((scope) => contains(scope, node))) {
1472
+ return [];
1473
+ }
1474
+ reported.add(node.start);
1475
+ const name = dottedName(unwrap(node.callee)).split(".").at(-1);
1476
+ return [
1477
+ {
1478
+ index: node.start,
1479
+ message: `A \`${name}\` is allocated here on every frame.`,
1480
+ hint: HINT.allocationPerFrame,
1481
+ },
1482
+ ];
1483
+ });
1484
+ },
1485
+ },
1486
+ {
1487
+ id: "layout-read-per-frame",
1488
+ level: "info",
1489
+ description:
1490
+ "A layout read — `getBoundingClientRect`, `offsetWidth`, `getComputedStyle` — in a callback that runs every frame",
1491
+ /**
1492
+ * Info, because a read is only expensive when something has invalidated
1493
+ * layout since the last frame, and that depends on what else runs — a
1494
+ * transform does not, a `width` tween does. What is certain is that the
1495
+ * value is re-measured every frame when it usually changes only on resize.
1496
+ */
1497
+ test(file) {
1498
+ const scopes = perFrameScopes(file.ast);
1499
+ if (scopes.length === 0) return [];
1500
+
1501
+ const reads = findAll(file.ast, (node) => {
1502
+ if (node.type === "CallExpression") {
1503
+ const name = calleeName(node);
1504
+ return (
1505
+ name === "getComputedStyle" ||
1506
+ name === "window.getComputedStyle" ||
1507
+ LAYOUT_READ_METHODS.has(methodName(node))
1508
+ );
1509
+ }
1510
+ return (
1511
+ node.type === "MemberExpression" &&
1512
+ !node.computed &&
1513
+ LAYOUT_READ_PROPERTIES.has(node.property.name)
1514
+ );
1515
+ });
1516
+
1517
+ const reported = new Set();
1518
+ return reads.flatMap((node) => {
1519
+ if (reported.has(node.start) || !scopes.some((scope) => contains(scope, node))) {
1520
+ return [];
1521
+ }
1522
+ reported.add(node.start);
1523
+ return [
1524
+ {
1525
+ index: node.start,
1526
+ message: MESSAGE.layoutReadPerFrame,
1527
+ hint: HINT.layoutReadPerFrame,
1528
+ },
1529
+ ];
1530
+ });
1531
+ },
1532
+ },
1363
1533
  {
1364
1534
  id: "paint-property",
1365
1535
  level: "warn",
@@ -1374,7 +1544,7 @@ export const RULES = [
1374
1544
 
1375
1545
  return findAll(file.ast, animates).flatMap((call) => {
1376
1546
  const seen = new Set();
1377
- return varsObjects(call, tweenMethod(call))
1547
+ return varsObjects(call, tweenMethod(call), file.ast)
1378
1548
  .flatMap((vars) => vars.properties)
1379
1549
  .filter((property) => {
1380
1550
  const key = keyName(property);
@@ -1421,7 +1591,7 @@ export const RULES = [
1421
1591
 
1422
1592
  return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap((call) => {
1423
1593
  if (inside(call)) return [];
1424
- const vars = ownVars(call, tweenMethod(call));
1594
+ const vars = ownVars(call, tweenMethod(call), file.ast);
1425
1595
  if (!vars) return [];
1426
1596
  const repeat = propertyOf(vars, "repeat");
1427
1597
  if (!repeat || numberValue(repeat.value) !== -1) return [];
@@ -1477,7 +1647,7 @@ export const RULES = [
1477
1647
 
1478
1648
  for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
1479
1649
  if (chainStart(call) !== call) continue;
1480
- const vars = ownVars(call, tweenMethod(call));
1650
+ const vars = ownVars(call, tweenMethod(call), file.ast);
1481
1651
  const delay = vars && propertyOf(vars, "delay");
1482
1652
  if (!delay || !(numberValue(delay.value) > 0)) continue;
1483
1653
  const scope = scopeOf(file.ast, call) ?? file.ast;
@@ -1628,35 +1798,6 @@ export const RULES = [
1628
1798
  const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
1629
1799
  const PREFERENCE = /prefers-reduced-motion/i;
1630
1800
 
1631
- /** Names bound to `gsap.matchMedia()` in this file, at declaration or later. */
1632
- const media = new Set(
1633
- findAll(
1634
- file.ast,
1635
- (node) =>
1636
- (node.type === "VariableDeclarator" &&
1637
- node.id.type === "Identifier" &&
1638
- /**
1639
- * `let tl;` has no initializer, and `calleeName` does not accept
1640
- * null. Every fixture initialised its declarations, so 133 passing
1641
- * tests missed this; the first real project threw on it and took
1642
- * the whole audit down with it.
1643
- */
1644
- Boolean(node.init) &&
1645
- calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
1646
- (node.type === "AssignmentExpression" &&
1647
- node.left.type === "Identifier" &&
1648
- calleeName(unwrap(node.right)) === "gsap.matchMedia"),
1649
- ).map((node) => (node.type === "VariableDeclarator" ? node.id.name : node.left.name)),
1650
- );
1651
-
1652
- const onMatchMedia = (call) => {
1653
- const callee = unwrap(call.callee);
1654
- if (callee?.type !== "MemberExpression") return false;
1655
- const object = unwrap(callee.object);
1656
- if (object?.type === "Identifier") return media.has(object.name);
1657
- return calleeName(object) === "gsap.matchMedia";
1658
- };
1659
-
1660
1801
  /** The conditions as written: in the call, or in a `const` the file declares once. */
1661
1802
  const conditionsOf = (argument) => {
1662
1803
  const target = unwrap(argument);
@@ -1714,7 +1855,7 @@ export const RULES = [
1714
1855
  }).length > 0;
1715
1856
  };
1716
1857
 
1717
- return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node))
1858
+ return matchMediaAdds(file)
1718
1859
  .filter((call) => {
1719
1860
  if (!everyReduces(conditionsOf(call.arguments[0]))) return false;
1720
1861
  const callback = resolveFunction(file.ast, call.arguments[1]);
@@ -1728,6 +1869,93 @@ export const RULES = [
1728
1869
  }));
1729
1870
  },
1730
1871
  },
1872
+ {
1873
+ id: "context-safe-misuse",
1874
+ level: "warn",
1875
+ description:
1876
+ "A callback made context-safe with the context itself, which runs it at once instead",
1877
+ /**
1878
+ * The callback of `gsap.matchMedia().add`, `gsap.context` or `useGSAP`
1879
+ * receives `(context, contextSafe)`. Two ways of getting that wrong both
1880
+ * ship a blank page:
1881
+ *
1882
+ * - `({ contextSafe }) => …` reads `contextSafe` from the context, which has
1883
+ * none. It is `undefined`, and the first call throws.
1884
+ * - `context.add(fn)` handed on as a callback. `add` with a function runs it
1885
+ * at once, passing the context, and returns what it returned, so the
1886
+ * caller receives no callback and `fn` ran with the wrong argument.
1887
+ *
1888
+ * Found in the 4.3 creative A/B: three of 27 runs, in both arms, crashed on
1889
+ * load this way, every one inside a matchMedia. `add` as a statement runs a
1890
+ * block inside the context and is correct; a named `add("onClick", fn)` and
1891
+ * `add(null, fn)` return a wrapper and are correct too. Only an `add` whose
1892
+ * result is used, given a function, is reported.
1893
+ */
1894
+ test(file) {
1895
+ const callbacks = new Set(
1896
+ [
1897
+ ...matchMediaAdds(file).map((call) => call.arguments[1]),
1898
+ ...findAll(
1899
+ file.ast,
1900
+ (node) =>
1901
+ node.type === "CallExpression" &&
1902
+ ["gsap.context", "useGSAP"].includes(calleeName(node)),
1903
+ ).map((call) => call.arguments[0]),
1904
+ ]
1905
+ .map((argument) => resolveFunction(file.ast, argument))
1906
+ .filter(Boolean),
1907
+ );
1908
+
1909
+ /** Is the call's result handed on, rather than thrown away as a statement? */
1910
+ const used = (call) => {
1911
+ let child = call;
1912
+ let parent = parentOf(file.ast, call);
1913
+ while (parent && TRANSPARENT.has(parent.type)) {
1914
+ child = parent;
1915
+ parent = parentOf(file.ast, parent);
1916
+ }
1917
+ if (!parent || parent.type === "ExpressionStatement" || parent.type === "SequenceExpression") {
1918
+ return false;
1919
+ }
1920
+ return !(parent.type === "CallExpression" && parent.callee === child);
1921
+ };
1922
+
1923
+ const findings = [];
1924
+ for (const fn of callbacks) {
1925
+ const [param] = fn.params;
1926
+ if (param?.type === "ObjectPattern") {
1927
+ const property = param.properties.find((p) => keyName(p) === "contextSafe");
1928
+ if (property) {
1929
+ findings.push({
1930
+ index: property.start,
1931
+ message:
1932
+ "`contextSafe` is read from the callback's first argument, the context, which has none: it is undefined, and the first call throws.",
1933
+ hint: HINT.contextSafeMisuse,
1934
+ });
1935
+ }
1936
+ continue;
1937
+ }
1938
+ if (param?.type !== "Identifier") continue;
1939
+ const adds = findAll(fn.body, (node) => {
1940
+ if (methodName(node) !== "add") return false;
1941
+ const receiver = unwrap(unwrap(node.callee).object);
1942
+ return receiver?.type === "Identifier" && receiver.name === param.name;
1943
+ });
1944
+ for (const call of adds) {
1945
+ const target = unwrap(call.arguments[0]);
1946
+ if (!target || target.type === "Literal" || target.type === "TemplateLiteral") continue;
1947
+ if (!isFunction(target) && target.type !== "Identifier") continue;
1948
+ if (!used(call)) continue;
1949
+ findings.push({
1950
+ index: call.start,
1951
+ message: `\`${param.name}.add(fn)\` runs fn at once and returns its result, so what is handed on here is not a callback.`,
1952
+ hint: HINT.contextSafeMisuse,
1953
+ });
1954
+ }
1955
+ }
1956
+ return findings;
1957
+ },
1958
+ },
1731
1959
  {
1732
1960
  id: "stacked-from",
1733
1961
  level: "warn",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/eslint-plugin-gsap-motion",
3
- "version": "4.2.1",
3
+ "version": "4.4.2",
4
4
  "description": "The gsap-motion audit as ESLint rules: GSAP tweens that outlive their component, per-event allocations and missing reduced-motion branches, reported in your editor.",
5
5
  "keywords": [
6
6
  "eslint",