@mehshekari/eslint-plugin-gsap-motion 3.1.1 → 3.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 +1 -0
- package/lib/ast.mjs +7 -1
- package/lib/rules.mjs +97 -3
- 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 |
|
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,8 @@ 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.",
|
|
54
56
|
danglingListener:
|
|
55
57
|
"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
58
|
tweenPerEvent:
|
|
@@ -212,7 +214,7 @@ function listenerMethod(node) {
|
|
|
212
214
|
* `followNames`, a handler passed by name is followed to its declaration when
|
|
213
215
|
* the file declares exactly one function of that name.
|
|
214
216
|
*/
|
|
215
|
-
function handlersFor(ast, events, { followNames = true } = {}) {
|
|
217
|
+
function handlersFor(ast, events, { followNames = true, perFrame = false } = {}) {
|
|
216
218
|
const handler = (expression) => {
|
|
217
219
|
const node = unwrap(expression);
|
|
218
220
|
if (node?.type === "CallExpression") return node;
|
|
@@ -242,6 +244,17 @@ function handlersFor(ast, events, { followNames = true } = {}) {
|
|
|
242
244
|
if (node) handlers.push({ name: `on${prop[1]}`, node });
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
/**
|
|
248
|
+
* R3F's `useFrame(callback)` is a per-frame scope of its own: what is inside
|
|
249
|
+
* runs on every rendered frame, whatever the pointer is doing.
|
|
250
|
+
*/
|
|
251
|
+
if (perFrame) {
|
|
252
|
+
for (const call of findAll(ast, (node) => calleeName(node) === "useFrame")) {
|
|
253
|
+
const node = handler(call.arguments[0]);
|
|
254
|
+
if (node) handlers.push({ name: "useFrame", node });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
245
258
|
return handlers;
|
|
246
259
|
}
|
|
247
260
|
|
|
@@ -493,6 +506,47 @@ export function pluginRegistrations(file) {
|
|
|
493
506
|
return names;
|
|
494
507
|
}
|
|
495
508
|
|
|
509
|
+
/**
|
|
510
|
+
* The mount hooks each adapter uses. A context created in one of them lives as
|
|
511
|
+
* long as the component does, so something has to revert it when the component
|
|
512
|
+
* goes.
|
|
513
|
+
*/
|
|
514
|
+
const MOUNT_HOOKS = new Set([
|
|
515
|
+
"onMounted",
|
|
516
|
+
"onMount",
|
|
517
|
+
"onActivated",
|
|
518
|
+
"useEffect",
|
|
519
|
+
"useLayoutEffect",
|
|
520
|
+
"$effect",
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Which mount hook `node` was created in, or null. Astro has no hook: its
|
|
525
|
+
* equivalent is a listener for `astro:page-load`, which fires on the first load
|
|
526
|
+
* and after every view transition.
|
|
527
|
+
*/
|
|
528
|
+
function mountHook(ast, node) {
|
|
529
|
+
/** Nearest first, so a function's own parent is the entry after it. */
|
|
530
|
+
const ancestors = ancestorsOf(ast, node);
|
|
531
|
+
|
|
532
|
+
for (let i = 0; i < ancestors.length; i += 1) {
|
|
533
|
+
if (!isFunction(ancestors[i])) continue;
|
|
534
|
+
const parent = ancestors[i + 1];
|
|
535
|
+
if (parent?.type !== "CallExpression") continue;
|
|
536
|
+
|
|
537
|
+
const name = calleeName(parent);
|
|
538
|
+
if (MOUNT_HOOKS.has(name)) return `\`${name}\``;
|
|
539
|
+
if (
|
|
540
|
+
name?.endsWith("addEventListener") &&
|
|
541
|
+
staticString(parent.arguments[0]) === "astro:page-load"
|
|
542
|
+
) {
|
|
543
|
+
return "the `astro:page-load` handler";
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
496
550
|
export const RULES = [
|
|
497
551
|
// --- Lifecycle ------------------------------------------------------------
|
|
498
552
|
{
|
|
@@ -575,6 +629,40 @@ export const RULES = [
|
|
|
575
629
|
}));
|
|
576
630
|
},
|
|
577
631
|
},
|
|
632
|
+
{
|
|
633
|
+
id: "unreverted-context",
|
|
634
|
+
level: "warn",
|
|
635
|
+
description:
|
|
636
|
+
"A `gsap.context` created on mount — `onMounted`, `onMount`, `useEffect`, `astro:page-load` — that nothing reverts",
|
|
637
|
+
/**
|
|
638
|
+
* Every adapter's teardown rule, in one shape: a context built when the
|
|
639
|
+
* component mounts holds every tween, ScrollTrigger and matchMedia made
|
|
640
|
+
* inside it, and reverting it is the whole cleanup. Nothing reverts it here.
|
|
641
|
+
*
|
|
642
|
+
* Only contexts created in a mount hook are read. A context built in a
|
|
643
|
+
* function the module exports — the vanilla `mountReveal(root)` shape — hands
|
|
644
|
+
* its teardown to the caller, and this file cannot see whether the caller
|
|
645
|
+
* calls it.
|
|
646
|
+
*/
|
|
647
|
+
test(file) {
|
|
648
|
+
const contexts = findAll(
|
|
649
|
+
file.ast,
|
|
650
|
+
(node) => calleeName(node) === "gsap.context",
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
return contexts.flatMap((call) => {
|
|
654
|
+
const hook = mountHook(file.ast, call);
|
|
655
|
+
if (!hook || tornDown(file.ast, call)) return [];
|
|
656
|
+
return [
|
|
657
|
+
{
|
|
658
|
+
index: call.start,
|
|
659
|
+
message: `The \`gsap.context\` created in ${hook} is never reverted.`,
|
|
660
|
+
hint: HINT.unrevertedContext,
|
|
661
|
+
},
|
|
662
|
+
];
|
|
663
|
+
});
|
|
664
|
+
},
|
|
665
|
+
},
|
|
578
666
|
{
|
|
579
667
|
id: "dangling-listener",
|
|
580
668
|
level: "warn",
|
|
@@ -699,14 +787,20 @@ export const RULES = [
|
|
|
699
787
|
);
|
|
700
788
|
});
|
|
701
789
|
|
|
702
|
-
return handlersFor(file.ast, STATE_HOT_EVENTS, {
|
|
790
|
+
return handlersFor(file.ast, STATE_HOT_EVENTS, {
|
|
791
|
+
followNames: false,
|
|
792
|
+
perFrame: true,
|
|
793
|
+
}).flatMap(
|
|
703
794
|
(handler) =>
|
|
704
795
|
setters
|
|
705
796
|
.filter((setter) => contains(handler.node, setter))
|
|
706
797
|
.filter((setter) => !debounced(handler, setter))
|
|
707
798
|
.map((setter) => ({
|
|
708
799
|
index: setter.start,
|
|
709
|
-
message:
|
|
800
|
+
message:
|
|
801
|
+
handler.name === "useFrame"
|
|
802
|
+
? "React state setter inside a `useFrame` callback, which runs every frame."
|
|
803
|
+
: `React state setter inside a ${handler.name} handler.`,
|
|
710
804
|
hint: HINT.statePerEvent,
|
|
711
805
|
})),
|
|
712
806
|
);
|
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.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",
|