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" }