Skip to content

feat: hydrate server-rendered markup - #89

Open
OnurGumus wants to merge 2 commits into
fable-hub:mainfrom
OnurGumus:feat/hydrate
Open

OnurGumus wants to merge 2 commits into
fable-hub:mainfrom
OnurGumus:feat/hydrate

Conversation

@OnurGumus

Copy link
Copy Markdown
Contributor

Builds on #88 (Fable.Lit.Server). Until that merges, this PR also shows its commit. Only the last commit is new here.

The browser half of server rendering: adopting the markup Fable.Lit.Server writes, for plain templates and for Elmish programs.

// a template
Hydrate.adopt (document.getElementById "items") (Views.table items)

// an Elmish program
Program.mkProgram init update view
|> Program.withLitHydrated "app"
|> Program.run

Hydrate.adopt

Adopts markup written by renderHydratable in place, through @lit-labs/ssr-client's hydrate: the elements stay, and lit attaches its bindings to them. After that, Lit.render on the same container updates it as usual.

What it adds over calling hydrate directly is the fallback. Without it, a refusal from lit is a broken page:

  • lit throws on a template mismatch, part way through wiring the container. adopt catches it, logs a warning to the console, clears the container and renders the view. The clearing matters: lit's render inserts beside what's already there, so rendering over the refused markup would show the page twice.
  • A container with no markers at all is refused without a throw. lit logs to the console and hydrate returns as if it had worked, leaving the container empty. adopt notices the missing root part and renders instead.

Different data isn't caught, by lit or here. I checked what happens: the server renders count 0 and the browser adopts with 5. The page shows 0, a render with 5 still shows 0 (lit believes 5 is already there), and only a change to 6 updates it. The docs say so.

Program.withLitHydrated / withLitHydratedOnElement

Mounts an Elmish program whose first render adopts instead of rendering. Every render after that is an ordinary one. The first model must be the one the server rendered from.

One dependency question

Hydrate.fs imports @lit-labs/ssr-client at module level, and Lit.Elmish refers to Hydrate. So a bundler has to resolve @lit-labs/ssr-client for every Lit.Elmish app, even one that never hydrates. Fable.Lit declares it in NpmDependencies, so it shows up as a dependency rather than as "Rollup failed to resolve import". With tree-shaking, nothing from it ends up in the bundle unless adopt is called.

If you'd rather Lit.Elmish didn't carry that, the Elmish functions could move to a separate file or package. It's a trade-off I'd leave to you.

Sample

sample/Hydrate: an ASP.NET server renders a page with Fable.Lit.Server, and the client adopts it with Hydrate.adopt. Both projects are in the solution, so CI builds them. I ran it as the README says: the button works, the table row and the button are the same DOM nodes after two clicks (patched, not rebuilt), a <script> in the data shows up escaped as text, and there's no fallback warning. Its vite config now resolves paths from its own folder; written relative to the current directory, the README's command failed from the repo root.

Tests

Three browser tests in DifferentialTest.fs, over markup from the .NET tests:

  • a refused adoption leaves one copy of the page, not two;
  • an empty container gets the view rendered, and its handler works;
  • an Elmish program over server markup keeps the server's button, handles a click, and patches the DOM rather than rebuilding it.

Each fails when the code it covers is taken out:

  • without the clearing, the first fails;
  • without the missing-marker check, the second;
  • with the first Elmish render made a plain render, the third.

dotnet build Fable.Lit.sln, dotnet test test/Lit.Server.Tests (23) and npm test (44) pass locally in CI's order. The "Server rendering" docs page now covers Hydrate.adopt and the Elmish functions.

🤖 Generated with Claude Code

OnurGumus and others added 2 commits October 19, 2026 09:18
A new package that renders Fable.Lit templates to HTML on .NET, with no
JavaScript runtime. The same html $"..." view compiles by Fable into lit
templates and by the .NET compiler into HTML: both declare the Lit
namespace, so a view file shared by the client and server projects needs no
conditional compilation.

render and toNode write plain HTML. renderHydratable and toHydratableNode
also write the markers lit's own SSR writes (<!--lit-part DIGEST--> and
<!--lit-node N-->), so @lit-labs/ssr-client's hydrate() adopts the markup
in place. That needs two of lit's internals ported exactly: the template
digest, and the way lit counts nodes to find attribute bindings.

What a shared view may use is html, Ev, Lit.classes, Lit.ofList and
Lit.nothing. Anything the server cannot honour raises
UnsupportedTemplateValue rather than writing an approximation. That
includes a Node put back into a template, which used to render as the text
"HtmlTypeProvider.Node", and a <template> element in markup lit will adopt:
lit counts it as one node and never looks inside, so every later binding
would be looked for on the wrong node without any error.

Output is built as HtmlTypeProvider Nodes, which supplies the escaping and
lets a rendered view drop into a page template's hole.

Tests: test/Lit.Server.Tests (xunit) checks the renderer and writes
test/server-rendered.json from views in test/Shared/SharedViews.fs. The
browser-side test/DifferentialTest.fs compiles the same views with Fable
and checks, in a browser, that the server's markup matches lit's, that its
node indices and digests equal lit's own, and that hydrate() adopts the
markup and wires its handlers. test.yml runs dotnet test before npm test.

The package has a CHANGELOG.md and is packed in the release job, so ShipIt
releases it with the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hydrate.adopt adopts markup written by Fable.Lit.Server's renderHydratable
in place, through @lit-labs/ssr-client's hydrate: the elements stay, and lit
attaches its bindings to them.

It renders instead when lit refuses, because a refusal is otherwise a broken
page. lit throws on a template mismatch part way through wiring the
container, so the error is caught, logged to the console, the container is
cleared (lit's render inserts beside what is there, so rendering over the
refused markup would show the page twice) and the view is rendered. A
container with no markers at all is refused without a throw, only a console
message from lit, and hydrate returns as if it had worked, leaving the
container empty; that is detected by the missing root part and handled the
same way.

Program.withLitHydrated and withLitHydratedOnElement mount an Elmish program
whose first render adopts instead of rendering. The model must be the one
the server rendered from.

Hydrate.fs imports @lit-labs/ssr-client at module level and Lit.Elmish
refers to it, so Fable.Lit declares the package as an npm dependency rather
than have a bundler fail to resolve it.

sample/Hydrate is a complete example: an ASP.NET server renders the page
with Fable.Lit.Server and the client adopts it. Its vite config now resolves
paths from its own folder, so the README's build command works from the
repository root.

Three browser tests, each failing when the code it covers is taken out:
the fallback leaves one copy of the page, an empty container is rendered
into, and an Elmish program keeps the server's button, handles a click and
patches rather than rebuilds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JordanMarr JordanMarr added the after-2.0 Considered after the 2.0 maintenance release (see #90) label Oct 19, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

after-2.0 Considered after the 2.0 maintenance release (see #90)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants