From bc61b9764f6eababf4a668c284b76dac4e3b3296 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Thu, 25 Jun 2026 11:52:06 -0400
Subject: [PATCH 01/13] New animation package
---
README.md | 3 +-
packages/animation/CHANGELOG.md | 9 +
packages/animation/LICENSE | 21 +
packages/animation/README.md | 299 ++++++++
packages/animation/dev/index.tsx | 102 +++
packages/animation/package.json | 77 ++
packages/animation/src/animate.ts | 41 +
packages/animation/src/animation-group.ts | 49 ++
packages/animation/src/flip.ts | 40 +
packages/animation/src/index.ts | 8 +
packages/animation/src/motion-path.ts | 61 ++
packages/animation/src/scroll-animation.ts | 56 ++
packages/animation/src/sequence.ts | 62 ++
packages/animation/src/stagger.ts | 43 ++
packages/animation/src/view-animation.ts | 79 ++
.../animation/stories/animation.stories.tsx | 709 ++++++++++++++++++
packages/animation/test/index.test.ts | 515 +++++++++++++
packages/animation/tsconfig.json | 16 +
pnpm-lock.yaml | 10 +
19 files changed, 2199 insertions(+), 1 deletion(-)
create mode 100644 packages/animation/CHANGELOG.md
create mode 100644 packages/animation/LICENSE
create mode 100644 packages/animation/README.md
create mode 100644 packages/animation/dev/index.tsx
create mode 100644 packages/animation/package.json
create mode 100644 packages/animation/src/animate.ts
create mode 100644 packages/animation/src/animation-group.ts
create mode 100644 packages/animation/src/flip.ts
create mode 100644 packages/animation/src/index.ts
create mode 100644 packages/animation/src/motion-path.ts
create mode 100644 packages/animation/src/scroll-animation.ts
create mode 100644 packages/animation/src/sequence.ts
create mode 100644 packages/animation/src/stagger.ts
create mode 100644 packages/animation/src/view-animation.ts
create mode 100644 packages/animation/stories/animation.stories.tsx
create mode 100644 packages/animation/test/index.test.ts
create mode 100644 packages/animation/tsconfig.json
diff --git a/README.md b/README.md
index bf1aad04a..b873c5015 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[](https://pnpm.io/)
[](https://vitest.dev)
-[](https://dash.deno.com/playground/combined-npm-downloads)
+[](https://dash.deno.com/playground/combined-npm-downloads)
Solid Primitives is a project dedicated to building high-quality, community-contributed primitives for SolidJS. Every utility is thoroughly tested, continuously maintained, and reviewed against a consistent quality bar before it lands in the repository. Our aim is to extend Solid's primary and secondary primitives with a well-rounded set of tertiary primitives.
@@ -139,6 +139,7 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n
|[orientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeOrientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#makeorientation) [createOrientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#createorientation)|[](https://bundlephobia.com/package/@solid-primitives/orientation)|[](https://www.npmjs.com/package/@solid-primitives/orientation)|✓|
|[vibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[isVibrationSupported](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#isvibrationsupported) [makeVibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#makevibrate) [createVibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#createvibrate) [frequencyToPattern](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#frequencytopattern) [makePulse](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#makepulse) [createPulse](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#createpulse)|[](https://bundlephobia.com/package/@solid-primitives/vibrate)|[](https://www.npmjs.com/package/@solid-primitives/vibrate)|✓|
|
*Animation*
|
+|[animation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createAnimate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createanimate) [createScrollAnimation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createscrollanimation) [createViewAnimation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createviewanimation) [createFlip](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createflip) [createStagger](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createstagger) [createAnimationGroup](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createanimationgroup)|[](https://bundlephobia.com/package/@solid-primitives/animation)|[](https://www.npmjs.com/package/@solid-primitives/animation)||
|[presence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createPresence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#createpresence)|[](https://bundlephobia.com/package/@solid-primitives/presence)|[](https://www.npmjs.com/package/@solid-primitives/presence)|✓|
|[raf](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createRAF](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createraf) [createMs](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createms) [targetFPS](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#targetfps)|[](https://bundlephobia.com/package/@solid-primitives/raf)|[](https://www.npmjs.com/package/@solid-primitives/raf)|✓|
|[spring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createSpring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#createspring) [createDerivedSpring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#createderivedspring)|[](https://bundlephobia.com/package/@solid-primitives/spring)|[](https://www.npmjs.com/package/@solid-primitives/spring)|✓|
diff --git a/packages/animation/CHANGELOG.md b/packages/animation/CHANGELOG.md
new file mode 100644
index 000000000..efd2133c0
--- /dev/null
+++ b/packages/animation/CHANGELOG.md
@@ -0,0 +1,9 @@
+# @solid-primitives/animation
+
+## 0.0.1
+
+### Minor Changes
+
+- Initial release. WAAPI-based animation primitives for SolidJS: `makeAnimate`, `createAnimate`,
+ `makeScrollAnimation`, `createScrollAnimation`, `makeViewAnimation`, `createViewAnimation`,
+ `makeFlip`, `makeStagger`, `createStagger`, `makeAnimationGroup`.
diff --git a/packages/animation/LICENSE b/packages/animation/LICENSE
new file mode 100644
index 000000000..38b41d975
--- /dev/null
+++ b/packages/animation/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/animation/README.md b/packages/animation/README.md
new file mode 100644
index 000000000..f868f1e35
--- /dev/null
+++ b/packages/animation/README.md
@@ -0,0 +1,299 @@
+
+
+
+
+# @solid-primitives/animation
+
+[](https://www.npmjs.com/package/@solid-primitives/animation)
+[](https://github.com/solidjs-community/solid-primitives#contribution-process)
+[](https://vitest.dev)
+
+Solid primitives for the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) (WAAPI). Each primitive follows the `make` / `create`.
+## Installation
+
+```bash
+npm install @solid-primitives/animation
+# or
+pnpm add @solid-primitives/animation
+```
+
+## Primitives
+
+| Primitive | Description |
+|---|---|
+| [`makeAnimate`](#makeanimate--createanimate) | Imperative `element.animate()` wrapper |
+| [`createAnimate`](#makeanimate--createanimate) | Reactive `makeAnimate` |
+| [`makeScrollAnimation`](#makescrollanimation--createscrollanimation) | Scroll-driven animation via `ScrollTimeline` |
+| [`createScrollAnimation`](#makescrollanimation--createscrollanimation) | Reactive `makeScrollAnimation` |
+| [`makeViewAnimation`](#makeviewanimation--createviewanimation) | Viewport-driven animation via `ViewTimeline` |
+| [`createViewAnimation`](#makeviewanimation--createviewanimation) | Reactive `makeViewAnimation` |
+| [`makeFlip`](#makeflip) | FLIP layout animation |
+| [`makeStagger`](#makestagger--createstagger) | Staggered animations across a list of elements |
+| [`createStagger`](#makestagger--createstagger) | Reactive `makeStagger` |
+| [`makeAnimationGroup`](#makeanimationgroup--createanimationgroup) | Coordinate multiple animations as a unit |
+| [`createAnimationGroup`](#makeanimationgroup--createanimationgroup) | Reactive `makeAnimationGroup` |
+
+---
+
+## `makeAnimate` / `createAnimate`
+
+`makeAnimate` is a thin wrapper around `element.animate()` with TypeScript types. `createAnimate` replays the animation whenever `target`, `keyframes`, or `options` change reactively, and cancels it when the owner disposes.
+
+```ts
+// Imperative
+const anim = makeAnimate(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 300 });
+anim.pause();
+
+// Reactive
+const anim = createAnimate(
+ () => ref,
+ [{ opacity: 0 }, { opacity: 1 }],
+ { duration: 300, fill: "forwards" },
+);
+// anim() is the current Animation instance, or undefined while ref is unset
+anim()?.pause();
+```
+
+```ts
+function makeAnimate(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: KeyframeAnimationOptions,
+): Animation
+
+function createAnimate(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeScrollAnimation` / `createScrollAnimation`
+
+Plays a WAAPI animation whose progress is driven by scroll position via [`ScrollTimeline`](https://developer.mozilla.org/en-US/docs/Web/API/ScrollTimeline). No scroll listeners or RAF loops needed.
+
+```ts
+// Fade + rise as the user scrolls down the page
+const anim = createScrollAnimation(
+ () => ref,
+ [{ opacity: 0, transform: "translateY(20px)" }, { opacity: 1, transform: "none" }],
+ { fill: "both" },
+);
+
+// Tie progress to a specific scroll container
+const anim = createScrollAnimation(() => ref, keyframes, {
+ fill: "both",
+ source: scrollContainerEl,
+ axis: "block",
+});
+```
+
+```ts
+type ScrollAnimationOptions = Omit & {
+ source?: Element; // scroll container — defaults to document root scroller
+ axis?: "block" | "inline" | "x" | "y";
+};
+
+function makeScrollAnimation(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: ScrollAnimationOptions,
+): Animation
+
+function createScrollAnimation(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeViewAnimation` / `createViewAnimation`
+
+Plays a WAAPI animation whose progress is driven by an element's intersection with the scroll port via [`ViewTimeline`](https://developer.mozilla.org/en-US/docs/Web/API/ViewTimeline). Replaces the IntersectionObserver + class-toggle pattern.
+
+```ts
+// Animate the element itself as it enters the viewport
+const anim = createViewAnimation(
+ () => ref,
+ [{ opacity: 0, transform: "translateY(16px)" }, { opacity: 1, transform: "none" }],
+ { fill: "both" },
+);
+
+// Observe a different element than the one being animated
+const anim = createViewAnimation(() => animatedEl, keyframes, {
+ fill: "both",
+ subject: triggerEl,
+ inset: "0px 0px -100px 0px",
+});
+```
+
+```ts
+type ViewAnimationOptions = Omit & {
+ subject?: Element; // element to observe — defaults to target
+ axis?: "block" | "inline" | "x" | "y";
+ inset?: string | string[]; // shrinks/expands the intersection root
+};
+
+function makeViewAnimation(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: ViewAnimationOptions,
+): Animation
+
+function createViewAnimation(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeFlip`
+
+FLIP (First–Last–Invert–Play) layout animation. Call `snapshot()` before the DOM change to record the element's current geometry, then call `flip()` after to animate from the old position/size to the new one.
+
+```tsx
+let el!: HTMLUListElement;
+const { snapshot, flip } = makeFlip(el, { duration: 300, easing: "ease" });
+
+const handleReorder = () => {
+ snapshot();
+ setItems(prev => [...prev].reverse()); // DOM updates synchronously
+ flip();
+};
+
+return
...
;
+```
+
+`flip()` is a no-op if `snapshot()` was never called or if the geometry didn't change. It resets the captured rect after each call, so a second `flip()` without a new `snapshot()` is always a no-op.
+
+> **Note:** geometry is measured via `getBoundingClientRect` (viewport coordinates). Elements inside `position: fixed` or `position: absolute` ancestors may need coordinate adjustment.
+
+```ts
+function makeFlip(
+ el: Element,
+ options?: KeyframeAnimationOptions,
+): { snapshot: () => void; flip: () => Animation | undefined }
+```
+
+---
+
+## `makeStagger` / `createStagger`
+
+Applies a WAAPI animation to a list of elements with a per-element delay offset. The `stagger` option is added on top of the base `delay`.
+
+```ts
+// Imperative — animate a static list of elements
+makeStagger(listItems, [{ opacity: 0 }, { opacity: 1 }], {
+ duration: 400,
+ stagger: 60,
+});
+
+// Reactive — re-runs (cancelling previous animations) when the target list changes
+const itemRefs: HTMLLIElement[] = [];
+
+const anims = createStagger(
+ () => itemRefs,
+ [{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }],
+ { duration: 400, stagger: 60, easing: "ease-out" },
+);
+```
+
+```ts
+type StaggerOptions = KeyframeAnimationOptions & {
+ stagger?: number; // ms added per element on top of `delay`
+};
+
+function makeStagger(
+ els: Element[],
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: StaggerOptions,
+): Animation[]
+
+function createStagger(
+ targets: Accessor<(Element | null | undefined)[]>,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeAnimationGroup` / `createAnimationGroup`
+
+Coordinates a list of `Animation` objects as a single unit. All five control methods are forwarded to every non-null animation simultaneously. Pairs naturally with `makeAnimate` and `makeStagger`.
+
+`makeAnimationGroup` takes a static array. `createAnimationGroup` takes an accessor and re-derives the group whenever the list changes — each control method always operates on the most recent set of animations.
+
+```ts
+// Imperative — static list
+const header = makeAnimate(headerEl, fadeIn, { duration: 300 });
+const body = makeAnimate(bodyEl, fadeIn, { duration: 300, delay: 100 });
+const footer = makeAnimate(footerEl, fadeIn, { duration: 300, delay: 200 });
+
+const group = makeAnimationGroup([header, body, footer]);
+
+group.pause();
+group.play();
+group.cancel();
+```
+
+```tsx
+// Reactive — list changes when items() changes
+const itemRefs: HTMLLIElement[] = [];
+const [items, setItems] = createSignal(data);
+
+const anims = createStagger(
+ () => itemRefs,
+ [{ opacity: 0 }, { opacity: 1 }],
+ { duration: 300, stagger: 40 },
+);
+
+// group.play() / pause() always targets the animations from the latest render
+const group = createAnimationGroup(anims);
+
+return (
+
+
+
+ );
+};
+
+export const PresenceBStory = meta.story({
+ name: "createPresenceB — deferred disposal via async gate",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`createPresenceB` uses Solid 2.0's built-in deferred-disposal mechanism. When `show` goes " +
+ "false, the `gate` memo returns a `Promise` instead of `false`. Returning a Promise causes " +
+ "`handleAsync` to throw `NotReadyError`, putting `gate` into `STATUS_PENDING`. " +
+ "`notifyStatus` propagates to Show's internal `createRenderEffect` via `queuePendingNode` " +
+ "(not the dirty heap), so Show never recomputes and the component owner is **not disposed**. " +
+ "When the WAAPI animation finishes the Promise resolves, `asyncWrite` makes Show recompute, " +
+ "and the component is finally removed. Each slide below has its own `count` signal — " +
+ "increment it, switch slides, and observe the old value during the 1.4 s exit.",
+ },
+ },
+ },
+ render: () => {
+ const [active, setActive] = createSignal(SLIDES[0]);
+
+ return (
+
+
createPresenceB
+
+ Increment a counter, then switch slides. The old slide's count persists during its 1.4 s
+ exit — the component owner is deferred via an async gate memo.
+
+
+ {/* Stacked so entering and exiting slides overlap during crossfade */}
+
+ {slide => }
+
+
+
+ active: {active().label}
+
+
+ );
+ },
+});
From c28e5cdc2c5fab809cfa314bd10db768c748ce3b Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:18:27 -0400
Subject: [PATCH 10/13] Added a stress test and adjusted exitComplete logic
---
packages/animation/src/presence-animation.ts | 33 ++-
.../animation/stories/animation.stories.tsx | 271 +++++++++++++++++-
2 files changed, 302 insertions(+), 2 deletions(-)
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index db39d2556..39ebe1265 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -283,6 +283,11 @@ export function createPresenceB(
let exitAnim: Animation | undefined;
let animPromise: Promise | undefined;
let enterGen = 0;
+ let exitGen = 0;
+ // exitCompleted prevents the gate from re-creating the exit Promise after the
+ // animation finishes. Without it, asyncWrite triggers a gate recompute where
+ // animPromise is already undefined → new Promise created → infinite exit loop.
+ let exitCompleted = false;
// Track whether this instance has ever been shown. Before the first mount,
// the gate should return plain `false` rather than a Promise — returning a
// Promise on a never-mounted slide would trigger handleAsync/initTransition
@@ -300,11 +305,21 @@ export function createPresenceB(
if (shouldShow) {
hasBeenShown = true;
+ exitCompleted = false;
+ // Increment exitGen to cancel any pending exit microtask that hasn't
+ // fired yet. Without this, the microtask starts the exit animation even
+ // though show is already true again — causing both exit and enter
+ // animations to compete on the same element.
+ exitGen++;
if (exitAnim) {
exitAnim.cancel();
exitAnim = undefined;
- animPromise = undefined;
}
+ // Always clear animPromise, not just when exitAnim was set. If the exit
+ // microtask hasn't run yet exitAnim is undefined, but animPromise holds
+ // the stale Promise. Clearing it here prevents asyncWrite from running
+ // after the stale microtask calls resolve().
+ animPromise = undefined;
return true;
}
@@ -312,14 +327,29 @@ export function createPresenceB(
// animation is needed and no Promise should be created.
if (!hasBeenShown) return false;
+ // After a completed exit, return false cleanly so Show can dispose the
+ // component. Without this guard the gate would create a new Promise every
+ // time asyncWrite re-triggers it, looping forever.
+ if (exitCompleted) return false;
+
// Create the exit Promise once. Returning the same Promise object on
// subsequent recomputes is safe: handleAsync guards stale asyncWrite
// callbacks via `el._inFlight !== result`.
if (!animPromise) {
+ // Cancel any pending enter microtask so enter and exit don't race.
+ enterGen++;
+ const gen = ++exitGen;
animPromise = new Promise(resolve => {
queueMicrotask(() => {
+ // If show flipped back to true before this microtask ran, exitGen
+ // was already incremented in the shouldShow=true branch above.
+ if (gen !== exitGen) {
+ resolve(); // stale — asyncWrite is a no-op (guarded by _inFlight)
+ return;
+ }
const el = untrack(target);
if (!el) {
+ exitCompleted = true;
animPromise = undefined;
setIsExiting(false);
resolve();
@@ -333,6 +363,7 @@ export function createPresenceB(
() => {
exitAnim = undefined;
animPromise = undefined;
+ exitCompleted = true;
setIsExiting(false);
resolve(); // → asyncWrite → gate._value = undefined → Show removes component
},
diff --git a/packages/animation/stories/animation.stories.tsx b/packages/animation/stories/animation.stories.tsx
index aec6c2949..074d5ab57 100644
--- a/packages/animation/stories/animation.stories.tsx
+++ b/packages/animation/stories/animation.stories.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createSignal, For, onSettled, Show } from "solid-js";
+import { createEffect, createSignal, For, onCleanup, onSettled, Show } from "solid-js";
import preview from "../../../.storybook/preview.js";
import {
createAnimate,
@@ -1547,3 +1547,272 @@ export const PresenceBStory = meta.story({
);
},
});
+
+// ─── createPresenceB diagnostic / stress-test ────────────────────────────────
+
+// Card defined at module level for a stable reactive scope (same pattern as SlideCardB).
+// Props receive gate/isExiting from the parent so the parent can read them for diagnostics.
+const DiagCard = (props: {
+ ref: (el: HTMLDivElement) => void;
+ label: string;
+ color: string;
+ isExiting: () => boolean;
+}) => {
+ const [count, setCount] = createSignal(0);
+ const c = props.color;
+
+ return (
+
+ Expected: at any moment exactly one slide shows{" "}
+ active or{" "}
+ exiting. If both become{" "}
+ – simultaneously, or a slide stays{" "}
+ exiting after it should be active again,
+ the animation has glitched.
+
+
+ );
+ },
+});
From 97f1e4c9fac1a5e23490f2576b79bf24739556ff Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Fri, 26 Jun 2026 12:12:55 -0400
Subject: [PATCH 11/13] Slight adjustments to entering logic
---
packages/animation/src/presence-animation.ts | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index 39ebe1265..b8b842693 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -281,6 +281,7 @@ export function createPresenceB(
const [isExiting, setIsExiting] = createSignal(false, INTERNAL_OPTIONS);
let exitAnim: Animation | undefined;
+ let enterAnim: Animation | undefined;
let animPromise: Promise | undefined;
let enterGen = 0;
let exitGen = 0;
@@ -336,8 +337,14 @@ export function createPresenceB(
// subsequent recomputes is safe: handleAsync guards stale asyncWrite
// callbacks via `el._inFlight !== result`.
if (!animPromise) {
- // Cancel any pending enter microtask so enter and exit don't race.
+ // Cancel any pending enter microtask (gen check) AND any enter animation
+ // that already started. Without the latter, a running enter animation
+ // competes with the new exit animation on the same element.
enterGen++;
+ if (enterAnim) {
+ enterAnim.cancel();
+ enterAnim = undefined;
+ }
const gen = ++exitGen;
animPromise = new Promise(resolve => {
queueMicrotask(() => {
@@ -392,7 +399,8 @@ export function createPresenceB(
if (gen !== enterGen) return;
const el = untrack(target);
if (!el) return;
- el.animate(options.enter, options.enterOptions);
+ enterAnim = el.animate(options.enter, options.enterOptions);
+ enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
});
},
);
@@ -403,7 +411,8 @@ export function createPresenceB(
if (gen !== enterGen) return;
const el = untrack(target);
if (!el) return;
- el.animate(options.enter, options.enterOptions);
+ enterAnim = el.animate(options.enter, options.enterOptions);
+ enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
});
}
From a2dd29539114e38d03c2069af9d1e4b1c93efc6a Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Fri, 26 Jun 2026 15:06:05 -0400
Subject: [PATCH 12/13] Stablizies it a bit better
---
packages/animation/src/presence-animation.ts | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index b8b842693..03d325c51 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -383,16 +383,15 @@ export function createPresenceB(
return animPromise;
}) as unknown as Accessor;
- // Read gate() — the async signal — inside a createRenderEffect.
- // When gate is STATUS_PENDING (exit animation running), this render effect
- // is also placed into _pendingNodes via queuePendingNode (apply does not
- // run while pending). When gate resolves to true (show becomes true),
- // apply fires and starts the enter animation.
- createRenderEffect(
- () => gate(),
- (gateValue: unknown, prev: unknown) => {
- if (prev === undefined) return; // skip initial mount
- if (gateValue !== true) return; // pending or false — exit handled by gate memo
+ // Track getShow() directly (a plain boolean, never async) rather than gate()
+ // (the async memo). Tracking gate() puts this effect into _pendingNodes while
+ // the exit Promise is live; if show flips back to true before the Promise
+ // resolves, Solid may not re-queue the stuck effect, so setIsExiting(false)
+ // and the enter animation would never fire. getShow() always triggers reliably.
+ createEffect(
+ () => getShow(),
+ (shouldShow) => {
+ if (!shouldShow) return;
setIsExiting(false);
const gen = ++enterGen;
queueMicrotask(() => {
@@ -403,6 +402,7 @@ export function createPresenceB(
enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
});
},
+ { defer: true },
);
if (options.initialEnter && untrack(getShow)) {
From ccbbc7e591c48fec510d1c92a9bcaad09dda9b30 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Fri, 26 Jun 2026 16:23:29 -0400
Subject: [PATCH 13/13] Additional tests
---
packages/animation/src/presence-animation.ts | 121 +++++-
.../animation/stories/animation.stories.tsx | 353 ++++++++++++++++++
2 files changed, 456 insertions(+), 18 deletions(-)
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index 03d325c51..c7a8fb614 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -4,6 +4,7 @@ import {
createRenderEffect,
createMemo,
untrack,
+ isPending,
type Accessor,
} from "solid-js";
import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
@@ -103,7 +104,7 @@ export function createPresenceAnimation(
createEffect(
() => getShow(),
- (shouldShow) => {
+ shouldShow => {
if (shouldShow) {
setIsMounted(true);
scheduleEnter();
@@ -117,7 +118,14 @@ export function createPresenceAnimation(
const exitKf = options.exit ?? reverseKeyframes(options.enter);
const anim = el.animate(exitKf, options.exitOptions ?? options.enterOptions);
let done = false;
- anim.addEventListener("finish", () => { done = true; setIsMounted(false); }, { once: true });
+ anim.addEventListener(
+ "finish",
+ () => {
+ done = true;
+ setIsMounted(false);
+ },
+ { once: true },
+ );
return () => {
if (!done) anim.cancel();
};
@@ -278,7 +286,12 @@ export function createPresenceB(
options: PresenceAnimationOptions,
): { gate: Accessor; isExiting: Accessor } {
const getShow = asAccessor(show);
- const [isExiting, setIsExiting] = createSignal(false, INTERNAL_OPTIONS);
+ // exitAnimRunning tracks whether the WAAPI exit animation is physically playing.
+ // isExiting is derived: it's automatically false whenever getShow() is true,
+ // so no effect is needed to reset it when show flips back — the memo re-derives
+ // the correct value synchronously in the same flush as the signal write.
+ const [exitAnimRunning, setExitAnimRunning] = createSignal(false, INTERNAL_OPTIONS);
+ const isExiting = createMemo(() => !getShow() && exitAnimRunning());
let exitAnim: Animation | undefined;
let enterAnim: Animation | undefined;
@@ -358,24 +371,37 @@ export function createPresenceB(
if (!el) {
exitCompleted = true;
animPromise = undefined;
- setIsExiting(false);
+ setExitAnimRunning(false);
resolve();
return;
}
- setIsExiting(true);
+ setExitAnimRunning(true);
const exitKf = options.exit ?? reverseKeyframes(options.enter);
- exitAnim = el.animate(exitKf, options.exitOptions ?? options.enterOptions);
- exitAnim.addEventListener(
+ const anim = el.animate(exitKf, options.exitOptions ?? options.enterOptions);
+ exitAnim = anim;
+ anim.addEventListener(
"finish",
() => {
- exitAnim = undefined;
+ if (exitAnim === anim) exitAnim = undefined;
animPromise = undefined;
exitCompleted = true;
- setIsExiting(false);
+ setExitAnimRunning(false);
resolve(); // → asyncWrite → gate._value = undefined → Show removes component
},
{ once: true },
);
+ // When the exit animation is cancelled (show flipped true mid-exit), the
+ // finish event never fires. Listen to cancel to reset exitAnimRunning so
+ // the signal matches reality. Guard against the case where a newer exit
+ // animation is already running when this event fires.
+ anim.addEventListener(
+ "cancel",
+ () => {
+ if (exitAnim === anim) exitAnim = undefined;
+ setExitAnimRunning(false);
+ },
+ { once: true },
+ );
});
});
}
@@ -383,23 +409,25 @@ export function createPresenceB(
return animPromise;
}) as unknown as Accessor;
- // Track getShow() directly (a plain boolean, never async) rather than gate()
- // (the async memo). Tracking gate() puts this effect into _pendingNodes while
- // the exit Promise is live; if show flips back to true before the Promise
- // resolves, Solid may not re-queue the stuck effect, so setIsExiting(false)
- // and the enter animation would never fire. getShow() always triggers reliably.
+ // isExiting is derived (memo), so it resets automatically when getShow() goes
+ // true — no manual reset needed. This effect only handles the enter animation.
createEffect(
() => getShow(),
- (shouldShow) => {
+ shouldShow => {
if (!shouldShow) return;
- setIsExiting(false);
const gen = ++enterGen;
queueMicrotask(() => {
if (gen !== enterGen) return;
const el = untrack(target);
if (!el) return;
enterAnim = el.animate(options.enter, options.enterOptions);
- enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
+ enterAnim.addEventListener(
+ "finish",
+ () => {
+ enterAnim = undefined;
+ },
+ { once: true },
+ );
});
},
{ defer: true },
@@ -412,9 +440,66 @@ export function createPresenceB(
const el = untrack(target);
if (!el) return;
enterAnim = el.animate(options.enter, options.enterOptions);
- enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
+ enterAnim.addEventListener(
+ "finish",
+ () => {
+ enterAnim = undefined;
+ },
+ { once: true },
+ );
});
}
return { gate, isExiting };
}
+
+export function createPresenceC(
+ target: Accessor,
+ show: Accessor,
+ options: any,
+) {
+ let animationP: Promise | undefined = undefined;
+
+ const isMounted = createMemo(async () => {
+ if (show()) {
+ return true;
+ } else {
+ if (!animationP) return false;
+ await animationP;
+ return false;
+ }
+ });
+
+ const isEntered = createMemo(async () => {
+ if (show()) {
+ if (!animationP) return true;
+ await animationP;
+ return true;
+ } else {
+ return false;
+ }
+ });
+
+ const isEntering = () => isPending(isEntered);
+ const isExiting = () => isPending(isMounted);
+
+ createEffect(show, show => {
+ const anim = show
+ ? target().animate(options.enter, options.enterOptions)
+ : target().animate(options.exit, options.exitOptions ?? options.enterOptions);
+
+ animationP = new Promise(resolve => {
+ anim.addEventListener(
+ "finish",
+ () => {
+ animationP = undefined;
+ resolve();
+ },
+ { once: true },
+ );
+ });
+ return () => animationP && anim.cancel();
+ });
+
+ return { isMounted, isEntered, isEntering, isExiting };
+}
diff --git a/packages/animation/stories/animation.stories.tsx b/packages/animation/stories/animation.stories.tsx
index 074d5ab57..71e6faafa 100644
--- a/packages/animation/stories/animation.stories.tsx
+++ b/packages/animation/stories/animation.stories.tsx
@@ -13,6 +13,7 @@ import {
createPresenceAnimation,
createPresenceA,
createPresenceB,
+ createPresenceC,
} from "@solid-primitives/animation";
import readme from "../README.md?raw";
import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js";
@@ -1816,3 +1817,355 @@ export const PresenceBDiagnostic = meta.story({
);
},
});
+
+// ─── createPresenceC diagnostic ──────────────────────────────────────────────
+
+export const PresenceCDiagnostic = meta.story({
+ name: "createPresenceC — async-memo diagnostic",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Diagnostic harness for `createPresenceC`. Shows all four returned values " +
+ "(`isMounted`, `isEntered`, `isEntering`, `isExiting`) live as you toggle or stress-test. " +
+ "**Note**: `target()` is called unconditionally in the effect so `show` must start `true` " +
+ "or the initial effect apply throws on a null element — this story starts visible. " +
+ "The key question: does `isMounted` stay `true` for the full 1 s exit animation, " +
+ "and do `isExiting`/`isEntering` correctly reflect animation state?",
+ },
+ },
+ },
+ render: () => {
+ const [show, setShow] = createSignal(true);
+ const [stressRunning, setStressRunning] = createSignal(false);
+
+ let el: HTMLDivElement | undefined;
+
+ const C_OPTS = {
+ enter: [
+ { opacity: 0, transform: "translateX(20px)" },
+ { opacity: 1, transform: "none" },
+ ],
+ exit: [
+ { opacity: 1, transform: "none" },
+ { opacity: 0, transform: "translateX(-20px)" },
+ ],
+ enterOptions: { duration: 400, easing: "ease-out", fill: "both" as FillMode },
+ // Long exit so timing issues are clearly visible.
+ exitOptions: { duration: 1000, easing: "ease-in", fill: "forwards" as FillMode },
+ };
+
+ // createPresenceC: show must start true so target() is non-null when the
+ // non-deferred effect apply fires on initial mount.
+ const { isMounted, isEntered, isEntering, isExiting } = createPresenceC(
+ () => el!,
+ show,
+ C_OPTS,
+ );
+
+ let stressTimer: ReturnType | undefined;
+
+ const runStress = () => {
+ clearInterval(stressTimer);
+ setStressRunning(true);
+ let ticks = 0;
+ stressTimer = setInterval(() => {
+ setShow(v => !v);
+ ticks++;
+ if (ticks >= 20) {
+ clearInterval(stressTimer);
+ stressTimer = undefined;
+ setShow(true);
+ setStressRunning(false);
+ }
+ }, 80);
+ };
+
+ onCleanup(() => clearInterval(stressTimer));
+
+ const Flag = (props: { label: string; value: () => boolean }) => (
+
+ Expected: isMounted stays true{" "}
+ for the full 1 s exit. isExiting ={" "}
+ true during exit,{" "}
+ isEntering = true{" "}
+ during enter. If isMounted flips to{" "}
+ false instantly on hide, the async-memo
+ timing is off — animationP isn't reactive so the memo reads it before the
+ effect sets it.
+
+
+ );
+ },
+});
+
+// ─── createPresenceC gate test ────────────────────────────────────────────────
+
+export const PresenceCGateTest = meta.story({
+ name: "createPresenceC — deferred disposal gate test",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Tests whether `isMounted` acts as a true deferred-disposal gate. " +
+ "The inner component holds a counter (reactive signal) and a live tick timer. " +
+ "Gate `` on `isMounted()` — if deferred disposal works, both the counter " +
+ "and the tick keep running during the 1.2 s exit animation. " +
+ "If the component is disposed immediately when `show` flips to `false`, " +
+ "the counter and tick disappear instantly — the gate mechanism is broken.",
+ },
+ },
+ },
+ render: () => {
+ const [show, setShow] = createSignal(true);
+
+ let el: HTMLDivElement | undefined;
+
+ const GATE_OPTS = {
+ enter: [{ opacity: 0, transform: "scale(0.92)" }, { opacity: 1, transform: "none" }],
+ exit: [{ opacity: 1, transform: "none" }, { opacity: 0, transform: "scale(0.92)" }],
+ enterOptions: { duration: 350, easing: "ease-out", fill: "both" as FillMode },
+ exitOptions: { duration: 1200, easing: "ease-in", fill: "forwards" as FillMode },
+ };
+
+ const { isMounted, isEntering, isExiting } = createPresenceC(
+ () => el!,
+ show,
+ GATE_OPTS,
+ );
+
+ // Inner component: has its own reactive state to prove it survives during exit.
+ const Inner = () => {
+ const [count, setCount] = createSignal(0);
+ const [ticks, setTicks] = createSignal(0);
+
+ // Tick every 200 ms — proves the reactive scope is still alive.
+ const timer = setInterval(() => setTicks(t => t + 1), 200);
+ onCleanup(() => clearInterval(timer));
+
+ const c = "#6366f1";
+ return (
+
+ Click Hide then immediately click the counter buttons. If the counter
+ responds for ~1.2 s before disappearing, deferred disposal works. If it vanishes
+ instantly, isMounted returned false synchronously instead of
+ staying pending.
+