@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 +48 -2
- package/lib/rules.mjs +61 -19
- package/lib/source.mjs +8 -1
- package/package.json +1 -1
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) =>
|
|
608
|
-
[...contained].some((fn) =>
|
|
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
|
|
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,
|
|
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 (
|
|
1845
|
-
return [];
|
|
1846
|
-
}
|
|
1891
|
+
if (hasReducedBranch(file)) return [];
|
|
1847
1892
|
/**
|
|
1848
|
-
*
|
|
1849
|
-
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
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
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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.
|
|
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",
|