/** @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();
});
});