Repository navigation
Hold lottie-player on a configurable frame (lottieFrame) - #88
Merged
Merged
Conversation
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]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
tmashara
force-pushed
the
claude/issue-78-lottie
branch
from
October 29, 2026 18:51
e296b24 to
e21497b
Compare
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A new opt-in config option,
lottieFrame?: number. Once eachlottie-playeron 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_CSScan't reach them. As things stand, a page with an autoplayinglottie-playerreads as unstable or doesn't settle. Freezing on frame 0 doesn't help either, because frame 0 is often blank (#78).Option and validation
Number.isSafeInteger(n) && n >= 0, which rejects with "lottieFramemust be a whole number ≥ 0". The player'sseek()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.lottieFrameandRunMeta.lottieFrameare optional rather thannull. JSON dropsundefined, so when the option is unset theconfighash andresults.jsondon't change, and--rerun-failedstill accepts results from earlier runs. I checked this: the hash of a default config is28c384078d30c44con both main and this branch, and themetakeys 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
freezeLottieruns in the page after the scroll pass,networkIdle, the secondfreezeVideoand the late stylesheet. It runs just before thebeforeShothook, so a user hook can still override it. For each player, in parallel, it:getLottie()?.isLoadedevery 50ms, up to 40 times (about 2s, the same budget asfreezeVideo), using a counter rather than the installed clock;setTimeout(0);stop()thenseek(frame).The wait in step 2 is needed because of how lottie-web loads.
AnimationItem.checkLoaded()setsisLoaded = trueand then firesDOMLoadedin asetTimeout(0), and the player'sDOMLoadedlistener callsplay()whenautoplayis set. A stop issued as soon asisLoadedreads true can be undone by thatplay()one tick later. Our ownsetTimeout(0)is queued after lottie-web's, so the stop lands after the autoplayplay().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 fromStopped. 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 namedlottie-playerwith nostop()) is also left alone: the players are awaited withPromise.allSettled, so a broken one never fails the shot. Every capture path goes throughcapture(): transient retry,--recheck, and the unsettled retry.Out of scope
dotlottie-playerand<dotlottie-wc>, which have different or unverified APIs.beforeShothook covers that case.lottie-playerelements are found.How tested
New guard-rail fixture
tests/fixtures/pages/lottie: an inline stand-in forlottie-player, since fixtures can't use a CDN. It copies the realgetLottie()/stop()/seek(), including the seek regex, and lottie-web's load order:isLoadedis set at the end of a long task, then autoplayplay()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 afterfreezeLottiestarts but within the poll window.tests/visual/guardrails.test.ts: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.unstablePixels > 0.I confirmed the new test fails in three cases:
tick(0)removed (failed on 3 of 3 runs);With the change, both tests passed on 3 of 3 runs.
Unit tests:
config-loadrows for1.5and-1;resolveConfigleaveslottieFrameundefined unless it's set.npm run typecheck,lint,format:check,npm test(326 passing) andnpm 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
tick(0)didn't make the test fail. With the long task, an overdue poll timer runs betweenisLoadedandplay(), which makes the race deterministic.lottieis added to the corpus manifest intests/helpers/corpus.ts, which the fixture lint requires.lottieFrameamong the things applied beforebeforeShot.Fixes #78
🤖 Generated with Claude Code