/** @jsxImportSource solid-js */ import { createSignal, For, Show, createMemo, onMount } from "solid-js"; import { render } from "solid-js/web"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("motion-dom", () => import("../test/motion-dom-mock.ts")); import { motion } from "../component/motion.ts"; import { AnimatePresence } from "./animate-presence.tsx"; function createRoot() { const root = document.createElement("div"); document.body.append(root); return root; } async function flushEffects() { await Promise.resolve(); await Promise.resolve(); } afterEach(() => { document.body.innerHTML = ""; vi.useRealTimers(); }); describe("AnimatePresence", () => { it("retains a Show-controlled motion child until its exit animation completes", async () => { vi.useFakeTimers(); const root = createRoot(); const [open, setOpen] = createSignal(true); const dispose = render( () => ( ), root, ); await flushEffects(); expect(root.querySelector("[data-testid='modal']")).not.toBeNull(); setOpen(false); await flushEffects(); expect(root.querySelector("[data-testid='modal']")).not.toBeNull(); vi.runAllTimers(); await flushEffects(); expect(root.querySelector("[data-testid='modal']")).toBeNull(); dispose(); }); it.todo("retains a wrapped nested motion descendant in the single-slot path"); it("retains a For-controlled motion child until its exit animation completes", async () => { vi.useFakeTimers(); const root = createRoot(); const alpha = { id: "alpha" }; const beta = { id: "beta" }; const [items, setItems] = createSignal([alpha, beta]); const dispose = render( () => ( {(item) => ( {item.id} )} ), root, ); await flushEffects(); expect(root.querySelector('[data-testid="alpha"]')).not.toBeNull(); expect(root.querySelector('[data-testid="beta"]')).not.toBeNull(); setItems([beta]); await flushEffects(); expect(root.querySelector('[data-testid="alpha"]')).not.toBeNull(); expect(root.querySelectorAll('[data-testid="beta"]')).toHaveLength(1); vi.runAllTimers(); await flushEffects(); expect(root.querySelector('[data-testid="alpha"]')).toBeNull(); expect(root.querySelectorAll('[data-testid="beta"]')).toHaveLength(1); dispose(); }); it.todo("retains a nested motion descendant inside a keyed list item when the descendant has presenceKey"); it.todo("retains a nested motion descendant inside a keyed list item without descendant presenceKey"); it("retains only the removed child from a direct motion child array when presenceKey is stable", async () => { vi.useFakeTimers(); const root = createRoot(); const alpha = { id: "alpha" }; const beta = { id: "beta" }; const [items, setItems] = createSignal([alpha, beta]); const dispose = render( () => ( {items().map((item) => ( {item.id} ))} ), root, ); await flushEffects(); setItems([beta]); await flushEffects(); expect(root.querySelector('[data-testid="alpha"]')).not.toBeNull(); expect(root.querySelectorAll('[data-testid="beta"]')).toHaveLength(1); vi.runAllTimers(); await flushEffects(); expect(root.querySelector('[data-testid="alpha"]')).toBeNull(); expect(root.querySelectorAll('[data-testid="beta"]')).toHaveLength(1); dispose(); }); it("reorders For children without duplicating siblings", async () => { const root = createRoot(); const alpha = { id: "alpha" }; const beta = { id: "beta" }; const gamma = { id: "gamma" }; const [items, setItems] = createSignal([alpha, beta, gamma]); const dispose = render( () => ( {(item) => ( {item.id} )} ), root, ); await flushEffects(); setItems([gamma, alpha, beta]); await flushEffects(); expect([...root.querySelectorAll('[data-testid="ordered-item"]')].map((element) => element.textContent )).toEqual(["gamma", "alpha", "beta"]); dispose(); }); it("cancels exit when a For child reappears with the same presenceKey", async () => { vi.useFakeTimers(); const root = createRoot(); const alpha = { id: "alpha" }; const beta = { id: "beta" }; const [items, setItems] = createSignal([alpha, beta]); const dispose = render( () => ( {(item) => ( {item.id} )} ), root, ); await flushEffects(); setItems([beta]); await flushEffects(); expect(root.querySelectorAll('[data-testid="alpha"]')).toHaveLength(1); setItems([alpha, beta]); await flushEffects(); expect(root.querySelectorAll('[data-testid="alpha"]')).toHaveLength(1); expect(root.querySelectorAll('[data-testid="beta"]')).toHaveLength(1); vi.runAllTimers(); await flushEffects(); expect(root.querySelectorAll('[data-testid="alpha"]')).toHaveLength(1); dispose(); }); it("treats same-key reentry after removal as a fresh component lifetime", async () => { vi.useFakeTimers(); const root = createRoot(); const alpha = { id: "alpha" }; const beta = { id: "beta" }; const [items, setItems] = createSignal([alpha, beta]); let alphaLifetimeCount = 0; function AlphaLifetimeLabel() { const [lifetime] = createSignal(++alphaLifetimeCount); onMount(() => { // Keep the test anchored to real component lifetime creation instead of DOM reuse alone. expect(lifetime()).toBeGreaterThan(0); }); return {lifetime()}; } const dispose = render( () => ( {(item) => ( {item.id === "alpha" ? : item.id} )} ), root, ); await flushEffects(); expect(root.querySelector('[data-testid="alpha-lifetime"]')?.textContent).toBe("1"); setItems([beta]); await flushEffects(); expect(root.querySelector('[data-testid="alpha-lifetime"]')?.textContent).toBe("1"); setItems([alpha, beta]); await flushEffects(); // Same-key reentry gets a fresh logical lifetime rather than reviving the old exiting one. expect(root.querySelector('[data-testid="alpha-lifetime"]')?.textContent).toBe("2"); expect(root.querySelectorAll('[data-testid="alpha"]')).toHaveLength(1); vi.runAllTimers(); await flushEffects(); expect(root.querySelector('[data-testid="alpha-lifetime"]')?.textContent).toBe("2"); dispose(); }); it("retains one disappearing child until its exit animation settles", async () => { let setIsVisible!: (value: boolean) => void; function TestHost() { const [isVisible, nextIsVisible] = createSignal(true); setIsVisible = nextIsVisible; return ( {isVisible() && ( content )} ); } const host = document.createElement("div"); document.body.append(host); const dispose = render(() => , host); await vi.waitFor(() => { expect(host.querySelector('[data-testid="child"]')).not.toBeNull(); }); setIsVisible(false); expect(host.querySelector('[data-testid="child"]')).not.toBeNull(); await vi.waitFor(() => { expect(host.querySelector('[data-testid="child"]')).toBeNull(); }); dispose(); }); it("admits the next direct child after the previous wait-mode child exits", async () => { let setScene!: (value: "aurora" | "ember") => void; function TestHost() { const [scene, nextScene] = createSignal<"aurora" | "ember">("aurora"); setScene = nextScene; return ( {scene() === "aurora" ? ( aurora ) : ( ember )} ); } const host = document.createElement("div"); document.body.append(host); const dispose = render(() => , host); await vi.waitFor(() => { expect(host.querySelector('[data-testid="scene"]')?.textContent).toBe("aurora"); }); setScene("ember"); await vi.waitFor(() => { expect(host.querySelector('[data-testid="scene"]')?.textContent).toBe("ember"); }); dispose(); }); it("replaces a queued deck child across repeated wait-mode scene advances", async () => { let advanceScene!: () => void; function TestHost() { const [sceneIndex, setSceneIndex] = createSignal(0); const scenes = ["aurora", "ember", "tide"] as const; advanceScene = () => { setSceneIndex((current) => (current + 1) % scenes.length); }; return ( {scenes[sceneIndex()] === "aurora" ? ( aurora ) : scenes[sceneIndex()] === "ember" ? ( ember ) : ( tide )} ); } const host = document.createElement("div"); document.body.append(host); const dispose = render(() => , host); await vi.waitFor(() => { expect(host.querySelector('[data-testid="deck-scene"]')?.textContent).toBe("aurora"); }); advanceScene(); expect(host.querySelector('[data-testid="deck-scene"]')?.textContent).toBe("aurora"); await vi.waitFor(() => { expect(host.querySelector('[data-testid="deck-scene"]')?.textContent).toBe("ember"); }); advanceScene(); expect(host.querySelector('[data-testid="deck-scene"]')?.textContent).toBe("ember"); await vi.waitFor(() => { expect(host.querySelector('[data-testid="deck-scene"]')?.textContent).toBe("tide"); }); dispose(); }); it("keeps nested wait-mode stage changes working after the outer shell exits and remounts", async () => { let advanceStage!: () => void; function IntakeStage() { return ( intake ); } function VerifyStage() { return ( verify ); } function ReleaseStage() { return ( release ); } function IntakeBadge() { return
intake
; } function VerifyBadge() { return
verify
; } function ReleaseBadge() { return
release
; } function TestHost() { const [stageIndex, setStageIndex] = createSignal(0); const stages = ["intake", "verify", "release"] as const; const currentStage = createMemo(() => stages[stageIndex()] ?? stages[0]); advanceStage = () => setStageIndex((value) => (value + 1) % stages.length); return (
{currentStage() === "intake" ? : currentStage() === "verify" ? : } {currentStage() === "intake" ? : currentStage() === "verify" ? : }
); } const host = document.createElement("div"); document.body.append(host); const dispose = render(() => , host); await vi.waitFor(() => { expect(host.querySelector('[data-testid="inner-stage"]')?.textContent).toBe("intake"); }); await vi.waitFor(() => { expect(host.querySelector('[data-testid="stage-badge"]')?.textContent).toBe("intake"); }); advanceStage(); await vi.waitFor(() => { expect(host.querySelector('[data-testid="inner-stage"]')?.textContent).toBe("verify"); }); await vi.waitFor(() => { expect(host.querySelector('[data-testid="stage-badge"]')?.textContent).toBe("verify"); }); advanceStage(); await vi.waitFor(() => { expect(host.querySelector('[data-testid="inner-stage"]')?.textContent).toBe("release"); }); await vi.waitFor(() => { expect(host.querySelector('[data-testid="stage-badge"]')?.textContent).toBe("release"); }); dispose(); }); });