@mehshekari/eslint-plugin-gsap-motion 4.4.2 → 4.6.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 +72 -5
- package/lib/rules.mjs +217 -4
- package/lib/source.mjs +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,7 @@ so a finding waived that way still fails `gsap-motion audit`.
|
|
|
81
81
|
| `unregistered-plugin` | error | A plugin imported and never registered, whose properties are silently ignored |
|
|
82
82
|
| `never-completes` | error | An `onComplete` that can never run: on a timeline holding a `repeat: -1` child, or on a tween or timeline that repeats forever |
|
|
83
83
|
| `dev-tool-shipped` | error | `GSDevTools` or `MotionPathHelper` imported statically, or unconditional `markers: true` |
|
|
84
|
+
| `ref-read-in-cleanup` | warn | `killTweensOf` given a ref's `.current` in a `useEffect` cleanup, where React has already cleared it at unmount |
|
|
84
85
|
| `dangling-listener` | warn | An event listener that is never removed, including inline handlers that cannot be |
|
|
85
86
|
| `layout-property` | warn | Animating `width`, `height`, `top`, `left`, margins or padding, which forces layout every frame |
|
|
86
87
|
| `trigger-per-item` | warn | A ScrollTrigger created per item in a loop |
|
package/lib/ast.mjs
CHANGED
|
@@ -307,7 +307,7 @@ export const contains = (outer, inner) =>
|
|
|
307
307
|
* returns `null`: an ambiguous handler is left unread rather than guessed at.
|
|
308
308
|
*/
|
|
309
309
|
export function resolveFunction(root, node) {
|
|
310
|
-
const target = unwrap(node);
|
|
310
|
+
const target = wrappedFunction(unwrap(node));
|
|
311
311
|
if (isFunction(target)) return target;
|
|
312
312
|
if (target?.type !== "Identifier") return null;
|
|
313
313
|
|
|
@@ -318,13 +318,34 @@ export function resolveFunction(root, node) {
|
|
|
318
318
|
(n.type === "VariableDeclarator" &&
|
|
319
319
|
n.id.type === "Identifier" &&
|
|
320
320
|
n.id.name === target.name &&
|
|
321
|
-
isFunction(unwrap(n.init))),
|
|
321
|
+
isFunction(wrappedFunction(unwrap(n.init)))),
|
|
322
322
|
);
|
|
323
323
|
if (declarations.length !== 1) return null;
|
|
324
324
|
const [declaration] = declarations;
|
|
325
325
|
return declaration.type === "FunctionDeclaration"
|
|
326
326
|
? declaration
|
|
327
|
-
: unwrap(declaration.init);
|
|
327
|
+
: wrappedFunction(unwrap(declaration.init));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The function inside a call that hands it back to run later, unchanged in
|
|
332
|
+
* what it does: `contextSafe(fn)`, `ctx.add(null, fn)` and `useCallback(fn, deps)`.
|
|
333
|
+
* The handler the skill recommends is wrapped in contextSafe, and following
|
|
334
|
+
* names stopped at the wrapper, so a tween per pointer event went unreported.
|
|
335
|
+
* Anything else is returned as it was.
|
|
336
|
+
*/
|
|
337
|
+
function wrappedFunction(node) {
|
|
338
|
+
if (node?.type !== "CallExpression") return node;
|
|
339
|
+
const name = calleeName(node)?.split(".").at(-1);
|
|
340
|
+
if (name === "contextSafe" || name === "useCallback") return unwrap(node.arguments[0]);
|
|
341
|
+
if (
|
|
342
|
+
name === "add" &&
|
|
343
|
+
unwrap(node.arguments[0])?.type === "Literal" &&
|
|
344
|
+
unwrap(node.arguments[0]).value === null
|
|
345
|
+
) {
|
|
346
|
+
return unwrap(node.arguments[1]);
|
|
347
|
+
}
|
|
348
|
+
return node;
|
|
328
349
|
}
|
|
329
350
|
|
|
330
351
|
const PARENTS = new WeakMap();
|
|
@@ -581,10 +602,56 @@ export function gsapContexts(root) {
|
|
|
581
602
|
functions.set(name, node.type === "FunctionDeclaration" ? node : unwrap(node.init));
|
|
582
603
|
}
|
|
583
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
|
+
|
|
584
651
|
const contained = new Set();
|
|
585
652
|
const inside = (node) =>
|
|
586
|
-
scopes.some((argument) =>
|
|
587
|
-
[...contained].some((fn) =>
|
|
653
|
+
scopes.some((argument) => runsWithin(argument, node)) ||
|
|
654
|
+
[...contained].some((fn) => runsWithin(fn, node));
|
|
588
655
|
const identifiers = findAll(root, (node) => node.type === "Identifier");
|
|
589
656
|
|
|
590
657
|
for (let changed = true; changed; ) {
|
package/lib/rules.mjs
CHANGED
|
@@ -54,13 +54,15 @@ 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
|
|
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:
|
|
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
|
+
refReadInCleanup:
|
|
65
|
+
"So nothing is killed, and the tweens outlive the component. Capture the element in the effect body — `const el = ref.current` — and kill through that in the cleanup, or build the tweens in a useGSAP or gsap.context body, whose revert needs no ref.",
|
|
64
66
|
allocationPerFrame:
|
|
65
67
|
"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
68
|
layoutReadPerFrame:
|
|
@@ -96,7 +98,7 @@ const HINT = {
|
|
|
96
98
|
unregisteredPlugin:
|
|
97
99
|
"An unregistered plugin's properties are ignored without an error — the animation simply does nothing.",
|
|
98
100
|
missingReducedMotion:
|
|
99
|
-
"Reduced motion is a design, not an off switch: decide what the motion was saying and say it without the travel. Note that the reduced branch must set the END state — a `from` that never runs leaves its target invisible.",
|
|
101
|
+
"Reduced motion is a design, not an off switch: decide what the motion was saying and say it without the travel. Note that the reduced branch must set the END state — a `from` that never runs leaves its target invisible. If the branch lives in the module that calls this one, waive it here and name that caller.",
|
|
100
102
|
devToolImport:
|
|
101
103
|
"A static import ships regardless of any `if` around the call. Load it with a dynamic `import()` behind a NODE_ENV check, or bake its output in and delete it.",
|
|
102
104
|
markers: 'Gate it: `markers: process.env.NODE_ENV === "development"`.',
|
|
@@ -248,7 +250,32 @@ function listenerMethod(node) {
|
|
|
248
250
|
* `followNames`, a handler passed by name is followed to its declaration when
|
|
249
251
|
* the file declares exactly one function of that name.
|
|
250
252
|
*/
|
|
251
|
-
function handlersFor(ast, events,
|
|
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 } = {}) {
|
|
252
279
|
const handler = (expression) => {
|
|
253
280
|
const node = unwrap(expression);
|
|
254
281
|
if (node?.type === "CallExpression") return node;
|
|
@@ -310,6 +337,113 @@ function debounced(handler, node) {
|
|
|
310
337
|
);
|
|
311
338
|
}
|
|
312
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Whether a tween's target is a local the enclosing effect captured, and that
|
|
342
|
+
* effect's code kills that local's tweens: `const el = ref.current` in the body,
|
|
343
|
+
* `gsap.killTweensOf(el)` in the cleanup. Found on the corpus, where it was
|
|
344
|
+
* reported as an orphan.
|
|
345
|
+
*
|
|
346
|
+
* Only a captured local counts. `killTweensOf(ref.current)` in a passive
|
|
347
|
+
* cleanup kills nothing at unmount — React has already cleared the ref — and
|
|
348
|
+
* that is `ref-read-in-cleanup`'s to say.
|
|
349
|
+
*/
|
|
350
|
+
function killedThroughCapture(ast, node) {
|
|
351
|
+
if (!isGsapCall(node)) return false;
|
|
352
|
+
const target = unwrap(node.arguments[0]);
|
|
353
|
+
if (target?.type !== "Identifier") return false;
|
|
354
|
+
|
|
355
|
+
const effect = ancestorsOf(ast, node).find((fn) => {
|
|
356
|
+
if (!isFunction(fn)) return false;
|
|
357
|
+
const call = parentOf(ast, fn);
|
|
358
|
+
return (
|
|
359
|
+
call?.type === "CallExpression" &&
|
|
360
|
+
call.arguments[0] === fn &&
|
|
361
|
+
EFFECTS.has(calleeName(call)?.split(".").at(-1))
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
if (!effect) return false;
|
|
365
|
+
|
|
366
|
+
const captured = findAll(
|
|
367
|
+
effect,
|
|
368
|
+
(n) => n.type === "VariableDeclarator" && n.id.type === "Identifier" && n.id.name === target.name,
|
|
369
|
+
).length > 0;
|
|
370
|
+
if (!captured) return false;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* In the cleanup — the function the effect returns — and nowhere else. A
|
|
374
|
+
* kill at the top of the body stops the previous run's tweens before
|
|
375
|
+
* starting new ones; it does nothing at unmount. Nine true corpus findings
|
|
376
|
+
* did exactly that, and the first version of this check let them go.
|
|
377
|
+
*/
|
|
378
|
+
const cleanups = cleanupsOf(ast, effect);
|
|
379
|
+
|
|
380
|
+
const names = (arg) => {
|
|
381
|
+
const value = unwrap(arg);
|
|
382
|
+
if (value?.type === "Identifier") return [value.name];
|
|
383
|
+
if (value?.type === "ArrayExpression") return value.elements.flatMap((e) => (e ? names(e) : []));
|
|
384
|
+
return [];
|
|
385
|
+
};
|
|
386
|
+
return cleanups.some(
|
|
387
|
+
(cleanup) =>
|
|
388
|
+
findAll(
|
|
389
|
+
cleanup,
|
|
390
|
+
(n) =>
|
|
391
|
+
n.type === "CallExpression" &&
|
|
392
|
+
calleeName(n)?.endsWith("killTweensOf") &&
|
|
393
|
+
n.arguments.some((arg) => names(arg).includes(target.name)),
|
|
394
|
+
).length > 0,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const EFFECTS = new Set(["useEffect", "useLayoutEffect"]);
|
|
399
|
+
|
|
400
|
+
/** The functions an effect callback returns: its cleanups. */
|
|
401
|
+
function cleanupsOf(ast, effect) {
|
|
402
|
+
const cleanups = findAll(
|
|
403
|
+
effect,
|
|
404
|
+
(n) => n.type === "ReturnStatement" && n.argument && scopeOf(ast, n) === effect,
|
|
405
|
+
)
|
|
406
|
+
.map((n) => unwrap(n.argument))
|
|
407
|
+
.filter(isFunction);
|
|
408
|
+
if (effect.type === "ArrowFunctionExpression" && effect.expression && isFunction(unwrap(effect.body))) {
|
|
409
|
+
cleanups.push(unwrap(effect.body));
|
|
410
|
+
}
|
|
411
|
+
return cleanups;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Whether `node` reads a ref: a `.current` anywhere inside it. */
|
|
415
|
+
const readsRef = (node) =>
|
|
416
|
+
findAll(
|
|
417
|
+
node,
|
|
418
|
+
(n) =>
|
|
419
|
+
n.type === "MemberExpression" &&
|
|
420
|
+
!n.computed &&
|
|
421
|
+
n.property.type === "Identifier" &&
|
|
422
|
+
n.property.name === "current",
|
|
423
|
+
).length > 0;
|
|
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
|
+
|
|
313
447
|
/** The calls that add a child to a timeline. */
|
|
314
448
|
const CHILDREN = new Set(["to", "from", "fromTo", "add"]);
|
|
315
449
|
|
|
@@ -341,6 +475,22 @@ function tornDown(ast, node) {
|
|
|
341
475
|
const kept = keptUnder(ast, node);
|
|
342
476
|
if (endsUnder(kept?.name)) return true;
|
|
343
477
|
|
|
478
|
+
/**
|
|
479
|
+
* A second name for it: `active = timeline`, then `active?.kill()` in the
|
|
480
|
+
* cleanup. Found on the corpus, in a preview whose timeline was built in a
|
|
481
|
+
* helper and handed to a variable the effect owns.
|
|
482
|
+
*/
|
|
483
|
+
if (kept?.name) {
|
|
484
|
+
const aliases = findAll(ast, (n) => {
|
|
485
|
+
const from = n.type === "AssignmentExpression" ? n.right : n.type === "VariableDeclarator" ? n.init : null;
|
|
486
|
+
const to = n.type === "AssignmentExpression" ? n.left : n.type === "VariableDeclarator" ? n.id : null;
|
|
487
|
+
return unwrap(from)?.type === "Identifier" && unwrap(from).name === kept.name && to?.type === "Identifier";
|
|
488
|
+
}).map((n) => (n.type === "AssignmentExpression" ? n.left.name : n.id.name));
|
|
489
|
+
if (aliases.some(endsUnder)) return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (killedThroughCapture(ast, node)) return true;
|
|
493
|
+
|
|
344
494
|
const fn = scopeOf(ast, node);
|
|
345
495
|
if (!isFunction(fn)) return false;
|
|
346
496
|
|
|
@@ -886,6 +1036,62 @@ export const RULES = [
|
|
|
886
1036
|
});
|
|
887
1037
|
},
|
|
888
1038
|
},
|
|
1039
|
+
{
|
|
1040
|
+
id: "ref-read-in-cleanup",
|
|
1041
|
+
level: "warn",
|
|
1042
|
+
description:
|
|
1043
|
+
"`killTweensOf` given a ref's `.current` in a `useEffect` cleanup, where React has already cleared it at unmount",
|
|
1044
|
+
/**
|
|
1045
|
+
* At unmount React detaches a DOM ref before it runs passive cleanups, so
|
|
1046
|
+
* `ref.current` is null there and the kill finds nothing. A corpus project
|
|
1047
|
+
* did this in three cleanups, and fifteen tweens outlived their component.
|
|
1048
|
+
*
|
|
1049
|
+
* Narrow on purpose. Only `useEffect`: when a layout cleanup runs relative
|
|
1050
|
+
* to ref detachment is not something this rule is sure of. Only
|
|
1051
|
+
* `killTweensOf`, whose argument is the element: a ref that holds a timeline
|
|
1052
|
+
* — `tlRef.current?.kill()` — is not cleared by React, and is correct.
|
|
1053
|
+
*/
|
|
1054
|
+
test(file) {
|
|
1055
|
+
if (!file.isReact) return [];
|
|
1056
|
+
const effects = findAll(
|
|
1057
|
+
file.ast,
|
|
1058
|
+
(n) => n.type === "CallExpression" && calleeName(n)?.split(".").at(-1) === "useEffect",
|
|
1059
|
+
)
|
|
1060
|
+
.map((call) => unwrap(call.arguments[0]))
|
|
1061
|
+
.filter(isFunction);
|
|
1062
|
+
|
|
1063
|
+
return effects.flatMap((effect) =>
|
|
1064
|
+
cleanupsOf(file.ast, effect).flatMap((cleanup) => {
|
|
1065
|
+
/** A local the cleanup itself reads from a ref: `const root = rootRef.current`. */
|
|
1066
|
+
const fromRef = new Set(
|
|
1067
|
+
findAll(
|
|
1068
|
+
cleanup,
|
|
1069
|
+
(n) =>
|
|
1070
|
+
n.type === "VariableDeclarator" &&
|
|
1071
|
+
n.id.type === "Identifier" &&
|
|
1072
|
+
n.init &&
|
|
1073
|
+
readsRef(n.init),
|
|
1074
|
+
).map((n) => n.id.name),
|
|
1075
|
+
);
|
|
1076
|
+
const viaRef = (arg) =>
|
|
1077
|
+
readsRef(arg) ||
|
|
1078
|
+
findAll(arg, (n) => n.type === "Identifier" && fromRef.has(n.name)).length > 0;
|
|
1079
|
+
|
|
1080
|
+
return findAll(
|
|
1081
|
+
cleanup,
|
|
1082
|
+
(n) =>
|
|
1083
|
+
n.type === "CallExpression" &&
|
|
1084
|
+
calleeName(n)?.endsWith("killTweensOf") &&
|
|
1085
|
+
n.arguments.some(viaRef),
|
|
1086
|
+
).map((call) => ({
|
|
1087
|
+
index: call.start,
|
|
1088
|
+
message: "`killTweensOf` reads a ref in a useEffect cleanup, which React has already cleared at unmount.",
|
|
1089
|
+
hint: HINT.refReadInCleanup,
|
|
1090
|
+
}));
|
|
1091
|
+
}),
|
|
1092
|
+
);
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
889
1095
|
{
|
|
890
1096
|
id: "dangling-listener",
|
|
891
1097
|
level: "warn",
|
|
@@ -1682,7 +1888,14 @@ export const RULES = [
|
|
|
1682
1888
|
*/
|
|
1683
1889
|
test(file) {
|
|
1684
1890
|
if (!file.usesGsap) return [];
|
|
1685
|
-
if (
|
|
1891
|
+
if (hasReducedBranch(file)) return [];
|
|
1892
|
+
/**
|
|
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.
|
|
1897
|
+
*/
|
|
1898
|
+
if (file.importers?.length && file.importers.every((importer) => !importer.parseError && hasReducedBranch(importer))) {
|
|
1686
1899
|
return [];
|
|
1687
1900
|
}
|
|
1688
1901
|
|
package/lib/source.mjs
CHANGED
|
@@ -241,7 +241,15 @@ export function fromText(raw, path, display = path) {
|
|
|
241
241
|
/\bfrom ["']react["']/.test(code) ||
|
|
242
242
|
/\bfrom ["']@gsap\/react["']/.test(code),
|
|
243
243
|
isClient: /^\s*["']use client["']/m.test(code),
|
|
244
|
-
|
|
244
|
+
/**
|
|
245
|
+
* GSAP itself, or a binding named `gsap` from anywhere: a project's own
|
|
246
|
+
* wrapper (`import { gsap } from "@/core/motion/gsap"`) is common, and
|
|
247
|
+
* reading only the package left orphan-tween and missing-reduced-motion
|
|
248
|
+
* blind to every file in a real project that used one.
|
|
249
|
+
*/
|
|
250
|
+
usesGsap:
|
|
251
|
+
/\bfrom ["']gsap(?:\/|["'])/.test(code) ||
|
|
252
|
+
/\bimport\s+(?!type\b)(?:gsap\b|\{[^}]*\bgsap\b[^}]*\})[^;]*?\bfrom\s*["']/.test(code),
|
|
245
253
|
};
|
|
246
254
|
}
|
|
247
255
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehshekari/eslint-plugin-gsap-motion",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.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",
|