From 0dd64405ef8309e0bc9f16180b0c76b148d1f2ba Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Wed, 1 Jul 2026 16:26:18 -0400 Subject: [PATCH 1/2] fix: fork animations to run concurrently instead of serializing reconcile loop Both ClientControlCtx and HydrationControlCtx were awaiting runEnterAnimation and runExitAnimation inline, which caused the sync loop in reconcile to serialize: item N's animation blocked item N+1's addSlot from starting. Made stagger configs functionally broken and exit animations cascade one-at-a-time instead of in parallel. Enter animations now fork into the slot's own scope. Exit animations fork into the parent scope so removeSlot returns immediately while the exit still plays; the slot's scope is closed inside the fork before the exit runs so any in-flight enter is interrupted first. All 50 existing Control/hydrate/Animation tests pass. Closes #25 Co-Authored-By: Claude Opus 4.7 --- .changeset/fix-animation-serialization.md | 9 ++ packages/dom/src/Control/ClientControlCtx.ts | 78 ++++++---- .../dom/src/Control/HydrationControlCtx.ts | 141 +++++++++++------- 3 files changed, 144 insertions(+), 84 deletions(-) create mode 100644 .changeset/fix-animation-serialization.md diff --git a/.changeset/fix-animation-serialization.md b/.changeset/fix-animation-serialization.md new file mode 100644 index 00000000..779c1a39 --- /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 cb3cc433..4eac47ef 100644 --- a/packages/dom/src/Control/ClientControlCtx.ts +++ b/packages/dom/src/Control/ClientControlCtx.ts @@ -107,15 +107,25 @@ 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); + // Fork enter animation into the slot's scope so it runs concurrently + // with subsequent addSlot calls (rather than serializing the reconcile + // loop) and gets interrupted if the slot is removed mid-animation. + // Config is read lazily inside the forked fiber. + if (element instanceof HTMLElement) { + yield* Effect.gen(function* () { + const animationConfigOption = + yield* Effect.serviceOption(AnimationConfigCtx); + const animationConfig = Option.getOrUndefined( + animationConfigOption, + ); + const animate = (animationConfig?.list ?? + animationConfig?.single) as + | Parameters[1] + | undefined; + if (animate) { + yield* runEnterAnimation(Effect.succeed(element), animate); + } + }).pipe(Effect.forkIn(slotScope)); } return entry; @@ -128,24 +138,40 @@ const createClientControlCtx = (): IControlCtx => { 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); + // Remove from map immediately so subsequent syncs see the slot as gone. slots.delete(key); - }), + + // Fork the exit sequence into the parent scope so removeSlot returns + // right away — otherwise the reconcile loop serializes on every exit + // animation. The fiber closes the slot's scope first (interrupting + // any in-flight enter animation), then plays exit, then removes the + // DOM node. Running on parentScope means the exit continues even if + // a fresh slot re-uses this key immediately. + const parentScope = yield* Effect.scope; + yield* Effect.gen(function* () { + yield* Scope.close(entry.scope, Exit.void); + + 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); + } + }).pipe(Effect.forkIn(parentScope)); + // Cast: Effect.scope introduces Scope.Scope in R which the interface + // signature doesn't expose. Callers always have Scope.Scope in + // context — same pattern as `subscribe` above. + }) as unknown as Effect.Effect, getSlot: (key: string): Effect.Effect => Effect.sync(() => slots.get(key)), diff --git a/packages/dom/src/Control/HydrationControlCtx.ts b/packages/dom/src/Control/HydrationControlCtx.ts index c87aec27..2f7e6750 100644 --- a/packages/dom/src/Control/HydrationControlCtx.ts +++ b/packages/dom/src/Control/HydrationControlCtx.ts @@ -148,19 +148,24 @@ 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); - } + if (hydrationDone && element instanceof HTMLElement) { + // Fork enter animation into the slot's scope so it runs concurrently + // with subsequent addSlot calls. Config is read lazily inside the + // forked fiber — see the note on createClientLikeControlCtx. + yield* Effect.gen(function* () { + const animationConfigOption = + yield* Effect.serviceOption(AnimationConfigCtx); + const animationConfig = Option.getOrUndefined( + animationConfigOption, + ); + const animate = (animationConfig?.list ?? + animationConfig?.single) as + | Parameters[1] + | undefined; + if (animate) { + yield* runEnterAnimation(Effect.succeed(element), animate); + } + }).pipe(Effect.forkIn(slotScope)); } return entry; @@ -182,24 +187,33 @@ const createClientLikeControlCtx = ( 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); - } + // Remove from map immediately so subsequent syncs see the slot as gone. + slots.delete(key); - if (containerElement && entry.element.parentNode === containerElement) { - containerElement.removeChild(entry.element); - } + // Fork the exit sequence into the parent scope; see ClientControlCtx + // for the full rationale. + const parentScope = yield* Effect.scope; + yield* Effect.gen(function* () { + yield* Scope.close(entry.scope, Exit.void); - yield* Scope.close(entry.scope, Exit.void); - slots.delete(key); - }), + 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); + } + }).pipe(Effect.forkIn(parentScope)); + }) as unknown as Effect.Effect, getSlot: (key: string): Effect.Effect => Effect.sync(() => slots.get(key)), @@ -349,17 +363,23 @@ 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); + if (element instanceof HTMLElement) { + // Fork enter animation into the slot's scope; see + // createClientLikeControlCtx for rationale. + yield* Effect.gen(function* () { + const animationConfigOption = + yield* Effect.serviceOption(AnimationConfigCtx); + const animationConfig = Option.getOrUndefined( + animationConfigOption, + ); + const animate = (animationConfig?.list ?? + animationConfig?.single) as + | Parameters[1] + | undefined; + if (animate) { + yield* runEnterAnimation(Effect.succeed(element), animate); + } + }).pipe(Effect.forkIn(slotScope)); } return entry; @@ -373,26 +393,31 @@ const createHydrationControlCtx = ( 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); - } + // Remove from map immediately so subsequent syncs see the slot as gone. + slots.delete(key); - if (entry.element.parentNode === containerElement) { - containerElement.removeChild(entry.element); - } + // Fork exit sequence into parent scope; see ClientControlCtx. + const parentScope = yield* Effect.scope; + yield* Effect.gen(function* () { + if (entry.scope) { + yield* Scope.close(entry.scope, Exit.void); + } - if (entry.scope) { - yield* Scope.close(entry.scope, Exit.void); - } - slots.delete(key); - }), + 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); + } + }).pipe(Effect.forkIn(parentScope)); + }) as unknown as Effect.Effect, getSlot: (key: string): Effect.Effect => Effect.sync(() => slots.get(key)), From 3f3a8396213dada5bcdd8ce6e459b92ce480dcef Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Thu, 2 Jul 2026 18:29:44 -0400 Subject: [PATCH 2/2] refactor: extract fork-into-scope pattern into slotAnimation helpers Pulls the config-read + fork-enter and close-scope + config-read + exit + remove-DOM blocks (repeated three times each across ClientControlCtx and both HydrationControlCtx factories) into forkSlotEnter and forkSlotRemoval in a new slotAnimation.ts module. Net -116 lines across the two ControlCtx files. All 704 tests still pass. Co-Authored-By: Claude Opus 4.7 --- packages/dom/src/Control/ClientControlCtx.ts | 57 ++---------- .../dom/src/Control/HydrationControlCtx.ts | 93 +++---------------- packages/dom/src/Control/slotAnimation.ts | 89 ++++++++++++++++++ 3 files changed, 106 insertions(+), 133 deletions(-) create mode 100644 packages/dom/src/Control/slotAnimation.ts diff --git a/packages/dom/src/Control/ClientControlCtx.ts b/packages/dom/src/Control/ClientControlCtx.ts index 4eac47ef..cc585071 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,26 +106,7 @@ const createClientControlCtx = (): IControlCtx => { }; slots.set(key, entry); - // Fork enter animation into the slot's scope so it runs concurrently - // with subsequent addSlot calls (rather than serializing the reconcile - // loop) and gets interrupted if the slot is removed mid-animation. - // Config is read lazily inside the forked fiber. - if (element instanceof HTMLElement) { - yield* Effect.gen(function* () { - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined( - animationConfigOption, - ); - const animate = (animationConfig?.list ?? - animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate) { - yield* runEnterAnimation(Effect.succeed(element), animate); - } - }).pipe(Effect.forkIn(slotScope)); - } + yield* forkSlotEnter(element, slotScope); return entry; }) as Effect.Effect, @@ -137,41 +117,16 @@ const createClientControlCtx = (): IControlCtx => { Effect.gen(function* () { const entry = slots.get(key); if (!entry) return; - - // Remove from map immediately so subsequent syncs see the slot as gone. slots.delete(key); - - // Fork the exit sequence into the parent scope so removeSlot returns - // right away — otherwise the reconcile loop serializes on every exit - // animation. The fiber closes the slot's scope first (interrupting - // any in-flight enter animation), then plays exit, then removes the - // DOM node. Running on parentScope means the exit continues even if - // a fresh slot re-uses this key immediately. - const parentScope = yield* Effect.scope; - yield* Effect.gen(function* () { - yield* Scope.close(entry.scope, Exit.void); - - 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); - } - + yield* forkSlotRemoval(entry, () => { if ( containerElement && entry.element.parentNode === containerElement ) { containerElement.removeChild(entry.element); } - }).pipe(Effect.forkIn(parentScope)); - // Cast: Effect.scope introduces Scope.Scope in R which the interface - // signature doesn't expose. Callers always have Scope.Scope in - // context — same pattern as `subscribe` above. - }) as unknown as Effect.Effect, + }); + }), getSlot: (key: string): Effect.Effect => Effect.sync(() => slots.get(key)), diff --git a/packages/dom/src/Control/HydrationControlCtx.ts b/packages/dom/src/Control/HydrationControlCtx.ts index 2f7e6750..362d7b35 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; @@ -148,24 +147,8 @@ const createClientLikeControlCtx = ( }; slots.set(key, entry); - if (hydrationDone && element instanceof HTMLElement) { - // Fork enter animation into the slot's scope so it runs concurrently - // with subsequent addSlot calls. Config is read lazily inside the - // forked fiber — see the note on createClientLikeControlCtx. - yield* Effect.gen(function* () { - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined( - animationConfigOption, - ); - const animate = (animationConfig?.list ?? - animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate) { - yield* runEnterAnimation(Effect.succeed(element), animate); - } - }).pipe(Effect.forkIn(slotScope)); + if (hydrationDone) { + yield* forkSlotEnter(element, slotScope); } return entry; @@ -186,34 +169,16 @@ const createClientLikeControlCtx = ( Effect.gen(function* () { const entry = slots.get(key); if (!entry) return; - - // Remove from map immediately so subsequent syncs see the slot as gone. slots.delete(key); - - // Fork the exit sequence into the parent scope; see ClientControlCtx - // for the full rationale. - const parentScope = yield* Effect.scope; - yield* Effect.gen(function* () { - yield* Scope.close(entry.scope, Exit.void); - - 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); - } - + yield* forkSlotRemoval(entry, () => { if ( containerElement && entry.element.parentNode === containerElement ) { containerElement.removeChild(entry.element); } - }).pipe(Effect.forkIn(parentScope)); - }) as unknown as Effect.Effect, + }); + }), getSlot: (key: string): Effect.Effect => Effect.sync(() => slots.get(key)), @@ -363,24 +328,7 @@ const createHydrationControlCtx = ( }; slots.set(key, entry); - if (element instanceof HTMLElement) { - // Fork enter animation into the slot's scope; see - // createClientLikeControlCtx for rationale. - yield* Effect.gen(function* () { - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined( - animationConfigOption, - ); - const animate = (animationConfig?.list ?? - animationConfig?.single) as - | Parameters[1] - | undefined; - if (animate) { - yield* runEnterAnimation(Effect.succeed(element), animate); - } - }).pipe(Effect.forkIn(slotScope)); - } + yield* forkSlotEnter(element, slotScope); return entry; }) as Effect.Effect, @@ -392,32 +340,13 @@ const createHydrationControlCtx = ( Effect.gen(function* () { const entry = slots.get(key); if (!entry) return; - - // Remove from map immediately so subsequent syncs see the slot as gone. slots.delete(key); - - // Fork exit sequence into parent scope; see ClientControlCtx. - const parentScope = yield* Effect.scope; - yield* Effect.gen(function* () { - if (entry.scope) { - yield* Scope.close(entry.scope, Exit.void); - } - - 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); - } - + yield* forkSlotRemoval(entry, () => { if (entry.element.parentNode === containerElement) { containerElement.removeChild(entry.element); } - }).pipe(Effect.forkIn(parentScope)); - }) as unknown as Effect.Effect, + }); + }), getSlot: (key: string): Effect.Effect => Effect.sync(() => slots.get(key)), diff --git a/packages/dom/src/Control/slotAnimation.ts b/packages/dom/src/Control/slotAnimation.ts new file mode 100644 index 00000000..ed89fca2 --- /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;