@mehshekari/eslint-plugin-gsap-motion 4.3.0 → 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 +2 -0
- package/lib/ast.mjs +58 -9
- package/lib/rules.mjs +134 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,6 +90,8 @@ 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 |
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
@@ -61,6 +61,10 @@ const HINT = {
|
|
|
61
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.",
|
|
62
62
|
tweenPerFrame:
|
|
63
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.",
|
|
64
68
|
paintProperty:
|
|
65
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.",
|
|
66
70
|
unownedLoop:
|
|
@@ -104,6 +108,7 @@ const HINT = {
|
|
|
104
108
|
const MESSAGE = {
|
|
105
109
|
easedLoop: "An infinite repeat with an ease other than `none`.",
|
|
106
110
|
tweenPerFrame: "A tween is created here on every frame.",
|
|
111
|
+
layoutReadPerFrame: "Layout is read here on every frame.",
|
|
107
112
|
unownedLoop: "An infinite repeat that nothing pauses.",
|
|
108
113
|
ungatedHover: "A hover animation with no `(hover: hover)` gate.",
|
|
109
114
|
delayChain: "Three or more tweens in this scope are sequenced by `delay`.",
|
|
@@ -173,10 +178,19 @@ function layoutHint(property, percent) {
|
|
|
173
178
|
const swap = TRANSFORM_FOR[property];
|
|
174
179
|
return swap
|
|
175
180
|
? `Use \`${swap}\` instead — a compositor-only property.` +
|
|
181
|
+
(property === "height" ? ` ${HEIGHT_CAVEAT}` : "") +
|
|
176
182
|
(percent ? ` ${PERCENT_CAVEAT}` : "")
|
|
177
183
|
: "Prefer transform and opacity; they never touch layout.";
|
|
178
184
|
}
|
|
179
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
|
+
|
|
180
194
|
/** Whether a transform's from-value, as written, leaves the element untransformed. */
|
|
181
195
|
function identityTransform(property, value) {
|
|
182
196
|
const v = value.trim().replace(/^["'`]|["'`]$/g, "");
|
|
@@ -589,6 +603,36 @@ const PAINT_PROPERTIES = new Set([
|
|
|
589
603
|
"boxShadow",
|
|
590
604
|
]);
|
|
591
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
|
+
|
|
592
636
|
const HOVER_EVENTS = new Set([
|
|
593
637
|
"pointerenter",
|
|
594
638
|
"pointerover",
|
|
@@ -1009,7 +1053,7 @@ export const RULES = [
|
|
|
1009
1053
|
return findAll(file.ast, animates).flatMap(
|
|
1010
1054
|
(call) => {
|
|
1011
1055
|
const seen = new Set();
|
|
1012
|
-
return varsObjects(call, tweenMethod(call))
|
|
1056
|
+
return varsObjects(call, tweenMethod(call), file.ast)
|
|
1013
1057
|
.flatMap((vars) => vars.properties)
|
|
1014
1058
|
.filter((property) => {
|
|
1015
1059
|
const key = keyName(property);
|
|
@@ -1087,7 +1131,7 @@ export const RULES = [
|
|
|
1087
1131
|
test(file) {
|
|
1088
1132
|
return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap(
|
|
1089
1133
|
(call) => {
|
|
1090
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1134
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1091
1135
|
if (!vars) return [];
|
|
1092
1136
|
const repeat = propertyOf(vars, "repeat");
|
|
1093
1137
|
if (!repeat || numberValue(repeat.value) !== -1) return [];
|
|
@@ -1401,6 +1445,91 @@ export const RULES = [
|
|
|
1401
1445
|
return findings;
|
|
1402
1446
|
},
|
|
1403
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
|
+
},
|
|
1404
1533
|
{
|
|
1405
1534
|
id: "paint-property",
|
|
1406
1535
|
level: "warn",
|
|
@@ -1415,7 +1544,7 @@ export const RULES = [
|
|
|
1415
1544
|
|
|
1416
1545
|
return findAll(file.ast, animates).flatMap((call) => {
|
|
1417
1546
|
const seen = new Set();
|
|
1418
|
-
return varsObjects(call, tweenMethod(call))
|
|
1547
|
+
return varsObjects(call, tweenMethod(call), file.ast)
|
|
1419
1548
|
.flatMap((vars) => vars.properties)
|
|
1420
1549
|
.filter((property) => {
|
|
1421
1550
|
const key = keyName(property);
|
|
@@ -1462,7 +1591,7 @@ export const RULES = [
|
|
|
1462
1591
|
|
|
1463
1592
|
return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap((call) => {
|
|
1464
1593
|
if (inside(call)) return [];
|
|
1465
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1594
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1466
1595
|
if (!vars) return [];
|
|
1467
1596
|
const repeat = propertyOf(vars, "repeat");
|
|
1468
1597
|
if (!repeat || numberValue(repeat.value) !== -1) return [];
|
|
@@ -1518,7 +1647,7 @@ export const RULES = [
|
|
|
1518
1647
|
|
|
1519
1648
|
for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
|
|
1520
1649
|
if (chainStart(call) !== call) continue;
|
|
1521
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1650
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1522
1651
|
const delay = vars && propertyOf(vars, "delay");
|
|
1523
1652
|
if (!delay || !(numberValue(delay.value) > 0)) continue;
|
|
1524
1653
|
const scope = scopeOf(file.ast, call) ?? file.ast;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehshekari/eslint-plugin-gsap-motion",
|
|
3
|
-
"version": "4.
|
|
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",
|