Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,42 @@ These make the declarative syntax work; check them before changing signatures:
- Three render paths on every `HtmlItem`: `ToString(indent)`, `AppendTo(ref StringBuilder)`, and `WriteTo(ref TextWriter)` (the streaming path used by the web integration). New node types must implement all three. For `HtmlNode` the first two funnel into `WriteTo` (one buffer for the whole subtree — `ToString` builds a `StringBuilder` then calls `AppendTo`, which wraps a `StringWriter`); keep the three outputs byte-identical (pinned by `GeneralRenderingTests.WriteTo_Should_ProduceTheSameOutputAsToString`). Indentation is emitted via the non-allocating `Indentation` helper, not `new string(' ', n)`.
- Lowest-allocation render: `node.WriteTo(IBufferWriter<byte>)` (extension in `HtmlNodeExtensions`) encodes UTF-8 straight into the buffer via `Utf8HtmlWriter`, streaming in pooled chunks — render allocation is O(1) regardless of page size (no large string, no LOH/Gen2). `CC.CSX.Web.HtmlResult` uses this against the response `PipeWriter`. The browser path is different: `BrowserApp.Refresh()` calls `view().ToString()` and hands the HTML string to the JS `morph`, so the browser benefits from the `ToString` fast path (not the `IBufferWriter` path).
- `HtmlNode.Attributes`/`Children` backing lists are allocated lazily (leaf nodes allocate neither). Render and traversal read the internal `RawAttributes`/`RawChildren` (nullable) so they never force a list allocation; the public getters allocate on demand for mutation.
- Fragment caching: `Raw(string)` emits pre-rendered HTML verbatim (caching its UTF-8 bytes for the `Utf8HtmlWriter` path); `node.Cache()` wraps a static subtree so it renders once and reuses the bytes (hold the result in a `static readonly`); `FragmentCache.GetOrAdd(key, factory)` is the keyed variant. Gated by the `FragmentCache.Enabled` flag, which **defaults to true in Release, false in Debug** (`#if DEBUG`) so dev sees fresh output. Caching only takes effect when `RenderOptions.Indent == 0` (indented output depends on nesting depth); otherwise the source renders live. Only cache genuinely-static fragments — a cached fragment holding per-request data serves stale content. Tests mutate this global flag + `RenderOptions`, so `CC.CSX.Tests` disables xUnit parallelization (`AssemblyInfo.cs`).
- **RenderOptions** is global static state (`Indent`, `TextNodeOnNewLine`) that affects formatting everywhere — including test expectations.
- `CC.CSX.Web.HtmlResult` streams via `WriteTo` to the response `BodyWriter` with `Content-Type: text/html`; `Render(...)` extension methods are in `CC.CSX.Web/HtmlNodeExtensions.cs`.

### Usage pattern (what user code looks like)

**Preferred styling: author CSS in a `.css` file and use the generated typed classes** — not raw
class strings. Register the file as `<AdditionalFiles Include="styles/*.css" />` and reference the
`CC.CSX.Css.Generator` (NuGet consumers get it with the `CC.CSX.Css` package; in-repo add the
analyzer ProjectReference). The generator emits `Css.<FileName>.<className>` constants plus a
`Bundle`, so classes are compile-checked, refactor-safe, and discoverable. Serve the styles with
`CssImports.Inline(...)`/`StyleSheet(...)`. Use `CssProperties` for typed inline styles and
`CC.CSX.Css.Tailwind` (`Tw.*`) for typed Tailwind utilities. Reserve plain class strings for one-off
or third-party class names only.

```csharp
using static CC.CSX.HtmlElements;
using static CC.CSX.HtmlAttributes;
using static CC.CSX.Htmx.HtmxAttributes; // if using HTMX

Div(@class("container"), id("main"),
H1("Title"),
P("Content here"),
("data-custom", "value") // tuple → attribute
using static CC.CSX.Css.CssImports; // Inline(...) / StyleSheet(...)
using static CC.CSX.Htmx.HtmxAttributes; // if using HTMX
using static MyApp.Css; // generated from your styles/*.css (e.g. site.css -> Site)

Html(
Head(Inline(Site.Bundle)), // serve the generated stylesheet
Body(
Div(@class(Site.container), id("main"), // typed class constant, not a raw "container"
H1(@class(Site.title), "Title"),
P("Content here"),
("data-custom", "value")) // tuple → attribute
)
)
```

See `samples/Web` (and `samples/CalendarSample`) for the end-to-end `.css` source-generator setup;
`CssProperties` for typed inline `style(...)`; `Tw.*` for typed Tailwind classes.

### Target frameworks

- All five runtime packages (CC.CSX, CC.CSX.Web, CC.CSX.Htmx, CC.CSX.Css, CC.CSX.Css.Tailwind, CC.CSX.Browser) multi-target `net8.0;net9.0;net10.0`. The source generator (CC.CSX.Css.Generator) stays `netstandard2.0` so it loads in every Roslyn host.
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ static HtmlNode Master(string title, params HtmlNode[] content)
Meta(charset("utf-8")),
HtmxImports),
Body(
H1(@class("text-center"), title),
H1(@class(Site.title), title), // prefer typed classes from the CSS generator (see "Styling" below)
content, // this is the content that will be rendered as a child of the body
Hr()
)
Expand Down Expand Up @@ -116,6 +116,35 @@ This will generate the following HTML:
```
For existing HTML elements and attributes, you can use the static methods provided by the `HtmlElements` and `HtmlAttributes` classes, if you need to create custom elements you can use the new HtmlNode constructor, and tuple for attributes.

## Styling and CSS classes

**The preferred way to author styles and classes is with `CC.CSX.Css` and its source generator —
not raw class strings.** Write your CSS in a `.css` file, register it as an additional file, and the
generator turns it into compile-checked, refactor-safe, discoverable typed members:

```cs
// site.css is registered via <AdditionalFiles Include="styles/*.css" /> and the
// CC.CSX.Css.Generator (shipped with the CC.CSX.Css package).
using static CC.CSX.Css.CssImports; // Inline(...) / StyleSheet(...)
using static MyApp.Css; // generated: site.css -> Site.<className> + Site.Bundle

Html(
Head(Inline(Site.Bundle)), // serve the generated stylesheet
Body(
Div(@class(Site.container), // typed class constant, not the raw string "container"
H1(@class(Site.title), "Title"))
)
);
```

- **Typed CSS classes** from your `.css` files via the generator — the recommended default.
- **`CssProperties`** for typed inline styles: `style(background("silver"), padding(8.px()))`.
- **`CC.CSX.Css.Tailwind`** (`Tw.*`) for typed Tailwind utility classes.
- Plain class strings still work everywhere (`CssClass` ⇄ `string`), but reserve them for one-off or
third-party class names.

See `samples/Web` and `samples/CalendarSample` for the end-to-end `.css` generator setup.

## How it compares to Blazor

Both let you build web UIs in C#, but htnet treats HTML as a plain C# *value* (a function returning `HtmlNode`) while Blazor runs a full component framework. The repo ships a head-to-head benchmark (`tests/CC.CSX.Benchmarks/BlazorComparison.cs`) rendering the same page through both — the Blazor side uses `RenderTreeBuilder` + `HtmlRenderer`, exactly what `.razor` SSR compiles down to:
Expand Down
104 changes: 104 additions & 0 deletions docs/production-comparison-htnet-vs-blazor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# htnet vs Blazor — production SSR comparison

A production-grade, apples-to-apples comparison of the two **optimized** server-side rendering paths,
across two scenarios: a **static-heavy data table** and a **dynamic-heavy product catalog** (loop of
cards with a conditional class, a computed price, a structural conditional badge, and a nested tag
loop). The two ends of the spectrum:

- **htnet (optimized)** — a `[RenderOptimized]` view compiled to a render plan: static markup baked to
`byte[]` and written by memcpy, only the dynamic values written per request.
- **Blazor (optimized)** — the same page as a Blazor component rendered via `HtmlRenderer.WriteHtmlTo`
(streams to a `TextWriter`, no output string) — Blazor's fastest SSR configuration.

A **hand-written** byte writer is included as the theoretical floor. The unoptimized "build a tree
then render" path is intentionally excluded — this is a best-vs-best, production comparison.

> The page: `<div class="uk-container"><h1>Report</h1><table class="uk-table">…<tbody>` +
> *N* rows of `<tr class="even|odd"><td>{id}</td><td>{name}</td><td>{email}</td></tr>`. Both engines
> emit identical HTML.
>
> Full multi-way numbers (including the unoptimized path and Blazor's `AddMarkupContent` variant):
> see [`render-plan-benchmarks.md`](./render-plan-benchmarks.md).

## Conclusions

**In production-grade configuration, across both a static-heavy and a dynamic-heavy page, htnet's
render plan is roughly 5–14× faster than the best-optimized Blazor SSR, and uses on the order of
17–240× less memory per render.** The advantage is largest on static-heavy pages and narrows — but
stays a multiple — as pages get more dynamic.

In plain terms:

- **Big page (1,000 items).** Data table: htnet **~66 µs / ~23 KB** vs Blazor **~471 µs / ~376 KB**
(~7× faster, ~17× less memory). Dynamic catalog: htnet **~134 µs / ~40 KB** vs Blazor
**~704 µs / ~697 KB** (~5× faster, ~18× less memory).
- **Medium page (100 items).** Table: ~8× faster, ~240× less memory. Catalog: ~7× faster, ~24× less
memory.
- **Small page (10 items).** Table: ~14× faster, ~34× less memory. Catalog: ~10× faster, ~23× less
memory. The advantage is there on tiny pages too, not just big lists.

**Why it matters in production:** htnet's per-render allocation stays small and grows slowly
(~0.2–40 KB), while Blazor's grows with page size into hundreds of KB. Under load that's the
difference between being CPU-bound and being GC-bound — fewer/cheaper garbage collections, flatter
tail latencies, and a higher requests-per-second ceiling on the same hardware.

**And it tracks the hand-written floor.** On the table the render plan renders in essentially the
same time as a hand-written byte writer (66 µs vs 67 µs at 1,000 rows) — there's effectively no
overhead left to remove — while you still write plain, declarative C# views.

## Numbers

`net10.0` · AMD Ryzen 9 5900X · BenchmarkDotNet (ShortRun) · lower is better.
"htnet advantage" = optimized Blazor ÷ htnet. "Blazor (optimized)" = `HtmlRenderer.WriteHtmlTo(TextWriter)`.

### Scenario 1 — data table (static-heavy)

| Rows | htnet (optimized) | Blazor (optimized) | hand-written (floor) | htnet advantage |
|---:|---|---|---|---|
| 10 | **0.61 µs · 184 B** | 8.56 µs · 6,288 B | 0.72 µs · 64 B | **14× faster · 34× less mem** |
| 100 | **6.13 µs · 184 B** | 48.78 µs · 44,288 B | 6.67 µs · 64 B | **8× faster · 240× less mem** |
| 1000 | **66.0 µs · 22,584 B** | 470.6 µs · 376,002 B | 67.1 µs · 64 B | **7× faster · 17× less mem** |

### Scenario 2 — product catalog (dynamic-heavy: conditional class, computed price, structural conditional badge, nested tag loop)

| Items | htnet (optimized) | Blazor (optimized) | htnet advantage |
|---:|---|---|---|
| 10 | **1.32 µs · 504 B** | 13.75 µs · 11,488 B | **10× faster · 23× less mem** |
| 100 | **12.99 µs · 3,648 B** | 94.75 µs · 86,312 B | **7× faster · 24× less mem** |
| 1000 | **133.8 µs · 39,648 B** | 704.4 µs · 696,711 B | **5× faster · 18× less mem** |

Typical Blazor via `.ToHtmlString()` is slower still (e.g. table 598 µs / 695 KB, catalog 1,043 µs /
1,231 KB at the largest size). One honest note from the dynamic scenario: optimized Blazor's flat
frame array actually edges out htnet's *unoptimized live tree* path there (704 µs vs 780 µs at 1,000
items) — which is exactly why the compiled render plan, not the tree, is the production path.

## How htnet's render plan works (one paragraph)

A view marked `[RenderOptimized]` is analyzed at compile time. Because the CC.CSX element/attribute
factories are *pure functions of their arguments*, a Roslyn source generator recursively splits the
view into **static chunks** (baked to `static readonly byte[]`, written via memcpy) and **dynamic
holes** (the values that read parameters). `Select`/`foreach` become real loops with the per-row
scaffold baked; node-producing conditionals become `if`/`else` with per-branch sub-plans. A C#
**interceptor** transparently redirects each call site to the generated builder — existing call sites
get the speedup with no code change. Anything not provably static falls back to rendering live, so
correctness is never traded for speed. Blazor, by contrast, always builds and walks a
`RenderTreeFrame[]` at render time — there is no SSR path that skips the tree the way a compiled plan
does, which is why even optimized Blazor stays several times behind.

## Methodology & caveats

- **BenchmarkDotNet ShortRun** (3 iterations) — solid for the order-of-magnitude ratios here; re-run
without `--job short` for publication-grade confidence intervals.
- Both engines render to a discarding output (htnet → discarding `IBufferWriter<byte>`; Blazor →
`TextWriter.Null`), so the numbers reflect the *renderer*, not output storage. In production htnet
writes straight to the response `PipeWriter`.
- `RenderOptions.Indent = 0` (production shape). Render plans apply only at `Indent = 0`.
- The render-plan generator is a spike (`CC.CSX.RenderPlan.Generator`, branch `feature/render-plan`).
The numbers only improve as codegen polish lands — e.g. `TryFormat` for numeric holes removes htnet's
last remaining allocation (the per-row `int.ToString()`).

## Reproduce

```bash
dotnet run -c Release --project tests/CC.CSX.Benchmarks -- --filter "*RealisticBenchmarks*" --job short
```
Loading
Loading