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
105 changes: 99 additions & 6 deletions src/interactions/LottieInteractions.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, LottieInstance>();
Expand Down Expand Up @@ -116,9 +130,9 @@ it("the lottie prop drives that animation, and children fall through", () => {
render(<Fixture />);

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 <Lottie> it sits inside", () => {
Expand All @@ -138,7 +152,7 @@ it("is driven by the animation whose <Lottie> it sits inside", () => {
render(<Fixture />);

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", () => {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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<string, LottieInstance>();
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 ? <div ref={setRefs} /> : null;
}

render(
<LottieInteractions interactions={[{ attach: copying, options: {} }]}>
<LateElement />
</LottieInteractions>,
);
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<string, LottieInstance>();

function Fixture({ tone }: { tone: number }) {
return (
<LottieInteractions
interactions={[{ attach: spy.attach, options: { tone } }]}
>
<AnimProbe name="a" instances={instances} />
</LottieInteractions>
);
}

const view = render(<Fixture tone={1} />);
const first = spy.context.lottie;
expect(spy.context.lottie).toBe(first);

view.rerender(<Fixture tone={2} />);

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", () => {
Expand Down
6 changes: 3 additions & 3 deletions src/interactions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
32 changes: 29 additions & 3 deletions src/interactions/useInteractionsRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ interface Attachment {
memory: Record<string, unknown>;
}

/*
* 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -77,6 +97,9 @@ export function useInteractionsRunner(
);

const runtime = useRef(new Map<LottieInstanceBox, Attachment[]>()).current;
const views = useRef(
new WeakMap<LottieInstanceBox, LottieInstance>(),
).current;

/*
* The attachments live in a ref rather than inside the effect, so a changed
Expand All @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/interactions/useLottieInteractions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ it("attaches once to the animation it was handed, inline array and all", () => {
view.rerender(<Fixture amount={0.5} />);

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(<Fixture amount={0.9} />);
expect(spy.attach).toHaveBeenCalledTimes(2);
Expand Down
4 changes: 2 additions & 2 deletions website/content/docs/(v3)/interactions/writing-your-own.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 5 additions & 5 deletions website/src/components/examples/interactions/custom-factory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -22,7 +22,7 @@ function playWhilePressed(): LottieInteraction {
};
};
arm();
const stop = context.onChange(arm);
const stop = onChange(arm);
return () => {
stop();
detach?.();
Expand Down