From 13ce4b89a1802b0d9ec28f742ae85548542ccf8a Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Fri, 3 Jul 2026 14:03:42 -0700 Subject: [PATCH] Fix Lottie replay for Next builds and fallback sources --- compiler/src/capture/lottie.ts | 17 +++++++++--- compiler/src/generate/lottie.ts | 48 ++++++++++++++++++--------------- compiler/test/lottie.test.ts | 14 ++++++++++ 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/compiler/src/capture/lottie.ts b/compiler/src/capture/lottie.ts index 84c7356..272fc24 100644 --- a/compiler/src/capture/lottie.ts +++ b/compiler/src/capture/lottie.ts @@ -89,6 +89,13 @@ export async function captureLotties( if (!u || typeof u !== "string") return null; try { return new URL(u, document.baseURI).href; } catch { return null; } }; + const registrySrc = (path: string, fileName: string): string | null => { + if (!path) return null; + if (/\.json(?:[?#]|$)/i.test(path)) return abs(path); + if (!fileName) return null; + const base = path.endsWith("/") ? path : path + "/"; + return abs(base + (fileName.includes(".") ? fileName : fileName + ".json")); + }; // climb to the nearest ancestor (or self) carrying a data-cid-cap const capOf = (node: Element | null): string | null => { let el: Element | null = node; @@ -144,10 +151,12 @@ export async function captureLotties( // fall back to the in-memory animationData document. const path = (a.path as string) || ""; const fileName = (a.fileName as string) || ""; - let src: string | null = null; - if (path) src = abs(path.endsWith("/") || !fileName ? path + (fileName.endsWith(".json") ? fileName : "data.json") : path + (fileName ? (fileName.includes(".") ? fileName : fileName + ".json") : "")); - let inlineKey: string | null = null; - if (!src && a.animationData) inlineKey = stashInline(a.animationData); + const src = registrySrc(path, fileName); + // Keep in-memory data as a fallback even when the registry reports a URL: + // some sites expose only a directory-ish path, and guessed `data.json` + // URLs 404. Generation prefers a downloaded local path, then falls back + // to this JSON if the URL is absent or unreachable. + const inlineKey = a.animationData ? stashInline(a.animationData) : null; const rendererType = ((a.renderer as { rendererType?: string })?.rendererType) || (a.renderer && a.renderer.constructor && a.renderer.constructor.name === "CanvasRenderer" ? "canvas" : undefined); push({ container, via: "registry", src, inlineKey, renderer: rendererType, loop: a.loop, autoplay: a.autoplay }); diff --git a/compiler/src/generate/lottie.ts b/compiler/src/generate/lottie.ts index b64c74e..7eeb49d 100644 --- a/compiler/src/generate/lottie.ts +++ b/compiler/src/generate/lottie.ts @@ -108,7 +108,6 @@ export function lottieWireJsx(spec: LottieSpec, indent: number): string { export const DITTO_LOTTIE_TSX = `"use client"; import { useEffect } from "react"; -import lottie from "lottie-web"; type RTLottie = { cid: string; @@ -127,28 +126,33 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data export default function DittoLottie({ spec }: { spec: LottieSpec }) { useEffect(() => { const stopped = (window as any).__dittoMotionStopped === true; - const anims: Array> = []; - for (const it of spec.items) { - const el = byCid(it.cid); - if (!el) continue; - // clear the captured placeholder frame so it never stacks with the live render - el.innerHTML = ""; - try { - const anim = lottie.loadAnimation({ - container: el, - renderer: it.renderer === "canvas" ? "canvas" : "svg", - loop: it.loop, - autoplay: it.autoplay && !stopped, - ...(it.path ? { path: it.path } : { animationData: it.animationData as object }), - rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, - }); - if (stopped) { try { anim.goToAndStop(0, true); } catch {} } - anims.push(anim); - } catch { - /* a single bad animation must not break the page */ + const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void }> = []; + let cancelled = false; + void (async () => { + const lottie = (await import("lottie-web")).default; + if (cancelled) return; + for (const it of spec.items) { + const el = byCid(it.cid); + if (!el) continue; + // clear the captured placeholder frame so it never stacks with the live render + el.innerHTML = ""; + try { + const anim = lottie.loadAnimation({ + container: el, + renderer: it.renderer === "canvas" ? "canvas" : "svg", + loop: it.loop, + autoplay: it.autoplay && !stopped, + ...(it.path ? { path: it.path } : { animationData: it.animationData as object }), + rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, + }); + if (stopped) { try { anim.goToAndStop(0, true); } catch {} } + anims.push(anim); + } catch { + /* a single bad animation must not break the page */ + } } - } - return () => { for (const a of anims) { try { a.destroy(); } catch {} } }; + })().catch(() => {}); + return () => { cancelled = true; for (const a of anims) { try { a.destroy(); } catch {} } }; }, [spec]); return null; } diff --git a/compiler/test/lottie.test.ts b/compiler/test/lottie.test.ts index 7e6e231..1b7c2e4 100644 --- a/compiler/test/lottie.test.ts +++ b/compiler/test/lottie.test.ts @@ -62,6 +62,20 @@ describe("buildLottieSpec", () => { assert.equal(spec.items[0]!.renderer, "canvas"); }); + it("falls back to inline animationData when a source URL was detected but not downloaded", () => { + const ir = irWith(node("n0", {}, [node("n6", { "data-cid-cap": "cap-1" })])); + const motion = emptyMotion({ + lotties: [lottie({ src: "https://x/missing.json", inlineKey: "k0" })], + lottieInline: { k0: { v: "5.7", layers: [] } }, + }); + const graph: AssetGraph = { entries: [], byUrl: new Map() }; + + const spec = buildLottieSpec(ir, motion, graph); + assert.equal(spec.items.length, 1); + assert.equal(spec.items[0]!.path, null); + assert.deepEqual(spec.items[0]!.animationData, { v: "5.7", layers: [] }); + }); + it("drops a lottie whose container node didn't survive into the IR", () => { const ir = irWith(node("n0", {}, [node("n6", { "data-cid-cap": "other-cap" })])); const motion = emptyMotion({ lotties: [lottie({ src: "https://x/anim.json" })] });