From 65c74ec4d737bf27e453c6446c8c4e30744449ba Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Thu, 16 Jul 2026 10:10:01 -0400 Subject: [PATCH] feat: emit enterFrom classes from SSR when intro is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSR/SSG previously rendered intro-animated content in its final visual state. Then hydration would apply the enterFrom classes and transition — but between the browser's first paint and hydration starting, users saw a flash of the final state. Now the SSR ControlCtx reads AnimationConfigCtx and, when intro: true is set, emits the enterFrom classes on each rendered slot element via the renderer's toggleClass primitive. The browser paints the pre-animation state directly, then hydration's runEnterAnimation removes enterFrom and transitions to the final state — same lifecycle as client-side, no flash. Applies to each (per-item) and single-slot controls (when, match, etc). Ordinary non-intro SSR output continues to render the final state directly. Co-Authored-By: Claude Opus 4.7 --- .changeset/ssr-emit-enter-from-for-intro.md | 7 ++ packages/dom/src/Control/SSRControlCtx.ts | 24 ++++++- packages/dom/src/Render/server/server.test.ts | 66 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .changeset/ssr-emit-enter-from-for-intro.md diff --git a/.changeset/ssr-emit-enter-from-for-intro.md b/.changeset/ssr-emit-enter-from-for-intro.md new file mode 100644 index 00000000..22f623f2 --- /dev/null +++ b/.changeset/ssr-emit-enter-from-for-intro.md @@ -0,0 +1,7 @@ +--- +"@effex/dom": patch +--- + +Fix the FOUC on `intro: true`. When a control (`each`, `when`, `match`, ...) opts into re-animating SSR/SSG-rendered content on hydration, the SSR renderer now emits the `enterFrom` classes on each rendered slot's element. That way the browser paints the pre-animation state (e.g. `opacity-0`) before hydration runs, and the enter animation transitions from there to the final state — instead of showing a flash of the final state, then jumping back to the pre-animation state, then animating in. + +Only applies when `intro: true` is set. Ordinary (non-intro) SSR output continues to render the final state directly, since those controls don't re-animate on hydration. diff --git a/packages/dom/src/Control/SSRControlCtx.ts b/packages/dom/src/Control/SSRControlCtx.ts index f1b080f4..727b34ab 100644 --- a/packages/dom/src/Control/SSRControlCtx.ts +++ b/packages/dom/src/Control/SSRControlCtx.ts @@ -3,7 +3,7 @@ * Renders once with hydration markers, no subscriptions or animations. */ -import { Effect, Layer, Scope } from "effect"; +import { Effect, Layer, Option, Scope } from "effect"; import { ControlCtx, @@ -13,7 +13,9 @@ import { type SlotEntry, } from "@effex/core"; +import type { AnimationOptions } from "../Animation/index.js"; import * as Element from "../Element/index.js"; +import { AnimationConfigCtx } from "./AnimationConfigCtx.js"; type DOMElement = HTMLElement | SVGElement; @@ -91,6 +93,26 @@ const createSSRControlCtx = (): IControlCtx => { yield* renderer.setAttribute(element, "data-effex-key", key); + // FOUC prevention: when the control opted into intro re-animation, + // emit the `enterFrom` classes on the SSR/SSG output so the browser + // paints the pre-animation state (e.g. `opacity-0`). Hydration then + // runs the full enter lifecycle which removes them and transitions + // to the final state. Without this, users see a flash of the final + // state before hydration starts animating. + const animConfigOpt = yield* Effect.serviceOption(AnimationConfigCtx); + const animConfig = Option.getOrUndefined(animConfigOpt); + if (animConfig?.intro) { + const animate = (animConfig.list ?? animConfig.single) as + | AnimationOptions + | undefined; + const enterFrom = animate?.enterFrom; + if (enterFrom) { + for (const cls of enterFrom.split(/\s+/).filter(Boolean)) { + yield* renderer.toggleClass(element, cls, true); + } + } + } + if (containerElement) { yield* renderer.appendChild(containerElement, element); } diff --git a/packages/dom/src/Render/server/server.test.ts b/packages/dom/src/Render/server/server.test.ts index 6c607501..01f7ac1d 100644 --- a/packages/dom/src/Render/server/server.test.ts +++ b/packages/dom/src/Render/server/server.test.ts @@ -288,4 +288,70 @@ describe("SSR", () => { expect(html).toContain("Loading..."); }); }); + + describe("intro FOUC prevention", () => { + it("emits enterFrom classes on each's SSR items when intro is set", async () => { + const App = () => + Effect.gen(function* () { + const items = yield* Signal.make(["a", "b"]); + return yield* each(items, { + key: (item) => item, + render: (item) => $.li({}, $.of(item)), + animate: { + enterFrom: "opacity-0 translate-y-2", + enter: "opacity-100 translate-y-0", + }, + intro: true, + }); + }); + + const html = await Effect.runPromise(renderToString(App())); + + // Both items should carry the enterFrom classes so the browser paints + // them hidden until hydration animates them in. + const opacityMatches = html.match(/opacity-0/g) ?? []; + const translateMatches = html.match(/translate-y-2/g) ?? []; + expect(opacityMatches).toHaveLength(2); + expect(translateMatches).toHaveLength(2); + }); + + it("does not emit enterFrom classes when intro is omitted", async () => { + const App = () => + Effect.gen(function* () { + const items = yield* Signal.make(["a", "b"]); + return yield* each(items, { + key: (item) => item, + render: (item) => $.li({}, $.of(item)), + animate: { + enterFrom: "opacity-0", + enter: "opacity-100", + }, + }); + }); + + const html = await Effect.runPromise(renderToString(App())); + + expect(html).not.toContain("opacity-0"); + }); + + it("emits enterFrom on when's SSR branch when intro is set", async () => { + const App = () => + Effect.gen(function* () { + const visible = yield* Signal.make(true); + return yield* when(visible, { + onTrue: () => $.div({ class: "hero" }, $.of("Hi")), + onFalse: () => $.div({}, $.of("")), + animate: { + enterFrom: "opacity-0", + enter: "opacity-100", + }, + intro: true, + }); + }); + + const html = await Effect.runPromise(renderToString(App())); + + expect(html).toContain("opacity-0"); + }); + }); });