@mehshekari/eslint-plugin-gsap-motion 4.5.0 → 4.6.1

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/lib/ast.mjs CHANGED
@@ -602,10 +602,56 @@ export function gsapContexts(root) {
602
602
  functions.set(name, node.type === "FunctionDeclaration" ? node : unwrap(node.init));
603
603
  }
604
604
 
605
+ /**
606
+ * Whether a function nested in a context body runs while the body runs, and
607
+ * so inside the context. GSAP sets the context only for the duration of the
608
+ * body (Context.add, gsap-core 3.15): a function the body hands to a listener,
609
+ * a timer, a promise or a ref runs later, outside it, and nothing it creates
610
+ * is reverted. A real project built a tween in `pulse.current = () => …`
611
+ * inside useGSAP and called it from pointermove; the audit read it as inside.
612
+ *
613
+ * In place: called right there, an array method's callback, GSAP's own
614
+ * callbacks (`on…` keys, `.add`, `.call`, which GSAP runs in the context),
615
+ * the cleanup the body returns, or a named function only ever called.
616
+ */
617
+ const ITERATORS = new Set(["forEach", "map", "filter", "flatMap", "reduce", "reduceRight", "some", "every", "find", "findIndex", "sort"]);
618
+ const runsInPlace = (fn) => {
619
+ const parent = parentOf(root, fn);
620
+ if (!parent) return false;
621
+ if (parent.type === "CallExpression") {
622
+ if (unwrap(parent.callee) === fn) return true;
623
+ const name = methodName(parent) ?? bareName(parent);
624
+ return ITERATORS.has(name) || name === "add" || name === "call" || isHook(parent) || safe.has(name) || calleeName(parent) === "gsap.context";
625
+ }
626
+ if (parent.type === "Property") return /^on[A-Z]/.test(keyName(parent) ?? "");
627
+ if (parent.type === "ReturnStatement" || (parent.type === "ArrowFunctionExpression" && parent.body === fn)) return true;
628
+ const bound =
629
+ parent.type === "VariableDeclarator" && parent.id.type === "Identifier" && unwrap(parent.init) === fn
630
+ ? parent.id.name
631
+ : fn.type === "FunctionDeclaration"
632
+ ? fn.id?.name
633
+ : null;
634
+ if (!bound) return false;
635
+ const uses = findAll(root, (n) => n.type === "Identifier" && n.name === bound && isReference(root, n));
636
+ return uses.every((id) => {
637
+ const call = parentOf(root, id);
638
+ return call?.type === "CallExpression" && call.callee === id;
639
+ });
640
+ };
641
+ /** Inside `scope`, with no function between that runs later. */
642
+ const runsWithin = (scope, node) => {
643
+ if (!contains(scope, node)) return false;
644
+ for (const ancestor of ancestorsOf(root, node)) {
645
+ if (ancestor === scope) return true;
646
+ if (isFunction(ancestor) && !runsInPlace(ancestor)) return false;
647
+ }
648
+ return false;
649
+ };
650
+
605
651
  const contained = new Set();
606
652
  const inside = (node) =>
607
- scopes.some((argument) => contains(argument, node)) ||
608
- [...contained].some((fn) => contains(fn, node));
653
+ scopes.some((argument) => runsWithin(argument, node)) ||
654
+ [...contained].some((fn) => runsWithin(fn, node));
609
655
  const identifiers = findAll(root, (node) => node.type === "Identifier");
610
656
 
611
657
  for (let changed = true; changed; ) {
package/lib/rules.mjs CHANGED
@@ -54,7 +54,7 @@ const HINT = {
54
54
  stackedFrom:
55
55
  "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`.",
56
56
  orphanTween:
57
- "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. Note that contextSafe keeps every tween it creates until the context reverts: a handler that runs often should replay a timeline built once, or call a quickTo.",
57
+ "Nothing reverts it on unmount, and React StrictMode may run 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. Note that contextSafe keeps every tween it creates until the context reverts: a handler that runs often should replay a timeline built once, or call a quickTo.",
58
58
  unmanagedInstance:
59
59
  "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.",
60
60
  unrevertedContext:
@@ -250,7 +250,32 @@ function listenerMethod(node) {
250
250
  * `followNames`, a handler passed by name is followed to its declaration when
251
251
  * the file declares exactly one function of that name.
252
252
  */
253
- function handlersFor(ast, events, { followNames = true, perFrame = false } = {}) {
253
+ function handlersFor(ast, events, options = {}) {
254
+ const direct = directHandlers(ast, events, options);
255
+ /**
256
+ * One hop through a ref: a handler that calls `pulse.current()`, where the
257
+ * file assigns `pulse.current = () => { … }`. A real project kept a tween on
258
+ * a ref and called it from a pointermove listener, and the rule read the
259
+ * listener as clean.
260
+ */
261
+ const viaRef = direct.flatMap((h) =>
262
+ findAll(h.node, (n) => n.type === "CallExpression" && unwrap(n.callee)?.type === "MemberExpression")
263
+ .map((call) => dottedName(unwrap(call.callee)))
264
+ .filter((name) => name?.endsWith(".current"))
265
+ .flatMap((name) =>
266
+ findAll(
267
+ ast,
268
+ (n) =>
269
+ n.type === "AssignmentExpression" &&
270
+ dottedName(unwrap(n.left)) === name &&
271
+ isFunction(unwrap(n.right)),
272
+ ).map((n) => ({ name: h.name, node: unwrap(n.right) })),
273
+ ),
274
+ );
275
+ return [...direct, ...viaRef];
276
+ }
277
+
278
+ function directHandlers(ast, events, { followNames = true, perFrame = false } = {}) {
254
279
  const handler = (expression) => {
255
280
  const node = unwrap(expression);
256
281
  if (node?.type === "CallExpression") return node;
@@ -397,6 +422,28 @@ const readsRef = (node) =>
397
422
  n.property.name === "current",
398
423
  ).length > 0;
399
424
 
425
+ /**
426
+ * Whether a file holds a reduced-motion branch: the query written in code, or
427
+ * asked by name — `useReducedMotion()`, `prefersReducedMotion()`,
428
+ * `isReduced()`, `matchMedia(REDUCED_MOTION_QUERY)` — since it is often written
429
+ * once, in a shared module. "Reduced", not "reduce": an array's `.reduce()` is
430
+ * not one. A comment that mentions it is not a branch.
431
+ */
432
+ function hasReducedBranch(file) {
433
+ if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) return true;
434
+ return (
435
+ findAll(file.ast, (node) => {
436
+ if (node.type !== "CallExpression") return false;
437
+ const name = calleeName(node)?.split(".").at(-1) ?? "";
438
+ if (/reduced/i.test(name)) return true;
439
+ return (
440
+ name === "matchMedia" &&
441
+ node.arguments.some((arg) => arg.type === "Identifier" && /reduced/i.test(arg.name))
442
+ );
443
+ }).length > 0
444
+ );
445
+ }
446
+
400
447
  /** The calls that add a child to a timeline. */
401
448
  const CHILDREN = new Set(["to", "from", "fromTo", "add"]);
402
449
 
@@ -1841,25 +1888,20 @@ export const RULES = [
1841
1888
  */
1842
1889
  test(file) {
1843
1890
  if (!file.usesGsap) return [];
1844
- if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
1845
- return [];
1846
- }
1891
+ if (hasReducedBranch(file)) return [];
1847
1892
  /**
1848
- * The query is often written once, in a shared module, and asked by name:
1849
- * `useReducedMotion()`, `prefersReducedMotion()`, `isReduced()`, or
1850
- * `matchMedia(REDUCED_MOTION_QUERY)`. That is a branch too. "Reduced",
1851
- * not "reduce", so an array's `.reduce()` is not one.
1893
+ * Every caller decides instead: each file that imports this one holds a
1894
+ * branch. Three projects reported a helper flagged while its only caller
1895
+ * returned early under reduced motion. Only the full audit knows the
1896
+ * importers; the editor and the write hook see one file, and report it.
1852
1897
  */
1853
- const asksByName = findAll(file.ast, (node) => {
1854
- if (node.type !== "CallExpression") return false;
1855
- const name = calleeName(node)?.split(".").at(-1) ?? "";
1856
- if (/reduced/i.test(name)) return true;
1857
- return (
1858
- name === "matchMedia" &&
1859
- node.arguments.some((arg) => arg.type === "Identifier" && /reduced/i.test(arg.name))
1860
- );
1861
- });
1862
- if (asksByName.length) return [];
1898
+ if (
1899
+ file.importers?.length &&
1900
+ !file.importersUnknown &&
1901
+ file.importers.every((importer) => !importer.parseError && hasReducedBranch(importer))
1902
+ ) {
1903
+ return [];
1904
+ }
1863
1905
 
1864
1906
  const [first] = findAll(file.ast, (node) => {
1865
1907
  const method = tweenMethod(node);
package/lib/source.mjs CHANGED
@@ -249,7 +249,14 @@ export function fromText(raw, path, display = path) {
249
249
  */
250
250
  usesGsap:
251
251
  /\bfrom ["']gsap(?:\/|["'])/.test(code) ||
252
- /\bimport\s+(?!type\b)(?:gsap\b|\{[^}]*\bgsap\b[^}]*\})[^;]*?\bfrom\s*["']/.test(code),
252
+ /\bimport\s+(?!type\b)(?:gsap\b|\{[^}]*\bgsap\b[^}]*\})[^;]*?\bfrom\s*["']/.test(code) ||
253
+ /**
254
+ * Loaded on demand: `loadGsap().then(({ gsap }) => …)` or
255
+ * `const { gsap } = await import(…)`. A real project's brand loader did
256
+ * this, and was the one file in eleven the lifecycle rules skipped.
257
+ */
258
+ /\(\s*\{[^}]*\bgsap\b[^}]*\}\s*\)\s*=>/.test(code) ||
259
+ /\{[^}]*\bgsap\b[^}]*\}\s*=\s*await\s+import\s*\(/.test(code),
253
260
  };
254
261
  }
255
262
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/eslint-plugin-gsap-motion",
3
- "version": "4.5.0",
3
+ "version": "4.6.1",
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",