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
14 changes: 14 additions & 0 deletions .changeset/fix-create-effex-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"create-effex": patch
---

Fix broken client entry templates.

- **SSG template** (`client.ts`): was calling `hydrate(App(), root)` with no
layers, even though the App uses `Outlet` and `Link` which require
`NavigationContext`. Hydration bailed as soon as either was resolved.
Now passes `Platform.makeClientLayer(router)` via `options.layers`.
- **SPA template** (`main.ts`): passed `{ layers: ... }` as a third arg to
`mount`, which doesn't accept options — the arg was silently dropped
and the returned `Effect` was never run, so nothing mounted at all. Now
uses `runApp(mount(...), { layer: ... })`, the documented pattern.
16 changes: 16 additions & 0 deletions .changeset/fix-hydrate-outer-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@effex/dom": patch
---

fix(hydrate): build `options.layers` in the outer program scope

Previously `hydrate` used `Effect.provide(element, elementLayers)`, which
internally wraps the effect in a fresh scope that closes as soon as the
effect completes. Since element functions return synchronously after
building the DOM, this tore down every scoped resource — Navigation's
popstate listener, `SubscriptionRef` PubSub subscribers, cache entries —
before the user could interact. Browser back/forward, reactive updates,
and anything relying on `Effect.addFinalizer` silently no-op'd.

Fixed by building the merged layers as a `Context` in hydrate's outer
program scope (kept alive by `Effect.never`) before providing.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ docs/api

# Documentation drafts
docs-notes
dev_notes

*.pem
examples/chat
Expand Down
11 changes: 3 additions & 8 deletions apps/docs/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import "./styles.css";

import { Effect } from "effect";

import { hydrate } from "@effex/dom/hydrate";
import { Navigation } from "@effex/router";

import { DocLayout } from "./layout.js";
import { router } from "./routes.js";

const navLayer = Navigation.makeLayer(router);

hydrate(
Effect.provide(DocLayout(), navLayer),
document.getElementById("root")!,
);
hydrate(DocLayout() as never, document.getElementById("root")!, {
layers: Navigation.makeLayer(router),
});
6 changes: 3 additions & 3 deletions packages/create-effex/templates/spa/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mount, type Element } from "@effex/dom";
import { mount, runApp, type Element } from "@effex/dom";
import { Navigation } from "@effex/router";

import { App } from "./App.js";
Expand All @@ -9,6 +9,6 @@ if (!container) {
throw new Error("Root element not found");
}

mount(App() as unknown as Element.Element<HTMLElement>, container, {
layers: Navigation.makeLayer(router),
runApp(mount(App() as unknown as Element.Element<HTMLElement>, container), {
layer: Navigation.makeLayer(router),
});
5 changes: 5 additions & 0 deletions packages/create-effex/templates/ssg/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { Element } from "@effex/dom";
import { hydrate } from "@effex/dom/hydrate";
import { Platform } from "@effex/platform";

import { App } from "./App.js";
import { router } from "./routes.js";

hydrate(
App() as unknown as Element.Element<HTMLElement>,
document.getElementById("root")!,
{
layers: Platform.makeClientLayer(router),
},
);
48 changes: 47 additions & 1 deletion packages/dom/src/Render/hydrate/hydrate.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect } from "effect";
import { Context, Effect, Layer } from "effect";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { Readable, Signal } from "@effex/core";
Expand Down Expand Up @@ -626,4 +626,50 @@ describe("Hydration", () => {
expect(snapshots[1]).toContain("-translate-y-[100px]");
});
});

describe("options.layers lifetime", () => {
// Regression: `Effect.provide(element, scopedLayer)` internally does
// `scopedWith(scope => ...)`, which scopes the layer to the effect's
// lifetime — since element functions return synchronously after
// building the DOM, the layer's finalizers used to fire immediately
// after hydrate. That tore down Navigation's popstate listener, the
// SubscriptionRef PubSub, and any other scoped resource before the
// user could interact. Hydrate must build options.layers in its OUTER
// program scope (kept alive by Effect.never) so finalizers only run
// on page unload.
it("does not run scoped-layer finalizers on the initial render", async () => {
class Marker extends Context.Tag("test/Marker")<
Marker,
{ readonly value: string }
>() {}

const finalizer = vi.fn();
const markerLayer = Layer.scoped(
Marker,
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.sync(() => finalizer()));
return { value: "alive" };
}),
);

// Element that reads the service — proves hydration actually built
// the layer (rather than the scoped layer just being unused).
const element = Effect.gen(function* () {
const marker = yield* Marker;
return yield* $.div({ class: "marker" }, $.of(marker.value));
});

// Pre-populate matching SSR HTML so hydration doesn't warn.
container.innerHTML = `<div class="marker">alive</div>`;

await hydrate(element as never, container, { layers: markerLayer });
await new Promise((r) => setTimeout(r, 10));

expect(container.querySelector(".marker")?.textContent).toBe("alive");
// The scope is kept alive by Effect.never inside hydrate — the
// finalizer must NOT have fired yet. Before the fix it fired
// synchronously right after the element function returned.
expect(finalizer).not.toHaveBeenCalled();
});
});
});
10 changes: 9 additions & 1 deletion packages/dom/src/Render/hydrate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,15 @@ export const hydrate = <A extends HTMLElement | SVGElement>(
elementLayers = Layer.merge(elementLayers, options.layers);
}

yield* Effect.provide(element, elementLayers);
// Build elementLayers in the OUTER program scope (kept alive by
// Effect.never below), not in a per-element scope. Effect.provide
// with a scoped Layer wraps the effect in a fresh scope that closes
// as soon as the effect completes — since the element function
// returns synchronously after building the DOM, that would tear down
// Navigation's popstate listener, the SubscriptionRef PubSub, and
// any other scoped resources before the user can interact.
const context = yield* Layer.build(elementLayers);
yield* Effect.provide(element, context);

// Keep the scope alive - subscriptions run in forked fibers that need to persist
// Wait forever (until page unload) so subscription fibers stay alive
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"@effex/dom/server": ["packages/dom/src/Render/server/index.ts"],
"@effex/dom/hydrate": ["packages/dom/src/Render/hydrate/index.ts"],
"@effex/router": ["packages/router/src/index.ts"],
"@effex/form": ["packages/form/src/index.ts"]
"@effex/form": ["packages/form/src/index.ts"],
"@effex/vite-plugin": ["packages/vite-plugin/src/index.ts"]
}
},
"include": ["packages/*/src", "src"]
Expand Down
Loading