@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 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 = /\.[mc]?ts$/.test(path) ? TYPESCRIPT : TYPESCRIPT_JSX;
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, { followNames: false }).flatMap(
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: `React state setter inside a ${handler.name} handler.`,
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([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
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 code = stripComments(raw);
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(raw, path), error: null };
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.1.1",
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",