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 0000000..22f623f --- /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 f1b080f..727b34a 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 6c60750..01f7ac1 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"); + }); + }); });