diff --git a/.changeset/fix-create-effex-templates.md b/.changeset/fix-create-effex-templates.md new file mode 100644 index 00000000..3688267d --- /dev/null +++ b/.changeset/fix-create-effex-templates.md @@ -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. diff --git a/.changeset/fix-hydrate-outer-scope.md b/.changeset/fix-hydrate-outer-scope.md new file mode 100644 index 00000000..74dabdfd --- /dev/null +++ b/.changeset/fix-hydrate-outer-scope.md @@ -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. diff --git a/.gitignore b/.gitignore index f6727386..56a800e3 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ docs/api # Documentation drafts docs-notes +dev_notes *.pem examples/chat diff --git a/apps/docs/src/client.ts b/apps/docs/src/client.ts index f95353f1..b709037c 100644 --- a/apps/docs/src/client.ts +++ b/apps/docs/src/client.ts @@ -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), +}); diff --git a/packages/create-effex/templates/spa/src/main.ts b/packages/create-effex/templates/spa/src/main.ts index c7b13053..3c5ef8fb 100644 --- a/packages/create-effex/templates/spa/src/main.ts +++ b/packages/create-effex/templates/spa/src/main.ts @@ -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"; @@ -9,6 +9,6 @@ if (!container) { throw new Error("Root element not found"); } -mount(App() as unknown as Element.Element, container, { - layers: Navigation.makeLayer(router), +runApp(mount(App() as unknown as Element.Element, container), { + layer: Navigation.makeLayer(router), }); diff --git a/packages/create-effex/templates/ssg/src/client.ts b/packages/create-effex/templates/ssg/src/client.ts index c45d5098..77684141 100644 --- a/packages/create-effex/templates/ssg/src/client.ts +++ b/packages/create-effex/templates/ssg/src/client.ts @@ -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, document.getElementById("root")!, + { + layers: Platform.makeClientLayer(router), + }, ); diff --git a/packages/dom/src/Render/hydrate/hydrate.test.ts b/packages/dom/src/Render/hydrate/hydrate.test.ts index a920a9dd..72788a8f 100644 --- a/packages/dom/src/Render/hydrate/hydrate.test.ts +++ b/packages/dom/src/Render/hydrate/hydrate.test.ts @@ -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"; @@ -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 = `
alive
`; + + 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(); + }); + }); }); diff --git a/packages/dom/src/Render/hydrate/index.ts b/packages/dom/src/Render/hydrate/index.ts index 1370f5b8..0989469f 100644 --- a/packages/dom/src/Render/hydrate/index.ts +++ b/packages/dom/src/Render/hydrate/index.ts @@ -103,7 +103,15 @@ export const hydrate = ( 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 diff --git a/tsconfig.json b/tsconfig.json index 3baa8c36..043802b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"]