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. 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; }), );