@mehshekari/eslint-plugin-gsap-motion 4.2.0 → 4.3.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
@@ -94,7 +94,8 @@ so a finding waived that way still fails `gsap-motion audit`.
94
94
  | `ungated-hover` | warn | A hover animation with no `(hover: hover)` gate, which a tap starts and nothing ends |
95
95
  | `delay-chain` | warn | Three or more tweens in one scope sequenced by `delay`, which is a timeline nobody can retime |
96
96
  | `unowned-loop` | info | An infinite repeat that nothing pauses, which keeps the ticker busy off screen |
97
- | `matchmedia-never-runs` | warn | A matchMedia callback that branches on its conditions, when every condition needs reduced motion |
97
+ | `matchmedia-never-runs` | warn | A matchMedia callback that branches on reduced motion, when every condition needs it |
98
+ | `context-safe-misuse` | warn | A callback made context-safe with the context itself, which runs it at once instead |
98
99
  | `stacked-from` | warn | A timeline's `from` or `fromTo` built more than once against the same target |
99
100
  | `not-parsed` | warn | A file that uses GSAP and that the audit's parser could not read, so no other rule checked it |
100
101
 
package/lib/ast.mjs CHANGED
@@ -478,12 +478,43 @@ export function gsapContexts(root) {
478
478
  }
479
479
  }
480
480
 
481
- const scopes = findAll(
482
- root,
483
- (node) =>
484
- node.type === "CallExpression" &&
485
- (isHook(node) || calleeName(node) === "gsap.context" || safe.has(bareName(node))),
486
- ).flatMap((call) => call.arguments);
481
+ /**
482
+ * A matchMedia is a context too: `mm.revert()` reverts what its `add`
483
+ * callbacks made. Its callback is recognised on `gsap.matchMedia().add` and on
484
+ * any name the file assigns `gsap.matchMedia()` to, followed when it is handed
485
+ * over by name. Whether the matchMedia itself is ever reverted is
486
+ * `unmanaged-instance`'s to report, not a reason to distrust its callbacks.
487
+ */
488
+ const matchMedias = new Set(
489
+ findAll(
490
+ root,
491
+ (node) =>
492
+ (node.type === "VariableDeclarator" &&
493
+ Boolean(node.init) &&
494
+ calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
495
+ (node.type === "AssignmentExpression" &&
496
+ calleeName(unwrap(node.right)) === "gsap.matchMedia"),
497
+ )
498
+ .map((node) => dottedName(node.type === "VariableDeclarator" ? node.id : node.left))
499
+ .filter(Boolean),
500
+ );
501
+ const isMatchMediaAdd = (node) => {
502
+ if (methodName(node) !== "add") return false;
503
+ const object = unwrap(unwrap(node.callee).object);
504
+ return calleeName(object) === "gsap.matchMedia" || matchMedias.has(dottedName(object));
505
+ };
506
+
507
+ const scopes = [
508
+ ...findAll(
509
+ root,
510
+ (node) =>
511
+ node.type === "CallExpression" &&
512
+ (isHook(node) || calleeName(node) === "gsap.context" || safe.has(bareName(node))),
513
+ ).flatMap((call) => call.arguments),
514
+ ...findAll(root, isMatchMediaAdd).flatMap((call) =>
515
+ call.arguments.map((argument) => resolveFunction(root, argument)).filter(Boolean),
516
+ ),
517
+ ];
487
518
 
488
519
  const counts = new Map();
489
520
  const functions = new Map();
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:
@@ -685,6 +687,45 @@ function stopped(ast, name) {
685
687
  );
686
688
  }
687
689
 
690
+ /**
691
+ * Every `add` call on a `gsap.matchMedia()`: made on it directly, or on a name
692
+ * the file binds it to, at declaration or later.
693
+ */
694
+ function matchMediaAdds(file) {
695
+ const media = new Set(
696
+ findAll(
697
+ file.ast,
698
+ (node) =>
699
+ (node.type === "VariableDeclarator" &&
700
+ node.id.type === "Identifier" &&
701
+ /**
702
+ * `let tl;` has no initializer, and `calleeName` does not accept
703
+ * null. Every fixture initialised its declarations, so 133 passing
704
+ * tests missed this; the first real project threw on it and took
705
+ * the whole audit down with it.
706
+ */
707
+ Boolean(node.init) &&
708
+ calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
709
+ (node.type === "AssignmentExpression" &&
710
+ node.left.type === "Identifier" &&
711
+ calleeName(unwrap(node.right)) === "gsap.matchMedia"),
712
+ ).map((node) => (node.type === "VariableDeclarator" ? node.id.name : node.left.name)),
713
+ );
714
+
715
+ const onMatchMedia = (call) => {
716
+ const callee = unwrap(call.callee);
717
+ if (callee?.type !== "MemberExpression") return false;
718
+ const object = unwrap(callee.object);
719
+ if (object?.type === "Identifier") return media.has(object.name);
720
+ return calleeName(object) === "gsap.matchMedia";
721
+ };
722
+
723
+ return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node));
724
+ }
725
+
726
+ /** Nodes a value passes through unchanged on its way to whatever uses it. */
727
+ const TRANSPARENT = new Set(["ParenthesizedExpression", "ChainExpression", "TSAsExpression", "TSNonNullExpression"]);
728
+
688
729
  export const RULES = [
689
730
  // --- Lifecycle ------------------------------------------------------------
690
731
  {
@@ -1606,50 +1647,67 @@ export const RULES = [
1606
1647
  id: "matchmedia-never-runs",
1607
1648
  level: "warn",
1608
1649
  description:
1609
- "A matchMedia callback that branches on its conditions, when every condition needs reduced motion",
1650
+ "A matchMedia callback that branches on reduced motion, when every condition needs it",
1610
1651
  /**
1611
1652
  * `mm.add({ reduced: "(prefers-reduced-motion: reduce)" }, (context) => …)`
1612
- * whose callback reads `context.conditions`. The callback runs only while a
1653
+ * whose callback branches on the preference. The callback runs only while a
1613
1654
  * named condition matches, and a visitor with no preference matches none —
1614
1655
  * so the branch written for them never runs, and neither does the
1615
1656
  * animation in it. Found in a head-to-head test on a real hero, where the
1616
- * whole page shipped without motion.
1657
+ * whole page shipped without motion, and again in phase 8's first round.
1658
+ *
1659
+ * Branching is the whole signal: reading `conditions`, or asking
1660
+ * `window.matchMedia` about reduced motion inside the callback. A
1661
+ * reduced-only `add` that does not branch is GSAP's documented pattern for
1662
+ * setting end states, meant to run for those visitors only, and is correct.
1617
1663
  *
1618
- * The reading of `conditions` is the whole signal. A reduced-only `add`
1619
- * that does not branch is GSAP's documented pattern for setting end states,
1620
- * meant to run for those visitors only, and is correct.
1664
+ * The conditions are read when they are written in the call, or kept in a
1665
+ * `const` the file declares once. An imported object, or a `let` that may be
1666
+ * reassigned, cannot be judged here and is left alone.
1621
1667
  */
1622
1668
  test(file) {
1623
1669
  const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
1670
+ const PREFERENCE = /prefers-reduced-motion/i;
1624
1671
 
1625
- /** Names bound to `gsap.matchMedia()` in this file. */
1626
- const media = new Set(
1627
- findAll(
1672
+ /** The conditions as written: in the call, or in a `const` the file declares once. */
1673
+ const conditionsOf = (argument) => {
1674
+ const target = unwrap(argument);
1675
+ if (target?.type !== "Identifier") return target;
1676
+ const declarators = findAll(
1628
1677
  file.ast,
1629
1678
  (node) =>
1630
1679
  node.type === "VariableDeclarator" &&
1631
1680
  node.id.type === "Identifier" &&
1632
- /**
1633
- * `let tl;` has no initializer, and `calleeName` does not accept
1634
- * null. Every fixture initialised its declarations, so 133 passing
1635
- * tests missed this; the first real project threw on it and took
1636
- * the whole audit down with it.
1637
- */
1638
- Boolean(node.init) &&
1639
- calleeName(unwrap(node.init)) === "gsap.matchMedia",
1640
- ).map((node) => node.id.name),
1641
- );
1681
+ node.id.name === target.name,
1682
+ );
1683
+ if (declarators.length !== 1) return null;
1684
+ const [declarator] = declarators;
1685
+ return parentOf(file.ast, declarator)?.kind === "const" ? unwrap(declarator.init) : null;
1686
+ };
1642
1687
 
1643
- const onMatchMedia = (call) => {
1644
- const callee = unwrap(call.callee);
1645
- if (callee?.type !== "MemberExpression") return false;
1646
- const object = unwrap(callee.object);
1647
- if (object?.type === "Identifier") return media.has(object.name);
1648
- return calleeName(object) === "gsap.matchMedia";
1688
+ /** Every condition needs reduced motion: one query string, or an object of them. */
1689
+ const everyReduces = (conditions) => {
1690
+ const query = staticString(conditions);
1691
+ if (typeof query === "string") return REDUCE.test(query);
1692
+ if (conditions?.type !== "ObjectExpression" || !conditions.properties.length) return false;
1693
+ return conditions.properties.every((property) => {
1694
+ const value = staticString(property.value);
1695
+ return typeof value === "string" && REDUCE.test(value);
1696
+ });
1649
1697
  };
1650
1698
 
1651
- /** Does the callback read the conditions it was handed? */
1699
+ /** Does the callback read its conditions, or ask the browser about reduced motion? */
1652
1700
  const branches = (fn) => {
1701
+ const asksTheBrowser =
1702
+ findAll(fn.body, (node) => {
1703
+ if (node.type !== "CallExpression") return false;
1704
+ const name = calleeName(node);
1705
+ if (name !== "window.matchMedia" && name !== "matchMedia") return false;
1706
+ const query = staticString(node.arguments[0]);
1707
+ return typeof query === "string" && PREFERENCE.test(query);
1708
+ }).length > 0;
1709
+ if (asksTheBrowser) return true;
1710
+
1653
1711
  const [param] = fn.params;
1654
1712
  if (!param) return false;
1655
1713
  if (param.type === "ObjectPattern") {
@@ -1668,26 +1726,107 @@ export const RULES = [
1668
1726
  }).length > 0;
1669
1727
  };
1670
1728
 
1671
- return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node))
1729
+ return matchMediaAdds(file)
1672
1730
  .filter((call) => {
1673
- const conditions = unwrap(call.arguments[0]);
1674
- if (conditions?.type !== "ObjectExpression" || !conditions.properties.length) return false;
1675
- const everyReduces = conditions.properties.every((property) => {
1676
- const value = staticString(property.value);
1677
- return typeof value === "string" && REDUCE.test(value);
1678
- });
1679
- if (!everyReduces) return false;
1731
+ if (!everyReduces(conditionsOf(call.arguments[0]))) return false;
1680
1732
  const callback = resolveFunction(file.ast, call.arguments[1]);
1681
1733
  return Boolean(callback) && branches(callback);
1682
1734
  })
1683
1735
  .map((call) => ({
1684
1736
  index: call.start,
1685
1737
  message:
1686
- "This matchMedia callback branches on its conditions, but every condition needs reduced motion — so it never runs for a visitor without that preference.",
1738
+ "This matchMedia callback branches on reduced motion, but every condition needs reduced motion — so it never runs for a visitor without that preference.",
1687
1739
  hint: HINT.matchMediaNeverRuns,
1688
1740
  }));
1689
1741
  },
1690
1742
  },
1743
+ {
1744
+ id: "context-safe-misuse",
1745
+ level: "warn",
1746
+ description:
1747
+ "A callback made context-safe with the context itself, which runs it at once instead",
1748
+ /**
1749
+ * The callback of `gsap.matchMedia().add`, `gsap.context` or `useGSAP`
1750
+ * receives `(context, contextSafe)`. Two ways of getting that wrong both
1751
+ * ship a blank page:
1752
+ *
1753
+ * - `({ contextSafe }) => …` reads `contextSafe` from the context, which has
1754
+ * none. It is `undefined`, and the first call throws.
1755
+ * - `context.add(fn)` handed on as a callback. `add` with a function runs it
1756
+ * at once, passing the context, and returns what it returned, so the
1757
+ * caller receives no callback and `fn` ran with the wrong argument.
1758
+ *
1759
+ * Found in the 4.3 creative A/B: three of 27 runs, in both arms, crashed on
1760
+ * load this way, every one inside a matchMedia. `add` as a statement runs a
1761
+ * block inside the context and is correct; a named `add("onClick", fn)` and
1762
+ * `add(null, fn)` return a wrapper and are correct too. Only an `add` whose
1763
+ * result is used, given a function, is reported.
1764
+ */
1765
+ test(file) {
1766
+ const callbacks = new Set(
1767
+ [
1768
+ ...matchMediaAdds(file).map((call) => call.arguments[1]),
1769
+ ...findAll(
1770
+ file.ast,
1771
+ (node) =>
1772
+ node.type === "CallExpression" &&
1773
+ ["gsap.context", "useGSAP"].includes(calleeName(node)),
1774
+ ).map((call) => call.arguments[0]),
1775
+ ]
1776
+ .map((argument) => resolveFunction(file.ast, argument))
1777
+ .filter(Boolean),
1778
+ );
1779
+
1780
+ /** Is the call's result handed on, rather than thrown away as a statement? */
1781
+ const used = (call) => {
1782
+ let child = call;
1783
+ let parent = parentOf(file.ast, call);
1784
+ while (parent && TRANSPARENT.has(parent.type)) {
1785
+ child = parent;
1786
+ parent = parentOf(file.ast, parent);
1787
+ }
1788
+ if (!parent || parent.type === "ExpressionStatement" || parent.type === "SequenceExpression") {
1789
+ return false;
1790
+ }
1791
+ return !(parent.type === "CallExpression" && parent.callee === child);
1792
+ };
1793
+
1794
+ const findings = [];
1795
+ for (const fn of callbacks) {
1796
+ const [param] = fn.params;
1797
+ if (param?.type === "ObjectPattern") {
1798
+ const property = param.properties.find((p) => keyName(p) === "contextSafe");
1799
+ if (property) {
1800
+ findings.push({
1801
+ index: property.start,
1802
+ message:
1803
+ "`contextSafe` is read from the callback's first argument, the context, which has none: it is undefined, and the first call throws.",
1804
+ hint: HINT.contextSafeMisuse,
1805
+ });
1806
+ }
1807
+ continue;
1808
+ }
1809
+ if (param?.type !== "Identifier") continue;
1810
+ const adds = findAll(fn.body, (node) => {
1811
+ if (methodName(node) !== "add") return false;
1812
+ const receiver = unwrap(unwrap(node.callee).object);
1813
+ return receiver?.type === "Identifier" && receiver.name === param.name;
1814
+ });
1815
+ for (const call of adds) {
1816
+ const target = unwrap(call.arguments[0]);
1817
+ if (!target || target.type === "Literal" || target.type === "TemplateLiteral") continue;
1818
+ if (!isFunction(target) && target.type !== "Identifier") continue;
1819
+ if (!used(call)) continue;
1820
+ findings.push({
1821
+ index: call.start,
1822
+ message: `\`${param.name}.add(fn)\` runs fn at once and returns its result, so what is handed on here is not a callback.`,
1823
+ hint: HINT.contextSafeMisuse,
1824
+ });
1825
+ }
1826
+ }
1827
+ return findings;
1828
+ },
1829
+ },
1691
1830
  {
1692
1831
  id: "stacked-from",
1693
1832
  level: "warn",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/eslint-plugin-gsap-motion",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
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",