diff --git a/.changeset/fix-animation-serialization.md b/.changeset/fix-animation-serialization.md new file mode 100644 index 0000000..779c1a3 --- /dev/null +++ b/.changeset/fix-animation-serialization.md @@ -0,0 +1,9 @@ +--- +"@effex/dom": patch +--- + +Fix inline animation serialization in `addSlot` / `removeSlot`. Previously each enter/exit animation was awaited via `yield*`, which serialized the `reconcile` sync loop — for a list of `[a, b, c]` added at once, item `b`'s animation would only start after item `a`'s finished. Made `stagger` configs effectively double-counted (each addSlot's stagger delay applied *after* the previous animation completed rather than concurrently). + +Now enter animations are forked into the slot's scope so multiple slots animate concurrently, and exit animations run in a fiber attached to the parent scope so `removeSlot` returns immediately (letting the reconcile loop continue) while the exit still plays before the DOM node is removed. If a slot is removed mid-enter, closing the slot scope interrupts the enter animation before exit starts. This applies to `ClientControlCtx` and both factories in `HydrationControlCtx`. + +`@effex/router` will receive an automatic patch via `updateInternalDependencies` since it depends on `@effex/dom` as a workspace dependency. diff --git a/packages/dom/src/Control/ClientControlCtx.ts b/packages/dom/src/Control/ClientControlCtx.ts index cb3cc43..cc58507 100644 --- a/packages/dom/src/Control/ClientControlCtx.ts +++ b/packages/dom/src/Control/ClientControlCtx.ts @@ -3,7 +3,7 @@ * Full reactive updates with optional animation support. */ -import { Effect, Exit, Layer, Option, Scope, Stream } from "effect"; +import { Effect, Layer, Scope, Stream } from "effect"; import { ControlCtx, @@ -15,10 +15,9 @@ import { type SlotEntry, } from "@effex/core"; -import { runEnterAnimation, runExitAnimation } from "../Animation/index.js"; import * as Element from "../Element/index.js"; import { DOMRenderer } from "../Render/DOMRenderer.js"; -import { AnimationConfigCtx } from "./AnimationConfigCtx.js"; +import { forkSlotEnter, forkSlotRemoval } from "./slotAnimation.js"; type DOMElement = HTMLElement | SVGElement; @@ -107,16 +106,7 @@ const createClientControlCtx = (): IControlCtx => { }; slots.set(key, entry); - // Run enter animation if configured (read at runtime, not Layer creation) - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined(animationConfigOption); - const animate = (animationConfig?.list ?? animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate && element instanceof HTMLElement) { - yield* runEnterAnimation(Effect.succeed(element), animate); - } + yield* forkSlotEnter(element, slotScope); return entry; }) as Effect.Effect, @@ -127,24 +117,15 @@ const createClientControlCtx = (): IControlCtx => { Effect.gen(function* () { const entry = slots.get(key); if (!entry) return; - - // Run exit animation if configured (read at runtime, not Layer creation) - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined(animationConfigOption); - const animate = (animationConfig?.list ?? animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate && entry.element instanceof HTMLElement) { - yield* runExitAnimation(Effect.succeed(entry.element), animate); - } - - if (containerElement && entry.element.parentNode === containerElement) { - containerElement.removeChild(entry.element); - } - - yield* Scope.close(entry.scope, Exit.void); slots.delete(key); + yield* forkSlotRemoval(entry, () => { + if ( + containerElement && + entry.element.parentNode === containerElement + ) { + containerElement.removeChild(entry.element); + } + }); }), getSlot: (key: string): Effect.Effect => diff --git a/packages/dom/src/Control/HydrationControlCtx.ts b/packages/dom/src/Control/HydrationControlCtx.ts index c87aec2..362d7b3 100644 --- a/packages/dom/src/Control/HydrationControlCtx.ts +++ b/packages/dom/src/Control/HydrationControlCtx.ts @@ -3,7 +3,7 @@ * Finds existing DOM and attaches handlers, then subscribes like client mode. */ -import { Context, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; +import { Context, Effect, Layer, Scope, Stream } from "effect"; import { ControlCtx, @@ -15,10 +15,9 @@ import { type SlotEntry, } from "@effex/core"; -import { runEnterAnimation, runExitAnimation } from "../Animation/index.js"; import * as Element from "../Element/index.js"; import { DOMRenderer } from "../Render/DOMRenderer.js"; -import { AnimationConfigCtx } from "./AnimationConfigCtx.js"; +import { forkSlotEnter, forkSlotRemoval } from "./slotAnimation.js"; type DOMElement = HTMLElement | SVGElement; @@ -149,18 +148,7 @@ const createClientLikeControlCtx = ( slots.set(key, entry); if (hydrationDone) { - // Read animation config at runtime, not at Layer creation, - // so nested `each` with `animate` sees the config its parent - // provided via AnimationConfigCtx. - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined(animationConfigOption); - const animate = (animationConfig?.list ?? animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate && element instanceof HTMLElement) { - yield* runEnterAnimation(Effect.succeed(element), animate); - } + yield* forkSlotEnter(element, slotScope); } return entry; @@ -181,24 +169,15 @@ const createClientLikeControlCtx = ( Effect.gen(function* () { const entry = slots.get(key); if (!entry) return; - - // Read animation config at runtime, not at Layer creation. - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined(animationConfigOption); - const animate = (animationConfig?.list ?? animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate && entry.element instanceof HTMLElement) { - yield* runExitAnimation(Effect.succeed(entry.element), animate); - } - - if (containerElement && entry.element.parentNode === containerElement) { - containerElement.removeChild(entry.element); - } - - yield* Scope.close(entry.scope, Exit.void); slots.delete(key); + yield* forkSlotRemoval(entry, () => { + if ( + containerElement && + entry.element.parentNode === containerElement + ) { + containerElement.removeChild(entry.element); + } + }); }), getSlot: (key: string): Effect.Effect => @@ -349,18 +328,7 @@ const createHydrationControlCtx = ( }; slots.set(key, entry); - // Read animation config at runtime, not at Layer creation, - // so nested `each` with `animate` sees the config its parent - // provided via AnimationConfigCtx. - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined(animationConfigOption); - const animate = (animationConfig?.list ?? animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate && element instanceof HTMLElement) { - yield* runEnterAnimation(Effect.succeed(element), animate); - } + yield* forkSlotEnter(element, slotScope); return entry; }) as Effect.Effect, @@ -372,26 +340,12 @@ const createHydrationControlCtx = ( Effect.gen(function* () { const entry = slots.get(key); if (!entry) return; - - // Read animation config at runtime, not at Layer creation. - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined(animationConfigOption); - const animate = (animationConfig?.list ?? animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate && entry.element instanceof HTMLElement) { - yield* runExitAnimation(Effect.succeed(entry.element), animate); - } - - if (entry.element.parentNode === containerElement) { - containerElement.removeChild(entry.element); - } - - if (entry.scope) { - yield* Scope.close(entry.scope, Exit.void); - } slots.delete(key); + yield* forkSlotRemoval(entry, () => { + if (entry.element.parentNode === containerElement) { + containerElement.removeChild(entry.element); + } + }); }), getSlot: (key: string): Effect.Effect => diff --git a/packages/dom/src/Control/slotAnimation.ts b/packages/dom/src/Control/slotAnimation.ts new file mode 100644 index 0000000..ed89fca --- /dev/null +++ b/packages/dom/src/Control/slotAnimation.ts @@ -0,0 +1,89 @@ +/** + * Shared animation-fork helpers used by every ControlCtx implementation + * (Client, HydrationClient-like, Hydration-root). + * + * The reconcile loop invokes addSlot/removeSlot sequentially. If enter/exit + * animations were awaited inline, every slot's animation would block the + * next slot's turn — so we fork them: + * + * - Enter animations fork into the slot's own scope; closing the slot's + * scope (e.g. via removeSlot) interrupts an in-flight enter. + * - Exit animations fork into the *parent* scope so removeSlot returns + * right away and the exit continues even if a fresh slot re-uses the + * same key immediately. + * + * AnimationConfigCtx is read lazily inside the forked fiber so nested + * control flow sees the config its parent provided (rather than whatever + * was in scope at Layer construction). + */ + +import { Effect, Exit, Option, Scope } from "effect"; + +import { runEnterAnimation, runExitAnimation } from "../Animation/index.js"; +import { AnimationConfigCtx } from "./AnimationConfigCtx.js"; + +type DOMElement = HTMLElement | SVGElement; + +const readAnimateOption = (): Effect.Effect => + Effect.gen(function* () { + const configOpt = yield* Effect.serviceOption(AnimationConfigCtx); + const config = Option.getOrUndefined(configOpt); + return (config?.list ?? config?.single) as T | undefined; + }); + +/** + * Fork an enter animation into the slot's scope. Returns immediately; the + * animation plays in the background and gets interrupted if the slot scope + * closes before it finishes. No-op if the element is not an HTMLElement + * (SVG doesn't support the CSS-class animation path). + */ +export const forkSlotEnter = ( + element: DOMElement, + slotScope: Scope.CloseableScope, +): Effect.Effect => { + if (!(element instanceof HTMLElement)) return Effect.void; + return Effect.gen(function* () { + const animate = + yield* readAnimateOption[1]>(); + if (animate) { + yield* runEnterAnimation(Effect.succeed(element), animate); + } + }).pipe(Effect.forkIn(slotScope), Effect.asVoid); +}; + +/** + * Fork the full slot-removal sequence into the parent scope: + * 1. Close the slot's scope (interrupts any in-flight enter animation). + * 2. Play the exit animation (if configured). + * 3. Call `removeFromDom` to detach the element. + * + * `entry.scope` is nullable because hydration seeds slots from existing + * DOM before their scopes are populated by addSlot; a slot removed in + * that intermediate state has nothing to close. + * + * The Scope.Scope required by `Effect.scope` is stripped from the returned + * signature; callers (removeSlot) always run inside a scope from reconcile. + */ +export const forkSlotRemoval = ( + entry: { + readonly element: DOMElement; + readonly scope: Scope.CloseableScope | null; + }, + removeFromDom: () => void, +): Effect.Effect => + Effect.gen(function* () { + const parentScope = yield* Effect.scope; + yield* Effect.gen(function* () { + if (entry.scope) { + yield* Scope.close(entry.scope, Exit.void); + } + if (entry.element instanceof HTMLElement) { + const animate = + yield* readAnimateOption[1]>(); + if (animate) { + yield* runExitAnimation(Effect.succeed(entry.element), animate); + } + } + removeFromDom(); + }).pipe(Effect.forkIn(parentScope)); + }) as unknown as Effect.Effect;