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
7 changes: 7 additions & 0 deletions .changeset/fix-hydration-list-animations.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 37 additions & 20 deletions packages/dom/src/Control/HydrationControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DOMElement> => {
const slots = new Map<string, DOMSlotEntry>();
Expand All @@ -63,10 +66,7 @@ const createClientLikeControlCtx = (
const ctx: IControlCtx<DOMElement> = {
// 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,

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<typeof runEnterAnimation>[1]
| undefined;
Expand Down Expand Up @@ -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<typeof runExitAnimation>[1]
| undefined;
Expand Down Expand Up @@ -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<DOMElement> => {
const slots = new Map<string, DOMSlotEntry>();
// Tracks whether the initial hydration pass is complete.
Expand All @@ -260,10 +272,7 @@ const createHydrationControlCtx = (

const ctx: IControlCtx<DOMElement> = {
// After hydration completes, nested control functions start in client mode
fork: () =>
Effect.succeed(
createClientLikeControlCtx(animationConfig, hydrationComplete),
),
fork: () => Effect.succeed(createClientLikeControlCtx(hydrationComplete)),

defaultContainer,

Expand Down Expand Up @@ -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(
Expand All @@ -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<typeof runEnterAnimation>[1]
| undefined;
Expand All @@ -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<typeof runExitAnimation>[1]
| undefined;
Expand Down Expand Up @@ -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,
Expand All @@ -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<unknown>;
return createHydrationControlCtx(containerElement) as IControlCtx<unknown>;
}),
);
Loading