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
18 changes: 18 additions & 0 deletions .changeset/type-hydrate-layers-generic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@effex/dom": patch
"create-effex": patch
---

fix(hydrate): make element requirements provably match `options.layers`

`hydrate`'s element parameter was locked to `Element<A, never, RendererContext | ControlCtx | SuspenseBoundaryCtx>`, so any element that also required a user-provided service (`NavigationContext`, `RouteDataProvider`, etc.) had to be cast — `as never` or `as unknown as Element<HTMLElement>` — even though `options.layers` was providing exactly those services at runtime.

`HydrateOptions` and `hydrate` are now generic over the layer type. The layer's provided services flow through `Layer.Layer.Success<L>` into the element's allowed `R`, so:

- Passing an element that needs a service without providing a layer for it is a type error.
- Providing a layer that only covers some of the element's requirements is a type error.
- `hydrate(App(), root, { layers: Platform.makeClientLayer(router) })` typechecks with no casts when the layer fully covers what `App()` needs.

`NoInfer` on the extracted layer type keeps TypeScript from inferring `L` from the element's requirements — inference flows one way, from `options.layers` into the element, so forgetting a service is a compile-time error rather than a silent runtime failure.

Removed the now-obsolete casts from `create-effex`'s SSG and SSR templates, `apps/docs`, and the hydrate regression test.
2 changes: 1 addition & 1 deletion apps/docs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ import { Navigation } from "@effex/router";
import { DocLayout } from "./layout.js";
import { router } from "./routes.js";

hydrate(DocLayout() as never, document.getElementById("root")!, {
hydrate(DocLayout(), document.getElementById("root")!, {
layers: Navigation.makeLayer(router),
});
11 changes: 3 additions & 8 deletions packages/create-effex/templates/ssg/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
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),
},
);
hydrate(App(), document.getElementById("root")!, {
layers: Platform.makeClientLayer(router),
});
11 changes: 3 additions & 8 deletions packages/create-effex/templates/ssr/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
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),
},
);
hydrate(App(), document.getElementById("root")!, {
layers: Platform.makeClientLayer(router),
});
2 changes: 1 addition & 1 deletion packages/dom/src/Render/hydrate/hydrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ describe("Hydration", () => {
// 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 hydrate(element, container, { layers: markerLayer });
await new Promise((r) => setTimeout(r, 10));

expect(container.querySelector(".marker")?.textContent).toBe("alive");
Expand Down
60 changes: 45 additions & 15 deletions packages/dom/src/Render/hydrate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ import { HydrationSuspenseBoundaryCtx } from "../../SuspenseBoundaryCtx/Hydratio
import { makeHydrationContext } from "./HydrationContext.js";
import { createHydrationRenderer } from "./HydrationRenderer.js";

export interface HydrateOptions {
export interface HydrateOptions<
L extends Layer.Layer<never, never, never> = Layer.Layer<never, never, never>,
> {
/**
* Called when a hydration mismatch is detected.
* In development, you might want to log warnings.
Expand All @@ -41,40 +43,65 @@ export interface HydrateOptions {
readonly onMismatch?: (message: string, node: Node | null) => void;

/**
* Additional layers to provide to the element during hydration.
* Use this to provide services like LoaderContext that the element requires.
* Additional layers to provide to the element during hydration. Whatever
* services these layers produce show up in the element's `R` parameter,
* so you can pass e.g. `Platform.makeClientLayer(router)` and have
* `App()` require `NavigationContext | RouteDataProvider` without any
* casts. The default `Layer<never, never, never>` allows omitting the
* field for elements that need no user services.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly layers?: Layer.Layer<any, never, never>;
readonly layers?: L;
}

/**
* Hydrate server-rendered HTML by attaching to existing DOM
* and setting up reactive bindings.
*
* `R` is inferred from `options.layers` — whatever services the layers
* provide are what the element is allowed to require on top of the
* framework-provided contexts. Pass the layers via `options.layers` rather
* than `Effect.provide(App(), layer)` before calling hydrate — the latter
* scopes the layer to the element's short-lived render.
*
* @param element - The Element to hydrate (same component tree as SSR)
* @param container - The DOM container with server-rendered HTML
* @param options - Hydration options
* @param options - Hydration options; `layers` provides `R` to the element
* @returns Promise that resolves when hydration is complete
*
* @example
* ```ts
* import { hydrate } from "@effex/dom/hydrate";
* import { Platform } from "@effex/platform";
* import { App } from "./App";
* import { router } from "./routes";
*
* hydrate(App(), document.getElementById("root")!);
* hydrate(App(), document.getElementById("root")!, {
* layers: Platform.makeClientLayer(router),
* });
* ```
*/
export const hydrate = <A extends HTMLElement | SVGElement>(
export function hydrate<
A extends HTMLElement | SVGElement,
L extends Layer.Layer<never, never, never> = Layer.Layer<never, never, never>,
>(
// `NoInfer` on the extracted layer type keeps TS from inferring L from
// the element's requirements. L is inferred solely from `options.layers`,
// then the element must fit `Framework | Layer.Success<L>` — passing an
// element that requires a service you forgot to provide via layers is a
// type error, not a silent runtime failure.
element: Element.Element<
A,
never,
RendererContext | ControlCtx | SuspenseBoundaryCtx
| RendererContext
| ControlCtx
| SuspenseBoundaryCtx
| NoInfer<Layer.Layer.Success<L>>
>,
container: HTMLElement,
options: HydrateOptions = {},
): Promise<void> => {
const renderer = createHydrationRenderer(container, options);
options?: HydrateOptions<L>,
): Promise<void> {
const opts = options ?? {};
const renderer = createHydrationRenderer(container, opts);

const HydrationRendererLayer = Layer.succeed(
RendererContext,
Expand All @@ -99,8 +126,11 @@ export const hydrate = <A extends HTMLElement | SVGElement>(
let elementLayers = Layer.merge(hydrationContextLayer, ControlLayer);
elementLayers = Layer.merge(elementLayers, suspenseLayer);
elementLayers = Layer.merge(elementLayers, ClientAsyncCacheLayer);
if (options.layers) {
elementLayers = Layer.merge(elementLayers, options.layers);
if (opts.layers) {
elementLayers = Layer.merge(
elementLayers,
opts.layers as Layer.Layer<never, never, never>,
);
}

// Build elementLayers in the OUTER program scope (kept alive by
Expand Down Expand Up @@ -129,7 +159,7 @@ export const hydrate = <A extends HTMLElement | SVGElement>(

// Return immediately - hydration setup is synchronous, subscriptions are async
return Promise.resolve();
};
}

export type { HydrationRenderer } from "./HydrationRenderer.js";
export { createHydrationRenderer } from "./HydrationRenderer.js";
Expand Down
Loading