diff --git a/.github/skills/docs-sync/SKILL.md b/.github/skills/docs-sync/SKILL.md
index 5789730b2..3026e852e 100644
--- a/.github/skills/docs-sync/SKILL.md
+++ b/.github/skills/docs-sync/SKILL.md
@@ -25,7 +25,7 @@ Use this skill whenever a change touches user-visible behavior, APIs, or contrac
| Public API (Rust crate, npm package) | Relevant handler/integration docs; `DESIGN.md` only if an architectural boundary changes |
| Error variants or error messages | Defining source/tests and relevant public troubleshooting docs; `DESIGN.md` only if error-handling architecture changes |
| `@microsoft/webui-framework` decorators or API | `docs/guide/concepts/interactivity.md` + `docs/ai.md` + `packages/webui-framework/README.md` |
-| `@microsoft/webui-router` behavior | `docs/guide/concepts/routing.md` + `packages/webui-router/README.md` |
+| `@microsoft/webui-delivery` behavior | `docs/guide/concepts/routing.md` + `packages/webui-delivery/README.md` |
## DESIGN.md rules
diff --git a/.github/skills/perf/SKILL.md b/.github/skills/perf/SKILL.md
index ffc655099..aff3442f1 100644
--- a/.github/skills/perf/SKILL.md
+++ b/.github/skills/perf/SKILL.md
@@ -1,11 +1,11 @@
---
name: perf
-description: Speed and memory performance rules for Rust crates, the Node addon, webui-framework, and webui-router.
+description: Speed and memory performance rules for Rust crates, the Node addon, webui-framework, and webui-delivery.
---
# Performance
-WebUI's value proposition is speed and low memory usage. Every change to core Rust crates, `@microsoft/webui-framework`, or `@microsoft/webui-router` must be evaluated through two lenses: **throughput** (how fast) and **memory** (how little).
+WebUI's value proposition is speed and low memory usage. Every change to core Rust crates, `@microsoft/webui-framework`, or `@microsoft/webui-delivery` must be evaluated through two lenses: **throughput** (how fast) and **memory** (how little).
Server memory is not cheap. Client memory is not unlimited. Every allocation that can be avoided is a win on both sides.
@@ -58,9 +58,9 @@ These apply to `packages/webui-framework` (the client-side Web Component runtime
4. **Strip SSR markers after hydration.** Comment nodes used as markers are removed from the DOM once wiring is complete - they don't persist as memory overhead.
5. **Scope frames are stack-allocated.** `` loop item variables use a linked-list scope chain, not cloned Maps or Objects.
-## TypeScript - `@microsoft/webui-router` rules
+## TypeScript - `@microsoft/webui-delivery` rules
-These apply to `packages/webui-router` (the client-side SPA router).
+These apply to `packages/webui-delivery` (the client-side SPA router).
### Speed
diff --git a/.github/skills/testing/SKILL.md b/.github/skills/testing/SKILL.md
index 6696b84d0..f819b5645 100644
--- a/.github/skills/testing/SKILL.md
+++ b/.github/skills/testing/SKILL.md
@@ -105,8 +105,11 @@ The test server (`fixture-render.ts`) auto-discovers fixtures with `src/index.ht
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
+import { startRuntime } from '../../../src/runtime.js';
import { WebUIElement, attr, observable } from '../../../src/index.js';
+startRuntime();
+
export class TestWidget extends WebUIElement {
@attr label = 'Hello';
@observable count = 0;
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 37064c99f..747569231 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -603,7 +603,9 @@ jobs:
python-test:
name: Python test (${{ matrix.platform.label }} / CPython ${{ matrix.python }})
runs-on: ${{ matrix.platform.os }}
- needs: python-wheel
+ needs: [python-wheel, python-fixture]
+ env:
+ WEBUI_TEST_FIXTURES: ${{ github.workspace }}/target/python-fixtures
strategy:
fail-fast: false
matrix:
@@ -635,6 +637,11 @@ jobs:
name: ${{ matrix.platform.artifact }}
path: publish/python
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: python-fixtures
+ path: target/python-fixtures
+
- name: Install the built wheel and test it
shell: bash
run: |
@@ -652,9 +659,9 @@ jobs:
python -m mypy
python -m mypy.stubtest microsoft_webui
- # ── Phase 3: Python fixture drift (after Linux build) ──────────────
- # The committed protocol fixtures are build outputs, so rebuild them and fail
- # if they drift, keeping the Python tests from asserting against stale binaries.
+ # ── Phase 3: Current Python fixtures (after Linux build) ──────────
+ # Build fixtures from source, verify exact semantic repeatability and v3
+ # metadata, and share these bytes with every interpreter/platform test.
python-fixture:
name: Python fixture
runs-on: ubuntu-latest
@@ -673,37 +680,13 @@ jobs:
- name: Verify the release-target contract is restated consistently
run: python crates/webui-python/tests/validate_release_targets.py
- - name: Rebuild and compare the protocol fixtures
- run: |
- set -euo pipefail
- output="target/python-fixture-check"
- streaming_output="target/python-streaming-fixture-check"
- rm -rf "$output" "$streaming_output"
- cargo run --quiet -p microsoft-webui-cli -- build \
- "crates/webui-python/tests/fixtures/app" --plugin webui --out "$output"
- cargo run --quiet -p microsoft-webui-cli -- build \
- "crates/webui-python/tests/fixtures/streaming-app" \
- --plugin webui --out "$streaming_output"
- cargo run --quiet -p microsoft-webui-cli -- inspect \
- "crates/webui-python/tests/fixtures/protocol.bin" \
- > "$output/committed.json"
- cargo run --quiet -p microsoft-webui-cli -- inspect \
- "$output/protocol.bin" > "$output/generated.json"
- python "crates/webui-python/tests/compare_fixture.py" \
- "$output/committed.json" "$output/generated.json" \
- "crates/webui-python/tests/fixtures/protocol.bin"
- cargo run --quiet -p microsoft-webui-cli -- inspect \
- "crates/webui-python/tests/fixtures/streaming_protocol.bin" \
- > "$streaming_output/committed.json"
- cargo run --quiet -p microsoft-webui-cli -- inspect \
- "$streaming_output/protocol.bin" \
- > "$streaming_output/generated.json"
- python "crates/webui-python/tests/compare_fixture.py" \
- "$streaming_output/committed.json" \
- "$streaming_output/generated.json" \
- "crates/webui-python/tests/fixtures/streaming_protocol.bin"
- cmp "crates/webui-python/tests/fixtures/greeting-card.css" \
- "$output/greeting-card.css"
+ - name: Build and verify current protocol fixtures
+ run: python crates/webui-python/tests/build_fixtures.py
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: python-fixtures
+ path: target/python-fixtures
# ── Gate: one stable required check ────────────────────────────────
# Branch protection can only require per-job contexts, so requiring the jobs
diff --git a/Cargo.lock b/Cargo.lock
index e2e630929..0769f7efe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -782,9 +782,9 @@ dependencies = [
[[package]]
name = "ctutils"
-version = "0.4.2"
+version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865"
dependencies = [
"cmov",
]
@@ -3552,9 +3552,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
-version = "1.53.1"
+version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
diff --git a/Cargo.toml b/Cargo.toml
index caef2bde6..e7533b4f4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,7 +28,7 @@ thiserror = "2.0.18"
anyhow = "1.0.104"
log = "0.4.34"
env_logger = "0.11.11"
-tokio = { version = "1.53.1", features = ["full"] }
+tokio = { version = "1.53.3", features = ["full"] }
clap = { version = "4.6.3", features = ["derive", "color"] }
console = "0.16.6"
rayon = "1.12.0"
diff --git a/DESIGN.md b/DESIGN.md
index ce6b11676..bdebc1733 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -54,7 +54,7 @@ The **compiler** (`webui`, `webui-discovery`, `webui-parser`,
artifact. The **handler** (`webui-handler`, `webui-state`,
`webui-expressions`) owns request-scoped evaluation and output; it does not
know how to find source files. The **browser** (`@microsoft/webui-framework`
-and, independently, `@microsoft/webui-router`) consumes compiler output and
+and, independently, `@microsoft/webui-delivery`) consumes compiler output and
server HTML rather than compiling templates again. CLI, desktop, and language
bindings are hosts around the same protocol and handler.
@@ -63,6 +63,27 @@ browser runtime, and a host can load `protocol.bin` without shipping the
compiler. Only the selected optional browser entry points should reach an
application's JavaScript bundle.
+Browser resource data, rendering, and delivery are separate layers. Template
+registration and style catalogs do not require a renderer or navigation
+orchestrator. Buffered rendering selects its style adapter explicitly; routing
+selects transport and optional navigation policies. Public API imports do not
+install runtimes or construct application singletons. Applications select
+renderer/style installers explicitly, start rendering after their selected
+modules have loaded, and configure routing once at startup;
+selected request-critical policies are ready before interaction begins.
+Unselected implementations must not reach production output through either
+static or dynamic imports. Application feature code can load on demand without
+changing global routing policy.
+
+Shared browser delivery remains hydration-agnostic. Navigation and lazy
+template loading consume one request-lifetime, resource availability, build
+identity, and transport contract. Route reconciliation owns early commits and
+per-level state; lazy loading returns scoped state to its existing owner only
+after resources are ready. Resource-only preloading does not cache application
+state. A renderer adapter registers opaque compiled
+payloads, reports readiness and release, and owns its live views. Delivery does
+not import the WebUI element renderer or turn a template section into a component.
+
## Compilation and artifact ownership
### Source discovery and parsing
@@ -96,6 +117,13 @@ become typed instructions or references. ``, ``, ``,
parser rejects invalid structure and unsupported syntax with source-located
diagnostics, rather than trying to repair arbitrary browser HTML.
+A lazy `` is an ordinary conditional with a delivery annotation. Its
+body retains the enclosing template's state, lexical scope, refs, and event
+ownership. Compilation separates that body's client payload from eager owner
+metadata without introducing a custom-element host. The protocol retains both
+server instructions and independently deliverable client sections; private
+sub-blocks travel together, while genuinely shared blocks remain reusable.
+
Component templates may share named loop bodies without expanding them at each
callsite. Structural recursion through component templates is invalid; finite
data-driven loop reuse is supported. Lexical scopes are compiled so the
@@ -137,6 +165,11 @@ requests, and creates separate render contexts for state, route, nonce,
inventory, writer, and plugin instance. No render should decode or re-index
the protocol per request.
+The compiler assigns an artifact identity over the complete protocol content.
+Availability bits are meaningful only for that identity. A response from another
+build is rejected rather than applied to a document with different resource
+positions or template definitions.
+
### CSS and themes
The build determines each component's **effective DOM mode**. Unwrapped
@@ -191,7 +224,9 @@ Initial hydration and partial navigation have separate state surfaces.
state is required. Initial state includes proven authored observable/attribute
properties; navigation additionally includes compiled template roots.
Reachability follows the active route but conservatively includes conditional
-and loop branches it might render. Projection is a payload/performance
+and loop branches it might render. Explicitly deferred edges are separate
+delivery roots: initial delivery follows the ones active in the request's actual
+scope. They do not create independent component state. Projection is a payload/performance
optimization, **not a secrecy boundary**: any browser render state may be
sent to the client. Never put secrets in it.
@@ -229,6 +264,14 @@ the handler does not finish rendering into an abandoned channel. Host entry
points may render a whole document, a component fragment, a route partial,
or a host-paced streaming session from the same loaded protocol.
+Lazy-body requests name an existing owner and region, not a route. A preload
+delivers the body's ordinary resource closure without application state or DOM
+creation. A stateful response can send resources first and fresh scoped state
+later; the final record adds any nested deferred resources selected by that
+state. Hosts own authorization, data providers, cancellation, and backpressure.
+The browser applies state to its existing owner/scope and uses normal conditional
+mounting. Initially active regions instead use ordinary SSR and hydrate that DOM.
+
### Server-authoritative routing
Nested `` declarations and a component's `` define the route
@@ -243,12 +286,18 @@ location.
For soft navigation, the server returns the authoritative route chain and
projected state, filters templates against the client's component inventory,
and supplies separately tracked style resources required by the destination.
-The router registers those resources, waits for their readiness, and reconciles
+The resource delivery layer registers those resources and acknowledges inventory
+only after registration/readiness succeeds. Concurrent responses merge availability;
+local cache collection invalidates older inventory acknowledgements without
+turning template retention into a cache of application state.
+The router waits for resource readiness and reconciles
changed route levels while preserving
unchanged parents. It may fall back to document navigation when client-side
mounting cannot complete. Partial responses can be complete JSON or an NDJSON
pair that sends chain/templates first and state later; neither is the
-progressive HTML streaming protocol. Cache tags, mutation invalidation,
+progressive HTML streaming protocol. A bounded record reader owns framing and
+body cancellation; the route adapter owns positional state and commit ordering.
+JSON-only navigation does not import that record reader. Cache tags, mutation invalidation,
pending/error components, and speculative preloading are optional router
features, not dependencies of the SSR handler.
@@ -305,6 +354,15 @@ re-render the first paint, or do a document-wide search per binding.
Client-created components clone compiled static HTML and fill the recorded
slots. Raw HTML ranges retain explicit ownership for later replacement.
+Native custom-element definitions retain only the identity and observer surface
+needed for future instances, not complete template bodies. Optional template
+collection uses compiler-derived direct dependencies: a live component's
+metadata retains the ordinary descendants that its conditions and repeats can
+instantiate synchronously. Cache ownership may be released without breaking
+those consumers. Constructor registration, behavior modules, and CSS still
+required by a live tree have distinct lifetimes and are not promised to unload
+when a template cache is cleared.
+
`WebUIElement` is the authored behavior layer. Compiler-owned scriptless hosts
need no application module and can stay dormant until state or client creation
requires activation. Component work policies may defer SSR hydration to
@@ -504,7 +562,8 @@ engineering another; they are not public application-authoring guides.
| Rust render and streaming APIs | `crates/webui-handler/src/`, `crates/webui/src/streaming.rs` |
| Progressive response wire and host-step invariants | [`specs/streaming.md`](specs/streaming.md); `crates/webui-handler/src/streaming/`, `packages/webui-framework/src/streaming-protocol.ts` |
| Client template metadata, SSR markers, and hydration | [`specs/hydration.md`](specs/hydration.md); `crates/webui-handler/src/plugin/webui.rs`, `packages/webui-framework/src/element/markers.ts`, `docs/guide/concepts/hydration.md` |
-| Browser routing | `packages/webui-router/`, `docs/guide/concepts/routing.md` |
+| Browser routing | `packages/webui-delivery/`, `docs/guide/concepts/routing.md` |
+| Optional client delivery and inventory ownership | [`specs/client-delivery.md`](specs/client-delivery.md); `packages/webui-delivery/src/`, `packages/webui-framework/src/template-cache.ts` |
| Desktop IPC trust, admission, and envelope contract | [`specs/desktop-ipc.md`](specs/desktop-ipc.md); `crates/webui-desktop/src/ipc/`, `packages/webui-desktop/src/envelope.ts` |
| Desktop website data ownership | [`specs/desktop-website-data.md`](specs/desktop-website-data.md); `crates/webui-desktop/src/local_server.rs`, `crates/webui-desktop/src/macos/website_data.rs` |
| Desktop app and window APIs | `docs/guide/concepts/desktop.md`, `crates/webui-desktop/src/` |
diff --git a/crates/webui-cli/src/commands/serve.rs b/crates/webui-cli/src/commands/serve.rs
index 3616bf768..caaa2c3e5 100644
--- a/crates/webui-cli/src/commands/serve.rs
+++ b/crates/webui-cli/src/commands/serve.rs
@@ -3,6 +3,7 @@
mod component_assets;
mod host;
+mod lazy;
mod streaming_api;
use actix_web::body::BoxBody;
@@ -269,8 +270,8 @@ struct SharedState {
/// the publication directory rather than retained in this map.
component_assets: HashMap,
protocol: Option>,
- state_data: Option,
- token_css: Option>,
+ state_data: Option>,
+ token_css: Option>>,
rebuild_error: Option,
/// Entry fragment ID used for rendering (e.g., "index.html").
entry: String,
@@ -429,8 +430,8 @@ fn run(args: &ServeArgs, control: Option) -> Result<()> {
css_files: initial_result.css_files,
component_assets: initial_result.component_assets,
protocol: Some(initial_result.protocol),
- state_data: Some(initial_result.state_data),
- token_css: initial_result.token_css,
+ state_data: Some(Arc::new(initial_result.state_data)),
+ token_css: initial_result.token_css.map(Arc::new),
rebuild_error: None,
entry: args.app_args.entry.clone(),
}));
@@ -549,6 +550,8 @@ fn serve_app(
"/_webui/templates",
web::get().to(handle_component_templates),
)
+ .route("/_webui/lazy", web::get().to(lazy::resources))
+ .route("/_webui/lazy/state", web::get().to(lazy::state))
.route("/{tail:.*}", web::get().to(handle_asset))
.default_service(web::route().to(handle_not_found))
}
@@ -1031,7 +1034,8 @@ async fn resolve_state(context: &ServerContext, request_path: &str) -> Value {
match context.state.lock() {
Ok(s) => (
s.state_data
- .clone()
+ .as_deref()
+ .cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new())),
s.token_css.clone(),
),
@@ -1043,7 +1047,8 @@ async fn resolve_state(context: &ServerContext, request_path: &str) -> Value {
match context.state.lock() {
Ok(s) => (
s.state_data
- .clone()
+ .as_deref()
+ .cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new())),
s.token_css.clone(),
),
@@ -1215,7 +1220,7 @@ fn fallback_state(context: &ServerContext) -> Value {
.state
.lock()
.ok()
- .and_then(|state| state.state_data.clone())
+ .and_then(|state| state.state_data.as_deref().cloned())
.unwrap_or_else(|| Value::Object(serde_json::Map::new()))
}
@@ -1227,7 +1232,7 @@ fn streaming_state_defaults(
.state
.lock()
.ok()
- .and_then(|state| state.token_css.clone());
+ .and_then(|state| state.token_css.as_deref().cloned());
streaming_api::StateDefaults::new(
token_css,
context.base_path.as_deref().unwrap_or("/").to_owned(),
@@ -1963,8 +1968,8 @@ fn rebuild_and_update_state(
s.css_files = result.css_files;
s.component_assets = result.component_assets;
s.protocol = Some(result.protocol);
- s.state_data = Some(result.state_data);
- s.token_css = result.token_css;
+ s.state_data = Some(Arc::new(result.state_data));
+ s.token_css = result.token_css.map(Arc::new);
s.rebuild_error = None;
// The rebuild worker prints these under the "rebuilt" line.
Ok(result.warnings)
@@ -2062,14 +2067,14 @@ mod tests {
})
}
- fn test_route_context(protocol: Arc) -> web::Data {
+ pub(super) fn test_route_context(protocol: Arc) -> web::Data {
web::Data::new(ServerContext {
state: Arc::new(Mutex::new(SharedState {
rendered_html: "ok".to_string(),
css_files: HashMap::new(),
component_assets: HashMap::new(),
protocol: Some(protocol),
- state_data: Some(Value::Object(serde_json::Map::new())),
+ state_data: Some(Arc::new(Value::Object(serde_json::Map::new()))),
token_css: None,
rebuild_error: None,
entry: "index.html".to_string(),
@@ -2850,7 +2855,7 @@ mod tests {
css_files: HashMap::new(),
component_assets: HashMap::new(),
protocol: Some(protocol),
- state_data: Some(Value::Object(serde_json::Map::new())),
+ state_data: Some(Arc::new(Value::Object(serde_json::Map::new()))),
token_css: None,
rebuild_error: None,
entry: "index.html".to_string(),
@@ -3373,7 +3378,7 @@ mod tests {
.render_partial(state.clone(), "index.html", "/", "")
.unwrap();
let context = test_route_context(protocol);
- context.state.lock().unwrap().state_data = Some(state);
+ context.state.lock().unwrap().state_data = Some(Arc::new(state));
let app = actix_test::init_service(
App::new()
.app_data(context)
diff --git a/crates/webui-cli/src/commands/serve/lazy.rs b/crates/webui-cli/src/commands/serve/lazy.rs
new file mode 100644
index 000000000..901dffaec
--- /dev/null
+++ b/crates/webui-cli/src/commands/serve/lazy.rs
@@ -0,0 +1,466 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use super::{accept_media_q, fetch_api_state, rebuild_error_json_response, ServerContext};
+use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse};
+use bytes::Bytes;
+use futures_util::{stream, StreamExt};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::sync::Arc;
+use webui::{HandlerError, LazyOptions, LazyRequest, Protocol};
+
+#[derive(Deserialize)]
+struct Target {
+ owner: String,
+ id: String,
+}
+
+struct Snapshot {
+ protocol: Arc,
+ state: Arc,
+ tokens: Option>>,
+}
+
+enum State {
+ Shared(Arc),
+ Owned(Value),
+}
+
+impl State {
+ fn value(&self) -> &Value {
+ match self {
+ Self::Shared(value) => value,
+ Self::Owned(value) => value,
+ }
+ }
+}
+
+struct StateTask(tokio::task::JoinHandle>);
+
+impl StateTask {
+ async fn value(mut self) -> Result {
+ (&mut self.0)
+ .await
+ .map_err(|error| format!("lazy state task failed: {error}"))?
+ }
+}
+
+impl Drop for StateTask {
+ fn drop(&mut self) {
+ self.0.abort();
+ }
+}
+
+fn snapshot(context: &ServerContext) -> Result> {
+ let state = context.state.lock().map_err(|_| {
+ Box::new(failure(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "lazy-state-unavailable",
+ "state lock poisoned",
+ ))
+ })?;
+ if let Some(error) = &state.rebuild_error {
+ return Err(Box::new(rebuild_error_json_response(error)));
+ }
+ let protocol = state.protocol.clone().ok_or_else(|| {
+ Box::new(failure(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "lazy-protocol-unavailable",
+ "no compiled protocol",
+ ))
+ })?;
+ Ok(Snapshot {
+ protocol,
+ state: state
+ .state_data
+ .clone()
+ .unwrap_or_else(|| Arc::new(Value::Object(serde_json::Map::new()))),
+ tokens: state.token_css.clone(),
+ })
+}
+
+fn request_options(req: &HttpRequest) -> Result> {
+ fn header(req: &HttpRequest, name: &str) -> Result> {
+ req.headers().get(name).map_or(Ok(String::new()), |value| {
+ value.to_str().map(str::to_owned).map_err(|_| {
+ Box::new(failure(
+ StatusCode::BAD_REQUEST,
+ "lazy-invalid-header",
+ "resource headers must be valid text",
+ ))
+ })
+ })
+ }
+ Ok(LazyOptions {
+ inventory: header(req, "x-webui-inventory")?,
+ style_inventory: header(req, "x-webui-style-inventory")?,
+ build_id: header(req, "x-webui-build")?,
+ })
+}
+
+fn target(req: &HttpRequest) -> Result> {
+ web::Query::::from_query(req.query_string())
+ .map(web::Query::into_inner)
+ .map_err(|_| {
+ Box::new(failure(
+ StatusCode::BAD_REQUEST,
+ "lazy-invalid-target",
+ "provide ?owner=template&id=region",
+ ))
+ })
+}
+
+pub(super) async fn resources(req: HttpRequest, context: web::Data) -> HttpResponse {
+ let target = match target(&req) {
+ Ok(target) => target,
+ Err(error) => return *error,
+ };
+ let options = match request_options(&req) {
+ Ok(options) => options,
+ Err(error) => return *error,
+ };
+ let snapshot = match snapshot(&context) {
+ Ok(snapshot) => snapshot,
+ Err(error) => return *error,
+ };
+ match snapshot
+ .protocol
+ .render_lazy_resources(LazyRequest::new(&target.owner, &target.id).with_options(&options))
+ {
+ Ok(body) => HttpResponse::Ok()
+ .content_type("application/json")
+ .body(body),
+ Err(error) => request_failure(error),
+ }
+}
+
+pub(super) async fn state(req: HttpRequest, context: web::Data) -> HttpResponse {
+ let mode = match req
+ .headers()
+ .get("accept")
+ .map(|value| value.to_str())
+ .transpose()
+ {
+ Ok(value) => match delivery(value) {
+ Some(mode) => mode,
+ None => {
+ return failure(
+ StatusCode::NOT_ACCEPTABLE,
+ "lazy-not-acceptable",
+ "accept application/json or application/x-ndjson",
+ )
+ }
+ },
+ Err(_) => {
+ return failure(
+ StatusCode::BAD_REQUEST,
+ "lazy-invalid-header",
+ "Accept must be valid text",
+ )
+ }
+ };
+ let target = match target(&req) {
+ Ok(target) => target,
+ Err(error) => return *error,
+ };
+ let options = match request_options(&req) {
+ Ok(options) => options,
+ Err(error) => return *error,
+ };
+ let snapshot = match snapshot(&context) {
+ Ok(snapshot) => snapshot,
+ Err(error) => return *error,
+ };
+ let path = req
+ .uri()
+ .path_and_query()
+ .map_or(req.path(), |path| path.as_str())
+ .to_owned();
+ let protocol = snapshot.protocol.clone();
+ let task = StateTask(actix_web::rt::spawn(resolve_state(context, path, snapshot)));
+ if mode == Delivery::Json {
+ let state = match task.value().await {
+ Ok(state) => state,
+ Err(error) => return failure(StatusCode::BAD_GATEWAY, "lazy-state-failed", &error),
+ };
+ return match protocol.render_lazy_partial(
+ state.value(),
+ LazyRequest::new(&target.owner, &target.id).with_options(&options),
+ ) {
+ Ok(body) => HttpResponse::Ok()
+ .content_type("application/json")
+ .body(body),
+ Err(error) => request_failure(error),
+ };
+ }
+ // Let the data operation begin before serializing the metadata record.
+ tokio::task::yield_now().await;
+ let prepared = match protocol
+ .prepare_lazy_resources(LazyRequest::new(&target.owner, &target.id).with_options(&options))
+ {
+ Ok(prepared) => prepared,
+ Err(error) => return request_failure(error),
+ };
+ let mut metadata = prepared.json;
+ metadata.push('\n');
+ let first = stream::once(async move { Ok::<_, actix_web::Error>(Bytes::from(metadata)) });
+ let last = stream::once(async move {
+ let result = match task.value().await {
+ Ok(state) => protocol
+ .render_lazy_partial(
+ state.value(),
+ LazyRequest::new(&target.owner, &target.id).with_options(&prepared.options),
+ )
+ .map_err(|error| error.to_string()),
+ Err(error) => Err(error),
+ };
+ let mut body = match result {
+ Ok(body) => body.into_bytes(),
+ Err(message) => {
+ log::error!("Lazy state delivery failed: {message}");
+ serde_json::to_vec(&ErrorRecord {
+ error: ErrorDetail {
+ code: "lazy-state-failed",
+ message: &message,
+ },
+ })
+ .map_err(actix_web::error::ErrorInternalServerError)?
+ }
+ };
+ body.push(b'\n');
+ Ok::<_, actix_web::Error>(Bytes::from(body))
+ });
+ HttpResponse::Ok()
+ .content_type("application/x-ndjson")
+ .streaming(first.chain(last))
+}
+
+async fn resolve_state(
+ context: web::Data,
+ path: String,
+ snapshot: Snapshot,
+) -> Result {
+ let Some(api) = &context.api else {
+ return Ok(State::Shared(snapshot.state));
+ };
+ let mut state = fetch_api_state(&api.client, api.port, &path).await?;
+ if let Some(tokens) = &snapshot.tokens {
+ webui_tokens::inject_token_css(&mut state, tokens);
+ }
+ if let Value::Object(values) = &mut state {
+ values.insert(
+ "basePath".into(),
+ Value::String(context.base_path.as_deref().unwrap_or("/").into()),
+ );
+ }
+ Ok(State::Owned(state))
+}
+
+#[derive(Debug, PartialEq, Eq)]
+enum Delivery {
+ Json,
+ Ndjson,
+}
+
+fn delivery(accept: Option<&str>) -> Option {
+ let Some(accept) = accept.filter(|value| !value.trim().is_empty()) else {
+ return Some(Delivery::Json);
+ };
+ let mut json: Option = None;
+ let mut ndjson: Option = None;
+ let mut wildcard: Option = None;
+ for entry in accept.split(',') {
+ let (media, parameters) = entry.split_once(';').unwrap_or((entry, ""));
+ let quality = accept_media_q(parameters);
+ if media.trim().eq_ignore_ascii_case("application/x-ndjson") {
+ ndjson = Some(ndjson.map_or(quality, |previous| previous.max(quality)));
+ } else if media.trim().eq_ignore_ascii_case("application/json") {
+ json = Some(json.map_or(quality, |previous| previous.max(quality)));
+ } else if media.trim() == "*/*" || media.trim().eq_ignore_ascii_case("application/*") {
+ wildcard = Some(wildcard.map_or(quality, |previous| previous.max(quality)));
+ }
+ }
+ let json = json.or(wildcard).unwrap_or(0.0);
+ let ndjson = ndjson.unwrap_or(0.0);
+ if ndjson > 0.0 && ndjson >= json {
+ Some(Delivery::Ndjson)
+ } else if json > 0.0 {
+ Some(Delivery::Json)
+ } else {
+ None
+ }
+}
+
+#[derive(Serialize)]
+struct ErrorRecord<'a> {
+ error: ErrorDetail<'a>,
+}
+
+#[derive(Serialize)]
+struct ErrorDetail<'a> {
+ code: &'a str,
+ message: &'a str,
+}
+
+fn failure(status: StatusCode, code: &str, message: &str) -> HttpResponse {
+ HttpResponse::build(status)
+ .content_type("application/json")
+ .json(ErrorRecord {
+ error: ErrorDetail { code, message },
+ })
+}
+
+fn request_failure(error: HandlerError) -> HttpResponse {
+ if let HandlerError::LazyRequest(error) = error {
+ let status = match error.code {
+ "lazy-not-found" => StatusCode::NOT_FOUND,
+ "lazy-build-mismatch" => StatusCode::CONFLICT,
+ _ => StatusCode::BAD_REQUEST,
+ };
+ return failure(status, error.code, &error.message);
+ }
+ log::error!("Lazy resource delivery failed: {error}");
+ failure(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "lazy-render-failed",
+ &error.to_string(),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use actix_web::{test as actix_test, App};
+ use webui_protocol::{ClientFragmentData, FragmentList, StateProjectionMode, WebUIProtocol};
+
+ fn context() -> web::Data {
+ let root = "app-shell::lazy:details";
+ let mut document =
+ WebUIProtocol::new(HashMap::from([(root.into(), FragmentList::default())]));
+ document.build_id = "fixture".into();
+ document.client_fragments.insert(
+ root.into(),
+ ClientFragmentData {
+ owner: "app-shell".into(),
+ name: "details".into(),
+ entry: true,
+ template_json: r#"{"h":"Body
","v":3}"#.into(),
+ state_mode: Some(StateProjectionMode::Keys as i32),
+ state_keys: vec!["message".into()],
+ ..Default::default()
+ },
+ );
+ let context = super::super::tests::test_route_context(Arc::new(Protocol::new(document)));
+ context.state.lock().unwrap().state_data = Some(Arc::new(
+ serde_json::from_str(r#"{"message":"fresh","unused":"not sent"}"#).unwrap(),
+ ));
+ context
+ }
+
+ #[test]
+ fn ndjson_is_explicit_and_respects_quality_preferences() {
+ assert_eq!(
+ delivery(Some("application/x-ndjson, application/json")),
+ Some(Delivery::Ndjson)
+ );
+ assert_eq!(
+ delivery(Some("application/x-ndjson;q=0, application/json")),
+ Some(Delivery::Json)
+ );
+ assert_eq!(
+ delivery(Some("application/x-ndjson;q=0.2, application/json;q=0.8")),
+ Some(Delivery::Json)
+ );
+ assert_eq!(delivery(None), Some(Delivery::Json));
+ assert_eq!(delivery(Some("application/json;q=0,*/*")), None);
+ assert_eq!(delivery(Some("image/png")), None);
+ }
+
+ #[test]
+ fn static_state_snapshot_borrows_the_existing_state_tree() {
+ let context = context();
+ let original = context.state.lock().unwrap().state_data.clone().unwrap();
+ let snapshot = snapshot(&context).ok().unwrap();
+ assert!(Arc::ptr_eq(&original, &snapshot.state));
+ }
+
+ #[actix_web::test]
+ async fn resource_endpoint_decodes_names_and_returns_no_state() {
+ let app = actix_test::init_service(
+ App::new()
+ .app_data(context())
+ .route("/_webui/lazy", web::get().to(resources)),
+ )
+ .await;
+ let response = actix_test::call_service(
+ &app,
+ actix_test::TestRequest::get()
+ .uri("/_webui/lazy?owner=app%2Dshell&id=details")
+ .to_request(),
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK);
+ let payload: Value = actix_test::read_body_json(response).await;
+ assert_eq!(payload["type"], "webui-lazy");
+ assert!(payload.get("state").is_none());
+ assert!(payload["templates"]["app-shell::lazy:details"].is_object());
+ }
+
+ #[actix_web::test]
+ async fn ndjson_state_uses_metadata_acknowledgement_without_resending_body() {
+ let app = actix_test::init_service(
+ App::new()
+ .app_data(context())
+ .route("/_webui/lazy/state", web::get().to(state)),
+ )
+ .await;
+ let response = actix_test::call_service(
+ &app,
+ actix_test::TestRequest::get()
+ .uri("/_webui/lazy/state?owner=app-shell&id=details")
+ .insert_header(("Accept", "application/x-ndjson, application/json"))
+ .to_request(),
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = actix_test::read_body(response).await;
+ let records: Vec = body
+ .split(|byte| *byte == b'\n')
+ .filter(|line| !line.is_empty())
+ .map(|line| serde_json::from_slice(line).unwrap())
+ .collect();
+ assert_eq!(records.len(), 2);
+ assert!(records[0].get("state").is_none());
+ assert!(records[1].get("templates").is_none());
+ assert_eq!(records[1]["state"]["message"], "fresh");
+ assert!(records[1]["state"].get("unused").is_none());
+ }
+
+ #[actix_web::test]
+ async fn stale_build_unknown_region_and_bad_inventory_have_error_statuses() {
+ let app = actix_test::init_service(
+ App::new()
+ .app_data(context())
+ .route("/_webui/lazy", web::get().to(resources)),
+ )
+ .await;
+ for (id, build, inventory, status) in [
+ ("details", "old", "", StatusCode::CONFLICT),
+ ("missing", "fixture", "", StatusCode::NOT_FOUND),
+ ("details", "fixture", "zz", StatusCode::BAD_REQUEST),
+ ] {
+ let response = actix_test::call_service(
+ &app,
+ actix_test::TestRequest::get()
+ .uri(&format!("/_webui/lazy?owner=app-shell&id={id}"))
+ .insert_header(("X-WebUI-Build", build))
+ .insert_header(("X-WebUI-Inventory", inventory))
+ .to_request(),
+ )
+ .await;
+ assert_eq!(response.status(), status);
+ }
+ }
+}
diff --git a/crates/webui-ffi/include/webui_ffi.h b/crates/webui-ffi/include/webui_ffi.h
index 9f3ca8d64..ec5d4e0ac 100644
--- a/crates/webui-ffi/include/webui_ffi.h
+++ b/crates/webui-ffi/include/webui_ffi.h
@@ -530,6 +530,51 @@ const char *webui_streaming_step_boundary_key_string(const webui_streaming_step_
bool webui_streaming_step_boundary_key_number(const webui_streaming_step_t *step_ptr,
double *out_value);
+/**
+ * Render resource JSON for an owner-scoped deferred body. Free it with `webui_free`.
+ *
+ * # Safety
+ *
+ * `protocol` must be a live handle from `webui_protocol_create`. `owner` and `id`
+ * must be non-null terminated UTF-8 strings. `options_json` may be null for
+ * defaults; otherwise it must be readable, terminated UTF-8 JSON.
+ */
+char *webui_protocol_render_lazy_resources(const webui_protocol_t *protocol,
+ const char *owner,
+ const char *id,
+ const char *options_json);
+
+/**
+ * Render complete or terminal JSON with fresh scoped state for a deferred body.
+ *
+ * # Safety
+ *
+ * `protocol` must be a live protocol handle. `state_json`, `owner`, and `id`
+ * must be non-null, readable, terminated UTF-8. `options_json` may be null.
+ * Release a successful returned string with `webui_free`.
+ */
+char *webui_protocol_render_lazy_partial(const webui_protocol_t *protocol,
+ const char *state_json,
+ const char *owner,
+ const char *id,
+ const char *options_json);
+
+/**
+ * Prepare resource JSON and a small inventory-options JSON acknowledgement.
+ *
+ * # Safety
+ *
+ * The protocol/string arguments follow `webui_protocol_render_lazy_resources`.
+ * `updated_options_json` must point to a writable pointer slot and must not be
+ * null. On success free both returned strings with `webui_free`; on failure the
+ * output slot is null.
+ */
+char *webui_protocol_prepare_lazy_resources(const webui_protocol_t *protocol,
+ const char *owner,
+ const char *id,
+ const char *options_json,
+ char **updated_options_json);
+
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
diff --git a/crates/webui-ffi/src/lazy.rs b/crates/webui-ffi/src/lazy.rs
new file mode 100644
index 000000000..2f50bcc83
--- /dev/null
+++ b/crates/webui-ffi/src/lazy.rs
@@ -0,0 +1,350 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use super::{clear_last_error, set_last_error, utf8_arg, webui_protocol_t, ProtocolContext};
+use std::ffi::{c_char, CString};
+use std::panic::{catch_unwind, UnwindSafe};
+use webui_handler::{LazyOptions, LazyRequest};
+
+struct Arguments<'a> {
+ protocol: &'a ProtocolContext,
+ owner: &'a str,
+ id: &'a str,
+ options: LazyOptions,
+}
+
+impl Arguments<'_> {
+ fn request(&self) -> LazyRequest<'_> {
+ LazyRequest::new(self.owner, self.id).with_options(&self.options)
+ }
+}
+
+// SAFETY: Non-null inputs must satisfy the corresponding exported function's contract.
+unsafe fn arguments<'a>(
+ protocol: *const webui_protocol_t,
+ owner: *const c_char,
+ id: *const c_char,
+ options: *const c_char,
+) -> Option> {
+ if protocol.is_null() || owner.is_null() || id.is_null() {
+ set_last_error("protocol, owner, and lazy id must not be null");
+ return None;
+ }
+ // SAFETY: The caller supplies a live protocol and readable terminated strings.
+ let protocol = unsafe { &*protocol.cast::() };
+ // SAFETY: Null pointers were rejected above; validity is the caller's contract.
+ let owner = unsafe { utf8_arg(owner, "owner") }?;
+ // SAFETY: Null pointers were rejected above; validity is the caller's contract.
+ let id = unsafe { utf8_arg(id, "id") }?;
+ let options = if options.is_null() {
+ LazyOptions::default()
+ } else {
+ // SAFETY: A present options pointer must be readable and terminated.
+ let text = unsafe { utf8_arg(options, "lazy options") }?;
+ match serde_json::from_str(text) {
+ Ok(options) => options,
+ Err(error) => {
+ set_last_error(format!("invalid lazy options JSON: {error}"));
+ return None;
+ }
+ }
+ };
+ Some(Arguments {
+ protocol,
+ owner,
+ id,
+ options,
+ })
+}
+
+fn guard(name: &str, operation: impl FnOnce() -> *mut c_char + UnwindSafe) -> *mut c_char {
+ clear_last_error();
+ match catch_unwind(operation) {
+ Ok(value) => value,
+ Err(_) => {
+ set_last_error(format!("panic in {name}"));
+ std::ptr::null_mut()
+ }
+ }
+}
+
+fn string_result(result: webui_handler::Result) -> *mut c_char {
+ match result {
+ Ok(json) => match CString::new(json) {
+ Ok(value) => value.into_raw(),
+ Err(error) => {
+ set_last_error(format!("lazy output contains an interior NUL: {error}"));
+ std::ptr::null_mut()
+ }
+ },
+ Err(error) => {
+ set_last_error(error.to_string());
+ std::ptr::null_mut()
+ }
+ }
+}
+
+/// Render resource JSON for an owner-scoped lazy body. Free it with `webui_free`.
+///
+/// # Safety
+///
+/// `protocol` must be a live handle from `webui_protocol_create`. `owner` and `id`
+/// must be non-null terminated UTF-8 strings. `options_json` may be null for
+/// defaults; otherwise it must be readable, terminated UTF-8 JSON.
+#[no_mangle]
+pub unsafe extern "C" fn webui_protocol_render_lazy_resources(
+ protocol: *const webui_protocol_t,
+ owner: *const c_char,
+ id: *const c_char,
+ options_json: *const c_char,
+) -> *mut c_char {
+ guard("webui_protocol_render_lazy_resources", || {
+ // SAFETY: Forwarded unchanged from this function's documented contract.
+ let Some(args) = (unsafe { arguments(protocol, owner, id, options_json) }) else {
+ return std::ptr::null_mut();
+ };
+ string_result(args.protocol.protocol.render_lazy_resources(args.request()))
+ })
+}
+
+/// Render complete or terminal JSON with fresh scoped state for a lazy body.
+///
+/// # Safety
+///
+/// `protocol` must be a live protocol handle. `state_json`, `owner`, and `id`
+/// must be non-null, readable, terminated UTF-8. `options_json` may be null.
+/// Release a successful returned string with `webui_free`.
+#[no_mangle]
+pub unsafe extern "C" fn webui_protocol_render_lazy_partial(
+ protocol: *const webui_protocol_t,
+ state_json: *const c_char,
+ owner: *const c_char,
+ id: *const c_char,
+ options_json: *const c_char,
+) -> *mut c_char {
+ guard("webui_protocol_render_lazy_partial", || {
+ if state_json.is_null() {
+ set_last_error("state_json must not be null");
+ return std::ptr::null_mut();
+ }
+ // SAFETY: Forwarded unchanged from this function's documented contract.
+ let Some(args) = (unsafe { arguments(protocol, owner, id, options_json) }) else {
+ return std::ptr::null_mut();
+ };
+ // SAFETY: Null was rejected; the caller guarantees a readable C string.
+ let Some(state) = (unsafe { utf8_arg(state_json, "state_json") }) else {
+ return std::ptr::null_mut();
+ };
+ string_result(
+ args.protocol
+ .protocol
+ .render_lazy_partial_json(state, args.request()),
+ )
+ })
+}
+
+/// Prepare resource JSON and a small inventory-options JSON acknowledgement.
+///
+/// # Safety
+///
+/// The protocol/string arguments follow `webui_protocol_render_lazy_resources`.
+/// `updated_options_json` must point to a writable pointer slot and must not be
+/// null. On success free both returned strings with `webui_free`; on failure the
+/// output slot is null.
+#[no_mangle]
+pub unsafe extern "C" fn webui_protocol_prepare_lazy_resources(
+ protocol: *const webui_protocol_t,
+ owner: *const c_char,
+ id: *const c_char,
+ options_json: *const c_char,
+ updated_options_json: *mut *mut c_char,
+) -> *mut c_char {
+ guard("webui_protocol_prepare_lazy_resources", || {
+ if updated_options_json.is_null() {
+ set_last_error("updated_options_json must point to a writable pointer slot");
+ return std::ptr::null_mut();
+ }
+ // SAFETY: The caller guarantees this output slot is writable.
+ unsafe {
+ *updated_options_json = std::ptr::null_mut();
+ }
+ // SAFETY: Forwarded unchanged from this function's documented contract.
+ let Some(args) = (unsafe { arguments(protocol, owner, id, options_json) }) else {
+ return std::ptr::null_mut();
+ };
+ let prepared = match args
+ .protocol
+ .protocol
+ .prepare_lazy_resources(args.request())
+ {
+ Ok(prepared) => prepared,
+ Err(error) => {
+ set_last_error(error.to_string());
+ return std::ptr::null_mut();
+ }
+ };
+ let options = match serde_json::to_string(&prepared.options) {
+ Ok(options) => options,
+ Err(error) => {
+ set_last_error(error.to_string());
+ return std::ptr::null_mut();
+ }
+ };
+ match (CString::new(prepared.json), CString::new(options)) {
+ (Ok(json), Ok(options)) => {
+ // SAFETY: The output slot is valid; ownership of both strings transfers.
+ unsafe {
+ *updated_options_json = options.into_raw();
+ }
+ json.into_raw()
+ }
+ (Err(error), _) | (_, Err(error)) => {
+ set_last_error(format!("lazy output contains an interior NUL: {error}"));
+ std::ptr::null_mut()
+ }
+ }
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::HashMap;
+ use std::ffi::CStr;
+ use webui_protocol::{ClientFragmentData, FragmentList, StateProjectionMode, WebUIProtocol};
+
+ fn protocol() -> *mut webui_protocol_t {
+ let id = "app-shell::lazy:settings";
+ let mut document =
+ WebUIProtocol::new(HashMap::from([(id.into(), FragmentList::default())]));
+ document.build_id = "fixture".into();
+ document.client_fragments.insert(
+ id.into(),
+ ClientFragmentData {
+ owner: "app-shell".into(),
+ name: "settings".into(),
+ entry: true,
+ template_json: r#"{"h":"Inline
","v":3}"#.into(),
+ state_mode: Some(StateProjectionMode::Keys as i32),
+ state_keys: vec!["message".into()],
+ ..Default::default()
+ },
+ );
+ let bytes = document.to_protobuf().unwrap();
+ // SAFETY: The encoded protocol byte slice remains valid for this call.
+ let handle = unsafe { crate::webui_protocol_create(bytes.as_ptr(), bytes.len()) };
+ assert!(!handle.is_null());
+ handle
+ }
+
+ unsafe fn take(pointer: *mut c_char) -> String {
+ assert!(!pointer.is_null());
+ // SAFETY: The test passes a successful, owned FFI string result.
+ let value = unsafe { CStr::from_ptr(pointer) }
+ .to_str()
+ .unwrap()
+ .to_owned();
+ // SAFETY: This allocation is released exactly once through the matching API.
+ unsafe {
+ crate::webui_free(pointer);
+ }
+ value
+ }
+
+ #[test]
+ fn c_abi_prepares_resources_and_renders_fresh_state_with_acknowledgement() {
+ let protocol = protocol();
+ let mut options = std::ptr::null_mut();
+ // SAFETY: The handle is live, C literals are terminated, and the output slot is writable.
+ let metadata = unsafe {
+ webui_protocol_prepare_lazy_resources(
+ protocol,
+ c"app-shell".as_ptr(),
+ c"settings".as_ptr(),
+ std::ptr::null(),
+ &mut options,
+ )
+ };
+ // SAFETY: Both pointers are successful owned results.
+ let metadata = unsafe { take(metadata) };
+ let json: serde_json::Value = serde_json::from_str(&metadata).unwrap();
+ assert!(json.get("state").is_none());
+ // SAFETY: Inputs and the acknowledgement remain live throughout the call.
+ let state = unsafe {
+ webui_protocol_render_lazy_partial(
+ protocol,
+ c"{\"message\":\"fresh\",\"unused\":1}".as_ptr(),
+ c"app-shell".as_ptr(),
+ c"settings".as_ptr(),
+ options,
+ )
+ };
+ // SAFETY: Successful owned result.
+ let state: serde_json::Value = serde_json::from_str(&unsafe { take(state) }).unwrap();
+ assert!(state.get("templates").is_none());
+ assert_eq!(state["state"]["message"], "fresh");
+ assert!(state["state"].get("unused").is_none());
+ // SAFETY: The option allocation is still live and owned by this test.
+ unsafe {
+ crate::webui_free(options);
+ }
+ // SAFETY: The same live handle and valid strings are reused.
+ let plain = unsafe {
+ webui_protocol_render_lazy_resources(
+ protocol,
+ c"app-shell".as_ptr(),
+ c"settings".as_ptr(),
+ std::ptr::null(),
+ )
+ };
+ // SAFETY: Successful owned result.
+ assert_eq!(unsafe { take(plain) }, metadata);
+ // SAFETY: All operations are complete; destroy the live handle once.
+ unsafe {
+ crate::webui_protocol_destroy(protocol);
+ }
+ }
+
+ #[test]
+ fn c_abi_rejects_null_and_invalid_utf8_without_panicking() {
+ // SAFETY: Null arguments are explicitly supported error inputs.
+ let missing = unsafe {
+ webui_protocol_render_lazy_resources(
+ std::ptr::null(),
+ std::ptr::null(),
+ std::ptr::null(),
+ std::ptr::null(),
+ )
+ };
+ assert!(missing.is_null());
+ let protocol = protocol();
+ let invalid = [255u8, 0];
+ // SAFETY: The invalid UTF-8 bytes are a valid, terminated readable C string.
+ let result = unsafe {
+ webui_protocol_render_lazy_resources(
+ protocol,
+ invalid.as_ptr().cast(),
+ c"settings".as_ptr(),
+ std::ptr::null(),
+ )
+ };
+ assert!(result.is_null());
+ let mut options = std::ptr::dangling_mut::();
+ // SAFETY: The output slot is writable; the null protocol is an explicit error input.
+ let result = unsafe {
+ webui_protocol_prepare_lazy_resources(
+ std::ptr::null(),
+ c"app-shell".as_ptr(),
+ c"settings".as_ptr(),
+ std::ptr::null(),
+ &mut options,
+ )
+ };
+ assert!(result.is_null());
+ assert!(options.is_null());
+ // SAFETY: The live handle is destroyed exactly once.
+ unsafe {
+ crate::webui_protocol_destroy(protocol);
+ }
+ }
+}
diff --git a/crates/webui-ffi/src/lib.rs b/crates/webui-ffi/src/lib.rs
index 8d6b46ae9..d08daaa11 100644
--- a/crates/webui-ffi/src/lib.rs
+++ b/crates/webui-ffi/src/lib.rs
@@ -31,7 +31,12 @@ use webui_handler::{
SessionOptions, StreamStep, StreamingSession, WebUIHandler,
};
+mod lazy;
mod render_capacity;
+pub use lazy::{
+ webui_protocol_prepare_lazy_resources, webui_protocol_render_lazy_partial,
+ webui_protocol_render_lazy_resources,
+};
use render_capacity::RenderCapacityHint;
/// Opaque C handle for a loaded WebUI protocol.
diff --git a/crates/webui-handler/benches/handler_bench.rs b/crates/webui-handler/benches/handler_bench.rs
index 09091ef35..e07c90e8c 100644
--- a/crates/webui-handler/benches/handler_bench.rs
+++ b/crates/webui-handler/benches/handler_bench.rs
@@ -601,6 +601,47 @@ fn handler_protocol_construction_bench(c: &mut Criterion) {
});
}
+ for (component_count, fragment_count) in [(1usize, 64usize), (64, 0), (64, 64), (256, 64)] {
+ let mut document = build_construction_document(component_count, 0);
+ for component in 0..component_count {
+ document.components.insert(
+ format!("card-{component}"),
+ webui_protocol::ComponentData {
+ template_json: r#"{"v":3,"h":"","s":[1,3,1]}"#.into(),
+ ..Default::default()
+ },
+ );
+ }
+ for fragment in 0..fragment_count {
+ document.client_fragments.insert(
+ format!("card-0::lazy:region-{fragment}"),
+ webui_protocol::ClientFragmentData {
+ owner: "card-0".into(),
+ name: format!("region-{fragment}"),
+ entry: true,
+ template_json: r#"{"v":3,"h":"","s":[1,3,5,1,1]}"#.into(),
+ ..Default::default()
+ },
+ );
+ }
+ group.throughput(Throughput::Elements(
+ u64::try_from(component_count + fragment_count).unwrap_or(u64::MAX),
+ ));
+ group.bench_function(
+ BenchmarkId::new(
+ "client_templates",
+ format!("{component_count}c_{fragment_count}f"),
+ ),
+ |b| {
+ b.iter_batched(
+ || document.clone(),
+ |document| Protocol::new(black_box(document)),
+ criterion::BatchSize::SmallInput,
+ );
+ },
+ );
+ }
+
group.finish();
}
diff --git a/crates/webui-handler/src/lazy/index.rs b/crates/webui-handler/src/lazy/index.rs
new file mode 100644
index 000000000..b35cca18c
--- /dev/null
+++ b/crates/webui-handler/src/lazy/index.rs
@@ -0,0 +1,152 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use std::collections::HashMap;
+use std::ops::Range;
+use std::sync::Arc;
+use webui_protocol::{web_ui_fragment::Fragment, FragmentList, WebUIProtocol, WebUiFragmentRoute};
+
+pub(super) const HAS_LAZY: u8 = 1;
+pub(super) const HAS_OUTLET: u8 = 2;
+
+pub(crate) struct LazyIndex {
+ pub(super) records: Box<[RecordPlan]>,
+}
+
+pub(super) struct RecordPlan {
+ pub(super) flags: u8,
+ pub(super) steps: Box<[Step]>,
+}
+
+#[derive(Clone)]
+pub(super) enum Step {
+ Component {
+ position: usize,
+ attributes: Range,
+ },
+ Condition(usize),
+ Repeat(usize),
+ Route(usize),
+ Outlet,
+}
+
+impl LazyIndex {
+ pub(crate) fn new(
+ protocol: &WebUIProtocol,
+ ids: &[Arc],
+ slots: &HashMap, u32>,
+ ) -> Option {
+ if !protocol.fragments.values().any(|list| list.fragments.iter().any(|fragment| {
+ matches!(fragment.fragment.as_ref(), Some(Fragment::IfCond(condition)) if condition.lazy)
+ })) {
+ return None;
+ }
+
+ let mut flags = vec![0u8; ids.len()];
+ let mut reverse = vec![Vec::new(); ids.len()];
+ let mut targets = Vec::new();
+ for (slot, id) in ids.iter().enumerate() {
+ let Some(list) = protocol.fragments.get(id.as_ref()) else {
+ continue;
+ };
+ targets.clear();
+ for fragment in &list.fragments {
+ match fragment.fragment.as_ref() {
+ Some(Fragment::IfCond(condition)) => {
+ if condition.lazy {
+ flags[slot] |= HAS_LAZY;
+ }
+ targets.push(condition.fragment_id.as_str());
+ }
+ Some(Fragment::ForLoop(repeat)) => targets.push(repeat.fragment_id.as_str()),
+ Some(Fragment::Component(component)) => {
+ targets.push(component.fragment_id.as_str())
+ }
+ Some(Fragment::Route(route)) => route_targets(route, &mut targets),
+ Some(Fragment::Outlet(_)) => flags[slot] |= HAS_OUTLET,
+ _ => {}
+ }
+ }
+ for target in &targets {
+ if let Some(&child) = slots.get(*target) {
+ reverse[child as usize].push(slot);
+ }
+ }
+ }
+ propagate(&mut flags, &reverse);
+ let records = ids
+ .iter()
+ .zip(flags)
+ .map(|(id, flags)| {
+ let steps = protocol
+ .fragments
+ .get(id.as_ref())
+ .filter(|_| flags != 0)
+ .map_or_else(Vec::new, compile_steps);
+ RecordPlan {
+ flags,
+ steps: steps.into_boxed_slice(),
+ }
+ })
+ .collect();
+ Some(Self { records })
+ }
+
+ pub(crate) fn relevant(&self, slot: usize, has_route_children: bool) -> bool {
+ self.records.get(slot).is_some_and(|record| {
+ record.flags & HAS_LAZY != 0 || (has_route_children && record.flags & HAS_OUTLET != 0)
+ })
+ }
+}
+
+fn route_targets<'a>(route: &'a WebUiFragmentRoute, targets: &mut Vec<&'a str>) {
+ let mut pending = vec![route];
+ while let Some(route) = pending.pop() {
+ targets.push(route.fragment_id.as_str());
+ if !route.content_fragment_id.is_empty() {
+ targets.push(route.content_fragment_id.as_str());
+ }
+ pending.extend(&route.children);
+ }
+}
+
+fn propagate(flags: &mut [u8], reverse: &[Vec]) {
+ let mut pending: Vec = flags
+ .iter()
+ .enumerate()
+ .filter_map(|(slot, flags)| (*flags != 0).then_some(slot))
+ .collect();
+ while let Some(child) = pending.pop() {
+ for &parent in &reverse[child] {
+ let next = flags[parent] | flags[child];
+ if next != flags[parent] {
+ flags[parent] = next;
+ pending.push(parent);
+ }
+ }
+ }
+}
+
+fn compile_steps(list: &FragmentList) -> Vec {
+ let mut steps = Vec::new();
+ let mut attributes = None;
+ for (position, fragment) in list.fragments.iter().enumerate() {
+ match fragment.fragment.as_ref() {
+ Some(Fragment::Attribute(attribute)) if attribute.attr_start => {
+ attributes = Some(position)
+ }
+ Some(Fragment::Component(_)) => {
+ steps.push(Step::Component {
+ position,
+ attributes: attributes.take().unwrap_or(position)..position,
+ });
+ }
+ Some(Fragment::IfCond(_)) => steps.push(Step::Condition(position)),
+ Some(Fragment::ForLoop(_)) => steps.push(Step::Repeat(position)),
+ Some(Fragment::Route(_)) => steps.push(Step::Route(position)),
+ Some(Fragment::Outlet(_)) => steps.push(Step::Outlet),
+ _ => {}
+ }
+ }
+ steps
+}
diff --git a/crates/webui-handler/src/lazy/mod.rs b/crates/webui-handler/src/lazy/mod.rs
new file mode 100644
index 000000000..55eacb6d9
--- /dev/null
+++ b/crates/webui-handler/src/lazy/mod.rs
@@ -0,0 +1,405 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+mod index;
+mod scope;
+
+use crate::{
+ route_matcher, route_renderer, HandlerError, LoopBinding, RenderFragmentList,
+ ResolvedRenderFragmentIndex, Result, VisibleLoopScope,
+};
+use index::{Step, HAS_LAZY};
+use scope::{Scope, Values};
+use serde_json::Value;
+use std::borrow::Cow;
+use std::collections::HashSet;
+use webui_protocol::{
+ web_ui_fragment::Fragment, WebUIFragmentFor, WebUIProtocol, WebUiFragmentRoute,
+};
+
+pub(crate) use index::LazyIndex;
+pub(crate) type LazySelection<'a> = HashSet<&'a str>;
+
+const MAX_PLAN_STEPS: usize = 1_000_000;
+const MAX_PLAN_DEPTH: usize = 1024;
+
+pub(crate) struct SelectionInput<'protocol, 'state, 'index> {
+ pub(crate) protocol: &'protocol WebUIProtocol,
+ pub(crate) render: &'index ResolvedRenderFragmentIndex<'protocol>,
+ pub(crate) index: &'protocol LazyIndex,
+ pub(crate) state: &'state Value,
+ pub(crate) entry: &'protocol str,
+ pub(crate) request_path: &'protocol str,
+ pub(crate) routes: &'protocol route_matcher::CompiledRouteIndex,
+}
+
+enum Frame<'protocol, 'state> {
+ Record {
+ slot: usize,
+ next: usize,
+ matched: Option<(String, usize)>,
+ },
+ ComponentEnd,
+ Component(&'protocol str),
+ Repeat {
+ repeat: &'protocol WebUIFragmentFor,
+ items: &'state [Value],
+ next: usize,
+ },
+ IterationEnd(VisibleLoopScope),
+ RepeatEnd {
+ name: &'protocol str,
+ owned: Option,
+ borrowed: Option<&'state Value>,
+ },
+ RouteEnd {
+ base: Cow<'protocol, str>,
+ children: &'protocol [WebUiFragmentRoute],
+ },
+}
+
+struct Selection<'protocol, 'state, 'index> {
+ input: SelectionInput<'protocol, 'state, 'index>,
+ frames: Vec>,
+ scopes: Vec>,
+ loops: Vec>,
+ active: LazySelection<'protocol>,
+ base: Cow<'protocol, str>,
+ children: &'protocol [WebUiFragmentRoute],
+}
+
+pub(crate) fn select<'protocol>(
+ input: SelectionInput<'protocol, '_, '_>,
+) -> Result> {
+ let slot = input
+ .render
+ .index(input.entry)
+ .ok_or_else(|| HandlerError::MissingFragment(input.entry.to_owned()))?;
+ if input.index.records[slot].flags & HAS_LAZY == 0 {
+ return Ok(HashSet::new());
+ }
+ let mut selection = Selection {
+ input,
+ frames: Vec::with_capacity(16),
+ scopes: vec![Scope::new(0)],
+ loops: Vec::new(),
+ active: HashSet::new(),
+ base: Cow::Borrowed("/"),
+ children: &[],
+ };
+ selection.enter_record(slot)?;
+ let mut steps = 0;
+ while let Some(frame) = selection.frames.pop() {
+ steps += 1;
+ if steps > MAX_PLAN_STEPS || selection.frames.len() > MAX_PLAN_DEPTH {
+ return Err(selection_limit());
+ }
+ selection.advance(frame)?;
+ }
+ Ok(selection.active)
+}
+
+impl<'protocol, 'state> Selection<'protocol, 'state, '_> {
+ fn values(&self) -> Values<'_, 'protocol, 'state> {
+ Values {
+ scope: &self.scopes[self.scopes.len() - 1],
+ loops: &self.loops,
+ state: self.input.state,
+ }
+ }
+
+ fn record(&self, slot: usize) -> Result> {
+ self.input
+ .render
+ .list(slot)
+ .ok_or_else(|| HandlerError::Invariant("lazy selection lost a compiled record".into()))
+ }
+
+ fn target(&self, id: &str) -> Result {
+ self.input
+ .render
+ .index(id)
+ .ok_or_else(|| HandlerError::MissingFragment(id.to_owned()))
+ }
+
+ fn relevant(&self, slot: usize) -> bool {
+ self.input.index.relevant(slot, !self.children.is_empty())
+ }
+
+ fn enter_record(&mut self, slot: usize) -> Result<()> {
+ if !self.relevant(slot) {
+ return Ok(());
+ }
+ let record = self.record(slot)?;
+ let matched = if record.has_routes {
+ route_renderer::find_best_route_match(
+ record.fragments,
+ self.input.request_path,
+ &self.base,
+ self.input.routes,
+ )
+ .map(|(name, matched)| (name, matched.consumed_segments))
+ } else {
+ None
+ };
+ self.frames.push(Frame::Record {
+ slot,
+ next: 0,
+ matched,
+ });
+ Ok(())
+ }
+
+ fn advance(&mut self, frame: Frame<'protocol, 'state>) -> Result<()> {
+ match frame {
+ Frame::Record {
+ slot,
+ next,
+ matched,
+ } => {
+ let Some(step) = self.input.index.records[slot].steps.get(next).cloned() else {
+ return Ok(());
+ };
+ let consumed = match (&step, &matched) {
+ (Step::Route(position), Some((name, consumed))) => {
+ match self.record(slot)?.fragments[*position].fragment.as_ref() {
+ Some(Fragment::Route(route)) if route.fragment_id == *name => {
+ Some(*consumed)
+ }
+ _ => None,
+ }
+ }
+ _ => None,
+ };
+ self.frames.push(Frame::Record {
+ slot,
+ next: next + 1,
+ matched,
+ });
+ self.step(slot, step, consumed)
+ }
+ Frame::ComponentEnd => {
+ self.scopes.pop();
+ Ok(())
+ }
+ Frame::Component(id) => self.component(self.target(id)?, Scope::new(self.loops.len())),
+ Frame::Repeat {
+ repeat,
+ items,
+ next,
+ } => self.repeat(repeat, items, next),
+ Frame::IterationEnd(visible) => {
+ self.loops.pop();
+ let last = self.scopes.len() - 1;
+ self.scopes[last].visible = visible;
+ Ok(())
+ }
+ Frame::RepeatEnd {
+ name,
+ owned,
+ borrowed,
+ } => {
+ let last = self.scopes.len() - 1;
+ if let Some(value) = owned {
+ self.scopes[last].owned.insert(name.to_owned(), value);
+ }
+ if let Some(value) = borrowed {
+ self.scopes[last].borrowed.insert(name, value);
+ }
+ Ok(())
+ }
+ Frame::RouteEnd { base, children } => {
+ self.base = base;
+ self.children = children;
+ Ok(())
+ }
+ }
+ }
+
+ fn step(&mut self, slot: usize, step: Step, consumed: Option) -> Result<()> {
+ let record = self.record(slot)?;
+ match step {
+ Step::Component {
+ position,
+ attributes,
+ } => {
+ let Some(Fragment::Component(component)) =
+ record.fragments[position].fragment.as_ref()
+ else {
+ return Err(invalid_plan());
+ };
+ let target = self.target(&component.fragment_id)?;
+ if !self.relevant(target) {
+ return Ok(());
+ }
+ let mut scope = Scope::new(self.loops.len());
+ for position in attributes {
+ if let Some(Fragment::Attribute(attribute)) =
+ record.fragments[position].fragment.as_ref()
+ {
+ if let Some(name) = record.component_attr_name(position) {
+ self.values().attribute(
+ attribute,
+ name,
+ self.input.protocol,
+ &mut scope,
+ )?;
+ }
+ }
+ }
+ self.component(target, scope)
+ }
+ Step::Condition(position) => {
+ let fragment = record.fragments[position].fragment.as_ref();
+ let (condition, lazy) = match fragment {
+ Some(Fragment::IfCond(condition)) => (condition, condition.lazy),
+ _ => return Err(invalid_plan()),
+ };
+ let target = self.target(&condition.fragment_id)?;
+ if !lazy && !self.relevant(target) {
+ return Ok(());
+ }
+ let expression = condition.condition.as_ref().ok_or_else(invalid_plan)?;
+ if self.values().condition(expression)? {
+ if lazy {
+ self.active.insert(condition.fragment_id.as_str());
+ }
+ self.enter_record(target)?;
+ }
+ Ok(())
+ }
+ Step::Repeat(position) => {
+ let Some(Fragment::ForLoop(repeat)) = record.fragments[position].fragment.as_ref()
+ else {
+ return Err(invalid_plan());
+ };
+ if !self.relevant(self.target(&repeat.fragment_id)?) {
+ return Ok(());
+ }
+ let items = self.values().collection(&repeat.collection)?;
+ let last = self.scopes.len() - 1;
+ let owned = self.scopes[last].owned.remove(repeat.item.as_str());
+ let borrowed = self.scopes[last].borrowed.remove(&repeat.item);
+ self.frames.push(Frame::RepeatEnd {
+ name: &repeat.item,
+ owned,
+ borrowed,
+ });
+ self.frames.push(Frame::Repeat {
+ repeat,
+ items,
+ next: 0,
+ });
+ Ok(())
+ }
+ Step::Route(position) => {
+ let Some(Fragment::Route(route)) = record.fragments[position].fragment.as_ref()
+ else {
+ return Err(invalid_plan());
+ };
+ if let Some(consumed) = consumed {
+ self.route(route, consumed)?;
+ }
+ Ok(())
+ }
+ Step::Outlet => self.outlet(),
+ }
+ }
+
+ fn component(&mut self, slot: usize, scope: Scope<'protocol, 'state>) -> Result<()> {
+ if !self.relevant(slot) {
+ return Ok(());
+ }
+ self.scopes.push(scope);
+ self.frames.push(Frame::ComponentEnd);
+ self.enter_record(slot)
+ }
+
+ fn repeat(
+ &mut self,
+ repeat: &'protocol WebUIFragmentFor,
+ items: &'state [Value],
+ next: usize,
+ ) -> Result<()> {
+ let Some(item) = items.get(next) else {
+ return Ok(());
+ };
+ self.frames.push(Frame::Repeat {
+ repeat,
+ items,
+ next: next + 1,
+ });
+ let last = self.scopes.len() - 1;
+ self.frames
+ .push(Frame::IterationEnd(self.scopes[last].visible));
+ self.loops.push(LoopBinding {
+ name: &repeat.item,
+ value: item,
+ });
+ self.scopes[last].visible.end = self.loops.len();
+ self.enter_record(self.target(&repeat.fragment_id)?)
+ }
+
+ fn route(&mut self, route: &'protocol WebUiFragmentRoute, consumed: usize) -> Result<()> {
+ let base = std::mem::replace(
+ &mut self.base,
+ Cow::Owned(route_matcher::compute_route_base(
+ self.input.request_path,
+ consumed,
+ )),
+ );
+ let children = std::mem::replace(&mut self.children, &route.children);
+ self.frames.push(Frame::RouteEnd { base, children });
+ if !route.fragment_id.is_empty() {
+ self.frames.push(Frame::Component(&route.fragment_id));
+ }
+ if !route.content_fragment_id.is_empty() {
+ self.enter_record(self.target(&route.content_fragment_id)?)?;
+ }
+ Ok(())
+ }
+
+ fn outlet(&mut self) -> Result<()> {
+ let children = std::mem::take(&mut self.children);
+ let segments = route_matcher::split_request_path(self.input.request_path);
+ let mut best = None;
+ for route in children {
+ if let Some(matched) = route_matcher::match_route_indexed_with_segments(
+ self.input.routes,
+ &route.path,
+ &self.base,
+ &segments,
+ route.exact,
+ ) {
+ if best
+ .as_ref()
+ .is_none_or(|(_, previous): &(_, route_matcher::RouteMatch)| {
+ matched.specificity > previous.specificity
+ })
+ {
+ best = Some((route, matched));
+ }
+ }
+ }
+ if let Some((route, matched)) = best {
+ self.route(route, matched.consumed_segments)?;
+ }
+ Ok(())
+ }
+}
+
+#[cold]
+#[inline(never)]
+fn invalid_plan() -> HandlerError {
+ HandlerError::Invariant(
+ "invalid compiled lazy-selection instruction; rebuild the protocol".into(),
+ )
+}
+
+#[cold]
+#[inline(never)]
+fn selection_limit() -> HandlerError {
+ HandlerError::Rendering(
+ "lazy-selection limit exceeded; reduce nested or repeated lazy regions".into(),
+ )
+}
diff --git a/crates/webui-handler/src/lazy/scope.rs b/crates/webui-handler/src/lazy/scope.rs
new file mode 100644
index 000000000..fc1170ec8
--- /dev/null
+++ b/crates/webui-handler/src/lazy/scope.rs
@@ -0,0 +1,160 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use crate::{
+ format_plain_json_scalar, resolve_borrowed_collection, resolve_state_backed_value,
+ resolve_value_from_sources, BorrowedScope, HandlerError, LocalValueSources, LoopBinding,
+ Result, ScalarBuffer, ScopeMap, VisibleLoopScope,
+};
+use serde_json::Value;
+use std::borrow::Cow;
+use webui_expressions::{evaluate_with_resolver, ExpressionError};
+use webui_protocol::{
+ web_ui_fragment::Fragment, ConditionExpr, WebUIFragmentAttribute, WebUIProtocol,
+};
+
+pub(super) struct Scope<'protocol, 'state> {
+ pub(super) owned: ScopeMap,
+ pub(super) borrowed: BorrowedScope<'protocol, 'state>,
+ pub(super) visible: VisibleLoopScope,
+}
+
+impl Scope<'_, '_> {
+ pub(super) fn new(loop_depth: usize) -> Self {
+ Self {
+ owned: ScopeMap::default(),
+ borrowed: BorrowedScope::default(),
+ visible: VisibleLoopScope {
+ start: loop_depth,
+ end: loop_depth,
+ },
+ }
+ }
+}
+
+pub(super) struct Values<'context, 'protocol, 'state> {
+ pub(super) scope: &'context Scope<'protocol, 'state>,
+ pub(super) loops: &'context [LoopBinding<'protocol, 'state>],
+ pub(super) state: &'state Value,
+}
+
+impl<'context, 'protocol, 'state> Values<'context, 'protocol, 'state> {
+ fn locals(&self) -> LocalValueSources<'_, 'protocol, 'state> {
+ LocalValueSources {
+ owned: &self.scope.owned,
+ borrowed: &self.scope.borrowed,
+ }
+ }
+
+ fn resolve(&self, path: &str) -> Option> {
+ resolve_value_from_sources(
+ path,
+ self.loops,
+ self.scope.visible,
+ self.locals(),
+ self.state,
+ )
+ }
+
+ pub(super) fn condition(&self, condition: &ConditionExpr) -> Result {
+ match evaluate_with_resolver(condition, |path| self.resolve(path)) {
+ Ok(value) => Ok(value),
+ Err(ExpressionError::MissingValue(_)) => Ok(false),
+ Err(error) => Err(HandlerError::Evaluation(error.to_string())),
+ }
+ }
+
+ pub(super) fn collection(&self, path: &str) -> Result<&'state [Value]> {
+ if let Some(items) = resolve_borrowed_collection(
+ path,
+ self.loops,
+ self.scope.visible,
+ self.locals(),
+ self.state,
+ ) {
+ return Ok(items);
+ }
+ match self.resolve(path) {
+ None => Ok(&[]),
+ Some(_) => Err(non_array_collection(path)),
+ }
+ }
+
+ pub(super) fn attribute(
+ &self,
+ attribute: &'protocol WebUIFragmentAttribute,
+ name: &'protocol str,
+ protocol: &WebUIProtocol,
+ target: &mut Scope<'protocol, 'state>,
+ ) -> Result<()> {
+ if attribute.attr_skip {
+ return Ok(());
+ }
+ let owned = if let Some(condition) = &attribute.condition_tree {
+ Some(Value::Bool(self.condition(condition)?))
+ } else if !attribute.template.is_empty() {
+ Some(Value::String(
+ self.template_attribute(protocol, &attribute.template)?,
+ ))
+ } else if attribute.raw_value {
+ Some(Value::String(attribute.value.clone()))
+ } else if attribute.value.is_empty() {
+ None
+ } else if let Some(value) = resolve_state_backed_value(
+ &attribute.value,
+ self.loops,
+ self.scope.visible,
+ self.locals(),
+ self.state,
+ ) {
+ target.owned.remove(name);
+ target.borrowed.insert(name, value);
+ return Ok(());
+ } else {
+ self.resolve(&attribute.value)
+ .map(Cow::into_owned)
+ .or_else(|| (!attribute.complex).then(|| Value::String(String::new())))
+ };
+ if let Some(value) = owned {
+ target.borrowed.remove(name);
+ target.owned.insert(name.to_owned(), value);
+ }
+ Ok(())
+ }
+
+ fn template_attribute(&self, protocol: &WebUIProtocol, id: &str) -> Result {
+ let list = protocol
+ .fragments
+ .get(id)
+ .ok_or_else(|| HandlerError::MissingFragment(id.to_owned()))?;
+ let mut value = String::with_capacity(crate::template_attr_capacity(&list.fragments));
+ for fragment in &list.fragments {
+ match fragment.fragment.as_ref() {
+ Some(Fragment::Raw(raw)) => value.push_str(&raw.value),
+ Some(Fragment::Signal(signal)) => {
+ if let Some(resolved) = self.resolve(&signal.value) {
+ match resolved.as_ref() {
+ Value::String(text) => value.push_str(text),
+ other => {
+ let mut scratch = ScalarBuffer::new();
+ if let Some(text) = format_plain_json_scalar(&mut scratch, other) {
+ value.push_str(text);
+ } else {
+ value.push_str(&other.to_string());
+ }
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ Ok(value)
+ }
+}
+
+#[cold]
+#[inline(never)]
+fn non_array_collection(path: &str) -> HandlerError {
+ HandlerError::TypeError(format!("Collection '{path}' is not a state-backed array"))
+}
diff --git a/crates/webui-handler/src/lazy_response.rs b/crates/webui-handler/src/lazy_response.rs
new file mode 100644
index 000000000..8009c2483
--- /dev/null
+++ b/crates/webui-handler/src/lazy_response.rs
@@ -0,0 +1,546 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+mod serialization;
+
+use crate::{lazy, route_handler, HandlerError, Protocol, Result};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use serialization::write_record;
+use std::collections::HashSet;
+use webui_protocol::{lazy_fragment_id, web_ui_fragment::Fragment};
+
+/// Client availability used by lazy-template requests.
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
+pub struct LazyOptions {
+ /// Loaded template-resource inventory.
+ pub inventory: String,
+ /// Loaded stylesheet-definition inventory.
+ pub style_inventory: String,
+ /// Artifact identity associated with the client inventory.
+ pub build_id: String,
+}
+
+/// A resource record and its acknowledgement, without reparsing the record JSON.
+#[derive(Debug, Serialize)]
+pub struct PreparedLazyResources {
+ /// JSON resource record to flush before waiting for application data.
+ pub json: String,
+ /// Availability to pass to the terminal state call after flushing `json`.
+ pub options: LazyOptions,
+}
+
+/// An owner-scoped lazy template request. It does not create a component.
+#[derive(Clone, Copy, Debug)]
+pub struct LazyRequest<'a> {
+ /// The owning template, usually a component tag.
+ pub owner: &'a str,
+ /// The lazy if's authored id.
+ pub id: &'a str,
+ /// Loaded client template resources.
+ pub inventory: &'a str,
+ /// Loaded stylesheet definitions.
+ pub style_inventory: &'a str,
+ /// Client artifact identity; required with nonempty inventory.
+ pub build_id: &'a str,
+}
+
+impl<'a> LazyRequest<'a> {
+ /// Request a named region without assuming any client resources.
+ #[must_use]
+ pub fn new(owner: &'a str, id: &'a str) -> Self {
+ Self {
+ owner,
+ id,
+ inventory: "",
+ style_inventory: "",
+ build_id: "",
+ }
+ }
+
+ /// Borrow client availability from deserialized host options.
+ #[must_use]
+ pub fn with_options(mut self, options: &'a LazyOptions) -> Self {
+ self.inventory = &options.inventory;
+ self.style_inventory = &options.style_inventory;
+ self.build_id = &options.build_id;
+ self
+ }
+}
+
+/// An actionable lazy-resource request failure.
+#[derive(Debug)]
+pub struct LazyRequestError {
+ /// Stable machine-readable failure code.
+ pub code: &'static str,
+ /// Plain diagnostic text with a corrective action.
+ pub message: String,
+}
+
+impl Protocol {
+ /// Prepare resource JSON and the small inventory acknowledgement for streaming.
+ ///
+ /// Hosts can flush `json`, start/await their data provider, then pass `options`
+ /// to the state call without parsing or retaining a second metadata tree.
+ ///
+ /// # Errors
+ ///
+ /// Returns the same errors as [`Self::render_lazy_resources`].
+ #[must_use = "flush the resource record and carry its acknowledgement into the state call"]
+ pub fn prepare_lazy_resources(
+ &self,
+ request: LazyRequest<'_>,
+ ) -> Result {
+ let root = validate_request(self, request)?;
+ let selected = collect_resources(self, root, None)?;
+ let record = prepare_resources(self, root, request, selected)?;
+ let options = LazyOptions {
+ inventory: record.inventory.clone(),
+ style_inventory: record.style_inventory.clone(),
+ build_id: self.protocol().build_id.clone(),
+ };
+ Ok(PreparedLazyResources {
+ json: write_record(record, None::<&Value>)?,
+ options,
+ })
+ }
+
+ /// Render a resource-only record for a lazy template body.
+ ///
+ /// Ordinary block/component dependencies are included; nested lazy
+ /// bodies remain independent. This operation loads no application state.
+ ///
+ /// # Errors
+ ///
+ /// Rejects unknown regions, incompatible builds, invalid inventories, and
+ /// malformed compiled metadata.
+ #[must_use = "handle lazy resource errors before sending a response"]
+ pub fn render_lazy_resources(&self, request: LazyRequest<'_>) -> Result {
+ let root = validate_request(self, request)?;
+ let selected = collect_resources(self, root, None)?;
+ let record = prepare_resources(self, root, request, selected)?;
+ write_record(record, None::<&Value>)
+ }
+
+ /// Render a complete lazy response, or the terminal state record of NDJSON.
+ ///
+ /// State belongs to the existing owner/scope. Inventory never suppresses
+ /// state. If the state activates nested lazy bodies, this record adds
+ /// their missing resources before the client mounts anything.
+ ///
+ /// # Errors
+ ///
+ /// Returns request/protocol errors or an invalid-state error for non-object input.
+ #[must_use = "handle lazy response errors before sending a response"]
+ pub fn render_lazy_partial(&self, state: &Value, request: LazyRequest<'_>) -> Result {
+ let root = validate_request(self, request)?;
+ if !state.is_object() {
+ return Err(HandlerError::InvalidState(
+ "lazy state must be a JSON object".into(),
+ ));
+ }
+ let render = self.render_fragments().resolve(self.protocol());
+ let active = self
+ .lazy_index()
+ .map(|index| {
+ lazy::select(lazy::SelectionInput {
+ protocol: self.protocol(),
+ render: &render,
+ index,
+ state,
+ entry: root,
+ request_path: "/",
+ routes: self.route_index(),
+ })
+ })
+ .transpose()?;
+ let selected = collect_resources(self, root, active.as_ref())?;
+ let selection = crate::collect_navigation_state(self.protocol(), selected.iter().copied());
+ let state = serialization::SelectedState {
+ value: state,
+ selection: &selection,
+ };
+ let record = prepare_resources(self, root, request, selected)?;
+ write_record(record, Some(&state))
+ }
+
+ /// Render lazy state from JSON, borrowing projected values when possible.
+ ///
+ /// State is parsed once only when nested lazy decisions need evaluation;
+ /// parsed-state Rust hosts can use [`Self::render_lazy_partial`] directly.
+ ///
+ /// # Errors
+ ///
+ /// Returns the same errors as [`Self::render_lazy_partial`], including
+ /// malformed state JSON.
+ #[must_use = "handle lazy response errors before sending a response"]
+ pub fn render_lazy_partial_json(
+ &self,
+ state_json: &str,
+ request: LazyRequest<'_>,
+ ) -> Result {
+ let root = validate_request(self, request)?;
+ if self.lazy_index().is_some_and(|index| {
+ self.fragment_slot(root)
+ .is_some_and(|slot| index.relevant(slot as usize, false))
+ }) {
+ let state = serde_json::from_str(state_json)
+ .map_err(|error| HandlerError::InvalidState(error.to_string()))?;
+ return self.render_lazy_partial(&state, request);
+ }
+ if !state_json.trim_start().starts_with('{') {
+ return Err(HandlerError::InvalidState(
+ "lazy state must be a JSON object".into(),
+ ));
+ }
+ let selected = collect_resources(self, root, None)?;
+ let selection = crate::collect_navigation_state(self.protocol(), selected.iter().copied());
+ let state = route_handler::select_raw_state(state_json, &selection)?;
+ let record = prepare_resources(self, root, request, selected)?;
+ write_record(record, Some(&state))
+ }
+}
+
+fn validate_request<'a>(protocol: &'a Protocol, request: LazyRequest<'_>) -> Result<&'a str> {
+ protocol.ensure_style_metadata()?;
+ let document = protocol.protocol();
+ if document.build_id.is_empty() {
+ return Err(request_error(
+ "lazy-missing-build-id",
+ "rebuild the protocol with the current compiler before requesting lazy regions",
+ ));
+ }
+ if !request.build_id.is_empty() && request.build_id != document.build_id {
+ return Err(request_error(
+ "lazy-build-mismatch",
+ "the document and protocol are different builds; reload the document",
+ ));
+ }
+ if request.build_id.is_empty()
+ && (!request.inventory.is_empty() || !request.style_inventory.is_empty())
+ {
+ return Err(request_error(
+ "lazy-missing-build-id",
+ "send the document buildId with its inventory, or request without inventory",
+ ));
+ }
+ if request.owner.len() > 512 || request.id.is_empty() || request.id.len() > 128 {
+ return Err(request_error(
+ "lazy-invalid-target",
+ "use the owning template and an authored lazy id",
+ ));
+ }
+ if request.inventory.len() > protocol.component_index().len().div_ceil(8) * 2
+ || request.style_inventory.len() > protocol.style_resource_index().len().div_ceil(8) * 2
+ {
+ return Err(request_error(
+ "lazy-invalid-inventory",
+ "inventory exceeds this artifact's resource count; use inventory from the same build",
+ ));
+ }
+ let key = lazy_fragment_id(request.owner, Some(request.id), 0);
+ document.client_fragments.get_key_value(&key)
+ .filter(|(_, data)| data.entry && data.owner == request.owner)
+ .map(|(key, _)| key.as_str())
+ .ok_or_else(|| request_error("lazy-not-found", "declare in this owning template and rebuild"))
+}
+
+fn collect_resources<'a>(
+ protocol: &'a Protocol,
+ root: &'a str,
+ active: Option<&lazy::LazySelection<'a>>,
+) -> Result> {
+ let document = protocol.protocol();
+ let mut pending = vec![root];
+ let mut visited = HashSet::new();
+ let mut selected = Vec::new();
+ while let Some(id) = pending.pop() {
+ if !visited.insert(id) {
+ continue;
+ }
+ if document
+ .client_template(id)
+ .is_some_and(|data| !data.metadata.is_empty() || !data.legacy_html.is_empty())
+ {
+ selected.push(id);
+ }
+ if let Some(fragment) = document.client_fragments.get(id) {
+ pending.extend(fragment.dependencies.iter().map(String::as_str));
+ }
+ let Some(list) = document.fragments.get(id) else {
+ if document.client_fragments.contains_key(id) {
+ continue;
+ }
+ return Err(HandlerError::MissingFragment(id.to_owned()));
+ };
+ for fragment in list.fragments.iter().rev() {
+ match fragment.fragment.as_ref() {
+ Some(Fragment::Component(component)) => pending.push(&component.fragment_id),
+ Some(Fragment::ForLoop(repeat)) => pending.push(&repeat.fragment_id),
+ Some(Fragment::IfCond(condition))
+ if !condition.lazy
+ || active.is_some_and(|active| {
+ active.contains(condition.fragment_id.as_str())
+ }) =>
+ {
+ pending.push(&condition.fragment_id);
+ }
+ Some(Fragment::Attribute(attribute)) if !attribute.template.is_empty() => {
+ pending.push(&attribute.template)
+ }
+ _ => {}
+ }
+ }
+ }
+ Ok(selected)
+}
+
+struct ResourceRecord<'a> {
+ protocol: &'a Protocol,
+ root: &'a str,
+ needed: Vec<&'a str>,
+ required: Vec<&'a str>,
+ styles: route_handler::BorrowedComponentStyleDelta<'a>,
+ inventory: String,
+ style_inventory: String,
+}
+
+fn prepare_resources<'a>(
+ protocol: &'a Protocol,
+ root: &'a str,
+ request: LazyRequest<'_>,
+ selected: Vec<&'a str>,
+) -> Result> {
+ let mut inventory =
+ route_handler::parse_inventory(request.inventory).map_err(invalid_inventory)?;
+ let mut style_inventory =
+ route_handler::parse_inventory(request.style_inventory).map_err(invalid_inventory)?;
+ let styles = route_handler::collect_borrowed_component_style_delta(
+ protocol.protocol(),
+ selected
+ .iter()
+ .copied()
+ .filter(|id| !protocol.protocol().client_fragments.contains_key(*id)),
+ &style_inventory,
+ protocol.style_resource_index(),
+ &protocol.protocol().style_chunk_index(),
+ )?;
+ for name in styles.resource_names() {
+ if let Some(&index) = protocol.style_resource_index().get(name) {
+ route_handler::set_component(&mut style_inventory, index);
+ }
+ }
+ let mut needed = Vec::with_capacity(selected.len());
+ for &id in &selected {
+ if let Some(&index) = protocol.component_index().get(id) {
+ if !route_handler::has_component(&inventory, index) {
+ needed.push(id);
+ }
+ route_handler::set_component(&mut inventory, index);
+ } else {
+ needed.push(id);
+ }
+ }
+ needed.sort_unstable();
+ let mut required = selected;
+ required.sort_unstable();
+ Ok(ResourceRecord {
+ protocol,
+ root,
+ needed,
+ required,
+ styles,
+ inventory: route_handler::encode_inventory(&inventory),
+ style_inventory: route_handler::encode_inventory(&style_inventory),
+ })
+}
+
+#[cold]
+#[inline(never)]
+fn request_error(code: &'static str, message: &str) -> HandlerError {
+ HandlerError::LazyRequest(Box::new(LazyRequestError {
+ code,
+ message: message.into(),
+ }))
+}
+
+#[cold]
+#[inline(never)]
+fn invalid_inventory(error: HandlerError) -> HandlerError {
+ request_error(
+ "lazy-invalid-inventory",
+ &format!("{error}; use even-length hexadecimal inventories from the matching document"),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::HashMap;
+ use webui_protocol::{
+ ClientFragmentData, ConditionExpr, FragmentList, StateProjectionMode, WebUIFragment,
+ WebUIProtocol,
+ };
+
+ fn fixture() -> Protocol {
+ let root = lazy_fragment_id("app-shell", Some("settings"), 0);
+ let nested = lazy_fragment_id("app-shell", Some("advanced"), 0);
+ let mut document = WebUIProtocol::new(HashMap::from([
+ (
+ root.clone(),
+ FragmentList {
+ fragments: vec![WebUIFragment::lazy_if(
+ ConditionExpr::identifier("advanced"),
+ &nested,
+ )],
+ ..Default::default()
+ },
+ ),
+ (nested.clone(), FragmentList::default()),
+ ]));
+ document.build_id = "fixture".into();
+ for (id, name, keys) in [
+ (root, "settings", vec!["title", "advanced"]),
+ (nested, "advanced", vec!["details"]),
+ ] {
+ document.client_fragments.insert(
+ id,
+ ClientFragmentData {
+ owner: "app-shell".into(),
+ name: name.into(),
+ entry: true,
+ template_json: r#"{"h":"Inline
","v":3}"#.into(),
+ state_keys: keys.into_iter().map(str::to_owned).collect(),
+ state_mode: Some(StateProjectionMode::Keys as i32),
+ ..Default::default()
+ },
+ );
+ }
+ Protocol::new(document)
+ }
+
+ #[test]
+ fn prepared_resources_return_acknowledgement_without_state_or_nested_lazy_bodies() {
+ let protocol = fixture();
+ let prepared = protocol
+ .prepare_lazy_resources(LazyRequest::new("app-shell", "settings"))
+ .unwrap();
+ let json: Value = serde_json::from_str(&prepared.json).unwrap();
+ assert_eq!(json["type"], "webui-lazy");
+ assert!(json.get("state").is_none());
+ assert!(json["templates"]["app-shell::lazy:settings"].is_object());
+ assert!(json["templates"].get("app-shell::lazy:advanced").is_none());
+ assert_eq!(json["inventory"], prepared.options.inventory);
+ assert_eq!(json["styleInventory"], prepared.options.style_inventory);
+ assert_eq!(json["buildId"], prepared.options.build_id);
+ assert_eq!(
+ protocol
+ .render_lazy_resources(LazyRequest::new("app-shell", "settings"))
+ .unwrap(),
+ prepared.json
+ );
+ }
+
+ #[test]
+ fn state_record_adds_newly_active_fragments_and_warm_inventory_never_discards_state() {
+ let protocol = fixture();
+ let prepared = protocol
+ .prepare_lazy_resources(LazyRequest::new("app-shell", "settings"))
+ .unwrap();
+ let state = serde_json::json!({
+ "title": "Fresh", "advanced": true, "details": [1, 2], "unused": 42,
+ "$webui": { "bodyEnd": "private host markup" },
+ });
+ let payload: Value = serde_json::from_str(
+ &protocol
+ .render_lazy_partial(
+ &state,
+ LazyRequest::new("app-shell", "settings").with_options(&prepared.options),
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ assert!(payload["templates"]
+ .get("app-shell::lazy:settings")
+ .is_none());
+ assert!(payload["templates"]["app-shell::lazy:advanced"].is_object());
+ assert_eq!(
+ payload["state"],
+ serde_json::json!({ "title": "Fresh", "advanced": true, "details": [1, 2] })
+ );
+ let options = LazyOptions {
+ inventory: payload["inventory"].as_str().unwrap().into(),
+ style_inventory: payload["styleInventory"].as_str().unwrap().into(),
+ build_id: "fixture".into(),
+ };
+ let warm: Value = serde_json::from_str(
+ &protocol
+ .render_lazy_partial_json(
+ &state.to_string(),
+ LazyRequest::new("app-shell", "settings").with_options(&options),
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ assert_eq!(warm["state"], payload["state"]);
+ assert!(warm.get("templates").is_none());
+ assert!(warm.get("componentStyles").is_none());
+ }
+
+ #[test]
+ fn raw_json_projection_validates_input_without_sending_unrelated_keys() {
+ let protocol = fixture();
+ let payload: Value = serde_json::from_str(
+ &protocol
+ .render_lazy_partial_json(
+ r#"{"details":"fresh","unrelated":[1,2,3]}"#,
+ LazyRequest::new("app-shell", "advanced"),
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ assert_eq!(payload["state"], serde_json::json!({ "details": "fresh" }));
+ for state in ["[]", "{", r#"{"unrelated":1e999}"#] {
+ assert!(protocol
+ .render_lazy_partial_json(state, LazyRequest::new("app-shell", "advanced"))
+ .is_err());
+ }
+ }
+
+ #[test]
+ fn bad_targets_builds_and_inventories_return_distinct_request_errors() {
+ let protocol = fixture();
+ for (request, code) in [
+ (LazyRequest::new("app-shell", "missing"), "lazy-not-found"),
+ (
+ LazyRequest {
+ build_id: "old",
+ ..LazyRequest::new("app-shell", "settings")
+ },
+ "lazy-build-mismatch",
+ ),
+ (
+ LazyRequest {
+ inventory: "01",
+ ..LazyRequest::new("app-shell", "settings")
+ },
+ "lazy-missing-build-id",
+ ),
+ (
+ LazyRequest {
+ inventory: "ffffffff",
+ build_id: "fixture",
+ ..LazyRequest::new("app-shell", "settings")
+ },
+ "lazy-invalid-inventory",
+ ),
+ ] {
+ let Err(HandlerError::LazyRequest(error)) = protocol.render_lazy_resources(request)
+ else {
+ panic!("expected typed request failure");
+ };
+ assert_eq!(error.code, code);
+ }
+ }
+}
diff --git a/crates/webui-handler/src/lazy_response/serialization.rs b/crates/webui-handler/src/lazy_response/serialization.rs
new file mode 100644
index 000000000..e7683f499
--- /dev/null
+++ b/crates/webui-handler/src/lazy_response/serialization.rs
@@ -0,0 +1,123 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use super::ResourceRecord;
+use crate::{HandlerError, Result, StateSelection};
+use serde::{ser::SerializeMap, Serialize, Serializer};
+use serde_json::Value;
+
+pub(super) struct SelectedState<'a, 'keys> {
+ pub(super) value: &'a Value,
+ pub(super) selection: &'a StateSelection<'keys>,
+}
+
+impl Serialize for SelectedState<'_, '_> {
+ fn serialize(&self, serializer: S) -> std::result::Result {
+ let values = self
+ .value
+ .as_object()
+ .ok_or_else(|| serde::ser::Error::custom("lazy state must be an object"))?;
+ let mut output = serializer.serialize_map(None)?;
+ match self.selection {
+ StateSelection::Full => {
+ for (key, value) in values {
+ if key != crate::STATE_INJECT_KEY {
+ output.serialize_entry(key, value)?;
+ }
+ }
+ }
+ StateSelection::Keys(keys) => {
+ for key in keys {
+ if *key != crate::STATE_INJECT_KEY {
+ if let Some(value) = values.get(*key) {
+ output.serialize_entry(key, value)?;
+ }
+ }
+ }
+ }
+ _ => return Err(serde::ser::Error::custom("invalid lazy state projection")),
+ }
+ output.end()
+ }
+}
+
+pub(super) fn write_record(
+ record: ResourceRecord<'_>,
+ state: Option<&impl Serialize>,
+) -> Result {
+ let document = record.protocol.protocol();
+ let root = &document.client_fragments[record.root];
+ let bytes: usize = record
+ .needed
+ .iter()
+ .filter_map(|id| document.client_template(id))
+ .map(|data| data.metadata.len() + data.functions.len() + data.legacy_html.len())
+ .sum();
+ let mut output = Vec::with_capacity(256 + bytes);
+ output.extend_from_slice(b"{\"type\":\"webui-lazy\",\"version\":1,\"fragment\":");
+ json(&mut output, record.root)?;
+ output.extend_from_slice(b",\"owner\":");
+ json(&mut output, &root.owner)?;
+ output.extend_from_slice(b",\"buildId\":");
+ json(&mut output, &document.build_id)?;
+ output.extend_from_slice(b",\"inventory\":");
+ json(&mut output, &record.inventory)?;
+ output.extend_from_slice(b",\"styleInventory\":");
+ json(&mut output, &record.style_inventory)?;
+ output.extend_from_slice(b",\"required\":");
+ json(&mut output, &record.required)?;
+ if !record.needed.is_empty() || record.styles.resource_names().next().is_some() {
+ output.extend_from_slice(b",\"componentStyles\":");
+ json(&mut output, &record.styles)?;
+ }
+ if !record.needed.is_empty() {
+ output.extend_from_slice(b",\"templates\":{");
+ for (position, id) in record.needed.iter().enumerate() {
+ if position != 0 {
+ output.push(b',');
+ }
+ json(&mut output, *id)?;
+ output.push(b':');
+ let payload = document
+ .client_template(id)
+ .ok_or_else(|| HandlerError::MissingFragment((*id).into()))?;
+ if payload.metadata.is_empty() {
+ json(&mut output, payload.legacy_html)?;
+ } else {
+ output.extend_from_slice(payload.metadata.as_bytes());
+ }
+ }
+ output.push(b'}');
+ }
+ let mut wrote = false;
+ for id in &record.needed {
+ let payload = document
+ .client_template(id)
+ .ok_or_else(|| HandlerError::MissingFragment((*id).into()))?;
+ if payload.functions.is_empty() {
+ continue;
+ }
+ if wrote {
+ output.push(b',');
+ } else {
+ output.extend_from_slice(b",\"templateFunctions\":{");
+ }
+ wrote = true;
+ json(&mut output, *id)?;
+ output.push(b':');
+ json(&mut output, payload.functions)?;
+ }
+ if wrote {
+ output.push(b'}');
+ }
+ if let Some(state) = state {
+ output.extend_from_slice(b",\"state\":");
+ json(&mut output, state)?;
+ }
+ output.push(b'}');
+ String::from_utf8(output).map_err(|error| HandlerError::Rendering(error.to_string()))
+}
+
+fn json(output: &mut Vec, value: &(impl Serialize + ?Sized)) -> Result<()> {
+ serde_json::to_writer(output, value).map_err(|error| HandlerError::Rendering(error.to_string()))
+}
diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs
index 7cd48d5df..5d5a2d958 100644
--- a/crates/webui-handler/src/lib.rs
+++ b/crates/webui-handler/src/lib.rs
@@ -7,6 +7,9 @@
//! into final HTML output based on provided data.
pub mod css_module;
+mod lazy;
+mod lazy_response;
+pub use lazy_response::{LazyOptions, LazyRequest, LazyRequestError, PreparedLazyResources};
pub(crate) mod html_encode;
pub mod plugin;
mod render_capacity;
@@ -72,6 +75,9 @@ use webui_state::find_value_by_dotted_path_ref;
/// Error types for the WebUI handler.
#[derive(Debug, Error)]
pub enum HandlerError {
+ /// Invalid target, identity, or availability information for lazy delivery.
+ #[error("lazy request [{}]: {}", .0.code, .0.message)]
+ LazyRequest(Box),
#[error("Rendering error: {0}")]
Rendering(String),
@@ -311,8 +317,8 @@ pub struct RenderOptions<'a> {
pub entry_id: &'a str,
/// The URL path to match routes against (e.g., `"/contacts/42"`).
pub request_path: &'a str,
- /// Optional CSP nonce for inline `"#,
"",
"",
- // Skipped: an island loader is deferred on purpose.
+ // Skipped: an island loader is lazy on purpose.
r#""#,
r#""#,
"",
diff --git a/crates/webui-parser/src/plugin/mod.rs b/crates/webui-parser/src/plugin/mod.rs
index 4af87c963..54fd43a5e 100644
--- a/crates/webui-parser/src/plugin/mod.rs
+++ b/crates/webui-parser/src/plugin/mod.rs
@@ -14,6 +14,7 @@ pub mod webui;
use crate::component_registry::Component;
use crate::{ParserOptions, Result};
+use webui_protocol::WebUIFragmentRecords;
/// Parser-owned decision about how an attribute should be handled.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -90,6 +91,17 @@ pub struct ComponentTemplateArtifact {
pub is_scripted: bool,
/// Whether this component effectively uses Shadow DOM.
pub uses_shadow_dom: bool,
+ /// Hostless compiled sections extracted from deferred bodies of this template.
+ pub lazy_templates: Vec,
+}
+
+/// An independently delivered template section without a component host.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct LazyTemplateArtifact {
+ /// Resource key, also the server body record id for public deferred roots.
+ pub id: String,
+ /// Owning template, optional authored id, metadata, and dependency surface.
+ pub data: webui_protocol::ClientFragmentData,
}
impl ComponentTemplateArtifact {
@@ -106,6 +118,7 @@ impl ComponentTemplateArtifact {
template_roots: Vec::new(),
is_scripted: false,
uses_shadow_dom,
+ lazy_templates: Vec::new(),
}
}
@@ -130,6 +143,7 @@ impl ComponentTemplateArtifact {
template_roots: Vec::new(),
is_scripted: false,
uses_shadow_dom,
+ lazy_templates: Vec::new(),
}
}
@@ -278,6 +292,11 @@ pub struct ComponentProcessing {
///
/// WebUI calls these hooks during parsing; plugins decide what (if anything) to do.
pub trait ParserPlugin {
+ /// Whether the plugin can represent deferred bodies without a component boundary.
+ fn supports_lazy_fragments(&self) -> bool {
+ false
+ }
+
/// Configure the plugin before parsing or component registration begins.
fn configure_parser(&mut self, _options: &ParserOptions) {}
@@ -321,11 +340,52 @@ pub trait ParserPlugin {
fn finish(self: Box) -> Result {
Ok(ParserPluginArtifacts::None)
}
+
+ /// Complete artifacts using the read-only compiled fragment graph.
+ ///
+ /// Plugins may derive client dependency metadata from typed references
+ /// instead of scanning HTML again. The default preserves [`Self::finish`]
+ /// for plugins that do not need the graph.
+ ///
+ /// # Errors
+ ///
+ /// Returns the plugin's artifact error, including invalid graph references.
+ fn finish_with_fragments(
+ self: Box,
+ _fragments: &WebUIFragmentRecords,
+ ) -> Result {
+ self.finish()
+ }
}
#[cfg(test)]
mod artifact_tests {
- use super::{ComponentTemplateArtifact, StateSurface};
+ use super::{ComponentTemplateArtifact, ParserPlugin, ParserPluginArtifacts, StateSurface};
+
+ #[test]
+ fn graph_finalization_preserves_existing_plugin_finish() {
+ struct ExistingPlugin;
+ impl ParserPlugin for ExistingPlugin {
+ fn finish(self: Box) -> crate::Result {
+ Ok(ParserPluginArtifacts::ComponentTemplates(vec![
+ ComponentTemplateArtifact::template(
+ "x-existing".into(),
+ "template".into(),
+ false,
+ ),
+ ]))
+ }
+ }
+
+ let output = Box::new(ExistingPlugin)
+ .finish_with_fragments(&webui_protocol::WebUIFragmentRecords::new())
+ .expect("existing finish");
+ assert!(matches!(
+ output,
+ ParserPluginArtifacts::ComponentTemplates(templates)
+ if templates[0].tag_name == "x-existing"
+ ));
+ }
#[test]
fn template_constructor_starts_with_no_hydration_surface() {
diff --git a/crates/webui-parser/src/plugin/webui.rs b/crates/webui-parser/src/plugin/webui.rs
index 63e63bc49..ff9ac750b 100644
--- a/crates/webui-parser/src/plugin/webui.rs
+++ b/crates/webui-parser/src/plugin/webui.rs
@@ -78,8 +78,10 @@ use std::fmt::Write;
use std::ops::Range;
use webui_protocol::{condition_expr, ConditionExpr, WebUIElementData};
+mod lazy_templates;
mod named_repeats;
mod ssr_layout;
+mod template_dependencies;
use named_repeats::CompileBlocks;
use ssr_layout::{SsrChildren, SsrLayout};
@@ -201,18 +203,18 @@ impl WebUIParserPlugin {
StateSurface::None
};
let navigation = navigation_surface(&payload.hydration_roots, &hydration);
- out.push(
- ComponentTemplateArtifact::webui(
- c.tag_name.clone(),
- payload.template_json,
- payload.template_functions,
- c.uses_shadow_dom,
- )
- .with_hydration(hydration)
- .with_navigation(navigation)
- .with_template_roots(payload.hydration_roots.clone())
- .scripted(is_authored),
- );
+ let mut artifact = ComponentTemplateArtifact::webui(
+ c.tag_name.clone(),
+ payload.template_json,
+ payload.template_functions,
+ c.uses_shadow_dom,
+ )
+ .with_hydration(hydration)
+ .with_navigation(navigation)
+ .with_template_roots(payload.hydration_roots.clone())
+ .scripted(is_authored);
+ artifact.lazy_templates = payload.lazy_templates;
+ out.push(artifact);
}
Ok(out)
}
@@ -392,6 +394,10 @@ impl Default for WebUIParserPlugin {
}
impl ParserPlugin for WebUIParserPlugin {
+ fn supports_lazy_fragments(&self) -> bool {
+ true
+ }
+
fn begin_fragment(&mut self, _context: FragmentContext<'_>) {
self.element_events.set(0);
self.next_event_idx.set(0);
@@ -455,6 +461,15 @@ impl ParserPlugin for WebUIParserPlugin {
self.take_component_templates()?,
))
}
+
+ fn finish_with_fragments(
+ self: Box,
+ fragments: &webui_protocol::WebUIFragmentRecords,
+ ) -> Result {
+ let mut templates = self.take_component_templates()?;
+ template_dependencies::attach(&mut templates, fragments)?;
+ Ok(ParserPluginArtifacts::ComponentTemplates(templates))
+ }
}
fn append_render_policy(template_json: &mut String, policy: &ComponentRenderPolicy) {
@@ -508,6 +523,8 @@ struct TemplateSectionMeta {
conditionals: Vec<(ConditionExpr, usize)>,
/// Client conditional anchor slots aligned to `conditionals`.
condition_slots: Vec,
+ /// Present only in sections with explicitly lazy component callsites.
+ lazy_conditions: Vec,
/// For-loop blocks and their optional first-child key paths.
repeats: Vec,
/// Client repeat anchor slots aligned to `repeats`.
@@ -516,6 +533,7 @@ struct TemplateSectionMeta {
events: Vec,
/// Client event target element indices aligned to `events`.
event_targets: Vec,
+ refs: Vec<(String, usize)>,
ssr_layout: SsrLayout,
}
@@ -618,6 +636,13 @@ struct TemplateMeta {
/// Root-level events from the `` wrapper tag.
/// Attached to the host element (shadow root host) by the client.
root_events: Vec,
+ lazy: Vec,
+}
+
+struct LazyDefinition {
+ block_index: usize,
+ id: String,
+ name: String,
}
type EventBinding = (String, String, Vec);
@@ -666,6 +691,7 @@ struct CompiledTemplatePayload {
/// Template reactive roots used when client navigation creates or updates
/// a component whose initial DOM was not server rendered.
hydration_roots: Vec,
+ lazy_templates: Vec,
}
fn navigation_surface(roots: &[String], hydration: &StateSurface) -> StateSurface {
@@ -696,9 +722,10 @@ struct TemplateBuildMetadata {
scope_count: usize,
}
-struct TemplatePayloadOptions {
+struct TemplatePayloadOptions<'a> {
shadow_dom: bool,
emit_static_host: bool,
+ owner: &'a str,
}
struct RootScope<'a> {
@@ -833,6 +860,7 @@ fn generate_compiled_template_with_root_source(
TemplatePayloadOptions {
shadow_dom,
emit_static_host: scriptless,
+ owner: tag_name,
},
))
}
@@ -843,11 +871,12 @@ fn emit_compiled_template_payload(
build_meta: &TemplateBuildMetadata,
options: TemplatePayloadOptions,
) -> CompiledTemplatePayload {
+ let lazy_plan = lazy_templates::Plan::new(options.owner, meta);
let mut conditions = ConditionFunctionEmitter::new(128);
let mut out = String::with_capacity(512 + html_content.len());
out.push('{');
- emit_json_template_section(&meta.root, &mut out, &mut conditions);
+ emit_json_template_section(&meta.root, &mut out, &mut conditions, lazy_plan.as_ref());
// sd: shadow DOM flag — tells the client runtime to use shadow root
if options.shadow_dom {
@@ -898,22 +927,28 @@ fn emit_compiled_template_payload(
// b: nested block table
if !meta.blocks.is_empty() {
out.push_str(",\"b\":[");
+ let mut wrote = false;
for (i, block) in meta.blocks.iter().enumerate() {
- if i > 0 {
+ if lazy_plan.as_ref().is_some_and(|plan| !plan.is_eager(i)) {
+ continue;
+ }
+ if wrote {
out.push(',');
}
+ wrote = true;
out.push('{');
- emit_json_template_section(block, &mut out, &mut conditions);
+ emit_json_template_section(block, &mut out, &mut conditions, lazy_plan.as_ref());
out.push('}');
}
out.push(']');
}
- out.push('}');
+ out.push_str(",\"v\":3}");
CompiledTemplatePayload {
template_json: out,
template_functions: conditions.finish(),
hydration_roots: build_meta.roots.clone(),
+ lazy_templates: lazy_plan.map_or_else(Vec::new, |plan| plan.emit(options.owner, meta)),
}
}
@@ -1100,21 +1135,33 @@ fn emit_json_attr_binding(
#[inline]
fn collect_template_build_metadata(meta: &TemplateMeta) -> TemplateBuildMetadata {
if meta.has_named_repeats {
- collect_template_roots::(meta)
+ collect_template_roots::(
+ &meta.root,
+ &meta.blocks,
+ !meta.root_events.is_empty(),
+ )
} else {
- collect_template_roots::(meta)
+ collect_template_roots::(
+ &meta.root,
+ &meta.blocks,
+ !meta.root_events.is_empty(),
+ )
}
}
#[inline(never)]
-fn collect_template_roots(meta: &TemplateMeta) -> TemplateBuildMetadata {
+fn collect_template_roots(
+ root: &TemplateSectionMeta,
+ blocks: &[TemplateSectionMeta],
+ root_events: bool,
+) -> TemplateBuildMetadata {
let mut roots = Vec::new();
let mut scopes = Vec::>::new();
- let mut stack = Vec::with_capacity(1 + meta.blocks.len());
- let mut has_events = !meta.root_events.is_empty();
+ let mut stack = Vec::with_capacity(1 + blocks.len());
+ let mut has_events = root_events;
let mut visits = SHARED.then(crate::scoped_visits::ScopedVisits::default);
stack.push(RootVisit {
- block: &meta.root,
+ block: root,
scope: None,
});
@@ -1149,9 +1196,12 @@ fn collect_template_roots(meta: &TemplateMeta) -> TemplateBu
}
}
- for (condition, block_index) in &block.conditionals {
+ for (position, (condition, block_index)) in block.conditionals.iter().enumerate() {
add_condition_roots(&mut roots, condition, &scopes, visit.scope);
- if let Some(child) = meta.blocks.get(*block_index) {
+ if STOP_LAZY && block.lazy_conditions.contains(&position) {
+ continue;
+ }
+ if let Some(child) = blocks.get(*block_index) {
stack.push(RootVisit {
block: child,
scope: visit.scope,
@@ -1165,7 +1215,7 @@ fn collect_template_roots(meta: &TemplateMeta) -> TemplateBu
{
continue;
}
- if let Some(child) = meta.blocks.get(repeat.block_index) {
+ if let Some(child) = blocks.get(repeat.block_index) {
let scope = scopes.len();
scopes.push(RootScope {
name: &repeat.item_var,
@@ -1477,13 +1527,87 @@ fn emit_js_text_parts(parts: &[CompiledAttrPart], out: &mut String) {
out.push(']');
}
+fn emit_block_reference(index: usize, references: Option<&lazy_templates::Plan>, out: &mut String) {
+ match references.map(|plan| &plan.references[index]) {
+ Some(lazy_templates::Reference::External(id)) => emit_js_string(id, out),
+ Some(lazy_templates::Reference::Inline(index)) => {
+ let _ = write!(out, "{index}");
+ }
+ Some(lazy_templates::Reference::Local(index)) => {
+ let _ = write!(out, "-{}", index + 1);
+ }
+ None => {
+ let _ = write!(out, "{index}");
+ }
+ }
+}
+
+#[cold]
+#[inline(never)]
+fn validate_lazy_metadata(
+ component: &str,
+ tag: &crate::html_parser::Tag<'_>,
+ source: &TemplateSource<'_>,
+ names: &mut std::collections::HashSet,
+) -> Result<()> {
+ let failure = if tag.attr("lazy").is_some_and(|value| !value.is_empty()) {
+ Some((
+ crate::codes::INVALID_LAZY_ATTRIBUTE,
+ "lazy must be a boolean attribute",
+ "write lazy without a value; use an optional id to name the region",
+ ))
+ } else if let Some(name) = tag.attr("id") {
+ if !crate::lazy::valid_name(name) {
+ Some((
+ crate::codes::INVALID_LAZY_ID,
+ "invalid lazy region id",
+ "use 1-128 ASCII letters, digits, periods, underscores, or hyphens",
+ ))
+ } else if !names.insert(name.to_owned()) {
+ Some((
+ crate::codes::DUPLICATE_LAZY_ID,
+ "duplicate lazy region id",
+ "choose a unique id within this owning template",
+ ))
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+ if let Some((code, title, help)) = failure {
+ return Err(source.locate_error(
+ crate::Diagnostic::error(title)
+ .code(code)
+ .component(component)
+ .element("if")
+ .help(help)
+ .into(),
+ ));
+ }
+ Ok(())
+}
+
fn emit_json_template_section(
meta: &TemplateSectionMeta,
out: &mut String,
functions: &mut ConditionFunctionEmitter,
+ references: Option<&lazy_templates::Plan>,
) {
out.push_str("\"h\":");
emit_js_string(&meta.html, out);
+ if !meta.refs.is_empty() {
+ out.push_str(",\"rf\":[");
+ for (index, (name, target)) in meta.refs.iter().enumerate() {
+ if index != 0 {
+ out.push(',');
+ }
+ out.push('[');
+ emit_js_string(name, out);
+ let _ = write!(out, ",{target}]");
+ }
+ out.push(']');
+ }
meta.ssr_layout.emit(out);
if !meta.text_runs.is_empty() {
@@ -1552,9 +1676,12 @@ fn emit_json_template_section(
out.push('[');
emit_json_condition_ref(cond, out, functions);
out.push(',');
- let _ = write!(out, "{}", block_index);
+ emit_block_reference(*block_index, references, out);
out.push(',');
emit_js_slot(slot, out);
+ if meta.lazy_conditions.contains(&i) {
+ out.push_str(",1");
+ }
out.push(']');
}
out.push(']');
@@ -1576,7 +1703,7 @@ fn emit_json_template_section(
out.push(',');
emit_js_string(&repeat.item_var, out);
out.push(',');
- let _ = write!(out, "{}", repeat.block_index);
+ emit_block_reference(repeat.block_index, references, out);
out.push(',');
emit_js_slot(slot, out);
if let Some(key_path) = &repeat.key_path {
@@ -1629,6 +1756,7 @@ fn compile_to_metadata(
has_named_repeats: blocks.has_named_repeats(),
blocks: blocks.blocks,
root_events,
+ lazy: blocks.lazy,
})
}
@@ -1647,10 +1775,12 @@ fn compile_section(
attr_groups: Vec::new(),
conditionals: Vec::new(),
condition_slots: Vec::new(),
+ lazy_conditions: Vec::new(),
repeats: Vec::new(),
repeat_slots: Vec::new(),
events: Vec::new(),
event_targets: Vec::new(),
+ refs: Vec::new(),
ssr_layout: SsrLayout::default(),
};
@@ -1682,15 +1812,41 @@ fn compile_section(
}
}
- let opening_name = parse_tag(remaining)
- .filter(|tag| !tag.closing)
- .map(|tag| tag.name);
+ let opening_tag = parse_tag(remaining).filter(|tag| !tag.closing);
+ let opening_name = opening_tag.as_ref().map(|tag| tag.name);
if opening_name == Some("if") {
+ if let Some(error) = opening_tag
+ .as_ref()
+ .and_then(crate::lazy::if_attribute_error)
+ {
+ return Err(source
+ .slice(i..len)
+ .locate_error(error.component(component).into()));
+ }
if let Some((cond, body, consumed)) = parse_if_block(source.slice(i..len)) {
+ let lazy_tag = opening_tag.as_ref().filter(|tag| tag.has_attr("lazy"));
+ let lazy = lazy_tag.is_some();
let block_index = blocks.reserve();
+ if let Some(tag) = lazy_tag {
+ let name = tag.attr("id");
+ validate_lazy_metadata(
+ component,
+ tag,
+ &source.slice(i..len),
+ &mut blocks.lazy_names,
+ )?;
+ blocks.lazy.push(LazyDefinition {
+ block_index,
+ id: crate::lazy::fragment_id(component, name, blocks.lazy.len()),
+ name: name.unwrap_or_default().to_owned(),
+ });
+ }
let block = compile_section(component, body, blocks)?;
blocks.blocks[block_index] = block;
let idx = meta.conditionals.len();
+ if lazy {
+ meta.lazy_conditions.push(idx);
+ }
meta.conditionals.push((cond, block_index));
meta.html.push_str(&format!(""));
i += consumed;
@@ -1922,6 +2078,32 @@ fn finalize_template_section(meta: &mut TemplateSectionMeta) {
let text_marker_offsets = std::mem::take(&mut meta.text_marker_offsets);
let mut nodes = parse_fragment_nodes(&raw_html, &text_marker_offsets);
insert_implied_table_containers(&mut nodes);
+ if raw_html.contains("w-ref") {
+ let mut pending: Vec<&FragmentNode> = nodes.iter().rev().collect();
+ let mut index = 0usize;
+ while let Some(node) = pending.pop() {
+ let FragmentNode::Element(element) = node else {
+ continue;
+ };
+ index += 1;
+ for attr in &element.attrs {
+ if attr.name == "w-ref" {
+ if let Some(name) = attr
+ .value
+ .as_deref()
+ .and_then(|value| value.strip_prefix('{'))
+ .and_then(|value| value.strip_suffix('}'))
+ .filter(|name| !name.is_empty())
+ {
+ meta.refs.push((name.to_owned(), index));
+ }
+ }
+ }
+ if !element.tag_name.eq_ignore_ascii_case("template") {
+ pending.extend(element.children.iter().rev());
+ }
+ }
+ }
let text_bindings = meta.text_bindings.clone();
let mut finalized_html = String::with_capacity(raw_html.len());
let mut text_runs = Vec::new();
@@ -3888,6 +4070,53 @@ mod tests {
}
}
+ #[test]
+ fn client_cache_dependencies_follow_compiled_edges_not_source_text() {
+ let mut parser = crate::HtmlParser::with_plugin(Box::new(WebUIParserPlugin::new()));
+ for (tag, html) in [
+ (
+ "x-parent",
+ concat!(
+ r#""#,
+ r#""#,
+ r#""#,
+ r#""#,
+ r#""#,
+ ),
+ ),
+ ("x-child", ""),
+ ("x-grandchild", "Grandchild
"),
+ ("x-leaf", "Leaf"),
+ ("x-route", "Route
"),
+ ] {
+ parser
+ .component_registry_mut()
+ .register_component(crate::ComponentRegistration::new(tag, html, None, false))
+ .expect("component registration");
+ }
+ parser
+ .parse("index.html", "")
+ .expect("entry parsing");
+ let ParserPluginArtifacts::ComponentTemplates(templates) =
+ parser.take_plugin_artifacts().expect("client artifacts")
+ else {
+ panic!("expected WebUI templates");
+ };
+ let metadata = |tag: &str| {
+ templates
+ .iter()
+ .find(|template| template.tag_name == tag)
+ .expect("compiled component")
+ .template_json
+ .as_str()
+ };
+ assert!(metadata("x-parent").contains(r#""v":3"#));
+ assert!(metadata("x-parent").contains(r#""d":["x-child","x-leaf"]"#));
+ assert!(metadata("x-child").contains(r#""d":["x-grandchild"]"#));
+ assert!(metadata("x-leaf").contains(r#""v":3"#));
+ assert!(!metadata("x-leaf").contains(r#""d":"#));
+ }
+
#[test]
fn component_render_policy_is_encoded_in_template_metadata() {
let mut plugin = WebUIParserPlugin::new();
diff --git a/crates/webui-parser/src/plugin/webui/lazy_templates.rs b/crates/webui-parser/src/plugin/webui/lazy_templates.rs
new file mode 100644
index 000000000..4bc0c00a9
--- /dev/null
+++ b/crates/webui-parser/src/plugin/webui/lazy_templates.rs
@@ -0,0 +1,231 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use super::{
+ emit_js_string, emit_json_template_section, ConditionFunctionEmitter, TemplateMeta,
+ TemplateSectionMeta,
+};
+use crate::plugin::LazyTemplateArtifact;
+use std::collections::HashSet;
+use webui_protocol::{ClientFragmentData, StateProjectionMode};
+
+pub(super) enum Reference {
+ Inline(usize),
+ Local(usize),
+ External(String),
+}
+
+pub(super) struct Plan {
+ pub(super) references: Vec,
+ bundles: Vec>,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Owner {
+ Unassigned,
+ Eager,
+ Region(usize),
+ Shared,
+}
+
+impl Plan {
+ pub(super) fn new(owner: &str, meta: &TemplateMeta) -> Option {
+ if meta.lazy.is_empty() {
+ return None;
+ }
+ let mut eager = vec![false; meta.blocks.len()];
+ let mut pending = Vec::new();
+ push_ordinary(&meta.root, &mut pending);
+ while let Some(index) = pending.pop() {
+ if eager[index] {
+ continue;
+ }
+ eager[index] = true;
+ push_ordinary(&meta.blocks[index], &mut pending);
+ }
+ let mut owners: Vec = eager
+ .iter()
+ .map(|eager| {
+ if *eager {
+ Owner::Eager
+ } else {
+ Owner::Unassigned
+ }
+ })
+ .collect();
+ let mut visited = vec![usize::MAX; meta.blocks.len()];
+ for (region, declaration) in meta.lazy.iter().enumerate() {
+ pending.push(declaration.block_index);
+ while let Some(index) = pending.pop() {
+ if owners[index] == Owner::Eager || visited[index] == region {
+ continue;
+ }
+ visited[index] = region;
+ owners[index] = match owners[index] {
+ Owner::Unassigned => Owner::Region(region),
+ Owner::Region(previous) if previous == region => Owner::Region(region),
+ _ => Owner::Shared,
+ };
+ push_ordinary(&meta.blocks[index], &mut pending);
+ }
+ }
+ let mut next = 0;
+ let mut bundles = vec![Vec::new(); meta.lazy.len()];
+ let mut references = Vec::with_capacity(meta.blocks.len());
+ for (index, owner_kind) in owners.into_iter().enumerate() {
+ references.push(match owner_kind {
+ Owner::Eager => {
+ let inline = next;
+ next += 1;
+ Reference::Inline(inline)
+ }
+ Owner::Region(region) if meta.lazy[region].block_index != index => {
+ let local = bundles[region].len();
+ bundles[region].push(index);
+ Reference::Local(local)
+ }
+ _ => Reference::External(crate::lazy::block_id(owner, index)),
+ });
+ }
+ for declaration in &meta.lazy {
+ references[declaration.block_index] = Reference::External(declaration.id.clone());
+ }
+ Some(Self {
+ references,
+ bundles,
+ })
+ }
+
+ pub(super) fn is_eager(&self, index: usize) -> bool {
+ matches!(self.references[index], Reference::Inline(_))
+ }
+
+ pub(super) fn emit(&self, owner: &str, meta: &TemplateMeta) -> Vec {
+ let mut emitted = Vec::new();
+ for (index, reference) in self.references.iter().enumerate() {
+ let Reference::External(id) = reference else {
+ continue;
+ };
+ let section = &meta.blocks[index];
+ let declaration = meta
+ .lazy
+ .iter()
+ .enumerate()
+ .find(|(_, entry)| entry.block_index == index);
+ let local_blocks = declaration
+ .map(|(region, _)| self.bundles[region].as_slice())
+ .unwrap_or_default();
+ let mut functions = ConditionFunctionEmitter::new(64);
+ let local_html: usize = local_blocks
+ .iter()
+ .map(|index| meta.blocks[*index].html.len())
+ .sum();
+ let mut json = String::with_capacity(section.html.len() + local_html + 128);
+ json.push('{');
+ emit_json_template_section(section, &mut json, &mut functions, Some(self));
+ json.push_str(",\"v\":3,\"f\":");
+ emit_js_string(id, &mut json);
+ json.push_str(",\"o\":");
+ emit_js_string(owner, &mut json);
+ if declaration.is_some() {
+ json.push_str(",\"e\":1");
+ }
+ if !local_blocks.is_empty() {
+ json.push_str(",\"b\":[");
+ for (position, index) in local_blocks.iter().enumerate() {
+ if position != 0 {
+ json.push(',');
+ }
+ json.push('{');
+ emit_json_template_section(
+ &meta.blocks[*index],
+ &mut json,
+ &mut functions,
+ Some(self),
+ );
+ json.push('}');
+ }
+ json.push(']');
+ }
+ let dependencies = self.dependencies(section, meta);
+ json.push('}');
+ emitted.push(LazyTemplateArtifact {
+ id: id.clone(),
+ data: ClientFragmentData {
+ owner: owner.to_owned(),
+ name: declaration
+ .map_or("", |(_, entry)| entry.name.as_str())
+ .to_owned(),
+ template_json: json,
+ template_functions: functions.finish(),
+ dependencies,
+ state_keys: if meta.has_named_repeats {
+ super::collect_template_roots::(section, &meta.blocks, false)
+ .roots
+ } else {
+ super::collect_template_roots::(section, &meta.blocks, false)
+ .roots
+ },
+ state_mode: Some(StateProjectionMode::All as i32),
+ entry: declaration.is_some(),
+ },
+ });
+ }
+ emitted
+ }
+
+ fn dependencies(&self, section: &TemplateSectionMeta, meta: &TemplateMeta) -> Vec {
+ let mut result = HashSet::new();
+ let mut visited = HashSet::new();
+ let mut pending = vec![section];
+ let mut targets = Vec::new();
+ while let Some(section) = pending.pop() {
+ collect_elements(§ion.html, &mut result);
+ targets.clear();
+ push_ordinary(section, &mut targets);
+ for &target in &targets {
+ match &self.references[target] {
+ Reference::External(id) => {
+ result.insert(id.clone());
+ }
+ Reference::Inline(_) | Reference::Local(_) if visited.insert(target) => {
+ pending.push(&meta.blocks[target])
+ }
+ Reference::Inline(_) | Reference::Local(_) => {}
+ }
+ }
+ }
+ let mut values: Vec = result.into_iter().collect();
+ values.sort_unstable();
+ values
+ }
+}
+
+fn push_ordinary(section: &TemplateSectionMeta, pending: &mut Vec) {
+ for (position, (_, index)) in section.conditionals.iter().enumerate() {
+ if !section.lazy_conditions.contains(&position) {
+ pending.push(*index);
+ }
+ }
+ pending.extend(section.repeats.iter().map(|repeat| repeat.block_index));
+}
+
+fn collect_elements(html: &str, components: &mut HashSet) {
+ let mut cursor = 0;
+ while let Some(offset) = html[cursor..].find('<') {
+ cursor += offset;
+ let remaining = &html[cursor..];
+ let Some(tag) = crate::html_parser::parse_tag(remaining) else {
+ cursor += 1;
+ continue;
+ };
+ if !tag.closing && tag.name.contains('-') {
+ components.insert(tag.name.to_owned());
+ }
+ if !tag.closing && !tag.self_closing && super::is_raw_text_or_rcdata_element(tag.name) {
+ cursor += super::raw_text_element_end(remaining, tag.name, tag.close + 1);
+ } else {
+ cursor += tag.close + 1;
+ }
+ }
+}
diff --git a/crates/webui-parser/src/plugin/webui/named_repeats.rs b/crates/webui-parser/src/plugin/webui/named_repeats.rs
index 75bcc999b..4e89bbece 100644
--- a/crates/webui-parser/src/plugin/webui/named_repeats.rs
+++ b/crates/webui-parser/src/plugin/webui/named_repeats.rs
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
-use super::{CompiledRepeat, ParsedForBlock, RootScope, TemplateSectionMeta};
+use super::{CompiledRepeat, LazyDefinition, ParsedForBlock, RootScope, TemplateSectionMeta};
use crate::scoped_visits::ScopedVisits;
use crate::{NamedForLoops, Result};
@@ -27,6 +27,8 @@ pub(super) fn visit_scope<'a>(
#[derive(Default)]
pub(super) struct CompileBlocks {
pub(super) blocks: Vec,
+ pub(super) lazy: Vec,
+ pub(super) lazy_names: std::collections::HashSet,
named: Vec,
validation: NamedForLoops,
}
diff --git a/crates/webui-parser/src/plugin/webui/template_dependencies.rs b/crates/webui-parser/src/plugin/webui/template_dependencies.rs
new file mode 100644
index 000000000..cdb301bd8
--- /dev/null
+++ b/crates/webui-parser/src/plugin/webui/template_dependencies.rs
@@ -0,0 +1,204 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use std::collections::HashSet;
+
+use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRecords};
+
+use crate::plugin::ComponentTemplateArtifact;
+use crate::{ParserError, Result};
+
+pub(super) fn attach(
+ templates: &mut [ComponentTemplateArtifact],
+ fragments: &WebUIFragmentRecords,
+) -> Result<()> {
+ let mut known = HashSet::with_capacity(templates.len());
+ let mut payloads = Vec::with_capacity(templates.len());
+ let mut lazy_payloads = Vec::new();
+ for template in templates {
+ if template.template_json.is_empty() {
+ continue;
+ }
+ let tag = template.tag_name.as_str();
+ known.insert(tag);
+ payloads.push((tag, &mut template.template_json));
+ for fragment in &mut template.lazy_templates {
+ known.insert(fragment.id.as_str());
+ lazy_payloads.push(&mut fragment.data);
+ }
+ }
+ if payloads.is_empty() {
+ return Ok(());
+ }
+
+ let mut scratch = DependencyScratch::default();
+ for (tag, json) in payloads {
+ scratch.collect(fragments, &known, tag)?;
+ append_dependencies(tag, json, &scratch.dependencies)?;
+ }
+ for data in lazy_payloads {
+ data.dependencies.retain(|id| known.contains(id.as_str()));
+ if !data.dependencies.is_empty() {
+ data.template_json.truncate(data.template_json.len() - 1);
+ data.template_json.push_str(",\"d\":[");
+ for (position, id) in data.dependencies.iter().enumerate() {
+ if position != 0 {
+ data.template_json.push(',');
+ }
+ super::emit_js_string(id, &mut data.template_json);
+ }
+ data.template_json.push_str("]}");
+ }
+ }
+ Ok(())
+}
+
+#[derive(Default)]
+struct DependencyScratch<'a> {
+ visited: HashSet<&'a str>,
+ seen_components: HashSet<&'a str>,
+ pending: Vec<&'a str>,
+ dependencies: Vec<&'a str>,
+}
+
+impl<'a> DependencyScratch<'a> {
+ fn collect(
+ &mut self,
+ fragments: &'a WebUIFragmentRecords,
+ known: &HashSet<&str>,
+ root: &'a str,
+ ) -> Result<()> {
+ self.visited.clear();
+ self.seen_components.clear();
+ self.pending.clear();
+ self.dependencies.clear();
+ self.pending.push(root);
+
+ while let Some(id) = self.pending.pop() {
+ if !self.visited.insert(id) {
+ continue;
+ }
+ let list = fragments
+ .get(id)
+ .ok_or_else(|| missing_fragment(root, id))?;
+ for fragment in &list.fragments {
+ match fragment.fragment.as_ref() {
+ Some(Fragment::Component(component)) => {
+ let child = component.fragment_id.as_str();
+ if known.contains(child) {
+ if self.seen_components.insert(child) {
+ self.dependencies.push(child);
+ }
+ } else {
+ self.pending.push(child);
+ }
+ }
+ Some(Fragment::ForLoop(repeat)) => {
+ self.pending.push(repeat.fragment_id.as_str());
+ }
+ Some(Fragment::IfCond(condition)) if !condition.lazy => {
+ self.pending.push(condition.fragment_id.as_str());
+ }
+ Some(Fragment::Attribute(attribute)) if !attribute.template.is_empty() => {
+ self.pending.push(attribute.template.as_str());
+ }
+ // Route activation owns its destination independently.
+ _ => {}
+ }
+ }
+ }
+
+ self.dependencies.sort_unstable();
+ Ok(())
+ }
+}
+
+fn append_dependencies(tag: &str, json: &mut String, dependencies: &[&str]) -> Result<()> {
+ if !json.starts_with('{') || !json.ends_with('}') {
+ return Err(invalid_payload(tag));
+ }
+ json.truncate(json.len() - 1);
+ if !dependencies.is_empty() {
+ json.push_str(",\"d\":[");
+ for (index, dependency) in dependencies.iter().enumerate() {
+ if index != 0 {
+ json.push(',');
+ }
+ super::emit_js_string(dependency, json);
+ }
+ json.push(']');
+ }
+ json.push('}');
+ Ok(())
+}
+
+#[cold]
+#[inline(never)]
+fn missing_fragment(component: &str, fragment: &str) -> ParserError {
+ ParserError::ClientTemplateMetadata {
+ component: component.into(),
+ reason: format!("missing compiled fragment '{fragment}'").into_boxed_str(),
+ }
+}
+
+#[cold]
+#[inline(never)]
+fn invalid_payload(component: &str) -> ParserError {
+ ParserError::ClientTemplateMetadata {
+ component: component.into(),
+ reason: "the WebUI plugin did not produce an object metadata payload".into(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use webui_protocol::{FragmentList, WebUIFragment};
+
+ #[test]
+ fn named_fragment_cycles_are_walked_once() {
+ let fragments = WebUIFragmentRecords::from([
+ (
+ "x-root".into(),
+ FragmentList {
+ fragments: vec![WebUIFragment::for_loop("item", "items", "body")],
+ ..Default::default()
+ },
+ ),
+ (
+ "body".into(),
+ FragmentList {
+ fragments: vec![
+ WebUIFragment::component("x-child"),
+ WebUIFragment::for_loop("item", "item.children", "body"),
+ ],
+ ..Default::default()
+ },
+ ),
+ ]);
+ let mut scratch = DependencyScratch::default();
+ scratch
+ .collect(&fragments, &HashSet::from(["x-root", "x-child"]), "x-root")
+ .expect("finite graph walk");
+ assert_eq!(scratch.dependencies, ["x-child"]);
+ }
+
+ #[test]
+ fn missing_fragments_return_a_typed_metadata_error() {
+ let mut scratch = DependencyScratch::default();
+ assert!(matches!(
+ scratch.collect(&WebUIFragmentRecords::new(), &HashSet::new(), "x-root"),
+ Err(ParserError::ClientTemplateMetadata { .. })
+ ));
+ }
+
+ #[test]
+ fn invalid_plugin_payloads_are_not_repaired_silently() {
+ let mut json = "invalid".to_string();
+ assert!(matches!(
+ append_dependencies("x-root", &mut json, &[]),
+ Err(ParserError::ClientTemplateMetadata { .. })
+ ));
+ assert_eq!(json, "invalid");
+ }
+}
diff --git a/crates/webui-parser/src/plugin/webui/whitespace_tests.rs b/crates/webui-parser/src/plugin/webui/whitespace_tests.rs
index 470d39501..0ff91a2d2 100644
--- a/crates/webui-parser/src/plugin/webui/whitespace_tests.rs
+++ b/crates/webui-parser/src/plugin/webui/whitespace_tests.rs
@@ -143,15 +143,15 @@ fn directive_attributes_use_html_scanner() -> Result<()> {
"{{item.label}}",
)?;
assert_same_metadata(
- "yes
",
- " 0\">yes
",
+ "",
+ "",
)
}
#[test]
fn matching_ignores_directive_text_in_attributes_and_comments() -> Result<()> {
let control = concat!(
- "\">",
+ "\">",
"",
"
\"><if> native text
",
"literal",
diff --git a/crates/webui-press/src/bundler.rs b/crates/webui-press/src/bundler.rs
index 557201375..ac18218a0 100644
--- a/crates/webui-press/src/bundler.rs
+++ b/crates/webui-press/src/bundler.rs
@@ -1199,6 +1199,14 @@ fn default_framework_alias(node_modules: &Path) -> Option {
None
}
+fn append_framework_runtime(entry: &mut String, has_components: bool, node_modules: &Path) {
+ if has_components && default_framework_alias(node_modules).is_some() {
+ entry.push_str("import { startRuntime } from '@microsoft/webui-framework/runtime.js';\n");
+ entry.push_str("import '@microsoft/webui-framework/styles.js';\n");
+ entry.push_str("startRuntime();\n");
+ }
+}
+
fn normalized_alias_target(config_dir: &Path, target: &str) -> String {
let path = Path::new(target);
if path.is_absolute() {
@@ -1217,6 +1225,16 @@ fn build_aliases(opts: &BundleOptions<'_>) -> Result> {
let mut aliases: BTreeMap = BTreeMap::new();
if let Some(node_modules) = opts.node_modules {
if let Some(path) = default_framework_alias(node_modules) {
+ let extension = path
+ .extension()
+ .and_then(|value| value.to_str())
+ .unwrap_or("js");
+ for (entry, file) in [("runtime", "runtime"), ("styles", "styles-entry")] {
+ aliases.insert(
+ format!("@microsoft/webui-framework/{entry}.js"),
+ path_for_js(&path.with_file_name(format!("{file}.{extension}"))),
+ );
+ }
aliases.insert("@microsoft/webui-framework".to_string(), path_for_js(&path));
}
}
@@ -1494,6 +1512,7 @@ pub(crate) fn bundle_assets(opts: &BundleOptions<'_>) -> Result {
let entry_path = bundle_tmp.path().join("index.ts");
let mut entry = String::with_capacity(32 + root.component_scripts.len() * 80);
entry.push_str("// Root template script\n");
+ append_framework_runtime(&mut entry, !root.component_scripts.is_empty(), node_modules);
let mut imports = HashSet::with_capacity(
root.component_scripts.len() + usize::from(root.script_path.is_some()),
);
@@ -1529,6 +1548,11 @@ pub(crate) fn bundle_assets(opts: &BundleOptions<'_>) -> Result {
entry.push_str("// Page: ");
entry.push_str(&bundle.page_path);
entry.push('\n');
+ append_framework_runtime(
+ &mut entry,
+ !bundle.component_scripts.is_empty(),
+ node_modules,
+ );
let mut imports =
HashSet::with_capacity(bundle.component_scripts.len() + bundle.explicit_scripts.len());
@@ -2062,6 +2086,27 @@ mod tests {
assert!(args.contains(&"--external:cdn-only-package".to_string()));
}
+ #[test]
+ fn only_component_entries_install_the_framework_runtime() -> TestResult {
+ let temporary = tempfile::tempdir()?;
+ let framework = temporary
+ .path()
+ .join("@microsoft")
+ .join("webui-framework")
+ .join("dist");
+ fs::create_dir_all(&framework)?;
+ fs::write(framework.join("index.js"), "export {};")?;
+ let mut entry = String::new();
+ append_framework_runtime(&mut entry, false, temporary.path());
+ assert!(entry.is_empty());
+ append_framework_runtime(&mut entry, true, temporary.path());
+ assert_eq!(
+ entry,
+ "import { startRuntime } from '@microsoft/webui-framework/runtime.js';\nimport '@microsoft/webui-framework/styles.js';\nstartRuntime();\n"
+ );
+ Ok(())
+ }
+
#[test]
fn esbuild_args_force_webui_decorator_semantics() -> TestResult {
let site_dir = Path::new("/site");
diff --git a/crates/webui-protocol/Cargo.toml b/crates/webui-protocol/Cargo.toml
index e454e987c..f1434ccb3 100644
--- a/crates/webui-protocol/Cargo.toml
+++ b/crates/webui-protocol/Cargo.toml
@@ -19,6 +19,8 @@ name = "webui_protocol"
[features]
# Enable the build-time state-projection manifest contract and validation.
projection-manifest = ["dep:sha2"]
+# Build-time artifact identity; runtime-only consumers use the stored identity.
+content-identity = ["dep:sha2"]
# Enable to regenerate src/gen_webui.rs from proto/webui.proto via prost-build.
# Off by default so crates.io consumers don't pull in prost-build.
regenerate-proto = ["dep:prost-build"]
diff --git a/crates/webui-protocol/build.rs b/crates/webui-protocol/build.rs
index 4c449bf3c..5173b5e08 100644
--- a/crates/webui-protocol/build.rs
+++ b/crates/webui-protocol/build.rs
@@ -14,6 +14,7 @@ fn main() -> std::io::Result<()> {
config.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]");
config.type_attribute("webui.ComponentData", "#[serde(deny_unknown_fields)]");
config.type_attribute("webui.WebUIProtocol", "#[serde(deny_unknown_fields)]");
+ config.type_attribute("webui.WebUIFragmentIf", "#[serde(deny_unknown_fields)]");
// Build into OUT_DIR first (prost-build requirement), then copy to src/.
config.compile_protos(&["proto/webui.proto"], &["proto/"])?;
diff --git a/crates/webui-protocol/proto/webui.proto b/crates/webui-protocol/proto/webui.proto
index b1f0cd14b..3f96f6cb9 100644
--- a/crates/webui-protocol/proto/webui.proto
+++ b/crates/webui-protocol/proto/webui.proto
@@ -143,6 +143,12 @@ message WebUIProtocol {
// Entries are sorted by root tag. Builds without component assets omit the
// field from the wire.
repeated ComponentAssetStylePreload component_asset_style_preloads = 12;
+ // Opaque content identity supplied by the compiler. Inventory positions may
+ // only be reused against this same compiled artifact.
+ string build_id = 13;
+ // Client-only template sections extracted from lazy if bodies. Keys are
+ // resource identities, not custom-element names.
+ map client_fragments = 14;
}
// Compile-time component style resources for one CSS tree.
@@ -312,6 +318,24 @@ message WebUIFragmentSignal {
message WebUIFragmentIf {
ConditionExpr condition = 1;
string fragment_id = 2;
+ // Keep this ordinary template body out of eager client delivery when inactive.
+ // Scope, events, refs, DOM ownership, and SSR condition semantics are unchanged.
+ bool lazy = 3;
+}
+
+// Compiled client metadata for a template section, without a component host.
+message ClientFragmentData {
+ string owner = 1;
+ // Optional owner-scoped authored id for a publicly loadable lazy region.
+ string name = 2;
+ string template_json = 3;
+ string template_functions = 4;
+ // Ordinary template resources required by this section; nested lazy edges
+ // are separate roots and are not implicit preload dependencies.
+ repeated string dependencies = 5;
+ repeated string state_keys = 6;
+ optional StateProjectionMode state_mode = 7;
+ bool entry = 8;
}
// Dynamic attribute binding.
diff --git a/crates/webui-protocol/src/build_hash.rs b/crates/webui-protocol/src/build_hash.rs
new file mode 100644
index 000000000..f501220ca
--- /dev/null
+++ b/crates/webui-protocol/src/build_hash.rs
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+pub(crate) fn format_sha256(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::with_capacity(7 + bytes.len() * 2);
+ output.push_str("sha256:");
+ for &byte in bytes {
+ output.push(char::from(HEX[usize::from(byte >> 4)]));
+ output.push(char::from(HEX[usize::from(byte & 15)]));
+ }
+ output
+}
diff --git a/crates/webui-protocol/src/content_identity.rs b/crates/webui-protocol/src/content_identity.rs
new file mode 100644
index 000000000..7ca1e1ae9
--- /dev/null
+++ b/crates/webui-protocol/src/content_identity.rs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use crate::ProtocolError;
+use crate::{
+ ClientFragmentData, ComponentAssetStylePreload, ComponentData, ComponentStyleClosure,
+ FragmentList, StyleChunk, WebUIProtocol,
+};
+use serde::{ser::SerializeMap, Serialize, Serializer};
+use sha2::{Digest, Sha256};
+use std::collections::HashMap;
+use std::io::{self, Write};
+
+struct SortedMap<'a, T>(Vec<(&'a str, &'a T)>);
+
+impl<'a, T> SortedMap<'a, T> {
+ fn new(values: &'a HashMap) -> Self {
+ let mut entries: Vec<_> = values
+ .iter()
+ .map(|(name, value)| (name.as_str(), value))
+ .collect();
+ entries.sort_unstable_by_key(|(name, _)| *name);
+ Self(entries)
+ }
+}
+
+impl Serialize for SortedMap<'_, T> {
+ fn serialize(&self, serializer: S) -> Result {
+ let mut map = serializer.serialize_map(Some(self.0.len()))?;
+ for (name, value) in &self.0 {
+ map.serialize_entry(name, value)?;
+ }
+ map.end()
+ }
+}
+
+#[derive(Serialize)]
+struct Content<'a> {
+ fragments: SortedMap<'a, FragmentList>,
+ tokens: &'a [String],
+ components: SortedMap<'a, ComponentData>,
+ css_strategy: i32,
+ initial_state_strategy: i32,
+ module_preloads: &'a [String],
+ component_render_css: &'a str,
+ style_closures: SortedMap<'a, ComponentStyleClosure>,
+ style_chunks: &'a [StyleChunk],
+ component_asset_style_preloads: &'a [ComponentAssetStylePreload],
+ client_fragments: SortedMap<'a, ClientFragmentData>,
+}
+
+impl<'a> Content<'a> {
+ fn new(protocol: &'a WebUIProtocol) -> Self {
+ Self {
+ fragments: SortedMap::new(&protocol.fragments),
+ tokens: &protocol.tokens,
+ components: SortedMap::new(&protocol.components),
+ css_strategy: protocol.css_strategy,
+ initial_state_strategy: protocol.initial_state_strategy,
+ module_preloads: &protocol.module_preloads,
+ component_render_css: &protocol.component_render_css,
+ style_closures: SortedMap::new(&protocol.style_closures),
+ style_chunks: &protocol.style_chunks,
+ component_asset_style_preloads: &protocol.component_asset_style_preloads,
+ client_fragments: SortedMap::new(&protocol.client_fragments),
+ }
+ }
+}
+
+struct HashWriter(Sha256);
+
+impl Write for HashWriter {
+ fn write(&mut self, bytes: &[u8]) -> io::Result {
+ self.0.update(bytes);
+ Ok(bytes.len())
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+}
+
+pub(crate) fn compute(protocol: &WebUIProtocol) -> Result {
+ let mut writer = HashWriter(Sha256::new());
+ writer.0.update(b"webui.protocol-content/v1\0");
+ serde_json::to_writer(&mut writer, &Content::new(protocol))
+ .map_err(|error| ProtocolError::Validation(error.to_string()))?;
+ Ok(crate::build_hash::format_sha256(
+ writer.0.finalize().as_slice(),
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::WebUIFragment;
+
+ #[test]
+ fn identity_is_independent_of_map_iteration_and_its_existing_identity() {
+ let entries = [
+ (
+ "x-first".to_string(),
+ FragmentList {
+ fragments: vec![WebUIFragment::raw("First
")],
+ ..Default::default()
+ },
+ ),
+ (
+ "x-second".to_string(),
+ FragmentList {
+ fragments: vec![WebUIFragment::raw("Second
")],
+ ..Default::default()
+ },
+ ),
+ ];
+ let first = WebUIProtocol::new(entries.clone().into_iter().collect());
+ let mut second = WebUIProtocol::new(entries.into_iter().rev().collect());
+ second.build_id = "previous".into();
+ assert_eq!(compute(&first).unwrap(), compute(&second).unwrap());
+ second.tokens.push("changed".into());
+ assert_ne!(compute(&first).unwrap(), compute(&second).unwrap());
+ }
+
+ #[test]
+ fn identity_covers_every_protocol_field_except_itself() {
+ let protocol = WebUIProtocol::new(HashMap::new());
+ let mut expected = serde_json::to_value(&protocol).unwrap();
+ expected.as_object_mut().unwrap().remove("build_id");
+ assert_eq!(
+ serde_json::to_value(Content::new(&protocol)).unwrap(),
+ expected
+ );
+ }
+}
diff --git a/crates/webui-protocol/src/gen_webui.rs b/crates/webui-protocol/src/gen_webui.rs
index f8d09ef73..80f3d03f1 100644
--- a/crates/webui-protocol/src/gen_webui.rs
+++ b/crates/webui-protocol/src/gen_webui.rs
@@ -146,6 +146,17 @@ pub struct WebUiProtocol {
pub component_asset_style_preloads: ::prost::alloc::vec::Vec<
ComponentAssetStylePreload,
>,
+ /// Opaque content identity supplied by the compiler. Inventory positions may
+ /// only be reused against this same compiled artifact.
+ #[prost(string, tag = "13")]
+ pub build_id: ::prost::alloc::string::String,
+ /// Client-only template sections extracted from lazy if bodies. Keys are
+ /// resource identities, not custom-element names.
+ #[prost(map = "string, message", tag = "14")]
+ pub client_fragments: ::std::collections::HashMap<
+ ::prost::alloc::string::String,
+ ClientFragmentData,
+ >,
}
/// Compile-time component style resources for one CSS tree.
#[derive(serde::Serialize, serde::Deserialize)]
@@ -362,12 +373,41 @@ pub struct WebUiFragmentSignal {
}
/// Conditional rendering fragment.
#[derive(serde::Serialize, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct WebUiFragmentIf {
#[prost(message, optional, tag = "1")]
pub condition: ::core::option::Option,
#[prost(string, tag = "2")]
pub fragment_id: ::prost::alloc::string::String,
+ /// Keep this ordinary template body out of eager client delivery when inactive.
+ /// Scope, events, refs, DOM ownership, and SSR condition semantics are unchanged.
+ #[prost(bool, tag = "3")]
+ pub lazy: bool,
+}
+/// Compiled client metadata for a template section, without a component host.
+#[derive(serde::Serialize, serde::Deserialize)]
+#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
+pub struct ClientFragmentData {
+ #[prost(string, tag = "1")]
+ pub owner: ::prost::alloc::string::String,
+ /// Optional owner-scoped authored id for a publicly loadable lazy region.
+ #[prost(string, tag = "2")]
+ pub name: ::prost::alloc::string::String,
+ #[prost(string, tag = "3")]
+ pub template_json: ::prost::alloc::string::String,
+ #[prost(string, tag = "4")]
+ pub template_functions: ::prost::alloc::string::String,
+ /// Ordinary template resources required by this section; nested lazy edges
+ /// are separate roots and are not implicit preload dependencies.
+ #[prost(string, repeated, tag = "5")]
+ pub dependencies: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(string, repeated, tag = "6")]
+ pub state_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(enumeration = "StateProjectionMode", optional, tag = "7")]
+ pub state_mode: ::core::option::Option,
+ #[prost(bool, tag = "8")]
+ pub entry: bool,
}
/// Dynamic attribute binding.
#[derive(serde::Serialize, serde::Deserialize)]
diff --git a/crates/webui-protocol/src/lazy.rs b/crates/webui-protocol/src/lazy.rs
new file mode 100644
index 000000000..399a53927
--- /dev/null
+++ b/crates/webui-protocol/src/lazy.rs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use std::fmt::Write as _;
+
+/// Return an owner-scoped resource identity for a lazy if body.
+///
+/// Authored names are validated by the parser. Anonymous identities are
+/// build-local and should not be hardcoded by application code.
+#[must_use]
+pub fn lazy_fragment_id(owner: &str, name: Option<&str>, ordinal: usize) -> String {
+ let mut id = String::with_capacity(owner.len() + name.map_or(24, str::len) + 8);
+ id.push_str(owner);
+ id.push_str("::lazy:");
+ if let Some(name) = name {
+ id.push_str(name);
+ } else {
+ id.push('@');
+ let _ = write!(id, "{ordinal}");
+ }
+ id
+}
+
+/// Return the compiler-owned identity of an external block in an owning template.
+#[must_use]
+pub fn lazy_block_id(owner: &str, index: usize) -> String {
+ let mut id = String::with_capacity(owner.len() + 28);
+ id.push_str(owner);
+ id.push_str("::block:");
+ let _ = write!(id, "{index}");
+ id
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn identities_are_owner_scoped_and_distinguish_anonymous_roots_from_blocks() {
+ assert_eq!(
+ lazy_fragment_id("app-shell", Some("settings"), 0),
+ "app-shell::lazy:settings"
+ );
+ assert_eq!(lazy_fragment_id("app-shell", None, 0), "app-shell::lazy:@0");
+ assert_eq!(lazy_block_id("app-shell", 0), "app-shell::block:0");
+ }
+}
diff --git a/crates/webui-protocol/src/lib.rs b/crates/webui-protocol/src/lib.rs
index 3c2559715..c6d922467 100644
--- a/crates/webui-protocol/src/lib.rs
+++ b/crates/webui-protocol/src/lib.rs
@@ -23,6 +23,13 @@ pub mod projection_manifest;
/// Attribute-name ↔ property-name mapping for irregular HTML attributes.
pub mod attrs;
+mod lazy;
+pub use lazy::{lazy_block_id, lazy_fragment_id};
+
+#[cfg(any(feature = "content-identity", feature = "projection-manifest"))]
+mod build_hash;
+#[cfg(feature = "content-identity")]
+mod content_identity;
/// Generated protobuf types from `proto/webui.proto`.
pub mod proto {
@@ -52,9 +59,46 @@ pub type ComponentData = proto::ComponentData;
pub type ComponentStyleClosure = proto::ComponentStyleClosure;
pub type StyleChunk = proto::StyleChunk;
+/// Borrowed client payload for a component template or a hostless template section.
+pub struct ClientTemplatePayload<'a> {
+ /// Canonical resource identity.
+ pub id: &'a str,
+ /// JSON-safe metadata, when emitted by the selected compiler plugin.
+ pub metadata: &'a str,
+ /// Separately delivered executable condition closures.
+ pub functions: &'a str,
+ /// Non-WebUI template markup for legacy plugin payloads.
+ pub legacy_html: &'a str,
+}
+
/// A mapping of unique fragment identifiers to their corresponding fragment lists.
pub type WebUIFragmentRecords = HashMap;
+impl ClientFragmentData {
+ /// Combine this body's template-read keys with its owner's authored state contract.
+ ///
+ /// A lazy body keeps the existing owner; it does not introduce an
+ /// independent component state boundary. Unproven authored state stays full.
+ pub fn inherit_owner_state(&mut self, owner: &ComponentData) {
+ match StateProjectionMode::try_from(owner.hydration_mode) {
+ Ok(StateProjectionMode::None | StateProjectionMode::Keys) => {
+ self.state_keys.extend(owner.hydration_keys.iter().cloned());
+ self.state_keys.sort_unstable();
+ self.state_keys.dedup();
+ self.state_mode = Some(if self.state_keys.is_empty() {
+ StateProjectionMode::None as i32
+ } else {
+ StateProjectionMode::Keys as i32
+ });
+ }
+ Ok(StateProjectionMode::All) | Err(_) => {
+ self.state_mode = Some(StateProjectionMode::All as i32);
+ self.state_keys.clear();
+ }
+ }
+ }
+}
+
#[derive(Debug, Error)]
pub enum ProtocolError {
#[error("IO error: {0}")]
@@ -209,6 +253,19 @@ impl WebUiFragment {
fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf {
condition: Some(condition),
fragment_id: fragment_id.into(),
+ lazy: false,
+ })),
+ }
+ }
+
+ /// Create a conditional boundary whose client resources are delivered on demand.
+ #[must_use]
+ pub fn lazy_if(condition: ConditionExpr, fragment_id: impl Into) -> Self {
+ Self {
+ fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf {
+ condition: Some(condition),
+ fragment_id: fragment_id.into(),
+ lazy: true,
})),
}
}
@@ -424,6 +481,42 @@ impl CssStrategy {
}
impl WebUiProtocol {
+ /// Borrow a client template payload without assigning a component host to it.
+ #[must_use]
+ pub fn client_template(&self, id: &str) -> Option> {
+ if let Some((name, data)) = self.components.get_key_value(id) {
+ return Some(ClientTemplatePayload {
+ id: name,
+ metadata: &data.template_json,
+ functions: &data.template_functions,
+ legacy_html: &data.template,
+ });
+ }
+ self.client_fragments
+ .get_key_value(id)
+ .map(|(name, data)| ClientTemplatePayload {
+ id: name,
+ metadata: &data.template_json,
+ functions: &data.template_functions,
+ legacy_html: "",
+ })
+ }
+
+ /// Assign a deterministic content identity before publishing an artifact.
+ ///
+ /// Hashes every protocol field except the identity itself in canonical map
+ /// order, borrowing payloads rather than cloning the protocol. This is a
+ /// build-time cache-compatibility identity, not an authentication mechanism.
+ ///
+ /// # Errors
+ ///
+ /// Returns a validation error if the protocol cannot be serialized.
+ #[cfg(feature = "content-identity")]
+ pub fn assign_build_id(&mut self) -> Result<()> {
+ self.build_id = content_identity::compute(self)?;
+ Ok(())
+ }
+
/// Return whether a component authored a declarative Shadow DOM root.
#[must_use]
pub fn component_uses_shadow_dom(&self, tag_name: &str) -> bool {
@@ -715,6 +808,8 @@ impl WebUiProtocol {
style_closures: HashMap::new(),
style_chunks: Vec::new(),
component_asset_style_preloads: Vec::new(),
+ build_id: String::new(),
+ client_fragments: HashMap::new(),
}
}
@@ -731,6 +826,8 @@ impl WebUiProtocol {
style_closures: HashMap::new(),
style_chunks: Vec::new(),
component_asset_style_preloads: Vec::new(),
+ build_id: String::new(),
+ client_fragments: HashMap::new(),
}
}
}
@@ -976,6 +1073,61 @@ impl WebUiProtocol {
mod tests {
use super::*;
+ #[test]
+ fn lazy_is_an_extended_if_without_legacy_json_aliases() {
+ let lazy = WebUIFragment::lazy_if(ConditionExpr::identifier("shown"), "body");
+ assert!(
+ matches!(lazy.fragment, Some(web_ui_fragment::Fragment::IfCond(ref condition)) if condition.lazy)
+ );
+ for legacy in [
+ r#"{"condition":null,"fragment_id":"body"}"#,
+ r#"{"condition":null,"fragment_id":"body","deferred":true}"#,
+ r#"{"condition":null,"fragment_id":"body","lazy":true,"deferred":true}"#,
+ ] {
+ assert!(serde_json::from_str::(legacy).is_err());
+ }
+ let current: WebUIFragmentIf =
+ serde_json::from_str(r#"{"condition":null,"fragment_id":"body","lazy":true}"#).unwrap();
+ assert!(current.lazy);
+ }
+
+ #[test]
+ fn client_fragments_are_payloads_without_component_ownership() {
+ let mut protocol = WebUIProtocol::new(HashMap::new());
+ protocol.client_fragments.insert(
+ "owner::lazy:body".into(),
+ ClientFragmentData {
+ owner: "owner".into(),
+ template_json: r#"{"h":"Body
"}"#.into(),
+ ..Default::default()
+ },
+ );
+ let payload = protocol.client_template("owner::lazy:body").unwrap();
+ assert_eq!(payload.metadata, r#"{"h":"Body
"}"#);
+ assert!(payload.legacy_html.is_empty());
+ assert!(protocol.components.is_empty());
+ }
+
+ #[test]
+ fn fragment_state_inherits_authored_owner_contract_without_a_new_state_boundary() {
+ let mut fragment = ClientFragmentData {
+ state_keys: vec!["body".into()],
+ ..Default::default()
+ };
+ let mut owner = ComponentData {
+ hydration_mode: StateProjectionMode::Keys as i32,
+ hydration_keys: vec!["owner".into(), "body".into()],
+ ..Default::default()
+ };
+ fragment.inherit_owner_state(&owner);
+ assert_eq!(fragment.state_keys, ["body", "owner"]);
+ assert_eq!(fragment.state_mode, Some(StateProjectionMode::Keys as i32));
+ owner.hydration_mode = StateProjectionMode::All as i32;
+ fragment.inherit_owner_state(&owner);
+ assert_eq!(fragment.state_mode, Some(StateProjectionMode::All as i32));
+ assert!(fragment.state_keys.is_empty());
+ }
+
fn sample_protocol() -> WebUIProtocol {
let mut fragments = HashMap::new();
fragments.insert(
diff --git a/crates/webui-protocol/src/projection_manifest.rs b/crates/webui-protocol/src/projection_manifest.rs
index 44b5f4b04..02118487c 100644
--- a/crates/webui-protocol/src/projection_manifest.rs
+++ b/crates/webui-protocol/src/projection_manifest.rs
@@ -446,15 +446,8 @@ fn append_record(output: &mut String, label: &str, fields: &[&str]) {
}
fn hash_bytes(bytes: &[u8]) -> String {
- const HEX: &[u8; 16] = b"0123456789abcdef";
let digest = Sha256::digest(bytes);
- let mut output = String::with_capacity(71);
- output.push_str("sha256:");
- for byte in digest {
- output.push(char::from(HEX[usize::from(byte >> 4)]));
- output.push(char::from(HEX[usize::from(byte & 0x0f)]));
- }
- output
+ crate::build_hash::format_sha256(digest.as_slice())
}
/// Whether a value is exactly `sha256:` plus 64 lowercase hex characters.
diff --git a/crates/webui-python/README.md b/crates/webui-python/README.md
index 855eb1012..c4fb1e90b 100644
--- a/crates/webui-python/README.md
+++ b/crates/webui-python/README.md
@@ -27,6 +27,12 @@ a Python string.
`render_component_templates()` accepts either one component tag as a string or
an iterable of tags.
+For named lazy template bodies, use `render_lazy_resources()` or
+`prepare_lazy_resources()`, then `render_lazy_partial()` with fresh
+scoped state. The prepared result carries the small inventory acknowledgement
+without reparsing the resource JSON. See the
+[lazy response API](https://microsoft.github.io/webui/guide/integrations/python#lazy-template-responses).
+
Host-driven streaming discovers runtime occurrences instead of resolving
compile-time names:
@@ -79,3 +85,17 @@ separately installed native library or separately published WebUI crates.
See the [Python integration
guide](https://microsoft.github.io/webui/guide/integrations/python) for partial
navigation and host-driven streaming examples.
+
+## Testing from source
+
+After installing a wheel built from this checkout, run
+`python -m pytest crates/webui-python/tests` from the workspace root. Tests
+compile the authored fixtures once per session using the current CLI.
+`WEBUI_BINARY_PATH` can select an already-built CLI.
+
+To reuse fixtures for the wheel matrix or benchmarks, run
+`python crates/webui-python/tests/build_fixtures.py`. It writes to
+`target/python-fixtures`, checks current template metadata and semantic
+repeatability, and preserves the authored stylesheet. Set `WEBUI_TEST_FIXTURES`
+to that directory when running the tests. CI downloads the same generated
+fixtures on every wheel-test platform; no compiled fixture binaries are committed.
diff --git a/crates/webui-python/benchmarks/benchmark_renderer.py b/crates/webui-python/benchmarks/benchmark_renderer.py
index ee1f41a95..afcc81e5d 100644
--- a/crates/webui-python/benchmarks/benchmark_renderer.py
+++ b/crates/webui-python/benchmarks/benchmark_renderer.py
@@ -16,10 +16,13 @@
if TYPE_CHECKING:
from collections.abc import Callable, Generator
-FIXTURES = Path(__file__).parents[1] / "tests" / "fixtures"
-PROTOCOL_PATH = FIXTURES / "protocol.bin"
+FIXTURES = Path(os.environ.get(
+ "WEBUI_TEST_FIXTURES",
+ Path(__file__).resolve().parents[3] / "target" / "python-fixtures",
+))
+PROTOCOL_PATH = FIXTURES / "app" / "protocol.bin"
PROTOCOL_BYTES = PROTOCOL_PATH.read_bytes()
-STREAMING_PROTOCOL_BYTES = (FIXTURES / "streaming_protocol.bin").read_bytes()
+STREAMING_PROTOCOL_BYTES = (FIXTURES / "streaming-app" / "protocol.bin").read_bytes()
STATE = {"title": "Benchmark", "name": "Ada", "status": "ready"}
STREAMING_STATE = {
**STATE,
diff --git a/crates/webui-python/python/microsoft_webui/__init__.py b/crates/webui-python/python/microsoft_webui/__init__.py
index 70d42ac5f..90891ac9a 100644
--- a/crates/webui-python/python/microsoft_webui/__init__.py
+++ b/crates/webui-python/python/microsoft_webui/__init__.py
@@ -6,7 +6,9 @@
from ._api import (
BoundaryDescriptor,
BoundaryMode,
+ LazyOptions,
Plugin,
+ PreparedLazyResources,
Renderer,
StateInput,
StreamingSession,
@@ -24,7 +26,9 @@
__all__ = [
"BoundaryDescriptor",
"BoundaryMode",
+ "LazyOptions",
"Plugin",
+ "PreparedLazyResources",
"ProtocolError",
"RenderError",
"Renderer",
diff --git a/crates/webui-python/python/microsoft_webui/_api.py b/crates/webui-python/python/microsoft_webui/_api.py
index d9e4cfbe6..b404a926c 100644
--- a/crates/webui-python/python/microsoft_webui/_api.py
+++ b/crates/webui-python/python/microsoft_webui/_api.py
@@ -54,6 +54,31 @@ class StreamStep:
boundary: BoundaryDescriptor | None = None
+@dataclass(frozen=True, slots=True)
+class LazyOptions:
+ """Availability of resources from one compiled document."""
+
+ inventory: str = ""
+ style_inventory: str = ""
+ build_id: str = ""
+
+
+@dataclass(frozen=True, slots=True)
+class PreparedLazyResources:
+ """Resource JSON and acknowledgement for host-owned two-phase delivery."""
+
+ json: bytes
+ options: LazyOptions
+
+
+def _lazy_options(
+ owner: str, region_id: str, options: LazyOptions | None
+) -> tuple[str, str, str, str, str]:
+ if options is None:
+ return owner, region_id, "", "", ""
+ return owner, region_id, options.inventory, options.style_inventory, options.build_id
+
+
def _plugin_value(plugin: Plugin | str | None) -> str | None:
if plugin is None:
return None
@@ -177,6 +202,34 @@ def render_component_templates(
(tags, inventory),
)
+ def render_lazy_resources(
+ self, owner: str, region_id: str, *, options: LazyOptions | None = None
+ ) -> bytes:
+ """Prepare a named template body's resources without application state."""
+ return self._inner.render_lazy_resources(_lazy_options(owner, region_id, options))
+
+ def prepare_lazy_resources(
+ self, owner: str, region_id: str, *, options: LazyOptions | None = None
+ ) -> PreparedLazyResources:
+ """Prepare metadata and acknowledgement without reparsing metadata JSON."""
+ body, acknowledgement = self._inner.prepare_lazy_resources(
+ _lazy_options(owner, region_id, options)
+ )
+ return PreparedLazyResources(body, LazyOptions(*acknowledgement))
+
+ def render_lazy_partial(
+ self,
+ state: StateInput,
+ owner: str,
+ region_id: str,
+ *,
+ options: LazyOptions | None = None,
+ ) -> bytes:
+ """Render fresh owner/scoped state and any still-missing resources."""
+ return self._inner.render_lazy_partial(
+ _state_json(state), _lazy_options(owner, region_id, options)
+ )
+
@property
def tokens(self) -> tuple[str, ...]:
"""CSS token names in build order."""
diff --git a/crates/webui-python/python/microsoft_webui/_native.pyi b/crates/webui-python/python/microsoft_webui/_native.pyi
index 8d6dd397b..74703bbf4 100644
--- a/crates/webui-python/python/microsoft_webui/_native.pyi
+++ b/crates/webui-python/python/microsoft_webui/_native.pyi
@@ -5,6 +5,7 @@ from os import PathLike
from typing import Self, TypeAlias, TypedDict, final
_JsonInput: TypeAlias = str | bytes | bytearray
+_LazyOptions: TypeAlias = tuple[str, str, str, str, str]
_RenderOptions: TypeAlias = tuple[
str,
str,
@@ -56,6 +57,13 @@ class _Renderer:
self,
options: tuple[list[str], str],
) -> bytes: ...
+ def render_lazy_resources(self, options: _LazyOptions) -> bytes: ...
+ def prepare_lazy_resources(
+ self, options: _LazyOptions
+ ) -> tuple[bytes, tuple[str, str, str]]: ...
+ def render_lazy_partial(
+ self, state_json: _JsonInput, options: _LazyOptions
+ ) -> bytes: ...
def tokens(self) -> list[str]: ...
def stream_response(self, options: _RenderOptions) -> _StreamingSession: ...
diff --git a/crates/webui-python/src/lib.rs b/crates/webui-python/src/lib.rs
index bdceae84c..26c946854 100644
--- a/crates/webui-python/src/lib.rs
+++ b/crates/webui-python/src/lib.rs
@@ -45,6 +45,17 @@ type PyRenderOptions = (
);
type PyPartialOptions = (String, String, String);
type PyTemplateOptions = (Vec, String);
+type PyLazyOptions = (String, String, String, String, String);
+
+fn lazy_request(options: &PyLazyOptions) -> webui_handler::LazyRequest<'_> {
+ webui_handler::LazyRequest {
+ owner: &options.0,
+ id: &options.1,
+ inventory: &options.2,
+ style_inventory: &options.3,
+ build_id: &options.4,
+ }
+}
webui_handler::define_bytes_response_writer!(BytesWriter, bytes);
@@ -271,6 +282,62 @@ impl NativeRenderer {
Ok(PyBytes::new(py, &result))
}
+ fn render_lazy_resources<'py>(
+ &self,
+ py: Python<'py>,
+ options: PyLazyOptions,
+ ) -> PyResult> {
+ let result = py
+ .detach(|| {
+ self.protocol
+ .render_lazy_resources(lazy_request(&options))
+ .map(String::into_bytes)
+ .map_err(render_binding_error)
+ })
+ .map_err(BindingError::into_py_error)?;
+ Ok(PyBytes::new(py, &result))
+ }
+
+ fn prepare_lazy_resources<'py>(
+ &self,
+ py: Python<'py>,
+ options: PyLazyOptions,
+ ) -> PyResult<(Bound<'py, PyBytes>, (String, String, String))> {
+ let prepared = py
+ .detach(|| {
+ self.protocol
+ .prepare_lazy_resources(lazy_request(&options))
+ .map_err(render_binding_error)
+ })
+ .map_err(BindingError::into_py_error)?;
+ Ok((
+ PyBytes::new(py, prepared.json.as_bytes()),
+ (
+ prepared.options.inventory,
+ prepared.options.style_inventory,
+ prepared.options.build_id,
+ ),
+ ))
+ }
+
+ fn render_lazy_partial<'py>(
+ &self,
+ py: Python<'py>,
+ state_json: &Bound<'_, PyAny>,
+ options: PyLazyOptions,
+ ) -> PyResult> {
+ let input = JsonInput::extract(state_json)?;
+ let result = py
+ .detach(|| {
+ self.protocol
+ .render_lazy_partial_json(input.as_str()?, lazy_request(&options))
+ .map(String::into_bytes)
+ .map_err(partial_binding_error)
+ })
+ .map_err(BindingError::into_py_error)?;
+ Ok(PyBytes::new(py, &result))
+ }
+
fn tokens(&self) -> Vec {
self.protocol.tokens().to_vec()
}
diff --git a/crates/webui-python/tests/build_fixtures.py b/crates/webui-python/tests/build_fixtures.py
new file mode 100644
index 000000000..609a64d1f
--- /dev/null
+++ b/crates/webui-python/tests/build_fixtures.py
@@ -0,0 +1,85 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Build source fixtures with the current compiler; never check protocol binaries in."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any
+
+from compare_fixture import compare_protocols
+
+ROOT = Path(__file__).resolve().parents[3]
+SOURCES = Path(__file__).parent / "fixtures"
+DEFAULT_OUTPUT = ROOT / "target" / "python-fixtures"
+NAMES = ("app", "streaming-app", "lazy-app")
+
+
+def cli_command() -> list[str]:
+ executable = os.environ.get("WEBUI_BINARY_PATH")
+ return [executable] if executable else [
+ "cargo", "run", "--quiet", "-p", "microsoft-webui-cli", "--",
+ ]
+
+
+def inspect_protocol(command: list[str], path: Path) -> dict[str, Any]:
+ result = subprocess.run(
+ [*command, "inspect", str(path)],
+ cwd=ROOT, check=True, stdout=subprocess.PIPE, text=True, encoding="utf-8",
+ )
+ protocol: dict[str, Any] = json.loads(result.stdout)
+ if not isinstance(protocol, dict):
+ raise ValueError(f"{path}: inspected protocol must be an object")
+ return protocol
+
+
+def build_fixtures(output: Path) -> Path:
+ output = output.resolve()
+ output.mkdir(parents=True, exist_ok=True)
+ command = cli_command()
+ with tempfile.TemporaryDirectory(prefix="verify-", dir=output) as scratch:
+ for name in NAMES:
+ destination = output / name
+ repeated = Path(scratch) / name
+ for directory in (destination, repeated):
+ subprocess.run(
+ [
+ *command, "build", str(SOURCES / name), "--plugin", "webui",
+ "--out", str(directory),
+ ],
+ cwd=ROOT, check=True,
+ )
+ # Protobuf map bytes may differ; every decoded field must match.
+ compare_protocols(
+ inspect_protocol(command, destination / "protocol.bin"),
+ inspect_protocol(command, repeated / "protocol.bin"),
+ )
+ if (output / "app" / "greeting-card.css").read_bytes() != (
+ SOURCES / "app" / "greeting-card.css"
+ ).read_bytes():
+ raise ValueError("greeting-card.css differs from the authored fixture stylesheet")
+ return output
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT)
+ args = parser.parse_args()
+ try:
+ output = build_fixtures(args.out)
+ except (OSError, ValueError, subprocess.CalledProcessError) as error:
+ print(f"error: could not build current Python fixtures: {error}", file=sys.stderr)
+ return 1
+ print(f"current Python fixtures verified: {output}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/crates/webui-python/tests/compare_fixture.py b/crates/webui-python/tests/compare_fixture.py
index 6033f2196..247f7e989 100644
--- a/crates/webui-python/tests/compare_fixture.py
+++ b/crates/webui-python/tests/compare_fixture.py
@@ -1,12 +1,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
-"""Compare two ``webui inspect`` JSON dumps semantically.
-
-Committed protocol fixtures are build outputs, so CI rebuilds them and checks
-for drift. Bytes cannot be compared directly because protobuf map field ordering
-is not stable, so compare the decoded JSON instead.
-"""
+"""Compare current-format ``webui inspect`` dumps without discarding metadata."""
from __future__ import annotations
@@ -14,38 +9,58 @@
import json
import sys
from pathlib import Path
+from typing import Any
+
+DEFAULT_FIXTURE = Path("target/python-fixtures/app/protocol.bin")
+
+
+def validate_metadata(protocol: dict[str, Any]) -> None:
+ if not isinstance(protocol, dict):
+ raise ValueError("protocol fixture must be an object")
+ identity = protocol.get("build_id", "")
+ if not (
+ isinstance(identity, str)
+ and identity.startswith("sha256:")
+ and len(identity) == 71
+ and all(char in "0123456789abcdef" for char in identity[7:])
+ ):
+ raise ValueError("protocol fixture must have a current build identity")
+ for group in ("components", "client_fragments"):
+ for name, record in protocol.get(group, {}).items():
+ template = record.get("template_json")
+ if template and json.loads(template).get("v") != 3:
+ raise ValueError(f"protocol fixture metadata for {name} must use v3")
-DEFAULT_FIXTURE = Path("crates/webui-python/tests/fixtures/protocol.bin")
+
+def compare_protocols(expected: dict[str, Any], actual: dict[str, Any]) -> None:
+ validate_metadata(expected)
+ validate_metadata(actual)
+ if expected != actual:
+ raise ValueError("protocol fixtures differ; inspect the current compiler output")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("committed", type=Path)
+ parser.add_argument("expected", type=Path)
parser.add_argument("generated", type=Path)
parser.add_argument(
"fixture",
nargs="?",
type=Path,
default=DEFAULT_FIXTURE,
- help="committed binary to name when the semantic comparison fails",
+ help="fixture to name when current-format validation or semantic comparison fails",
)
args = parser.parse_args()
try:
- committed = json.loads(args.committed.read_text(encoding="utf-8"))
+ expected = json.loads(args.expected.read_text(encoding="utf-8"))
generated = json.loads(args.generated.read_text(encoding="utf-8"))
+ compare_protocols(expected, generated)
except (OSError, ValueError) as error:
- print(f"error: {error}", file=sys.stderr)
- return 1
-
- if committed != generated:
- print(
- f"error: the Python protocol fixture is stale; regenerate {args.fixture}",
- file=sys.stderr,
- )
+ print(f"error: {args.fixture}: {error}", file=sys.stderr)
return 1
- print("protocol fixture is current")
+ print("current protocol fixtures match")
return 0
diff --git a/crates/webui-python/tests/conftest.py b/crates/webui-python/tests/conftest.py
index 4e946e247..f8d23648d 100644
--- a/crates/webui-python/tests/conftest.py
+++ b/crates/webui-python/tests/conftest.py
@@ -1,15 +1,13 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
+import os
from pathlib import Path
import pytest
+from build_fixtures import NAMES, build_fixtures
from microsoft_webui import Plugin, Renderer
-FIXTURES = Path(__file__).parent / "fixtures"
-PROTOCOL_PATH = FIXTURES / "protocol.bin"
-STREAMING_PROTOCOL_PATH = FIXTURES / "streaming_protocol.bin"
-
STATE = {
"title": "Python renderer",
"name": "Ada",
@@ -27,8 +25,29 @@
@pytest.fixture(scope="session")
-def protocol_bytes() -> bytes:
- return PROTOCOL_PATH.read_bytes()
+def fixture_directory(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ supplied = os.environ.get("WEBUI_TEST_FIXTURES")
+ directory = Path(supplied) if supplied else build_fixtures(
+ tmp_path_factory.mktemp("python-fixtures")
+ )
+ for name in NAMES:
+ if not (directory / name / "protocol.bin").is_file():
+ raise pytest.UsageError(
+ f"Missing {name}/protocol.bin in {directory}. "
+ "Run crates/webui-python/tests/build_fixtures.py "
+ "or download the CI fixture artifact."
+ )
+ return directory
+
+
+@pytest.fixture(scope="session")
+def protocol_path(fixture_directory: Path) -> Path:
+ return fixture_directory / "app" / "protocol.bin"
+
+
+@pytest.fixture(scope="session")
+def protocol_bytes(protocol_path: Path) -> bytes:
+ return protocol_path.read_bytes()
@pytest.fixture
@@ -37,5 +56,12 @@ def renderer(protocol_bytes: bytes) -> Renderer:
@pytest.fixture
-def streaming_renderer() -> Renderer:
- return Renderer(STREAMING_PROTOCOL_PATH.read_bytes(), plugin=Plugin.WEBUI)
+def streaming_renderer(fixture_directory: Path) -> Renderer:
+ return Renderer.from_file(
+ fixture_directory / "streaming-app" / "protocol.bin", plugin=Plugin.WEBUI
+ )
+
+
+@pytest.fixture(scope="session")
+def lazy_renderer(fixture_directory: Path) -> Renderer:
+ return Renderer.from_file(fixture_directory / "lazy-app" / "protocol.bin", plugin=Plugin.WEBUI)
diff --git a/crates/webui-python/tests/fixtures/greeting-card.css b/crates/webui-python/tests/fixtures/greeting-card.css
deleted file mode 100644
index 2e87e2210..000000000
--- a/crates/webui-python/tests/fixtures/greeting-card.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.message {
- color: var(--brand-color, #06c);
-}
diff --git a/crates/webui-python/tests/fixtures/lazy-app/index.html b/crates/webui-python/tests/fixtures/lazy-app/index.html
new file mode 100644
index 000000000..cced8b439
--- /dev/null
+++ b/crates/webui-python/tests/fixtures/lazy-app/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/webui-python/tests/fixtures/lazy-app/test-panel.html b/crates/webui-python/tests/fixtures/lazy-app/test-panel.html
new file mode 100644
index 000000000..522c92ad1
--- /dev/null
+++ b/crates/webui-python/tests/fixtures/lazy-app/test-panel.html
@@ -0,0 +1,6 @@
+Owner
+
+ Lazy inline UI
+ {{message}}
+
+
diff --git a/crates/webui-python/tests/fixtures/protocol.bin b/crates/webui-python/tests/fixtures/protocol.bin
deleted file mode 100644
index 14b59c3cc..000000000
Binary files a/crates/webui-python/tests/fixtures/protocol.bin and /dev/null differ
diff --git a/crates/webui-python/tests/fixtures/streaming_protocol.bin b/crates/webui-python/tests/fixtures/streaming_protocol.bin
deleted file mode 100644
index 3f6aa8518..000000000
--- a/crates/webui-python/tests/fixtures/streaming_protocol.bin
+++ /dev/null
@@ -1,80 +0,0 @@
-
-·
-if-1®
-(R&
-index.htmlgreeting"{{integerKey}}
-
-
-Hello,
-"
-name
-
-
-!
-R8
-1
-/
--Greeting committed.
-&R$
-index.htmlstatus"{{floatKey}}
-
-
-
-
-"
-status
-
-
-
-R8
--
-+
-)Status committed.
-(R&
-index.htmlsummary"
{{stringKey}}
-
-
-
-"
-summary
-
-
-
-R8
-(
-&
-$
-œ
-
-index.html�
-)
-'
-%
-"
-}}}webui:head_start
-"
-
-
- "
-title
-
-
-
-
-"
-}}}webui:head_end
-
-
-
-"
-}}}webui:body_start
-*
-"
-showif-1
-"
-}}}webui:body_end
-
-
-R
-
-index.html
\ No newline at end of file
diff --git a/crates/webui-python/tests/test_compare_fixture.py b/crates/webui-python/tests/test_compare_fixture.py
index 8d3adca05..24e80a396 100644
--- a/crates/webui-python/tests/test_compare_fixture.py
+++ b/crates/webui-python/tests/test_compare_fixture.py
@@ -3,23 +3,91 @@
from __future__ import annotations
+import json
import sys
+from copy import deepcopy
from pathlib import Path
+from typing import Any
import compare_fixture
import pytest
-def test_reports_requested_stale_fixture(
+def current_protocol() -> dict[str, Any]:
+ return {
+ "build_id": "sha256:" + "a" * 64,
+ "components": {
+ "owner-widget": {
+ "template_json": '{"v":3,"h":"","d":["child-widget"]}'
+ },
+ },
+ }
+
+
+def test_current_comparison_preserves_all_metadata() -> None:
+ expected = current_protocol()
+ actual = deepcopy(expected)
+ compare_fixture.compare_protocols(expected, actual)
+ assert expected == actual == current_protocol()
+
+
+@pytest.mark.parametrize("version", [None, 1, 2, 4])
+@pytest.mark.parametrize("group", ["components", "client_fragments"])
+def test_old_or_missing_metadata_versions_are_not_normalized(
+ version: int | None, group: str,
+) -> None:
+ expected = current_protocol()
+ template: dict[str, object] = {"h": "", "d": ["child-widget"]}
+ if version is not None:
+ template["v"] = version
+ expected[group] = {"owner-widget": {"template_json": json.dumps(template)}}
+ actual = deepcopy(expected)
+ with pytest.raises(ValueError, match="must use v3"):
+ compare_fixture.compare_protocols(expected, actual)
+ assert actual == expected
+
+
+@pytest.mark.parametrize("template", [
+ '{"h":"","v":3,"d":[]}',
+ '{"h":"Changed","v":3,"d":["child-widget"]}',
+ '{"h":"","v":3,"d":["child-widget"],"rf":[["button",1]]}',
+])
+def test_current_content_dependencies_and_refs_cannot_be_discarded(template: str) -> None:
+ expected = current_protocol()
+ actual = deepcopy(expected)
+ actual["components"]["owner-widget"]["template_json"] = template
+ with pytest.raises(ValueError, match="fixtures differ"):
+ compare_fixture.compare_protocols(expected, actual)
+ assert actual["components"]["owner-widget"]["template_json"] == template
+
+
+@pytest.mark.parametrize("identity", ["", "not-a-content-identity", "sha256:" + "g" * 64])
+def test_invalid_identity_is_not_ignored(identity: str) -> None:
+ actual = current_protocol()
+ actual["build_id"] = identity
+ with pytest.raises(ValueError, match="current build identity"):
+ compare_fixture.compare_protocols(current_protocol(), actual)
+
+
+def test_different_current_identity_is_not_normalized() -> None:
+ actual = current_protocol()
+ actual["build_id"] = "sha256:" + "b" * 64
+ with pytest.raises(ValueError, match="fixtures differ"):
+ compare_fixture.compare_protocols(current_protocol(), actual)
+
+
+def test_reports_requested_mismatched_fixture(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
committed = tmp_path / "committed.json"
generated = tmp_path / "generated.json"
- fixture = Path("fixtures/streaming_protocol.bin")
- committed.write_text('{"value": 1}', encoding="utf-8")
- generated.write_text('{"value": 2}', encoding="utf-8")
+ fixture = Path("target/python-fixtures/streaming-app/protocol.bin")
+ current = current_protocol()
+ committed.write_text(json.dumps(current), encoding="utf-8")
+ current["tokens"] = ["unexpected-token"]
+ generated.write_text(json.dumps(current), encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
@@ -27,18 +95,22 @@ def test_reports_requested_stale_fixture(
)
assert compare_fixture.main() == 1
- assert str(fixture) in capsys.readouterr().err
+ message = capsys.readouterr().err
+ assert str(fixture) in message
+ assert "fixtures differ" in message
-def test_default_fixture_path_remains_compatible(
+def test_reports_default_generated_fixture_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
committed = tmp_path / "committed.json"
generated = tmp_path / "generated.json"
- committed.write_text('{"value": 1}', encoding="utf-8")
- generated.write_text('{"value": 2}', encoding="utf-8")
+ current = current_protocol()
+ committed.write_text(json.dumps(current), encoding="utf-8")
+ current["tokens"] = ["unexpected-token"]
+ generated.write_text(json.dumps(current), encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
@@ -47,3 +119,19 @@ def test_default_fixture_path_remains_compatible(
assert compare_fixture.main() == 1
assert str(compare_fixture.DEFAULT_FIXTURE) in capsys.readouterr().err
+
+
+def test_comparison_accepts_map_order_but_does_not_rewrite_inputs(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ expected = tmp_path / "expected.json"
+ generated = tmp_path / "generated.json"
+ expected.write_text(json.dumps(current_protocol(), sort_keys=True), encoding="utf-8")
+ actual = json.dumps(current_protocol(), indent=2)
+ generated.write_text(actual, encoding="utf-8")
+ monkeypatch.setattr(sys, "argv", ["compare_fixture.py", str(expected), str(generated)])
+ assert compare_fixture.main() == 0
+ assert "current protocol fixtures match" in capsys.readouterr().out
+ assert generated.read_text(encoding="utf-8") == actual
diff --git a/crates/webui-python/tests/test_lazy.py b/crates/webui-python/tests/test_lazy.py
new file mode 100644
index 000000000..70b892baa
--- /dev/null
+++ b/crates/webui-python/tests/test_lazy.py
@@ -0,0 +1,51 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import json
+
+import pytest
+from microsoft_webui import LazyOptions, Renderer, RenderError, StateError
+
+
+def test_lazy_resources_and_terminal_state(lazy_renderer: Renderer) -> None:
+ prepared = lazy_renderer.prepare_lazy_resources("test-panel", "details")
+ metadata = json.loads(prepared.json)
+ assert metadata["type"] == "webui-lazy"
+ assert "state" not in metadata
+ fragment = metadata["templates"]["test-panel::lazy:details"]
+ assert fragment["v"] == 3
+ assert fragment["f"] == "test-panel::lazy:details"
+ assert fragment["o"] == "test-panel"
+ assert fragment["e"] == 1
+ assert fragment["s"] == [6, 7, 1, 1, 1]
+ assert fragment["tx"] == [[[2, 0], [["message"]]]]
+ assert fragment["r"] == [["items", "item", -1, [3, 0]]]
+ assert fragment["b"] == [{
+ "h": "", "s": [1, 3, 1], "tx": [[[1, 0], [["item"]]]],
+ }]
+ assert prepared.options.inventory == metadata["inventory"]
+ assert prepared.json == lazy_renderer.render_lazy_resources("test-panel", "details")
+
+ state = {"message": "Fresh", "items": ["one"], "unused": "not sent"}
+ outputs = [
+ lazy_renderer.render_lazy_partial(
+ value, "test-panel", "details", options=prepared.options
+ )
+ for value in [state, json.dumps(state), json.dumps(state).encode()]
+ ]
+ assert outputs[0] == outputs[1] == outputs[2]
+ payload = json.loads(outputs[0])
+ assert payload["state"] == {"message": "Fresh", "items": ["one"]}
+ assert "templates" not in payload
+ assert "componentStyles" not in payload
+
+
+def test_lazy_errors_remain_explicit(lazy_renderer: Renderer) -> None:
+ with pytest.raises(RenderError, match="lazy-not-found"):
+ lazy_renderer.render_lazy_resources("test-panel", "missing")
+ with pytest.raises(RenderError, match="lazy-build-mismatch"):
+ lazy_renderer.render_lazy_resources(
+ "test-panel", "details", options=LazyOptions(build_id="old")
+ )
+ with pytest.raises(StateError):
+ lazy_renderer.render_lazy_partial("{", "test-panel", "details")
diff --git a/crates/webui-python/tests/test_package.py b/crates/webui-python/tests/test_package.py
index 744dc3540..a77fea77a 100644
--- a/crates/webui-python/tests/test_package.py
+++ b/crates/webui-python/tests/test_package.py
@@ -39,6 +39,8 @@ def test_public_surface_and_version() -> None:
assert set(microsoft_webui.__all__) == {
"BoundaryDescriptor",
"BoundaryMode",
+ "LazyOptions",
+ "PreparedLazyResources",
"Plugin",
"ProtocolError",
"RenderError",
diff --git a/crates/webui-python/tests/test_renderer.py b/crates/webui-python/tests/test_renderer.py
index 8b45fa9d7..3e21e056b 100644
--- a/crates/webui-python/tests/test_renderer.py
+++ b/crates/webui-python/tests/test_renderer.py
@@ -10,7 +10,7 @@
from typing import Any
import pytest
-from conftest import PROTOCOL_PATH, STATE
+from conftest import STATE
from microsoft_webui import (
Plugin,
ProtocolError,
@@ -20,13 +20,15 @@
)
-def test_constructs_from_supported_protocol_inputs(protocol_bytes: bytes) -> None:
+def test_constructs_from_supported_protocol_inputs(
+ protocol_bytes: bytes, protocol_path: Path,
+) -> None:
renderers = [
Renderer(protocol_bytes),
Renderer(bytearray(protocol_bytes)),
Renderer(memoryview(protocol_bytes)),
- Renderer.from_file(PROTOCOL_PATH),
- Renderer.from_file(Path(PROTOCOL_PATH)),
+ Renderer.from_file(str(protocol_path)),
+ Renderer.from_file(protocol_path),
]
outputs = [renderer.render(STATE) for renderer in renderers]
@@ -156,6 +158,25 @@ def test_partial_and_component_template_payloads_are_json_bytes(renderer: Render
assert single_template_bytes == templates_bytes
+def test_current_fixture_preserves_exact_v3_template_metadata(renderer: Renderer) -> None:
+ expected = {
+ "v": 3,
+ "h": '',
+ "s": [1, 3, 1],
+ "tx": [[[1, 0], ["Hello, ", ["name"], "!"]]],
+ "sd": 1,
+ "th": 1,
+ "tr": ["name"],
+ "ta": ["name"],
+ }
+ resources = json.loads(renderer.render_component_templates("greeting-card"))
+ assert resources["templates"] == {"greeting-card": expected}
+ html = renderer.render_text(STATE)
+ bootstrap = json.loads(html.split('id="webui-data">', 1)[1].split("", 1)[0])
+ assert bootstrap["templates"] == {"greeting-card": expected}
+ assert bootstrap["buildId"] == resources["buildId"]
+
+
def test_tokens_are_immutable_and_in_build_order(renderer: Renderer) -> None:
assert renderer.tokens == ("brand-color",)
assert isinstance(renderer.tokens, tuple)
diff --git a/crates/webui-wasm/Cargo.toml b/crates/webui-wasm/Cargo.toml
index 7ec2d6cd8..9472f221d 100644
--- a/crates/webui-wasm/Cargo.toml
+++ b/crates/webui-wasm/Cargo.toml
@@ -23,7 +23,7 @@ crate-type = ["cdylib", "rlib"]
default = ["all"]
all = ["handler", "parser"]
handler = ["dep:microsoft-webui-handler"]
-parser = ["dep:microsoft-webui-parser", "microsoft-webui-protocol/projection-manifest"]
+parser = ["dep:microsoft-webui-parser", "microsoft-webui-protocol/projection-manifest", "microsoft-webui-protocol/content-identity"]
[dependencies]
microsoft-webui-parser = { path = "../webui-parser", version = "3.0.4", default-features = false, optional = true }
diff --git a/crates/webui-wasm/src/handler.rs b/crates/webui-wasm/src/handler.rs
index 19f664d3d..202c81b7f 100644
--- a/crates/webui-wasm/src/handler.rs
+++ b/crates/webui-wasm/src/handler.rs
@@ -22,6 +22,14 @@ use webui_protocol::WebUIProtocol;
const STREAM_CHUNK_SIZE: usize = 16 * 1024;
+fn lazy_options(options: JsValue) -> Result {
+ if options.is_null() || options.is_undefined() {
+ return Ok(webui_handler::LazyOptions::default());
+ }
+ serde_wasm_bindgen::from_value(options)
+ .map_err(|error| JsValue::from_str(&format!("invalid lazy options: {error}")))
+}
+
webui_handler::define_string_response_writer!(StringWriter, content);
/// A writer that batches rendered fragments before crossing into JavaScript.
@@ -198,6 +206,58 @@ impl Protocol {
.map_err(|error| JsValue::from_str(&format!("JSON serialize error: {error}")))
}
+ /// Prepare resources for a named lazy template body without application state.
+ #[wasm_bindgen(js_name = renderLazyResources)]
+ pub fn render_lazy_resources(
+ &self,
+ owner: &str,
+ id: &str,
+ options: JsValue,
+ ) -> Result {
+ let options = lazy_options(options)?;
+ self.inner
+ .render_lazy_resources(
+ webui_handler::LazyRequest::new(owner, id).with_options(&options),
+ )
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Prepare metadata and acknowledgement without parsing the metadata JSON again.
+ #[wasm_bindgen(js_name = prepareLazyResources)]
+ pub fn prepare_lazy_resources(
+ &self,
+ owner: &str,
+ id: &str,
+ options: JsValue,
+ ) -> Result {
+ let options = lazy_options(options)?;
+ let result = self
+ .inner
+ .prepare_lazy_resources(
+ webui_handler::LazyRequest::new(owner, id).with_options(&options),
+ )
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Render a complete or terminal lazy record with fresh scoped state.
+ #[wasm_bindgen(js_name = renderLazyPartial)]
+ pub fn render_lazy_partial(
+ &self,
+ state_json: &str,
+ owner: &str,
+ id: &str,
+ options: JsValue,
+ ) -> Result {
+ let options = lazy_options(options)?;
+ self.inner
+ .render_lazy_partial_json(
+ state_json,
+ webui_handler::LazyRequest::new(owner, id).with_options(&options),
+ )
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
/// Return CSS token names in build order.
#[wasm_bindgen(js_name = tokens)]
pub fn tokens(&self) -> Result {
diff --git a/crates/webui-wasm/src/parser.rs b/crates/webui-wasm/src/parser.rs
index c4347f0f3..9349383e7 100644
--- a/crates/webui-wasm/src/parser.rs
+++ b/crates/webui-wasm/src/parser.rs
@@ -244,8 +244,14 @@ pub(crate) fn parse_to_protocol_with_dom(
let (navigation_mode, navigation_keys) = encode_state_surface(navigation);
component.navigation_mode = Some(navigation_mode);
component.navigation_keys = navigation_keys;
+ for fragment in artifact.lazy_templates {
+ let mut data = fragment.data;
+ data.inherit_owner_state(component);
+ protocol.client_fragments.insert(fragment.id, data);
+ }
}
apply_component_delivery(&mut protocol, component_delivery, entry);
+ protocol.assign_build_id().map_err(WasmError::Protocol)?;
fn merge_projection_manifests(
manifests: &[ProjectionManifest],
diff --git a/crates/webui/Cargo.toml b/crates/webui/Cargo.toml
index 1683769d1..c81454ff9 100644
--- a/crates/webui/Cargo.toml
+++ b/crates/webui/Cargo.toml
@@ -20,7 +20,7 @@ cli = ["microsoft-webui-parser/cli"]
[dependencies]
microsoft-webui-parser = { path = "../webui-parser", version = "3.0.4" }
-microsoft-webui-protocol = { path = "../webui-protocol", version = "3.0.4", features = ["projection-manifest"] }
+microsoft-webui-protocol = { path = "../webui-protocol", version = "3.0.4", features = ["projection-manifest", "content-identity"] }
microsoft-webui-handler = { path = "../webui-handler", version = "3.0.4" }
microsoft-webui-discovery = { path = "../webui-discovery", version = "3.0.4" }
microsoft-webui-tokens = { path = "../webui-tokens", version = "3.0.4" }
@@ -68,6 +68,10 @@ harness = false
name = "server_request_bench"
harness = false
+[[bench]]
+name = "lazy_bench"
+harness = false
+
[[example]]
name = "streaming_resource_bench"
path = "examples/streaming_resource_bench.rs"
diff --git a/crates/webui/benches/lazy_bench.rs b/crates/webui/benches/lazy_bench.rs
new file mode 100644
index 000000000..0231e247f
--- /dev/null
+++ b/crates/webui/benches/lazy_bench.rs
@@ -0,0 +1,110 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+use criterion::{criterion_group, criterion_main, Criterion};
+use std::fmt::Write as _;
+use std::hint::black_box;
+use tempfile::TempDir;
+use webui::{build, BuildOptions, LazyRequest, Plugin, Protocol, RenderOptions, WebUIHandler};
+use webui_handler::plugin::webui::WebUIHydrationPlugin;
+
+webui_handler::define_string_response_writer!(Output, text);
+
+fn protocol(lazy: bool) -> Protocol {
+ let directory = TempDir::new().unwrap();
+ std::fs::write(
+ directory.path().join("index.html"),
+ "",
+ )
+ .unwrap();
+ let mut template = String::with_capacity(20_000);
+ template.push_str(if lazy {
+ "Shell
"
+ } else {
+ "Shell
"
+ });
+ for index in 0..250 {
+ write!(template, "");
+ }
+ template.push_str("");
+ std::fs::write(directory.path().join("bench-owner.html"), template).unwrap();
+ let result = build(BuildOptions {
+ app_dir: directory.path().to_owned(),
+ plugin: Some(Plugin::WebUI),
+ ..BuildOptions::default()
+ })
+ .unwrap();
+ Protocol::new(result.protocol)
+}
+
+fn render(protocol: &Protocol, state: &serde_json::Value, handler: &WebUIHandler) -> String {
+ let mut writer = Output::with_capacity(4096);
+ handler
+ .render(
+ protocol,
+ state,
+ &RenderOptions::new("index.html", "/"),
+ &mut writer,
+ )
+ .unwrap();
+ writer.text
+}
+
+fn lazy_bench(c: &mut Criterion) {
+ let eager = protocol(false);
+ let lazy = protocol(true);
+ let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
+ let closed = serde_json::json!({ "show": false, "value": "Fresh" });
+ let open = serde_json::json!({ "show": true, "value": "Fresh" });
+ let eager_bytes = render(&eager, &closed, &handler).len();
+ let lazy_bytes = render(&lazy, &closed, &handler).len();
+ let prepared = lazy
+ .prepare_lazy_resources(LazyRequest::new("bench-owner", "settings"))
+ .unwrap();
+ let request = LazyRequest::new("bench-owner", "settings").with_options(&prepared.options);
+ let state = r#"{"value":"Fresh","unused":"not selected"}"#;
+ let warm_bytes = lazy.render_lazy_partial_json(state, request).unwrap().len();
+ let plain = WebUIHandler::new();
+ assert_eq!(
+ render(&eager, &closed, &plain),
+ render(&lazy, &closed, &plain)
+ );
+ assert_eq!(render(&eager, &open, &plain), render(&lazy, &open, &plain));
+ let warm: serde_json::Value =
+ serde_json::from_str(&lazy.render_lazy_partial_json(state, request).unwrap()).unwrap();
+ assert_eq!(warm["state"], serde_json::json!({ "value": "Fresh" }));
+ assert!(warm.get("templates").is_none());
+ assert!(lazy_bytes < eager_bytes / 4);
+ eprintln!("lazy fixture bytes: eager_closed={eager_bytes}, lazy_closed={lazy_bytes}, resources={}, warm_state={warm_bytes}", prepared.json.len());
+
+ for (name, protocol, state) in [
+ ("closed_eager", &eager, &closed),
+ ("closed", &lazy, &closed),
+ ("open_eager", &eager, &open),
+ ("open", &lazy, &open),
+ ] {
+ c.bench_function(&format!("lazy/{name}"), |b| {
+ b.iter(|| black_box(render(black_box(protocol), black_box(state), &handler)));
+ });
+ }
+ c.bench_function("lazy/resources_cold", |b| {
+ b.iter(|| {
+ black_box(
+ lazy.render_lazy_resources(LazyRequest::new("bench-owner", "settings"))
+ .unwrap(),
+ )
+ });
+ });
+ c.bench_function("lazy/state_warm", |b| {
+ b.iter(|| {
+ black_box(
+ lazy.render_lazy_partial_json(black_box(state), request)
+ .unwrap(),
+ )
+ });
+ });
+}
+
+criterion_group!(benches, lazy_bench);
+criterion_main!(benches);
diff --git a/crates/webui/src/component_assets/traversal.rs b/crates/webui/src/component_assets/traversal.rs
index 74f2b818d..bf3892495 100644
--- a/crates/webui/src/component_assets/traversal.rs
+++ b/crates/webui/src/component_assets/traversal.rs
@@ -146,6 +146,7 @@ fn enqueue_dependency(
let dependency = match fragment {
Some(Fragment::Component(component)) => Some(component.fragment_id.as_str()),
Some(Fragment::ForLoop(for_loop)) => Some(for_loop.fragment_id.as_str()),
+ Some(Fragment::IfCond(if_cond)) if if_cond.lazy => return Err(deferred_unsupported(owner)),
Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
Some(Fragment::Attribute(attribute)) if !attribute.template.is_empty() => {
Some(attribute.template.as_str())
@@ -171,7 +172,17 @@ fn routes_unsupported(owner: &str) -> WebUIError {
.component(owner)
.element("route")
.help(
- "remove --emit-component-assets and use @microsoft/webui-router, or remove and import generated component roots from application code",
+ "remove --emit-component-assets and use @microsoft/webui-delivery, or remove and import generated component roots from application code",
);
WebUIError::ComponentAssets(Box::new(diagnostic))
}
+
+#[cold]
+#[inline(never)]
+fn deferred_unsupported(owner: &str) -> WebUIError {
+ WebUIError::ComponentAssets(Box::new(webui_parser::Diagnostic::error(
+ "deferred template bodies require protocol-backed delivery",
+ ).code(webui_parser::codes::COMPONENT_ASSETS_WITH_LAZY)
+ .component(owner).element("if")
+ .help("remove --emit-component-assets for this build; load deferred regions through the shared resource/inventory API")))
+}
diff --git a/crates/webui/src/lib.rs b/crates/webui/src/lib.rs
index 979765628..8557474ae 100644
--- a/crates/webui/src/lib.rs
+++ b/crates/webui/src/lib.rs
@@ -38,9 +38,9 @@ pub use webui_handler::route_handler::{encode_inventory, get_needed_components,
pub use webui_handler::Result as HandlerResult;
pub use webui_handler::{
plugin::HandlerPlugin, BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode,
- FlushWriter, HandlerError, Protocol, RenderOptions, ResponseWriter, SessionOptions,
- SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
- WebUIHandler,
+ FlushWriter, HandlerError, LazyOptions, LazyRequest, LazyRequestError, PreparedLazyResources,
+ Protocol, RenderOptions, ResponseWriter, SessionOptions, SpanInstanceId, StreamStatus,
+ StreamStep, StreamingResponse, StreamingSession, StreamingState, WebUIHandler,
};
pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
pub use webui_parser::CssStrategy;
@@ -417,7 +417,8 @@ fn streaming_without_projection_warning(entry: &str, boundary_count: usize) -> D
pub fn build(options: BuildOptions) -> Result {
let started = Instant::now();
- let raw = build_protocol_inner(&options)?;
+ let mut raw = build_protocol_inner(&options)?;
+ raw.protocol.assign_build_id()?;
let protocol_bytes = raw.protocol.to_protobuf()?;
@@ -872,6 +873,11 @@ fn build_protocol_inner(options: &BuildOptions) -> Result webui_handler::Result<()> {
+ Ok(())
+ }
+ }
+
fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().unwrap();
for (name, content) in files {
@@ -1077,6 +1089,339 @@ mod tests {
mod fast;
+ #[test]
+ fn lazy_ui_is_compiled_but_omitted_until_its_initial_condition_is_true() {
+ let app = create_app_dir(&[
+ (
+ "index.html",
+ "",
+ ),
+ (
+ "app-shell.html",
+ r#""#,
+ ),
+ (
+ "settings-panel.html",
+ "Lazy settings content
",
+ ),
+ ("settings-panel.css", "h2 { color: red; }"),
+ ("settings-child.html", "Nested settings content
"),
+ ]);
+ let mut options = default_options(app.path());
+ options.plugin = Some(Plugin::WebUI);
+ let built = build(options).expect("compiled lazy UI");
+ assert!(built.protocol.fragments.contains_key("settings-panel"));
+ assert!(built.protocol.components.contains_key("settings-child"));
+ let protocol = Protocol::new(built.protocol);
+ let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
+ for shown in [false, true] {
+ let mut writer = StringWriter { buf: String::new() };
+ handler
+ .render(
+ &protocol,
+ &serde_json::json!({ "showSettings": shown }),
+ &RenderOptions::new("index.html", "/"),
+ &mut writer,
+ )
+ .expect("render lazy condition");
+ assert_eq!(
+ writer.buf.contains("Lazy settings content"),
+ shown,
+ "inactive metadata must not embed the lazy body: {}",
+ writer.buf
+ );
+ assert_eq!(writer.buf.contains("Nested settings content"), shown);
+ assert_eq!(writer.buf.contains("settings-panel.css"), shown);
+ assert_eq!(writer.buf.contains(r#""settings-panel":{"h":"#), shown);
+ assert_eq!(writer.buf.contains(r#""settings-child":{"h":"#), shown);
+ let head = writer.buf.split("").next().expect("document head");
+ assert_eq!(head.contains("settings-panel.css"), shown);
+ }
+ }
+
+ #[test]
+ fn lazy_if_keeps_inline_bodies_hostless_and_preserves_dom_mode() {
+ for dom in [DomStrategy::Shadow, DomStrategy::Light] {
+ let app = create_app_dir(&[
+ (
+ "index.html",
+ "",
+ ),
+ (
+ "app-shell.html",
+ concat!(
+ "Shell
",
+ ),
+ ),
+ ("app-shell.ts", "export {};"),
+ ]);
+ let mut options = default_options(app.path());
+ options.plugin = Some(Plugin::WebUI);
+ options.dom = dom;
+ let built = build(options).expect("arbitrary lazy body");
+ assert_eq!(
+ built.protocol.components.len(),
+ 1,
+ "a lazy body must not become a component"
+ );
+ let root = "app-shell::lazy:settings";
+ let nested = "app-shell::lazy:advanced";
+ assert!(built.protocol.fragments.contains_key(root));
+ assert!(built.protocol.client_fragments[root].entry);
+ assert_eq!(built.protocol.client_fragments[root].owner, "app-shell");
+ assert!(!built.protocol.client_fragments[root]
+ .dependencies
+ .iter()
+ .any(|id| id == nested));
+ assert!(!built.protocol.components["app-shell"]
+ .template_json
+ .contains("Inline lazy settings"));
+ assert!(!built.protocol.components["app-shell"]
+ .template_json
+ .contains("pick"));
+ assert!(built.protocol.fragments["app-shell"].fragments.iter().any(|fragment| {
+ matches!(fragment.fragment.as_ref(), Some(Fragment::IfCond(condition)) if condition.lazy && condition.fragment_id == root)
+ }));
+ let protocol = Protocol::new(built.protocol);
+ let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
+ for (shown, advanced) in [(false, false), (true, false), (true, true)] {
+ let state = serde_json::json!({
+ "show": shown, "advanced": advanced, "loaded": true,
+ "subtitle": "Current", "detail": "Detail", "items": [{"id":1,"label":"First"}],
+ });
+ let mut writer = StringWriter { buf: String::new() };
+ handler
+ .render(
+ &protocol,
+ &state,
+ &RenderOptions::new("index.html", "/"),
+ &mut writer,
+ )
+ .unwrap();
+ assert_eq!(writer.buf.contains("Inline lazy settings"), shown);
+ assert_eq!(writer.buf.contains("Advanced lazy settings"), advanced);
+ assert!(
+ !writer.buf.contains("