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/ssr-emit-enter-from-for-intro.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 23 additions & 1 deletion packages/dom/src/Control/SSRControlCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -91,6 +93,26 @@ const createSSRControlCtx = (): IControlCtx<DOMElement> => {

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);
}
Expand Down
66 changes: 66 additions & 0 deletions packages/dom/src/Render/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
Loading