Skip to content

Hold lottie-player on a configurable frame (lottieFrame) - #88

Merged
tmashara merged 3 commits into
mainfrom
claude/issue-78-lottie
Oct 29, 2026
Merged

tmashara merged 3 commits into
mainfrom
claude/issue-78-lottie

Conversation

@tmashara

@tmashara tmashara commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

What

A new opt-in config option, lottieFrame?: number. Once each lottie-player on the page loads, renderdiff stops it and seeks it to that frame, so both sides are shot on the same still frame instead of mid-playback.

Why

Lottie animations are driven by script, so STILL_CSS can't reach them. As things stand, a page with an autoplaying lottie-player reads as unstable or doesn't settle. Freezing on frame 0 doesn't help either, because frame 0 is often blank (#78).

Option and validation

  • It's config-only. There's no CLI flag and no default: unset, players keep playing exactly as they do today.
  • Validation is Number.isSafeInteger(n) && n >= 0, which rejects with "lottieFrame must be a whole number ≥ 0". The player's seek() parses its argument with /^(\d+)(%?)$/ and silently ignores anything else (1.5, -1, 1e21), so a bad value would otherwise do nothing and give no error.
  • ResolvedConfig.lottieFrame and RunMeta.lottieFrame are optional rather than null. JSON drops undefined, so when the option is unset the config hash and results.json don't change, and --rerun-failed still accepts results from earlier runs. I checked this: the hash of a default config is 28c384078d30c44c on both main and this branch, and the meta keys are identical. When it's set, the report's "What was neutralized" line adds "every lottie-player held on frame N".

The lottie-web race and the tick

freezeLottie runs in the page after the scroll pass, networkIdle, the second freezeVideo and the late stylesheet. It runs just before the beforeShot hook, so a user hook can still override it. For each player, in parallel, it:

  1. polls getLottie()?.isLoaded every 50ms, up to 40 times (about 2s, the same budget as freezeVideo), using a counter rather than the installed clock;
  2. waits one setTimeout(0);
  3. calls stop() then seek(frame).

The wait in step 2 is needed because of how lottie-web loads. AnimationItem.checkLoaded() sets isLoaded = true and then fires DOMLoaded in a setTimeout(0), and the player's DOMLoaded listener calls play() when autoplay is set. A stop issued as soon as isLoaded reads true can be undone by that play() one tick later. Our own setTimeout(0) is queued after lottie-web's, so the stop lands after the autoplay play().

Once a player is stopped it stays stopped through the scroll and the stability shots: lottie-player's IntersectionObserver and visibility handlers only resume a player from Frozen, not from Stopped. A player that never loads is left alone, and the stability check reports it as it does today. One that throws (say, an unrelated element named lottie-player with no stop()) is also left alone: the players are awaited with Promise.allSettled, so a broken one never fails the shot. Every capture path goes through capture(): transient retry, --recheck, and the unsettled retry.

Out of scope

  • dotlottie-player and <dotlottie-wc>, which have different or unverified APIs.
  • Bare lottie-web without the player element. A beforeShot hook covers that case.
  • Players inside shadow DOM. Only light-DOM lottie-player elements are found.

How tested

  • New guard-rail fixture tests/fixtures/pages/lottie: an inline stand-in for lottie-player, since fixtures can't use a CDN. It copies the real getLottie()/stop()/seek(), including the seek regex, and lottie-web's load order: isLoaded is set at the end of a long task, then autoplay play() runs a tick later. Frame 0 is transparent, and every later frame has its own colour. The left side loads at 50ms; the right side loads at 1200ms, which is after freezeLottie starts but within the poll window.

  • tests/visual/guardrails.test.ts:

    • With lottieFrame: 60, the run exits 0 with [diffPixels, unstablePixels] = [0, 0]. A pixel inside the box is frame 60's colour on both sides, which proves the late side was seeked and not just stopped on blank frame 0. The neutralized line names the frame.
    • Without the option, unstablePixels > 0.
  • I confirmed the new test fails in three cases:

    • with src from main (40000 unstable pixels, i.e. the whole box);
    • with only the tick(0) removed (failed on 3 of 3 runs);
    • with the poll loop cut to a single check, which shows the right side really does load during the poll.

    With the change, both tests passed on 3 of 3 runs.

  • Unit tests:

    • config-load rows for 1.5 and -1;
    • resolveConfig leaves lottieFrame undefined unless it's set.
  • npm run typecheck, lint, format:check, npm test (326 passing) and npm run test:visual (345 passing) all pass, rebased onto Masks: re-read boxes at every shot, clip to overflow, cover overflowing children #86.

Deviations from the plan

  • The fake does its load as a ~100ms long task, standing in for lottie-web building its renderer. With an instant load, the 50ms poll almost never fires in the one-tick gap, so removing tick(0) didn't make the test fail. With the long task, an overdue poll timer runs between isLoaded and play(), which makes the race deterministic.
  • The player box is absolutely positioned at the top right, so the sampled pixel doesn't depend on platform font metrics.
  • lottie is added to the corpus manifest in tests/helpers/corpus.ts, which the fixture lint requires.
  • docs/scoping.md (from Add a beforeShot hook that runs after the page settles #85) now lists lottieFrame among the things applied before beforeShot.

Fixes #78

🤖 Generated with Claude Code

tmashara and others added 3 commits October 29, 2026 19:48
Opt-in lottieFrame config option: once each lottie-player loads, stop it and
seek it to that frame, waiting one tick past isLoaded so lottie-web's
autoplay play() cannot restart it.

Fixes #78

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@tmashara
tmashara force-pushed the claude/issue-78-lottie branch from e296b24 to e21497b Compare October 29, 2026 18:51
@tmashara
tmashara merged commit 6f6c7c5 into main Oct 29, 2026
3 checks passed
@tmashara
tmashara deleted the claude/issue-78-lottie branch October 29, 2026 18:59
@tmashara tmashara mentioned this pull request Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Settle lottie-player: wait for load, then seek to a configurable frame

1 participant