diff --git a/.changeset/type-hydrate-layers-generic.md b/.changeset/type-hydrate-layers-generic.md new file mode 100644 index 00000000..a4b2427a --- /dev/null +++ b/.changeset/type-hydrate-layers-generic.md @@ -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`, 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 the layer type. The layer's provided services flow through `Layer.Layer.Success` 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. 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..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. @@ -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` allows omitting the + * field for elements that need no user services. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly layers?: Layer.Layer; + 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 = ( +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 + | 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, @@ -99,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 @@ -129,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";