@mehshekari/eslint-plugin-gsap-motion 4.1.0 → 4.2.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 +2 -0
- package/lib/rules.mjs +315 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,6 +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 |
|
|
98
|
+
| `stacked-from` | warn | A timeline's `from` or `fromTo` built more than once against the same target |
|
|
97
99
|
| `not-parsed` | warn | A file that uses GSAP and that the audit's parser could not read, so no other rule checked it |
|
|
98
100
|
|
|
99
101
|
`not-parsed` is an info finding in the command line; ESLint has no info level.
|
package/lib/rules.mjs
CHANGED
|
@@ -47,6 +47,10 @@ import {
|
|
|
47
47
|
|
|
48
48
|
/** What each rule says, in one place. */
|
|
49
49
|
const HINT = {
|
|
50
|
+
matchMediaNeverRuns:
|
|
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
|
+
stackedFrom:
|
|
53
|
+
"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`.",
|
|
50
54
|
orphanTween:
|
|
51
55
|
"Nothing reverts it on unmount, and React StrictMode runs it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. A helper counts as inside only when every use of it is a call from inside a context.",
|
|
52
56
|
unmanagedInstance:
|
|
@@ -1598,4 +1602,315 @@ export const RULES = [
|
|
|
1598
1602
|
}));
|
|
1599
1603
|
},
|
|
1600
1604
|
},
|
|
1605
|
+
{
|
|
1606
|
+
id: "matchmedia-never-runs",
|
|
1607
|
+
level: "warn",
|
|
1608
|
+
description:
|
|
1609
|
+
"A matchMedia callback that branches on its conditions, when every condition needs reduced motion",
|
|
1610
|
+
/**
|
|
1611
|
+
* `mm.add({ reduced: "(prefers-reduced-motion: reduce)" }, (context) => …)`
|
|
1612
|
+
* whose callback reads `context.conditions`. The callback runs only while a
|
|
1613
|
+
* named condition matches, and a visitor with no preference matches none —
|
|
1614
|
+
* so the branch written for them never runs, and neither does the
|
|
1615
|
+
* animation in it. Found in a head-to-head test on a real hero, where the
|
|
1616
|
+
* whole page shipped without motion.
|
|
1617
|
+
*
|
|
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.
|
|
1621
|
+
*/
|
|
1622
|
+
test(file) {
|
|
1623
|
+
const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
|
|
1624
|
+
|
|
1625
|
+
/** Names bound to `gsap.matchMedia()` in this file. */
|
|
1626
|
+
const media = new Set(
|
|
1627
|
+
findAll(
|
|
1628
|
+
file.ast,
|
|
1629
|
+
(node) =>
|
|
1630
|
+
node.type === "VariableDeclarator" &&
|
|
1631
|
+
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
|
+
);
|
|
1642
|
+
|
|
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";
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
/** Does the callback read the conditions it was handed? */
|
|
1652
|
+
const branches = (fn) => {
|
|
1653
|
+
const [param] = fn.params;
|
|
1654
|
+
if (!param) return false;
|
|
1655
|
+
if (param.type === "ObjectPattern") {
|
|
1656
|
+
return param.properties.some((p) => keyName(p) === "conditions");
|
|
1657
|
+
}
|
|
1658
|
+
if (param.type !== "Identifier") return false;
|
|
1659
|
+
return findAll(fn.body, (node) => {
|
|
1660
|
+
const member = unwrap(node);
|
|
1661
|
+
return (
|
|
1662
|
+
member?.type === "MemberExpression" &&
|
|
1663
|
+
!member.computed &&
|
|
1664
|
+
unwrap(member.object)?.type === "Identifier" &&
|
|
1665
|
+
unwrap(member.object).name === param.name &&
|
|
1666
|
+
member.property.name === "conditions"
|
|
1667
|
+
);
|
|
1668
|
+
}).length > 0;
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node))
|
|
1672
|
+
.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;
|
|
1680
|
+
const callback = resolveFunction(file.ast, call.arguments[1]);
|
|
1681
|
+
return Boolean(callback) && branches(callback);
|
|
1682
|
+
})
|
|
1683
|
+
.map((call) => ({
|
|
1684
|
+
index: call.start,
|
|
1685
|
+
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.",
|
|
1687
|
+
hint: HINT.matchMediaNeverRuns,
|
|
1688
|
+
}));
|
|
1689
|
+
},
|
|
1690
|
+
},
|
|
1691
|
+
{
|
|
1692
|
+
id: "stacked-from",
|
|
1693
|
+
level: "warn",
|
|
1694
|
+
description:
|
|
1695
|
+
"A timeline's `from` or `fromTo` built more than once against the same target",
|
|
1696
|
+
/**
|
|
1697
|
+
* A `from` or `fromTo` on a timeline, without `immediateRender: false`,
|
|
1698
|
+
* that is built more than once against the same element: written out
|
|
1699
|
+
* twice, or inside a loop, or inside a helper a loop calls. Each applies
|
|
1700
|
+
* its start state the moment it is built, so the last one wins and holds
|
|
1701
|
+
* the element hidden until the playhead arrives. Found in a real hero,
|
|
1702
|
+
* where one call site in a helper ran six times and a badge stayed
|
|
1703
|
+
* invisible through the whole first state.
|
|
1704
|
+
*
|
|
1705
|
+
* Two shapes must stay quiet, and both sat in that same file. A target that
|
|
1706
|
+
* varies with the loop — `forEach((card) => tl.from(card, …))` — is a new
|
|
1707
|
+
* element each pass. And a tween inside a callback that is only passed
|
|
1708
|
+
* along is not known to run each pass, so it is not reported.
|
|
1709
|
+
*/
|
|
1710
|
+
test(file) {
|
|
1711
|
+
const LOOPS = ["ForStatement", "ForOfStatement", "ForInStatement", "WhileStatement", "DoWhileStatement"];
|
|
1712
|
+
const isLoopCallback = (fn) => {
|
|
1713
|
+
const parent = parentOf(file.ast, fn);
|
|
1714
|
+
return (
|
|
1715
|
+
parent?.type === "CallExpression" &&
|
|
1716
|
+
unwrap(parent.arguments[0]) === fn &&
|
|
1717
|
+
["forEach", "map", "flatMap"].includes(methodName(parent))
|
|
1718
|
+
);
|
|
1719
|
+
};
|
|
1720
|
+
const renders = (call) =>
|
|
1721
|
+
!call.arguments.some((arg) => {
|
|
1722
|
+
const object = unwrap(arg);
|
|
1723
|
+
return (
|
|
1724
|
+
object?.type === "ObjectExpression" &&
|
|
1725
|
+
object.properties.some((p) => {
|
|
1726
|
+
const value = unwrap(p.value);
|
|
1727
|
+
return keyName(p) === "immediateRender" && value?.type === "Literal" && value.value === false;
|
|
1728
|
+
})
|
|
1729
|
+
);
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
const paramNames = (fn) =>
|
|
1733
|
+
fn.params.flatMap((param) =>
|
|
1734
|
+
findAll(param, (n) => n.type === "Identifier").map((n) => n.name),
|
|
1735
|
+
);
|
|
1736
|
+
const loopNames = (loop) => {
|
|
1737
|
+
const left = loop.left ?? loop.init;
|
|
1738
|
+
return left ? findAll(left, (n) => n.type === "Identifier").map((n) => n.name) : [];
|
|
1739
|
+
};
|
|
1740
|
+
|
|
1741
|
+
/** Is this node inside a loop body or a loop callback? */
|
|
1742
|
+
const inLoop = (node) =>
|
|
1743
|
+
ancestorsOf(file.ast, node).some((a) => LOOPS.includes(a.type) || (isFunction(a) && isLoopCallback(a)));
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Walk out from the tween. The names that change each pass are gathered on
|
|
1747
|
+
* the way; the walk stops at the first function that is neither a loop's
|
|
1748
|
+
* own callback nor a named helper some loop calls, because nothing then
|
|
1749
|
+
* says it runs more than once.
|
|
1750
|
+
*/
|
|
1751
|
+
/**
|
|
1752
|
+
* Does a guard on the way out pick exactly one pass? `key === "money"`
|
|
1753
|
+
* does; `key !== "brand"` runs on most passes and still stacks. Only an
|
|
1754
|
+
* equality against a literal, on a name that changes each pass, counts —
|
|
1755
|
+
* the version of a real hero built without the skill guarded its one
|
|
1756
|
+
* per-state tween that way, and a first version of this rule credited it
|
|
1757
|
+
* with a bug it did not have.
|
|
1758
|
+
*/
|
|
1759
|
+
const selectsOnePass = (guards, varying) =>
|
|
1760
|
+
guards.some((test) =>
|
|
1761
|
+
findAll(test, (node) => {
|
|
1762
|
+
if (node.type !== "BinaryExpression" || !["===", "=="].includes(node.operator)) return false;
|
|
1763
|
+
const sides = [unwrap(node.left), unwrap(node.right)];
|
|
1764
|
+
const name = sides.find((side) => side?.type === "Identifier" && varying.has(side.name));
|
|
1765
|
+
const literal = sides.find((side) => side?.type === "Literal");
|
|
1766
|
+
return Boolean(name && literal);
|
|
1767
|
+
}).length > 0,
|
|
1768
|
+
);
|
|
1769
|
+
|
|
1770
|
+
/** Names that change each pass: its variable, and whatever its body declares. */
|
|
1771
|
+
const declaredIn = (body) =>
|
|
1772
|
+
body
|
|
1773
|
+
? findAll(body, (n) => n.type === "VariableDeclarator" && n.id.type === "Identifier").map((n) => n.id.name)
|
|
1774
|
+
: [];
|
|
1775
|
+
|
|
1776
|
+
const repetition = (tween, timeline) => {
|
|
1777
|
+
const varying = new Set();
|
|
1778
|
+
const guards = [];
|
|
1779
|
+
const settle = (loopBody) => {
|
|
1780
|
+
declaredIn(loopBody).forEach((n) => varying.add(n));
|
|
1781
|
+
return selectsOnePass(guards, varying) ? null : varying;
|
|
1782
|
+
};
|
|
1783
|
+
let inner = tween;
|
|
1784
|
+
for (const ancestor of ancestorsOf(file.ast, tween)) {
|
|
1785
|
+
if (contains(ancestor, timeline)) return null;
|
|
1786
|
+
if ((ancestor.type === "IfStatement" || ancestor.type === "ConditionalExpression") && contains(ancestor.consequent, inner)) {
|
|
1787
|
+
guards.push(ancestor.test);
|
|
1788
|
+
}
|
|
1789
|
+
inner = ancestor;
|
|
1790
|
+
if (LOOPS.includes(ancestor.type)) {
|
|
1791
|
+
loopNames(ancestor).forEach((n) => varying.add(n));
|
|
1792
|
+
return settle(ancestor.body);
|
|
1793
|
+
}
|
|
1794
|
+
if (!isFunction(ancestor)) continue;
|
|
1795
|
+
paramNames(ancestor).forEach((n) => varying.add(n));
|
|
1796
|
+
if (isLoopCallback(ancestor)) return settle(ancestor.body);
|
|
1797
|
+
|
|
1798
|
+
const holder = parentOf(file.ast, ancestor);
|
|
1799
|
+
const name =
|
|
1800
|
+
holder?.type === "VariableDeclarator" && holder.id.type === "Identifier"
|
|
1801
|
+
? holder.id.name
|
|
1802
|
+
: ancestor.type === "FunctionDeclaration"
|
|
1803
|
+
? ancestor.id?.name
|
|
1804
|
+
: null;
|
|
1805
|
+
if (!name) return null;
|
|
1806
|
+
const calledInLoop = findAll(
|
|
1807
|
+
file.ast,
|
|
1808
|
+
(n) =>
|
|
1809
|
+
n.type === "CallExpression" &&
|
|
1810
|
+
unwrap(n.callee)?.type === "Identifier" &&
|
|
1811
|
+
unwrap(n.callee).name === name &&
|
|
1812
|
+
inLoop(n),
|
|
1813
|
+
).length > 0;
|
|
1814
|
+
return calledInLoop ? varying : null;
|
|
1815
|
+
}
|
|
1816
|
+
return null;
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* The properties a tween's start state sets, or null when they cannot be
|
|
1821
|
+
* read. "Last one wins" is per property: two from-tweens on one element
|
|
1822
|
+
* that set different properties hide nothing, and a real project relied on
|
|
1823
|
+
* exactly that — a line flying in on `opacity` and `y` while a second tween
|
|
1824
|
+
* turned it on `rotationY`. `autoAlpha` sets opacity, so they count as one.
|
|
1825
|
+
*/
|
|
1826
|
+
const CONFIG = new Set([
|
|
1827
|
+
"duration", "delay", "ease", "stagger", "repeat", "yoyo", "repeatDelay",
|
|
1828
|
+
"immediateRender", "overwrite", "paused", "id", "data", "callbackScope",
|
|
1829
|
+
"onStart", "onUpdate", "onComplete", "onRepeat", "onReverseComplete", "scrollTrigger",
|
|
1830
|
+
]);
|
|
1831
|
+
const startKeys = (tween) => {
|
|
1832
|
+
const vars = unwrap(tween.arguments[1]);
|
|
1833
|
+
if (vars?.type !== "ObjectExpression") return null;
|
|
1834
|
+
if (vars.properties.some((p) => p.type !== "Property" || p.computed)) return null;
|
|
1835
|
+
return new Set(
|
|
1836
|
+
vars.properties
|
|
1837
|
+
.map((p) => keyName(p))
|
|
1838
|
+
.filter((key) => key && !CONFIG.has(key))
|
|
1839
|
+
.map((key) => (key === "autoAlpha" ? "opacity" : key)),
|
|
1840
|
+
);
|
|
1841
|
+
};
|
|
1842
|
+
const overlap = (a, b) => {
|
|
1843
|
+
if (!a || !b) return false;
|
|
1844
|
+
for (const key of a) if (b.has(key)) return true;
|
|
1845
|
+
return false;
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* Two calls that cannot both run: opposite branches of one `if` or one
|
|
1850
|
+
* ternary, or two cases of one `switch`. A real project built a fade-in in
|
|
1851
|
+
* the `if` and a fade-out in the `else` against the same element, and only
|
|
1852
|
+
* ever ran one of them.
|
|
1853
|
+
*/
|
|
1854
|
+
const exclusive = (a, b) => {
|
|
1855
|
+
const upB = new Set(ancestorsOf(file.ast, b));
|
|
1856
|
+
for (const node of ancestorsOf(file.ast, a)) {
|
|
1857
|
+
if (!upB.has(node)) continue;
|
|
1858
|
+
if ((node.type === "IfStatement" || node.type === "ConditionalExpression") && node.alternate) {
|
|
1859
|
+
if (contains(node.alternate, a) !== contains(node.alternate, b)) return true;
|
|
1860
|
+
}
|
|
1861
|
+
if (node.type === "SwitchStatement") {
|
|
1862
|
+
const caseOf = (n) => node.cases.find((c) => contains(c, n));
|
|
1863
|
+
if (caseOf(a) && caseOf(a) !== caseOf(b)) return true;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
return false;
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
const mentions = (text, names) =>
|
|
1870
|
+
[...names].some((name) => new RegExp(`(^|[^\\w$])${name.replace(/\$/g, "\\$")}([^\\w$]|$)`).test(text));
|
|
1871
|
+
|
|
1872
|
+
const findings = [];
|
|
1873
|
+
const timelines = findAll(file.ast, (node) => calleeName(node) === "gsap.timeline");
|
|
1874
|
+
for (const timeline of timelines) {
|
|
1875
|
+
const tweens = timelineLinks(file.ast, timeline).filter(
|
|
1876
|
+
(call) => ["from", "fromTo"].includes(methodName(call)) && call.arguments[0] && renders(call),
|
|
1877
|
+
);
|
|
1878
|
+
const reported = new Set();
|
|
1879
|
+
/** Earlier from-tweens on each target, to compare a later one against. */
|
|
1880
|
+
const earlier = new Map();
|
|
1881
|
+
|
|
1882
|
+
for (const tween of tweens) {
|
|
1883
|
+
const target = unwrap(tween.arguments[0]);
|
|
1884
|
+
const text = file.raw.slice(target.start, target.end).replace(/\s+/g, "");
|
|
1885
|
+
if (reported.has(text)) continue;
|
|
1886
|
+
|
|
1887
|
+
/** One call site built repeatedly always shares its own properties. */
|
|
1888
|
+
const varying = repetition(tween, timeline);
|
|
1889
|
+
const repeatedInPlace = varying !== null && !mentions(text, varying);
|
|
1890
|
+
|
|
1891
|
+
const keys = startKeys(tween);
|
|
1892
|
+
const before = earlier.get(text) ?? [];
|
|
1893
|
+
const writtenTwice = before.some(
|
|
1894
|
+
(other) => !exclusive(other.tween, tween) && overlap(other.keys, keys),
|
|
1895
|
+
);
|
|
1896
|
+
earlier.set(text, [...before, { tween, keys }]);
|
|
1897
|
+
|
|
1898
|
+
if (repeatedInPlace || writtenTwice) {
|
|
1899
|
+
reported.add(text);
|
|
1900
|
+
findings.push({
|
|
1901
|
+
/**
|
|
1902
|
+
* The method's own position, not the call's. A chained call starts
|
|
1903
|
+
* where its chain starts, so `tween.start` pointed a real report 25
|
|
1904
|
+
* lines above the `fromTo` it was about.
|
|
1905
|
+
*/
|
|
1906
|
+
index: unwrap(tween.callee)?.property?.start ?? tween.start,
|
|
1907
|
+
message: `\`${text}\` gets a \`${methodName(tween)}\` more than once on this timeline, and each applies its start state as soon as it is built.`,
|
|
1908
|
+
hint: HINT.stackedFrom,
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
return findings;
|
|
1914
|
+
},
|
|
1915
|
+
},
|
|
1601
1916
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehshekari/eslint-plugin-gsap-motion",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.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",
|