@mehshekari/eslint-plugin-gsap-motion 4.2.1 → 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 +1 -0
- package/lib/rules.mjs +129 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -95,6 +95,7 @@ so a finding waived that way still fails `gsap-motion audit`.
|
|
|
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
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/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
|
{
|
|
@@ -1628,35 +1669,6 @@ export const RULES = [
|
|
|
1628
1669
|
const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
|
|
1629
1670
|
const PREFERENCE = /prefers-reduced-motion/i;
|
|
1630
1671
|
|
|
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
1672
|
/** The conditions as written: in the call, or in a `const` the file declares once. */
|
|
1661
1673
|
const conditionsOf = (argument) => {
|
|
1662
1674
|
const target = unwrap(argument);
|
|
@@ -1714,7 +1726,7 @@ export const RULES = [
|
|
|
1714
1726
|
}).length > 0;
|
|
1715
1727
|
};
|
|
1716
1728
|
|
|
1717
|
-
return
|
|
1729
|
+
return matchMediaAdds(file)
|
|
1718
1730
|
.filter((call) => {
|
|
1719
1731
|
if (!everyReduces(conditionsOf(call.arguments[0]))) return false;
|
|
1720
1732
|
const callback = resolveFunction(file.ast, call.arguments[1]);
|
|
@@ -1728,6 +1740,93 @@ export const RULES = [
|
|
|
1728
1740
|
}));
|
|
1729
1741
|
},
|
|
1730
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
|
+
},
|
|
1731
1830
|
{
|
|
1732
1831
|
id: "stacked-from",
|
|
1733
1832
|
level: "warn",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehshekari/eslint-plugin-gsap-motion",
|
|
3
|
-
"version": "4.
|
|
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",
|