Skip to content

Projected zero-copy hydration: emit only the hydratable state surface#390

Open
mohamedmansour wants to merge 11 commits into
mainfrom
mohamedmansour-silver-tribble
Open

Projected zero-copy hydration: emit only the hydratable state surface#390
mohamedmansour wants to merge 11 commits into
mainfrom
mohamedmansour-silver-tribble

Conversation

@mohamedmansour

Copy link
Copy Markdown
Contributor

Problem

Every SSR render re-serialized the entire application state into the #webui-data bootstrap <script> block — even though the client only ever hydrates a small @observable/@attr subset. For a ~1MB state payload that is a full, wasteful serde_json serialization pass on every render, and it was the dominant startup CPU cost on webui-node (and every other host).

Approach — move the decision to build time, apply zero-copy at runtime

BUILD (once)                              RENDER (every request)
────────────                              ──────────────────────
scan sibling .ts  ─┐                      full state ──► project to schema ──► #webui-data
  @observable/@attr │  union → sorted,       (Value)      (binary_search over    (only hydratable
template roots (tr) ─┘  deduped allowlist                  sorted allowlist)       keys emitted)
        │                    │
        └──► WebUIProtocol.hydration_schema (protobuf field 6)
  • Build time: the WebUI parser plugin scans each component's sibling .ts for @observable/@attr fields, unions them with the template reactive roots, and aggregates a sorted/deduped WebUIProtocol.hydration_schema.
  • Render time: the handler projects the full state object down to that allowlist via a zero-alloc binary_search and serializes only the projected bag (reusing the existing buffered </-escape). The full state is still parsed once because templates render HTML from it — that parse is legitimate and unchanged; only the redundant re-serialization of the whole tree into the script block is removed.

Over-inclusion is harmless (the client ignores unknown keys), so the schema is a safe superset — no field a component needs is ever dropped. No backwards compatibility is kept: the bootstrap block now contains only the hydratable surface.

Evidence

webui-node end-to-end (real addon, contact-book-manager, 1MB payload)

Scenario render() wall-clock #webui-data size
1MB in a server-only field (projected out) 0.852 ms/op 16,130 bytes
1MB in a hydratable field (old full-emit) 2.575 ms/op 1,064,703 bytes
small state (floor) 0.126 ms/op

→ 3.0× faster, −1.72 ms/op on the node render path when a large server-only field is projected out. The residual 0.85 ms is the mandatory serde_json::from_str(1MB) that feeds HTML rendering — not waste. CPU now tracks the hydratable surface, not the total payload size.

bootstrap_state_bench (render-isolation, Rust, --release)

Payload projected_typical (the win) projected_full (anti-regression guard) without_plugin (floor)
64 KB 738 ns 66.0 µs 82 ns
256 KB 715 ns 269 µs 79 ns
1 MB 691 ns 1.88 ms 74 ns

projected_typical stays flat (~700 ns) from 64 KB to 1 MB because it only serializes the metadata-sized hydratable subset. projected_full (every key marked hydratable) scales with payload and reproduces the old cost — proving projection is what saves it, and that we don't regress when everything genuinely is hydratable. (Criterion's red "regressed" deltas are noise from a shared, contended dev box; this bench exercises only render-side code, which is unchanged by the build-time scanner work.)

Host + example coverage (all green)

Projection is verified end-to-end on every host that renders a protocol:

  • noderender_projects_state_to_hydration_schema
  • wasmrender_projects_state_to_hydration_schema
  • ffihandler_render_projects_state_to_hydration_schema (real C ABI)
  • dotnetHandler_Render_ProjectsStateToHydrationSchema (real C ABI via a committed protobuf fixture; 7/7 C# tests pass)
  • example appscontact_book_manager_projects_hydration_surface, commerce_projects_hydration_surface build the real apps and assert the schema + projected render.

WebUIRenderer/webui_render (one-shot, no schema) is unaffected; dotnet needed zero binding-code changes — it is a pure FFI consumer, so the win lands automatically.

Scanner robustness (webui-parser/src/hydration.rs)

A deterministic, allocation-light byte scanner — no regex, no recursion, no unwrap/expect, UTF-8-safe slicing, and safe on truncated input. Handles the real authoring shapes: TS member modifiers (public/readonly/…), get/set accessors, definite-assignment (subtotal!: string), decorator factories (@attr({ attribute: 'x' })), stacked decorators, inter-token comments, and semicolon-less fields. 27 unit tests. The scanner deliberately biases toward over-inclusion because a miss silently breaks hydration.

Deep review + fixes

A full-branch code review found no blocking issues and flagged three under-inclusion gaps, all fixed here:

  1. (SHOULD-FIX) A sibling .ts/.js that exists but can't be read (I/O error or non-UTF-8) previously fell through to has_script = false, silently downgrading the component to a static host and dropping its hydration surface. Now a hard build error in both webui-parser and webui-discovery, matching the sibling HTML/CSS reads. Added a non-UTF-8 regression test.
  2. (NIT) @observable get fullName() scanned get and missed fullName. The scanner now reads the accessor name after a leading get/set (with the same "only when an identifier follows" rule, so a field literally named get still works). Added accessor tests.
  3. (NIT) write_projected_state now debug_assert!s the schema is sorted — a mis-sorted hand-built protocol fails at test time, at zero release cost.

Also marked *.bin binary in .gitattributes so the committed protobuf fixture is never EOL-normalized on checkout.

Validation

  • cargo xtask check✨ all checks passed (164.5s): license-headers → fmt → clippy → deny → test → build → wasm → examples → bench → docs.
  • DESIGN.md, rfcs/hydration-schema.md, and docs/guide/concepts/routing.md updated to match behavior.

Acceptance checklist

  • cargo xtask check passes
  • Tests added/updated (scanner, per-host projection, example apps, non-UTF-8 regression, dotnet)
  • DESIGN.md + docs updated
  • No new recursion/regex in core paths
  • No new unwrap/expect in library code
  • Allocation-disciplined (zero-copy projection over the sorted allowlist)
  • License headers on all new source files
  • FFI cannot panic across the boundary (release is panic = "abort")
  • Protobuf schema change cascade-tested (proto → handler → ffi → node → wasm → dotnet)

mohamedmansour and others added 11 commits July 9, 2026 14:38
Propose replacing the monolithic #webui-data state block with per-component, plugin-produced hydration payloads compiled at build time. Covers protobuf HydrationSchema additions, the emit_hydration_payload plugin hook, handler emission flow, webui-framework/webui-router cleanup, positional-value-array format rationale, O(N*K) to O(total fields) analysis, and a benchmark-first plan targeting ~1MB state payloads.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Capture the baseline cost of the monolithic #webui-data state emission before the per-component hydration redesign. A minimal <body>+body_end protocol isolates write_script_safe_json serialization; with_webui_plugin vs without_plugin arms bracket the cost at 64KB/256KB/1MB of state. Baseline: ~64us/243us/1.56ms respectively, flat ~80ns without the block, proving all render cost is whole-state serialization.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Produce a per-component hydration key set at build time so the runtime
handler can project client state down to only the fields a component
actually hydrates, instead of serializing the entire app state.

The schema is the union of two deterministic build-time sources:
- `@observable`/`@attr` decorator property names scanned from each
  component's sibling `.ts`/`.js` via a new non-recursive, regex-free
  byte scanner (`scan_hydration_attributes`).
- Template reactive roots (`tr`) and observed attributes (`ta`) already
  extracted by the template build metadata pass.

Over-inclusion is harmless (the client ignores unknown keys) while
under-inclusion would silently drop hydration, so the schema is a safe
superset union. Carried in protobuf `ComponentData.hydration_keys` with a
precomputed sorted global union on `WebUIProtocol.hydration_schema`.

Both registration paths scan: app-dir components read the sibling script
directly; external `--components` surface it through discovery
`script_content`. npm/cache components fall back to template roots only.

This is the build-side half; the runtime projection/streaming handler
change lands next.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the monolithic client-state block that serialized the entire

application state on every full-HTML render with a projected, zero-copy

stream:

- ProjectedState serializes only keys in the build-time hydration schema

  (a sorted, deduped union), via a zero-allocation binary search. Server

  -only fields (e.g. CSRF tokens) are excluded because they are never

  hydration keys; over-inclusion is harmless.

- ScriptSafeWriter is an io::Write adapter that escapes </ to <\/ inline

  in a single streaming pass (no intermediate Vec, no UTF-8 revalidate,

  no second scan), carrying a split < across write boundaries.

- Drop the ClientState struct and CLIENT_STATE_TOKEN_KEY strip; the

  positive allowlist supersedes the old negative token filter.

Rewrite rfcs/hydration-schema.md to document the projected-single-block

design and explain why the superseded per-component-block design was

rejected (it duplicated shared-state bytes across authored instances).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the per-token streaming ScriptSafeWriter with the proven

write_script_safe_json fast path (serde to Vec<u8> + one SIMD </ escape

pass) applied to the ProjectedState wrapper. Streaming regressed the

all-keys-hydratable case ~2x at 1MB (3.16ms) from per-token dyn dispatch,

per-chunk UTF-8 revalidation, and byte-by-byte scanning; buffering restores

baseline parity (1.51ms vs 1.557ms) while keeping the projection win.

Restructure bootstrap_state_bench into projected_full (anti-regression

guard, all keys), projected_typical (metadata-only, the win), and

without_plugin (lower bound) arms, each setting a realistic hydration_schema.

projected_typical is flat at ~656ns from 256KB to 1MB, proving cost now

tracks the hydratable surface rather than total state size (~2370x faster

than baseline at 1MB).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The client already consumes window.__webui.state correctly: it filters by observableNames/usesTemplateState, so the projected bag (only hydratable keys) needs no functional change - it simply iterates fewer keys. Update the docstring to reflect that state is now the projected hydratable surface rather than the full server render props.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
DESIGN.md: add hydration_schema to WebUIProtocol and hydration_keys to ComponentData; add a Hydration schema subsection documenting the tr-union-decorator-scan derivation, the safety bias toward over-inclusion, runtime projection, and the initial-bootstrap-only scope; note the projected state field in the #webui-data contract.

routing.md: explain that the bootstrap state field is projected to the hydratable surface so the block stays small for large render states, with client hydration unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The build-time hydration surface is now carried on every registration via the ComponentRegistration struct, so a separate _with_hydration method is redundant. Make register_component take ComponentRegistration and add ComponentRegistration::new for the common no-hydration case (tests, npm-provided components).

Also wire the wasm build path to scan sibling .ts/.js so it projects SSR state to the same hydration allowlist as the native build, instead of silently emitting an empty surface.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The `@observable`/`@attr` scan is WebUIs hydration convention, but it was called from plugin-agnostic registration code (component_registry, webui discovery, wasm parser) and the scanned result rode on the shared Component/ComponentRegistration structs. That hardcoded one plugins strategy into generic code, so FAST or other plugins could not own their own hydration surface.

Registration now carries only the raw authored client module as `script_source`; the registry never interprets it. The WebUI parser plugin scans it itself, exactly once, in take_component_templates, and unions the result with template roots to produce ComponentTemplateArtifact.hydration_keys. `register_component_template` (via `component.script_source`) is the metadata hook any plugin uses to author its own strategy.

No runtime cost change (same number of scans, relocated to build-time plugin ownership). The union test now drives the real scan path. Updated DESIGN.md, the hydration RFC, and module/trait docs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Make the build-time @observable/@attr scanner robust to real TS/JS
authoring while keeping it allocation-light (no regex, no recursion):
skip TS member modifiers (public/private/protected/readonly/static/
declare/override/accessor/abstract) with proper disambiguation, and skip
line/block comments between a decorator and its property. The decorator-
matching pass stays deliberately comment-unaware since over-inclusion is
harmless.

Prove the projected-hydration path end-to-end:
- example_apps_hydration.rs builds the real contact-book-manager and
  commerce apps, asserts their observables land in hydration_schema, and
  verifies render projects a 256KB server-only field out of #webui-data.
- Per-host projection tests exercise each real render path with the WebUI
  plugin: node, ffi (via the C ABI webui_handler_create_with_plugin +
  webui_handler_render), and wasm (host-target render_protocol_to_string).

Re-ran bootstrap_state_bench: projected_typical is ~700ns flat at both
256KB and 1MB (~2250x vs the 1.557ms full-serialization baseline); cost
now tracks the hydratable surface, not payload size.

Update DESIGN.md and rfcs/hydration-schema.md to note the scanner tolerates
TS member modifiers and intervening comments.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Prove the projected-hydration CPU win reaches the .NET host and address the
three findings from the deep review of the branch.

dotnet (pure FFI consumer — no binding code change needed):
- Add Handler_Render_ProjectsStateToHydrationSchema: loads a compiled protocol
  fixture whose only hydration key is `kept`, renders with both `kept` and a
  server-only `dropped` field, and asserts `dropped` is projected out of the
  #webui-data block. Proves projection lands through the real C ABI. 7/7 pass.
- Commit fixtures/projected_protocol.bin (generated via the node addon) and
  wire it into the test csproj output; document projection on WebUIHandler.Render.
- Mark *.bin as binary in .gitattributes so the protobuf fixture is never
  EOL-normalized on checkout.

Review fixes (all guard against silent under-inclusion — the failure this
feature exists to prevent):
- A sibling .ts/.js that exists but cannot be read (I/O error or non-UTF-8, which
  read_to_string rejects) is now a hard build error instead of silently
  downgrading the component to a static host and dropping its hydration surface.
  Fixed in both webui-parser (component_registry) and webui-discovery, matching
  the sibling HTML/CSS reads. Adds a non-UTF-8 regression test.
- The scanner now reads the accessor name after a leading `get`/`set`
  (`@observable get fullName()` -> `fullName`, not `get`), using the same
  "only when another identifier follows" disambiguation as TS modifiers, so a
  field literally named `get` still reads correctly. Adds accessor tests.
- write_projected_state debug_asserts the schema is sorted, turning a
  mis-sorted hand-built protocol into a test-time failure at zero release cost.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
// plugin must project the render state down to that allowlist before
// emitting the #webui-data bootstrap block, dropping server-only fields.
byte[] protocol = File.ReadAllBytes(
Path.Combine(AppContext.BaseDirectory, "fixtures", "projected_protocol.bin"));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant