From 2b575488b46e3e72159e559ea4117a37dde86dde Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Tue, 4 Aug 2026 11:14:45 -0400 Subject: [PATCH 1/2] fix(hydrate): make R inferable from options.layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hydrate's element parameter was locked to `Element`, which meant any element that also required a user-provided service (NavigationContext, RouteDataProvider, etc.) had to be cast — `as never` or `as unknown as Element` — even though `options.layers` was providing exactly those services at runtime. HydrateOptions and hydrate are now generic over `R`. Whatever services `options.layers` provides show up as the element's `R`, so the intended pattern typechecks with no casts: hydrate(App(), root, { layers: Platform.makeClientLayer(router) }); Removed the now-obsolete casts from: - packages/create-effex SSG + SSR templates - apps/docs/src/client.ts - packages/dom's own hydrate regression test Co-Authored-By: Claude Opus 4.7 --- .changeset/type-hydrate-layers-generic.md | 16 ++++++++++ apps/docs/src/client.ts | 2 +- .../create-effex/templates/ssg/src/client.ts | 11 ++----- .../create-effex/templates/ssr/src/client.ts | 11 ++----- .../dom/src/Render/hydrate/hydrate.test.ts | 2 +- packages/dom/src/Render/hydrate/index.ts | 31 +++++++++++++------ 6 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 .changeset/type-hydrate-layers-generic.md diff --git a/.changeset/type-hydrate-layers-generic.md b/.changeset/type-hydrate-layers-generic.md new file mode 100644 index 00000000..073847c8 --- /dev/null +++ b/.changeset/type-hydrate-layers-generic.md @@ -0,0 +1,16 @@ +--- +"@effex/dom": patch +"create-effex": patch +--- + +fix(hydrate): make `R` inferable from `options.layers` + +`hydrate`'s element parameter was locked to `Element`, which meant any element that also required a user-provided service (`NavigationContext`, `RouteDataProvider`, etc.) had to be cast — `as never` or `as unknown as Element` — even though `options.layers` was providing exactly those services at runtime. + +`HydrateOptions` and `hydrate` are now generic over `R`. Whatever services `options.layers` provides show up as the element's `R`, so the intended pattern typechecks with no casts: + +```ts +hydrate(App(), root, { layers: Platform.makeClientLayer(router) }); +``` + +Removed the now-obsolete casts from `create-effex`'s SSG and SSR templates and from `apps/docs`. diff --git a/apps/docs/src/client.ts b/apps/docs/src/client.ts index b709037c..deecda32 100644 --- a/apps/docs/src/client.ts +++ b/apps/docs/src/client.ts @@ -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), }); diff --git a/packages/create-effex/templates/ssg/src/client.ts b/packages/create-effex/templates/ssg/src/client.ts index 77684141..1a816ed9 100644 --- a/packages/create-effex/templates/ssg/src/client.ts +++ b/packages/create-effex/templates/ssg/src/client.ts @@ -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, - document.getElementById("root")!, - { - layers: Platform.makeClientLayer(router), - }, -); +hydrate(App(), document.getElementById("root")!, { + layers: Platform.makeClientLayer(router), +}); diff --git a/packages/create-effex/templates/ssr/src/client.ts b/packages/create-effex/templates/ssr/src/client.ts index 77684141..1a816ed9 100644 --- a/packages/create-effex/templates/ssr/src/client.ts +++ b/packages/create-effex/templates/ssr/src/client.ts @@ -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, - document.getElementById("root")!, - { - layers: Platform.makeClientLayer(router), - }, -); +hydrate(App(), 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 72788a8f..b5370197 100644 --- a/packages/dom/src/Render/hydrate/hydrate.test.ts +++ b/packages/dom/src/Render/hydrate/hydrate.test.ts @@ -662,7 +662,7 @@ describe("Hydration", () => { // Pre-populate matching SSR HTML so hydration doesn't warn. container.innerHTML = `
alive
`; - 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"); diff --git a/packages/dom/src/Render/hydrate/index.ts b/packages/dom/src/Render/hydrate/index.ts index 0989469f..29b15eec 100644 --- a/packages/dom/src/Render/hydrate/index.ts +++ b/packages/dom/src/Render/hydrate/index.ts @@ -32,7 +32,7 @@ import { HydrationSuspenseBoundaryCtx } from "../../SuspenseBoundaryCtx/Hydratio import { makeHydrationContext } from "./HydrationContext.js"; import { createHydrationRenderer } from "./HydrationRenderer.js"; -export interface HydrateOptions { +export interface HydrateOptions { /** * Called when a hydration mismatch is detected. * In development, you might want to log warnings. @@ -41,38 +41,49 @@ 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. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly layers?: Layer.Layer; + readonly layers?: Layer.Layer; } /** * 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 = ( +export const hydrate = ( element: Element.Element< A, never, - RendererContext | ControlCtx | SuspenseBoundaryCtx + RendererContext | ControlCtx | SuspenseBoundaryCtx | R >, container: HTMLElement, - options: HydrateOptions = {}, + options: HydrateOptions = {}, ): Promise => { const renderer = createHydrationRenderer(container, options); From bb5198474eff6992a9b86e8d4932c501cf899a1a Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Tue, 4 Aug 2026 11:33:45 -0400 Subject: [PATCH 2/2] strengthen: element requirements must be provably covered by options.layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up: the earlier commit made `R` generic but relied on TS inferring `R` from both element and options — so passing `hydrate(NeedsFoo(), root)` without any layers still compiled (R widened to Foo, but options was undefined). No compile-time protection against forgetting a service. Refactored so `L` is inferred solely from `options.layers`, and the element's `R` position is `NoInfer>`. Inference flows one way — layers in, element requirements out. Forgetting to provide a service the element uses is now a type error. Matrix of behaviors, all verified: - element needs Foo, no options → ERROR - element needs Foo, options={} → ERROR - element needs Foo, wrong layer provided → ERROR - element needs Foo+Bar, only Foo layer → ERROR - element needs Foo, correct layer → OK - element needs Foo+Bar, merged Foo|Bar layer → OK - element needs nothing extra, no options → OK (2-arg call still works) Co-Authored-By: Claude Opus 4.7 --- .changeset/type-hydrate-layers-generic.md | 16 ++++---- packages/dom/src/Render/hydrate/index.ts | 45 ++++++++++++++++------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/.changeset/type-hydrate-layers-generic.md b/.changeset/type-hydrate-layers-generic.md index 073847c8..a4b2427a 100644 --- a/.changeset/type-hydrate-layers-generic.md +++ b/.changeset/type-hydrate-layers-generic.md @@ -3,14 +3,16 @@ "create-effex": patch --- -fix(hydrate): make `R` inferable from `options.layers` +fix(hydrate): make element requirements provably match `options.layers` -`hydrate`'s element parameter was locked to `Element`, which meant any element that also required a user-provided service (`NavigationContext`, `RouteDataProvider`, etc.) had to be cast — `as never` or `as unknown as Element` — even though `options.layers` was providing exactly those services at runtime. +`hydrate`'s element parameter was locked to `Element`, so any element that also required a user-provided service (`NavigationContext`, `RouteDataProvider`, etc.) had to be cast — `as never` or `as unknown as Element` — even though `options.layers` was providing exactly those services at runtime. -`HydrateOptions` and `hydrate` are now generic over `R`. Whatever services `options.layers` provides show up as the element's `R`, so the intended pattern typechecks with no casts: +`HydrateOptions` and `hydrate` are now generic over the layer type. The layer's provided services flow through `Layer.Layer.Success` into the element's allowed `R`, so: -```ts -hydrate(App(), root, { layers: Platform.makeClientLayer(router) }); -``` +- 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. -Removed the now-obsolete casts from `create-effex`'s SSG and SSR templates and from `apps/docs`. +`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. diff --git a/packages/dom/src/Render/hydrate/index.ts b/packages/dom/src/Render/hydrate/index.ts index 29b15eec..7691edf0 100644 --- a/packages/dom/src/Render/hydrate/index.ts +++ b/packages/dom/src/Render/hydrate/index.ts @@ -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 = Layer.Layer, +> { /** * Called when a hydration mismatch is detected. * In development, you might want to log warnings. @@ -42,11 +44,13 @@ export interface HydrateOptions { /** * 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. + * 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` allows omitting the + * field for elements that need no user services. */ - readonly layers?: Layer.Layer; + readonly layers?: L; } /** @@ -76,16 +80,28 @@ export interface HydrateOptions { * }); * ``` */ -export const hydrate = ( +export function hydrate< + A extends HTMLElement | SVGElement, + L extends Layer.Layer = Layer.Layer, +>( + // `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` — 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 | R + | RendererContext + | ControlCtx + | SuspenseBoundaryCtx + | NoInfer> >, container: HTMLElement, - options: HydrateOptions = {}, -): Promise => { - const renderer = createHydrationRenderer(container, options); + options?: HydrateOptions, +): Promise { + const opts = options ?? {}; + const renderer = createHydrationRenderer(container, opts); const HydrationRendererLayer = Layer.succeed( RendererContext, @@ -110,8 +126,11 @@ export const hydrate = ( 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, + ); } // Build elementLayers in the OUTER program scope (kept alive by @@ -140,7 +159,7 @@ export const hydrate = ( // Return immediately - hydration setup is synchronous, subscriptions are async return Promise.resolve(); -}; +} export type { HydrationRenderer } from "./HydrationRenderer.js"; export { createHydrationRenderer } from "./HydrationRenderer.js";