Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/wire-up-stagger.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 14 additions & 2 deletions packages/core/src/Control.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -58,6 +58,12 @@ export const reconcile = <A, E, R>(

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);
Expand Down Expand Up @@ -94,7 +100,13 @@ export const reconcile = <A, E, R>(
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,
},
);
}
}
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/ControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ export interface IControlCtx<A> {
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<SlotEntry<A>, E, R>;

Expand Down
8 changes: 7 additions & 1 deletion packages/dom/src/Control/ClientControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ const createClientControlCtx = (): IControlCtx<DOMElement> => {
atIndex?: number;
initialItem?: unknown;
initialIndex?: number;
totalItems?: number;
staggerStartAt?: number;
},
): Effect.Effect<DOMSlotEntry, E, R> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -106,7 +108,11 @@ const createClientControlCtx = (): IControlCtx<DOMElement> => {
};
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<DOMSlotEntry, E, R>,
Expand Down
69 changes: 69 additions & 0 deletions packages/dom/src/Control/Control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);
});
});
20 changes: 18 additions & 2 deletions packages/dom/src/Control/HydrationControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ const createClientLikeControlCtx = (
atIndex?: number;
initialItem?: unknown;
initialIndex?: number;
totalItems?: number;
staggerStartAt?: number;
},
): Effect.Effect<DOMSlotEntry, E, R> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -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<DOMSlotEntry, E, R>,
Expand Down Expand Up @@ -270,6 +277,8 @@ const createHydrationControlCtx = (
atIndex?: number;
initialItem?: unknown;
initialIndex?: number;
totalItems?: number;
staggerStartAt?: number;
},
): Effect.Effect<DOMSlotEntry, E, R> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<DOMSlotEntry, E, R>,
Expand Down
53 changes: 47 additions & 6 deletions packages/dom/src/Control/slotAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,36 @@
* was in scope at Layer construction).
*/

import { Effect, Exit, Option, Scope } from "effect";
import { Clock, Effect, Exit, Option, Scope } from "effect";

import {
_awaitGate,
_complete,
_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> = 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<T> {
readonly animate: AnimateWithGroup<T>;
readonly intro: boolean;
Expand Down Expand Up @@ -69,12 +84,25 @@ const readAnimation = <T>(): Effect.Effect<ResolvedAnimation<T> | 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<void> => {
if (!(element instanceof HTMLElement)) return Effect.void;
return Effect.gen(function* () {
const resolved =
yield* readAnimation<Parameters<typeof runEnterAnimation>[1]>();
const resolved = yield* readAnimation<ListAnimationOptions>();
if (!resolved) return;
if (opts?.hydrating && !resolved.intro) return;

Expand All @@ -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);
};
Expand Down
5 changes: 5 additions & 0 deletions packages/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading