Skip to content

feat: wire up stagger for each's enter animations#35

Merged
jonlaing merged 3 commits into
mainfrom
feat/wire-up-stagger
Jul 12, 2026
Merged

feat: wire up stagger for each's enter animations#35
jonlaing merged 3 commits into
mainfrom
feat/wire-up-stagger

Conversation

@jonlaing

Copy link
Copy Markdown
Owner

Summary

stagger was declared on `ListAnimationOptions` and exported via helpers (`stagger`, `staggerFromCenter`) but nothing in the runtime read it, so `each({ animate: { stagger: stagger(40) } })` produced no visible staggering. The API had been advertising the feature since before the current branch history without ever implementing it. This wires it up.

Design note — why a shared reference timestamp

The naive approach — `Effect.delay(index * step)` per slot — compounds reconcile overhead:

t=0    reconcile starts
t=0    slot 0 forked, delayMs=0,   fires at t=0    ✓
t=40   slot 1 forked, delayMs=40,  fires at t=80   ✗  (expected 40)
t=80   slot 2 forked, delayMs=80,  fires at t=160  ✗  (expected 80)

Each item drifts further behind its expected position because reconcile itself takes time between forks (Signal.make, render, insertBefore all yield). Confirmed empirically in my first test iteration — item 1 fired at ~77ms with `stagger(40)`.

Fix: reconcile captures `batchStart = Date.now()` when a sync begins and threads it through `addSlot` → `forkSlotEnter`. Each slot's animation delays by `max(0, batchStart + stagger(index, total) - now())` — the residual after reconcile overhead. Slot N always fires at `batchStart + stagger(N, total)` regardless of how long reconcile takes to reach it.

Plumbing

  • `IControlCtx.addSlot` gains two optional fields: `totalItems` and `staggerStartAt`.
  • `reconcile` (in `@effex/core`) captures `batchStart` and passes both.
  • All three `addSlot` impls in `@effex/dom` (Client, HydrationClient-like, Hydration-root) forward them into `forkSlotEnter`.
  • `forkSlotEnter` computes `targetDelay` via `stagger(index, total)` and `actualDelay` via the shared-reference formula, then `Effect.delay`s the run.

Applies to `each` in all four paths (client, client-fallback-during-hydration, hydration-root, intro re-animation). `when` / `match` etc. don't take a stagger option — they render single elements.

Test plan

  • `npx tsc --noEmit` clean
  • Two new tests in `Control.test.ts`:
    • Stagger produces per-item delays matching the stagger function
    • No stagger → all items fire near-simultaneously
  • Full suite: 720 pass / 2 skipped (was 718 / 2)
  • Manual verification on the portfolio-site headline

🤖 Generated with Claude Code

The stagger option on ListAnimationOptions was declared and exported but nothing in the runtime consumed it — each({ animate: { stagger: stagger(40) } }) produced no staggering. Fixed by threading index, totalItems, and a batch-start timestamp through IControlCtx.addSlot, into forkSlotEnter, which computes each slot's fire time relative to the shared reference and Effect.delays by only the residual after reconcile overhead. Compounding-delay bug (each item drifting further behind its expected position as reconcile takes time between slots) avoided by measuring from the shared start rather than from each slot's fork time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploying effex-api with  Cloudflare Pages  Cloudflare Pages

Latest commit: 855f30b
Status: ✅  Deploy successful!
Preview URL: https://00baf9ca.effex-api.pages.dev
Branch Preview URL: https://feat-wire-up-stagger.effex-api.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploying effex with  Cloudflare Pages  Cloudflare Pages

Latest commit: 855f30b
Status: ✅  Deploy successful!
Preview URL: https://f2a32a55.effex.pages.dev
Branch Preview URL: https://feat-wire-up-stagger.effex.pages.dev

View logs

jonlaing and others added 2 commits July 12, 2026 09:36
…elpers from @effex/dom top level

The Animation namespace (group choreography), AnimationGroup type, and waitForAnimationEvent / waitForAnimationEnd / forceReflow helpers were reachable via @effex/dom/Animation but not from the top-level entry, so users had to know the internal module layout. Re-export them alongside the stagger helpers so 'import { Animation, ... } from "@effex/dom"' works.

Note: top-level sequence and parallel still refer to the deprecated Effect-combining helpers (kept for back-compat until the next major); the new choreography lives under Animation.sequence / Animation.parallel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous stagger tests measured wall-clock timing of onBeforeEnter hooks. That's fine locally but flaky under CI load — Effect.delay(40ms) can fire at 200ms+ when setTimeout queues get starved, so 'item 1 fires < 70ms' fails intermittently.

Rewrote to spy on the stagger function itself: with three items, the function must be invoked with {index: 0, total: 3}, {1, 3}, {2, 3}. This proves the wiring (reconcile passes index/total through to forkSlotEnter which invokes the stagger function) without depending on any real-time behaviour. Second test verifies stagger isn't invoked when the option is omitted.

Also switched the Date.now() call in reconcile and forkSlotEnter to Clock.currentTimeMillis so callers can substitute a TestClock later if needed. Runtime behaviour is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jonlaing
jonlaing merged commit b5dc709 into main Jul 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant