diff --git a/src/interactions/LottieInteractions.test.tsx b/src/interactions/LottieInteractions.test.tsx index 3531e5c..dcc4446 100644 --- a/src/interactions/LottieInteractions.test.tsx +++ b/src/interactions/LottieInteractions.test.tsx @@ -1,9 +1,10 @@ import { act, cleanup, render } from "@testing-library/react"; import lottie from "lottie-web"; -import { useState } from "react"; +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 { type UseLottieOptions, useLottieAnimation, @@ -77,6 +78,19 @@ function spyInteraction(options: unknown = {}) { }; } +/** + * The context hands out a live view of the animation rather than the instance + * object itself, so "this context drives that animation" is proved through + * `subscribe`, which is one function per animation for its whole life. + */ +function expectDrives( + context: LottieInteractionContext, + instance: LottieInstance | undefined, +) { + expect(instance).toBeDefined(); + expect(context.lottie.subscribe).toBe(instance?.subscribe); +} + it("drives every animation rendered inside it", () => { const spy = spyInteraction(); const instances = new Map(); @@ -116,9 +130,9 @@ it("the lottie prop drives that animation, and children fall through", () => { render(); expect(inner.attach).toHaveBeenCalledTimes(1); - expect(inner.context.lottie).toBe(handed); + expectDrives(inner.context, handed); expect(outer.attach).toHaveBeenCalledTimes(1); - expect(outer.context.lottie).toBe(instances.get("b")); + expectDrives(outer.context, instances.get("b")); }); it("is driven by the animation whose it sits inside", () => { @@ -138,7 +152,7 @@ it("is driven by the animation whose it sits inside", () => { render(); expect(spy.attach).toHaveBeenCalledTimes(1); - expect(spy.context.lottie).toBe(instances.get("host")); + expectDrives(spy.context, instances.get("host")); }); it("the nearest wrapper wins, and even an empty one isolates", () => { @@ -158,7 +172,7 @@ it("the nearest wrapper wins, and even an empty one isolates", () => { ); expect(inner.attach).toHaveBeenCalledTimes(1); - expect(inner.context.lottie).toBe(instances.get("a")); + expectDrives(inner.context, instances.get("a")); expect(outer.attach).not.toHaveBeenCalled(); }); @@ -308,7 +322,86 @@ it("a late animation is armed when it arrives, none before", () => { }); expect(spy.attach).toHaveBeenCalledTimes(1); - expect(spy.context.lottie).toBe(instances.get("late")); + expectDrives(spy.context, instances.get("late")); +}); + +it("a copy of the context's animation stays live", () => { + let copy: LottieInstance | undefined; + const copying = vi.fn(({ lottie }: LottieInteractionContext) => { + copy = lottie; + return undefined; + }); + const instances = new Map(); + let mountElement: () => void = () => undefined; + + /* The element, and with it the root and the load, arrive after attach. */ + function LateElement() { + const instance = useLottieAnimation(lottie, { src: ANIMATION }); + instances.set("a", instance); + const [on, setOn] = useState(false); + mountElement = () => { + setOn(true); + }; + const setRefs = useCallback( + (element: HTMLElement | null) => { + instance.setDisplayRef(element); + instance.setRootRef(element); + }, + [instance.setDisplayRef, instance.setRootRef], + ); + return on ?
: null; + } + + render( + + + , + ); + expect(copying).toHaveBeenCalledTimes(1); + expect(copy?.root).toBeNull(); + expect(copy?.state).toBe(LottieState.loading); + + act(() => { + mountElement(); + }); + act(() => { + vi.advanceTimersByTime(0); + }); + + expect(copy?.root).not.toBeNull(); + expect(copy?.state).not.toBe(LottieState.loading); + act(() => { + copy?.play(); + }); + expect(instances.get("a")?.animationItem?.isPaused).toBe(false); + expect(Object.keys(copy ?? {})).toEqual( + Object.keys(instances.get("a") ?? {}), + ); +}); + +it("the context's animation is one stable object", () => { + const spy = spyInteraction({ tone: 1 }); + const instances = new Map(); + + function Fixture({ tone }: { tone: number }) { + return ( + + + + ); + } + + const view = render(); + const first = spy.context.lottie; + expect(spy.context.lottie).toBe(first); + + view.rerender(); + + expect(spy.attach).toHaveBeenCalledTimes(2); + expect(spy.context.lottie).toBe(first); + expectDrives(spy.context, instances.get("a")); }); it("hears about an animation's values moving through onChange", () => { diff --git a/src/interactions/types.ts b/src/interactions/types.ts index 0876bb7..ee7e613 100644 --- a/src/interactions/types.ts +++ b/src/interactions/types.ts @@ -11,9 +11,9 @@ import type { LottieInstance } from "../animation/types.js"; */ export interface LottieInteractionContext { /** - * The animation: values, commands, `subscribe`, `root`. Read it through the - * context on every use: it answers the current animation each time, and a - * copy taken once keeps that moment's values, `root` included. + * The animation: values, commands, `subscribe`, `root`. Live: every member + * answers the current animation, so keeping or destructuring it is safe; a + * value pulled out of it is a copy of that moment. */ readonly lottie: LottieInstance; /** The descriptor's options as they are right now. */ diff --git a/src/interactions/useInteractionsRunner.ts b/src/interactions/useInteractionsRunner.ts index 27ca82f..0d2fd8e 100644 --- a/src/interactions/useInteractionsRunner.ts +++ b/src/interactions/useInteractionsRunner.ts @@ -24,6 +24,26 @@ interface Attachment { memory: Record; } +/* + * The animation object a hook returns is rebuilt on every render, so anything + * that keeps one keeps the past. A factory naturally keeps what it is handed + * (`const { lottie } = context`), so what it is handed is a view: one object + * per animation whose members read the current instance on every access. + * The members are the instance's own, read from it once, so the view is + * complete by construction. Getters only: a member of the view is a reading, + * not a place to write, and a value pulled out of it is a copy of that moment. + */ +function createLiveInstanceView(box: LottieInstanceBox): LottieInstance { + const view = {} as LottieInstance; + for (const key of Object.keys(box.current) as (keyof LottieInstance)[]) { + Object.defineProperty(view, key, { + enumerable: true, + get: () => box.current[key], + }); + } + return view; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -77,6 +97,9 @@ export function useInteractionsRunner( ); const runtime = useRef(new Map()).current; + const views = useRef( + new WeakMap(), + ).current; /* * The attachments live in a ref rather than inside the effect, so a changed @@ -98,10 +121,13 @@ export function useInteractionsRunner( listeners: new Set(), memory: memory ?? {}, }; + let view = views.get(box); + if (view === undefined) { + view = createLiveInstanceView(box); + views.set(box, view); + } const context: LottieInteractionContext = { - get lottie() { - return box.current; - }, + lottie: view, options: () => latest.current[slot]?.options, onChange: (listener) => { record.listeners.add(listener); diff --git a/src/interactions/useLottieInteractions.test.tsx b/src/interactions/useLottieInteractions.test.tsx index b5f3f07..0376a38 100644 --- a/src/interactions/useLottieInteractions.test.tsx +++ b/src/interactions/useLottieInteractions.test.tsx @@ -62,7 +62,9 @@ it("attaches once to the animation it was handed, inline array and all", () => { view.rerender(); expect(spy.attach).toHaveBeenCalledTimes(1); - expect(spy.contexts.at(-1)?.lottie).toBe(latest); + /* The context hands out a live view, so identity is proved through + `subscribe`, one function per animation for its whole life. */ + expect(spy.contexts.at(-1)?.lottie.subscribe).toBe(latest?.subscribe); view.rerender(); expect(spy.attach).toHaveBeenCalledTimes(2); diff --git a/website/content/docs/(v3)/interactions/writing-your-own.mdx b/website/content/docs/(v3)/interactions/writing-your-own.mdx index e218112..9fd0b71 100644 --- a/website/content/docs/(v3)/interactions/writing-your-own.mdx +++ b/website/content/docs/(v3)/interactions/writing-your-own.mdx @@ -15,12 +15,12 @@ Press and hold the animation to play it. `attach(context, options)` runs once per animation and returns its cleanup, or nothing when there was nothing to attach. The context carries four things: -- `lottie`: the animation, read fresh on every use. +- `lottie`: the animation, live: its members always answer the current animation. - `options()`: the descriptor's options as they are right now, so a callback option stays current without re-attaching. - `onChange(listener)`: fires when the root arrives, the values move, or the options change; re-check what you armed against and return early when nothing you use moved. - `memory`: scratch that survives an option change, for state that must outlive a re-attach. -The factory above arms through `onChange` because `root` can arrive after `attach` runs, and it reads `context.lottie` each time rather than copying it once: the context hands out the current animation on every read, and a copy taken at attach time keeps the `root` of that moment, usually `null`. +The factory above arms through `onChange` because `root` can arrive after `attach` runs; `context.lottie` is a live view, so holding it is safe, and a value pulled out of it is a copy of that moment. `LottieInteraction` and `LottieInteractionContext` are exported, so a factory of yours typechecks against the same contract the shipped ones use. diff --git a/website/src/components/examples/interactions/custom-factory.tsx b/website/src/components/examples/interactions/custom-factory.tsx index ceb969f..794012b 100644 --- a/website/src/components/examples/interactions/custom-factory.tsx +++ b/website/src/components/examples/interactions/custom-factory.tsx @@ -7,13 +7,13 @@ import { function playWhilePressed(): LottieInteraction { return { options: undefined, - attach: (context) => { + attach: ({ lottie, onChange }) => { let detach: (() => void) | undefined; const arm = () => { - const root = context.lottie.root; + const root = lottie.root; if (root === null || detach !== undefined) return; - const down = () => context.lottie.play(); - const up = () => context.lottie.pause(); + const down = () => lottie.play(); + const up = () => lottie.pause(); root.addEventListener("pointerdown", down); root.addEventListener("pointerup", up); detach = () => { @@ -22,7 +22,7 @@ function playWhilePressed(): LottieInteraction { }; }; arm(); - const stop = context.onChange(arm); + const stop = onChange(arm); return () => { stop(); detach?.();