@mehshekari/gsap-motion 4.3.0 → 4.5.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 -1
- package/bin/gsap-motion.mjs +20 -2
- package/package.json +1 -1
- package/skill/SKILL.md +8 -8
- package/skill/adapter/next.md +1 -2
- package/skill/adapter/r3f.md +4 -1
- package/skill/adapter/react.md +8 -5
- package/skill/adapter/three.md +2 -0
- package/skill/example/magnetic-button.md +101 -101
- package/skill/preset/cursor.md +184 -184
- package/skill/preset/magnetic.md +155 -155
- package/skill/reference/devices.md +10 -0
- package/skill/reference/flip.md +220 -220
- package/skill/reference/interaction.md +1 -0
- package/skill/reference/performance.md +87 -0
- package/skill/reference/refine.md +63 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +27 -3
- package/skill/scripts/capture-motion.mjs +248 -44
- package/skill/scripts/lib/ast.mjs +82 -12
- package/skill/scripts/lib/rules.mjs +311 -7
- package/skill/scripts/lib/source.mjs +9 -1
- package/skill/scripts/review-motion.mjs +224 -25
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
|
@@ -214,26 +214,75 @@ export function tweenMethod(node) {
|
|
|
214
214
|
return allowed.has(method) ? method : null;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The object literal bound to `name` by a `const`, when the file binds that
|
|
219
|
+
* name exactly once. Two bindings of one name, in different functions, would
|
|
220
|
+
* need scope to tell apart; a wrong guess is a false finding, so neither is
|
|
221
|
+
* read.
|
|
222
|
+
*/
|
|
223
|
+
function constObject(root, name) {
|
|
224
|
+
const bound = findAll(
|
|
225
|
+
root,
|
|
226
|
+
(node) => node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.id.name === name,
|
|
227
|
+
);
|
|
228
|
+
if (bound.length !== 1 || !bound[0].init) return null;
|
|
229
|
+
const declaration = parentOf(root, bound[0]);
|
|
230
|
+
if (declaration?.kind !== "const") return null;
|
|
231
|
+
const init = unwrap(bound[0].init);
|
|
232
|
+
return init.type === "ObjectExpression" ? init : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `node`'s properties, re-anchored at `site`: a finding lands where the tween uses them. */
|
|
236
|
+
const anchoredAt = (properties, site) =>
|
|
237
|
+
properties.map((property) => ({ ...property, start: site.start, end: site.end }));
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The object a vars argument stands for: a literal, or a `const` bound to one,
|
|
241
|
+
* with any spread of such a `const` unfolded in place. Vars written once and
|
|
242
|
+
* shared between a `fromTo`'s ends are how a real film's credits hid two of
|
|
243
|
+
* seven filter tweens from the audit.
|
|
244
|
+
*/
|
|
245
|
+
function resolveObject(root, node, seen = new Set()) {
|
|
246
|
+
let target = unwrap(node);
|
|
247
|
+
let site = null;
|
|
248
|
+
if (target?.type === "Identifier" && root) {
|
|
249
|
+
site = target;
|
|
250
|
+
target = constObject(root, target.name);
|
|
251
|
+
}
|
|
252
|
+
if (target?.type !== "ObjectExpression" || seen.has(target)) return null;
|
|
253
|
+
seen.add(target);
|
|
254
|
+
|
|
255
|
+
let unfolded = false;
|
|
256
|
+
const properties = target.properties.flatMap((property) => {
|
|
257
|
+
if (property.type !== "SpreadElement") return [property];
|
|
258
|
+
const spread = resolveObject(root, property.argument, seen);
|
|
259
|
+
if (!spread) return [property];
|
|
260
|
+
unfolded = true;
|
|
261
|
+
return anchoredAt(spread.properties, property);
|
|
262
|
+
});
|
|
263
|
+
/** A literal with nothing to unfold is returned as it is, not as a copy. */
|
|
264
|
+
if (site) return { ...target, start: site.start, end: site.end, properties: anchoredAt(properties, site) };
|
|
265
|
+
return unfolded ? { ...target, properties } : target;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const argumentObject = (call, index, root) => resolveObject(root, call.arguments[index]);
|
|
221
269
|
|
|
222
270
|
/**
|
|
223
271
|
* The vars object that holds a tween's own settings — `repeat`, `ease`,
|
|
224
272
|
* `yoyo`: the second argument, a `fromTo`'s third, a timeline's first. `null`
|
|
225
|
-
* when it is
|
|
273
|
+
* when it is neither an object literal nor a `const` bound to one. Pass the
|
|
274
|
+
* file's `root` to follow a `const`; without it, only literals are read.
|
|
226
275
|
*/
|
|
227
|
-
export const ownVars = (call, method) =>
|
|
228
|
-
argumentObject(call, method === "fromTo" ? 2 : method === "timeline" ? 0 : 1);
|
|
276
|
+
export const ownVars = (call, method, root) =>
|
|
277
|
+
argumentObject(call, method === "fromTo" ? 2 : method === "timeline" ? 0 : 1, root);
|
|
229
278
|
|
|
230
279
|
/**
|
|
231
280
|
* Every vars object a tween call carries, as object literals: both of a
|
|
232
281
|
* `fromTo`'s, a timeline's own, or the tween's.
|
|
233
282
|
*/
|
|
234
|
-
export function varsObjects(call, method) {
|
|
283
|
+
export function varsObjects(call, method, root) {
|
|
235
284
|
const indexes = method === "fromTo" ? [1, 2] : method === "timeline" ? [0] : [1];
|
|
236
|
-
return indexes.map((index) => argumentObject(call, index)).filter(Boolean);
|
|
285
|
+
return indexes.map((index) => argumentObject(call, index, root)).filter(Boolean);
|
|
237
286
|
}
|
|
238
287
|
|
|
239
288
|
const FUNCTIONS = new Set([
|
|
@@ -258,7 +307,7 @@ export const contains = (outer, inner) =>
|
|
|
258
307
|
* returns `null`: an ambiguous handler is left unread rather than guessed at.
|
|
259
308
|
*/
|
|
260
309
|
export function resolveFunction(root, node) {
|
|
261
|
-
const target = unwrap(node);
|
|
310
|
+
const target = wrappedFunction(unwrap(node));
|
|
262
311
|
if (isFunction(target)) return target;
|
|
263
312
|
if (target?.type !== "Identifier") return null;
|
|
264
313
|
|
|
@@ -269,13 +318,34 @@ export function resolveFunction(root, node) {
|
|
|
269
318
|
(n.type === "VariableDeclarator" &&
|
|
270
319
|
n.id.type === "Identifier" &&
|
|
271
320
|
n.id.name === target.name &&
|
|
272
|
-
isFunction(unwrap(n.init))),
|
|
321
|
+
isFunction(wrappedFunction(unwrap(n.init)))),
|
|
273
322
|
);
|
|
274
323
|
if (declarations.length !== 1) return null;
|
|
275
324
|
const [declaration] = declarations;
|
|
276
325
|
return declaration.type === "FunctionDeclaration"
|
|
277
326
|
? declaration
|
|
278
|
-
: 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;
|
|
279
349
|
}
|
|
280
350
|
|
|
281
351
|
const PARENTS = new WeakMap();
|
|
@@ -54,13 +54,19 @@ 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.",
|
|
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.",
|
|
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.",
|
|
66
|
+
allocationPerFrame:
|
|
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()`.",
|
|
68
|
+
layoutReadPerFrame:
|
|
69
|
+
"If anything has changed layout since the last frame, this read makes the browser compute it now, every frame. Measure once and again only on resize — a ScrollTrigger's `onRefresh`, or a ResizeObserver — and read the cached value here.",
|
|
64
70
|
paintProperty:
|
|
65
71
|
"Filters and shadows repaint the layer every frame, and the cost grows with the painted area. Animate `opacity` on a pre-blurred or pre-shadowed copy, or move a transform instead, and measure before defending anything else.",
|
|
66
72
|
unownedLoop:
|
|
@@ -92,7 +98,7 @@ const HINT = {
|
|
|
92
98
|
unregisteredPlugin:
|
|
93
99
|
"An unregistered plugin's properties are ignored without an error — the animation simply does nothing.",
|
|
94
100
|
missingReducedMotion:
|
|
95
|
-
"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.",
|
|
96
102
|
devToolImport:
|
|
97
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.",
|
|
98
104
|
markers: 'Gate it: `markers: process.env.NODE_ENV === "development"`.',
|
|
@@ -104,6 +110,7 @@ const HINT = {
|
|
|
104
110
|
const MESSAGE = {
|
|
105
111
|
easedLoop: "An infinite repeat with an ease other than `none`.",
|
|
106
112
|
tweenPerFrame: "A tween is created here on every frame.",
|
|
113
|
+
layoutReadPerFrame: "Layout is read here on every frame.",
|
|
107
114
|
unownedLoop: "An infinite repeat that nothing pauses.",
|
|
108
115
|
ungatedHover: "A hover animation with no `(hover: hover)` gate.",
|
|
109
116
|
delayChain: "Three or more tweens in this scope are sequenced by `delay`.",
|
|
@@ -173,10 +180,19 @@ function layoutHint(property, percent) {
|
|
|
173
180
|
const swap = TRANSFORM_FOR[property];
|
|
174
181
|
return swap
|
|
175
182
|
? `Use \`${swap}\` instead — a compositor-only property.` +
|
|
183
|
+
(property === "height" ? ` ${HEIGHT_CAVEAT}` : "") +
|
|
176
184
|
(percent ? ` ${PERCENT_CAVEAT}` : "")
|
|
177
185
|
: "Prefer transform and opacity; they never touch layout.";
|
|
178
186
|
}
|
|
179
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A scale squashes what is inside. An accordion's text is the usual case, and
|
|
190
|
+
* a real project kept its `height` tween for exactly that reason, which the
|
|
191
|
+
* hint above alone would have argued against.
|
|
192
|
+
*/
|
|
193
|
+
const HEIGHT_CAVEAT =
|
|
194
|
+
"Unless what is inside must keep its shape — an accordion's text: then transition `grid-template-rows` from `0fr` to `1fr`, or use Flip.";
|
|
195
|
+
|
|
180
196
|
/** Whether a transform's from-value, as written, leaves the element untransformed. */
|
|
181
197
|
function identityTransform(property, value) {
|
|
182
198
|
const v = value.trim().replace(/^["'`]|["'`]$/g, "");
|
|
@@ -296,6 +312,91 @@ function debounced(handler, node) {
|
|
|
296
312
|
);
|
|
297
313
|
}
|
|
298
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Whether a tween's target is a local the enclosing effect captured, and that
|
|
317
|
+
* effect's code kills that local's tweens: `const el = ref.current` in the body,
|
|
318
|
+
* `gsap.killTweensOf(el)` in the cleanup. Found on the corpus, where it was
|
|
319
|
+
* reported as an orphan.
|
|
320
|
+
*
|
|
321
|
+
* Only a captured local counts. `killTweensOf(ref.current)` in a passive
|
|
322
|
+
* cleanup kills nothing at unmount — React has already cleared the ref — and
|
|
323
|
+
* that is `ref-read-in-cleanup`'s to say.
|
|
324
|
+
*/
|
|
325
|
+
function killedThroughCapture(ast, node) {
|
|
326
|
+
if (!isGsapCall(node)) return false;
|
|
327
|
+
const target = unwrap(node.arguments[0]);
|
|
328
|
+
if (target?.type !== "Identifier") return false;
|
|
329
|
+
|
|
330
|
+
const effect = ancestorsOf(ast, node).find((fn) => {
|
|
331
|
+
if (!isFunction(fn)) return false;
|
|
332
|
+
const call = parentOf(ast, fn);
|
|
333
|
+
return (
|
|
334
|
+
call?.type === "CallExpression" &&
|
|
335
|
+
call.arguments[0] === fn &&
|
|
336
|
+
EFFECTS.has(calleeName(call)?.split(".").at(-1))
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
if (!effect) return false;
|
|
340
|
+
|
|
341
|
+
const captured = findAll(
|
|
342
|
+
effect,
|
|
343
|
+
(n) => n.type === "VariableDeclarator" && n.id.type === "Identifier" && n.id.name === target.name,
|
|
344
|
+
).length > 0;
|
|
345
|
+
if (!captured) return false;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* In the cleanup — the function the effect returns — and nowhere else. A
|
|
349
|
+
* kill at the top of the body stops the previous run's tweens before
|
|
350
|
+
* starting new ones; it does nothing at unmount. Nine true corpus findings
|
|
351
|
+
* did exactly that, and the first version of this check let them go.
|
|
352
|
+
*/
|
|
353
|
+
const cleanups = cleanupsOf(ast, effect);
|
|
354
|
+
|
|
355
|
+
const names = (arg) => {
|
|
356
|
+
const value = unwrap(arg);
|
|
357
|
+
if (value?.type === "Identifier") return [value.name];
|
|
358
|
+
if (value?.type === "ArrayExpression") return value.elements.flatMap((e) => (e ? names(e) : []));
|
|
359
|
+
return [];
|
|
360
|
+
};
|
|
361
|
+
return cleanups.some(
|
|
362
|
+
(cleanup) =>
|
|
363
|
+
findAll(
|
|
364
|
+
cleanup,
|
|
365
|
+
(n) =>
|
|
366
|
+
n.type === "CallExpression" &&
|
|
367
|
+
calleeName(n)?.endsWith("killTweensOf") &&
|
|
368
|
+
n.arguments.some((arg) => names(arg).includes(target.name)),
|
|
369
|
+
).length > 0,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const EFFECTS = new Set(["useEffect", "useLayoutEffect"]);
|
|
374
|
+
|
|
375
|
+
/** The functions an effect callback returns: its cleanups. */
|
|
376
|
+
function cleanupsOf(ast, effect) {
|
|
377
|
+
const cleanups = findAll(
|
|
378
|
+
effect,
|
|
379
|
+
(n) => n.type === "ReturnStatement" && n.argument && scopeOf(ast, n) === effect,
|
|
380
|
+
)
|
|
381
|
+
.map((n) => unwrap(n.argument))
|
|
382
|
+
.filter(isFunction);
|
|
383
|
+
if (effect.type === "ArrowFunctionExpression" && effect.expression && isFunction(unwrap(effect.body))) {
|
|
384
|
+
cleanups.push(unwrap(effect.body));
|
|
385
|
+
}
|
|
386
|
+
return cleanups;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Whether `node` reads a ref: a `.current` anywhere inside it. */
|
|
390
|
+
const readsRef = (node) =>
|
|
391
|
+
findAll(
|
|
392
|
+
node,
|
|
393
|
+
(n) =>
|
|
394
|
+
n.type === "MemberExpression" &&
|
|
395
|
+
!n.computed &&
|
|
396
|
+
n.property.type === "Identifier" &&
|
|
397
|
+
n.property.name === "current",
|
|
398
|
+
).length > 0;
|
|
399
|
+
|
|
299
400
|
/** The calls that add a child to a timeline. */
|
|
300
401
|
const CHILDREN = new Set(["to", "from", "fromTo", "add"]);
|
|
301
402
|
|
|
@@ -327,6 +428,22 @@ function tornDown(ast, node) {
|
|
|
327
428
|
const kept = keptUnder(ast, node);
|
|
328
429
|
if (endsUnder(kept?.name)) return true;
|
|
329
430
|
|
|
431
|
+
/**
|
|
432
|
+
* A second name for it: `active = timeline`, then `active?.kill()` in the
|
|
433
|
+
* cleanup. Found on the corpus, in a preview whose timeline was built in a
|
|
434
|
+
* helper and handed to a variable the effect owns.
|
|
435
|
+
*/
|
|
436
|
+
if (kept?.name) {
|
|
437
|
+
const aliases = findAll(ast, (n) => {
|
|
438
|
+
const from = n.type === "AssignmentExpression" ? n.right : n.type === "VariableDeclarator" ? n.init : null;
|
|
439
|
+
const to = n.type === "AssignmentExpression" ? n.left : n.type === "VariableDeclarator" ? n.id : null;
|
|
440
|
+
return unwrap(from)?.type === "Identifier" && unwrap(from).name === kept.name && to?.type === "Identifier";
|
|
441
|
+
}).map((n) => (n.type === "AssignmentExpression" ? n.left.name : n.id.name));
|
|
442
|
+
if (aliases.some(endsUnder)) return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (killedThroughCapture(ast, node)) return true;
|
|
446
|
+
|
|
330
447
|
const fn = scopeOf(ast, node);
|
|
331
448
|
if (!isFunction(fn)) return false;
|
|
332
449
|
|
|
@@ -589,6 +706,36 @@ const PAINT_PROPERTIES = new Set([
|
|
|
589
706
|
"boxShadow",
|
|
590
707
|
]);
|
|
591
708
|
|
|
709
|
+
/** Three's math types: what a render loop allocates when it should reuse. */
|
|
710
|
+
const THREE_MATH = new Set([
|
|
711
|
+
"Vector2",
|
|
712
|
+
"Vector3",
|
|
713
|
+
"Vector4",
|
|
714
|
+
"Quaternion",
|
|
715
|
+
"Euler",
|
|
716
|
+
"Matrix3",
|
|
717
|
+
"Matrix4",
|
|
718
|
+
"Color",
|
|
719
|
+
"Box3",
|
|
720
|
+
"Sphere",
|
|
721
|
+
"Ray",
|
|
722
|
+
"Plane",
|
|
723
|
+
"Spherical",
|
|
724
|
+
]);
|
|
725
|
+
|
|
726
|
+
/** Reads that return layout, and so make the browser compute it if it is stale. */
|
|
727
|
+
const LAYOUT_READ_METHODS = new Set(["getBoundingClientRect", "getClientRects"]);
|
|
728
|
+
const LAYOUT_READ_PROPERTIES = new Set([
|
|
729
|
+
"offsetWidth",
|
|
730
|
+
"offsetHeight",
|
|
731
|
+
"offsetTop",
|
|
732
|
+
"offsetLeft",
|
|
733
|
+
"clientWidth",
|
|
734
|
+
"clientHeight",
|
|
735
|
+
"scrollWidth",
|
|
736
|
+
"scrollHeight",
|
|
737
|
+
]);
|
|
738
|
+
|
|
592
739
|
const HOVER_EVENTS = new Set([
|
|
593
740
|
"pointerenter",
|
|
594
741
|
"pointerover",
|
|
@@ -842,6 +989,62 @@ export const RULES = [
|
|
|
842
989
|
});
|
|
843
990
|
},
|
|
844
991
|
},
|
|
992
|
+
{
|
|
993
|
+
id: "ref-read-in-cleanup",
|
|
994
|
+
level: "warn",
|
|
995
|
+
description:
|
|
996
|
+
"`killTweensOf` given a ref's `.current` in a `useEffect` cleanup, where React has already cleared it at unmount",
|
|
997
|
+
/**
|
|
998
|
+
* At unmount React detaches a DOM ref before it runs passive cleanups, so
|
|
999
|
+
* `ref.current` is null there and the kill finds nothing. A corpus project
|
|
1000
|
+
* did this in three cleanups, and fifteen tweens outlived their component.
|
|
1001
|
+
*
|
|
1002
|
+
* Narrow on purpose. Only `useEffect`: when a layout cleanup runs relative
|
|
1003
|
+
* to ref detachment is not something this rule is sure of. Only
|
|
1004
|
+
* `killTweensOf`, whose argument is the element: a ref that holds a timeline
|
|
1005
|
+
* — `tlRef.current?.kill()` — is not cleared by React, and is correct.
|
|
1006
|
+
*/
|
|
1007
|
+
test(file) {
|
|
1008
|
+
if (!file.isReact) return [];
|
|
1009
|
+
const effects = findAll(
|
|
1010
|
+
file.ast,
|
|
1011
|
+
(n) => n.type === "CallExpression" && calleeName(n)?.split(".").at(-1) === "useEffect",
|
|
1012
|
+
)
|
|
1013
|
+
.map((call) => unwrap(call.arguments[0]))
|
|
1014
|
+
.filter(isFunction);
|
|
1015
|
+
|
|
1016
|
+
return effects.flatMap((effect) =>
|
|
1017
|
+
cleanupsOf(file.ast, effect).flatMap((cleanup) => {
|
|
1018
|
+
/** A local the cleanup itself reads from a ref: `const root = rootRef.current`. */
|
|
1019
|
+
const fromRef = new Set(
|
|
1020
|
+
findAll(
|
|
1021
|
+
cleanup,
|
|
1022
|
+
(n) =>
|
|
1023
|
+
n.type === "VariableDeclarator" &&
|
|
1024
|
+
n.id.type === "Identifier" &&
|
|
1025
|
+
n.init &&
|
|
1026
|
+
readsRef(n.init),
|
|
1027
|
+
).map((n) => n.id.name),
|
|
1028
|
+
);
|
|
1029
|
+
const viaRef = (arg) =>
|
|
1030
|
+
readsRef(arg) ||
|
|
1031
|
+
findAll(arg, (n) => n.type === "Identifier" && fromRef.has(n.name)).length > 0;
|
|
1032
|
+
|
|
1033
|
+
return findAll(
|
|
1034
|
+
cleanup,
|
|
1035
|
+
(n) =>
|
|
1036
|
+
n.type === "CallExpression" &&
|
|
1037
|
+
calleeName(n)?.endsWith("killTweensOf") &&
|
|
1038
|
+
n.arguments.some(viaRef),
|
|
1039
|
+
).map((call) => ({
|
|
1040
|
+
index: call.start,
|
|
1041
|
+
message: "`killTweensOf` reads a ref in a useEffect cleanup, which React has already cleared at unmount.",
|
|
1042
|
+
hint: HINT.refReadInCleanup,
|
|
1043
|
+
}));
|
|
1044
|
+
}),
|
|
1045
|
+
);
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
845
1048
|
{
|
|
846
1049
|
id: "dangling-listener",
|
|
847
1050
|
level: "warn",
|
|
@@ -1009,7 +1212,7 @@ export const RULES = [
|
|
|
1009
1212
|
return findAll(file.ast, animates).flatMap(
|
|
1010
1213
|
(call) => {
|
|
1011
1214
|
const seen = new Set();
|
|
1012
|
-
return varsObjects(call, tweenMethod(call))
|
|
1215
|
+
return varsObjects(call, tweenMethod(call), file.ast)
|
|
1013
1216
|
.flatMap((vars) => vars.properties)
|
|
1014
1217
|
.filter((property) => {
|
|
1015
1218
|
const key = keyName(property);
|
|
@@ -1087,7 +1290,7 @@ export const RULES = [
|
|
|
1087
1290
|
test(file) {
|
|
1088
1291
|
return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap(
|
|
1089
1292
|
(call) => {
|
|
1090
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1293
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1091
1294
|
if (!vars) return [];
|
|
1092
1295
|
const repeat = propertyOf(vars, "repeat");
|
|
1093
1296
|
if (!repeat || numberValue(repeat.value) !== -1) return [];
|
|
@@ -1401,6 +1604,91 @@ export const RULES = [
|
|
|
1401
1604
|
return findings;
|
|
1402
1605
|
},
|
|
1403
1606
|
},
|
|
1607
|
+
{
|
|
1608
|
+
id: "allocation-per-frame",
|
|
1609
|
+
level: "warn",
|
|
1610
|
+
description:
|
|
1611
|
+
"A Three.js vector, colour, matrix or quaternion built every frame — in `useFrame`, the ticker, `onUpdate` or a `requestAnimationFrame` loop",
|
|
1612
|
+
/**
|
|
1613
|
+
* Only Three's math types, by name. Any `new` in a frame allocates, but a
|
|
1614
|
+
* `Set` or a `Map` there is usually deliberate and rarely hot; these are
|
|
1615
|
+
* the objects a render loop builds by the thousand, once per object per
|
|
1616
|
+
* frame, and each has a `set` or `copy` that makes the allocation pointless.
|
|
1617
|
+
*/
|
|
1618
|
+
test(file) {
|
|
1619
|
+
const scopes = perFrameScopes(file.ast);
|
|
1620
|
+
if (scopes.length === 0) return [];
|
|
1621
|
+
|
|
1622
|
+
const built = findAll(file.ast, (node) => {
|
|
1623
|
+
if (node.type !== "NewExpression") return false;
|
|
1624
|
+
const name = dottedName(unwrap(node.callee))?.split(".").at(-1);
|
|
1625
|
+
return THREE_MATH.has(name);
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
const reported = new Set();
|
|
1629
|
+
return built.flatMap((node) => {
|
|
1630
|
+
if (reported.has(node.start) || !scopes.some((scope) => contains(scope, node))) {
|
|
1631
|
+
return [];
|
|
1632
|
+
}
|
|
1633
|
+
reported.add(node.start);
|
|
1634
|
+
const name = dottedName(unwrap(node.callee)).split(".").at(-1);
|
|
1635
|
+
return [
|
|
1636
|
+
{
|
|
1637
|
+
index: node.start,
|
|
1638
|
+
message: `A \`${name}\` is allocated here on every frame.`,
|
|
1639
|
+
hint: HINT.allocationPerFrame,
|
|
1640
|
+
},
|
|
1641
|
+
];
|
|
1642
|
+
});
|
|
1643
|
+
},
|
|
1644
|
+
},
|
|
1645
|
+
{
|
|
1646
|
+
id: "layout-read-per-frame",
|
|
1647
|
+
level: "info",
|
|
1648
|
+
description:
|
|
1649
|
+
"A layout read — `getBoundingClientRect`, `offsetWidth`, `getComputedStyle` — in a callback that runs every frame",
|
|
1650
|
+
/**
|
|
1651
|
+
* Info, because a read is only expensive when something has invalidated
|
|
1652
|
+
* layout since the last frame, and that depends on what else runs — a
|
|
1653
|
+
* transform does not, a `width` tween does. What is certain is that the
|
|
1654
|
+
* value is re-measured every frame when it usually changes only on resize.
|
|
1655
|
+
*/
|
|
1656
|
+
test(file) {
|
|
1657
|
+
const scopes = perFrameScopes(file.ast);
|
|
1658
|
+
if (scopes.length === 0) return [];
|
|
1659
|
+
|
|
1660
|
+
const reads = findAll(file.ast, (node) => {
|
|
1661
|
+
if (node.type === "CallExpression") {
|
|
1662
|
+
const name = calleeName(node);
|
|
1663
|
+
return (
|
|
1664
|
+
name === "getComputedStyle" ||
|
|
1665
|
+
name === "window.getComputedStyle" ||
|
|
1666
|
+
LAYOUT_READ_METHODS.has(methodName(node))
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
return (
|
|
1670
|
+
node.type === "MemberExpression" &&
|
|
1671
|
+
!node.computed &&
|
|
1672
|
+
LAYOUT_READ_PROPERTIES.has(node.property.name)
|
|
1673
|
+
);
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
const reported = new Set();
|
|
1677
|
+
return reads.flatMap((node) => {
|
|
1678
|
+
if (reported.has(node.start) || !scopes.some((scope) => contains(scope, node))) {
|
|
1679
|
+
return [];
|
|
1680
|
+
}
|
|
1681
|
+
reported.add(node.start);
|
|
1682
|
+
return [
|
|
1683
|
+
{
|
|
1684
|
+
index: node.start,
|
|
1685
|
+
message: MESSAGE.layoutReadPerFrame,
|
|
1686
|
+
hint: HINT.layoutReadPerFrame,
|
|
1687
|
+
},
|
|
1688
|
+
];
|
|
1689
|
+
});
|
|
1690
|
+
},
|
|
1691
|
+
},
|
|
1404
1692
|
{
|
|
1405
1693
|
id: "paint-property",
|
|
1406
1694
|
level: "warn",
|
|
@@ -1415,7 +1703,7 @@ export const RULES = [
|
|
|
1415
1703
|
|
|
1416
1704
|
return findAll(file.ast, animates).flatMap((call) => {
|
|
1417
1705
|
const seen = new Set();
|
|
1418
|
-
return varsObjects(call, tweenMethod(call))
|
|
1706
|
+
return varsObjects(call, tweenMethod(call), file.ast)
|
|
1419
1707
|
.flatMap((vars) => vars.properties)
|
|
1420
1708
|
.filter((property) => {
|
|
1421
1709
|
const key = keyName(property);
|
|
@@ -1462,7 +1750,7 @@ export const RULES = [
|
|
|
1462
1750
|
|
|
1463
1751
|
return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap((call) => {
|
|
1464
1752
|
if (inside(call)) return [];
|
|
1465
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1753
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1466
1754
|
if (!vars) return [];
|
|
1467
1755
|
const repeat = propertyOf(vars, "repeat");
|
|
1468
1756
|
if (!repeat || numberValue(repeat.value) !== -1) return [];
|
|
@@ -1518,7 +1806,7 @@ export const RULES = [
|
|
|
1518
1806
|
|
|
1519
1807
|
for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
|
|
1520
1808
|
if (chainStart(call) !== call) continue;
|
|
1521
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1809
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1522
1810
|
const delay = vars && propertyOf(vars, "delay");
|
|
1523
1811
|
if (!delay || !(numberValue(delay.value) > 0)) continue;
|
|
1524
1812
|
const scope = scopeOf(file.ast, call) ?? file.ast;
|
|
@@ -1556,6 +1844,22 @@ export const RULES = [
|
|
|
1556
1844
|
if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
|
|
1557
1845
|
return [];
|
|
1558
1846
|
}
|
|
1847
|
+
/**
|
|
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.
|
|
1852
|
+
*/
|
|
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 [];
|
|
1559
1863
|
|
|
1560
1864
|
const [first] = findAll(file.ast, (node) => {
|
|
1561
1865
|
const method = tweenMethod(node);
|
|
@@ -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
|
|