From 6e3201f15ce949c7ac7263eab40ec3407ff2c70c Mon Sep 17 00:00:00 2001 From: Gamote <38785171+Gamote@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:00:04 +0200 Subject: [PATCH] feat: configureLottie for the engine's global settings, and the renderer settings the engine reads Some lottie-web settings belong to a loaded copy of the engine rather than to one animation, so they can never be props: the prefix of the element IDs it mints, read at every mint, and how finely it draws curves, read whenever it builds one. `configureLottie({ idPrefix, quality })` sets both for all three engine builds. It takes effect at once on every engine that has loaded, and again before every load, which also puts the settings back should anything else on the page have changed them. IDs are prefixed by default: the base `lottie-react` plus each build's own name, so `Lottie` and `LottieLight` on one page, two engine copies whose counters both start at one, no longer share an ID; two copies of the library each set a base of their own. Each build declares its engine and name as one pair next to its hook, so the two cannot drift apart. The renderer settings are typed as a superset of lottie-web's declarations, adding the fields the engine reads and lottie-web leaves out: expressions on every renderer, the element's id and content-visibility on svg and canvas, the size on svg, the filter region on html. --- src/animation/Lottie.tsx | 4 +- src/animation/LottieLight.tsx | 4 +- src/animation/LottieRegistryContext.test.tsx | 4 +- src/animation/LottieSvg.tsx | 4 +- src/animation/configureLottie.test.ts | 88 +++++++++++++++ src/animation/configureLottie.ts | 101 ++++++++++++++++++ src/animation/createLottieComponent.tsx | 4 +- src/animation/types.test.ts | 36 +++++++ src/animation/types.ts | 32 +++++- src/animation/useLottie.ts | 9 +- .../useLottieAnimation.motion.test.tsx | 4 +- .../useLottieAnimation.server.test.tsx | 6 +- src/animation/useLottieAnimation.test.tsx | 59 +++++++++- src/animation/useLottieAnimation.ts | 14 ++- src/animation/useLottieLight.ts | 9 +- src/animation/useLottieSvg.ts | 9 +- src/index.ts | 15 ++- src/interactions/LottieInteractions.test.tsx | 13 ++- src/interactions/lottieInView.test.tsx | 6 +- src/interactions/lottieScrollScrub.test.tsx | 6 +- .../useLottieInteractions.test.tsx | 4 +- .../(v3)/animation/configuring-the-engine.mdx | 49 +++++++++ website/content/docs/(v3)/animation/meta.json | 1 + website/content/docs/(v3)/migration.mdx | 2 +- .../docs/(v3)/reference/configure-lottie.mdx | 17 +++ .../content/docs/(v3)/reference/lottie.mdx | 2 +- website/content/docs/(v3)/reference/meta.json | 3 +- 27 files changed, 452 insertions(+), 53 deletions(-) create mode 100644 src/animation/configureLottie.test.ts create mode 100644 src/animation/configureLottie.ts create mode 100644 website/content/docs/(v3)/animation/configuring-the-engine.mdx create mode 100644 website/content/docs/(v3)/reference/configure-lottie.mdx diff --git a/src/animation/Lottie.tsx b/src/animation/Lottie.tsx index a9117ea..338fcc4 100644 --- a/src/animation/Lottie.tsx +++ b/src/animation/Lottie.tsx @@ -1,10 +1,10 @@ -import lottie from "lottie-web"; import type { ReactNode } from "react"; import { createLottieComponent, type LottieComponentProps, } from "./createLottieComponent.js"; import type { AnyTag, LottieRenderer } from "./types.js"; +import { fullEngine } from "./useLottie.js"; /** * What {@link Lottie} accepts: our own props, plus every attribute of the @@ -51,4 +51,4 @@ export type LottieProps< * animation uses no expressions either: each carries a smaller copy of the * engine. */ -export const Lottie = createLottieComponent(lottie); +export const Lottie = createLottieComponent(fullEngine); diff --git a/src/animation/LottieLight.tsx b/src/animation/LottieLight.tsx index 7b9e9d5..ed441c1 100644 --- a/src/animation/LottieLight.tsx +++ b/src/animation/LottieLight.tsx @@ -1,10 +1,10 @@ -import lottieLight from "lottie-web/build/player/lottie_light.js"; import type { ReactNode } from "react"; import { createLottieComponent, type LottieComponentProps, } from "./createLottieComponent.js"; import type { AnyTag, LottieRenderer, RendererInLight } from "./types.js"; +import { lightEngine } from "./useLottieLight.js"; /** * What {@link LottieLight} accepts. Identical to `LottieProps` except that @@ -27,4 +27,4 @@ export type LottieLightProps< * drawn at its static value; {@link LottieSvg} is the smaller build that keeps * expressions. */ -export const LottieLight = createLottieComponent(lottieLight); +export const LottieLight = createLottieComponent(lightEngine); diff --git a/src/animation/LottieRegistryContext.test.tsx b/src/animation/LottieRegistryContext.test.tsx index 4e3bdac..cbd461a 100644 --- a/src/animation/LottieRegistryContext.test.tsx +++ b/src/animation/LottieRegistryContext.test.tsx @@ -1,5 +1,4 @@ import { act, cleanup, render } from "@testing-library/react"; -import lottie from "lottie-web"; import type { ReactNode } from "react"; import { useEffect, useState } from "react"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; @@ -9,6 +8,7 @@ import { type LottieRegistryStore, } from "./LottieRegistryContext.js"; import { LottieState } from "./types.js"; +import { fullEngine } from "./useLottie.js"; import { type UseLottieOptions, useLottieAnimation, @@ -44,7 +44,7 @@ afterEach(() => { }); function Probe(props: UseLottieOptions) { - const instance = useLottieAnimation(lottie, props); + const instance = useLottieAnimation(fullEngine, props); return
; } diff --git a/src/animation/LottieSvg.tsx b/src/animation/LottieSvg.tsx index 2d6a8f8..59141d1 100644 --- a/src/animation/LottieSvg.tsx +++ b/src/animation/LottieSvg.tsx @@ -1,10 +1,10 @@ -import lottieSvg from "lottie-web/build/player/lottie_svg.js"; import type { ReactNode } from "react"; import { createLottieComponent, type LottieComponentProps, } from "./createLottieComponent.js"; import type { AnyTag, LottieRenderer, RendererInSvg } from "./types.js"; +import { svgEngine } from "./useLottieSvg.js"; /** * What {@link LottieSvg} accepts. Identical to `LottieProps` except that @@ -28,4 +28,4 @@ export type LottieSvgProps< * expression engine, so an animation whose properties are driven by * expressions plays as designed. */ -export const LottieSvg = createLottieComponent(lottieSvg); +export const LottieSvg = createLottieComponent(svgEngine); diff --git a/src/animation/configureLottie.test.ts b/src/animation/configureLottie.test.ts new file mode 100644 index 0000000..38c8172 --- /dev/null +++ b/src/animation/configureLottie.test.ts @@ -0,0 +1,88 @@ +import type { LottiePlayer } from "lottie-web"; +import { beforeEach, expect, it, vi } from "vitest"; +import type { LottieEngine } from "./configureLottie.js"; + +/* The settings are module state, so every test starts from a fresh module. */ +beforeEach(() => { + vi.resetModules(); +}); + +async function load() { + return import("./configureLottie.js"); +} + +function fakeEngine(name: LottieEngine["name"]) { + const player = { + setIDPrefix: vi.fn(), + setQuality: vi.fn(), + }; + return { + engine: { player: player as unknown as LottiePlayer, name }, + player, + }; +} + +it("prefixes with the library's base and the engine's own suffix by default", async () => { + const { applyEngineSettings, LottieEngineName } = await load(); + const light = fakeEngine(LottieEngineName.light); + + applyEngineSettings(light.engine); + + expect(light.player.setIDPrefix).toHaveBeenCalledWith( + "lottie-react-lottie_light", + ); + expect(light.player.setQuality).not.toHaveBeenCalled(); +}); + +it("reaches every engine that has loaded at once, and the rest at their next load", async () => { + const { applyEngineSettings, configureLottie, LottieEngineName } = + await load(); + const full = fakeEngine(LottieEngineName.full); + const svg = fakeEngine(LottieEngineName.svg); + applyEngineSettings(full.engine); + + configureLottie({ idPrefix: "crm" }); + + expect(full.player.setIDPrefix).toHaveBeenLastCalledWith("crm-lottie"); + expect(svg.player.setIDPrefix).not.toHaveBeenCalled(); + applyEngineSettings(svg.engine); + expect(svg.player.setIDPrefix).toHaveBeenCalledWith("crm-lottie_svg"); +}); + +it("puts the settings back before every load", async () => { + const { applyEngineSettings, LottieEngineName } = await load(); + const full = fakeEngine(LottieEngineName.full); + + applyEngineSettings(full.engine); + applyEngineSettings(full.engine); + + expect(full.player.setIDPrefix).toHaveBeenCalledTimes(2); +}); + +it("passes the quality through only once it is set", async () => { + const { applyEngineSettings, configureLottie, LottieEngineName } = + await load(); + const full = fakeEngine(LottieEngineName.full); + + applyEngineSettings(full.engine); + expect(full.player.setQuality).not.toHaveBeenCalled(); + + configureLottie({ quality: "low" }); + expect(full.player.setQuality).toHaveBeenCalledWith("low"); + + configureLottie({ quality: 120 }); + expect(full.player.setQuality).toHaveBeenLastCalledWith(120); +}); + +it("keeps a field that a later call leaves out", async () => { + const { applyEngineSettings, configureLottie, LottieEngineName } = + await load(); + const full = fakeEngine(LottieEngineName.full); + + configureLottie({ idPrefix: "crm", quality: "high" }); + configureLottie({ quality: "medium" }); + applyEngineSettings(full.engine); + + expect(full.player.setIDPrefix).toHaveBeenCalledWith("crm-lottie"); + expect(full.player.setQuality).toHaveBeenCalledWith("medium"); +}); diff --git a/src/animation/configureLottie.ts b/src/animation/configureLottie.ts new file mode 100644 index 0000000..7dde31e --- /dev/null +++ b/src/animation/configureLottie.ts @@ -0,0 +1,101 @@ +import type { LottiePlayer } from "lottie-web"; + +/** What {@link configureLottie} can be told. */ +export interface ConfigureLottieOptions { + /** + * The base of every element ID the engine mints, so that two copies of the + * library on one page can be told apart. Each engine build appends its own + * suffix (`-lottie`, `-lottie_svg`, `-lottie_light`), so the three builds + * never share an ID with each other. `lottie-react` unless set. + */ + idPrefix?: string; + /** + * How finely curves are drawn: `low`, `medium`, `high`, or a number of + * segments above 1. Left alone, the engine draws with 150, between `medium` + * (50) and `high` (200); the named levels trade smoothness for work. Set it + * once, before animations load: the engine reads it whenever it builds a + * curve, so a change reaches every animation on the engine, running ones + * included. + */ + quality?: "low" | "medium" | "high" | number; +} + +/** + * The engine builds by the name lottie-web gives each, which is the suffix a + * build's element IDs carry. + */ +export const LottieEngineName = { + full: "lottie", + svg: "lottie_svg", + light: "lottie_light", +} as const; +export type LottieEngineName = + (typeof LottieEngineName)[keyof typeof LottieEngineName]; + +/** + * One engine build: lottie-web's player object paired with the build's name. + * Each build declares its pair once, next to the hook that loads it, so the + * two cannot drift apart. + */ +export interface LottieEngine { + readonly player: LottiePlayer; + readonly name: LottieEngineName; +} + +let idPrefix = "lottie-react"; +let quality: ConfigureLottieOptions["quality"]; + +/* + * Both settings are global to a loaded copy of the engine, and there are three + * such copies, one per build, none of which knows about the others. So the + * settings live here, apart from every engine, and reach each engine two ways: + * at once, for every engine that has already loaded something, and again right + * before every load, which also puts them back should anything else on the + * page have changed them. Nothing here touches an engine at module scope, so + * importing any pair still costs only that pair, and a page that never loads + * an animation never reaches one. + */ +const engines = new Map(); + +function apply(player: LottiePlayer, name: LottieEngineName): void { + player.setIDPrefix(`${idPrefix}-${name}`); + if (quality !== undefined) { + player.setQuality(quality); + } +} + +/** + * Sets what is global to the engine rather than to one animation: the prefix + * of the element IDs it mints, and how finely it draws curves. + * + * ```ts + * configureLottie({ idPrefix: "crm", quality: "low" }); + * ``` + * + * Set it once, at startup, before animations load. It takes effect at once on + * every engine that has loaded an animation, and on the others when they first + * do, and it reaches what is already on screen: element IDs for everything the + * engines build from then on, the drawing quality of every animation on them. + * A field left out keeps its value. + */ +export function configureLottie(options: ConfigureLottieOptions): void { + if (options.idPrefix !== undefined) { + idPrefix = options.idPrefix; + } + if (options.quality !== undefined) { + quality = options.quality; + } + for (const [player, name] of engines) { + apply(player, name); + } +} + +/** + * Brings one engine up to the current settings and remembers it for later + * calls to {@link configureLottie}. The load path calls it right before + * `loadAnimation`. + */ +export function applyEngineSettings(engine: LottieEngine): void { + engines.set(engine.player, engine.name); + apply(engine.player, engine.name); +} diff --git a/src/animation/createLottieComponent.tsx b/src/animation/createLottieComponent.tsx index 8b3210b..8c78196 100644 --- a/src/animation/createLottieComponent.tsx +++ b/src/animation/createLottieComponent.tsx @@ -1,4 +1,3 @@ -import type { LottiePlayer } from "lottie-web"; import { type ReactNode, type Ref, @@ -10,6 +9,7 @@ import { useRef, } from "react"; import { mergeRefs } from "../utils/mergeRefs.js"; +import type { LottieEngine } from "./configureLottie.js"; import { lottieDisplayClass, lottieDisplayStyles } from "./LottieDisplay.js"; import { LottieInstanceContext } from "./LottieInstanceContext.js"; import { polymorphicForwardRef } from "./polymorphicForwardRef.js"; @@ -130,7 +130,7 @@ export type LottieComponent = < * than a blank animation and a runtime throw. */ export function createLottieComponent( - engine: LottiePlayer, + engine: LottieEngine, ): LottieComponent { return polymorphicForwardRef(function Lottie< As extends AnyTag = "div", diff --git a/src/animation/types.test.ts b/src/animation/types.test.ts index 732c6e3..bb396b7 100644 --- a/src/animation/types.test.ts +++ b/src/animation/types.test.ts @@ -236,6 +236,42 @@ const _canvasRefusesSvg: RendererRows["canvas"]["settings"] = { viewBoxOnly: true, }; +/* + * The fields the engine reads but lottie-web leaves undeclared: expressions + * and the element's id and content-visibility on svg and canvas, the size on + * svg alone (as attribute values, so strings too), and on html expressions + * and the filter region. + */ +const _svgTakesWhatTheEngineReads: RendererRows["svg"]["settings"] = { + runExpressions: false, + contentVisibility: "hidden", + id: "hero", + width: 320, + height: 240, +}; +const _canvasTakesWhatTheEngineReads: RendererRows["canvas"]["settings"] = { + runExpressions: false, + contentVisibility: "hidden", + id: "hero", +}; +const _htmlTakesExpressionsAndTheFilterRegion: RendererRows["html"]["settings"] = + { + runExpressions: false, + filterSize: { width: "200%", height: "200%", x: "-50%", y: "-50%" }, + }; +const _svgSizesFromStringsToo: RendererRows["svg"]["settings"] = { + width: "100%", + height: "100%", +}; +const _canvasRefusesTheSvgSize: RendererRows["canvas"]["settings"] = { + // @ts-expect-error only the svg renderer sizes itself from the settings + width: 320, +}; +const _htmlRefusesTheId: RendererRows["html"]["settings"] = { + // @ts-expect-error the html renderer reads no id from the settings + id: "hero", +}; + /* * A seek target names exactly one unit, and the obvious way to write that does * not work: a plain union of single-key objects accepts a literal combining two diff --git a/src/animation/types.ts b/src/animation/types.ts index 891f5f0..467b6b4 100644 --- a/src/animation/types.ts +++ b/src/animation/types.ts @@ -1,6 +1,7 @@ import type { AnimationItem, CanvasRendererConfig, + FilterSizeConfig, HTMLRendererConfig, RendererType, SVGRendererConfig, @@ -135,24 +136,49 @@ type _EverySubscriptionHasAHandler = MustBeNever< export interface RendererRows { svg: { puts: "inline"; - settings: SVGRendererConfig; + settings: SVGRendererConfig & SettingsTheEngineReads & SvgSizeSettings; inSvg: true; inLight: true; }; canvas: { puts: "inline"; - settings: CanvasRendererConfig; + settings: CanvasRendererConfig & SettingsTheEngineReads; inSvg: false; inLight: false; }; html: { puts: "block"; - settings: HTMLRendererConfig; + settings: HTMLRendererConfig & + Pick & { + /** The filter region for effects; declared on svg, read by html too. */ + filterSize?: FilterSizeConfig; + }; inSvg: false; inLight: false; }; } +/* + * Settings every renderer reads from its config that lottie-web's own + * declarations leave out. The table only ever adds to those declarations, so + * there is nothing to keep in step: a field lottie-web declares later is + * simply declared twice, identically. + */ +interface SettingsTheEngineReads { + /** Whether expressions in the file are evaluated. On unless turned off. */ + runExpressions?: boolean; + /** The `content-visibility` the renderer sets on what it draws. */ + contentVisibility?: string; + /** The `id` the renderer puts on the element it draws. */ + id?: string; +} + +/** The svg renderer alone sizes its `` from these, as attribute values. */ +interface SvgSizeSettings { + width?: number | string; + height?: number | string; +} + /** The shape every row of the table has to have. */ interface RendererRow { puts: "inline" | "block"; diff --git a/src/animation/useLottie.ts b/src/animation/useLottie.ts index 5d24a16..195302d 100644 --- a/src/animation/useLottie.ts +++ b/src/animation/useLottie.ts @@ -1,4 +1,5 @@ import lottie from "lottie-web"; +import { type LottieEngine, LottieEngineName } from "./configureLottie.js"; import type { LottieInstance, LottieRenderer } from "./types.js"; import { type UseLottieOptions, @@ -35,8 +36,14 @@ import { * the animation uses no expressions either: each carries a smaller copy of the * engine. */ +/** The full engine: every renderer, expressions included. */ +export const fullEngine: LottieEngine = { + player: lottie, + name: LottieEngineName.full, +}; + export function useLottie< Renderer extends LottieRenderer = typeof LottieRenderer.svg, >(options: UseLottieOptions): LottieInstance { - return useLottieAnimation(lottie, options); + return useLottieAnimation(fullEngine, options); } diff --git a/src/animation/useLottieAnimation.motion.test.tsx b/src/animation/useLottieAnimation.motion.test.tsx index 94fc44f..776d534 100644 --- a/src/animation/useLottieAnimation.motion.test.tsx +++ b/src/animation/useLottieAnimation.motion.test.tsx @@ -10,9 +10,9 @@ * @vitest-environment-options { "settings": { "device": { "prefersReducedMotion": "reduce" } } } */ import { act, cleanup, render } from "@testing-library/react"; -import lottie from "lottie-web"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; import { type LottieInstance, LottieState } from "./types.js"; +import { fullEngine } from "./useLottie.js"; import { type UseLottieOptions, useLottieAnimation, @@ -51,7 +51,7 @@ function setup(options: UseLottieOptions): { instance: LottieInstance } { let latest: LottieInstance | undefined; function Probe(props: UseLottieOptions) { - const instance = useLottieAnimation(lottie, props); + const instance = useLottieAnimation(fullEngine, props); latest = instance; return
; } diff --git a/src/animation/useLottieAnimation.server.test.tsx b/src/animation/useLottieAnimation.server.test.tsx index f4f039f..7edd9a5 100644 --- a/src/animation/useLottieAnimation.server.test.tsx +++ b/src/animation/useLottieAnimation.server.test.tsx @@ -9,7 +9,7 @@ import lottie from "lottie-web"; import { renderToString } from "react-dom/server"; import { afterEach, expect, it, vi } from "vitest"; import { LottieState } from "./types.js"; -import { useLottie } from "./useLottie.js"; +import { fullEngine, useLottie } from "./useLottie.js"; import { useLottieAnimation } from "./useLottieAnimation.js"; const ANIMATION = { @@ -66,7 +66,7 @@ it("reports no animation and the loading state on the server", () => { let item: unknown; function Probe() { - const animation = useLottieAnimation(lottie, { src: ANIMATION }); + const animation = useLottieAnimation(fullEngine, { src: ANIMATION }); state = animation.state; item = animation.animationItem; return
; @@ -80,7 +80,7 @@ it("reports no animation and the loading state on the server", () => { it("renders a source it could never load without complaining during render", () => { function Probe() { - const animation = useLottieAnimation(lottie, { src: "" }); + const animation = useLottieAnimation(fullEngine, { src: "" }); return
; } diff --git a/src/animation/useLottieAnimation.test.tsx b/src/animation/useLottieAnimation.test.tsx index e5df1e8..6350229 100644 --- a/src/animation/useLottieAnimation.test.tsx +++ b/src/animation/useLottieAnimation.test.tsx @@ -1,16 +1,19 @@ import { act, cleanup, render } from "@testing-library/react"; import lottie from "lottie-web"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; +import { configureLottie, type LottieEngine } from "./configureLottie.js"; import { LottieDirection, type LottieInstance, LottieState, LottieSubscription, } from "./types.js"; +import { fullEngine } from "./useLottie.js"; import { type UseLottieOptions, useLottieAnimation, } from "./useLottieAnimation.js"; +import { lightEngine } from "./useLottieLight.js"; /** 60 frames at 30fps, so two seconds and a `playableFrames` worth asserting. */ const ANIMATION = { @@ -64,6 +67,8 @@ afterEach(() => { vi.advanceTimersByTime(100); }); vi.restoreAllMocks(); + /* The engine settings are module state; 150 is the engine's own quality. */ + configureLottie({ idPrefix: "lottie-react", quality: 150 }); }); interface Harness { @@ -78,12 +83,15 @@ interface Harness { */ function setup( options: UseLottieOptions, - { strict = false }: { strict?: boolean } = {}, + { + strict = false, + engine = fullEngine, + }: { strict?: boolean; engine?: LottieEngine } = {}, ): Harness { let latest: LottieInstance | undefined; function Probe(props: UseLottieOptions) { - const instance = useLottieAnimation(lottie, props); + const instance = useLottieAnimation(engine, props); latest = instance; return
; } @@ -644,7 +652,7 @@ it("does nothing, rather than throwing, while there is no display", () => { let latest: LottieInstance | undefined; function Unattached() { - latest = useLottieAnimation(lottie, { src: ANIMATION }); + latest = useLottieAnimation(fullEngine, { src: ANIMATION }); // Deliberately never given `setDisplayRef`, so nothing is ever loaded. return
; } @@ -1664,7 +1672,7 @@ it("reports the element it is asked to treat as the root", () => { let latest: LottieInstance | undefined; function Probe({ attached }: { attached: boolean }) { - const instance = useLottieAnimation(lottie, { src: ANIMATION }); + const instance = useLottieAnimation(fullEngine, { src: ANIMATION }); latest = instance; return (
@@ -1694,3 +1702,46 @@ it("reports the element it is asked to treat as the root", () => { expect(latest?.root).toBeNull(); }); + +it("mints element IDs under the library's prefix and the engine's suffix by default", () => { + const full = setup({ src: ANIMATION }); + const light = setup({ src: ANIMATION }, { engine: lightEngine }); + flushLoad(); + + const fullId = full.instance.animationItem?.animationID ?? ""; + const lightId = light.instance.animationItem?.animationID ?? ""; + expect(fullId).toMatch(/^lottie-react-lottie__lottie_element_\d+$/); + expect(lightId).toMatch(/^lottie-react-lottie_light__lottie_element_\d+$/); + expect(fullId).not.toBe(lightId); +}); + +it("mints under a configured base for what loads after the call", () => { + const before = setup({ src: ANIMATION }); + flushLoad(); + expect(before.instance.animationItem?.animationID).toMatch( + /^lottie-react-lottie__/, + ); + + configureLottie({ idPrefix: "crm" }); + const after = setup({ src: ANIMATION }); + flushLoad(); + + expect(after.instance.animationItem?.animationID).toMatch( + /^crm-lottie__lottie_element_\d+$/, + ); +}); + +it("hands the quality to the engine at once and again before every load", () => { + const setQuality = vi.spyOn(lottie, "setQuality"); + setup({ src: ANIMATION }); + flushLoad(); + expect(setQuality).not.toHaveBeenCalledWith("low"); + + configureLottie({ quality: "low" }); + expect(setQuality).toHaveBeenCalledWith("low"); + + setQuality.mockClear(); + setup({ src: ANIMATION }); + flushLoad(); + expect(setQuality).toHaveBeenCalledWith("low"); +}); diff --git a/src/animation/useLottieAnimation.ts b/src/animation/useLottieAnimation.ts index ab8d1ae..8715ca8 100644 --- a/src/animation/useLottieAnimation.ts +++ b/src/animation/useLottieAnimation.ts @@ -1,8 +1,4 @@ -import type { - AnimationEventName, - AnimationItem, - LottiePlayer, -} from "lottie-web"; +import type { AnimationEventName, AnimationItem } from "lottie-web"; import { type RefCallback, useCallback, @@ -15,6 +11,7 @@ import { createLogger } from "../utils/createLogger.js"; import { SubscriptionManager } from "../utils/SubscriptionManager.js"; import { useStableValue } from "../utils/useStableValue.js"; import { collectMarkerCrossings } from "./collectMarkerCrossings.js"; +import { applyEngineSettings, type LottieEngine } from "./configureLottie.js"; import { hasExpressions } from "./hasExpressions.js"; import { type LottieInstanceBox, @@ -102,7 +99,7 @@ function toError(cause: unknown): Error { */ export function useLottieAnimation< Renderer extends LottieRenderer = typeof LottieRenderer.svg, ->(lottie: LottiePlayer, options: UseLottieOptions): LottieInstance { +>(engine: LottieEngine, options: UseLottieOptions): LottieInstance { const { src, renderer, @@ -751,9 +748,10 @@ export function useLottieAnimation< segment: loadConfig.segment, }); + applyEngineSettings(engine); let item: AnimationItem; try { - item = lottie.loadAnimation({ + item = engine.player.loadAnimation({ ...normalized, /* `container` is lottie-web's name for the element we call the display. */ container: display, @@ -951,7 +949,7 @@ export function useLottieAnimation< setAnimationItem(null); logger.log("the animation was destroyed"); }; - }, [display, source, loadConfig, lottie, manager, refreshRange, loadAttempt]); + }, [display, source, loadConfig, engine, manager, refreshRange, loadAttempt]); // One effect per reactive value, so a change to one never disturbs another. useEffect(() => { diff --git a/src/animation/useLottieLight.ts b/src/animation/useLottieLight.ts index 0745806..3fa61f0 100644 --- a/src/animation/useLottieLight.ts +++ b/src/animation/useLottieLight.ts @@ -1,4 +1,5 @@ import lottieLight from "lottie-web/build/player/lottie_light.js"; +import { type LottieEngine, LottieEngineName } from "./configureLottie.js"; import type { LottieInstance, LottieRenderer } from "./types.js"; import { type UseLottieOptions, @@ -15,8 +16,14 @@ import { * drawn at its static value; {@link useLottieSvg} is the smaller build that * keeps expressions. */ +/** The light engine: svg only, no expressions, no `eval`. */ +export const lightEngine: LottieEngine = { + player: lottieLight, + name: LottieEngineName.light, +}; + export function useLottieLight( options: UseLottieOptions, ): LottieInstance { - return useLottieAnimation(lottieLight, options); + return useLottieAnimation(lightEngine, options); } diff --git a/src/animation/useLottieSvg.ts b/src/animation/useLottieSvg.ts index 066d9df..b05a70a 100644 --- a/src/animation/useLottieSvg.ts +++ b/src/animation/useLottieSvg.ts @@ -1,4 +1,5 @@ import lottieSvg from "lottie-web/build/player/lottie_svg.js"; +import { type LottieEngine, LottieEngineName } from "./configureLottie.js"; import type { LottieInstance, LottieRenderer } from "./types.js"; import { type UseLottieOptions, @@ -13,8 +14,14 @@ import { * it for one throws at runtime while its own declarations claim otherwise. * Unlike {@link useLottieLight} it keeps the expression engine. */ +/** The svg engine: svg only, expressions kept. */ +export const svgEngine: LottieEngine = { + player: lottieSvg, + name: LottieEngineName.svg, +}; + export function useLottieSvg( options: UseLottieOptions, ): LottieInstance { - return useLottieAnimation(lottieSvg, options); + return useLottieAnimation(svgEngine, options); } diff --git a/src/index.ts b/src/index.ts index 72284bf..13178e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,18 @@ /* * The public surface: a symbol is public exactly when it is exported here. - * Every export carries the `Lottie` prefix, hooks carry `useLottie`, and the - * sections mirror the folder order under `src/`. Types are free to widen later; - * removing or reshaping anything here is breaking. + * Every export names Lottie: components and types with the `Lottie` prefix, + * hooks with `useLottie`, factories with `lottie`, and the one setup function + * as `configureLottie`. The sections mirror the folder order under `src/`. + * Types are free to widen later; removing or reshaping anything here is + * breaking. */ -// The animation: components, hooks, and the vocabulary they share. +// The animation: components, hooks, the vocabulary they share, and the +// engine's own configuration. +export { + type ConfigureLottieOptions, + configureLottie, +} from "./animation/configureLottie.js"; export { Lottie, type LottieProps } from "./animation/Lottie.js"; export { LottieDisplay, diff --git a/src/interactions/LottieInteractions.test.tsx b/src/interactions/LottieInteractions.test.tsx index dcc4446..a48fc34 100644 --- a/src/interactions/LottieInteractions.test.tsx +++ b/src/interactions/LottieInteractions.test.tsx @@ -1,10 +1,10 @@ import { act, cleanup, render } from "@testing-library/react"; -import lottie from "lottie-web"; import { useCallback, useState } from "react"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; import { LottieInstanceContext } from "../animation/LottieInstanceContext.js"; import type { LottieInstance } from "../animation/types.js"; import { LottieState } from "../animation/types.js"; +import { fullEngine } from "../animation/useLottie.js"; import { type UseLottieOptions, useLottieAnimation, @@ -50,7 +50,10 @@ function AnimProbe({ name: string; instances: Map; } & Partial) { - const instance = useLottieAnimation(lottie, { src: ANIMATION, ...options }); + const instance = useLottieAnimation(fullEngine, { + src: ANIMATION, + ...options, + }); instances.set(name, instance); return
; } @@ -116,7 +119,7 @@ it("the lottie prop drives that animation, and children fall through", () => { let handed: LottieInstance | undefined; function Fixture() { - const a = useLottieAnimation(lottie, { src: ANIMATION }); + const a = useLottieAnimation(fullEngine, { src: ANIMATION }); handed = a; return ( @@ -140,7 +143,7 @@ it("is driven by the animation whose it sits inside", () => { const instances = new Map(); function Fixture() { - const instance = useLottieAnimation(lottie, { src: ANIMATION }); + const instance = useLottieAnimation(fullEngine, { src: ANIMATION }); instances.set("host", instance); return ( @@ -336,7 +339,7 @@ it("a copy of the context's animation stays live", () => { /* The element, and with it the root and the load, arrive after attach. */ function LateElement() { - const instance = useLottieAnimation(lottie, { src: ANIMATION }); + const instance = useLottieAnimation(fullEngine, { src: ANIMATION }); instances.set("a", instance); const [on, setOn] = useState(false); mountElement = () => { diff --git a/src/interactions/lottieInView.test.tsx b/src/interactions/lottieInView.test.tsx index 540ae1e..c897fd4 100644 --- a/src/interactions/lottieInView.test.tsx +++ b/src/interactions/lottieInView.test.tsx @@ -1,9 +1,9 @@ import { act, cleanup, render } from "@testing-library/react"; -import lottie from "lottie-web"; import { useCallback } from "react"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; import type { LottieInstance } from "../animation/types.js"; import { LottieState } from "../animation/types.js"; +import { fullEngine } from "../animation/useLottie.js"; import { useLottieAnimation } from "../animation/useLottieAnimation.js"; import { type IntersectionObserverStub, @@ -53,7 +53,7 @@ function AnimProbe({ instances: Map; autoplay?: boolean; }) { - const instance = useLottieAnimation(lottie, { src: ANIMATION, autoplay }); + const instance = useLottieAnimation(fullEngine, { src: ANIMATION, autoplay }); instances.set("a", instance); const setRefs = useCallback( (element: HTMLElement | null) => { @@ -241,7 +241,7 @@ it("lets go of a root that goes away, and arms nothing new after once", () => { const instances = new Map(); function SwappableProbe({ phase }: { phase: number }) { - const instance = useLottieAnimation(lottie, { src: ANIMATION }); + const instance = useLottieAnimation(fullEngine, { src: ANIMATION }); instances.set("a", instance); const setRefs = useCallback( (element: HTMLElement | null) => { diff --git a/src/interactions/lottieScrollScrub.test.tsx b/src/interactions/lottieScrollScrub.test.tsx index 9f129fc..5a4f015 100644 --- a/src/interactions/lottieScrollScrub.test.tsx +++ b/src/interactions/lottieScrollScrub.test.tsx @@ -1,9 +1,9 @@ import { act, cleanup, render } from "@testing-library/react"; -import lottie from "lottie-web"; import { useCallback } from "react"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; import type { LottieInstance } from "../animation/types.js"; import { LottieState } from "../animation/types.js"; +import { fullEngine } from "../animation/useLottie.js"; import { useLottieAnimation } from "../animation/useLottieAnimation.js"; import { type IntersectionObserverStub, @@ -59,7 +59,7 @@ function AnimProbe({ instances: Map; autoplay?: boolean; }) { - const instance = useLottieAnimation(lottie, { src: ANIMATION, autoplay }); + const instance = useLottieAnimation(fullEngine, { src: ANIMATION, autoplay }); instances.set("a", instance); const setRefs = useCallback( (element: HTMLElement | null) => { @@ -409,7 +409,7 @@ it("lets go cleanly when the root goes away mid-life", () => { const instances = new Map(); function SwappableProbe({ rootless }: { rootless: boolean }) { - const instance = useLottieAnimation(lottie, { src: ANIMATION }); + const instance = useLottieAnimation(fullEngine, { src: ANIMATION }); instances.set("a", instance); const setRefs = useCallback( (element: HTMLElement | null) => { diff --git a/src/interactions/useLottieInteractions.test.tsx b/src/interactions/useLottieInteractions.test.tsx index 0376a38..87ca7f5 100644 --- a/src/interactions/useLottieInteractions.test.tsx +++ b/src/interactions/useLottieInteractions.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, render } from "@testing-library/react"; -import lottie from "lottie-web"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; import type { LottieInstance } from "../animation/types.js"; +import { fullEngine } from "../animation/useLottie.js"; import { useLottieAnimation } from "../animation/useLottieAnimation.js"; import type { LottieInteractionContext } from "./types.js"; import { useLottieInteractions } from "./useLottieInteractions.js"; @@ -50,7 +50,7 @@ it("attaches once to the animation it was handed, inline array and all", () => { let latest: LottieInstance | undefined; function Fixture({ amount }: { amount: number }) { - const instance = useLottieAnimation(lottie, { src: ANIMATION }); + const instance = useLottieAnimation(fullEngine, { src: ANIMATION }); latest = instance; useLottieInteractions(instance, [ { ...spy.interaction, options: { amount } }, diff --git a/website/content/docs/(v3)/animation/configuring-the-engine.mdx b/website/content/docs/(v3)/animation/configuring-the-engine.mdx new file mode 100644 index 0000000..b610624 --- /dev/null +++ b/website/content/docs/(v3)/animation/configuring-the-engine.mdx @@ -0,0 +1,49 @@ +--- +title: Configuring the engine +description: The two settings that belong to the engine rather than to one animation. +--- + +Most of what lottie-web does is per animation, and those things are props. +Two settings are not: they belong to a loaded copy of the engine, and one animation changing them would change every other animation on the page. +`configureLottie` sets them for all three engine builds at once: + +```ts +import { configureLottie } from "lottie-react"; + +configureLottie({ idPrefix: "crm", quality: "low" }); +``` + +Set it once, at startup, before animations load. +It takes effect at once on every engine that has loaded an animation, and on the others when they first do, and it reaches what is already on screen: element IDs for everything the engines build from then on, and the drawing quality of every animation on them. +A field left out keeps its value. +The settings are held for the life of the page, or of the server process, so it is a one-time setup, not something to call per render or per request. + +## Element IDs + +The engine gives every clip path, mask and gradient it draws an ID from one counter per engine copy. +Two engine copies on one page, a monorepo whose packages each bundle their own, count from one twice: the two animations share an ID, and the second is drawn with the first's clip path. + +`idPrefix` is the base of every ID. +The library prefixes by default with `lottie-react`, and each build appends its own name, `-lottie`, `-lottie_svg` or `-lottie_light`, so `Lottie` and `LottieLight` on one page never share an ID, and a separate lottie-web on the page, with no prefix of its own, cannot collide with ours. +The library sets the engine's prefix itself before every load, so choose it here rather than through lottie-web directly. +Two copies of the library each set a base of their own: + +```ts +configureLottie({ idPrefix: "crm" }); // crm-lottie__lottie_element_1, crm-lottie_light__lottie_element_1, ... +``` + +## Quality + +`quality` is how finely the engine draws curves: `low`, `medium`, `high`, or a number of segments above 1. +Fewer segments mean less work per frame, which is what a page with many animations or a slower device wants; more mean smoother curves. +Left alone, the engine draws with 150 segments, between `medium` (50) and `high` (200), and passing that number restores it. +The engine reads it whenever it builds a curve, so a change reaches every animation on the engine, running ones included; set it once, before they load. + +```ts +configureLottie({ quality: "low" }); +``` + +## What stays per animation + +Everything on `` and `useLottie` is per animation, the renderer and its settings included; see [Renderers and the smaller builds](/docs/animation/renderers-and-smaller-builds). +`configureLottie` is for the two settings that cannot be. diff --git a/website/content/docs/(v3)/animation/meta.json b/website/content/docs/(v3)/animation/meta.json index 67efd0f..874c2d9 100644 --- a/website/content/docs/(v3)/animation/meta.json +++ b/website/content/docs/(v3)/animation/meta.json @@ -9,6 +9,7 @@ "loading-and-errors", "seeking-and-segments", "renderers-and-smaller-builds", + "configuring-the-engine", "styling" ], "icon": "Clapperboard" diff --git a/website/content/docs/(v3)/migration.mdx b/website/content/docs/(v3)/migration.mdx index caf7bd0..6ccc71e 100644 --- a/website/content/docs/(v3)/migration.mdx +++ b/website/content/docs/(v3)/migration.mdx @@ -29,7 +29,7 @@ import { Lottie } from "lottie-react"; // v3 ``` The v2 types went with it: `LottieRefCurrentProps` and `LottieRef` become `LottieHandle`, `LottieOptions` becomes `UseLottieOptions`, and the component's props type is `LottieProps`. -`LottiePlayer`, the re-export of the engine's player object, is removed with no replacement: the engine's global settings are not part of the v3 surface. +`LottiePlayer`, the re-export of the engine's player object, is removed; the engine's global settings, its ID prefix and its quality, are set through [`configureLottie`](/docs/animation/configuring-the-engine), and element IDs are prefixed by default (`lottie-react-lottie__lottie_element_1` where v2 minted `__lottie_element_1`). ## Two defaults flipped diff --git a/website/content/docs/(v3)/reference/configure-lottie.mdx b/website/content/docs/(v3)/reference/configure-lottie.mdx new file mode 100644 index 0000000..53dfdac --- /dev/null +++ b/website/content/docs/(v3)/reference/configure-lottie.mdx @@ -0,0 +1,17 @@ +--- +title: configureLottie +description: The engine's global settings, and when they apply. +--- + +`configureLottie(options)` sets what belongs to the engine rather than to one animation, for all three engine builds. +Set it once, at startup, before animations load: it takes effect at once, engine-wide, running animations included. + +## Options + +| Option | Type | Behaviour | +|------------|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| `idPrefix` | `string` | The base of every element ID the engine mints. Each build appends its own name (`-lottie`, `-lottie_svg`, `-lottie_light`). Default `lottie-react`. | +| `quality` | `"low" \| "medium" \| "high" \| number` | How finely curves are drawn: 10, 50 or 200 segments, or any number above 1. Left alone the engine draws with 150. | + +A field left out keeps its value. +[Configuring the engine](/docs/animation/configuring-the-engine) has the reasons and the two-copies case. diff --git a/website/content/docs/(v3)/reference/lottie.mdx b/website/content/docs/(v3)/reference/lottie.mdx index 4a3bbac..16bf298 100644 --- a/website/content/docs/(v3)/reference/lottie.mdx +++ b/website/content/docs/(v3)/reference/lottie.mdx @@ -11,7 +11,7 @@ description: The three components, and every prop of their own. | --- | --- | --- | | `src` | `string \| object` | The animation: a path or URL to fetch, or the parsed file. Load-time; compared by content. | | `renderer` | `"svg" \| "canvas" \| "html"` | Which renderer draws it. Load-time. Default `svg`. | -| `rendererSettings` | per renderer | Settings typed to match the chosen renderer. Load-time. | +| `rendererSettings` | per renderer | Settings typed to match the chosen renderer, including the fields the engine reads that its own declarations leave out (`runExpressions`, `contentVisibility`, `id`; `width` and `height` on svg). Load-time. | | `autoplay` | `boolean` | Start as soon as the animation is ready. Load-time. Default `false`; holds under reduced motion. | | `segment` | `[number, number]` | The frame range to play. Load-time. | | `assetsPath` | `string` | Where the animation's images live when not beside it. Load-time. | diff --git a/website/content/docs/(v3)/reference/meta.json b/website/content/docs/(v3)/reference/meta.json index 2762626..2913e46 100644 --- a/website/content/docs/(v3)/reference/meta.json +++ b/website/content/docs/(v3)/reference/meta.json @@ -7,7 +7,8 @@ "lottie-controls", "overlays", "interactions", - "stylesheet" + "stylesheet", + "configure-lottie" ], "icon": "BookOpen" }