@mehshekari/eslint-plugin-gsap-motion 3.1.1 → 3.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 +6 -0
- package/lib/ast.mjs +7 -1
- package/lib/rules.mjs +418 -13
- package/lib/source.mjs +51 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,7 @@ so a finding waived that way still fails `gsap-motion audit`.
|
|
|
75
75
|
| `orphan-tween` | error | A tween created outside `useGSAP`, `gsap.context` or `contextSafe` in React — never reverted, and doubled by StrictMode |
|
|
76
76
|
| `unmanaged-instance` | error | `matchMedia`, `Observer`, `Draggable`, `ScrollSmoother` or `SplitText` created outside a context and never torn down |
|
|
77
77
|
| `tween-per-event` | error | A new tween allocated on every pointer, scroll or wheel event |
|
|
78
|
+
| `unreverted-context` | warn | A `gsap.context` created on mount — `onMounted`, `onMount`, `useEffect`, `astro:page-load` — that nothing reverts |
|
|
78
79
|
| `state-per-event` | error | React state set in a high-frequency handler — a re-render per frame |
|
|
79
80
|
| `shared-plugin-id` | error | A hardcoded `#id` in MotionPath or MorphSVG config, which two instances of a component will share |
|
|
80
81
|
| `unregistered-plugin` | error | A plugin imported and never registered, whose properties are silently ignored |
|
|
@@ -88,6 +89,11 @@ so a finding waived that way still fails `gsap-motion audit`.
|
|
|
88
89
|
| `late-transform-origin` | warn | A `fromTo` whose origin is only in its to-vars while its from-vars scale, rotate or skew, which leaves an SVG element offset |
|
|
89
90
|
| `missing-reduced-motion` | warn | An animating file with no `prefers-reduced-motion` branch |
|
|
90
91
|
| `barrel-import` | warn | Importing from `gsap/all`, which pulls in every plugin |
|
|
92
|
+
| `tween-per-frame` | warn | A tween created every frame — in `onUpdate`, the ticker, an Observer callback, `useFrame` or a `requestAnimationFrame` loop |
|
|
93
|
+
| `paint-property` | warn | Animating `filter`, `backdropFilter` or `boxShadow`, which repaints the element every frame |
|
|
94
|
+
| `ungated-hover` | warn | A hover animation with no `(hover: hover)` gate, which a tap starts and nothing ends |
|
|
95
|
+
| `delay-chain` | warn | Three or more tweens in one scope sequenced by `delay`, which is a timeline nobody can retime |
|
|
96
|
+
| `unowned-loop` | info | An infinite repeat that nothing pauses, which keeps the ticker busy off screen |
|
|
91
97
|
| `not-parsed` | warn | A file that uses GSAP and that the audit's parser could not read, so no other rule checked it |
|
|
92
98
|
|
|
93
99
|
`not-parsed` is an info finding in the command line; ESLint has no info level.
|
package/lib/ast.mjs
CHANGED
|
@@ -21,10 +21,16 @@ const OPTIONS = {
|
|
|
21
21
|
* `.ts` is read without JSX, because there `<T>(x) => x` is a generic and
|
|
22
22
|
* `<Type>value` a cast; every other extension is read with it. A `.js` file
|
|
23
23
|
* with JSX is common enough, and JSX never changes how plain JavaScript parses.
|
|
24
|
+
*
|
|
25
|
+
* A single-file component's `<script>` is read without JSX too: it is
|
|
26
|
+
* TypeScript as often as not, and the template where the angle brackets live is
|
|
27
|
+
* blanked out before it reaches here.
|
|
24
28
|
*/
|
|
25
29
|
const TYPESCRIPT = Parser.extend(tsPlugin());
|
|
26
30
|
const TYPESCRIPT_JSX = Parser.extend(tsPlugin({ jsx: true }));
|
|
27
31
|
|
|
32
|
+
const WITHOUT_JSX = /\.(?:[mc]?ts|vue|svelte|astro)$/;
|
|
33
|
+
|
|
28
34
|
/**
|
|
29
35
|
* Parses `source` as the grammar its `path` implies.
|
|
30
36
|
*
|
|
@@ -34,7 +40,7 @@ const TYPESCRIPT_JSX = Parser.extend(tsPlugin({ jsx: true }));
|
|
|
34
40
|
*/
|
|
35
41
|
export function parse(source, path) {
|
|
36
42
|
const comments = [];
|
|
37
|
-
const parser =
|
|
43
|
+
const parser = WITHOUT_JSX.test(path) ? TYPESCRIPT : TYPESCRIPT_JSX;
|
|
38
44
|
const ast = parser.parse(source, { ...OPTIONS, onComment: comments });
|
|
39
45
|
return { ast, comments };
|
|
40
46
|
}
|
package/lib/rules.mjs
CHANGED
|
@@ -51,6 +51,18 @@ const HINT = {
|
|
|
51
51
|
"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
52
|
unmanagedInstance:
|
|
53
53
|
"Inside a context these register themselves and are reverted with it. Out here nothing is watching: move it into the useGSAP body, or keep the instance and revert/kill it from the cleanup.",
|
|
54
|
+
unrevertedContext:
|
|
55
|
+
"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.",
|
|
56
|
+
tweenPerFrame:
|
|
57
|
+
"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.",
|
|
58
|
+
paintProperty:
|
|
59
|
+
"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.",
|
|
60
|
+
unownedLoop:
|
|
61
|
+
"An infinite repeat keeps the ticker busy while it is off screen. Pause it from an IntersectionObserver, or let a ScrollTrigger's `onToggle` own it, so it costs nothing when nobody can see it.",
|
|
62
|
+
ungatedHover:
|
|
63
|
+
"A tap fires `mouseenter` on a touch device and nothing fires the leave, so the effect sticks. Gate it with `gsap.matchMedia()` on `(hover: hover)`, and decide what a coarse pointer gets instead.",
|
|
64
|
+
delayChain:
|
|
65
|
+
"Delays are a choreography nobody can change: moving one beat means re-adding every number after it. Build a timeline and position each beat with a label or a relative offset — `\"<\"`, `\"-=0.2\"`.",
|
|
54
66
|
danglingListener:
|
|
55
67
|
"A listener that survives unmount keeps the component's closure — and its DOM nodes — alive. Name the handler and remove it in the cleanup, or pass `{ signal }` from an AbortController and abort it there.",
|
|
56
68
|
tweenPerEvent:
|
|
@@ -85,6 +97,10 @@ const HINT = {
|
|
|
85
97
|
/** Messages that do not vary with what was found. */
|
|
86
98
|
const MESSAGE = {
|
|
87
99
|
easedLoop: "An infinite repeat with an ease other than `none`.",
|
|
100
|
+
tweenPerFrame: "A tween is created here on every frame.",
|
|
101
|
+
unownedLoop: "An infinite repeat that nothing pauses.",
|
|
102
|
+
ungatedHover: "A hover animation with no `(hover: hover)` gate.",
|
|
103
|
+
delayChain: "Three or more tweens in this scope are sequenced by `delay`.",
|
|
88
104
|
easedScrub: "Easing inside a scrubbed ScrollTrigger timeline.",
|
|
89
105
|
neverCompletesChild:
|
|
90
106
|
"An endlessly repeating child in a timeline with an `onComplete`, which therefore never fires.",
|
|
@@ -212,7 +228,7 @@ function listenerMethod(node) {
|
|
|
212
228
|
* `followNames`, a handler passed by name is followed to its declaration when
|
|
213
229
|
* the file declares exactly one function of that name.
|
|
214
230
|
*/
|
|
215
|
-
function handlersFor(ast, events, { followNames = true } = {}) {
|
|
231
|
+
function handlersFor(ast, events, { followNames = true, perFrame = false } = {}) {
|
|
216
232
|
const handler = (expression) => {
|
|
217
233
|
const node = unwrap(expression);
|
|
218
234
|
if (node?.type === "CallExpression") return node;
|
|
@@ -242,6 +258,17 @@ function handlersFor(ast, events, { followNames = true } = {}) {
|
|
|
242
258
|
if (node) handlers.push({ name: `on${prop[1]}`, node });
|
|
243
259
|
}
|
|
244
260
|
|
|
261
|
+
/**
|
|
262
|
+
* R3F's `useFrame(callback)` is a per-frame scope of its own: what is inside
|
|
263
|
+
* runs on every rendered frame, whatever the pointer is doing.
|
|
264
|
+
*/
|
|
265
|
+
if (perFrame) {
|
|
266
|
+
for (const call of findAll(ast, (node) => calleeName(node) === "useFrame")) {
|
|
267
|
+
const node = handler(call.arguments[0]);
|
|
268
|
+
if (node) handlers.push({ name: "useFrame", node });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
245
272
|
return handlers;
|
|
246
273
|
}
|
|
247
274
|
|
|
@@ -401,6 +428,13 @@ function notPerEvent(ast, handler, node) {
|
|
|
401
428
|
}
|
|
402
429
|
|
|
403
430
|
/** Listener targets that outlive every component: the window, the document and its root elements. */
|
|
431
|
+
/**
|
|
432
|
+
* Events that fire once and are never removed by anyone: the document's own
|
|
433
|
+
* start-up. The corpus reported two `DOMContentLoaded` handlers in an Astro
|
|
434
|
+
* page as leaks, which is not what a leak is.
|
|
435
|
+
*/
|
|
436
|
+
const ONE_SHOT_EVENTS = new Set(["DOMContentLoaded", "load", "pageshow"]);
|
|
437
|
+
|
|
404
438
|
const GLOBAL_TARGETS = new Set([
|
|
405
439
|
"window",
|
|
406
440
|
"document",
|
|
@@ -493,6 +527,160 @@ export function pluginRegistrations(file) {
|
|
|
493
527
|
return names;
|
|
494
528
|
}
|
|
495
529
|
|
|
530
|
+
/**
|
|
531
|
+
* The mount hooks each adapter uses. A context created in one of them lives as
|
|
532
|
+
* long as the component does, so something has to revert it when the component
|
|
533
|
+
* goes.
|
|
534
|
+
*/
|
|
535
|
+
const MOUNT_HOOKS = new Set([
|
|
536
|
+
"onMounted",
|
|
537
|
+
"onMount",
|
|
538
|
+
"onActivated",
|
|
539
|
+
"useEffect",
|
|
540
|
+
"useLayoutEffect",
|
|
541
|
+
"$effect",
|
|
542
|
+
]);
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Which mount hook `node` was created in, or null. Astro has no hook: its
|
|
546
|
+
* equivalent is a listener for `astro:page-load`, which fires on the first load
|
|
547
|
+
* and after every view transition.
|
|
548
|
+
*/
|
|
549
|
+
function mountHook(ast, node) {
|
|
550
|
+
/** Nearest first, so a function's own parent is the entry after it. */
|
|
551
|
+
const ancestors = ancestorsOf(ast, node);
|
|
552
|
+
|
|
553
|
+
for (let i = 0; i < ancestors.length; i += 1) {
|
|
554
|
+
if (!isFunction(ancestors[i])) continue;
|
|
555
|
+
const parent = ancestors[i + 1];
|
|
556
|
+
if (parent?.type !== "CallExpression") continue;
|
|
557
|
+
|
|
558
|
+
const name = calleeName(parent);
|
|
559
|
+
if (MOUNT_HOOKS.has(name)) return `\`${name}\``;
|
|
560
|
+
if (
|
|
561
|
+
name?.endsWith("addEventListener") &&
|
|
562
|
+
staticString(parent.arguments[0]) === "astro:page-load"
|
|
563
|
+
) {
|
|
564
|
+
return "the `astro:page-load` handler";
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Properties whose animation repaints the element rather than moving a layer
|
|
573
|
+
* the compositor already has.
|
|
574
|
+
*
|
|
575
|
+
* `clipPath` is deliberately absent. An inset or circle reveal is the technique
|
|
576
|
+
* this skill recommends for image reveals, it is cheap on a simple shape, and a
|
|
577
|
+
* rule that fires on its own guidance is noise.
|
|
578
|
+
*/
|
|
579
|
+
const PAINT_PROPERTIES = new Set([
|
|
580
|
+
"filter",
|
|
581
|
+
"webkitFilter",
|
|
582
|
+
"backdropFilter",
|
|
583
|
+
"boxShadow",
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
const HOVER_EVENTS = new Set([
|
|
587
|
+
"pointerenter",
|
|
588
|
+
"pointerover",
|
|
589
|
+
"mouseenter",
|
|
590
|
+
"mouseover",
|
|
591
|
+
]);
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Every function that runs once per frame: the ticker, R3F's `useFrame`, an
|
|
595
|
+
* `onUpdate`/`onMove`/`onChange` callback, and a function that schedules
|
|
596
|
+
* itself with `requestAnimationFrame`.
|
|
597
|
+
*/
|
|
598
|
+
function perFrameScopes(ast) {
|
|
599
|
+
const scopes = [];
|
|
600
|
+
const add = (node) => {
|
|
601
|
+
const fn = node && unwrap(node);
|
|
602
|
+
if (isFunction(fn)) scopes.push(fn);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
for (const call of findAll(ast, (node) => {
|
|
606
|
+
const name = calleeName(node);
|
|
607
|
+
return name === "gsap.ticker.add" || name === "useFrame";
|
|
608
|
+
})) {
|
|
609
|
+
add(call.arguments[0]);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* `onUpdate`, `onMove` and `onChange` are per-frame in a tween, a
|
|
614
|
+
* ScrollTrigger, an Observer or a Draggable — and nowhere else. Every control
|
|
615
|
+
* and form library in the world has an `onChange`, and the corpus duly
|
|
616
|
+
* reported a Three.js `traverse` inside one of them as a per-frame tween.
|
|
617
|
+
*/
|
|
618
|
+
const PER_FRAME_KEYS = new Set(["onUpdate", "onMove", "onChange"]);
|
|
619
|
+
const GSAP_CONFIG = /^(?:gsap\.|ScrollTrigger\.|Observer\.|Draggable\.|ScrollSmoother\.|Flip\.)/;
|
|
620
|
+
|
|
621
|
+
for (const property of findAll(
|
|
622
|
+
ast,
|
|
623
|
+
(node) => node.type === "Property" && PER_FRAME_KEYS.has(keyName(node)),
|
|
624
|
+
)) {
|
|
625
|
+
const owner = ancestorsOf(ast, property).find(
|
|
626
|
+
(node) => node.type === "CallExpression",
|
|
627
|
+
);
|
|
628
|
+
if (!owner) continue;
|
|
629
|
+
const name = calleeName(owner);
|
|
630
|
+
if (tweenMethod(owner) === null && !(name && GSAP_CONFIG.test(name))) continue;
|
|
631
|
+
add(property.value);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const call of findAll(
|
|
635
|
+
ast,
|
|
636
|
+
(node) => calleeName(node) === "requestAnimationFrame",
|
|
637
|
+
)) {
|
|
638
|
+
const fn = resolveFunction(ast, call.arguments[0]);
|
|
639
|
+
if (isFunction(fn) && contains(fn, call)) scopes.push(fn);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return scopes;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The handlers a hover starts: a listener's callback, or a JSX `on*` prop. */
|
|
646
|
+
function hoverHandlers(ast) {
|
|
647
|
+
const handlers = [];
|
|
648
|
+
|
|
649
|
+
for (const call of findAll(
|
|
650
|
+
ast,
|
|
651
|
+
(node) => listenerMethod(node) === "addEventListener",
|
|
652
|
+
)) {
|
|
653
|
+
const event = staticString(call.arguments[0]);
|
|
654
|
+
if (event === null || !HOVER_EVENTS.has(event)) continue;
|
|
655
|
+
const fn = resolveFunction(ast, call.arguments[1]);
|
|
656
|
+
if (isFunction(fn)) handlers.push(fn);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
for (const attribute of findAll(
|
|
660
|
+
ast,
|
|
661
|
+
(node) => node.type === "JSXAttribute" && node.name.type === "JSXIdentifier",
|
|
662
|
+
)) {
|
|
663
|
+
if (!/^on(?:Mouse|Pointer)(?:Enter|Over)$/.test(attribute.name.name)) continue;
|
|
664
|
+
if (attribute.value?.type !== "JSXExpressionContainer") continue;
|
|
665
|
+
const fn = resolveFunction(ast, attribute.value.expression);
|
|
666
|
+
if (isFunction(fn)) handlers.push(fn);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return handlers;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Whether anything in the file pauses, kills or reverts what is kept as `name`. */
|
|
673
|
+
function stopped(ast, name) {
|
|
674
|
+
if (!name) return false;
|
|
675
|
+
return (
|
|
676
|
+
findAll(ast, (node) => {
|
|
677
|
+
const method = methodName(node);
|
|
678
|
+
if (method !== "pause" && !TEARDOWN.has(method)) return false;
|
|
679
|
+
return dottedName(unwrap(node.callee).object) === name;
|
|
680
|
+
}).length > 0
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
496
684
|
export const RULES = [
|
|
497
685
|
// --- Lifecycle ------------------------------------------------------------
|
|
498
686
|
{
|
|
@@ -575,6 +763,40 @@ export const RULES = [
|
|
|
575
763
|
}));
|
|
576
764
|
},
|
|
577
765
|
},
|
|
766
|
+
{
|
|
767
|
+
id: "unreverted-context",
|
|
768
|
+
level: "warn",
|
|
769
|
+
description:
|
|
770
|
+
"A `gsap.context` created on mount — `onMounted`, `onMount`, `useEffect`, `astro:page-load` — that nothing reverts",
|
|
771
|
+
/**
|
|
772
|
+
* Every adapter's teardown rule, in one shape: a context built when the
|
|
773
|
+
* component mounts holds every tween, ScrollTrigger and matchMedia made
|
|
774
|
+
* inside it, and reverting it is the whole cleanup. Nothing reverts it here.
|
|
775
|
+
*
|
|
776
|
+
* Only contexts created in a mount hook are read. A context built in a
|
|
777
|
+
* function the module exports — the vanilla `mountReveal(root)` shape — hands
|
|
778
|
+
* its teardown to the caller, and this file cannot see whether the caller
|
|
779
|
+
* calls it.
|
|
780
|
+
*/
|
|
781
|
+
test(file) {
|
|
782
|
+
const contexts = findAll(
|
|
783
|
+
file.ast,
|
|
784
|
+
(node) => calleeName(node) === "gsap.context",
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
return contexts.flatMap((call) => {
|
|
788
|
+
const hook = mountHook(file.ast, call);
|
|
789
|
+
if (!hook || tornDown(file.ast, call)) return [];
|
|
790
|
+
return [
|
|
791
|
+
{
|
|
792
|
+
index: call.start,
|
|
793
|
+
message: `The \`gsap.context\` created in ${hook} is never reverted.`,
|
|
794
|
+
hint: HINT.unrevertedContext,
|
|
795
|
+
},
|
|
796
|
+
];
|
|
797
|
+
});
|
|
798
|
+
},
|
|
799
|
+
},
|
|
578
800
|
{
|
|
579
801
|
id: "dangling-listener",
|
|
580
802
|
level: "warn",
|
|
@@ -599,6 +821,7 @@ export const RULES = [
|
|
|
599
821
|
const event = staticString(call.arguments[0]);
|
|
600
822
|
const handler = unwrap(call.arguments[1]);
|
|
601
823
|
if (event === null || !/^[\w:-]+$/.test(event) || !handler) return [];
|
|
824
|
+
if (ONE_SHOT_EVENTS.has(event)) return [];
|
|
602
825
|
const options = unwrap(call.arguments[2]);
|
|
603
826
|
const once = unwrap(propertyOf(options, "once")?.value);
|
|
604
827
|
const callee = unwrap(call.callee);
|
|
@@ -699,14 +922,20 @@ export const RULES = [
|
|
|
699
922
|
);
|
|
700
923
|
});
|
|
701
924
|
|
|
702
|
-
return handlersFor(file.ast, STATE_HOT_EVENTS, {
|
|
925
|
+
return handlersFor(file.ast, STATE_HOT_EVENTS, {
|
|
926
|
+
followNames: false,
|
|
927
|
+
perFrame: true,
|
|
928
|
+
}).flatMap(
|
|
703
929
|
(handler) =>
|
|
704
930
|
setters
|
|
705
931
|
.filter((setter) => contains(handler.node, setter))
|
|
706
932
|
.filter((setter) => !debounced(handler, setter))
|
|
707
933
|
.map((setter) => ({
|
|
708
934
|
index: setter.start,
|
|
709
|
-
message:
|
|
935
|
+
message:
|
|
936
|
+
handler.name === "useFrame"
|
|
937
|
+
? "React state setter inside a `useFrame` callback, which runs every frame."
|
|
938
|
+
: `React state setter inside a ${handler.name} handler.`,
|
|
710
939
|
hint: HINT.statePerEvent,
|
|
711
940
|
})),
|
|
712
941
|
);
|
|
@@ -1090,6 +1319,177 @@ export const RULES = [
|
|
|
1090
1319
|
return findings;
|
|
1091
1320
|
},
|
|
1092
1321
|
},
|
|
1322
|
+
{
|
|
1323
|
+
id: "tween-per-frame",
|
|
1324
|
+
level: "warn",
|
|
1325
|
+
description:
|
|
1326
|
+
"A tween created every frame — in `onUpdate`, the ticker, an Observer callback, `useFrame` or a `requestAnimationFrame` loop",
|
|
1327
|
+
/**
|
|
1328
|
+
* `gsap.set` is left out: writing a value straight to an element is what a
|
|
1329
|
+
* per-frame callback is for, and `quickSetter` is the faster shape of the
|
|
1330
|
+
* same thing rather than a different one. What this catches is a tween —
|
|
1331
|
+
* an object with its own duration and ease — being built sixty times a
|
|
1332
|
+
* second, each one overwriting the last before it can finish.
|
|
1333
|
+
*/
|
|
1334
|
+
test(file) {
|
|
1335
|
+
const scopes = perFrameScopes(file.ast);
|
|
1336
|
+
if (scopes.length === 0) return [];
|
|
1337
|
+
|
|
1338
|
+
const tweens = findAll(file.ast, (node) => {
|
|
1339
|
+
const method = tweenMethod(node);
|
|
1340
|
+
return method !== null && method !== "set";
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
const reported = new Set();
|
|
1344
|
+
const findings = [];
|
|
1345
|
+
for (const scope of scopes) {
|
|
1346
|
+
for (const tween of tweens) {
|
|
1347
|
+
if (!contains(scope, tween) || reported.has(tween.start)) continue;
|
|
1348
|
+
reported.add(tween.start);
|
|
1349
|
+
findings.push({
|
|
1350
|
+
index: tween.start,
|
|
1351
|
+
message: MESSAGE.tweenPerFrame,
|
|
1352
|
+
hint: HINT.tweenPerFrame,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return findings;
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
id: "paint-property",
|
|
1361
|
+
level: "warn",
|
|
1362
|
+
description:
|
|
1363
|
+
"Animating `filter`, `backdropFilter` or `boxShadow`, which repaints the element every frame",
|
|
1364
|
+
/** Read exactly as layout-property reads its own keys, and `set` is skipped too. */
|
|
1365
|
+
test(file) {
|
|
1366
|
+
const animates = (node) => {
|
|
1367
|
+
const method = tweenMethod(node);
|
|
1368
|
+
return method !== null && method !== "set";
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
return findAll(file.ast, animates).flatMap((call) => {
|
|
1372
|
+
const seen = new Set();
|
|
1373
|
+
return varsObjects(call, tweenMethod(call))
|
|
1374
|
+
.flatMap((vars) => vars.properties)
|
|
1375
|
+
.filter((property) => {
|
|
1376
|
+
const key = keyName(property);
|
|
1377
|
+
if (!PAINT_PROPERTIES.has(key) || seen.has(key)) return false;
|
|
1378
|
+
seen.add(key);
|
|
1379
|
+
return true;
|
|
1380
|
+
})
|
|
1381
|
+
.map((property) => ({
|
|
1382
|
+
index: property.start,
|
|
1383
|
+
message: `Animating \`${keyName(property)}\` repaints on every frame.`,
|
|
1384
|
+
hint: HINT.paintProperty,
|
|
1385
|
+
}));
|
|
1386
|
+
});
|
|
1387
|
+
},
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
id: "unowned-loop",
|
|
1391
|
+
level: "info",
|
|
1392
|
+
description:
|
|
1393
|
+
"An infinite repeat that nothing pauses, which keeps the ticker busy off screen",
|
|
1394
|
+
/**
|
|
1395
|
+
* Info, because "nothing pauses it" is read from one file and a loop can be
|
|
1396
|
+
* owned from elsewhere. A loop is taken as owned when its name is paused,
|
|
1397
|
+
* killed or reverted anywhere here, when the file builds an
|
|
1398
|
+
* IntersectionObserver, when the tween carries a `scrollTrigger` that
|
|
1399
|
+
* toggles it, or when a context holds it — unmount reverts that one.
|
|
1400
|
+
*
|
|
1401
|
+
* The last of those came from a real site's loader, where three loops sit
|
|
1402
|
+
* in a `useGSAP` body inside `matchMedia`. Reporting them would have meant
|
|
1403
|
+
* "nothing stops this" about an animation the component's own unmount stops.
|
|
1404
|
+
* What is left — a loop running while it is off screen but still mounted —
|
|
1405
|
+
* is a measurement, and 3.4's `inspect` is where that belongs.
|
|
1406
|
+
*/
|
|
1407
|
+
test(file) {
|
|
1408
|
+
const observed = findAll(
|
|
1409
|
+
file.ast,
|
|
1410
|
+
(node) =>
|
|
1411
|
+
node.type === "NewExpression" &&
|
|
1412
|
+
dottedName(node.callee) === "IntersectionObserver",
|
|
1413
|
+
).length > 0;
|
|
1414
|
+
if (observed) return [];
|
|
1415
|
+
|
|
1416
|
+
const inside = gsapContexts(file.ast);
|
|
1417
|
+
|
|
1418
|
+
return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap((call) => {
|
|
1419
|
+
if (inside(call)) return [];
|
|
1420
|
+
const vars = ownVars(call, tweenMethod(call));
|
|
1421
|
+
if (!vars) return [];
|
|
1422
|
+
const repeat = propertyOf(vars, "repeat");
|
|
1423
|
+
if (!repeat || numberValue(repeat.value) !== -1) return [];
|
|
1424
|
+
if (propertyOf(vars, "scrollTrigger")) return [];
|
|
1425
|
+
if (stopped(file.ast, keptUnder(file.ast, call)?.name)) return [];
|
|
1426
|
+
return [
|
|
1427
|
+
{
|
|
1428
|
+
index: repeat.start,
|
|
1429
|
+
message: MESSAGE.unownedLoop,
|
|
1430
|
+
hint: HINT.unownedLoop,
|
|
1431
|
+
},
|
|
1432
|
+
];
|
|
1433
|
+
});
|
|
1434
|
+
},
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
id: "ungated-hover",
|
|
1438
|
+
level: "warn",
|
|
1439
|
+
description:
|
|
1440
|
+
"A hover animation with no `(hover: hover)` gate, which a tap starts and nothing ends",
|
|
1441
|
+
/**
|
|
1442
|
+
* The gate is looked for as text, anywhere in the file: a `matchMedia`
|
|
1443
|
+
* condition, a CSS string, a constant. One finding per handler, on its first
|
|
1444
|
+
* tween, because the handler is what needs the gate.
|
|
1445
|
+
*/
|
|
1446
|
+
test(file) {
|
|
1447
|
+
if (/\(\s*(?:any-)?hover\s*:\s*hover\s*\)/.test(file.code)) return [];
|
|
1448
|
+
|
|
1449
|
+
return hoverHandlers(file.ast).flatMap((handler) => {
|
|
1450
|
+
const [tween] = findAll(file.ast, (node) => tweenMethod(node) !== null).filter(
|
|
1451
|
+
(node) => contains(handler, node),
|
|
1452
|
+
);
|
|
1453
|
+
if (!tween) return [];
|
|
1454
|
+
return [
|
|
1455
|
+
{ index: tween.start, message: MESSAGE.ungatedHover, hint: HINT.ungatedHover },
|
|
1456
|
+
];
|
|
1457
|
+
});
|
|
1458
|
+
},
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
id: "delay-chain",
|
|
1462
|
+
level: "warn",
|
|
1463
|
+
description:
|
|
1464
|
+
"Three or more tweens in one scope sequenced by `delay`, which is a timeline nobody can retime",
|
|
1465
|
+
/**
|
|
1466
|
+
* Three is the threshold because two tweens with delays are a pair, and a
|
|
1467
|
+
* pair is still readable. Counted per scope — the function they share — so
|
|
1468
|
+
* three components each with one delayed tween are not a chain. A tween on a
|
|
1469
|
+
* timeline is not counted: a timeline already has positions.
|
|
1470
|
+
*/
|
|
1471
|
+
test(file) {
|
|
1472
|
+
const scopes = new Map();
|
|
1473
|
+
|
|
1474
|
+
for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
|
|
1475
|
+
if (chainStart(call) !== call) continue;
|
|
1476
|
+
const vars = ownVars(call, tweenMethod(call));
|
|
1477
|
+
const delay = vars && propertyOf(vars, "delay");
|
|
1478
|
+
if (!delay || !(numberValue(delay.value) > 0)) continue;
|
|
1479
|
+
const scope = scopeOf(file.ast, call) ?? file.ast;
|
|
1480
|
+
if (!scopes.has(scope)) scopes.set(scope, []);
|
|
1481
|
+
scopes.get(scope).push(call);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
return [...scopes.values()]
|
|
1485
|
+
.filter((calls) => calls.length >= 3)
|
|
1486
|
+
.map((calls) => ({
|
|
1487
|
+
index: calls[0].start,
|
|
1488
|
+
message: MESSAGE.delayChain,
|
|
1489
|
+
hint: HINT.delayChain,
|
|
1490
|
+
}));
|
|
1491
|
+
},
|
|
1492
|
+
},
|
|
1093
1493
|
{
|
|
1094
1494
|
id: "missing-reduced-motion",
|
|
1095
1495
|
level: "warn",
|
|
@@ -1097,23 +1497,28 @@ export const RULES = [
|
|
|
1097
1497
|
"An animating file with no `prefers-reduced-motion` branch",
|
|
1098
1498
|
/**
|
|
1099
1499
|
* A branch is the query written in code — a string, a template or JSX
|
|
1100
|
-
* text. A comment that mentions it is not a branch.
|
|
1101
|
-
*
|
|
1500
|
+
* text. A comment that mentions it is not a branch.
|
|
1501
|
+
*
|
|
1502
|
+
* Only a file that builds an animation of its own is reported, and the
|
|
1503
|
+
* finding lands on the first one. Three things are not that:
|
|
1504
|
+
* `registerPlugin` and `killTweensOf`, which animate nothing; `set`, which
|
|
1505
|
+
* has no duration; and a method called on a timeline the file was handed,
|
|
1506
|
+
* because a helper that adds beats to its caller's timeline does not own
|
|
1507
|
+
* the reduced-motion decision — the caller does.
|
|
1102
1508
|
*/
|
|
1103
1509
|
test(file) {
|
|
1104
1510
|
if (!file.usesGsap) return [];
|
|
1105
1511
|
if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
|
|
1106
1512
|
return [];
|
|
1107
1513
|
}
|
|
1108
|
-
if (!findAll(file.ast, (node) => isGsapCall(node)).length) return [];
|
|
1109
1514
|
|
|
1110
|
-
const first = findAll(
|
|
1111
|
-
|
|
1112
|
-
(
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1515
|
+
const [first] = findAll(file.ast, (node) => {
|
|
1516
|
+
const method = tweenMethod(node);
|
|
1517
|
+
if (method === null || method === "set") return false;
|
|
1518
|
+
/** Built here, rather than added to something passed in. */
|
|
1519
|
+
return calleeName(node)?.startsWith("gsap.") === true;
|
|
1520
|
+
}).sort((a, b) => a.start - b.start);
|
|
1521
|
+
if (!first) return [];
|
|
1117
1522
|
|
|
1118
1523
|
return [
|
|
1119
1524
|
{
|
package/lib/source.mjs
CHANGED
|
@@ -12,7 +12,19 @@ import { extname, join, relative } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { parse } from "./ast.mjs";
|
|
14
14
|
|
|
15
|
-
const SOURCE_EXTENSIONS = new Set([
|
|
15
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
16
|
+
".ts",
|
|
17
|
+
".tsx",
|
|
18
|
+
".js",
|
|
19
|
+
".jsx",
|
|
20
|
+
".mjs",
|
|
21
|
+
".vue",
|
|
22
|
+
".svelte",
|
|
23
|
+
".astro",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** A single-file component: only its `<script>` blocks are code. */
|
|
27
|
+
const SINGLE_FILE = /\.(?:vue|svelte|astro)$/;
|
|
16
28
|
const SKIP_DIRECTORIES = new Set([
|
|
17
29
|
"node_modules",
|
|
18
30
|
".next",
|
|
@@ -131,6 +143,41 @@ export function lineAt(source, index) {
|
|
|
131
143
|
return line;
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
/**
|
|
147
|
+
* A single-file component with everything outside its `<script>` blocks blanked
|
|
148
|
+
* out, keeping every newline and every offset — so what reaches the parser is
|
|
149
|
+
* JavaScript, and a finding still points at the right line of the `.vue`,
|
|
150
|
+
* `.svelte` or `.astro` file.
|
|
151
|
+
*
|
|
152
|
+
* A `<script>` whose `type` is not JavaScript — JSON-LD, an import map, a
|
|
153
|
+
* template — is blanked with the markup. Astro's `---` frontmatter runs on the
|
|
154
|
+
* server and animates nothing, so it is left out too; the client `<script>` is
|
|
155
|
+
* what this reads.
|
|
156
|
+
*/
|
|
157
|
+
export function scriptsOnly(raw) {
|
|
158
|
+
const blank = (text) => text.replace(/[^\n]/g, " ");
|
|
159
|
+
const JAVASCRIPT = /^(?:module|text\/javascript|application\/javascript|ts|tsx)$/i;
|
|
160
|
+
const open = /<script\b([^>]*)>/gi;
|
|
161
|
+
|
|
162
|
+
let out = "";
|
|
163
|
+
let index = 0;
|
|
164
|
+
|
|
165
|
+
for (let tag = open.exec(raw); tag; tag = open.exec(raw)) {
|
|
166
|
+
const start = tag.index + tag[0].length;
|
|
167
|
+
const close = raw.indexOf("</script", start);
|
|
168
|
+
const end = close === -1 ? raw.length : close;
|
|
169
|
+
const type = tag[1].match(/\btype\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
170
|
+
const code = !type || JAVASCRIPT.test(type.trim());
|
|
171
|
+
|
|
172
|
+
out += blank(raw.slice(index, start));
|
|
173
|
+
out += code ? raw.slice(start, end) : blank(raw.slice(start, end));
|
|
174
|
+
index = end;
|
|
175
|
+
open.lastIndex = end;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return out + blank(raw.slice(index));
|
|
179
|
+
}
|
|
180
|
+
|
|
134
181
|
/**
|
|
135
182
|
* A file plus everything the rules need, from its text.
|
|
136
183
|
*
|
|
@@ -139,7 +186,8 @@ export function lineAt(source, index) {
|
|
|
139
186
|
* points run the rules on the same parse of the same characters.
|
|
140
187
|
*/
|
|
141
188
|
export function fromText(raw, path, display = path) {
|
|
142
|
-
const
|
|
189
|
+
const source = SINGLE_FILE.test(path) ? scriptsOnly(raw) : raw;
|
|
190
|
+
const code = stripComments(source);
|
|
143
191
|
|
|
144
192
|
/**
|
|
145
193
|
* Parsed on first use, and once. Most files in a project never mention GSAP,
|
|
@@ -151,7 +199,7 @@ export function fromText(raw, path, display = path) {
|
|
|
151
199
|
const tree = () => {
|
|
152
200
|
if (parsed === undefined) {
|
|
153
201
|
try {
|
|
154
|
-
parsed = { ...parse(
|
|
202
|
+
parsed = { ...parse(source, path), error: null };
|
|
155
203
|
} catch (error) {
|
|
156
204
|
parsed = {
|
|
157
205
|
ast: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehshekari/eslint-plugin-gsap-motion",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.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",
|