@mehshekari/gsap-motion 4.3.0 → 4.4.2
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 +7 -0
- 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 +5 -1
- 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 +54 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +4 -0
- package/skill/scripts/capture-motion.mjs +120 -10
- package/skill/scripts/lib/ast.mjs +58 -9
- package/skill/scripts/lib/rules.mjs +134 -5
- package/skill/scripts/review-motion.mjs +167 -8
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
|
@@ -54,6 +54,15 @@ two is not a coin toss.
|
|
|
54
54
|
| "it feels cheap" | linear easing, or one uniform duration | `tune` |
|
|
55
55
|
| "it feels slow" | usually too sequential, not too long: try overlap | `tune` |
|
|
56
56
|
|
|
57
|
+
### "Match this site, one to one"
|
|
58
|
+
|
|
59
|
+
The reference is the precedent, and its motion is the spec. Measure its curves
|
|
60
|
+
and durations from it — `inspect` on its URL with a dense `--at`, or a
|
|
61
|
+
recording frame by frame — and copy them exactly. "Motion carries meaning or it
|
|
62
|
+
goes" judged the reference when it was designed; applied to the copy, it
|
|
63
|
+
simplifies what was asked for verbatim. Personalising is a second pass, after
|
|
64
|
+
the copy matches.
|
|
65
|
+
|
|
57
66
|
### "Something crazy"
|
|
58
67
|
|
|
59
68
|
A fade-and-scale is a failure of nerve; twelve techniques at once is a failure
|
|
@@ -87,7 +96,8 @@ single hover takes [interaction](interaction.md) and nothing else from its row.
|
|
|
87
96
|
| Request | Add |
|
|
88
97
|
|---|---|
|
|
89
98
|
| SVG draw / morph / path | [svg](svg.md), [timeline](timeline.md) |
|
|
90
|
-
| Scroll sequence, pin, scrub | [scrolltrigger](scrolltrigger.md), [timeline](timeline.md), [performance](performance.md) |
|
|
99
|
+
| Scroll sequence, pin, scrub | [scrolltrigger](scrolltrigger.md), [timeline](timeline.md), [performance](performance.md), [scroll-storytelling](../preset/scroll-storytelling.md) |
|
|
100
|
+
| Smooth scroll, Lenis | [scrolltrigger](scrolltrigger.md#lenis), [devices](devices.md) |
|
|
91
101
|
| Horizontal scroll | [scrolltrigger](scrolltrigger.md), [scroll-storytelling](../preset/scroll-storytelling.md) |
|
|
92
102
|
| Text reveal, kinetic type | [text](text.md), [timeline](timeline.md) |
|
|
93
103
|
| Magnetic, cursor, hover | [interaction](interaction.md), [performance](performance.md) |
|
|
@@ -97,6 +97,39 @@ Things that bite:
|
|
|
97
97
|
- `position: sticky` is cheaper and needs no plugin. Use it when you only need
|
|
98
98
|
the element to stay put, not to drive a timeline.
|
|
99
99
|
|
|
100
|
+
### `pinType`
|
|
101
|
+
|
|
102
|
+
How the pin holds the element. On the window it defaults to `"fixed"`: at each
|
|
103
|
+
enter and leave, ScrollTrigger swaps the element's whole state — `position`,
|
|
104
|
+
`top`, `left`, `width`, `height`, margins, padding — and the browser lays it out
|
|
105
|
+
again in that frame. `"transform"` swaps nothing, and writes a translate every
|
|
106
|
+
frame while the pin is active (verified against gsap 3.15).
|
|
107
|
+
|
|
108
|
+
`"fixed"` is the default because native scrolling runs off the main thread, and
|
|
109
|
+
a translate written once per frame would trail it. Under Lenis that reason is
|
|
110
|
+
gone for the wheel — the scroll moves in the same frame as the pin — and the
|
|
111
|
+
state swap at every section edge is what remains. On one landing page it was
|
|
112
|
+
measured at a CLS of 4 to 7.5 and frames of 50 to 117ms per edge, and
|
|
113
|
+
`"transform"` removed both. Touch is still native under Lenis, so keep
|
|
114
|
+
`"fixed"` there:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
// Inside the component's context, so it reverts with it. Both conditions are
|
|
118
|
+
// named: a matchMedia callback runs only while one of them matches.
|
|
119
|
+
gsap.matchMedia().add({ smoothed: "(pointer: fine)", touch: "(pointer: coarse)" }, (context) => {
|
|
120
|
+
const { smoothed } = context.conditions;
|
|
121
|
+
ScrollTrigger.create({ trigger: section, pin: true, pinType: smoothed ? "transform" : "fixed" });
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A trackpad sends wheel events, so a laptop is smoothed too. A laptop with a
|
|
126
|
+
touch screen still reads `(pointer: fine)`, and scrolled by finger its pin
|
|
127
|
+
trails the native scroll. If that audience matters, branch on
|
|
128
|
+
`(any-pointer: coarse)` instead: every device with touch gets `"fixed"`, and
|
|
129
|
+
with it the cost at each edge.
|
|
130
|
+
|
|
131
|
+
Without smooth scrolling, leave the default.
|
|
132
|
+
|
|
100
133
|
## Horizontal scroll
|
|
101
134
|
|
|
102
135
|
A vertical scroll driving a horizontal translation. `xPercent` avoids measuring:
|
|
@@ -162,6 +195,49 @@ interferes with `position: fixed`, anchor links, and native scroll on some
|
|
|
162
195
|
touch devices. Do not add it to make a reveal smoother — fix the reveal. Add it
|
|
163
196
|
only when the whole page is designed around smoothed scrolling.
|
|
164
197
|
|
|
198
|
+
## Lenis
|
|
199
|
+
|
|
200
|
+
Lenis smooths wheel input and writes the result to the real scroll position, so
|
|
201
|
+
ScrollTrigger needs no `scrollerProxy`. Pins need one decision, in
|
|
202
|
+
[`pinType`](#pintype). What it needs above all is **one clock**, shared with
|
|
203
|
+
GSAP:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const lenis = new Lenis(); // autoRaf stays false, its default
|
|
207
|
+
lenis.on("scroll", ScrollTrigger.update); // triggers read this frame's scroll
|
|
208
|
+
const raf = (time: number) => lenis.raf(time * 1000); // the ticker's time is seconds
|
|
209
|
+
gsap.ticker.add(raf, false, true); // prioritized: before GSAP renders
|
|
210
|
+
gsap.ticker.lagSmoothing(0);
|
|
211
|
+
|
|
212
|
+
// Teardown. A context owns neither of these.
|
|
213
|
+
gsap.ticker.remove(raf);
|
|
214
|
+
lenis.destroy();
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- **Prioritized, because GSAP renders in its own first ticker listener.** Added
|
|
218
|
+
after it, Lenis moves the scroll once GSAP has drawn the frame: a numeric
|
|
219
|
+
`scrub` — a tween — and anything tweened from the scroll position render one
|
|
220
|
+
frame late. `scrub: true` sets progress directly and is not affected.
|
|
221
|
+
- **Never `autoRaf: true` as well.** Lenis steps by the difference between
|
|
222
|
+
consecutive `raf` calls, and the two loops pass clocks with different origins,
|
|
223
|
+
so the step swings and the scroll stutters.
|
|
224
|
+
- **`lagSmoothing(0)`**: after a frame longer than 500ms, GSAP otherwise
|
|
225
|
+
advances its clock by 33ms, and Lenis on that clock crawls back to the
|
|
226
|
+
scroll. It applies to every tween on the page, which then jump after a long
|
|
227
|
+
frame instead of slowing.
|
|
228
|
+
- **Touch is native by default.** With `syncTouch` off, Lenis smooths nothing on
|
|
229
|
+
a touch screen and only runs its loop. Leave it off — it takes over the
|
|
230
|
+
platform's momentum, and is unstable on older iOS — and consider not creating
|
|
231
|
+
Lenis at all without a fine pointer; see [devices.md](devices.md).
|
|
232
|
+
- **Reduced motion is handled.** By default Lenis tracks the input 1:1 and makes
|
|
233
|
+
programmatic scrolls instant, and keeps running. Do not destroy it for reduced
|
|
234
|
+
motion: the ScrollTrigger sync above depends on it.
|
|
235
|
+
- In-page links need `anchors: true`; a nested scroll container needs
|
|
236
|
+
`data-lenis-prevent`. In React, `ReactLenis` from `lenis/react` takes the same
|
|
237
|
+
options: `autoRaf: false`, and the ticker wiring in an effect.
|
|
238
|
+
|
|
239
|
+
Verified against `lenis@1.3` and `gsap@3.15`.
|
|
240
|
+
|
|
165
241
|
## React
|
|
166
242
|
|
|
167
243
|
The lifecycle is the hard part. `useGSAP` reverts its context on unmount, which
|
|
@@ -189,3 +265,6 @@ images settle. `ScrollTrigger.refresh()` on mount is usually needed. See
|
|
|
189
265
|
| Janky while scrolling | layout-triggering properties — see [performance](performance.md) |
|
|
190
266
|
| Works, then breaks after navigation | ScrollTriggers not killed on unmount |
|
|
191
267
|
| Wrong on mobile after address-bar hide | viewport resize — `invalidateOnRefresh` |
|
|
268
|
+
| Smooth scroll stutters | Lenis on two loops — `autoRaf` and the ticker |
|
|
269
|
+
| A numeric scrub trails the scroll by a frame | Lenis's `raf` added to the ticker without `prioritize` |
|
|
270
|
+
| A layout shift and a long frame at each pinned section's edge | a `"fixed"` pin under Lenis — `pinType: "transform"` for the wheel |
|
|
@@ -151,6 +151,10 @@ if (asJson) {
|
|
|
151
151
|
.join(", ");
|
|
152
152
|
|
|
153
153
|
console.log(`\n${tally}`);
|
|
154
|
+
/** Said here because a report that never mentions it reads as "there is no way". */
|
|
155
|
+
console.log(
|
|
156
|
+
"A deliberate finding is waived with `<rule-id>-ok` and a reason, on its line or in the eight above.",
|
|
157
|
+
);
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
process.exit(findings.some((f) => f.level === "error") ? 1 : 0);
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
* --cpu 4 throttle the CPU by this factor
|
|
30
30
|
* --json machine-readable, for scripts
|
|
31
31
|
* --chrome <path> a browser to use, overriding the search
|
|
32
|
+
* --seek <name> call window.<name>(t) before each capture, and read
|
|
33
|
+
* --at as seconds of the timeline instead of the clock
|
|
34
|
+
* --headed a visible Chrome, on the machine's GPU: what a WebGL
|
|
35
|
+
* page needs for its frames and rate to mean anything
|
|
36
|
+
* --probe timeline print the timeline reader as a function, for a
|
|
37
|
+
* browser you already drive, and open none
|
|
32
38
|
*
|
|
33
39
|
* `--scroll`, `--hover` and `--click` run in the order they are written, before
|
|
34
40
|
* the first capture. An animation that needs a pointer or a scroll position is
|
|
@@ -40,7 +46,7 @@
|
|
|
40
46
|
import { spawn } from "node:child_process";
|
|
41
47
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
42
48
|
import { homedir, tmpdir } from "node:os";
|
|
43
|
-
import { join, resolve } from "node:path";
|
|
49
|
+
import { join, resolve, sep } from "node:path";
|
|
44
50
|
import { fileURLToPath } from "node:url";
|
|
45
51
|
|
|
46
52
|
// --- The browser -------------------------------------------------------------
|
|
@@ -85,11 +91,20 @@ export function connect(executable, { headless = true } = {}) {
|
|
|
85
91
|
const chrome = spawn(
|
|
86
92
|
executable,
|
|
87
93
|
[
|
|
88
|
-
|
|
94
|
+
/** Headless runs without a GPU, for a steady result; headed gets the machine's own. */
|
|
95
|
+
...(headless ? ["--headless=new", "--disable-gpu"] : []),
|
|
89
96
|
"--remote-debugging-pipe",
|
|
97
|
+
/**
|
|
98
|
+
* A window nobody can see is a page Chrome stops animating: headed and
|
|
99
|
+
* moved off-screen, as a window behind an editor is, rAF went from 122 a
|
|
100
|
+
* second to none, and the run hung. With these it ran at 166. They are
|
|
101
|
+
* Puppeteer's defaults too.
|
|
102
|
+
*/
|
|
103
|
+
"--disable-backgrounding-occluded-windows",
|
|
104
|
+
"--disable-renderer-backgrounding",
|
|
105
|
+
"--disable-background-timer-throttling",
|
|
90
106
|
"--no-first-run",
|
|
91
107
|
"--no-default-browser-check",
|
|
92
|
-
"--disable-gpu",
|
|
93
108
|
"--disable-extensions",
|
|
94
109
|
`--user-data-dir=${profile}`,
|
|
95
110
|
"about:blank",
|
|
@@ -176,6 +191,10 @@ export function parseOptions(argv) {
|
|
|
176
191
|
cpu: 1,
|
|
177
192
|
json: false,
|
|
178
193
|
chrome: null,
|
|
194
|
+
headed: false,
|
|
195
|
+
seek: null,
|
|
196
|
+
atGiven: false,
|
|
197
|
+
probe: null,
|
|
179
198
|
errors: [],
|
|
180
199
|
};
|
|
181
200
|
|
|
@@ -192,6 +211,7 @@ export function parseOptions(argv) {
|
|
|
192
211
|
options.errors.push("--at takes milliseconds, such as --at 0,300,900");
|
|
193
212
|
} else {
|
|
194
213
|
options.at = times.sort((a, b) => a - b);
|
|
214
|
+
options.atGiven = true;
|
|
195
215
|
}
|
|
196
216
|
break;
|
|
197
217
|
}
|
|
@@ -234,6 +254,20 @@ export function parseOptions(argv) {
|
|
|
234
254
|
case "--chrome":
|
|
235
255
|
options.chrome = value() ?? null;
|
|
236
256
|
break;
|
|
257
|
+
case "--headed":
|
|
258
|
+
options.headed = true;
|
|
259
|
+
break;
|
|
260
|
+
case "--seek": {
|
|
261
|
+
/** A name the page exports, never an expression: it is spliced into the page's code. */
|
|
262
|
+
const name = value() ?? "";
|
|
263
|
+
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(name)) options.seek = name;
|
|
264
|
+
else options.errors.push("--seek takes the name of a function on window, such as --seek __seek");
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case "--probe":
|
|
268
|
+
if (value() === "timeline") options.probe = "timeline";
|
|
269
|
+
else options.errors.push("--probe takes timeline");
|
|
270
|
+
break;
|
|
237
271
|
default:
|
|
238
272
|
if (argument.startsWith("--")) options.errors.push(`Unknown option: ${argument}`);
|
|
239
273
|
else if (options.url === null) options.url = argument;
|
|
@@ -241,7 +275,15 @@ export function parseOptions(argv) {
|
|
|
241
275
|
}
|
|
242
276
|
}
|
|
243
277
|
|
|
244
|
-
|
|
278
|
+
/** A probe is printed, not run, so it needs no page. */
|
|
279
|
+
if (options.url === null && options.probe === null) {
|
|
280
|
+
options.errors.push("No URL. Pass the page to watch.");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Seeking makes `--at` timeline seconds, so the millisecond default would mean minutes. */
|
|
284
|
+
if (options.seek && !options.atGiven) {
|
|
285
|
+
options.errors.push("--seek needs --at, in seconds of the timeline, such as --at 3,3.3,3.6");
|
|
286
|
+
}
|
|
245
287
|
|
|
246
288
|
/** A coarse pointer has no hover; emulating both would measure something no device is. */
|
|
247
289
|
if (options.mobile && options.steps.some((step) => step.type === "hover")) {
|
|
@@ -325,6 +367,21 @@ const COUNT_FRAMES = `(() => {
|
|
|
325
367
|
return true;
|
|
326
368
|
})()`;
|
|
327
369
|
|
|
370
|
+
/**
|
|
371
|
+
* The timeline reader as a function, for a browser something else already
|
|
372
|
+
* holds — a DevTools MCP server's `evaluate_script`. Layouts and style
|
|
373
|
+
* recalculations come from the DevTools protocol, which a page cannot reach, so
|
|
374
|
+
* they are not part of it.
|
|
375
|
+
*/
|
|
376
|
+
export const probe = () => `() => ${READ_TIMELINE}`;
|
|
377
|
+
|
|
378
|
+
/** Two rendered frames: a seek has been applied and painted once this resolves. */
|
|
379
|
+
const SETTLED = "new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => done(true))))";
|
|
380
|
+
|
|
381
|
+
/** A frame's file name: milliseconds of the clock, or seconds of the timeline when seeking. */
|
|
382
|
+
export const frameName = (at, options) =>
|
|
383
|
+
options.seek ? `frame-${at.toFixed(3).padStart(8, "0")}s.png` : `frame-${String(at).padStart(5, "0")}ms.png`;
|
|
384
|
+
|
|
328
385
|
/** The live box of a selector, so a pointer lands on an element that is moving. */
|
|
329
386
|
const boxOf = (selector) => `(() => {
|
|
330
387
|
const element = document.querySelector(${JSON.stringify(selector)});
|
|
@@ -333,6 +390,39 @@ const boxOf = (selector) => `(() => {
|
|
|
333
390
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2, width: box.width, height: box.height };
|
|
334
391
|
})()`;
|
|
335
392
|
|
|
393
|
+
// --- Where output goes -------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* What a report says when the page has a canvas and Chrome ran without its GPU:
|
|
397
|
+
* a WebGL scene was then drawn in software, and its frames, rate and long frames
|
|
398
|
+
* describe that, not the visitor's machine. Null when there is nothing to say.
|
|
399
|
+
*/
|
|
400
|
+
export function gpuNote(canvases, options) {
|
|
401
|
+
if (!canvases || options.headed) return null;
|
|
402
|
+
return (
|
|
403
|
+
`${canvases} canvas${canvases === 1 ? "" : "es"} on the page, and Chrome ran without its GPU:` +
|
|
404
|
+
" a WebGL scene's frames, rate and long frames are software rendering's. Measure it with --headed."
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Creates `out` and returns its absolute path. When it is inside the project's
|
|
410
|
+
* `.gsap-motion/` — the default is — that folder gets a `.gitignore` of `*`,
|
|
411
|
+
* so the frames are never committed: a real project committed four of them in
|
|
412
|
+
* its first commit, and a knowledge-graph tool indexed them after that. The
|
|
413
|
+
* project's own `.gitignore` is left alone, and so is any other `--out`.
|
|
414
|
+
*/
|
|
415
|
+
export function prepareOut(out, cwd = process.cwd()) {
|
|
416
|
+
const path = resolve(cwd, out);
|
|
417
|
+
mkdirSync(path, { recursive: true });
|
|
418
|
+
const own = resolve(cwd, ".gsap-motion");
|
|
419
|
+
if (path === own || path.startsWith(own + sep)) {
|
|
420
|
+
const ignore = join(own, ".gitignore");
|
|
421
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "# Written by gsap-motion: frames are not source.\n*\n");
|
|
422
|
+
}
|
|
423
|
+
return path;
|
|
424
|
+
}
|
|
425
|
+
|
|
336
426
|
// --- The run -----------------------------------------------------------------
|
|
337
427
|
|
|
338
428
|
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
@@ -356,7 +446,7 @@ async function capture(options) {
|
|
|
356
446
|
);
|
|
357
447
|
}
|
|
358
448
|
|
|
359
|
-
const browser = connect(executable);
|
|
449
|
+
const browser = connect(executable, { headless: !options.headed });
|
|
360
450
|
const notes = [];
|
|
361
451
|
|
|
362
452
|
try {
|
|
@@ -467,18 +557,27 @@ async function capture(options) {
|
|
|
467
557
|
}
|
|
468
558
|
}
|
|
469
559
|
|
|
470
|
-
|
|
560
|
+
const out = prepareOut(options.out);
|
|
471
561
|
|
|
472
562
|
const frames = [];
|
|
473
563
|
let elapsed = 0;
|
|
564
|
+
if (options.seek && !(await evaluate(`typeof window.${options.seek} === "function"`))) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
`--seek: window.${options.seek} is not a function on this page. Export it in development; see refine.md.`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
474
569
|
for (const at of options.at) {
|
|
475
|
-
if (
|
|
570
|
+
if (options.seek) {
|
|
571
|
+
/** Seek, then let two frames render, so what is captured is that time drawn. */
|
|
572
|
+
await evaluate(`window.${options.seek}(${at})`);
|
|
573
|
+
await evaluate(SETTLED);
|
|
574
|
+
} else if (at > elapsed) {
|
|
476
575
|
await wait(at - elapsed);
|
|
477
576
|
elapsed = at;
|
|
478
577
|
}
|
|
479
578
|
const shot = await call("Page.captureScreenshot", { format: "png" });
|
|
480
579
|
const bytes = Buffer.from(shot.data, "base64");
|
|
481
|
-
const file = join(
|
|
580
|
+
const file = join(out, frameName(at, options));
|
|
482
581
|
writeFileSync(file, bytes);
|
|
483
582
|
frames.push({ at, file, bytes: bytes.length });
|
|
484
583
|
}
|
|
@@ -487,6 +586,7 @@ async function capture(options) {
|
|
|
487
586
|
const drawn = await evaluate(
|
|
488
587
|
"({ frames: window.__gsapMotionFrames ?? null, since: window.__gsapMotionStart ?? null })",
|
|
489
588
|
);
|
|
589
|
+
const canvases = await evaluate("document.querySelectorAll(\"canvas\").length");
|
|
490
590
|
const after = await call("Performance.getMetrics");
|
|
491
591
|
|
|
492
592
|
const delta = (name) => {
|
|
@@ -511,7 +611,7 @@ async function capture(options) {
|
|
|
511
611
|
overMs: span,
|
|
512
612
|
};
|
|
513
613
|
|
|
514
|
-
return { url: options.url, frames, timeline, measured, notes, chrome: executable };
|
|
614
|
+
return { url: options.url, frames, timeline, measured, notes, canvases, chrome: executable };
|
|
515
615
|
} finally {
|
|
516
616
|
await browser.close();
|
|
517
617
|
}
|
|
@@ -527,13 +627,18 @@ export function report(result, options) {
|
|
|
527
627
|
options.dark && "dark",
|
|
528
628
|
options.mobile && "mobile 390x844",
|
|
529
629
|
options.cpu > 1 && `CPU ÷${options.cpu}`,
|
|
630
|
+
options.headed && "headed, on the GPU",
|
|
631
|
+
options.seek && `seeking with window.${options.seek}`,
|
|
530
632
|
].filter(Boolean);
|
|
531
633
|
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
532
634
|
for (const note of result.notes) lines.push(` ${note}`);
|
|
635
|
+
const gpu = gpuNote(result.canvases, options);
|
|
636
|
+
if (gpu) lines.push(` ${gpu}`);
|
|
533
637
|
|
|
534
638
|
lines.push("", `Frames (${result.frames.length}), in ${options.out}`);
|
|
535
639
|
for (const frame of result.frames) {
|
|
536
|
-
|
|
640
|
+
const when = options.seek ? `${frame.at}s`.padStart(8) : `${String(frame.at).padStart(5)}ms`;
|
|
641
|
+
lines.push(` ${when} ${frame.file}`);
|
|
537
642
|
}
|
|
538
643
|
|
|
539
644
|
lines.push("", "Timeline, as the page is running it");
|
|
@@ -591,6 +696,11 @@ async function main(argv) {
|
|
|
591
696
|
process.exit(2);
|
|
592
697
|
}
|
|
593
698
|
|
|
699
|
+
if (options.probe) {
|
|
700
|
+
console.log(probe());
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
594
704
|
try {
|
|
595
705
|
const result = await capture(options);
|
|
596
706
|
console.log(options.json ? JSON.stringify(result, null, 2) : report(result, options));
|
|
@@ -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([
|
|
@@ -61,6 +61,10 @@ const HINT = {
|
|
|
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
|
+
allocationPerFrame:
|
|
65
|
+
"Every frame's object is garbage by the next, and the collector reclaims it in a pause that lands as a dropped frame. Build it once, outside the callback — at module scope, or in a ref or useMemo — and reuse it with `.set()` or `.copy()`.",
|
|
66
|
+
layoutReadPerFrame:
|
|
67
|
+
"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
68
|
paintProperty:
|
|
65
69
|
"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
70
|
unownedLoop:
|
|
@@ -104,6 +108,7 @@ const HINT = {
|
|
|
104
108
|
const MESSAGE = {
|
|
105
109
|
easedLoop: "An infinite repeat with an ease other than `none`.",
|
|
106
110
|
tweenPerFrame: "A tween is created here on every frame.",
|
|
111
|
+
layoutReadPerFrame: "Layout is read here on every frame.",
|
|
107
112
|
unownedLoop: "An infinite repeat that nothing pauses.",
|
|
108
113
|
ungatedHover: "A hover animation with no `(hover: hover)` gate.",
|
|
109
114
|
delayChain: "Three or more tweens in this scope are sequenced by `delay`.",
|
|
@@ -173,10 +178,19 @@ function layoutHint(property, percent) {
|
|
|
173
178
|
const swap = TRANSFORM_FOR[property];
|
|
174
179
|
return swap
|
|
175
180
|
? `Use \`${swap}\` instead — a compositor-only property.` +
|
|
181
|
+
(property === "height" ? ` ${HEIGHT_CAVEAT}` : "") +
|
|
176
182
|
(percent ? ` ${PERCENT_CAVEAT}` : "")
|
|
177
183
|
: "Prefer transform and opacity; they never touch layout.";
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
/**
|
|
187
|
+
* A scale squashes what is inside. An accordion's text is the usual case, and
|
|
188
|
+
* a real project kept its `height` tween for exactly that reason, which the
|
|
189
|
+
* hint above alone would have argued against.
|
|
190
|
+
*/
|
|
191
|
+
const HEIGHT_CAVEAT =
|
|
192
|
+
"Unless what is inside must keep its shape — an accordion's text: then transition `grid-template-rows` from `0fr` to `1fr`, or use Flip.";
|
|
193
|
+
|
|
180
194
|
/** Whether a transform's from-value, as written, leaves the element untransformed. */
|
|
181
195
|
function identityTransform(property, value) {
|
|
182
196
|
const v = value.trim().replace(/^["'`]|["'`]$/g, "");
|
|
@@ -589,6 +603,36 @@ const PAINT_PROPERTIES = new Set([
|
|
|
589
603
|
"boxShadow",
|
|
590
604
|
]);
|
|
591
605
|
|
|
606
|
+
/** Three's math types: what a render loop allocates when it should reuse. */
|
|
607
|
+
const THREE_MATH = new Set([
|
|
608
|
+
"Vector2",
|
|
609
|
+
"Vector3",
|
|
610
|
+
"Vector4",
|
|
611
|
+
"Quaternion",
|
|
612
|
+
"Euler",
|
|
613
|
+
"Matrix3",
|
|
614
|
+
"Matrix4",
|
|
615
|
+
"Color",
|
|
616
|
+
"Box3",
|
|
617
|
+
"Sphere",
|
|
618
|
+
"Ray",
|
|
619
|
+
"Plane",
|
|
620
|
+
"Spherical",
|
|
621
|
+
]);
|
|
622
|
+
|
|
623
|
+
/** Reads that return layout, and so make the browser compute it if it is stale. */
|
|
624
|
+
const LAYOUT_READ_METHODS = new Set(["getBoundingClientRect", "getClientRects"]);
|
|
625
|
+
const LAYOUT_READ_PROPERTIES = new Set([
|
|
626
|
+
"offsetWidth",
|
|
627
|
+
"offsetHeight",
|
|
628
|
+
"offsetTop",
|
|
629
|
+
"offsetLeft",
|
|
630
|
+
"clientWidth",
|
|
631
|
+
"clientHeight",
|
|
632
|
+
"scrollWidth",
|
|
633
|
+
"scrollHeight",
|
|
634
|
+
]);
|
|
635
|
+
|
|
592
636
|
const HOVER_EVENTS = new Set([
|
|
593
637
|
"pointerenter",
|
|
594
638
|
"pointerover",
|
|
@@ -1009,7 +1053,7 @@ export const RULES = [
|
|
|
1009
1053
|
return findAll(file.ast, animates).flatMap(
|
|
1010
1054
|
(call) => {
|
|
1011
1055
|
const seen = new Set();
|
|
1012
|
-
return varsObjects(call, tweenMethod(call))
|
|
1056
|
+
return varsObjects(call, tweenMethod(call), file.ast)
|
|
1013
1057
|
.flatMap((vars) => vars.properties)
|
|
1014
1058
|
.filter((property) => {
|
|
1015
1059
|
const key = keyName(property);
|
|
@@ -1087,7 +1131,7 @@ export const RULES = [
|
|
|
1087
1131
|
test(file) {
|
|
1088
1132
|
return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap(
|
|
1089
1133
|
(call) => {
|
|
1090
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1134
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1091
1135
|
if (!vars) return [];
|
|
1092
1136
|
const repeat = propertyOf(vars, "repeat");
|
|
1093
1137
|
if (!repeat || numberValue(repeat.value) !== -1) return [];
|
|
@@ -1401,6 +1445,91 @@ export const RULES = [
|
|
|
1401
1445
|
return findings;
|
|
1402
1446
|
},
|
|
1403
1447
|
},
|
|
1448
|
+
{
|
|
1449
|
+
id: "allocation-per-frame",
|
|
1450
|
+
level: "warn",
|
|
1451
|
+
description:
|
|
1452
|
+
"A Three.js vector, colour, matrix or quaternion built every frame — in `useFrame`, the ticker, `onUpdate` or a `requestAnimationFrame` loop",
|
|
1453
|
+
/**
|
|
1454
|
+
* Only Three's math types, by name. Any `new` in a frame allocates, but a
|
|
1455
|
+
* `Set` or a `Map` there is usually deliberate and rarely hot; these are
|
|
1456
|
+
* the objects a render loop builds by the thousand, once per object per
|
|
1457
|
+
* frame, and each has a `set` or `copy` that makes the allocation pointless.
|
|
1458
|
+
*/
|
|
1459
|
+
test(file) {
|
|
1460
|
+
const scopes = perFrameScopes(file.ast);
|
|
1461
|
+
if (scopes.length === 0) return [];
|
|
1462
|
+
|
|
1463
|
+
const built = findAll(file.ast, (node) => {
|
|
1464
|
+
if (node.type !== "NewExpression") return false;
|
|
1465
|
+
const name = dottedName(unwrap(node.callee))?.split(".").at(-1);
|
|
1466
|
+
return THREE_MATH.has(name);
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
const reported = new Set();
|
|
1470
|
+
return built.flatMap((node) => {
|
|
1471
|
+
if (reported.has(node.start) || !scopes.some((scope) => contains(scope, node))) {
|
|
1472
|
+
return [];
|
|
1473
|
+
}
|
|
1474
|
+
reported.add(node.start);
|
|
1475
|
+
const name = dottedName(unwrap(node.callee)).split(".").at(-1);
|
|
1476
|
+
return [
|
|
1477
|
+
{
|
|
1478
|
+
index: node.start,
|
|
1479
|
+
message: `A \`${name}\` is allocated here on every frame.`,
|
|
1480
|
+
hint: HINT.allocationPerFrame,
|
|
1481
|
+
},
|
|
1482
|
+
];
|
|
1483
|
+
});
|
|
1484
|
+
},
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
id: "layout-read-per-frame",
|
|
1488
|
+
level: "info",
|
|
1489
|
+
description:
|
|
1490
|
+
"A layout read — `getBoundingClientRect`, `offsetWidth`, `getComputedStyle` — in a callback that runs every frame",
|
|
1491
|
+
/**
|
|
1492
|
+
* Info, because a read is only expensive when something has invalidated
|
|
1493
|
+
* layout since the last frame, and that depends on what else runs — a
|
|
1494
|
+
* transform does not, a `width` tween does. What is certain is that the
|
|
1495
|
+
* value is re-measured every frame when it usually changes only on resize.
|
|
1496
|
+
*/
|
|
1497
|
+
test(file) {
|
|
1498
|
+
const scopes = perFrameScopes(file.ast);
|
|
1499
|
+
if (scopes.length === 0) return [];
|
|
1500
|
+
|
|
1501
|
+
const reads = findAll(file.ast, (node) => {
|
|
1502
|
+
if (node.type === "CallExpression") {
|
|
1503
|
+
const name = calleeName(node);
|
|
1504
|
+
return (
|
|
1505
|
+
name === "getComputedStyle" ||
|
|
1506
|
+
name === "window.getComputedStyle" ||
|
|
1507
|
+
LAYOUT_READ_METHODS.has(methodName(node))
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
return (
|
|
1511
|
+
node.type === "MemberExpression" &&
|
|
1512
|
+
!node.computed &&
|
|
1513
|
+
LAYOUT_READ_PROPERTIES.has(node.property.name)
|
|
1514
|
+
);
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
const reported = new Set();
|
|
1518
|
+
return reads.flatMap((node) => {
|
|
1519
|
+
if (reported.has(node.start) || !scopes.some((scope) => contains(scope, node))) {
|
|
1520
|
+
return [];
|
|
1521
|
+
}
|
|
1522
|
+
reported.add(node.start);
|
|
1523
|
+
return [
|
|
1524
|
+
{
|
|
1525
|
+
index: node.start,
|
|
1526
|
+
message: MESSAGE.layoutReadPerFrame,
|
|
1527
|
+
hint: HINT.layoutReadPerFrame,
|
|
1528
|
+
},
|
|
1529
|
+
];
|
|
1530
|
+
});
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1404
1533
|
{
|
|
1405
1534
|
id: "paint-property",
|
|
1406
1535
|
level: "warn",
|
|
@@ -1415,7 +1544,7 @@ export const RULES = [
|
|
|
1415
1544
|
|
|
1416
1545
|
return findAll(file.ast, animates).flatMap((call) => {
|
|
1417
1546
|
const seen = new Set();
|
|
1418
|
-
return varsObjects(call, tweenMethod(call))
|
|
1547
|
+
return varsObjects(call, tweenMethod(call), file.ast)
|
|
1419
1548
|
.flatMap((vars) => vars.properties)
|
|
1420
1549
|
.filter((property) => {
|
|
1421
1550
|
const key = keyName(property);
|
|
@@ -1462,7 +1591,7 @@ export const RULES = [
|
|
|
1462
1591
|
|
|
1463
1592
|
return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap((call) => {
|
|
1464
1593
|
if (inside(call)) return [];
|
|
1465
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1594
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1466
1595
|
if (!vars) return [];
|
|
1467
1596
|
const repeat = propertyOf(vars, "repeat");
|
|
1468
1597
|
if (!repeat || numberValue(repeat.value) !== -1) return [];
|
|
@@ -1518,7 +1647,7 @@ export const RULES = [
|
|
|
1518
1647
|
|
|
1519
1648
|
for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
|
|
1520
1649
|
if (chainStart(call) !== call) continue;
|
|
1521
|
-
const vars = ownVars(call, tweenMethod(call));
|
|
1650
|
+
const vars = ownVars(call, tweenMethod(call), file.ast);
|
|
1522
1651
|
const delay = vars && propertyOf(vars, "delay");
|
|
1523
1652
|
if (!delay || !(numberValue(delay.value) > 0)) continue;
|
|
1524
1653
|
const scope = scopeOf(file.ast, call) ?? file.ast;
|