From 87acc34e29caaf90e897e683206ac02c67ceedc6 Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Mon, 22 Jun 2026 10:00:26 -0400 Subject: [PATCH 1/2] fix: read AnimationConfigCtx lazily in HydrationControlCtx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hydration control contexts were reading AnimationConfigCtx once at Layer construction (at the hydration root) and closing over the result. Since each's animate config is provided locally via Effect.provideService around the each, the captured value was always undefined — the layer runs before any each has a chance to provide its config. Net effect: list animations were silently dropped on every hydrated page (SSG/SSR). Mirrors the ClientControlCtx pattern: read AnimationConfigCtx via Effect.serviceOption inside addSlot/removeSlot, so each call sees the config its parent provided. Drops the captured animationConfig parameter from createClientLikeControlCtx and createHydrationControlCtx entirely; the Layer no longer pre-reads the service. Confirmed all existing Control/hydrate/Animation tests still pass (50 tests, 3 files). Closes #22 Co-Authored-By: Claude Opus 4.7 --- .../dom/src/Control/HydrationControlCtx.ts | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/dom/src/Control/HydrationControlCtx.ts b/packages/dom/src/Control/HydrationControlCtx.ts index 78396616..c87aec27 100644 --- a/packages/dom/src/Control/HydrationControlCtx.ts +++ b/packages/dom/src/Control/HydrationControlCtx.ts @@ -37,9 +37,12 @@ export class HydrationRootCtx extends Context.Tag( /** * Creates a fresh client-like control context for nested/forked contexts during hydration. * Used when fork() is called - nested control functions don't read from DOM. + * + * AnimationConfigCtx is read lazily inside addSlot/removeSlot at the moment + * the effect runs, not captured at construction time — that way nested control + * flow (e.g. `each` with `animate`) sees the config its parent provided. */ const createClientLikeControlCtx = ( - animationConfig: { single?: unknown; list?: unknown } | undefined, startInClientMode = false, ): IControlCtx => { const slots = new Map(); @@ -63,10 +66,7 @@ const createClientLikeControlCtx = ( const ctx: IControlCtx = { // Propagate hydrationDone so nested control functions created after // hydration finishes start in client mode immediately. - fork: () => - Effect.succeed( - createClientLikeControlCtx(animationConfig, hydrationDone), - ), + fork: () => Effect.succeed(createClientLikeControlCtx(hydrationDone)), defaultContainer, @@ -121,7 +121,7 @@ const createClientLikeControlCtx = ( // After hydration: use DOMRenderer to create new DOM. // Provide a fresh client-mode ControlCtx so nested control flow // (each, matchOption, etc.) doesn't inherit the stale hydration root. - const freshCtx = createClientLikeControlCtx(animationConfig, true); + const freshCtx = createClientLikeControlCtx(true); element = (yield* render({ item, index }).pipe( Effect.provideService(Scope.Scope, slotScope), Effect.provideService( @@ -149,6 +149,12 @@ 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; @@ -176,6 +182,10 @@ 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; @@ -230,10 +240,12 @@ const createClientLikeControlCtx = ( /** * Creates a hydration control context that reads from existing DOM. * fork() returns a client-like context for nested control functions. + * + * AnimationConfigCtx is read lazily inside addSlot/removeSlot — see the + * note on createClientLikeControlCtx for why. */ const createHydrationControlCtx = ( containerElement: DOMElement, - animationConfig: { single?: unknown; list?: unknown } | undefined, ): IControlCtx => { const slots = new Map(); // Tracks whether the initial hydration pass is complete. @@ -260,10 +272,7 @@ const createHydrationControlCtx = ( const ctx: IControlCtx = { // After hydration completes, nested control functions start in client mode - fork: () => - Effect.succeed( - createClientLikeControlCtx(animationConfig, hydrationComplete), - ), + fork: () => Effect.succeed(createClientLikeControlCtx(hydrationComplete)), defaultContainer, @@ -316,7 +325,7 @@ const createHydrationControlCtx = ( Effect.provideService(Scope.Scope, slotScope), ); - const freshCtx = createClientLikeControlCtx(animationConfig, true); + const freshCtx = createClientLikeControlCtx(true); const element = (yield* render({ item, index }).pipe( Effect.provideService(Scope.Scope, slotScope), Effect.provideService( @@ -340,6 +349,12 @@ 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; @@ -358,6 +373,10 @@ 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; @@ -414,6 +433,11 @@ const createHydrationControlCtx = ( /** * Hydration ControlCtx implementation. * Finds existing DOM and attaches handlers, then subscribes like client mode. + * + * AnimationConfigCtx is intentionally NOT read here — it would be captured + * at Layer construction time (at the hydration root), before any `each` + * gets a chance to provide it. Instead each addSlot/removeSlot reads the + * service lazily, mirroring ClientControlCtx. */ export const HydrationControlCtx: Layer.Layer< ControlCtx, @@ -423,13 +447,6 @@ export const HydrationControlCtx: Layer.Layer< ControlCtx, Effect.gen(function* () { const containerElement = yield* HydrationRootCtx; - const animationConfigOption = - yield* Effect.serviceOption(AnimationConfigCtx); - const animationConfig = Option.getOrUndefined(animationConfigOption); - - return createHydrationControlCtx( - containerElement, - animationConfig, - ) as IControlCtx; + return createHydrationControlCtx(containerElement) as IControlCtx; }), ); From 2e2670ec95a12114ad1924b1507c990f38b99b4f Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Wed, 24 Jun 2026 21:12:46 -0400 Subject: [PATCH 2/2] chore: add changeset for hydration animation fix Co-Authored-By: Claude Opus 4.7 --- .changeset/fix-hydration-list-animations.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-hydration-list-animations.md diff --git a/.changeset/fix-hydration-list-animations.md b/.changeset/fix-hydration-list-animations.md new file mode 100644 index 00000000..577cbcdc --- /dev/null +++ b/.changeset/fix-hydration-list-animations.md @@ -0,0 +1,7 @@ +--- +"@effex/dom": patch +--- + +Fix list animations being silently dropped on hydrated pages (SSG + SSR). `HydrationControlCtx` was reading `AnimationConfigCtx` once at Layer construction (at the hydration root, before any `each` had a chance to provide its config) and closing over the resulting `undefined`. Now reads the service lazily inside `addSlot`/`removeSlot`, mirroring the existing `ClientControlCtx` pattern, so each `each` with `animate` sees the config its parent provided. + +`@effex/router` will receive an automatic patch via the `updateInternalDependencies` Changesets rule because it depends on `@effex/dom` as a workspace dependency.