Repository navigation
Conversation
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>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The browser half of server rendering: adopting the markup Fable.Lit.Server writes, for plain templates and for Elmish programs.
Hydrate.adoptAdopts markup written by
renderHydratablein place, through@lit-labs/ssr-client'shydrate: the elements stay, and lit attaches its bindings to them. After that,Lit.renderon the same container updates it as usual.What it adds over calling
hydratedirectly is the fallback. Without it, a refusal from lit is a broken page:adoptcatches it, logs a warning to the console, clears the container and renders the view. The clearing matters: lit'srenderinserts beside what's already there, so rendering over the refused markup would show the page twice.hydratereturns as if it had worked, leaving the container empty.adoptnotices 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/withLitHydratedOnElementMounts 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.fsimports@lit-labs/ssr-clientat module level, and Lit.Elmish refers toHydrate. So a bundler has to resolve@lit-labs/ssr-clientfor every Lit.Elmish app, even one that never hydrates. Fable.Lit declares it inNpmDependencies, 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 unlessadoptis 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 withHydrate.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:Each fails when the code it covers is taken out:
dotnet build Fable.Lit.sln,dotnet test test/Lit.Server.Tests(23) andnpm test(44) pass locally in CI's order. The "Server rendering" docs page now coversHydrate.adoptand the Elmish functions.🤖 Generated with Claude Code