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
43 changes: 43 additions & 0 deletions .changeset/animation-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@effex/dom": minor
---

Add animation groups — declarative sequencing across multiple animated blocks. Solves the "chained word-by-word intro" case where each word is its own `each` and word N should only start after word N-1 finishes.

```ts
import { $, collect, each, stagger, Animation } from "@effex/dom";

const App = () =>
Effect.gen(function* () {
const [greeting, name, tagline] = yield* Animation.sequence(3);
return $.div(
{},
collect(
each(greetingLetters, {
key: (l) => l.id,
render: (l) => $.span({}, $.of(l.char)),
animate: { enter: "letter-in", stagger: stagger(40), group: greeting },
}),
each(nameLetters, {
key: (l) => l.id,
render: (l) => $.span({}, $.of(l.char)),
animate: { enter: "letter-in", stagger: stagger(40), group: name },
}),
each(taglineLetters, {
key: (l) => l.id,
render: (l) => $.span({}, $.of(l.char)),
animate: { enter: "letter-in", stagger: stagger(40), group: tagline },
}),
),
);
});
```

New API:

- `Animation.group()` — creates a group with a gate (unresolved) and a completion signal.
- `Animation.sequence(count)` — returns `count` groups wired end-to-end: group 0's gate is open immediately, group N's gate opens when group N-1's registered animations all complete.
- `Animation.parallel(count)` — returns `count` groups with all gates open (useful nested inside `sequence` for concurrent segments in a follow-up release).
- `animate.group: AnimationGroup` — new option on any animated control (`each`, `when`, `match`, ...). The animation registers with the group synchronously, awaits the gate before starting, and signals completion when finished.

Groups finalize when their pending count returns to zero for the first time after having been non-zero. Late registrations that arrive after finalization run immediately (gate is already open) — intended semantics for one-shot intros where post-sequence additions behave like ordinary animations.
195 changes: 195 additions & 0 deletions packages/dom/src/Animation/groups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { Deferred, Effect } from "effect";
import { describe, expect, it } from "vitest";

import {
_awaitGate,
_complete,
_register,
group,
parallel,
sequence,
} from "./groups.js";

describe("Animation groups", () => {
describe("group()", () => {
it("creates a group whose gate is initially closed", async () => {
const result = await Effect.runPromise(
Effect.gen(function* () {
const g = yield* group();
expect(g._tag).toBe("AnimationGroup");
expect(g._state.gateResolved).toBe(false);
expect(g._state.doneResolved).toBe(false);
expect(g._state.pending).toBe(0);
const gateOpen = yield* Deferred.isDone(g._gate);
return gateOpen;
}),
);
expect(result).toBe(false);
});
});

describe("sequence()", () => {
it("opens the first group's gate immediately", async () => {
const gateOpen = await Effect.runPromise(
Effect.gen(function* () {
const [g0] = yield* sequence(1);
return yield* Deferred.isDone(g0._gate);
}),
);
expect(gateOpen).toBe(true);
});

it("keeps subsequent groups' gates closed until the prior finishes", async () => {
const gates = await Effect.runPromise(
Effect.gen(function* () {
const [g0, g1, g2] = yield* sequence(3);
const g0Open = yield* Deferred.isDone(g0._gate);
const g1Open = yield* Deferred.isDone(g1._gate);
const g2Open = yield* Deferred.isDone(g2._gate);
return { g0Open, g1Open, g2Open };
}),
);
expect(gates).toEqual({ g0Open: true, g1Open: false, g2Open: false });
});

it("opens the next gate when the prior group completes", async () => {
await Effect.runPromise(
Effect.gen(function* () {
const [g0, g1] = yield* sequence(2);

// Register two animations under g0
_register(g0);
_register(g0);
expect(g0._state.pending).toBe(2);
expect(yield* Deferred.isDone(g1._gate)).toBe(false);

// Complete one — g0 not done yet
yield* _complete(g0);
expect(g0._state.pending).toBe(1);
expect(g0._state.doneResolved).toBe(false);

// Complete the other — g0 done, g1 gate opens
yield* _complete(g0);
expect(g0._state.doneResolved).toBe(true);

// Give the daemon fiber a tick to open g1's gate
yield* Deferred.await(g1._gate);
expect(g1._state.gateResolved).toBe(true);
}),
);
});

it("chains multiple gates end-to-end", async () => {
await Effect.runPromise(
Effect.gen(function* () {
const [g0, g1, g2] = yield* sequence(3);

_register(g0);
yield* _complete(g0);
yield* Deferred.await(g1._gate);
expect(yield* Deferred.isDone(g2._gate)).toBe(false);

_register(g1);
yield* _complete(g1);
yield* Deferred.await(g2._gate);
expect(g2._state.gateResolved).toBe(true);
}),
);
});

it("returns an empty array for count 0", async () => {
const result = await Effect.runPromise(sequence(0));
expect(result).toEqual([]);
});
});

describe("parallel()", () => {
it("opens every gate immediately", async () => {
const gates = await Effect.runPromise(
Effect.gen(function* () {
const [g0, g1, g2] = yield* parallel(3);
return [
yield* Deferred.isDone(g0._gate),
yield* Deferred.isDone(g1._gate),
yield* Deferred.isDone(g2._gate),
];
}),
);
expect(gates).toEqual([true, true, true]);
});
});

describe("_complete()", () => {
it("only resolves done after pending has been non-zero and returns to zero", async () => {
await Effect.runPromise(
Effect.gen(function* () {
const g = yield* group();

// Never registered — completing shouldn't resolve done
expect(g._state.doneResolved).toBe(false);

_register(g);
expect(g._state.started).toBe(true);
yield* _complete(g);
expect(g._state.doneResolved).toBe(true);
}),
);
});

it("is idempotent past the first zero-crossing", async () => {
await Effect.runPromise(
Effect.gen(function* () {
const g = yield* group();
_register(g);
yield* _complete(g);
expect(g._state.doneResolved).toBe(true);

// Late registration + completion — pending stays balanced but
// doneResolved doesn't re-fire (Deferred.succeed is a no-op).
_register(g);
yield* _complete(g);
expect(g._state.doneResolved).toBe(true);
}),
);
});
});

describe("_awaitGate()", () => {
it("returns immediately once the gate is open", async () => {
const value = await Effect.runPromise(
Effect.gen(function* () {
const [g0] = yield* sequence(1);
yield* _awaitGate(g0);
return "unblocked";
}),
);
expect(value).toBe("unblocked");
});

it("blocks until the prior group completes", async () => {
const events = await Effect.runPromise(
Effect.gen(function* () {
const [g0, g1] = yield* sequence(2);
const log: string[] = [];

const g1Fiber = yield* Effect.fork(
Effect.gen(function* () {
yield* _awaitGate(g1);
log.push("g1-open");
}),
);

log.push("registering-g0");
_register(g0);
yield* Effect.sleep(1);
log.push("completing-g0");
yield* _complete(g0);

yield* g1Fiber.await;
return log;
}),
);
expect(events).toEqual(["registering-g0", "completing-g0", "g1-open"]);
});
});
});
159 changes: 159 additions & 0 deletions packages/dom/src/Animation/groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Animation groups: declarative sequencing across multiple animated blocks.
*
* A group is a shared handle that any animated control (`each`, `when`,
* `match`, ...) can attach to via `animate.group`. When groups are wired
* with `sequence()`, later groups' animations wait for the prior group's
* animations to complete before starting.
*
* @example Chained word-by-word intro:
* ```ts
* const App = () =>
* Effect.gen(function* () {
* const [greeting, name, tagline] = yield* Animation.sequence(3);
* return $.div({}, collect(
* each(greetingLetters, {
* key: (l) => l.id,
* render: (l) => $.span({}, $.of(l.char)),
* animate: { enter: "letter-in", stagger: stagger(40), group: greeting },
* }),
* each(nameLetters, {
* key: (l) => l.id,
* render: (l) => $.span({}, $.of(l.char)),
* animate: { enter: "letter-in", stagger: stagger(40), group: name },
* }),
* each(taglineLetters, {
* key: (l) => l.id,
* render: (l) => $.span({}, $.of(l.char)),
* animate: { enter: "letter-in", stagger: stagger(40), group: tagline },
* }),
* ));
* });
* ```
*
* A group's `done` signal fires the first time its pending count returns
* to zero after having been non-zero. Late registrations that arrive after
* the group is already done run immediately without gating and don't
* re-open the signal — intended for one-shot intros where new items added
* after the sequence completes should behave like ordinary animations.
*/

import { Deferred, Effect } from "effect";

/**
* Opaque handle representing a group of animations that can be gated and
* awaited together. Construct via `group()`, `sequence()`, or `parallel()`.
*/
export interface AnimationGroup {
readonly _tag: "AnimationGroup";
readonly _gate: Deferred.Deferred<void>;
readonly _done: Deferred.Deferred<void>;
readonly _state: {
pending: number;
started: boolean;
doneResolved: boolean;
gateResolved: boolean;
};
}

/**
* Create a fresh group whose gate is closed. Use `sequence()` or `parallel()`
* for the common case of building wired chains; call `group()` directly only
* if you need custom gating logic.
*/
export const group = (): Effect.Effect<AnimationGroup> =>
Effect.gen(function* () {
const gate = yield* Deferred.make<void>();
const done = yield* Deferred.make<void>();
return {
_tag: "AnimationGroup" as const,
_gate: gate,
_done: done,
_state: {
pending: 0,
started: false,
doneResolved: false,
gateResolved: false,
},
};
});

/**
* Create `count` groups where each opens after the prior's animations
* complete. Group 0's gate is open immediately; groups 1..N-1 gate on the
* prior group's `_done`.
*/
export const sequence = (count: number): Effect.Effect<AnimationGroup[]> =>
Effect.gen(function* () {
const groups: AnimationGroup[] = [];
for (let i = 0; i < count; i++) {
groups.push(yield* group());
}
if (count > 0) {
yield* openGate(groups[0]);
}
for (let i = 1; i < count; i++) {
const prev = groups[i - 1];
const curr = groups[i];
// Daemon so the wiring outlives the render scope — deferreds get GC'd
// when the groups are unreachable.
yield* Effect.forkDaemon(
Deferred.await(prev._done).pipe(Effect.andThen(() => openGate(curr))),
);
}
return groups;
});

/**
* Create `count` groups whose gates are all open immediately. Useful nested
* inside `sequence` for concurrent segments.
*/
export const parallel = (count: number): Effect.Effect<AnimationGroup[]> =>
Effect.gen(function* () {
const groups: AnimationGroup[] = [];
for (let i = 0; i < count; i++) {
const g = yield* group();
yield* openGate(g);
groups.push(g);
}
return groups;
});

const openGate = (g: AnimationGroup): Effect.Effect<void> =>
Effect.gen(function* () {
if (g._state.gateResolved) return;
g._state.gateResolved = true;
yield* Deferred.succeed(g._gate, void 0);
});

/**
* Register an animation with the group synchronously — must be called from
* the calling fiber before its animation fiber is forked, so the counter
* reflects all sibling animations before any completion decrements it.
* Internal — invoked by the ControlCtx slot machinery.
*/
export const _register = (g: AnimationGroup): void => {
g._state.pending += 1;
g._state.started = true;
};

/**
* Signal that a registered animation has finished. When the pending count
* hits zero (having previously been non-zero), the group's `_done` fires.
* Idempotent past the first zero-crossing. Internal.
*/
export const _complete = (g: AnimationGroup): Effect.Effect<void> =>
Effect.gen(function* () {
g._state.pending -= 1;
if (g._state.pending === 0 && g._state.started && !g._state.doneResolved) {
g._state.doneResolved = true;
yield* Deferred.succeed(g._done, void 0);
}
});

/**
* Wait until the group's gate is open. Once open, returns immediately on
* subsequent calls. Internal.
*/
export const _awaitGate = (g: AnimationGroup): Effect.Effect<void> =>
Deferred.await(g._gate);
Loading
Loading