Skip to content

feat: add Fable.Lit.Server, render templates on .NET - #88

Open
OnurGumus wants to merge 1 commit into
fable-hub:mainfrom
OnurGumus:feat/lit-server
Open

OnurGumus wants to merge 1 commit into
fable-hub:mainfrom
OnurGumus:feat/lit-server

Conversation

@OnurGumus

Copy link
Copy Markdown
Contributor

Adds Fable.Lit.Server, a new package that renders Fable.Lit templates to HTML on .NET, with no JavaScript runtime involved.

The same html $"..." view compiles two ways: by Fable into lit templates in the browser, and by the .NET compiler into HTML on the server. Both packages declare the Lit namespace, so a view file shared by the client and server projects needs no conditional compilation:

// Shared/Views.fs, compiled by both projects
let row (item: Item) =
    html $"""<tr><td>{item.Name}</td><td>{item.Qty}</td></tr>"""

// server
let markup = renderHydratable (Views.row item)

// browser, with hydrate from @lit-labs/ssr-client
hydrate (Views.row item) container

This is the first of a few PRs. Later ones would add a client-side helper that adopts server markup and falls back to rendering, Elmish programs that start from server markup, declarative shadow roots, and server-rendered LitElements. They're all built and tested in my fork (OnurGumus/Fable.Lit), and a demo using them runs at https://litdemo.novian.works. I'm sending them in pieces so each one can be reviewed on its own. This one is server-side only, plus the tests that hold it to lit's behaviour.

What it does

  • render / toNode: plain HTML, which the browser renders over.
  • renderHydratable / toHydratableNode: also write the markers lit's own SSR writes (<!--lit-part DIGEST-->, <!--lit-node N-->), so lit's hydrate() adopts the markup in place: the elements stay, and lit attaches its bindings to them. That needs two of lit's internals ported exactly: the template digest, and the way lit counts nodes to find attribute bindings.
  • Values are escaped. @event and .property bindings have no HTML form and are left out. ?bool attributes are written or left out.
  • What a shared view may use: html, Ev, Lit.classes, Lit.ofList, Lit.nothing. Anything else the server can't honour raises UnsupportedTemplateValue instead of writing an approximation. Two mistakes that would otherwise fail silently are refused:
    • a Node put back into a template, which would render as the text HtmlTypeProvider.Node;
    • a <template> element in markup lit will adopt. lit counts it as one node and never looks inside, so every binding after it is looked for on the wrong node, without any error.

Why HtmlTypeProvider

Output is built as HtmlTypeProvider Nodes. That supplies the escaping, and lets a rendered view drop into a page template's hole as a typed value rather than a string. Disclosure: it's my package. The dependency is small (FSharp.Core only), but if you'd rather Fable.Lit.Server had no third-party dependency, it could be replaced with a string builder and a few escaping functions.

Tests

The renderer is only worth anything if it agrees with lit, so most of the testing is a comparison against lit itself:

  • test/Lit.Server.Tests (xunit, 23 tests): the renderer's own cases, including the refusals. It also writes test/server-rendered.json from the views in test/Shared/SharedViews.fs.

  • test/DifferentialTest.fs (browser, 4 tests): compiles the same SharedViews.fs with Fable and checks, in Chrome, that:

    • the server's markup and lit's render parse to the same DOM;
    • the server's node indices equal lit's own (computed with lit's _getTemplateHtml);
    • the server's digest equals digestForTemplateResult from @lit-labs/ssr-client;
    • hydrate() adopts the server's markup without replacing elements, and its click handler fires.

    A wrong digest passes the .NET tests and fails two of these; I checked by breaking it on purpose.

test.yml gets a dotnet test step before npm test, because the browser test reads the JSON the .NET tests write. Running npm test alone in a fresh checkout fails with missing /test/server-rendered.json rather than passing quietly.

@lit-labs/ssr-client is added as a dev dependency, for the tests only.

Other changes

  • Fable.Lit.sln: the two new projects.
  • Release: src/Lit.Server/CHANGELOG.md with ShipIt front matter, and a dotnet pack line in the release job. With no force_version, I expect ShipIt to start it at 0.1.0. If you'd rather it start at 2.0.0 with the others, that's a one-line change.
  • Docs: a "Server rendering" page in docsrc/docs/server-rendering.md.
  • Targets net10.0, because HtmlTypeProvider does.

dotnet build Fable.Lit.sln, dotnet test test/Lit.Server.Tests (23 passed) and npm test (41 passed) all pass locally, in the order CI runs them. dotnet pack produces Fable.Lit.Server with upstream's metadata.

🤖 Generated with Claude Code

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>
@JordanMarr JordanMarr added the after-2.0 Considered after the 2.0 maintenance release (see #90) label Oct 19, 2026
OnurGumus added a commit to OnurGumus/Fable.Lit that referenced this pull request Oct 28, 2026
Render.fs still opened by saying it does not hydrate and emits none of
lit's markers, which stopped being true when renderHydratable arrived, and
toHydratableNode pointed at a Lit.Server.md that has never existed. Both
were found while porting the renderer upstream (fable-hub#88).

The hydration sample's vite config gave its paths relative to the current
directory, so the README's command, run from the repository root as it
says, could not find Client/build/App.js. CI never built the bundle, so
nothing noticed. Paths now resolve from the config file's own folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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