diff --git a/.changeset/wire-up-stagger.md b/.changeset/wire-up-stagger.md new file mode 100644 index 0000000..1798e54 --- /dev/null +++ b/.changeset/wire-up-stagger.md @@ -0,0 +1,10 @@ +--- +"@effex/core": patch +"@effex/dom": patch +--- + +Wire up `stagger` for `each`'s enter animations. `stagger` was declared on `ListAnimationOptions` and exported via helpers (`stagger`, `staggerFromCenter`) but nothing in the runtime consumed it, so `each({ animate: { stagger: stagger(40) } })` produced no visible staggering. The API had been advertising the feature without implementing it. + +Now `reconcile` captures a `staggerStartAt` timestamp when a batch begins and threads it plus `totalItems` through to each slot's animation. `forkSlotEnter` computes the target fire time as `startAt + stagger(index, total)` and delays only the residual after reconcile overhead — so item N always fires at the same wall-clock moment regardless of how long reconcile takes to iterate to slot N. Without the shared reference, per-slot delays would compound and each item would drift further behind its expected position. + +Applies to `each` (client, client-fallback-during-hydration, hydration-root, and intro re-animation paths). `when` / `match` / etc. don't take a stagger option — they render single elements. diff --git a/packages/core/src/Control.ts b/packages/core/src/Control.ts index ce5d95e..25d9313 100644 --- a/packages/core/src/Control.ts +++ b/packages/core/src/Control.ts @@ -1,4 +1,4 @@ -import { Effect, Either, Option } from "effect"; +import { Clock, Effect, Either, Option } from "effect"; import { ControlCtx } from "./ControlCtx.js"; import type { Element } from "./Element.js"; @@ -58,6 +58,12 @@ export const reconcile = ( const sync = (value: A) => Effect.gen(function* () { + // Reference point for stagger delays. All new slots in this batch + // compute their fire time as `batchStart + index * staggerStep`, so + // slot N's animation fires at the same moment regardless of how + // long reconcile takes to iterate to it. Sourced from Effect.Clock + // (not Date.now()) so tests can substitute a TestClock. + const batchStart = yield* Clock.currentTimeMillis; const currentKeys = yield* ctx.getSlotKeys(); const targetKeys = config.getTargetKeys(value); const targetSet = new Set(targetKeys); @@ -94,7 +100,13 @@ export const reconcile = ( key, ({ item, index }) => config.renderSlot(key, value, { item, index }), - { atIndex: i, initialItem: itemValue, initialIndex: i }, + { + atIndex: i, + initialItem: itemValue, + initialIndex: i, + totalItems: targetKeys.length, + staggerStartAt: batchStart, + }, ); } } diff --git a/packages/core/src/ControlCtx.ts b/packages/core/src/ControlCtx.ts index 3267885..dbfb1bb 100644 --- a/packages/core/src/ControlCtx.ts +++ b/packages/core/src/ControlCtx.ts @@ -81,6 +81,19 @@ export interface IControlCtx { atIndex?: number; initialItem?: unknown; initialIndex?: number; + /** + * Total number of slots in this reconcile batch, used by animation + * helpers to compute per-item stagger delay. Passed by `reconcile`; + * external callers can omit it. + */ + totalItems?: number; + /** + * `Date.now()` timestamp captured when the current reconcile batch + * started. Animation helpers use it as a shared reference so every + * slot's stagger delay is measured from the same wall-clock moment + * rather than accumulating reconcile overhead. Passed by `reconcile`. + */ + staggerStartAt?: number; }, ) => Effect.Effect, E, R>; diff --git a/packages/dom/src/Control/ClientControlCtx.ts b/packages/dom/src/Control/ClientControlCtx.ts index cc58507..fcb394a 100644 --- a/packages/dom/src/Control/ClientControlCtx.ts +++ b/packages/dom/src/Control/ClientControlCtx.ts @@ -70,6 +70,8 @@ const createClientControlCtx = (): IControlCtx => { atIndex?: number; initialItem?: unknown; initialIndex?: number; + totalItems?: number; + staggerStartAt?: number; }, ): Effect.Effect => Effect.gen(function* () { @@ -106,7 +108,11 @@ const createClientControlCtx = (): IControlCtx => { }; slots.set(key, entry); - yield* forkSlotEnter(element, slotScope); + yield* forkSlotEnter(element, slotScope, { + index: addOptions?.atIndex, + total: addOptions?.totalItems, + staggerStartAt: addOptions?.staggerStartAt, + }); return entry; }) as Effect.Effect, diff --git a/packages/dom/src/Control/Control.test.ts b/packages/dom/src/Control/Control.test.ts index 8435f3c..3f71876 100644 --- a/packages/dom/src/Control/Control.test.ts +++ b/packages/dom/src/Control/Control.test.ts @@ -617,4 +617,73 @@ describe("Control", () => { }).pipe(Effect.provide(TestLayer)), ); }); + + describe("each animation stagger", () => { + it.scopedLive( + "invokes the stagger function with each item's index and total", + () => + // Reconcile is expected to call stagger(index, total) for every new + // slot in the batch — so we spy on the function itself instead of + // measuring wall-clock delays (which are unreliable under CI load). + Effect.gen(function* () { + const calls: Array<{ index: number; total: number }> = []; + const spy = (index: number, total: number) => { + calls.push({ index, total }); + return 0; + }; + + const items = yield* Signal.make(["a", "b", "c"]); + yield* each(items, { + key: (item) => item, + render: (item) => $.li({}, $.of(item)), + animate: { + enterFrom: "opacity-0", + enter: "opacity-100", + stagger: spy, + timeout: 10, + }, + }); + + // Give the forked fibers a moment to reach the stagger call. + yield* Effect.sleep("20 millis"); + + expect(calls).toEqual([ + { index: 0, total: 3 }, + { index: 1, total: 3 }, + { index: 2, total: 3 }, + ]); + }).pipe(Effect.provide(TestLayer)), + ); + + it.scopedLive("does not invoke stagger when the option is omitted", () => + Effect.gen(function* () { + let callCount = 0; + const untriggered = (_index: number, _total: number) => { + callCount++; + return 0; + }; + + const items = yield* Signal.make(["a", "b"]); + yield* each(items, { + key: (item) => item, + render: (item) => $.li({}, $.of(item)), + animate: { + // Note: stagger is deliberately omitted here. `untriggered` is + // declared only so the assertion below can reference it and be + // read as "the spy that would have fired if stagger had been + // set". Tsc otherwise flags the arrow as unused. + enterFrom: "opacity-0", + enter: "opacity-100", + timeout: 10, + }, + }); + + yield* Effect.sleep("20 millis"); + expect(callCount).toBe(0); + // Reference `untriggered` to satisfy the linter that it exists as a + // documentation aid; behaviourally it must never have been called. + expect(untriggered.length).toBe(2); + }).pipe(Effect.provide(TestLayer)), + ); + }); }); diff --git a/packages/dom/src/Control/HydrationControlCtx.ts b/packages/dom/src/Control/HydrationControlCtx.ts index d9f30b9..c9e841f 100644 --- a/packages/dom/src/Control/HydrationControlCtx.ts +++ b/packages/dom/src/Control/HydrationControlCtx.ts @@ -95,6 +95,8 @@ const createClientLikeControlCtx = ( atIndex?: number; initialItem?: unknown; initialIndex?: number; + totalItems?: number; + staggerStartAt?: number; }, ): Effect.Effect => Effect.gen(function* () { @@ -149,7 +151,12 @@ const createClientLikeControlCtx = ( // Post-hydration → always. During hydration → forkSlotEnter checks // the intro flag and only runs for opted-in controls. - yield* forkSlotEnter(element, slotScope, { hydrating: !hydrationDone }); + yield* forkSlotEnter(element, slotScope, { + hydrating: !hydrationDone, + index: addOptions?.atIndex, + total: addOptions?.totalItems, + staggerStartAt: addOptions?.staggerStartAt, + }); return entry; }) as Effect.Effect, @@ -270,6 +277,8 @@ const createHydrationControlCtx = ( atIndex?: number; initialItem?: unknown; initialIndex?: number; + totalItems?: number; + staggerStartAt?: number; }, ): Effect.Effect => Effect.gen(function* () { @@ -298,6 +307,9 @@ const createHydrationControlCtx = ( // leave the rendered DOM as-is. yield* forkSlotEnter(existing.element, slotScope, { hydrating: true, + index: addOptions?.atIndex, + total: addOptions?.totalItems, + staggerStartAt: addOptions?.staggerStartAt, }); return entry; @@ -337,7 +349,11 @@ const createHydrationControlCtx = ( }; slots.set(key, entry); - yield* forkSlotEnter(element, slotScope); + yield* forkSlotEnter(element, slotScope, { + index: addOptions?.atIndex, + total: addOptions?.totalItems, + staggerStartAt: addOptions?.staggerStartAt, + }); return entry; }) as Effect.Effect, diff --git a/packages/dom/src/Control/slotAnimation.ts b/packages/dom/src/Control/slotAnimation.ts index 79da452..57d509f 100644 --- a/packages/dom/src/Control/slotAnimation.ts +++ b/packages/dom/src/Control/slotAnimation.ts @@ -17,7 +17,7 @@ * was in scope at Layer construction). */ -import { Effect, Exit, Option, Scope } from "effect"; +import { Clock, Effect, Exit, Option, Scope } from "effect"; import { _awaitGate, @@ -25,13 +25,28 @@ import { _register, type AnimationGroup, } from "../Animation/groups.js"; -import { runEnterAnimation, runExitAnimation } from "../Animation/index.js"; +import { + runEnterAnimation, + runExitAnimation, + type ListAnimationOptions, + type StaggerFunction, +} from "../Animation/index.js"; import { AnimationConfigCtx } from "./AnimationConfigCtx.js"; type DOMElement = HTMLElement | SVGElement; type AnimateWithGroup = T & { group?: AnimationGroup }; +const staggerDelayMs = ( + stagger: ListAnimationOptions["stagger"] | undefined, + index: number, + total: number, +): number => { + if (stagger === undefined) return 0; + if (typeof stagger === "number") return index * stagger; + return (stagger as StaggerFunction)(index, total); +}; + interface ResolvedAnimation { readonly animate: AnimateWithGroup; readonly intro: boolean; @@ -69,12 +84,25 @@ const readAnimation = (): Effect.Effect | undefined> => export const forkSlotEnter = ( element: DOMElement, slotScope: Scope.CloseableScope, - opts?: { readonly hydrating?: boolean }, + opts?: { + readonly hydrating?: boolean; + /** Position of this slot within its reconcile batch (0-based). */ + readonly index?: number; + /** Total number of slots in this reconcile batch. */ + readonly total?: number; + /** + * `Date.now()` timestamp captured when reconcile started iterating. + * Used to compute stagger delays relative to a shared reference so + * items fire at `startAt + delayMs` regardless of how long reconcile + * takes to reach each slot — otherwise reconcile overhead would + * compound and each staggered item would drift further behind. + */ + readonly staggerStartAt?: number; + }, ): Effect.Effect => { if (!(element instanceof HTMLElement)) return Effect.void; return Effect.gen(function* () { - const resolved = - yield* readAnimation[1]>(); + const resolved = yield* readAnimation(); if (!resolved) return; if (opts?.hydrating && !resolved.intro) return; @@ -84,13 +112,26 @@ export const forkSlotEnter = ( _register(grp); } + const targetDelay = + opts?.index !== undefined && opts?.total !== undefined + ? staggerDelayMs(animate.stagger, opts.index, opts.total) + : 0; + const now = yield* Clock.currentTimeMillis; + const actualDelay = + targetDelay > 0 && opts?.staggerStartAt !== undefined + ? Math.max(0, opts.staggerStartAt + targetDelay - now) + : targetDelay; + yield* Effect.gen(function* () { if (grp) { yield* _awaitGate(grp); } - yield* runEnterAnimation(Effect.succeed(element), animate).pipe( + const run = runEnterAnimation(Effect.succeed(element), animate).pipe( Effect.ensuring(grp ? _complete(grp) : Effect.void), ); + yield* actualDelay > 0 + ? run.pipe(Effect.delay(`${actualDelay} millis`)) + : run; }).pipe(Effect.forkIn(slotScope)); }).pipe(Effect.asVoid); }; diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts index 871093e..85673b1 100644 --- a/packages/dom/src/index.ts +++ b/packages/dom/src/index.ts @@ -71,15 +71,20 @@ export { collect } from "./Collect.js"; // Animation export type { AnimationEndResult, + AnimationGroup, AnimationHook, AnimationOptions, ListAnimationOptions, StaggerFunction, } from "./Animation/index.js"; export { + Animation, runEnterAnimation, runExitAnimation, prefersReducedMotion, + waitForAnimationEvent, + waitForAnimationEnd, + forceReflow, stagger, staggerFromCenter, staggerEased,