Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/animation/Lottie.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,4 +51,4 @@ export type LottieProps<
* animation uses no expressions either: each carries a smaller copy of the
* engine.
*/
export const Lottie = createLottieComponent<LottieRenderer>(lottie);
export const Lottie = createLottieComponent<LottieRenderer>(fullEngine);
4 changes: 2 additions & 2 deletions src/animation/LottieLight.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<RendererInLight>(lottieLight);
export const LottieLight = createLottieComponent<RendererInLight>(lightEngine);
4 changes: 2 additions & 2 deletions src/animation/LottieRegistryContext.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +8,7 @@ import {
type LottieRegistryStore,
} from "./LottieRegistryContext.js";
import { LottieState } from "./types.js";
import { fullEngine } from "./useLottie.js";
import {
type UseLottieOptions,
useLottieAnimation,
Expand Down Expand Up @@ -44,7 +44,7 @@ afterEach(() => {
});

function Probe(props: UseLottieOptions) {
const instance = useLottieAnimation(lottie, props);
const instance = useLottieAnimation(fullEngine, props);
return <div ref={instance.setDisplayRef} />;
}

Expand Down
4 changes: 2 additions & 2 deletions src/animation/LottieSvg.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<RendererInSvg>(lottieSvg);
export const LottieSvg = createLottieComponent<RendererInSvg>(svgEngine);
88 changes: 88 additions & 0 deletions src/animation/configureLottie.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
101 changes: 101 additions & 0 deletions src/animation/configureLottie.ts
Original file line number Diff line number Diff line change
@@ -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<LottiePlayer, LottieEngineName>();

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);
}
4 changes: 2 additions & 2 deletions src/animation/createLottieComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { LottiePlayer } from "lottie-web";
import {
type ReactNode,
type Ref,
Expand All @@ -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";
Expand Down Expand Up @@ -130,7 +130,7 @@ export type LottieComponent<Renderers extends LottieRenderer> = <
* than a blank animation and a runtime throw.
*/
export function createLottieComponent<Renderers extends LottieRenderer>(
engine: LottiePlayer,
engine: LottieEngine,
): LottieComponent<Renderers> {
return polymorphicForwardRef(function Lottie<
As extends AnyTag = "div",
Expand Down
36 changes: 36 additions & 0 deletions src/animation/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions src/animation/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AnimationItem,
CanvasRendererConfig,
FilterSizeConfig,
HTMLRendererConfig,
RendererType,
SVGRendererConfig,
Expand Down Expand Up @@ -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<SettingsTheEngineReads, "runExpressions"> & {
/** 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 `<svg>` 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";
Expand Down
Loading