@mehshekari/gsap-motion 3.0.0 → 3.1.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/NOTICE.md +59 -0
- package/README.md +4 -1
- package/package.json +1 -1
- package/skill/NOTICE.md +59 -0
- package/skill/SKILL.md +1 -1
- package/skill/scripts/audit-gsap.mjs +49 -50
- package/skill/scripts/lib/ast.mjs +527 -0
- package/skill/scripts/lib/audit.mjs +91 -0
- package/skill/scripts/lib/rules.mjs +890 -441
- package/skill/scripts/lib/source.mjs +79 -105
- package/skill/scripts/lib/vendor/parser.mjs +15 -0
|
@@ -11,11 +11,92 @@
|
|
|
11
11
|
* `{ index, message, hint }`. `level` is "error" (a bug), "warn" (a likely
|
|
12
12
|
* bug or a real cost) or "info" (worth a look, higher false-positive rate).
|
|
13
13
|
*
|
|
14
|
+
* Rules read `file.ast`, the syntax tree `ast.mjs` builds. A file that does not
|
|
15
|
+
* parse never reaches a rule: the runner reports it as `not-parsed`.
|
|
16
|
+
*
|
|
14
17
|
* Every rule has fixtures in `scripts/test/rules.test.mjs`: a case it must fire
|
|
15
18
|
* on and a case it must stay quiet on. A wrong finding in either direction is
|
|
16
19
|
* fixed by adding the case there first and watching it fail.
|
|
17
20
|
*/
|
|
18
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
ancestorsOf,
|
|
23
|
+
calleeName,
|
|
24
|
+
chainStart,
|
|
25
|
+
contains,
|
|
26
|
+
dottedName,
|
|
27
|
+
findAll,
|
|
28
|
+
gsapContexts,
|
|
29
|
+
isFunction,
|
|
30
|
+
isGsapCall,
|
|
31
|
+
keptUnder,
|
|
32
|
+
keyName,
|
|
33
|
+
methodName,
|
|
34
|
+
moduleSource,
|
|
35
|
+
numberValue,
|
|
36
|
+
ownVars,
|
|
37
|
+
parentOf,
|
|
38
|
+
propertyOf,
|
|
39
|
+
resolveFunction,
|
|
40
|
+
scopeOf,
|
|
41
|
+
staticString,
|
|
42
|
+
timelineLinks,
|
|
43
|
+
tweenMethod,
|
|
44
|
+
unwrap,
|
|
45
|
+
varsObjects,
|
|
46
|
+
} from "./ast.mjs";
|
|
47
|
+
|
|
48
|
+
/** What each rule says, in one place. */
|
|
49
|
+
const HINT = {
|
|
50
|
+
orphanTween:
|
|
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
|
+
unmanagedInstance:
|
|
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
|
+
danglingListener:
|
|
55
|
+
"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
|
+
tweenPerEvent:
|
|
57
|
+
"One tween allocated per event, each fighting the last. Create a `gsap.quickTo()` once and call it from the handler — or, for work that only matters once resizing stops, debounce it.",
|
|
58
|
+
statePerEvent:
|
|
59
|
+
"A re-render per frame so one element can move a few pixels. State owns what exists; GSAP owns how it moves — use a ref and quickTo.",
|
|
60
|
+
triggerPerItem:
|
|
61
|
+
"Each carries its own start/end maths on every scroll frame. If the items belong to one visual moment, use one trigger with a stagger. Per-item is right only when they genuinely reveal independently — pinned card stacks, for instance.",
|
|
62
|
+
easedLoop:
|
|
63
|
+
'The tween decelerates into its own restart, so the loop seam becomes visible. Use `ease: "none"`, or `yoyo: true` if it should breathe.',
|
|
64
|
+
easedScrub:
|
|
65
|
+
"The scrollbar is the playhead, so an ease fights the visitor's own scrolling and reads as lag. Use `ease: \"none\"` on the children and let `scrub: 1` do the smoothing.",
|
|
66
|
+
neverCompletesOwn:
|
|
67
|
+
"An endless repeat never completes. Use `onRepeat` for something that should happen every cycle, or give it a finite `repeat`.",
|
|
68
|
+
neverCompletesChild:
|
|
69
|
+
'The child gives the timeline an effectively infinite duration, and a tween added after it does not end it. Hand over at a position instead — `.call(fn, [], "label+=0.5")` fires when the playhead passes — or run the loop as its own tween outside the timeline.',
|
|
70
|
+
lateTransformOrigin:
|
|
71
|
+
"On an SVG element GSAP holds the element in place when its origin changes, and that correction outlives the tween: it ends offset, silently — a circle grown from 0 about its centre lands a whole radius off. Put `transformOrigin` or `svgOrigin` in the from-vars, or set it before the tween. HTML elements are unaffected.",
|
|
72
|
+
sharedPluginId:
|
|
73
|
+
"useGSAP scopes the selectors it resolves itself, but MotionPath and MorphSVG resolve their own config against the whole document. Mount this component twice and both instances drive the first one's geometry, silently. Namespace it with useId().",
|
|
74
|
+
unregisteredPlugin:
|
|
75
|
+
"An unregistered plugin's properties are ignored without an error — the animation simply does nothing.",
|
|
76
|
+
missingReducedMotion:
|
|
77
|
+
"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.",
|
|
78
|
+
devToolImport:
|
|
79
|
+
"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.",
|
|
80
|
+
markers: 'Gate it: `markers: process.env.NODE_ENV === "development"`.',
|
|
81
|
+
barrelImport:
|
|
82
|
+
"Import each from its own subpath — `gsap/ScrollTrigger` — so the bundler can drop the ones this route does not use.",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Messages that do not vary with what was found. */
|
|
86
|
+
const MESSAGE = {
|
|
87
|
+
easedLoop: "An infinite repeat with an ease other than `none`.",
|
|
88
|
+
easedScrub: "Easing inside a scrubbed ScrollTrigger timeline.",
|
|
89
|
+
neverCompletesChild:
|
|
90
|
+
"An endlessly repeating child in a timeline with an `onComplete`, which therefore never fires.",
|
|
91
|
+
lateTransformOrigin:
|
|
92
|
+
"The transform origin is only in this `fromTo`'s to-vars, while its from-vars scale, rotate or skew.",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Whether a string, template chunk or JSX text spells `text`. Comments are not nodes. */
|
|
96
|
+
const spells = (node, text) =>
|
|
97
|
+
(node.type === "Literal" && typeof node.raw === "string" && node.raw.includes(text)) ||
|
|
98
|
+
(node.type === "TemplateElement" && node.value.raw.includes(text)) ||
|
|
99
|
+
(node.type === "JSXText" && node.value.includes(text));
|
|
19
100
|
|
|
20
101
|
/** Properties whose animation forces the browser to re-run layout. */
|
|
21
102
|
const LAYOUT_PROPERTIES = [
|
|
@@ -62,157 +143,354 @@ const TRANSFORM_FOR = {
|
|
|
62
143
|
const PERCENT_CAVEAT =
|
|
63
144
|
"Careful with percentages: `top: 50%` is half the containing block, `yPercent: 50` is half the element itself. Either wrap it in a full-height box and translate that — the percentages then agree — or measure the container once and translate in pixels.";
|
|
64
145
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
146
|
+
/**
|
|
147
|
+
* The hint for an animated layout property: the transform that replaces it,
|
|
148
|
+
* and the percentage trap when the value is a percentage.
|
|
149
|
+
*/
|
|
150
|
+
function layoutHint(property, percent) {
|
|
151
|
+
const swap = TRANSFORM_FOR[property];
|
|
152
|
+
return swap
|
|
153
|
+
? `Use \`${swap}\` instead — a compositor-only property.` +
|
|
154
|
+
(percent ? ` ${PERCENT_CAVEAT}` : "")
|
|
155
|
+
: "Prefer transform and opacity; they never touch layout.";
|
|
156
|
+
}
|
|
73
157
|
|
|
74
|
-
|
|
158
|
+
/** Whether a transform's from-value, as written, leaves the element untransformed. */
|
|
159
|
+
function identityTransform(property, value) {
|
|
160
|
+
const v = value.trim().replace(/^["'`]|["'`]$/g, "");
|
|
161
|
+
return property.startsWith("scale")
|
|
162
|
+
? /^1(?:\.0+)?$/.test(v)
|
|
163
|
+
: /^-?0(?:\.0+)?(?:deg|rad)?$/.test(v);
|
|
164
|
+
}
|
|
75
165
|
|
|
76
166
|
/** An import from `gsap/*` that is a plugin needing `registerPlugin`. */
|
|
77
167
|
const PLUGIN_NAME =
|
|
78
168
|
/(?:Plugin|Trigger|Text|Flip|Observer|Draggable|Ease|Smoother|Bounce|Wiggle)$/;
|
|
79
169
|
|
|
80
170
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* This used to ask whether `.revert(` or `.kill(` appeared anywhere in the
|
|
85
|
-
* file, so one unrelated `tl.kill()` exempted every instance beside it.
|
|
171
|
+
* Events that fire many times a second, by the name a listener is given — not
|
|
172
|
+
* a substring anywhere in the file, which could not include `resize` without
|
|
173
|
+
* reporting every `ResizeObserver`.
|
|
86
174
|
*/
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
175
|
+
const HOT_EVENTS = new Set([
|
|
176
|
+
"pointermove",
|
|
177
|
+
"pointerrawupdate",
|
|
178
|
+
"mousemove",
|
|
179
|
+
"touchmove",
|
|
180
|
+
"scroll",
|
|
181
|
+
"wheel",
|
|
182
|
+
"resize",
|
|
183
|
+
]);
|
|
97
184
|
|
|
98
185
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* removes used to pass even when both removes were for the same event, and an
|
|
104
|
-
* inline arrow — which no remove can ever reach — passed whenever any remove
|
|
105
|
-
* existed. A dynamic event name cannot be judged statically, so it is skipped.
|
|
186
|
+
* A resize handler that sets state is how a component learns its width: a
|
|
187
|
+
* render per resize, but a render deciding what exists rather than a frame of
|
|
188
|
+
* motion. `state-per-event` leaves it out; a tween per resize is still a tween
|
|
189
|
+
* per event.
|
|
106
190
|
*/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
/** Removed by an AbortController, or by the browser after one call. */
|
|
123
|
-
selfCleaning: /\bsignal\b|\bonce\s*:\s*true/.test(rest),
|
|
124
|
-
};
|
|
125
|
-
});
|
|
191
|
+
const STATE_HOT_EVENTS = new Set(
|
|
192
|
+
[...HOT_EVENTS].filter((event) => event !== "resize"),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
/** `addEventListener` or `removeEventListener` when `node` calls one, on any receiver. */
|
|
196
|
+
function listenerMethod(node) {
|
|
197
|
+
if (node.type !== "CallExpression") return null;
|
|
198
|
+
const callee = unwrap(node.callee);
|
|
199
|
+
const name =
|
|
200
|
+
callee?.type === "Identifier"
|
|
201
|
+
? callee.name
|
|
202
|
+
: callee?.type === "MemberExpression" && !callee.computed
|
|
203
|
+
? callee.property.name
|
|
204
|
+
: null;
|
|
205
|
+
return name === "addEventListener" || name === "removeEventListener" ? name : null;
|
|
126
206
|
}
|
|
127
207
|
|
|
128
208
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
209
|
+
* Every handler for one of `events`, as `{ name, node }`: the second argument
|
|
210
|
+
* of an `addEventListener` whose first names that event, or the expression of a
|
|
211
|
+
* JSX prop for it. A wrapping call — `debounce(fn, 150)` — is kept whole. With
|
|
212
|
+
* `followNames`, a handler passed by name is followed to its declaration when
|
|
213
|
+
* the file declares exactly one function of that name.
|
|
132
214
|
*/
|
|
133
|
-
function
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (!
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
215
|
+
function handlersFor(ast, events, { followNames = true } = {}) {
|
|
216
|
+
const handler = (expression) => {
|
|
217
|
+
const node = unwrap(expression);
|
|
218
|
+
if (node?.type === "CallExpression") return node;
|
|
219
|
+
if (!followNames && !isFunction(node)) return null;
|
|
220
|
+
return resolveFunction(ast, node);
|
|
221
|
+
};
|
|
222
|
+
const handlers = [];
|
|
223
|
+
|
|
224
|
+
for (const call of findAll(ast, (node) => listenerMethod(node) === "addEventListener")) {
|
|
225
|
+
const event = staticString(call.arguments[0]);
|
|
226
|
+
if (event === null || !events.has(event) || !call.arguments[1]) continue;
|
|
227
|
+
const node = handler(call.arguments[1]);
|
|
228
|
+
if (node) handlers.push({ name: event, node });
|
|
143
229
|
}
|
|
230
|
+
|
|
231
|
+
const props = findAll(
|
|
232
|
+
ast,
|
|
233
|
+
(node) => node.type === "JSXAttribute" && node.name.type === "JSXIdentifier",
|
|
234
|
+
);
|
|
235
|
+
for (const attribute of props) {
|
|
236
|
+
const prop = attribute.name.name.match(
|
|
237
|
+
/^on(PointerMove|MouseMove|TouchMove|Scroll|Wheel)(?:Capture)?$/,
|
|
238
|
+
);
|
|
239
|
+
if (!prop || !events.has(prop[1].toLowerCase())) continue;
|
|
240
|
+
if (attribute.value?.type !== "JSXExpressionContainer") continue;
|
|
241
|
+
const node = handler(attribute.value.expression);
|
|
242
|
+
if (node) handlers.push({ name: `on${prop[1]}`, node });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return handlers;
|
|
144
246
|
}
|
|
145
247
|
|
|
146
248
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
249
|
+
* Whether `node` runs once the events stop rather than once per event: the
|
|
250
|
+
* handler is a `debounce(…)` call, or `node` sits inside a `setTimeout` that
|
|
251
|
+
* the same handler also clears.
|
|
252
|
+
*/
|
|
253
|
+
function debounced(handler, node) {
|
|
254
|
+
const timer = (name) => (n) => {
|
|
255
|
+
const called = calleeName(n);
|
|
256
|
+
return called === name || called === `window.${name}`;
|
|
257
|
+
};
|
|
258
|
+
const wrapper = handler.node.type === "CallExpression" ? calleeName(handler.node) : null;
|
|
259
|
+
if (wrapper === "debounce" || wrapper?.endsWith(".debounce")) return true;
|
|
260
|
+
if (!findAll(handler.node, timer("clearTimeout")).length) return false;
|
|
261
|
+
return findAll(handler.node, timer("setTimeout")).some(
|
|
262
|
+
(call) => call !== node && contains(call, node),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The calls that add a child to a timeline. */
|
|
267
|
+
const CHILDREN = new Set(["to", "from", "fromTo", "add"]);
|
|
268
|
+
|
|
269
|
+
const TEARDOWN = new Set(["kill", "revert"]);
|
|
270
|
+
const FINISH = new Set(["progress", "totalProgress"]);
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether the value built at `node` is torn down by hand somewhere in the file:
|
|
274
|
+
* kept under a name that is `.kill()`ed or `.revert()`ed, or finished with
|
|
275
|
+
* `.progress(1)`, which completes it so GSAP lets it go.
|
|
150
276
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
277
|
+
* Followed through one helper. When a function returns the value — alone, or as
|
|
278
|
+
* an element of an array — every call to that function must keep the result, or
|
|
279
|
+
* that element, under a name that is torn down. Found on the corpus: a component
|
|
280
|
+
* that builds its timeline and trigger in `getProjectsSt()` and kills what the
|
|
281
|
+
* effect destructures from it.
|
|
156
282
|
*/
|
|
157
|
-
function
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
283
|
+
function tornDown(ast, node) {
|
|
284
|
+
const endsUnder = (name) =>
|
|
285
|
+
Boolean(name) &&
|
|
286
|
+
findAll(ast, (n) => {
|
|
287
|
+
const method = methodName(n);
|
|
288
|
+
if (!method) return false;
|
|
289
|
+
const ends =
|
|
290
|
+
TEARDOWN.has(method) || (FINISH.has(method) && numberValue(n.arguments[0]) === 1);
|
|
291
|
+
return ends && dottedName(unwrap(n.callee).object) === name;
|
|
292
|
+
}).length > 0;
|
|
293
|
+
|
|
294
|
+
const kept = keptUnder(ast, node);
|
|
295
|
+
if (endsUnder(kept?.name)) return true;
|
|
296
|
+
|
|
297
|
+
const fn = scopeOf(ast, node);
|
|
298
|
+
if (!isFunction(fn)) return false;
|
|
299
|
+
|
|
300
|
+
const holds = (expression) => {
|
|
301
|
+
const value = unwrap(expression);
|
|
302
|
+
if (!value) return false;
|
|
303
|
+
if (value === node) return true;
|
|
304
|
+
if (value.type === "CallExpression" && chainStart(value) === node) return true;
|
|
305
|
+
return value.type === "Identifier" && kept?.name === value.name;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const returned = findAll(
|
|
309
|
+
fn,
|
|
310
|
+
(n) => n.type === "ReturnStatement" && n.argument && scopeOf(ast, n) === fn,
|
|
311
|
+
).map((n) => n.argument);
|
|
312
|
+
if (fn.type === "ArrowFunctionExpression" && fn.expression) returned.push(fn.body);
|
|
313
|
+
|
|
314
|
+
let slot = null;
|
|
315
|
+
for (const expression of returned) {
|
|
316
|
+
const value = unwrap(expression);
|
|
317
|
+
if (holds(value)) {
|
|
318
|
+
slot = "whole";
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
if (value?.type === "ArrayExpression") {
|
|
322
|
+
const index = value.elements.findIndex((element) => element && holds(element));
|
|
323
|
+
if (index !== -1) {
|
|
324
|
+
slot = index;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
177
327
|
}
|
|
178
328
|
}
|
|
329
|
+
if (slot === null) return false;
|
|
330
|
+
|
|
331
|
+
const holder = parentOf(ast, fn);
|
|
332
|
+
const name =
|
|
333
|
+
fn.type === "FunctionDeclaration"
|
|
334
|
+
? fn.id?.name
|
|
335
|
+
: holder?.type === "VariableDeclarator" &&
|
|
336
|
+
holder.init === fn &&
|
|
337
|
+
holder.id.type === "Identifier"
|
|
338
|
+
? holder.id.name
|
|
339
|
+
: null;
|
|
340
|
+
if (!name) return false;
|
|
179
341
|
|
|
180
|
-
const
|
|
181
|
-
|
|
342
|
+
const calls = findAll(
|
|
343
|
+
ast,
|
|
344
|
+
(n) =>
|
|
345
|
+
n.type === "CallExpression" &&
|
|
346
|
+
unwrap(n.callee)?.type === "Identifier" &&
|
|
347
|
+
unwrap(n.callee).name === name,
|
|
348
|
+
);
|
|
349
|
+
return (
|
|
350
|
+
calls.length > 0 &&
|
|
351
|
+
calls.every((call) => {
|
|
352
|
+
const parent = parentOf(ast, call);
|
|
353
|
+
const target =
|
|
354
|
+
parent?.type === "VariableDeclarator" && parent.init === call
|
|
355
|
+
? parent.id
|
|
356
|
+
: parent?.type === "AssignmentExpression" && parent.right === call
|
|
357
|
+
? parent.left
|
|
358
|
+
: null;
|
|
359
|
+
if (!target) return false;
|
|
360
|
+
if (slot === "whole") return endsUnder(dottedName(target));
|
|
361
|
+
const element = target.type === "ArrayPattern" ? target.elements[slot] : null;
|
|
362
|
+
return element?.type === "Identifier" && endsUnder(element.name);
|
|
363
|
+
})
|
|
182
364
|
);
|
|
183
|
-
return { children, completes };
|
|
184
365
|
}
|
|
185
366
|
|
|
186
367
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
368
|
+
* Whether `node`, inside a high-frequency handler, runs less often than once
|
|
369
|
+
* per event: inside a completion callback — `onComplete`, `onStart`,
|
|
370
|
+
* `onReverseComplete`, `onInterrupt` — of something the handler started, or
|
|
371
|
+
* behind an in-flight flag the handler raises first,
|
|
372
|
+
* `if (!busy.current) { busy.current = true; … }`.
|
|
190
373
|
*/
|
|
191
|
-
function
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (quote) {
|
|
201
|
-
if (c === "\\") i += 1;
|
|
202
|
-
else if (c === quote) quote = "";
|
|
203
|
-
continue;
|
|
374
|
+
function notPerEvent(ast, handler, node) {
|
|
375
|
+
for (const ancestor of ancestorsOf(ast, node)) {
|
|
376
|
+
if (ancestor === handler.node) return false;
|
|
377
|
+
if (
|
|
378
|
+
ancestor.type === "Property" &&
|
|
379
|
+
/^on(?:Complete|Start|ReverseComplete|Interrupt)$/.test(keyName(ancestor) ?? "") &&
|
|
380
|
+
isFunction(unwrap(ancestor.value))
|
|
381
|
+
) {
|
|
382
|
+
return true;
|
|
204
383
|
}
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
384
|
+
if (ancestor.type === "IfStatement" && contains(ancestor.consequent, node)) {
|
|
385
|
+
const test = unwrap(ancestor.test);
|
|
386
|
+
const flag =
|
|
387
|
+
test?.type === "UnaryExpression" && test.operator === "!"
|
|
388
|
+
? dottedName(test.argument)
|
|
389
|
+
: null;
|
|
390
|
+
const raised =
|
|
391
|
+
flag !== null &&
|
|
392
|
+
findAll(ancestor.consequent, (n) => {
|
|
393
|
+
if (n.type !== "AssignmentExpression" || dottedName(n.left) !== flag) return false;
|
|
394
|
+
const value = unwrap(n.right);
|
|
395
|
+
return value?.type === "Literal" && value.value === true;
|
|
396
|
+
}).length > 0;
|
|
397
|
+
if (raised) return true;
|
|
211
398
|
}
|
|
212
399
|
}
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Listener targets that outlive every component: the window, the document and its root elements. */
|
|
404
|
+
const GLOBAL_TARGETS = new Set([
|
|
405
|
+
"window",
|
|
406
|
+
"document",
|
|
407
|
+
"document.body",
|
|
408
|
+
"document.documentElement",
|
|
409
|
+
"globalThis",
|
|
410
|
+
"self",
|
|
411
|
+
"visualViewport",
|
|
412
|
+
"window.visualViewport",
|
|
413
|
+
]);
|
|
213
414
|
|
|
214
|
-
|
|
215
|
-
|
|
415
|
+
/**
|
|
416
|
+
* Whether `target` is an element or object the file builds itself — every
|
|
417
|
+
* value it is given comes from `new …` or `createElement` — and so takes its
|
|
418
|
+
* listeners with it when it goes.
|
|
419
|
+
*/
|
|
420
|
+
function createdHere(ast, target) {
|
|
421
|
+
const builds = (value) => {
|
|
422
|
+
const v = unwrap(value);
|
|
423
|
+
return (
|
|
424
|
+
v?.type === "NewExpression" ||
|
|
425
|
+
["createElement", "createElementNS", "cloneNode"].includes(methodName(v))
|
|
426
|
+
);
|
|
427
|
+
};
|
|
428
|
+
if (builds(target)) return true;
|
|
429
|
+
if (target.type !== "Identifier") return false;
|
|
430
|
+
|
|
431
|
+
const assignments = findAll(
|
|
432
|
+
ast,
|
|
433
|
+
(n) =>
|
|
434
|
+
(n.type === "VariableDeclarator" &&
|
|
435
|
+
n.id.type === "Identifier" &&
|
|
436
|
+
n.id.name === target.name &&
|
|
437
|
+
n.init) ||
|
|
438
|
+
(n.type === "AssignmentExpression" && dottedName(n.left) === target.name),
|
|
439
|
+
);
|
|
440
|
+
return (
|
|
441
|
+
assignments.length > 0 &&
|
|
442
|
+
assignments.every((n) => builds(n.type === "VariableDeclarator" ? n.init : n.right))
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A value that is the same on every call: a literal, a plain template, `undefined`. */
|
|
447
|
+
const isConstant = (node) => {
|
|
448
|
+
const value = unwrap(node);
|
|
449
|
+
return (
|
|
450
|
+
value?.type === "Literal" ||
|
|
451
|
+
staticString(value) !== null ||
|
|
452
|
+
(value?.type === "Identifier" && value.name === "undefined")
|
|
453
|
+
);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The plugins a file registers, by the names they are imported under from
|
|
458
|
+
* `gsap/*`, so `ScrollTrigger as ST` registered as `ST` is `ScrollTrigger`.
|
|
459
|
+
* Registration is global, so the runner unions these across every audited file
|
|
460
|
+
* before any rule runs.
|
|
461
|
+
*/
|
|
462
|
+
export function pluginRegistrations(file) {
|
|
463
|
+
const names = new Set();
|
|
464
|
+
if (!file.ast) return names;
|
|
465
|
+
|
|
466
|
+
const imported = new Map();
|
|
467
|
+
for (const declaration of file.ast.body) {
|
|
468
|
+
if (declaration.type !== "ImportDeclaration") continue;
|
|
469
|
+
const from = declaration.source.value;
|
|
470
|
+
if (typeof from !== "string" || !/^gsap\/[\w/-]+$/.test(from)) continue;
|
|
471
|
+
for (const specifier of declaration.specifiers) {
|
|
472
|
+
const name =
|
|
473
|
+
specifier.type === "ImportSpecifier"
|
|
474
|
+
? (specifier.imported.name ?? specifier.imported.value)
|
|
475
|
+
: specifier.type === "ImportDefaultSpecifier"
|
|
476
|
+
? from.split("/").pop()
|
|
477
|
+
: null;
|
|
478
|
+
if (name) imported.set(specifier.local.name, name);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const registrations = findAll(file.ast, (node) => {
|
|
483
|
+
const name = calleeName(node);
|
|
484
|
+
return name === "registerPlugin" || name?.endsWith(".registerPlugin");
|
|
485
|
+
});
|
|
486
|
+
for (const call of registrations) {
|
|
487
|
+
for (const argument of call.arguments) {
|
|
488
|
+
for (const id of findAll(argument, (n) => n.type === "Identifier")) {
|
|
489
|
+
names.add(imported.get(id.name) ?? id.name);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return names;
|
|
216
494
|
}
|
|
217
495
|
|
|
218
496
|
export const RULES = [
|
|
@@ -220,29 +498,27 @@ export const RULES = [
|
|
|
220
498
|
{
|
|
221
499
|
id: "orphan-tween",
|
|
222
500
|
level: "error",
|
|
501
|
+
description:
|
|
502
|
+
"A tween created outside `useGSAP`, `gsap.context` or `contextSafe` in React — never reverted, and doubled by StrictMode",
|
|
503
|
+
/**
|
|
504
|
+
* A GSAP tween call inside a function but outside every context — module
|
|
505
|
+
* scope is not the target. Contexts are recognised under the names the file
|
|
506
|
+
* gives them, and a helper counts as inside when every use of it is a call
|
|
507
|
+
* from inside one. A value torn down by hand — see `tornDown` — is not
|
|
508
|
+
* reported: "nothing reverts it" would be false.
|
|
509
|
+
*/
|
|
223
510
|
test(file) {
|
|
224
511
|
if (!file.isReact || !file.usesGsap) return [];
|
|
512
|
+
const inside = gsapContexts(file.ast);
|
|
225
513
|
|
|
226
|
-
return
|
|
227
|
-
.filter((
|
|
228
|
-
.filter((
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
* `\b` needs a word character beside it and `=>` has none, so the
|
|
235
|
-
* arrow alternative could never match: every tween in an arrow-function
|
|
236
|
-
* handler, which is most of React, was silently exempt. `function`
|
|
237
|
-
* only matched the anonymous `function (`, never `function Name(`.
|
|
238
|
-
*/
|
|
239
|
-
const before = file.code.slice(0, m.index);
|
|
240
|
-
return /(?:\bfunction\b[^(]*\(|=>)/.test(before.slice(-400));
|
|
241
|
-
})
|
|
242
|
-
.map((m) => ({
|
|
243
|
-
index: m.index,
|
|
244
|
-
message: `\`gsap.${m[1]}\` is created outside any useGSAP/gsap.context scope.`,
|
|
245
|
-
hint: "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. This check does not follow calls: a helper that tweens reads as outside even when only the body calls it — keep the measuring in the helper and the tween in the body.",
|
|
514
|
+
return findAll(file.ast, (node) => isGsapCall(node))
|
|
515
|
+
.filter((call) => !inside(call))
|
|
516
|
+
.filter((call) => ancestorsOf(file.ast, call).some(isFunction))
|
|
517
|
+
.filter((call) => !tornDown(file.ast, call))
|
|
518
|
+
.map((call) => ({
|
|
519
|
+
index: call.start,
|
|
520
|
+
message: `\`gsap.${methodName(call)}\` is created outside any useGSAP/gsap.context scope.`,
|
|
521
|
+
hint: HINT.orphanTween,
|
|
246
522
|
}));
|
|
247
523
|
},
|
|
248
524
|
},
|
|
@@ -263,41 +539,101 @@ export const RULES = [
|
|
|
263
539
|
{
|
|
264
540
|
id: "unmanaged-instance",
|
|
265
541
|
level: "error",
|
|
542
|
+
description:
|
|
543
|
+
"`matchMedia`, `Observer`, `Draggable`, `ScrollSmoother` or `SplitText` created outside a context and never torn down",
|
|
544
|
+
/**
|
|
545
|
+
* Torn down as `tornDown` reads it: kept under a name — through any chain
|
|
546
|
+
* called on it, or a helper's return — that has `.revert()` or `.kill()`
|
|
547
|
+
* called on it somewhere in the file, optional chaining included.
|
|
548
|
+
*/
|
|
266
549
|
test(file) {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
550
|
+
const inside = gsapContexts(file.ast);
|
|
551
|
+
const CREATORS = new Set(["Observer", "Draggable", "ScrollSmoother", "ScrollTrigger"]);
|
|
552
|
+
|
|
553
|
+
const label = (node) => {
|
|
554
|
+
if (node.type === "NewExpression") {
|
|
555
|
+
const callee = unwrap(node.callee);
|
|
556
|
+
return callee?.type === "Identifier" && callee.name === "SplitText"
|
|
557
|
+
? "SplitText"
|
|
558
|
+
: null;
|
|
559
|
+
}
|
|
560
|
+
if (node.type !== "CallExpression") return null;
|
|
561
|
+
const name = calleeName(node);
|
|
562
|
+
if (name === "gsap.matchMedia") return "matchMedia";
|
|
563
|
+
const [owner, method, extra] = name?.split(".") ?? [];
|
|
564
|
+
return method === "create" && extra === undefined && CREATORS.has(owner)
|
|
565
|
+
? owner
|
|
566
|
+
: null;
|
|
567
|
+
};
|
|
568
|
+
return findAll(file.ast, (node) => label(node) !== null)
|
|
569
|
+
.filter((node) => !inside(node))
|
|
570
|
+
.filter((node) => !tornDown(file.ast, node))
|
|
571
|
+
.map((node) => ({
|
|
572
|
+
index: node.start,
|
|
573
|
+
message: `\`${label(node)}\` is created outside any useGSAP/gsap.context scope and never torn down.`,
|
|
574
|
+
hint: HINT.unmanagedInstance,
|
|
277
575
|
}));
|
|
278
576
|
},
|
|
279
577
|
},
|
|
280
578
|
{
|
|
281
579
|
id: "dangling-listener",
|
|
282
580
|
level: "warn",
|
|
581
|
+
description:
|
|
582
|
+
"An event listener that is never removed, including inline handlers that cannot be",
|
|
583
|
+
/**
|
|
584
|
+
* Adds and removes are paired by event and by the handler's name as
|
|
585
|
+
* written, across the file — not by counting calls. A handler that is not a
|
|
586
|
+
* plain name — an inline function, or `fn.bind(this)`, which makes a new
|
|
587
|
+
* function each call — can never be matched by a remove. `signal` and
|
|
588
|
+
* `once: true` are read from the options object. A dynamic event name
|
|
589
|
+
* cannot be judged statically, so it is skipped.
|
|
590
|
+
*
|
|
591
|
+
* Only a target that can outlive the code is reported. An element or object
|
|
592
|
+
* the file creates takes its listeners with it. Outside React, where nothing
|
|
593
|
+
* says when code stops running, only the window and the document can: a
|
|
594
|
+
* listener on a page's own button lasts exactly as long as the button.
|
|
595
|
+
*/
|
|
283
596
|
test(file) {
|
|
597
|
+
const listeners = (method) =>
|
|
598
|
+
findAll(file.ast, (node) => listenerMethod(node) === method).flatMap((call) => {
|
|
599
|
+
const event = staticString(call.arguments[0]);
|
|
600
|
+
const handler = unwrap(call.arguments[1]);
|
|
601
|
+
if (event === null || !/^[\w:-]+$/.test(event) || !handler) return [];
|
|
602
|
+
const options = unwrap(call.arguments[2]);
|
|
603
|
+
const once = unwrap(propertyOf(options, "once")?.value);
|
|
604
|
+
const callee = unwrap(call.callee);
|
|
605
|
+
const target = callee.type === "MemberExpression" ? unwrap(callee.object) : null;
|
|
606
|
+
return [
|
|
607
|
+
{
|
|
608
|
+
global: target === null || GLOBAL_TARGETS.has(dottedName(target)),
|
|
609
|
+
created: target !== null && createdHere(file.ast, target),
|
|
610
|
+
index: callee.type === "MemberExpression" ? callee.property.start : callee.start,
|
|
611
|
+
event,
|
|
612
|
+
handler: dottedName(handler),
|
|
613
|
+
inline: isFunction(handler) || dottedName(handler) === null,
|
|
614
|
+
selfCleaning:
|
|
615
|
+
Boolean(propertyOf(options, "signal")) ||
|
|
616
|
+
(once?.type === "Literal" && once.value === true),
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
});
|
|
620
|
+
|
|
284
621
|
const removed = new Set(
|
|
285
|
-
|
|
622
|
+
listeners("removeEventListener")
|
|
286
623
|
.filter((call) => call.handler)
|
|
287
624
|
.map((call) => `${call.event}|${call.handler}`),
|
|
288
625
|
);
|
|
289
626
|
|
|
290
|
-
return
|
|
291
|
-
.filter((call) => call.
|
|
292
|
-
.filter(
|
|
293
|
-
|
|
294
|
-
)
|
|
627
|
+
return listeners("addEventListener")
|
|
628
|
+
.filter((call) => !call.selfCleaning && !call.created)
|
|
629
|
+
.filter((call) => file.isReact || call.global)
|
|
630
|
+
.filter((call) => call.inline || !removed.has(`${call.event}|${call.handler}`))
|
|
295
631
|
.map((call) => ({
|
|
296
632
|
index: call.index,
|
|
297
633
|
message: call.inline
|
|
298
634
|
? `An inline \`${call.event}\` listener, which no removeEventListener can reach.`
|
|
299
635
|
: `\`${call.event}\` listener \`${call.handler}\` is added and never removed.`,
|
|
300
|
-
hint:
|
|
636
|
+
hint: HINT.danglingListener,
|
|
301
637
|
}));
|
|
302
638
|
},
|
|
303
639
|
},
|
|
@@ -306,114 +642,159 @@ export const RULES = [
|
|
|
306
642
|
{
|
|
307
643
|
id: "tween-per-event",
|
|
308
644
|
level: "error",
|
|
645
|
+
description:
|
|
646
|
+
"A new tween allocated on every pointer, scroll or wheel event",
|
|
647
|
+
/**
|
|
648
|
+
* A GSAP tween call inside a high-frequency handler, followed by name when
|
|
649
|
+
* it can be. A debounced one, one in a completion callback, and one behind
|
|
650
|
+
* an in-flight flag run less than once per event, and are not reported.
|
|
651
|
+
*/
|
|
309
652
|
test(file) {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
for (const handler of find(file.code, HOT_EVENTS)) {
|
|
313
|
-
const span = blockAfter(file.code, handler.index);
|
|
314
|
-
if (!span) continue;
|
|
315
|
-
|
|
316
|
-
for (const call of tweenCalls(file.code)) {
|
|
317
|
-
if (call[1] === "set" || !within([span], call.index)) continue;
|
|
318
|
-
findings.push({
|
|
319
|
-
index: call.index,
|
|
320
|
-
message: `\`gsap.${call[1]}\` inside a high-frequency handler (${handler[1]}).`,
|
|
321
|
-
hint: "One tween allocated per event, each fighting the last. Create a `gsap.quickTo()` once and call it from the handler.",
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
}
|
|
653
|
+
const calls = findAll(file.ast, (node) => isGsapCall(node));
|
|
325
654
|
|
|
326
|
-
return
|
|
655
|
+
return handlersFor(file.ast, HOT_EVENTS).flatMap((handler) =>
|
|
656
|
+
calls
|
|
657
|
+
.filter((call) => contains(handler.node, call))
|
|
658
|
+
.filter((call) => !debounced(handler, call))
|
|
659
|
+
.filter((call) => !notPerEvent(file.ast, handler, call))
|
|
660
|
+
.map((call) => ({
|
|
661
|
+
index: call.start,
|
|
662
|
+
message: `\`gsap.${methodName(call)}\` inside a high-frequency handler (${handler.name}).`,
|
|
663
|
+
hint: HINT.tweenPerEvent,
|
|
664
|
+
})),
|
|
665
|
+
);
|
|
327
666
|
},
|
|
328
667
|
},
|
|
329
668
|
{
|
|
330
669
|
id: "state-per-event",
|
|
331
670
|
level: "error",
|
|
671
|
+
description:
|
|
672
|
+
"React state set in a high-frequency handler — a re-render per frame",
|
|
673
|
+
/**
|
|
674
|
+
* A bare `setX(…)` call — not a member call such as `el.style.setProperty`,
|
|
675
|
+
* which is exactly what a pointer handler should do, and not a timer —
|
|
676
|
+
* inside an inline high-frequency handler other than resize.
|
|
677
|
+
*
|
|
678
|
+
* A handler passed by name is not followed here, unlike tween-per-event. A
|
|
679
|
+
* named scroll handler is usually a scroll-spy setting a discrete value — a
|
|
680
|
+
* section id, a boolean — and React skips the render when that value has not
|
|
681
|
+
* changed, so "a render per frame" would be false and, at error level, would
|
|
682
|
+
* fail correct code. Telling a continuous value from a discrete one is a
|
|
683
|
+
* precision question for the corpus, not something to guess.
|
|
684
|
+
*
|
|
685
|
+
* A setter called with a constant — `null`, a boolean, a literal — is not
|
|
686
|
+
* reported either: once the state holds that value React skips the render,
|
|
687
|
+
* so it costs one render, not one per frame. The corpus found
|
|
688
|
+
* `onScroll={() => setHoveredCard(null)}`.
|
|
689
|
+
*/
|
|
332
690
|
test(file) {
|
|
333
691
|
if (!file.isReact) return [];
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
index: setter.index,
|
|
344
|
-
message: `React state setter inside a ${handler[1]} handler.`,
|
|
345
|
-
hint: "A re-render per frame so one element can move a few pixels. State owns what exists; GSAP owns how it moves — use a ref and quickTo.",
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
}
|
|
692
|
+
const setters = findAll(file.ast, (node) => {
|
|
693
|
+
if (node.type !== "CallExpression") return false;
|
|
694
|
+
const callee = unwrap(node.callee);
|
|
695
|
+
return (
|
|
696
|
+
callee?.type === "Identifier" &&
|
|
697
|
+
/^set(?!Timeout$|Interval$)[A-Z]\w*$/.test(callee.name) &&
|
|
698
|
+
!(node.arguments.length <= 1 && node.arguments.every(isConstant))
|
|
699
|
+
);
|
|
700
|
+
});
|
|
349
701
|
|
|
350
|
-
return
|
|
702
|
+
return handlersFor(file.ast, STATE_HOT_EVENTS, { followNames: false }).flatMap(
|
|
703
|
+
(handler) =>
|
|
704
|
+
setters
|
|
705
|
+
.filter((setter) => contains(handler.node, setter))
|
|
706
|
+
.filter((setter) => !debounced(handler, setter))
|
|
707
|
+
.map((setter) => ({
|
|
708
|
+
index: setter.start,
|
|
709
|
+
message: `React state setter inside a ${handler.name} handler.`,
|
|
710
|
+
hint: HINT.statePerEvent,
|
|
711
|
+
})),
|
|
712
|
+
);
|
|
351
713
|
},
|
|
352
714
|
},
|
|
353
715
|
{
|
|
354
716
|
id: "layout-property",
|
|
355
717
|
level: "warn",
|
|
718
|
+
description:
|
|
719
|
+
"Animating `width`, `height`, `top`, `left`, margins or padding, which forces layout every frame",
|
|
720
|
+
/**
|
|
721
|
+
* The layout keys of every vars object a tween carries — both of a
|
|
722
|
+
* `fromTo`'s — on any receiver, so a timeline child counts. One finding per
|
|
723
|
+
* key per call: a `fromTo` that names `height` in both vars animates one
|
|
724
|
+
* property, and says so once, where it first appears. `gsap.set` animates
|
|
725
|
+
* nothing, and is skipped: measuring with `set(el, { height: "auto" })` is
|
|
726
|
+
* not a frame of layout.
|
|
727
|
+
*/
|
|
356
728
|
test(file) {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
729
|
+
const LAYOUT = new Set(LAYOUT_PROPERTIES);
|
|
730
|
+
const animates = (node) => {
|
|
731
|
+
const method = tweenMethod(node);
|
|
732
|
+
return method !== null && method !== "set";
|
|
733
|
+
};
|
|
361
734
|
|
|
362
|
-
return
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return {
|
|
385
|
-
index: span[0] + m.index,
|
|
386
|
-
message: `Animating \`${m[1]}\` forces layout on every frame.`,
|
|
387
|
-
hint: swap
|
|
388
|
-
? `Use \`${swap}\` instead — a compositor-only property.` +
|
|
389
|
-
(percent ? ` ${PERCENT_CAVEAT}` : "")
|
|
390
|
-
: "Prefer transform and opacity; they never touch layout.",
|
|
391
|
-
};
|
|
392
|
-
});
|
|
393
|
-
});
|
|
735
|
+
return findAll(file.ast, animates).flatMap(
|
|
736
|
+
(call) => {
|
|
737
|
+
const seen = new Set();
|
|
738
|
+
return varsObjects(call, tweenMethod(call))
|
|
739
|
+
.flatMap((vars) => vars.properties)
|
|
740
|
+
.filter((property) => {
|
|
741
|
+
const key = keyName(property);
|
|
742
|
+
if (!LAYOUT.has(key) || staticString(property.value) === "") return false;
|
|
743
|
+
if (seen.has(key)) return false;
|
|
744
|
+
seen.add(key);
|
|
745
|
+
return true;
|
|
746
|
+
})
|
|
747
|
+
.map((property) => ({
|
|
748
|
+
index: property.start,
|
|
749
|
+
message: `Animating \`${keyName(property)}\` forces layout on every frame.`,
|
|
750
|
+
hint: layoutHint(
|
|
751
|
+
keyName(property),
|
|
752
|
+
findAll(property.value, (node) => spells(node, "%")).length > 0,
|
|
753
|
+
),
|
|
754
|
+
}));
|
|
755
|
+
},
|
|
756
|
+
);
|
|
394
757
|
},
|
|
395
758
|
},
|
|
396
759
|
{
|
|
397
760
|
id: "trigger-per-item",
|
|
398
761
|
level: "warn",
|
|
762
|
+
description:
|
|
763
|
+
"A ScrollTrigger created per item in a loop",
|
|
764
|
+
/**
|
|
765
|
+
* A `scrollTrigger` property inside a loop body: the callback of `.forEach`
|
|
766
|
+
* or `.map`, or a `for`, `for…of` or `for…in` statement. One finding per
|
|
767
|
+
* file, at the first.
|
|
768
|
+
*/
|
|
399
769
|
test(file) {
|
|
400
|
-
|
|
401
|
-
.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
.
|
|
770
|
+
const callbacks = findAll(file.ast, (node) => {
|
|
771
|
+
if (node.type !== "CallExpression") return false;
|
|
772
|
+
const callee = unwrap(node.callee);
|
|
773
|
+
return (
|
|
774
|
+
callee?.type === "MemberExpression" &&
|
|
775
|
+
!callee.computed &&
|
|
776
|
+
(callee.property.name === "forEach" || callee.property.name === "map")
|
|
777
|
+
);
|
|
778
|
+
})
|
|
779
|
+
.map((call) => unwrap(call.arguments[0]))
|
|
780
|
+
.filter(isFunction);
|
|
781
|
+
const loops = findAll(file.ast, (node) =>
|
|
782
|
+
["ForStatement", "ForOfStatement", "ForInStatement"].includes(node.type),
|
|
783
|
+
).map((loop) => loop.body);
|
|
784
|
+
|
|
785
|
+
const [first] = [...callbacks, ...loops]
|
|
786
|
+
.flatMap((body) => findAll(body, (node) => keyName(node) === "scrollTrigger"))
|
|
787
|
+
.sort((a, b) => a.start - b.start);
|
|
788
|
+
|
|
789
|
+
return first
|
|
790
|
+
? [
|
|
791
|
+
{
|
|
792
|
+
index: first.start,
|
|
793
|
+
message: "A ScrollTrigger created per item in a loop.",
|
|
794
|
+
hint: HINT.triggerPerItem,
|
|
795
|
+
},
|
|
796
|
+
]
|
|
797
|
+
: [];
|
|
417
798
|
},
|
|
418
799
|
},
|
|
419
800
|
|
|
@@ -421,87 +802,75 @@ export const RULES = [
|
|
|
421
802
|
{
|
|
422
803
|
id: "eased-loop",
|
|
423
804
|
level: "warn",
|
|
805
|
+
description:
|
|
806
|
+
"An infinite repeat with an ease, which makes its own seam visible",
|
|
807
|
+
/**
|
|
808
|
+
* The tween's own vars — a `fromTo`'s to-vars — on any receiver, so a
|
|
809
|
+
* timeline child counts. Keys at the top level only: a `stagger` object's
|
|
810
|
+
* own `repeat` is not the tween's. `yoyo` reverses rather than restarting,
|
|
811
|
+
* so there is no seam to hide.
|
|
812
|
+
*/
|
|
424
813
|
test(file) {
|
|
425
|
-
return
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
hint: "The tween decelerates into its own restart, so the loop seam becomes visible. Use `ease: \"none\"`, or `yoyo: true` if it should breathe.",
|
|
441
|
-
},
|
|
442
|
-
];
|
|
443
|
-
});
|
|
814
|
+
return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap(
|
|
815
|
+
(call) => {
|
|
816
|
+
const vars = ownVars(call, tweenMethod(call));
|
|
817
|
+
if (!vars) return [];
|
|
818
|
+
const repeat = propertyOf(vars, "repeat");
|
|
819
|
+
if (!repeat || numberValue(repeat.value) !== -1) return [];
|
|
820
|
+
const yoyo = unwrap(propertyOf(vars, "yoyo")?.value);
|
|
821
|
+
if (yoyo?.type === "Literal" && yoyo.value === true) return [];
|
|
822
|
+
const ease = propertyOf(vars, "ease");
|
|
823
|
+
if (!ease || staticString(ease.value) === "none") return [];
|
|
824
|
+
return [
|
|
825
|
+
{ index: ease.start, message: MESSAGE.easedLoop, hint: HINT.easedLoop },
|
|
826
|
+
];
|
|
827
|
+
},
|
|
828
|
+
);
|
|
444
829
|
},
|
|
445
830
|
},
|
|
446
831
|
{
|
|
447
832
|
id: "eased-scrub",
|
|
448
833
|
level: "warn",
|
|
834
|
+
description:
|
|
835
|
+
"Easing inside a scrubbed ScrollTrigger, which fights the scrollbar",
|
|
836
|
+
/**
|
|
837
|
+
* A GSAP call scrubbed anywhere in its arguments — `scrub: false` aside —
|
|
838
|
+
* with a string ease other than `"none"` in those arguments or, for a
|
|
839
|
+
* timeline, in a child `timelineLinks` finds: the chain, and the name it is
|
|
840
|
+
* kept under within its scope, `this.tl` included. One finding per file.
|
|
841
|
+
*/
|
|
449
842
|
test(file) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
* a scrubbed timeline is the common case, and it used to be missed: only
|
|
465
|
-
* the timeline's own vars were read, and they rarely carry the ease.
|
|
466
|
-
*/
|
|
467
|
-
for (const call of find(
|
|
468
|
-
file.code,
|
|
469
|
-
/\bgsap\s*\.\s*(to|from|fromTo|timeline)\s*\(/g,
|
|
470
|
-
)) {
|
|
471
|
-
const open = call.index + call[0].length - 1;
|
|
472
|
-
const span = parenSpan(file.code, open);
|
|
473
|
-
if (!span) continue;
|
|
474
|
-
const args = file.code.slice(open, span[1]);
|
|
475
|
-
if (!/scrub\s*:/.test(args)) continue;
|
|
476
|
-
|
|
477
|
-
const own = eased(args);
|
|
478
|
-
if (own !== -1) report(open + own);
|
|
479
|
-
if (call[1] !== "timeline") continue;
|
|
480
|
-
|
|
481
|
-
const children = chainedTweens(file.code, span[1] + 1);
|
|
482
|
-
const kept = file.code
|
|
483
|
-
.slice(Math.max(0, call.index - 80), call.index)
|
|
484
|
-
.match(/([\w$]+)\s*=\s*$/);
|
|
485
|
-
if (kept) {
|
|
486
|
-
const name = kept[1].replace(/\$/g, "\\$");
|
|
487
|
-
const uses = new RegExp(
|
|
488
|
-
`(?<![\\w$.])${name}(?=\\s*\\.\\s*(?:to|from|fromTo)\\s*\\()`,
|
|
489
|
-
"g",
|
|
490
|
-
);
|
|
491
|
-
for (const use of find(file.code, uses)) {
|
|
492
|
-
children.push(
|
|
493
|
-
...chainedTweens(file.code, use.index + use[0].length),
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
843
|
+
const scrubbed = (node) =>
|
|
844
|
+
findAll(node, (n) => {
|
|
845
|
+
if (keyName(n) !== "scrub") return false;
|
|
846
|
+
const value = unwrap(n.value);
|
|
847
|
+
return !(value?.type === "Literal" && value.value === false);
|
|
848
|
+
}).length > 0;
|
|
849
|
+
const eased = (nodes) =>
|
|
850
|
+
nodes.flatMap((node) =>
|
|
851
|
+
findAll(node, (n) => {
|
|
852
|
+
if (keyName(n) !== "ease") return false;
|
|
853
|
+
const value = staticString(n.value);
|
|
854
|
+
return value !== null && value !== "none";
|
|
855
|
+
}),
|
|
856
|
+
)[0];
|
|
497
857
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
858
|
+
for (const call of findAll(file.ast, (node) => isGsapCall(node))) {
|
|
859
|
+
if (!call.arguments.some(scrubbed)) continue;
|
|
860
|
+
const own = eased(call.arguments);
|
|
861
|
+
const child =
|
|
862
|
+
methodName(call) === "timeline"
|
|
863
|
+
? timelineLinks(file.ast, call)
|
|
864
|
+
.filter((link) => CHILDREN.has(methodName(link)))
|
|
865
|
+
.map((link) => eased(link.arguments))
|
|
866
|
+
.find(Boolean)
|
|
867
|
+
: undefined;
|
|
868
|
+
const at = own ?? child;
|
|
869
|
+
if (at) {
|
|
870
|
+
return [{ index: at.start, message: MESSAGE.easedScrub, hint: HINT.easedScrub }];
|
|
501
871
|
}
|
|
502
872
|
}
|
|
503
|
-
|
|
504
|
-
return findings.slice(0, 1);
|
|
873
|
+
return [];
|
|
505
874
|
},
|
|
506
875
|
},
|
|
507
876
|
/**
|
|
@@ -519,48 +888,57 @@ export const RULES = [
|
|
|
519
888
|
{
|
|
520
889
|
id: "never-completes",
|
|
521
890
|
level: "error",
|
|
891
|
+
description:
|
|
892
|
+
"An `onComplete` that can never run: on a timeline holding a `repeat: -1` child, or on a tween or timeline that repeats forever",
|
|
893
|
+
/**
|
|
894
|
+
* `onComplete` and `repeat: -1` anywhere in a GSAP call's arguments; for a
|
|
895
|
+
* timeline, its children and any `eventCallback("onComplete", …)` from
|
|
896
|
+
* `timelineLinks` — the chain, and the name it is kept under within its
|
|
897
|
+
* scope, `this.tl` included.
|
|
898
|
+
*/
|
|
522
899
|
test(file) {
|
|
523
|
-
|
|
524
|
-
|
|
900
|
+
const endless = (nodes) =>
|
|
901
|
+
nodes.flatMap((node) =>
|
|
902
|
+
findAll(node, (n) => keyName(n) === "repeat" && numberValue(n.value) === -1),
|
|
903
|
+
)[0];
|
|
525
904
|
const findings = [];
|
|
526
905
|
|
|
527
|
-
for (const call of
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
if (
|
|
534
|
-
const args = file.code.slice(open, span[1]);
|
|
535
|
-
const waits = /\bonComplete\b/.test(args);
|
|
536
|
-
|
|
537
|
-
const own = args.search(endless);
|
|
538
|
-
if (waits && own !== -1) {
|
|
906
|
+
for (const call of findAll(file.ast, (node) => isGsapCall(node))) {
|
|
907
|
+
const method = methodName(call);
|
|
908
|
+
const waits = call.arguments.some(
|
|
909
|
+
(argument) => findAll(argument, (n) => keyName(n) === "onComplete").length > 0,
|
|
910
|
+
);
|
|
911
|
+
const own = endless(call.arguments);
|
|
912
|
+
if (waits && own) {
|
|
539
913
|
findings.push({
|
|
540
|
-
index:
|
|
541
|
-
message: `\`gsap.${
|
|
542
|
-
hint:
|
|
914
|
+
index: own.start,
|
|
915
|
+
message: `\`gsap.${method}\` repeats forever, so its \`onComplete\` can never run.`,
|
|
916
|
+
hint: HINT.neverCompletesOwn,
|
|
543
917
|
});
|
|
544
918
|
continue;
|
|
545
919
|
}
|
|
546
|
-
if (
|
|
920
|
+
if (method !== "timeline") continue;
|
|
547
921
|
|
|
548
|
-
const
|
|
922
|
+
const links = timelineLinks(file.ast, call);
|
|
923
|
+
const completes = links.some(
|
|
924
|
+
(link) =>
|
|
925
|
+
methodName(link) === "eventCallback" &&
|
|
926
|
+
staticString(link.arguments[0]) === "onComplete",
|
|
927
|
+
);
|
|
549
928
|
if (!waits && !completes) continue;
|
|
550
929
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
930
|
+
const child = links
|
|
931
|
+
.filter((link) => CHILDREN.has(methodName(link)))
|
|
932
|
+
.map((link) => endless(link.arguments))
|
|
933
|
+
.find(Boolean);
|
|
934
|
+
if (child) {
|
|
554
935
|
findings.push({
|
|
555
|
-
index: start
|
|
556
|
-
message:
|
|
557
|
-
|
|
558
|
-
hint: 'The child gives the timeline an effectively infinite duration, and a tween added after it does not end it. Hand over at a position instead — `.call(fn, [], "label+=0.5")` fires when the playhead passes — or run the loop as its own tween outside the timeline.',
|
|
936
|
+
index: child.start,
|
|
937
|
+
message: MESSAGE.neverCompletesChild,
|
|
938
|
+
hint: HINT.neverCompletesChild,
|
|
559
939
|
});
|
|
560
|
-
break;
|
|
561
940
|
}
|
|
562
941
|
}
|
|
563
|
-
|
|
564
942
|
return findings;
|
|
565
943
|
},
|
|
566
944
|
},
|
|
@@ -581,124 +959,167 @@ export const RULES = [
|
|
|
581
959
|
{
|
|
582
960
|
id: "late-transform-origin",
|
|
583
961
|
level: "warn",
|
|
962
|
+
description:
|
|
963
|
+
"A `fromTo` whose origin is only in its to-vars while its from-vars scale, rotate or skew, which leaves an SVG element offset",
|
|
964
|
+
/**
|
|
965
|
+
* Any `.fromTo(target, fromVars, toVars)` whose two vars are object
|
|
966
|
+
* literals. Keys are read at the top level of each, and a transform's
|
|
967
|
+
* from-value as it is written: `rotation: "90deg"` and `scale: 0` move the
|
|
968
|
+
* element, `scale: 1` and `rotation: 0` do not.
|
|
969
|
+
*/
|
|
584
970
|
test(file) {
|
|
585
|
-
const ORIGIN =
|
|
586
|
-
const TRANSFORM =
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
const v = value.trim().replace(/^["'`]|["'`]$/g, "");
|
|
590
|
-
return prop.startsWith("scale")
|
|
591
|
-
? /^1(?:\.0+)?$/.test(v)
|
|
592
|
-
: /^-?0(?:\.0+)?(?:deg|rad)?$/.test(v);
|
|
593
|
-
};
|
|
971
|
+
const ORIGIN = new Set(["transformOrigin", "svgOrigin"]);
|
|
972
|
+
const TRANSFORM = /^(?:scale[XY]?|rotation|rotate|skew[XY])$/;
|
|
973
|
+
|
|
974
|
+
const calls = findAll(file.ast, (node) => methodName(node) === "fromTo");
|
|
594
975
|
|
|
595
|
-
return
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
if (
|
|
599
|
-
const [, from, to] = callArgs(file.code, open, span[1]);
|
|
600
|
-
if (!from || !to) return [];
|
|
601
|
-
|
|
602
|
-
const fromVars = file.code.slice(from[0], from[1]);
|
|
603
|
-
const toVars = file.code.slice(to[0], to[1]);
|
|
604
|
-
const origin = toVars.search(ORIGIN);
|
|
605
|
-
if (origin === -1 || ORIGIN.test(fromVars)) return [];
|
|
606
|
-
if (/\bsmoothOrigin\s*:\s*false\b/.test(toVars)) return [];
|
|
607
|
-
if (
|
|
608
|
-
[...fromVars.matchAll(TRANSFORM)].every(([, prop, value]) =>
|
|
609
|
-
identity(prop, value),
|
|
610
|
-
)
|
|
611
|
-
) {
|
|
976
|
+
return calls.flatMap((call) => {
|
|
977
|
+
const from = unwrap(call.arguments[1]);
|
|
978
|
+
const to = unwrap(call.arguments[2]);
|
|
979
|
+
if (from?.type !== "ObjectExpression" || to?.type !== "ObjectExpression") {
|
|
612
980
|
return [];
|
|
613
981
|
}
|
|
982
|
+
const origin = to.properties.find((property) => ORIGIN.has(keyName(property)));
|
|
983
|
+
if (!origin) return [];
|
|
984
|
+
if (from.properties.some((property) => ORIGIN.has(keyName(property)))) return [];
|
|
985
|
+
const smooth = unwrap(propertyOf(to, "smoothOrigin")?.value);
|
|
986
|
+
if (smooth?.type === "Literal" && smooth.value === false) return [];
|
|
614
987
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
988
|
+
const moves = from.properties.some(
|
|
989
|
+
(property) =>
|
|
990
|
+
TRANSFORM.test(keyName(property) ?? "") &&
|
|
991
|
+
!identityTransform(
|
|
992
|
+
keyName(property),
|
|
993
|
+
file.raw.slice(property.value.start, property.value.end),
|
|
994
|
+
),
|
|
995
|
+
);
|
|
996
|
+
return moves
|
|
997
|
+
? [
|
|
998
|
+
{
|
|
999
|
+
index: origin.start,
|
|
1000
|
+
message: MESSAGE.lateTransformOrigin,
|
|
1001
|
+
hint: HINT.lateTransformOrigin,
|
|
1002
|
+
},
|
|
1003
|
+
]
|
|
1004
|
+
: [];
|
|
623
1005
|
});
|
|
624
1006
|
},
|
|
625
1007
|
},
|
|
626
1008
|
{
|
|
627
1009
|
id: "shared-plugin-id",
|
|
628
1010
|
level: "error",
|
|
1011
|
+
description:
|
|
1012
|
+
"A hardcoded `#id` in MotionPath or MorphSVG config, which two instances of a component will share",
|
|
1013
|
+
/**
|
|
1014
|
+
* Any property naming a target geometry whose value is a fixed `#id`: a
|
|
1015
|
+
* string or a template literal with nothing interpolated, including the
|
|
1016
|
+
* shorthands `motionPath: "#id"` and `morphSVG: "#id"`.
|
|
1017
|
+
*/
|
|
629
1018
|
test(file) {
|
|
630
1019
|
if (!file.isReact) return [];
|
|
1020
|
+
const KEYS = new Set(["path", "shape", "align", "motionPath", "morphSVG"]);
|
|
631
1021
|
|
|
632
|
-
return
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1022
|
+
return findAll(file.ast, (node) => KEYS.has(keyName(node))).flatMap(
|
|
1023
|
+
(property) => {
|
|
1024
|
+
const id = staticString(property.value)?.match(/^#[A-Za-z][\w-]*$/)?.[0];
|
|
1025
|
+
return id
|
|
1026
|
+
? [
|
|
1027
|
+
{
|
|
1028
|
+
index: property.start,
|
|
1029
|
+
message: `Hardcoded id \`${id}\` in plugin config.`,
|
|
1030
|
+
hint: HINT.sharedPluginId,
|
|
1031
|
+
},
|
|
1032
|
+
]
|
|
1033
|
+
: [];
|
|
1034
|
+
},
|
|
1035
|
+
);
|
|
640
1036
|
},
|
|
641
1037
|
},
|
|
642
1038
|
{
|
|
643
1039
|
id: "unregistered-plugin",
|
|
644
1040
|
level: "error",
|
|
1041
|
+
description:
|
|
1042
|
+
"A plugin imported and never registered, whose properties are silently ignored",
|
|
1043
|
+
/**
|
|
1044
|
+
* Registered means named anywhere inside the arguments of a
|
|
1045
|
+
* `registerPlugin` call — not mentioned anywhere after one, and under its
|
|
1046
|
+
* local name, so an alias registered by its alias counts. Imports are read
|
|
1047
|
+
* as declarations: a default import takes its plugin's name from the path,
|
|
1048
|
+
* and a type-only import, of the whole statement or of one specifier, ships
|
|
1049
|
+
* nothing and is skipped.
|
|
1050
|
+
*/
|
|
645
1051
|
test(file) {
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
? file.code
|
|
659
|
-
.slice(open + 1, span[1])
|
|
660
|
-
.split(/[^\w$]+/)
|
|
661
|
-
.filter(Boolean)
|
|
662
|
-
: [];
|
|
663
|
-
}),
|
|
664
|
-
);
|
|
1052
|
+
const registered = new Set();
|
|
1053
|
+
const registrations = findAll(file.ast, (node) => {
|
|
1054
|
+
const name = calleeName(node);
|
|
1055
|
+
return name === "registerPlugin" || name?.endsWith(".registerPlugin");
|
|
1056
|
+
});
|
|
1057
|
+
for (const call of registrations) {
|
|
1058
|
+
for (const arg of call.arguments) {
|
|
1059
|
+
for (const id of findAll(arg, (node) => node.type === "Identifier")) {
|
|
1060
|
+
registered.add(id.name);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
665
1064
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1065
|
+
const findings = [];
|
|
1066
|
+
for (const declaration of file.ast.body) {
|
|
1067
|
+
if (declaration.type !== "ImportDeclaration") continue;
|
|
1068
|
+
if (declaration.importKind === "type") continue;
|
|
1069
|
+
const from = declaration.source.value;
|
|
1070
|
+
if (typeof from !== "string" || !/^gsap\/[\w/-]+$/.test(from)) continue;
|
|
1071
|
+
|
|
1072
|
+
for (const specifier of declaration.specifiers) {
|
|
1073
|
+
if (specifier.importKind === "type") continue;
|
|
1074
|
+
const imported =
|
|
1075
|
+
specifier.type === "ImportSpecifier"
|
|
1076
|
+
? (specifier.imported.name ?? specifier.imported.value)
|
|
1077
|
+
: specifier.type === "ImportDefaultSpecifier"
|
|
1078
|
+
? from.split("/").pop()
|
|
1079
|
+
: null;
|
|
1080
|
+
if (!imported || !PLUGIN_NAME.test(imported)) continue;
|
|
1081
|
+
if (registered.has(specifier.local.name)) continue;
|
|
1082
|
+
if (file.registeredElsewhere?.has(imported)) continue;
|
|
1083
|
+
findings.push({
|
|
1084
|
+
index: declaration.start,
|
|
1085
|
+
message: `\`${imported}\` is imported but never passed to \`gsap.registerPlugin\`.`,
|
|
1086
|
+
hint: HINT.unregisteredPlugin,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return findings;
|
|
686
1091
|
},
|
|
687
1092
|
},
|
|
688
1093
|
{
|
|
689
1094
|
id: "missing-reduced-motion",
|
|
690
1095
|
level: "warn",
|
|
1096
|
+
description:
|
|
1097
|
+
"An animating file with no `prefers-reduced-motion` branch",
|
|
1098
|
+
/**
|
|
1099
|
+
* A branch is the query written in code — a string, a template or JSX
|
|
1100
|
+
* text. A comment that mentions it is not a branch. A file that only sets
|
|
1101
|
+
* values is not animating anything.
|
|
1102
|
+
*/
|
|
691
1103
|
test(file) {
|
|
692
1104
|
if (!file.usesGsap) return [];
|
|
693
|
-
if (
|
|
694
|
-
|
|
695
|
-
|
|
1105
|
+
if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
|
|
1106
|
+
return [];
|
|
1107
|
+
}
|
|
1108
|
+
if (!findAll(file.ast, (node) => isGsapCall(node)).length) return [];
|
|
1109
|
+
|
|
1110
|
+
const first = findAll(
|
|
1111
|
+
file.ast,
|
|
1112
|
+
(node) =>
|
|
1113
|
+
node.type === "MemberExpression" &&
|
|
1114
|
+
unwrap(node.object)?.type === "Identifier" &&
|
|
1115
|
+
unwrap(node.object).name === "gsap",
|
|
1116
|
+
).sort((a, b) => a.start - b.start)[0];
|
|
696
1117
|
|
|
697
1118
|
return [
|
|
698
1119
|
{
|
|
699
|
-
index:
|
|
1120
|
+
index: first.start,
|
|
700
1121
|
message: "No `prefers-reduced-motion` branch in an animating file.",
|
|
701
|
-
hint:
|
|
1122
|
+
hint: HINT.missingReducedMotion,
|
|
702
1123
|
},
|
|
703
1124
|
];
|
|
704
1125
|
},
|
|
@@ -708,25 +1129,47 @@ export const RULES = [
|
|
|
708
1129
|
{
|
|
709
1130
|
id: "dev-tool-shipped",
|
|
710
1131
|
level: "error",
|
|
1132
|
+
description:
|
|
1133
|
+
"`GSDevTools` or `MotionPathHelper` imported statically, or unconditional `markers: true`",
|
|
1134
|
+
/**
|
|
1135
|
+
* A static import declaration that brings in a dev tool, by a specifier's
|
|
1136
|
+
* name or, for a bare or default import, by its path. A type-only import
|
|
1137
|
+
* ships no code. `markers: true` is the property itself.
|
|
1138
|
+
*/
|
|
711
1139
|
test(file) {
|
|
1140
|
+
const TOOLS = /^(?:MotionPathHelper|GSDevTools)$/;
|
|
712
1141
|
const findings = [];
|
|
713
1142
|
|
|
714
|
-
for (const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1143
|
+
for (const declaration of file.ast.body) {
|
|
1144
|
+
if (declaration.type !== "ImportDeclaration") continue;
|
|
1145
|
+
if (declaration.importKind === "type") continue;
|
|
1146
|
+
const values = declaration.specifiers.filter((s) => s.importKind !== "type");
|
|
1147
|
+
if (declaration.specifiers.length && !values.length) continue;
|
|
1148
|
+
|
|
1149
|
+
const tool =
|
|
1150
|
+
values
|
|
1151
|
+
.map((s) => s.imported?.name ?? s.local.name)
|
|
1152
|
+
.find((name) => TOOLS.test(name)) ??
|
|
1153
|
+
String(declaration.source.value).split("/").pop();
|
|
1154
|
+
if (!TOOLS.test(tool)) continue;
|
|
1155
|
+
|
|
718
1156
|
findings.push({
|
|
719
|
-
index:
|
|
720
|
-
message: `\`${
|
|
721
|
-
hint:
|
|
1157
|
+
index: declaration.start,
|
|
1158
|
+
message: `\`${tool}\` is statically imported.`,
|
|
1159
|
+
hint: HINT.devToolImport,
|
|
722
1160
|
});
|
|
723
1161
|
}
|
|
724
1162
|
|
|
725
|
-
|
|
1163
|
+
const markers = findAll(file.ast, (node) => {
|
|
1164
|
+
if (keyName(node) !== "markers") return false;
|
|
1165
|
+
const value = unwrap(node.value);
|
|
1166
|
+
return value?.type === "Literal" && value.value === true;
|
|
1167
|
+
});
|
|
1168
|
+
for (const property of markers) {
|
|
726
1169
|
findings.push({
|
|
727
|
-
index:
|
|
1170
|
+
index: property.start,
|
|
728
1171
|
message: "`markers: true` is unconditional.",
|
|
729
|
-
hint:
|
|
1172
|
+
hint: HINT.markers,
|
|
730
1173
|
});
|
|
731
1174
|
}
|
|
732
1175
|
|
|
@@ -736,12 +1179,18 @@ export const RULES = [
|
|
|
736
1179
|
{
|
|
737
1180
|
id: "barrel-import",
|
|
738
1181
|
level: "warn",
|
|
1182
|
+
description:
|
|
1183
|
+
"Importing from `gsap/all`, which pulls in every plugin",
|
|
1184
|
+
/** An import or re-export whose module is `gsap/all`. */
|
|
739
1185
|
test(file) {
|
|
740
|
-
return
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1186
|
+
return file.ast.body
|
|
1187
|
+
.map(moduleSource)
|
|
1188
|
+
.filter((source) => source?.value === "gsap/all")
|
|
1189
|
+
.map((source) => ({
|
|
1190
|
+
index: source.start,
|
|
1191
|
+
message: "Importing from `gsap/all` pulls in every plugin.",
|
|
1192
|
+
hint: HINT.barrelImport,
|
|
1193
|
+
}));
|
|
745
1194
|
},
|
|
746
1195
|
},
|
|
747
1196
|
];
|