Projected zero-copy hydration: emit only the hydratable state surface#390
Open
mohamedmansour wants to merge 11 commits into
Open
Projected zero-copy hydration: emit only the hydratable state surface#390mohamedmansour wants to merge 11 commits into
mohamedmansour wants to merge 11 commits into
Conversation
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")); |
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.
Problem
Every SSR render re-serialized the entire application state into the
#webui-databootstrap<script>block — even though the client only ever hydrates a small@observable/@attrsubset. For a ~1MB state payload that is a full, wastefulserde_jsonserialization pass on every render, and it was the dominant startup CPU cost onwebui-node(and every other host).Approach — move the decision to build time, apply zero-copy at runtime
.tsfor@observable/@attrfields, unions them with the template reactive roots, and aggregates a sorted/dedupedWebUIProtocol.hydration_schema.binary_searchand 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-nodeend-to-end (real addon, contact-book-manager, 1MB payload)#webui-datasize→ 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)projected_typical(the win)projected_full(anti-regression guard)without_plugin(floor)projected_typicalstays 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:
render_projects_state_to_hydration_schemarender_projects_state_to_hydration_schemahandler_render_projects_state_to_hydration_schema(real C ABI)Handler_Render_ProjectsStateToHydrationSchema(real C ABI via a committed protobuf fixture; 7/7 C# tests pass)contact_book_manager_projects_hydration_surface,commerce_projects_hydration_surfacebuild 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/setaccessors, 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:
.ts/.jsthat exists but can't be read (I/O error or non-UTF-8) previously fell through tohas_script = false, silently downgrading the component to a static host and dropping its hydration surface. Now a hard build error in bothwebui-parserandwebui-discovery, matching the sibling HTML/CSS reads. Added a non-UTF-8 regression test.@observable get fullName()scannedgetand missedfullName. The scanner now reads the accessor name after a leadingget/set(with the same "only when an identifier follows" rule, so a field literally namedgetstill works). Added accessor tests.write_projected_statenowdebug_assert!s the schema is sorted — a mis-sorted hand-built protocol fails at test time, at zero release cost.Also marked
*.bin binaryin.gitattributesso 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, anddocs/guide/concepts/routing.mdupdated to match behavior.Acceptance checklist
cargo xtask checkpassesDESIGN.md+ docs updatedunwrap/expectin library codepanic = "abort")