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
9 changes: 9 additions & 0 deletions .changeset/fix-animation-serialization.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 11 additions & 30 deletions packages/dom/src/Control/ClientControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -107,16 +106,7 @@ const createClientControlCtx = (): IControlCtx<DOMElement> => {
};
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<typeof runEnterAnimation>[1]
| undefined;
if (animate && element instanceof HTMLElement) {
yield* runEnterAnimation(Effect.succeed(element), animate);
}
yield* forkSlotEnter(element, slotScope);

return entry;
}) as Effect.Effect<DOMSlotEntry, E, R>,
Expand All @@ -127,24 +117,15 @@ const createClientControlCtx = (): IControlCtx<DOMElement> => {
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<typeof runExitAnimation>[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<DOMSlotEntry | undefined> =>
Expand Down
80 changes: 17 additions & 63 deletions packages/dom/src/Control/HydrationControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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<typeof runEnterAnimation>[1]
| undefined;
if (animate && element instanceof HTMLElement) {
yield* runEnterAnimation(Effect.succeed(element), animate);
}
yield* forkSlotEnter(element, slotScope);
}

return entry;
Expand All @@ -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<typeof runExitAnimation>[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<DOMSlotEntry | undefined> =>
Expand Down Expand Up @@ -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<typeof runEnterAnimation>[1]
| undefined;
if (animate && element instanceof HTMLElement) {
yield* runEnterAnimation(Effect.succeed(element), animate);
}
yield* forkSlotEnter(element, slotScope);

return entry;
}) as Effect.Effect<DOMSlotEntry, E, R>,
Expand All @@ -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<typeof runExitAnimation>[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<DOMSlotEntry | undefined> =>
Expand Down
89 changes: 89 additions & 0 deletions packages/dom/src/Control/slotAnimation.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(): Effect.Effect<T | undefined> =>
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<void> => {
if (!(element instanceof HTMLElement)) return Effect.void;
return Effect.gen(function* () {
const animate =
yield* readAnimateOption<Parameters<typeof runEnterAnimation>[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<void> =>
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<Parameters<typeof runExitAnimation>[1]>();
if (animate) {
yield* runExitAnimation(Effect.succeed(entry.element), animate);
}
}
removeFromDom();
}).pipe(Effect.forkIn(parentScope));
}) as unknown as Effect.Effect<void>;
Loading