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":"

Settings

","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 ` ``` -Import the optional coordinator once before component registration modules: +Select the optional coordinator in the application entry before startup: ```typescript +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; import '@microsoft/webui-framework/lazy-hydration.js'; import './todo-row.js'; + +startRuntime(); ``` This combines visibility-deferred hydration with `content-visibility: auto`. @@ -357,6 +436,10 @@ focusInput(): void { (or the unquoted `w-ref={inputEl}`), never `w-ref="inputEl"`. The build fails with an actionable error otherwise. +Refs inside `` or `` bodies are cleared to `undefined` when their owning +view is removed. Declare those refs as optional, such as +`inputEl?: HTMLInputElement`, and use them only while the body is mounted. + `w-ref` is scalar. Reusing a name inside `` writes the same property, so the last wired occurrence wins; repeat keys and positions do not create an indexed ref collection. For lookup by item identity, author a stable `id` and @@ -516,7 +599,7 @@ The child emits data without depending on the parent's implementation. ## Loading Static Component Assets -When you are not using `@microsoft/webui-router`, deferred UI can still be +When you are not using `@microsoft/webui-delivery`, deferred UI can still be loaded from static files. Build the root components as assets: ```bash @@ -534,7 +617,7 @@ protocol. Asset-only fragments and component records do not remain in `protocol.bin`. Static component assets and `` cannot be used in the same build. Use -`@microsoft/webui-router` for routed components, or use component assets for +`@microsoft/webui-delivery` for routed components, or use component assets for non-routed deferred UI. During development, pass the same flag to `webui serve` so these roots are diff --git a/docs/guide/concepts/lazy.md b/docs/guide/concepts/lazy.md new file mode 100644 index 000000000..dd2e27b2d --- /dev/null +++ b/docs/guide/concepts/lazy.md @@ -0,0 +1,227 @@ +# Lazy template bodies + +Try the [popover and dialog example](https://github.com/microsoft/webui/tree/main/examples/app/lazy-ui): +`cargo xtask dev lazy-ui`. Its `lazy-popover` and `lazy-dialog` +components own native overlays, with all interior content in an inline lazy +body. Loading and retry feedback stay at the trigger until the overlay is ready. + +Add `lazy` to an ordinary `` when its UI should stay compiled but not be +delivered until it is needed. The body can contain native HTML, multiple roots, +conditions, repeats, refs, events, and component calls. It does not become a +component: state, handlers, refs, and loop variables keep their existing owner. +As with other reactive directives, WebUI browser activation runs inside an +existing component template; the lazy body does not introduce another one. + +```html + + + + + +

Loading...

+

{{settingsError}}

+ + +

Settings

+

{{displayName}}

+ +
+
+``` + +`lazy` is boolean. `` accepts `condition`, optional `lazy`, and an optional +`id` only on lazy regions. Unsupported attributes fail at build time. +Use matching `lazy.js` imports and `renderLazy*` host APIs, and rebuild protocol +and client together; compiled templates use one current format. + +This is different from `w-hydrate="lazy"` and `w-render="lazy"`, which postpone +work on already server-rendered components. `` omits an inactive +template body's markup and metadata from initial delivery. + +Use an optional `id` for application-controlled loading; +names are unique within the owning template and may contain letters, digits, +periods, underscores, and hyphens, up to 128 characters. The directive does not +produce an HTML element or DOM id. Unnamed regions have compiler-managed +identities; do not hardcode them. + +## Initial server rendering + +The condition still controls SSR: + +| Initial condition | Result | +| --- | --- | +| False | No body markup or lazy-only template metadata is sent. | +| True | The body renders with the initial request's state and required metadata. The browser hydrates that DOM without a second state request or replacement host. | + +Existing CSS ownership and DOM-mode rules are unchanged. Owner styles and +dependencies used elsewhere are not removed merely because one use is lazy. +Likewise, deferral does not remove unrelated state the host supplied to an +active owner; state projection is not a secrecy boundary. + +A native dialog's content can be server-rendered, but modal top-layer placement +and focus still use the browser's `showModal()` API. + +## Select the renderer and delivery capabilities + +The WebUI renderer adapter contains no fetching or routing: + +```typescript +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; +import '@microsoft/webui-framework/lazy.js'; +import './app-shell.js'; + +startRuntime(); +``` + +Inventory, preloading, transport, and instance-independent resource reuse remain +in the hydration-agnostic client package. Importing lazy delivery does not +start routing or install a rendering engine: + +```typescript +import { createLazyLoader } from '@microsoft/webui-delivery/lazy.js'; +import { ndjson } from '@microsoft/webui-delivery/resources.js'; + +const fragments = createLazyLoader({ + endpoint: '/_webui/lazy', + transport: ndjson(), +}); +``` + +Omit `transport` for JSON-only delivery. That composition does not include the +NDJSON reader. `ndjson()` accepts JSON responses and limits each NDJSON +record to 256 KiB; use `ndjson(maxRecordBytes)` for a different +finite record budget. `timeoutMs` defaults to 10 seconds and covers the complete +request, including state and resource preparation. + +## Preload resources, then request fresh state + +Inside the owning `app-shell` class: + +```typescript +private settingsRequest?: AbortController; +private settingsGeneration = 0; + +async preloadSettings(): Promise { + try { + await fragments.ensureLoaded('app-shell', 'settings'); + } catch (error) { + this.settingsError = String(error); + } +} + +async openSettings(): Promise { + const generation = ++this.settingsGeneration; + this.settingsRequest?.abort(); + const request = new AbortController(); + this.settingsRequest = request; + this.settingsLoading = true; + this.settingsError = ''; + if (!this.settingsDialog.open) this.settingsDialog.showModal(); + + try { + const state = await fragments.fetchPartial('app-shell', 'settings', { + url: '/_webui/lazy/state?owner=app-shell&id=settings', + signal: request.signal, + }); + if (generation !== this.settingsGeneration || request.signal.aborted) return; + this.setState(state); + this.settingsReady = true; + } catch (error) { + if (generation === this.settingsGeneration && !request.signal.aborted) { + this.settingsError = String(error); + } + } finally { + if (generation === this.settingsGeneration) this.settingsLoading = false; + } +} + +dismissSettings(): void { + this.settingsDialog.close(); +} + +onSettingsClosed(): void { + this.settingsGeneration++; + this.settingsRequest?.abort(); + this.settingsRequest = undefined; + this.settingsReady = false; + this.settingsLoading = false; + this.displayName = ''; +} +``` + +Declare the mutable template fields as observables, as with ordinary +[interactive components](./interactivity). The body mounts after state and +resources are ready; it has no separate component lifecycle. `fetchPartial()` +returns state and never applies it globally or mounts DOM. When a body is inside +a repeat, update the appropriate owner/collection state before activating it. + +`ensureLoaded()` fetches no application state and creates no UI. Reopening still +requests fresh state even when all resources are available. A state request does +not wait for an unfinished speculative preload. Concurrent instances share +resources, not state or cancellation. + +Refs inside a lazy body are valid while that view exists. Removing it clears +refs that still point to its nodes, so the owner does not keep a detached UI tree +alive. A newer ref assignment is not cleared by an older view's cleanup. + +Optional behavior modules remain explicit. Deferring an inline body does not +split its owner's JavaScript automatically: + +```typescript +import { createLazyLoader, lazyFragment } from '@microsoft/webui-delivery/lazy.js'; + +const fragments = createLazyLoader({ + loaders: { + [lazyFragment('app-shell', 'settings')]: () => import('./settings-logic.js'), + 'custom-input': () => import('./custom-input.js'), + }, +}); +``` + +Handlers in the lazy body still resolve on the existing owner. Optional +modules must provide the behavior that the application expects; the loader does +not turn a fragment into a custom element. + +## Host endpoints + +Use the same loaded protocol for SSR and lazy requests. Hosts authenticate +and authorize before committing headers, and own data loading, cancellation, +backpressure, and flushing. + +| Operation | Purpose | +| --- | --- | +| `renderLazyResources(owner, id, options?)` | Resource-only JSON for preload | +| `prepareLazyResources(owner, id, options?)` | Resource JSON plus its acknowledgement for two-phase delivery | +| `renderLazyPartial(state, owner, id, options?)` | Complete JSON, or the final NDJSON state record | + +The options are `inventory`, `styleInventory`, and `buildId`, supplied by the +client's corresponding `X-WebUI-*` headers. For NDJSON, flush the prepared +resource record, await the application's state work, then render the state +record with `prepared.options`. This avoids resending the first record's +resources or reparsing its metadata. A final state record can add resources for +nested lazy bodies that the returned state activates. + +See the [Node](../integrations/node#lazy-template-responses), +[Rust](../integrations/rust#lazy-template-responses), +[Python](../integrations/python#lazy-template-responses), +[.NET](../integrations/dotnet#lazy-template-responses), +[WASM](../integrations/wasm#lazy-template-responses), and +[C](../integrations/ffi#lazy-template-responses) APIs. + +For local development, `webui serve` provides `/_webui/lazy?owner=...&id=...` +and `/_webui/lazy/state?owner=...&id=...`. The state endpoint uses configured +state or the API state provider and supports JSON/NDJSON negotiation. With +`--api-port`, the provider receives the complete lazy-state path and query shown +above and returns `{ "state": { ... } }`; the CLI wraps that state with the +matching protocol resources. A custom URL such as `/ui/settings` requires a +custom host that returns the complete lazy response, not just application state. Data +failures are explicit, including failures after metadata has been sent. + +Build with the WebUI plugin and the matching compiler/runtime. This +protocol-backed feature is not combined with `--emit-component-assets`. +Inventory belongs to one artifact identity; a mismatch requires a document +refresh rather than reinterpreting old bits. Explicit collection invalidates +availability, and an in-flight request retries against fresh inventory before +returning state. diff --git a/docs/guide/concepts/plugins/index.md b/docs/guide/concepts/plugins/index.md index f11c38fbb..3a79a2ae1 100644 --- a/docs/guide/concepts/plugins/index.md +++ b/docs/guide/concepts/plugins/index.md @@ -279,6 +279,10 @@ pub trait ParserPlugin { fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction { AttributeAction::Keep } fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option> { None } fn finish(self: Box) -> Result { Ok(ParserPluginArtifacts::None) } + fn finish_with_fragments( + self: Box, + fragments: &WebUIFragmentRecords, + ) -> Result { self.finish() } } ``` @@ -286,6 +290,11 @@ Implement only the phases your integration owns; the parser callbacks have no-op defaults. A source transform returns `Ok(None)` to leave a component's source unchanged. +Artifact extraction calls `finish_with_fragments` with the read-only compiled +graph. Its default delegates to `finish`, so existing plugins need no change. +Override it when client artifacts need dependency information from typed +fragment references rather than another source-HTML scan. + When producing a `ComponentTemplateArtifact`, pass `context.uses_shadow_dom` to its constructor. This parser-derived boolean is required metadata and is not inferred after the plugin returns its artifacts. diff --git a/docs/guide/concepts/routing.md b/docs/guide/concepts/routing.md index 026c10cb3..376037a55 100644 --- a/docs/guide/concepts/routing.md +++ b/docs/guide/concepts/routing.md @@ -7,7 +7,7 @@ description: "Declare nested routes in HTML and add browser navigation, loaders, Declare routes in HTML and place `` where a child page belongs. The native renderer matches the request path and returns the selected -components. Add `@microsoft/webui-router` for client-side navigation that keeps +components. Add `@microsoft/webui-delivery` for client-side navigation that keeps unchanged parent components mounted. Cache tags, pending UI, and error components are optional route attributes @@ -17,7 +17,7 @@ server's JavaScript engine. ## Installation ```bash -npm install @microsoft/webui-router +npm install @microsoft/webui-delivery ``` Only needed when your app has client-side navigation. Server-only apps with full page loads don't need it. @@ -69,9 +69,10 @@ duplicated layout into the matched child component instead. **4. Start the router:** ```typescript -import { Router } from '@microsoft/webui-router'; +import { WebUIRouter } from '@microsoft/webui-delivery/router.js'; -Router.start(); +const router = new WebUIRouter(); +router.start(); ``` The server renders the matched route on first load. The router intercepts @@ -84,14 +85,80 @@ Route segments may contain literal dots, such as `/docs/v2.1` or client navigation. The router never imports framework code. Authored route components use their -registered classes. When the application also loads -`@microsoft/webui-framework`, HTML-only route templates can mount during soft +registered classes. When the application calls `startRuntime()` from +`@microsoft/webui-framework/runtime.js`, HTML-only route templates can mount during soft navigation without empty TypeScript modules. The router falls back to a full document request only when no runtime registers the destination tag. -The router uses the View Transitions API when available. Define transition -styles in CSS; do not wrap `Router.navigate()` in another view transition. -Navigation does not wait for the animation to finish. +## Selecting router capabilities + +There is one router class. Configure it once in `start()`; the constructor takes +no configuration and importing the package does not create a singleton. + +```typescript +import { WebUIRouter } from '@microsoft/webui-delivery/router.js'; + +const router = new WebUIRouter(); +router.start(); +``` + +This router requests JSON and has no automatic preload, mutation interception, +navigation cache, pending/error presentation, or view transitions. URL/history, +server-authoritative route chains, outlets, and component loaders work normally. + +Import only the capabilities you select: + +```typescript +import { + WebUIRouter, navigationCache, routeActions, routeBoundaries, viewTransitions, +} from '@microsoft/webui-delivery/router.js'; +import { ndjson } from '@microsoft/webui-delivery/resources.js'; + +const router = new WebUIRouter(); +router.start({ + transport: ndjson(), + boundaries: routeBoundaries, + transitions: viewTransitions, + cache: navigationCache({ maxEntries: 20, staleTime: 10_000 }), + actions: routeActions(), +}); +``` + +| Capability | `start()` option | +| --- | --- | +| NDJSON partials | `transport: ndjson()` from `resources.js` | +| Pending/error UI | `boundaries: routeBoundaries` | +| View transitions | `transitions: viewTransitions` | +| Navigation cache | `cache: navigationCache(options)` | +| Mutation actions | `actions: routeActions()` | +| Intent preload | `preload: routePreload(options)`, with a configured cache | +| Development warnings | `diagnostics: routeDiagnostics` | + +The route policies are named exports from `router.js`. Selecting a configured +policy enables it; there is no second boolean flag. Unselected implementations +are excluded by production tree-shaking. Selected event listeners are ready +before `start()` returns. Load application components lazily through `loaders`, +not by changing global configuration during navigation. Do not mutate loader +maps after startup. Calling `start()` on an active router is an error; destroy +it before restarting. + +The NDJSON adapter is ready when imported; it does not wait until the first +response to import its decoder. JSON-only applications do not import it at all. +It bounds each UTF-8 record to 256 KiB and releases cancelled readers. A partial +request's deadline includes the deferred state tail. Malformed or truncated +tails are reported and never marked complete in the navigation cache. + +For custom delivery code, `resources.js` exports `fetchComponentTemplates()`, +`registerTemplatesAndStyles()`, `readJsonPartial()`, and +`waitForTemplateReadiness()` without importing a router or renderer. +The same `ndjson()` transport works with `createLazyLoader()`. These are +resource/transport primitives, not a new component-state +endpoint or a replacement for declaring compiled components. + +With the transition capability selected, the router uses the View Transitions +API when available. Define transition styles in CSS; do not wrap +`router.navigate()` in another view transition. Navigation does not wait for the +animation to finish. ## Nested Routes @@ -204,7 +271,9 @@ Prefetch route data when a mouse hovers an internal link to reduce the wait after a click: ```ts -Router.start({ preload: true }); +import { navigationCache, routePreload } from '@microsoft/webui-delivery/router.js'; + +router.start({ cache: navigationCache(), preload: routePreload() }); ``` Preloading applies to internal links hovered with a mouse. A ready, fresh @@ -212,10 +281,11 @@ result avoids another fetch on click; hovering another link cancels the previous preload. It is off by default, so enable it only when the navigation benefit justifies speculative requests. -`@microsoft/webui-router/preload.js` exposes the framework-agnostic -`prepareRoutePreload()` handle. Start framework-specific hydration from its -`onIntent` callback, then pass the handle to -`Router.start({ preload: prepared })`. WebUI Framework calls +For interaction-deferred apps, create the same `routePreload({ onIntent })` +policy and call its `prepare()` method before activation. It starts one bounded +JSON prefetch without loading the router or a stream decoder. Pass that policy +and a configured cache to `router.start()` to adopt the request without another +fetch. Start framework-specific hydration from `onIntent`. WebUI Framework calls `wakeInteractionHydration()` from that callback; FAST uses its own hydration readiness signal. The router imports neither runtime. @@ -225,7 +295,7 @@ Define a static `loader()` method on a component class to fetch data from a cust ```typescript import { WebUIElement } from '@microsoft/webui-framework'; -import type { RouteLoaderContext } from '@microsoft/webui-router'; +import type { RouteLoaderContext } from '@microsoft/webui-delivery/router.js'; export class LiveDashboard extends WebUIElement { static async loader({ params, signal }: RouteLoaderContext) { @@ -288,16 +358,16 @@ including when an `` is inside a Shadow component. The router caches partial responses and tags them with server-provided cache tags for precise invalidation. Enable caching at startup: -The cache is opt-in. The default `Router.start()` path does not load the cache -module; enabling `cache` or `preload` loads it on demand. +The cache is opt-in. Select it once at startup; a default `router.start()` does +not allocate it. Resource inventories do not cache application state. ```typescript -Router.start({ - cache: { +router.start({ + cache: navigationCache({ staleTime: 30_000, // ms before refetch (default: 0 = disabled) gcTime: 300_000, // ms before eviction from memory (default: 5 min) maxEntries: 50, // LRU cap (default: 50) - }, + }), }); ``` @@ -321,7 +391,7 @@ Tag placeholders must name declared route parameters. For `/email/42`, -When `preload: true` is enabled, hover fetches write to the same cache. Preloaded entries get a minimum 5-second freshness window even when `staleTime` is 0 (disabled). +With `preload: routePreload()`, hover fetches write to the same cache. Preloaded entries get a minimum 5-second freshness window even when `staleTime` is 0. @@ -343,18 +413,18 @@ mutation changes; the router cannot infer your application's data dependencies. Programmatic invalidation: ```typescript -Router.invalidateTags(['inbox', 'thread:42']); // evict by tag -Router.invalidate('/email/42'); // evict by path -Router.invalidate(); // evict everything +router.invalidateTags(['inbox', 'thread:42']); // evict by tag +router.invalidate('/email/42'); // evict by path +router.invalidate(); // evict everything ``` ### Mutation Actions -The write counterpart to `static loader()`. Components define `static action()` to handle form submissions, and the router auto-invalidates the cache. Enable this optional runtime with `Router.start({ actions: true })`: +The write counterpart to `static loader()`. Components define `static action()` to handle form submissions, and the router auto-invalidates the cache. Select it with `router.start({ actions: routeActions() })`, importing `routeActions` from `router.js`: ```typescript import { WebUIElement } from '@microsoft/webui-framework'; -import type { RouteActionContext, RouteActionResult } from '@microsoft/webui-router'; +import type { RouteActionContext, RouteActionResult } from '@microsoft/webui-delivery/router.js'; export class ComposePage extends WebUIElement { static async action({ formData, params, signal }: RouteActionContext): Promise { @@ -409,7 +479,7 @@ export class ErrorDisplay extends WebUIElement { @observable status = 0; // HTTP status code (0 if network error) @observable path = ''; // the path that failed - onRetry = () => Router.navigate(this.path); + onRetry = () => router.navigate(this.path); } ``` @@ -437,7 +507,7 @@ do not construct route bootstrap data yourself. #### SSR Fresh / Loaders -By default, `Router.start({ ssrFresh: true })` skips running route loaders on the initial SSR-bootstrapped navigation. The server-rendered state is considered authoritative, so there is no redundant client-side fetch on first load. +By default, `router.start({ ssrFresh: true })` skips running route loaders on the initial SSR-bootstrapped navigation. The server-rendered state is considered authoritative, so there is no redundant client-side fetch on first load. Components that need to run their loader even during SSR bootstrap can opt in: @@ -456,86 +526,110 @@ Loaders always run on subsequent client-side navigations regardless of the `ssrF -## The `Router` API +## The `WebUIRouter` API -### `Router.start(config?)` +### `router.start(config?)` -Starts the router. Call after hydration completes. +Starts an application-owned router. Call once after hydration completes. +The constructor takes no configuration. ```typescript -Router.start({ +router.start({ loaders: { ... }, // lazy-loading map (component tag -> async import) - preload: true, // speculative fetch on link hover - actions: true, // intercept POST forms and call static action() + preload: routePreload(), // speculative fetch on link hover + actions: routeActions(), // intercept POST forms and call static action() ssrFresh: true, // skip initial loader replay (default: true) - cache: { // tagged navigation cache + cache: navigationCache({ // tagged navigation cache staleTime: 30_000, // ms before refetch (0 = disabled) gcTime: 300_000, // ms before memory eviction maxEntries: 50, // LRU cap - }, + }), }); ``` > **Base path:** The router automatically reads `` from the DOM. No `basePath` config needed — just set `` in your HTML. -### `Router.navigate(path)` +### `router.navigate(path)` ```typescript -Router.navigate('/users/42'); +router.navigate('/users/42'); ``` -### `Router.back()` +### `router.back()` ```typescript -Router.back(); +router.back(); ``` -### `Router.invalidateTags(tags)` +### `router.invalidateTags(tags)` Evict all cache entries whose tags overlap with the given tags: ```typescript -Router.invalidateTags(['inbox', 'thread:42']); +router.invalidateTags(['inbox', 'thread:42']); ``` -### `Router.invalidate(path?)` +### `router.invalidate(path?)` Evict cache entries by path, or all entries if no path is given: ```typescript -Router.invalidate('/email/42'); // evict one entry -Router.invalidate(); // evict everything +router.invalidate('/email/42'); // evict one entry +router.invalidate(); // evict everything ``` -### `Router.activeComponent` +### `router.activeComponent` The component tag of the currently active leaf route: ```typescript -console.log(Router.activeComponent); // "user-detail" +console.log(router.activeComponent); // "user-detail" ``` -### `Router.activeParams` +### `router.activeParams` The bound parameters of the current route: ```typescript -console.log(Router.activeParams); // { id: "42" } +console.log(router.activeParams); // { id: "42" } ``` -### `Router.destroy()` +### `router.destroy()` Tears down the router, removes event listeners, and clears the cache. -### `Router.gc()` +### `router.gc()` Release cached component templates to free memory. Later navigation can fetch them again when needed. +With the WebUI framework, import its optional cache adapter before runtime +startup or template reads, and use a freshly rebuilt protocol: + ```typescript -Router.gc(); +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; +import '@microsoft/webui-framework/template-cache.js'; +import './app-shell.js'; + +startRuntime(); + +router.gc(); ``` +Collection releases cache ownership while live components retain the ordinary +templates their conditions/repeats can still instantiate. Active pending/error +resources remain available. Inventory is reset conservatively; old in-flight +responses cannot restore the cleared inventory. +The optional navigation response cache is cleared as well, so its responses do +not continue retaining the same resources. + +The operation does not destroy mounted components or unload JavaScript modules, +native custom-element definitions, or shared document styles. Garbage collection +is asynchronous. Calling `router.gc()` for WebUI templates without the adapter +now fails explicitly rather than reporting a release that can leave metadata +retained by component definitions. + The template inventory already avoids duplicate downloads. Use `gc()` for a @@ -548,7 +642,7 @@ long-lived app when measured template memory warrants the later refetch cost. Lazy-load route components so their JavaScript is only fetched on navigation: ```typescript -Router.start({ +router.start({ loaders: { 'user-list': () => import('./pages/user-list.js'), 'user-detail': () => import('./pages/user-detail.js'), @@ -579,7 +673,7 @@ the build compiles them: Then load dynamically before first use: ```typescript -await Router.ensureLoaded('settings-dialog'); +await router.ensureLoaded('settings-dialog'); ``` A component outside the active route stays deferred until it is requested. @@ -593,7 +687,7 @@ unstyled rather than remain permanently hidden. Configure a custom template endpoint: ```typescript -Router.start({ +router.start({ templateEndpoint: '/api/templates', // default: '/_webui/templates' }); ``` @@ -817,10 +911,15 @@ Hidden routes use inline `display: none`. Do not override that with ```typescript // index.ts -import { Router } from '@microsoft/webui-router'; +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; +import { WebUIRouter } from '@microsoft/webui-delivery/router.js'; +import './app-shell.js'; + +const router = new WebUIRouter(); window.addEventListener('webui:hydration-complete', () => { - Router.start({ + router.start({ loaders: { 'home-page': () => import('./pages/home-page.js'), 'contacts-page': () => import('./pages/contacts-page.js'), @@ -830,6 +929,5 @@ window.addEventListener('webui:hydration-complete', () => { }); }); -// Shell component - eagerly loaded -import './app-shell.js'; +startRuntime(); ``` diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 237dc2df3..669566477 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -153,7 +153,7 @@ the decorators and browser bundler as shown in Ordinary document navigation needs no client router. For soft navigation: ```bash -npm install @microsoft/webui-router +npm install @microsoft/webui-delivery ``` The router is a separate browser package, with server-side route and partial diff --git a/docs/guide/integrations/dotnet.md b/docs/guide/integrations/dotnet.md index 4d9eb173a..ff0dfbbfb 100644 --- a/docs/guide/integrations/dotnet.md +++ b/docs/guide/integrations/dotnet.md @@ -32,6 +32,29 @@ string html = handler.Render( "/"); ``` +## Lazy template responses + +The same `Protocol` serves [lazy bodies](../concepts/lazy) without +creating a route or another component: + +```csharp +PreparedLazyResources prepared = + protocol.PrepareLazyResources("app-shell", "settings", clientOptions); +Task pendingState = LoadSettingsAsync(cancellationToken); +await Response.WriteAsync(prepared.Json + "\n", cancellationToken); +await Response.Body.FlushAsync(cancellationToken); +string stateJson = await pendingState; +await Response.WriteAsync(protocol.RenderLazyPartial( + stateJson, "app-shell", "settings", prepared.Options) + "\n", + cancellationToken); +``` + +`LazyOptions` contains `Inventory`, `StyleInventory`, and `BuildId`. +Use `RenderLazyResources` for preload-only JSON or `RenderLazyPartial` +directly for a complete JSON response. The host authenticates before headers, +sets `application/x-ndjson` for two-phase delivery, and turns post-header data +failures into explicit terminal error records. + ## Progressive streaming `StreamResponse` creates a single-driver session. `Start`, `Resume`, and diff --git a/docs/guide/integrations/ffi.md b/docs/guide/integrations/ffi.md index 3f7c3c8a5..28b9b6ca7 100644 --- a/docs/guide/integrations/ffi.md +++ b/docs/guide/integrations/ffi.md @@ -69,6 +69,42 @@ Loaded protocol handles are thread-safe. Handler instances are safe for concurrent renders as long as configuration such as the nonce is not mutated concurrently. +## Lazy template responses + +Use the loaded protocol for [lazy bodies](../concepts/lazy): + +```c +char *updated_options = NULL; +char *metadata = webui_protocol_prepare_lazy_resources( + protocol, "app-shell", "settings", client_options_json, &updated_options +); +if (metadata == NULL) { + fprintf(stderr, "%s\n", webui_last_error()); + return; +} +send_record(metadata); /* application writer: append LF, flush, honor backpressure */ +webui_free(metadata); + +char *state_record = webui_protocol_render_lazy_partial( + protocol, scoped_state_json, "app-shell", "settings", updated_options +); +webui_free(updated_options); +if (state_record != NULL) { + send_record(state_record); + webui_free(state_record); +} else { + /* Send an explicit terminal error record; headers have already committed. */ + report_state_error(webui_last_error()); +} +``` + +`webui_protocol_render_lazy_resources` is the resource-only convenience +operation. Options JSON uses `inventory`, `styleInventory`, and `buildId`; a null +options pointer selects defaults. The prepared form returns resource JSON and +writes a second owned JSON string through `updated_options_json`. Free both +strings with `webui_free`. All failures return null and set `webui_last_error`. +Keep the protocol handle alive while the host resolves state. + ## C API Reference The generated C header is at `crates/webui-ffi/include/webui_ffi.h`. diff --git a/docs/guide/integrations/node.md b/docs/guide/integrations/node.md index 2c1bb0330..fc53ebd40 100644 --- a/docs/guide/integrations/node.md +++ b/docs/guide/integrations/node.md @@ -92,6 +92,9 @@ Bun.serve({ | `protocol.renderStream(state, onChunk, options?)` | Render with callbacks coalesced around a 16 KiB target before crossing into JavaScript | | `protocol.streamResponse(options?)` | Open a [progressive streaming session](#progressive-streaming) that returns one `Buffer` per host call | | `protocol.renderPartial(state, entry, requestPath, inventory)` | Produce a complete partial-navigation JSON response | +| `protocol.renderLazyResources(owner, id, options?)` | Resource-only JSON for a named lazy body | +| `protocol.prepareLazyResources(owner, id, options?)` | Resource JSON and acknowledgement for two-phase delivery | +| `protocol.renderLazyPartial(state, owner, id, options?)` | Fresh scoped state and missing resources | | `protocol.renderComponentTemplates(tags, inventory)` | Return on-demand template payloads | | `protocol.tokens()` | Return CSS token names in build order | | `inspect(protocol)` | Convert protocol to JSON for debugging | @@ -110,6 +113,36 @@ Bun.serve({ |-------|------|---------|-------------| | `plugin` | `string` | - | Handler plugin bound for the lifetime of the protocol | +## Lazy template responses + +Use the existing `Protocol` for [lazy bodies](../concepts/lazy). These +operations do not create routes or invoke application data providers: + +```typescript +const prepared = protocol.prepareLazyResources('app-shell', 'settings', { + inventory: clientInventory, + styleInventory: clientStyleInventory, + buildId: clientBuildId, +}); +const pendingState = loadSettings(signal); +await writeRecord(prepared.json); +const state = await pendingState; +await writeRecord(protocol.renderLazyPartial( + state, 'app-shell', 'settings', prepared.options, +)); +``` + +`loadSettings` and `writeRecord` are application functions. Authenticate before +writing headers. The writer appends LF, flushes, and handles backpressure; +disconnects cancel the data operation. Catch failures after the first record and +send a terminal `{"error":{"code":"state-failed","message":"..."}}` record rather +than returning successful empty state. For a complete JSON response, call +`renderLazyPartial` directly with the client's options. + +Keep the same protocol snapshot throughout the request. `prepared.options` +avoids reparsing metadata or resending its templates. Inventory affects resources, +not state freshness. + ## Reusing Protocol Load `protocol.bin` once and construct one `Protocol` for the lifetime of the diff --git a/docs/guide/integrations/python.md b/docs/guide/integrations/python.md index e672cee6a..b4e941662 100644 --- a/docs/guide/integrations/python.md +++ b/docs/guide/integrations/python.md @@ -30,6 +30,35 @@ Linux (manylinux), each for x86_64 and ARM64, plus one source distribution. See [Wheel matrix and scope](#wheel-matrix-and-scope) for what isn't covered and why. +## Lazy template responses + +`Renderer` provides resource-only and stateful operations for +[lazy bodies](../concepts/lazy): + +```python +from microsoft_webui import LazyOptions + +prepared = renderer.prepare_lazy_resources( + "app-shell", "settings", + options=LazyOptions( + inventory=client_inventory, + style_inventory=client_style_inventory, + build_id=client_build_id, + ), +) +send_and_flush(prepared.json + b"\n") +state = load_settings() +send_and_flush(renderer.render_lazy_partial( + state, "app-shell", "settings", options=prepared.options, +) + b"\n") +``` + +The send/data functions belong to the application. Schedule data work alongside +resource delivery, authenticate before headers, and cancel it on disconnect. +All rendering operations return UTF-8 `bytes`; state also accepts pre-serialized +inputs. `render_lazy_resources` is the resource-only convenience call. +Catch post-header errors and send an explicit terminal error record. + ## Buffered rendering Load `protocol.bin` once and reuse the `Renderer` for the process lifetime: @@ -121,7 +150,7 @@ templates = renderer.render_component_templates( ``` Both return `bytes`; write them directly as the response body for -`Router.ensureLoaded()` / partial-navigation requests from the client. +`router.ensureLoaded()` / partial-navigation requests from the client. ## Progressive (host-driven) streaming diff --git a/docs/guide/integrations/rust.md b/docs/guide/integrations/rust.md index 139a32408..8785e152f 100644 --- a/docs/guide/integrations/rust.md +++ b/docs/guide/integrations/rust.md @@ -22,6 +22,33 @@ serde_json = "1" The crate is published as `microsoft-webui` on crates.io; the bare `webui` name is owned by an unrelated project. Cargo's default rename rules mean items remain importable as `use webui::...` because the crate sets `[lib] name = "webui"` internally. +## Lazy template responses + +Use `LazyRequest` to address an existing template's named +[lazy body](../concepts/lazy): + +```rust +use webui::{LazyOptions, LazyRequest}; + +let request = LazyRequest::new("app-shell", "settings") + .with_options(&client_options); +let prepared = protocol.prepare_lazy_resources(request)?; +// Flush prepared.json as the first NDJSON record. + +let state_record = protocol.render_lazy_partial( + &scoped_state, + LazyRequest::new("app-shell", "settings").with_options(&prepared.options), +)?; +// Flush state_record as the terminal record. +``` + +The application owns authentication, scheduling its state provider, cancellation, +and transport backpressure. `render_lazy_resources` returns resource-only +JSON; `render_lazy_partial_json` accepts serialized state. Parsed-state +rendering borrows the tree rather than cloning it. Options carry `inventory`, +`style_inventory`, and `build_id`; resource availability never replaces fresh +application state. + ## Examples These examples render an existing `dist/protocol.bin`. They buffer into a @@ -181,7 +208,7 @@ Use `Protocol::matches_route(entry, path)` when only route existence is needed. ## Router-aware request helper Use `webui::server::serve_request` when one endpoint serves both initial HTML -documents and `webui-router` JSON partials. Build the `ServeRequest` with the +documents and `webui-delivery` JSON partials. Build the `ServeRequest` with the complete `RenderOptions` value for that HTTP response: ```rust diff --git a/docs/guide/integrations/wasm.md b/docs/guide/integrations/wasm.md index 18e0559c7..bcb105524 100644 --- a/docs/guide/integrations/wasm.md +++ b/docs/guide/integrations/wasm.md @@ -26,6 +26,20 @@ Ordinary native SSR does not require browser WASM or a browser download of Use the narrowest bundle your application needs. The handler excludes the parser, and the parser excludes the renderer. +## Lazy template responses + +The loaded `Protocol` also serves [lazy bodies](../concepts/lazy). +`renderLazyResources(owner, id, options)` returns resource JSON; +`prepareLazyResources` returns `{ json, options }` for two-phase delivery. +Pass the acknowledgement to +`renderLazyPartial(stateJson, owner, id, prepared.options)` after resolving +application state. Options use `inventory`, `styleInventory`, and `buildId`. + +The host owns state loading and transport. These APIs do not navigate, create +components, or perform browser hydration. The handler-only variant can serve a +protocol built elsewhere; the parser and combined variants assign artifact +identity when building new protocols. + ## Building the WASM bundles ```bash @@ -63,6 +77,9 @@ same bytes repeatedly. | `streamResponse(entry, requestPath, options?)` | Open a progressive [`StreamingSession`](#streamingsession) returning one `Uint8Array` per call | | `renderPartial(stateJson, entry, requestPath, inventoryHex)` | Return a complete JSON partial response with active-route projected state | | `renderComponentTemplates(componentTags, inventoryHex)` | Return requested template payloads and updated inventory | +| `renderLazyResources(owner, id, options?)` | Resource-only JSON for a named template body | +| `prepareLazyResources(owner, id, options?)` | Resource JSON and acknowledgement for two-phase delivery | +| `renderLazyPartial(stateJson, owner, id, options?)` | Complete/terminal JSON with fresh scoped state | | `tokens()` | Return CSS token names in build order | For a service-worker example that drives a progressive session and prepares diff --git a/docs/tutorials/hello-world.md b/docs/tutorials/hello-world.md index ddf4fe09c..400a526f2 100644 --- a/docs/tutorials/hello-world.md +++ b/docs/tutorials/hello-world.md @@ -202,7 +202,7 @@ MyCounter.define('my-counter'); ``` `@attr` exposes an HTML attribute; `@observable` connects a value changed by -TypeScript to its template binding. The `define()` call registers the element. +TypeScript to its template binding. The `define()` call declares the component. **`src/index.ts`** @@ -210,10 +210,15 @@ TypeScript to its template binding. The `define()` call registers the element. // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; import './my-counter/my-counter.js'; + +startRuntime(); ``` The `.js` import is intentional: esbuild resolves it to the TypeScript source. +`startRuntime()` registers the imported component and starts hydration. Replace the entry template with this complete file: diff --git a/docs/tutorials/todo-app.md b/docs/tutorials/todo-app.md index f6668c445..a34e0b945 100644 --- a/docs/tutorials/todo-app.md +++ b/docs/tutorials/todo-app.md @@ -343,12 +343,16 @@ across Shadow DOM boundaries. // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; import './todo-app/todo-app.js'; import './todo-item/todo-item.js'; + +startRuntime(); ``` -These imports register the elements. The runtime adopts the rendered DOM and -connects behavior; no explicit "hydrate the page" call is needed. The initial +`startRuntime()` registers the imported components, adopts the rendered DOM, and +connects behavior. The initial content remains visible without JavaScript, but the todo actions need this bundle. diff --git a/dotnet/src/Microsoft.WebUI/LazyOptions.cs b/dotnet/src/Microsoft.WebUI/LazyOptions.cs new file mode 100644 index 000000000..b927cd52a --- /dev/null +++ b/dotnet/src/Microsoft.WebUI/LazyOptions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.WebUI; + +/// Client availability for a named lazy template body. +public sealed record LazyOptions +{ + /// Loaded template-resource bits. + [JsonPropertyName("inventory")] + public string Inventory { get; init; } = ""; + + /// Loaded stylesheet-definition bits. + [JsonPropertyName("styleInventory")] + public string StyleInventory { get; init; } = ""; + + /// The artifact identity associated with these inventories. + [JsonPropertyName("buildId")] + public string BuildId { get; init; } = ""; +} + +/// A resource record and acknowledgement for host-owned two-phase delivery. +/// Resource JSON to flush before awaiting application state. +/// Availability to pass to the terminal state call. +public sealed record PreparedLazyResources(string Json, LazyOptions Options); diff --git a/dotnet/src/Microsoft.WebUI/NativeBindings.cs b/dotnet/src/Microsoft.WebUI/NativeBindings.cs index 468110c64..197f25723 100644 --- a/dotnet/src/Microsoft.WebUI/NativeBindings.cs +++ b/dotnet/src/Microsoft.WebUI/NativeBindings.cs @@ -194,6 +194,29 @@ internal static extern IntPtr webui_protocol_render_component_templates( [MarshalAs(UnmanagedType.LPUTF8Str)] string componentTagsJson, [MarshalAs(UnmanagedType.LPUTF8Str)] string inventoryHex); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr webui_protocol_render_lazy_resources( + WebUIProtocolSafeHandle protocolPtr, + [MarshalAs(UnmanagedType.LPUTF8Str)] string owner, + [MarshalAs(UnmanagedType.LPUTF8Str)] string id, + [MarshalAs(UnmanagedType.LPUTF8Str)] string optionsJson); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr webui_protocol_prepare_lazy_resources( + WebUIProtocolSafeHandle protocolPtr, + [MarshalAs(UnmanagedType.LPUTF8Str)] string owner, + [MarshalAs(UnmanagedType.LPUTF8Str)] string id, + [MarshalAs(UnmanagedType.LPUTF8Str)] string optionsJson, + out IntPtr updatedOptionsJson); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr webui_protocol_render_lazy_partial( + WebUIProtocolSafeHandle protocolPtr, + [MarshalAs(UnmanagedType.LPUTF8Str)] string stateJson, + [MarshalAs(UnmanagedType.LPUTF8Str)] string owner, + [MarshalAs(UnmanagedType.LPUTF8Str)] string id, + [MarshalAs(UnmanagedType.LPUTF8Str)] string optionsJson); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] internal static extern IntPtr webui_protocol_tokens( WebUIProtocolSafeHandle protocolPtr); @@ -372,6 +395,21 @@ internal static WebUIProtocolSafeHandle CreateProtocol(byte[] protocolData) } } + internal static (string Json, string Options) ReadAndFreeLazy(IntPtr json, IntPtr options) + { + try + { + return ( + Marshal.PtrToStringUTF8(json) ?? throw new WebUIException("Missing deferred resource JSON."), + Marshal.PtrToStringUTF8(options) ?? throw new WebUIException("Missing deferred acknowledgement.")); + } + finally + { + webui_free(json); + webui_free(options); + } + } + /// /// Copies a rendered document from a native render result and destroys the result. /// diff --git a/dotnet/src/Microsoft.WebUI/Protocol.cs b/dotnet/src/Microsoft.WebUI/Protocol.cs index c730597e6..a2a73bd35 100644 --- a/dotnet/src/Microsoft.WebUI/Protocol.cs +++ b/dotnet/src/Microsoft.WebUI/Protocol.cs @@ -118,6 +118,55 @@ public string RenderComponentTemplates( return NativeBindings.ReadAndFreeString(resultPtr)!; } + /// Prepare resources for an owner's named deferred body without application state. + public string RenderLazyResources(string owner, string id, LazyOptions? options = null) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(id); + IntPtr result = NativeBindings.webui_protocol_render_lazy_resources( + _handle, owner, id, JsonSerializer.Serialize(options ?? new LazyOptions())); + if (result == IntPtr.Zero) + { + throw new WebUIException(NativeBindings.GetLastError() ?? "RenderLazyResources failed."); + } + return NativeBindings.ReadAndFreeString(result)!; + } + + /// Prepare metadata and acknowledgement for a two-phase response without reparsing metadata. + public PreparedLazyResources PrepareLazyResources(string owner, string id, LazyOptions? options = null) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(id); + IntPtr result = NativeBindings.webui_protocol_prepare_lazy_resources( + _handle, owner, id, JsonSerializer.Serialize(options ?? new LazyOptions()), out IntPtr updated); + if (result == IntPtr.Zero) + { + throw new WebUIException(NativeBindings.GetLastError() ?? "PrepareLazyResources failed."); + } + var prepared = NativeBindings.ReadAndFreeLazy(result, updated); + var acknowledgement = JsonSerializer.Deserialize(prepared.Options) + ?? throw new WebUIException("Invalid deferred acknowledgement."); + return new PreparedLazyResources(prepared.Json, acknowledgement); + } + + /// Render fresh scoped state and any missing deferred template resources. + public string RenderLazyPartial(string stateJson, string owner, string id, LazyOptions? options = null) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(stateJson); + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(id); + IntPtr result = NativeBindings.webui_protocol_render_lazy_partial( + _handle, stateJson, owner, id, JsonSerializer.Serialize(options ?? new LazyOptions())); + if (result == IntPtr.Zero) + { + throw new WebUIException(NativeBindings.GetLastError() ?? "RenderLazyPartial failed."); + } + return NativeBindings.ReadAndFreeString(result)!; + } + /// /// Returns CSS token names in build order. /// diff --git a/dotnet/test/Microsoft.WebUI.Tests/Microsoft.WebUI.Tests.csproj b/dotnet/test/Microsoft.WebUI.Tests/Microsoft.WebUI.Tests.csproj index bc4181c15..37fcf6597 100644 --- a/dotnet/test/Microsoft.WebUI.Tests/Microsoft.WebUI.Tests.csproj +++ b/dotnet/test/Microsoft.WebUI.Tests/Microsoft.WebUI.Tests.csproj @@ -47,4 +47,14 @@
+ + + + + diff --git a/dotnet/test/Microsoft.WebUI.Tests/ProtocolTests.cs b/dotnet/test/Microsoft.WebUI.Tests/ProtocolTests.cs index 35d04755e..347c81f98 100644 --- a/dotnet/test/Microsoft.WebUI.Tests/ProtocolTests.cs +++ b/dotnet/test/Microsoft.WebUI.Tests/ProtocolTests.cs @@ -48,6 +48,37 @@ public void Protocol_Tokens_ReturnsBuildOrder() Assert.Empty(protocol.Tokens()); } + [Fact] + public void Protocol_LazyResourcesAndState_PreserveAcknowledgementAndProjection() + { + using var protocol = new Protocol(File.ReadAllBytes(Path.Combine( + AppContext.BaseDirectory, "fixtures", "lazy-app", "protocol.bin"))); + var prepared = protocol.PrepareLazyResources("test-panel", "details"); + using var metadata = JsonDocument.Parse(prepared.Json); + Assert.Equal("webui-lazy", metadata.RootElement.GetProperty("type").GetString()); + Assert.False(metadata.RootElement.TryGetProperty("state", out _)); + Assert.Equal(prepared.Json, protocol.RenderLazyResources("test-panel", "details")); + string body = protocol.RenderLazyPartial( + """{"message":"Fresh","items":["one"],"unused":"not sent"}""", + "test-panel", "details", prepared.Options); + using var response = JsonDocument.Parse(body); + Assert.False(response.RootElement.TryGetProperty("templates", out _)); + Assert.Equal("Fresh", response.RootElement.GetProperty("state").GetProperty("message").GetString()); + Assert.False(response.RootElement.GetProperty("state").TryGetProperty("unused", out _)); + } + + [Fact] + public void Protocol_LazyRequests_RejectMissingTargetsStaleBuildsAndDisposedHandles() + { + var protocol = new Protocol(File.ReadAllBytes(Path.Combine( + AppContext.BaseDirectory, "fixtures", "lazy-app", "protocol.bin"))); + Assert.Throws(() => protocol.RenderLazyResources("test-panel", "missing")); + Assert.Throws(() => protocol.RenderLazyResources("test-panel", "details", + new LazyOptions { BuildId = "different" })); + protocol.Dispose(); + Assert.Throws(() => protocol.RenderLazyPartial("{}", "test-panel", "details")); + } + [Fact] public void Protocol_OperationAfterDispose_ThrowsObjectDisposedException() { diff --git a/dotnet/test/Microsoft.WebUI.Tests/fixtures/lazy-app/index.html b/dotnet/test/Microsoft.WebUI.Tests/fixtures/lazy-app/index.html new file mode 100644 index 000000000..cced8b439 --- /dev/null +++ b/dotnet/test/Microsoft.WebUI.Tests/fixtures/lazy-app/index.html @@ -0,0 +1,2 @@ + + diff --git a/dotnet/test/Microsoft.WebUI.Tests/fixtures/lazy-app/test-panel.html b/dotnet/test/Microsoft.WebUI.Tests/fixtures/lazy-app/test-panel.html new file mode 100644 index 000000000..522c92ad1 --- /dev/null +++ b/dotnet/test/Microsoft.WebUI.Tests/fixtures/lazy-app/test-panel.html @@ -0,0 +1,6 @@ +

Owner

+ +

Lazy inline UI

+

{{message}}

+
  • {{item}}
+
diff --git a/examples/README.md b/examples/README.md index e4fb5b894..545d3923a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,12 +18,14 @@ Current entries: | `app/contact-book-manager` | Full CRUD contact manager with WebUI Framework + router + Node API | | `app/native-services` | Native directory picker, dialogs, visible webview capture, and PNG clipboard demo | | `app/component-assets` | No-router WebUI Framework app that lazy-loads a static component asset on demand | +| `app/lazy-ui` | Native popover and dialog with inline lazy bodies, scoped state streaming, preload, and cancellation | | `app/commerce` | WebUI Framework hydration app with a Rust backend for commerce demo app, dozens of controls | | `app/routes` | Nested declarative routing demo showing 4-level deep routes, full server side and client handoff | | `app/service-worker` | Static/CDN service worker app using `webui_wasm_handler` to stream WASM-rendered chunks from public API state | | `app/streaming` | Progressive hydration demo with runtime-discovered component boundaries, descriptor-driven backend pacing, and a bounded browser stream | | `integration/node` | Node.js integration via native addon | | `integration/node-addon-bench` | Node/V8/N-API runtime benchmark for the native addon | +| `integration/lazy-ui-bench` | Reproducible eager/lazy startup, interaction, payload, and retained-heap comparison | | `integration/rust` | Rust integration via `webui-handler` | | `integration/tauri` | Rust-only Tauri snapshot viewer for pre-built WebUI apps | @@ -43,6 +45,7 @@ cargo xtask dev hello-world cargo xtask dev calculator cargo xtask dev contact-book-manager cargo xtask dev component-assets +cargo xtask dev lazy-ui cargo xtask dev todo-webui # Native-only system service demo (macOS or Windows) diff --git a/examples/app/calculator/src/index.ts b/examples/app/calculator/src/index.ts index e12745d82..7feef537e 100644 --- a/examples/app/calculator/src/index.ts +++ b/examples/app/calculator/src/index.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; /** * Calculator hydration entry point. * @@ -22,6 +24,8 @@ function logHydrationTiming(): void { import './calc-app/calc-app.js'; import './calc-button/calc-button.js'; +startRuntime(); + // Fallback: if hydration already completed before the listener, log now if (performance.getEntriesByName('webui:hydrate:total', 'measure').length > 0) { logHydrationTiming(); diff --git a/examples/app/commerce/Dockerfile b/examples/app/commerce/Dockerfile index 375c26b8e..e79d5fe19 100644 --- a/examples/app/commerce/Dockerfile +++ b/examples/app/commerce/Dockerfile @@ -39,7 +39,7 @@ RUN cargo chef cook --release --bin marketplace-api --recipe-path recipe.json COPY . . RUN pnpm install --frozen-lockfile --filter=commerce... && \ - pnpm --filter=@microsoft/webui-framework --filter=@microsoft/webui-router run build && \ + pnpm --filter=@microsoft/webui-framework --filter=@microsoft/webui-delivery run build && \ cd examples/app/commerce && pnpm build RUN cargo build --release -p marketplace-api diff --git a/examples/app/commerce/README.md b/examples/app/commerce/README.md index 8b936192a..192ef1cdd 100644 --- a/examples/app/commerce/README.md +++ b/examples/app/commerce/README.md @@ -8,7 +8,7 @@ A full-featured commerce demo built with WebUI — server-side rendered with cli ## What It Demonstrates - **Nested routing** with `` and `` -- **Client-side navigation** via `@microsoft/webui-router` +- **Client-side navigation** via `@microsoft/webui-delivery` - **SSR + hydration** with WebUI Framework - **Declarative-only components** that auto-upgrade without authored TypeScript stubs - **View transitions** for smooth page changes diff --git a/examples/app/commerce/package.json b/examples/app/commerce/package.json index e14f039d0..ec70bae1d 100644 --- a/examples/app/commerce/package.json +++ b/examples/app/commerce/package.json @@ -6,7 +6,7 @@ "webuiProjectionManifest": "./dist/webui-projection.json", "scripts": { "build": "pnpm run build:deps && pnpm run build:client && pnpm run build:protocol && pnpm run build:server", - "build:deps": "pnpm --filter @microsoft/webui --filter @microsoft/webui-framework --filter @microsoft/webui-router run build", + "build:deps": "pnpm --filter @microsoft/webui --filter @microsoft/webui-framework --filter @microsoft/webui-delivery run build", "build:protocol": "cargo run -p microsoft-webui-cli -- build ./src --plugin=webui --projection-manifest ./dist/webui-projection.json --css module --out ./dist", "build:client": "node ../../build-client.mjs", "build:server": "cargo check -p marketplace-api", @@ -25,7 +25,7 @@ "devDependencies": { "@microsoft/webui": "workspace:*", "@microsoft/webui-framework": "workspace:*", - "@microsoft/webui-router": "workspace:*", + "@microsoft/webui-delivery": "workspace:*", "@playwright/test": "catalog:", "esbuild": "catalog:", "tslib": "catalog:", diff --git a/examples/app/commerce/playwright.config.ts b/examples/app/commerce/playwright.config.ts index 1328a23af..d2823113b 100644 --- a/examples/app/commerce/playwright.config.ts +++ b/examples/app/commerce/playwright.config.ts @@ -9,9 +9,10 @@ export default defineConfig({ '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}{ext}', timeout: 30_000, use: { - baseURL: 'http://127.0.0.1:3004', + baseURL: process.env['COMMERCE_TEST_URL'] ?? 'http://127.0.0.1:3004', screenshot: 'only-on-failure', launchOptions: { + channel: process.env['COMMERCE_BROWSER_CHANNEL'], // The Module CSS strategy emits ` + + + + + + diff --git a/examples/app/lazy-ui/src/index.ts b/examples/app/lazy-ui/src/index.ts new file mode 100644 index 000000000..9d85c660b --- /dev/null +++ b/examples/app/lazy-ui/src/index.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; +import '@microsoft/webui-framework/template-cache.js'; +import '@microsoft/webui-framework/lazy.js'; +import './lazy-popover/lazy-popover.js'; +import './lazy-dialog/lazy-dialog.js'; +import './lazy-workspace/lazy-workspace.js'; + +startRuntime(); diff --git a/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.css b/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.css new file mode 100644 index 000000000..ed4051cac --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.css @@ -0,0 +1,75 @@ +:host { display: block; } +*, *::before, *::after { box-sizing: border-box; } +h2, p { margin: 0; } +button, input, select { font: inherit; } +button { cursor: pointer; } +button:focus-visible, input:focus-visible, select:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 3px; +} + +.button, .icon-button { + min-height: 42px; + border: 1px solid var(--color-neutral-300); + border-radius: var(--border-radius-m); + background: var(--color-white); + color: var(--color-neutral-800); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-s); + padding: var(--spacing-s) var(--spacing-l); + font-size: var(--font-size-m); + font-weight: 600; +} + +.button:hover, .icon-button:hover { background: var(--color-neutral-150); } +.primary { background: var(--color-brand-primary); color: var(--color-white); border-color: transparent; } +.primary:hover { background: var(--color-brand-primary-hover); } +svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.settings-trigger { width: 100%; } + +.settings-dialog { + width: min(480px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + padding: 0; + border: 1px solid var(--color-neutral-300); + border-radius: var(--border-radius-xl); + background: var(--color-white); + color: var(--color-neutral-800); + box-shadow: 0 12px 36px rgb(0 0 0 / 16%); +} + +.settings-dialog::backdrop { background: rgb(0 0 0 / 38%); } +.overlay-header { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-l); padding: var(--spacing-l) var(--spacing-xl); border-bottom: 1px solid var(--color-neutral-200); } +.overlay-header h2 { font-size: var(--font-size-xl); font-weight: 600; } +.icon-button { padding: var(--spacing-s); min-width: 36px; min-height: 36px; border: 0; background: transparent; } +.dialog-intro { padding: var(--spacing-xl) var(--spacing-xxl) 0; color: var(--color-neutral-700); font-size: var(--font-size-m); } +.settings-body { padding: var(--spacing-xxl); } +fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } +legend { margin-bottom: var(--spacing-xl); padding: 0; font-size: var(--font-size-l); font-weight: 600; } +.field-label { display: block; margin-bottom: var(--spacing-s); font-size: var(--font-size-m); font-weight: 600; } +select { width: 100%; min-height: 44px; border: 1px solid var(--color-neutral-300); border-radius: var(--border-radius-m); padding: var(--spacing-s) var(--spacing-m); background: var(--color-white); color: var(--color-neutral-800); } +.field-description { margin-top: var(--spacing-s); font-size: var(--font-size-s); color: var(--color-neutral-700); } +.check-control { display: flex; align-items: center; gap: var(--spacing-m); min-height: 44px; font-size: var(--font-size-m); cursor: pointer; } +.check-control input { width: 17px; height: 17px; margin: 0; accent-color: var(--color-brand-primary); } +.preference { align-items: flex-start; margin-top: var(--spacing-xxl); padding-block: var(--spacing-s); } +.preference input { margin-top: var(--spacing-xs); } +.preference strong { display: block; font-weight: 600; } +.preference small { display: block; margin: var(--spacing-xs) 0 0; font-size: var(--font-size-s); color: var(--color-neutral-700); } +.settings-note { margin-top: var(--spacing-xxl); font-size: var(--font-size-s); color: var(--color-neutral-700); } +.dialog-actions { display: flex; justify-content: flex-end; gap: var(--spacing-s); margin-top: var(--spacing-xxl); padding-top: var(--spacing-xl); border-top: 1px solid var(--color-neutral-200); } +.response-stamp { margin-top: var(--spacing-l); color: var(--color-neutral-500); font-size: var(--font-size-xs); } + +.loading-state { display: flex; align-items: center; gap: var(--spacing-s); margin-top: var(--spacing-s); color: var(--color-neutral-700); font-size: var(--font-size-s); } +.loading-dot { width: 8px; height: 8px; border-radius: var(--border-radius-round); background: var(--color-brand-primary); animation: waiting 1s ease-in-out infinite alternate; } +.error-state { max-width: 26ch; margin-top: var(--spacing-m); } +.error-state p { margin-bottom: var(--spacing-s); color: var(--color-danger-hover); font-size: var(--font-size-s); } + +@keyframes waiting { to { opacity: 0.35; } } +@media (prefers-reduced-motion: reduce) { + .loading-dot { animation: none; } +} +@media (max-width: 380px) { + .dialog-actions { flex-direction: column-reverse; } +} diff --git a/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.html b/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.html new file mode 100644 index 000000000..9b7bae92e --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.html @@ -0,0 +1,54 @@ + diff --git a/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.ts b/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.ts new file mode 100644 index 000000000..20a9d2306 --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-dialog/lazy-dialog.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { attr, observable, WebUIElement } from '@microsoft/webui-framework'; +import { fragments, stateUrl } from '../lazy-state.js'; + +export interface ViewPreferences { + compactMode: boolean; + highlightPinned: boolean; +} + +export class LazyDialog extends WebUIElement { + @attr({ mode: 'boolean' }) slowData = false; + @attr({ mode: 'boolean' }) compactMode = false; + @attr({ mode: 'boolean' }) highlightPinned = true; + @observable densityDraft = 'comfortable'; + @observable highlightDraft = true; + @observable settingsReady = false; + @observable settingsLoading = false; + @observable settingsError = ''; + + triggerElement!: HTMLButtonElement; + dialog!: HTMLDialogElement; + private request?: AbortController; + private generation = 0; + + override hydratedCallback(): void { + if (this.settingsReady) this.dialog.showModal(); + } + + override disconnectedCallback(): void { + this.generation++; + this.request?.abort(); + super.disconnectedCallback(); + } + + async preload(): Promise { + try { + await fragments.ensureLoaded(this.localName, 'settings'); + } catch (error) { + console.error('Settings preload failed:', error); + this.$emit('overlay-notice', 'Preload was unavailable. Opening the overlay will try again.'); + } + } + + onTrigger(): void { + if (this.settingsLoading) this.dismiss(); + else void this.open(); + } + + onKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.settingsLoading) { + event.preventDefault(); + this.dismiss(); + } + } + + async open(): Promise { + if (this.settingsReady) { + if (!this.dialog.open) this.dialog.showModal(); + return; + } + this.triggerElement.focus(); + const generation = ++this.generation; + this.request?.abort(); + const request = new AbortController(); + this.request = request; + this.settingsLoading = true; + this.settingsError = ''; + try { + const state = await fragments.fetchPartial(this.localName, 'settings', { + url: stateUrl(this.localName, 'settings', this.slowData), + signal: request.signal, + }); + if (generation !== this.generation || request.signal.aborted) return; + this.setState(state); + this.densityDraft = this.compactMode ? 'compact' : 'comfortable'; + this.highlightDraft = this.highlightPinned; + this.settingsReady = true; + this.$flushUpdates(); + this.dialog.showModal(); + } catch (error) { + if (generation === this.generation && !request.signal.aborted) { + console.error('Settings state failed:', error); + this.settingsError = 'Settings could not be loaded. Please try again.'; + } + } finally { + if (generation === this.generation) this.settingsLoading = false; + } + } + + dismiss(): void { + this.dialog.close(); + this.clear(); + } + + onClosed(): void { + if (!this.dialog.open && this.settingsReady) this.clear(); + } + + changeDensity(event: Event): void { + const select = event.currentTarget; + if (select instanceof HTMLSelectElement) this.densityDraft = select.value; + } + + changeHighlight(event: Event): void { + const input = event.currentTarget; + if (input instanceof HTMLInputElement) this.highlightDraft = input.checked; + } + + apply(event: Event): void { + event.preventDefault(); + const preferences: ViewPreferences = { + compactMode: this.densityDraft === 'compact', + highlightPinned: this.highlightDraft, + }; + this.$emit('preferences-applied', preferences); + this.dismiss(); + } + + private clear(): void { + this.generation++; + this.request?.abort(); + this.request = undefined; + this.settingsReady = false; + this.settingsLoading = false; + this.settingsError = ''; + this.setState({ layoutOptions: [], settingsNote: '', settingsStamp: '' }); + } +} + +LazyDialog.define('lazy-dialog'); diff --git a/examples/app/lazy-ui/src/lazy-popover/lazy-popover.css b/examples/app/lazy-ui/src/lazy-popover/lazy-popover.css new file mode 100644 index 000000000..9a7897ba7 --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-popover/lazy-popover.css @@ -0,0 +1,72 @@ +:host { display: block; } +*, *::before, *::after { box-sizing: border-box; } +h2, h3, p { margin: 0; } +button { font: inherit; cursor: pointer; } +button:focus-visible { outline: 2px solid var(--color-brand-primary); outline-offset: 3px; } + +.button, .icon-button { + min-height: 42px; + border: 1px solid var(--color-neutral-300); + border-radius: var(--border-radius-m); + background: var(--color-white); + color: var(--color-neutral-800); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-s); + padding: var(--spacing-s) var(--spacing-l); + font-size: var(--font-size-m); + font-weight: 600; +} + +.button:hover, .icon-button:hover { background: var(--color-neutral-150); } +svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.activity-trigger { width: 100%; anchor-name: --activity-trigger; } +.activity-popover { + position: fixed; + inset: 110px 16px auto auto; + width: min(390px, calc(100vw - 32px)); + max-height: min(620px, calc(100dvh - 100px)); + margin: var(--spacing-s) 0 0; + padding: 0; + overflow-y: auto; + border: 1px solid var(--color-neutral-300); + border-radius: var(--border-radius-xl); + background: var(--color-white); + color: var(--color-neutral-800); + box-shadow: 0 12px 36px rgb(0 0 0 / 16%); +} + +@supports (anchor-name: --activity-trigger) and (position-area: bottom) { + .activity-popover { + inset: auto; + position-anchor: --activity-trigger; + position-area: bottom span-left; + position-try-fallbacks: flip-inline, flip-block; + } +} + +.overlay-header { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-l); padding: var(--spacing-l) var(--spacing-xl); border-bottom: 1px solid var(--color-neutral-200); } +.overlay-header h2 { font-size: var(--font-size-xl); font-weight: 600; } +.icon-button { padding: var(--spacing-s); min-width: 36px; min-height: 36px; border: 0; background: transparent; } +.activity-body { padding: var(--spacing-xl); } +.overlay-intro { color: var(--color-neutral-700); font-size: var(--font-size-m); } +.section-label { margin-top: var(--spacing-xxl); font-size: var(--font-size-s); font-weight: 600; } +.activity-list { margin: var(--spacing-s) 0 0; padding: 0; list-style: none; } +.activity-list li { padding-block: var(--spacing-l); border-bottom: 1px solid var(--color-neutral-200); } +.activity-list li:last-child { border-bottom: 0; } +.activity-line { display: flex; align-items: baseline; justify-content: space-between; gap: var(--spacing-m); } +.activity-line strong { font-size: var(--font-size-m); font-weight: 600; } +.activity-line span { color: var(--color-neutral-500); font-size: var(--font-size-xs); } +.activity-list p { margin-top: var(--spacing-xs); color: var(--color-neutral-700); font-size: var(--font-size-s); line-height: 1.5; } +.response-stamp { margin-top: var(--spacing-l); color: var(--color-neutral-500); font-size: var(--font-size-xs); } + +.loading-state { display: flex; align-items: center; gap: var(--spacing-s); margin-top: var(--spacing-s); color: var(--color-neutral-700); font-size: var(--font-size-s); } +.loading-dot { width: 8px; height: 8px; border-radius: var(--border-radius-round); background: var(--color-brand-primary); animation: waiting 1s ease-in-out infinite alternate; } +.error-state { max-width: 26ch; margin-top: var(--spacing-m); } +.error-state p { margin-bottom: var(--spacing-s); color: var(--color-danger-hover); font-size: var(--font-size-s); } + +@keyframes waiting { to { opacity: 0.35; } } +@media (prefers-reduced-motion: reduce) { + .loading-dot { animation: none; } +} diff --git a/examples/app/lazy-ui/src/lazy-popover/lazy-popover.html b/examples/app/lazy-ui/src/lazy-popover/lazy-popover.html new file mode 100644 index 000000000..ef0516138 --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-popover/lazy-popover.html @@ -0,0 +1,45 @@ + diff --git a/examples/app/lazy-ui/src/lazy-popover/lazy-popover.ts b/examples/app/lazy-ui/src/lazy-popover/lazy-popover.ts new file mode 100644 index 000000000..427c219ee --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-popover/lazy-popover.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { attr, observable, WebUIElement } from '@microsoft/webui-framework'; +import { fragments, stateUrl } from '../lazy-state.js'; + +export class LazyPopover extends WebUIElement { + @attr({ mode: 'boolean' }) slowData = false; + @observable activityReady = false; + @observable activityLoading = false; + @observable activityError = ''; + + triggerElement!: HTMLButtonElement; + popoverElement!: HTMLElement; + private request?: AbortController; + private generation = 0; + + override hydratedCallback(): void { + if (this.activityReady) this.popoverElement.showPopover(); + } + + override disconnectedCallback(): void { + this.generation++; + this.request?.abort(); + super.disconnectedCallback(); + } + + async preload(): Promise { + try { + await fragments.ensureLoaded(this.localName, 'activity'); + } catch (error) { + console.error('Activity preload failed:', error); + this.$emit('overlay-notice', 'Preload was unavailable. Opening the overlay will try again.'); + } + } + + onToggle(event: ToggleEvent): void { + if (event.newState === 'closed') { + const focused = this.shadowRoot?.activeElement; + if (focused && this.popoverElement.contains(focused)) this.triggerElement.focus(); + return; + } + if (this.activityReady) return; + event.preventDefault(); + if (this.activityLoading) this.clear(); + else void this.load(); + } + + onToggled(event: ToggleEvent): void { + if (event.newState === 'closed' && !this.popoverElement.matches(':popover-open') && this.activityReady) { + this.clear(); + } + } + + onKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.activityLoading) { + event.preventDefault(); + this.clear(); + } + } + + async load(): Promise { + this.triggerElement.focus(); + const generation = ++this.generation; + this.request?.abort(); + const request = new AbortController(); + this.request = request; + this.activityLoading = true; + this.activityError = ''; + try { + const state = await fragments.fetchPartial(this.localName, 'activity', { + url: stateUrl(this.localName, 'activity', this.slowData), + signal: request.signal, + }); + if (generation !== this.generation || request.signal.aborted) return; + this.setState(state); + this.activityReady = true; + this.$flushUpdates(); + this.popoverElement.showPopover({ source: this.triggerElement }); + } catch (error) { + if (generation === this.generation && !request.signal.aborted) { + console.error('Activity state failed:', error); + this.activityError = 'Activity could not be loaded. Please try again.'; + } + } finally { + if (generation === this.generation) this.activityLoading = false; + } + } + + private clear(): void { + this.generation++; + this.request?.abort(); + this.request = undefined; + this.activityReady = false; + this.activityLoading = false; + this.activityError = ''; + this.setState({ activityItems: [], activitySummary: '', activityStamp: '' }); + } +} + +LazyPopover.define('lazy-popover'); diff --git a/examples/app/lazy-ui/src/lazy-state.ts b/examples/app/lazy-ui/src/lazy-state.ts new file mode 100644 index 000000000..b76b098c5 --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-state.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createLazyLoader } from '@microsoft/webui-delivery/lazy.js'; +import { ndjson } from '@microsoft/webui-delivery/resources.js'; + +const endpoint = new URL('_webui/lazy', document.baseURI).href; + +export const fragments = createLazyLoader({ endpoint, transport: ndjson() }); + +export function stateUrl(owner: string, id: 'activity' | 'settings', slowData: boolean): string { + const query = new URLSearchParams({ owner, id }); + if (slowData) query.set('slow', '1'); + return `${endpoint}/state?${query}`; +} diff --git a/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.css b/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.css new file mode 100644 index 000000000..07b4d67fb --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.css @@ -0,0 +1,80 @@ +:host { + display: block; + max-width: 1040px; + margin: 0 auto; + padding: var(--spacing-4xl) var(--spacing-xxl); +} + +*, *::before, *::after { box-sizing: border-box; } + +h1, h2, h3, p { margin: 0; } +button, input { font: inherit; } +button { cursor: pointer; } +button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 3px; +} + +.workspace-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-xxl); + margin-bottom: var(--spacing-3xl); +} + +.heading { max-width: 39ch; } +h1 { font-size: clamp(1.75rem, 3vw, 2.125rem); line-height: 1.2; font-weight: 650; } +.heading p { margin-top: var(--spacing-s); color: var(--color-neutral-700); } +.toolbar { display: flex; flex-wrap: wrap; gap: var(--spacing-s); padding-top: var(--spacing-xs); } + +.reading-list { + overflow: hidden; + border: 1px solid var(--color-neutral-200); + border-radius: var(--border-radius-l); + background: var(--color-white); +} + +.list-header { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-l); padding: var(--spacing-l) var(--spacing-xxl); } +.list-header h2 { font-size: var(--font-size-l); font-weight: 600; } +.list-header > span { font-size: var(--font-size-s); color: var(--color-neutral-500); } +.documents { margin: 0; padding: 0; list-style: none; } +.document-row { display: flex; align-items: flex-start; gap: var(--spacing-l); padding: var(--spacing-xxl); border-top: 1px solid var(--color-neutral-200); } +.document-icon { width: 28px; height: 32px; flex-shrink: 0; margin-top: var(--spacing-xs); fill: none; stroke: var(--color-neutral-500); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; } +.document-content { flex: 1; min-width: 0; } +.document-content h3 { font-size: var(--font-size-l); font-weight: 600; } +.document-content p { margin-top: var(--spacing-xs); color: var(--color-neutral-700); font-size: var(--font-size-m); } +.document-meta { display: flex; flex-wrap: wrap; gap: var(--spacing-l); margin-top: var(--spacing-s); font-size: var(--font-size-xs); color: var(--color-neutral-700); } +.pinned { color: var(--color-blue-text); font-size: var(--font-size-xs); font-weight: 600; } +[data-compact] .document-row { padding-block: var(--spacing-m); } +[data-highlight] .document-row[data-pinned] { background: var(--color-brand-light); } + +.demo-controls { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--spacing-m); padding-top: var(--spacing-l); } +.check-control { display: flex; align-items: center; gap: var(--spacing-m); min-height: 44px; font-size: var(--font-size-m); cursor: pointer; } +.check-control input { width: 17px; height: 17px; margin: 0; accent-color: var(--color-brand-primary); } +.check-control small { color: var(--color-neutral-700); font-size: var(--font-size-xs); margin-left: var(--spacing-s); } +.text-button { min-height: 44px; padding: var(--spacing-s); border: 0; background: transparent; color: var(--color-blue-text); font-size: var(--font-size-m); } +.text-button:hover { text-decoration: underline; } +.announcement { min-height: 1.6em; color: var(--color-neutral-700); font-size: var(--font-size-s); } +.explanation { margin-top: var(--spacing-xl); padding-top: var(--spacing-l); border-top: 1px solid var(--color-neutral-300); color: var(--color-neutral-700); font-size: var(--font-size-m); } +.explanation summary { cursor: pointer; padding-block: var(--spacing-xs); color: var(--color-neutral-800); font-weight: 600; } +.explanation p { max-width: 72ch; margin-top: var(--spacing-m); } +code { font-size: 0.95em; } +.ssr-links { display: flex; flex-wrap: wrap; gap: var(--spacing-m); margin-top: var(--spacing-l); } +a { color: var(--color-blue-text); text-underline-offset: 3px; } + +@media (max-width: 700px) { + :host { padding: var(--spacing-xxl) var(--spacing-l); } + .workspace-header { flex-direction: column; gap: var(--spacing-l); } + .heading { max-width: 45ch; } + .toolbar { width: 100%; } + .toolbar > * { flex: 1; } + .document-row { padding: var(--spacing-l); } + .list-header { padding-inline: var(--spacing-l); } + .pinned { align-self: flex-start; } +} + +@media (max-width: 380px) { + .toolbar { flex-direction: column; } + .document-icon { display: none; } +} diff --git a/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.html b/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.html new file mode 100644 index 000000000..8c7a3439b --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.html @@ -0,0 +1,61 @@ + diff --git a/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.ts b/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.ts new file mode 100644 index 000000000..33d84c617 --- /dev/null +++ b/examples/app/lazy-ui/src/lazy-workspace/lazy-workspace.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { observable, WebUIElement } from '@microsoft/webui-framework'; +import { collectTemplates } from '@microsoft/webui-framework/template-cache.js'; +import type { ViewPreferences } from '../lazy-dialog/lazy-dialog.js'; + +export class LazyWorkspace extends WebUIElement { + @observable compactMode = false; + @observable highlightPinned = true; + @observable slowData = false; + @observable announcement = ''; + + onOverlayNotice(event: CustomEvent): void { + this.announcement = event.detail; + } + + changeDelay(event: Event): void { + const input = event.currentTarget; + if (input instanceof HTMLInputElement) this.slowData = input.checked; + } + + applyPreferences(event: CustomEvent): void { + this.compactMode = event.detail.compactMode; + this.highlightPinned = event.detail.highlightPinned; + this.announcement = 'View preferences applied to this page.'; + } + + clearTemplateCache(): void { + collectTemplates(); + this.announcement = 'Template cache cleared. Live views keep the resources they still need.'; + } +} + +LazyWorkspace.define('lazy-workspace'); diff --git a/examples/app/lazy-ui/tests/lazy-ui.spec.ts b/examples/app/lazy-ui/tests/lazy-ui.spec.ts new file mode 100644 index 000000000..06c6f77a3 --- /dev/null +++ b/examples/app/lazy-ui/tests/lazy-ui.spec.ts @@ -0,0 +1,352 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test, type Page } from '@playwright/test'; +import type { LazyDialog } from '../src/lazy-dialog/lazy-dialog.js'; +import type { LazyPopover } from '../src/lazy-popover/lazy-popover.js'; + +async function ready(page: Page, path = '/'): Promise { + await page.goto(path); + await hydrated(page); +} + +async function hydrated(page: Page): Promise { + await page.waitForFunction(() => { + const workspace = document.querySelector('lazy-workspace'); + return [ + workspace, + workspace?.shadowRoot?.querySelector('lazy-popover'), + workspace?.shadowRoot?.querySelector('lazy-dialog'), + ].every(element => element && Reflect.get(element, '$ready') === true); + }); +} + +function isState(url: string): boolean { + return new URL(url).pathname === '/_webui/lazy/state'; +} + +test('initial SSR omits all overlay contents and their lazy metadata', async ({ page }) => { + const response = await page.goto('/'); + const html = await response!.text(); + expect(html).not.toContain('Review queue'); + expect(html).not.toContain('Reading layout'); + expect(html).not.toContain('A quick look at this sample workspace.'); + expect(html).not.toContain('overlay-header'); + expect(html).not.toContain('Close activity'); + expect(html).not.toContain('Close settings'); + expect(html).not.toContain('Make this workspace easier to read.'); + await hydrated(page); + await expect(page.locator('lazy-popover')).toHaveCount(1); + await expect(page.locator('lazy-dialog')).toHaveCount(1); + await expect(page.locator('.document-row')).toHaveCount(3); + await expect(page.locator('.activity-body')).toHaveCount(0); + await expect(page.locator('.settings-body')).toHaveCount(0); + for (const selector of ['#activity-popover', '.settings-dialog']) { + expect(await page.locator(selector).evaluate(element => element.children.length)).toBe(0); + } +}); + +test('hover preloads the popover without showing it or fetching state', async ({ page }) => { + await ready(page); + let stateRequests = 0; + page.on('request', request => { if (isState(request.url())) stateRequests++; }); + const metadata = page.waitForResponse(response => { + const url = new URL(response.url()); + return url.pathname === '/_webui/lazy' && url.searchParams.get('id') === 'activity'; + }); + await page.getByRole('button', { name: 'Recent activity', exact: true }).hover(); + const response = await metadata; + expect(response.ok()).toBe(true); + const payload = await response.json(); + expect(payload).toMatchObject({ + owner: 'lazy-popover', fragment: 'lazy-popover::lazy:activity', + }); + expect(payload.templates['lazy-popover::lazy:activity'].h).toContain('overlay-header'); + expect(payload.templates['lazy-popover::lazy:activity'].h).toContain('Close activity'); + await page.locator('lazy-popover').evaluate(element => (element as LazyPopover).preload()); + await expect(page.locator('#activity-popover')).not.toBeVisible(); + await expect(page.locator('.activity-body')).toHaveCount(0); + await expect(page.locator('#activity-popover .overlay-header')).toHaveCount(0); + expect(stateRequests).toBe(0); +}); + +test('a warm native popover requests fresh state without repeating its template', async ({ page }) => { + await ready(page); + await page.locator('lazy-popover').evaluate(element => (element as LazyPopover).preload()); + const state = page.waitForResponse(response => isState(response.url())); + await page.getByRole('button', { name: 'Recent activity', exact: true }).click(); + const records = (await (await state).text()).trim().split('\n').map(line => JSON.parse(line)); + expect(records).toHaveLength(2); + expect(records.every(record => record.owner === 'lazy-popover')).toBe(true); + expect(records.every(record => record.templates === undefined)).toBe(true); + await expect(page.locator('.activity-list li')).toHaveCount(3); + const stamp = await page.locator('.activity-body .response-stamp').textContent(); + await page.keyboard.press('Escape'); + await expect(page.locator('.activity-body')).toHaveCount(0); + await page.getByRole('button', { name: 'Recent activity', exact: true }).click(); + await expect(page.locator('.activity-body .response-stamp')).not.toHaveText(stamp!); +}); + +test('native popover light-dismiss removes the lazy body', async ({ page }) => { + await ready(page); + await page.getByRole('button', { name: 'Recent activity', exact: true }).click(); + await expect(page.locator('.activity-body')).toBeVisible(); + await page.getByRole('heading', { name: 'Workspace', exact: true }).click(); + await expect(page.locator('#activity-popover')).not.toBeVisible(); + await expect(page.locator('.activity-body')).toHaveCount(0); +}); + +test('the dialog emits preferences to the workspace and receives updated attributes', async ({ page }) => { + await ready(page); + const opener = page.getByRole('button', { name: 'View settings', exact: true }); + const state = page.waitForResponse(response => isState(response.url())); + await opener.click(); + const dialog = page.getByRole('dialog', { name: 'View settings' }); + const records = (await (await state).text()).trim().split('\n').map(line => JSON.parse(line)); + expect(records.every(record => record.owner === 'lazy-dialog')).toBe(true); + await expect(dialog).toBeVisible(); + expect(await dialog.evaluate(element => element instanceof HTMLDialogElement && element.matches(':modal'))).toBe(true); + await expect(dialog.getByLabel('Item spacing')).toBeVisible(); + await dialog.getByLabel('Item spacing').selectOption('compact'); + await dialog.getByLabel('Highlight pinned items').uncheck(); + await dialog.getByRole('button', { name: 'Apply to this page' }).click(); + await expect(dialog).not.toBeVisible(); + await expect(page.locator('.settings-body')).toHaveCount(0); + await expect(page.locator('.workspace')).toHaveAttribute('data-compact', ''); + await expect(page.locator('.workspace')).not.toHaveAttribute('data-highlight', ''); + await expect(page.getByRole('status').filter({ hasText: 'View preferences applied' })).toBeVisible(); + await expect(opener).toBeFocused(); + await expect(page.locator('lazy-dialog')).toHaveAttribute('compact-mode', ''); + await expect(page.locator('lazy-dialog')).not.toHaveAttribute('highlight-pinned', ''); + await opener.click(); + await expect(dialog.getByLabel('Item spacing')).toHaveValue('compact'); + await expect(dialog.getByLabel('Highlight pinned items')).not.toBeChecked(); +}); + +test('cancelling dialog edits preserves the current view and resets the draft', async ({ page }) => { + await ready(page); + const opener = page.getByRole('button', { name: 'View settings', exact: true }); + await opener.click(); + await page.getByLabel('Item spacing').selectOption('compact'); + await page.getByLabel('Highlight pinned items').uncheck(); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.locator('.workspace')).not.toHaveAttribute('data-compact', ''); + await expect(page.locator('.workspace')).toHaveAttribute('data-highlight', ''); + await opener.click(); + await expect(page.getByLabel('Item spacing')).toHaveValue('comfortable'); + await expect(page.getByLabel('Highlight pinned items')).toBeChecked(); +}); + +test('an old native close event does not cancel a newly reopened dialog', async ({ page }) => { + await ready(page); + await page.getByRole('button', { name: 'View settings', exact: true }).click(); + await expect(page.locator('.settings-body')).toBeVisible(); + await page.locator('lazy-dialog').evaluate(async element => { + const component = element as LazyDialog; + component.dismiss(); + await component.open(); + }); + await expect(page.locator('lazy-dialog .settings-body')).toBeVisible(); +}); + +for (const overlay of ['activity', 'settings'] as const) { + for (const cancel of ['Escape', 'trigger'] as const) { + test(`${overlay} stays closed and empty while loading, cancellable by ${cancel}`, async ({ page }) => { + await ready(page); + await page.getByLabel('Simulate slow data').check(); + let requests = 0; + page.on('request', request => { if (isState(request.url())) requests++; }); + const response = page.waitForResponse(value => isState(value.url())); + await page.getByRole('button', { + name: overlay === 'activity' ? 'Recent activity' : 'View settings', exact: true, + }).click(); + await response; + const component = page.locator(overlay === 'activity' ? 'lazy-popover' : 'lazy-dialog'); + const shell = component.locator(overlay === 'activity' ? '#activity-popover' : '.settings-dialog'); + await expect(component.locator('.loading-state')).toBeVisible(); + await expect(shell).not.toBeVisible(); + expect(await shell.evaluate(element => element.children.length)).toBe(0); + const trigger = component.getByRole('button', { name: `Cancel ${overlay} load` }); + await expect(trigger).toHaveAttribute('aria-busy', 'true'); + if (cancel === 'Escape') await page.keyboard.press('Escape'); + else await trigger.click(); + await expect(component.locator('.loading-state')).toHaveCount(0); + await page.waitForTimeout(650); + expect(await shell.evaluate(element => element.children.length)).toBe(0); + await expect(shell).not.toBeVisible(); + expect(requests).toBe(1); + }); + } + + test(`${overlay} disconnect aborts its pending state request`, async ({ page }) => { + await ready(page); + await page.getByLabel('Simulate slow data').check(); + const response = page.waitForResponse(value => isState(value.url())); + await page.getByRole('button', { + name: overlay === 'activity' ? 'Recent activity' : 'View settings', exact: true, + }).click(); + await response; + const component = overlay === 'activity' ? 'lazy-popover' : 'lazy-dialog'; + const cancelled = page.waitForEvent('requestfailed', request => isState(request.url())); + await page.locator(component).evaluate(element => element.remove()); + await cancelled; + await expect(page.locator(overlay === 'activity' ? 'lazy-dialog' : 'lazy-popover')).toHaveCount(1); + await expect(page.getByRole('heading', { name: 'Workspace', exact: true })).toBeVisible(); + }); +} + +for (const [tag, label, selector, error] of [ + ['lazy-popover', 'Recent activity', '#activity-popover', 'Activity could not be loaded'], + ['lazy-dialog', 'View settings', '.settings-dialog', 'Settings could not be loaded'], +]) { + test(`${tag} reports state failures outside its empty overlay and retries`, async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }); + await ready(page); + let failed = false; + await page.route('**/_webui/lazy/state?**', async route => { + if (!failed) { + failed = true; + await route.fulfill({ + status: 503, contentType: 'application/json', + body: '{"error":{"code":"sample-unavailable","message":"Try again"}}', + }); + } else { + await route.continue(); + } + }); + const component = page.locator(tag); + const overlay = component.locator(selector); + await component.getByRole('button', { name: label, exact: true }).click(); + await expect(component.getByRole('alert')).toContainText(error); + const contrast = await component.locator('.error-state p').evaluate(element => { + const luminance = (color: string): number => { + const channels = color.match(/\d+/g); + if (!channels) throw new Error(`Expected an RGB color, received ${color}`); + const linear = channels.map(channel => { + const value = Number(channel) / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; + }; + const foreground = luminance(getComputedStyle(element).color); + const background = luminance(getComputedStyle(document.body).backgroundColor); + return (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05); + }); + expect(contrast).toBeGreaterThanOrEqual(4.5); + await expect(overlay).not.toBeVisible(); + expect(await overlay.evaluate(element => element.children.length)).toBe(0); + await component.getByRole('button', { name: 'Try again' }).click(); + await expect(overlay).toBeVisible(); + await expect(overlay.locator('.overlay-header')).toBeVisible(); + expect(new URL(page.url()).pathname).toBe('/'); + }); +} + +for (const overlay of ['popover', 'dialog'] as const) { + test(`an initially active ${overlay} uses SSR without another state request`, async ({ page }) => { + let requests = 0; + page.on('request', request => { if (isState(request.url())) requests++; }); + const response = await page.goto(`/?open=${overlay}`); + const html = await response!.text(); + expect(html).toContain(overlay === 'popover' ? 'class="activity-body"' : 'class="settings-body"'); + expect(html).toContain('class="overlay-header"'); + await hydrated(page); + await expect(page.locator(overlay === 'popover' ? '.activity-body' : '.settings-body')).toBeVisible(); + expect(requests).toBe(0); + await page.keyboard.press('Escape'); + await expect(page.locator(overlay === 'popover' ? '.activity-body' : '.settings-body')).toHaveCount(0); + await expect(page.locator('.overlay-header')).toHaveCount(0); + }); +} + +test('clearing cached templates preserves the shell and permits reopening', async ({ page }) => { + await ready(page); + await page.getByRole('button', { name: 'View settings', exact: true }).click(); + await expect(page.locator('.settings-body')).toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Clear template cache' }).click(); + await expect(page.locator('.document-row')).toHaveCount(3); + await page.getByRole('button', { name: 'View settings', exact: true }).click(); + await expect(page.getByLabel('Item spacing')).toBeVisible(); +}); + +test('native overlays stay within a narrow viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await ready(page); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + for (const [button, selector] of [ + ['Recent activity', '#activity-popover'], + ['View settings', '.settings-dialog'], + ]) { + await page.getByRole('button', { name: button, exact: true }).click(); + await expect(page.locator(selector)).toBeVisible(); + const box = await page.locator(selector).boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(376); + await page.keyboard.press('Escape'); + } +}); + +test('the popover uses its component-local native invoker and CSS anchor', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await ready(page); + const component = page.locator('lazy-popover'); + const trigger = component.getByRole('button', { name: 'Recent activity', exact: true }); + const popover = component.locator('#activity-popover'); + await expect(trigger).toHaveAttribute('popovertarget', 'activity-popover'); + await expect(popover).toHaveAttribute('popover', 'auto'); + await trigger.click(); + await expect(component.locator('.activity-body')).toBeVisible(); + expect(await popover.evaluate(element => element.matches(':popover-open'))).toBe(true); + await expect(popover).toHaveCSS('position-anchor', '--activity-trigger'); + const button = await trigger.boundingBox(); + const panel = await popover.boundingBox(); + expect(panel!.x + panel!.width).toBeCloseTo(button!.x + button!.width, 0); + expect(panel!.y - button!.y - button!.height).toBeCloseTo(8, 0); + await component.getByRole('button', { name: 'Close activity' }).click(); + await expect(component.locator('.activity-body')).toHaveCount(0); + await expect(component.locator('.overlay-header')).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test('a component preload failure reaches the workspace announcement', async ({ page }) => { + await ready(page); + await page.route('**/_webui/lazy?**', route => route.fulfill({ status: 503, body: 'Unavailable' })); + await page.getByRole('button', { name: 'Recent activity', exact: true }).hover(); + await expect(page.getByRole('status').filter({ hasText: 'Preload was unavailable' })).toBeVisible(); + await page.unroute('**/_webui/lazy?**'); + await page.getByRole('button', { name: 'Recent activity', exact: true }).click(); + await expect(page.locator('lazy-popover .activity-body')).toBeVisible(); +}); + +test('resource and state requests respect the demo catalog base path', async ({ page }) => { + const prefix = '/lazy-ui/'; + const outsideBase: string[] = []; + await page.route('**/_webui/**', async route => { + outsideBase.push(route.request().url()); + await route.fulfill({ status: 404, body: 'Use the app base path.' }); + }); + await page.route('**/lazy-ui/**', async route => { + const url = new URL(route.request().url()); + url.pathname = '/' + url.pathname.slice(prefix.length); + const response = await route.fetch({ url: url.href }); + if (route.request().isNavigationRequest()) { + const html = await response.text(); + const base = //; + expect(html).toMatch(base); + await route.fulfill({ response, body: html.replace(base, ``) }); + } else { + await route.fulfill({ response }); + } + }); + await ready(page, prefix); + for (const [button, selector] of [ + ['Recent activity', '.activity-body'], + ['View settings', '.settings-body'], + ]) { + await page.getByRole('button', { name: button, exact: true }).click(); + await expect(page.locator(selector)).toBeVisible(); + await page.keyboard.press('Escape'); + } + expect(outsideBase).toEqual([]); +}); diff --git a/examples/app/lazy-ui/tsconfig.json b/examples/app/lazy-ui/tsconfig.json new file mode 100644 index 000000000..2f355cfee --- /dev/null +++ b/examples/app/lazy-ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/examples/app/lazy-ui/tsconfig.test.json b/examples/app/lazy-ui/tsconfig.test.json new file mode 100644 index 000000000..d953c58db --- /dev/null +++ b/examples/app/lazy-ui/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node", "@playwright/test"] + }, + "include": ["src", "tests", "playwright.config.ts"] +} diff --git a/examples/app/native-services/src/index.ts b/examples/app/native-services/src/index.ts index a0f23ad7b..3b357730b 100644 --- a/examples/app/native-services/src/index.ts +++ b/examples/app/native-services/src/index.ts @@ -1,4 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; import './native-services-app/native-services-app.js'; + +startRuntime(); diff --git a/examples/app/routes/package.json b/examples/app/routes/package.json index a20d90c94..b88aa638e 100644 --- a/examples/app/routes/package.json +++ b/examples/app/routes/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "build": "pnpm run build:deps && pnpm run build:client && pnpm run build:protocol", - "build:deps": "pnpm --filter @microsoft/webui --filter @microsoft/webui-framework --filter @microsoft/webui-router run build", + "build:deps": "pnpm --filter @microsoft/webui --filter @microsoft/webui-framework --filter @microsoft/webui-delivery run build", "build:protocol": "cargo run -p microsoft-webui-cli -- build ./src --plugin=webui --projection-manifest ./dist/webui-projection.json --out ./dist", "build:client": "node ../../build-client.mjs", "start:client": "pnpm run build:deps && node ../../build-client.mjs --watch", @@ -18,7 +18,7 @@ "devDependencies": { "@microsoft/webui": "workspace:*", "@microsoft/webui-framework": "workspace:*", - "@microsoft/webui-router": "workspace:*", + "@microsoft/webui-delivery": "workspace:*", "@playwright/test": "catalog:", "esbuild": "catalog:", "tslib": "catalog:", diff --git a/examples/app/routes/src/index.ts b/examples/app/routes/src/index.ts index 0bd5a2be6..e4212b700 100644 --- a/examples/app/routes/src/index.ts +++ b/examples/app/routes/src/index.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; /** * Routes example — nested routing with WebUI Framework. * @@ -10,7 +12,10 @@ * ./topics/:topicId → topic-page (lessons list) * ./lessons/:lessonId → lesson-page (lesson content) */ -import { Router } from '@microsoft/webui-router'; +import { WebUIRouter, routeBoundaries, viewTransitions } from '@microsoft/webui-delivery/router.js'; +import { ndjson } from '@microsoft/webui-delivery/resources.js'; + +const router = new WebUIRouter(); // Listen for the framework's global hydration-complete event. // NOTE: ES module imports are hoisted, so hydration may complete before @@ -21,7 +26,10 @@ function onHydrationComplete(): void { const total = performance.getEntriesByName('webui:hydrate:total', 'measure')[0]; console.log(`Hydration complete in ${total?.duration.toFixed(1)}ms`); - Router.start({ + router.start({ + transport: ndjson(), + boundaries: routeBoundaries, + transitions: viewTransitions, loaders: { 'section-page': () => import('./section-page/section-page.js'), 'topic-page': () => import('./topic-page/topic-page.js'), @@ -32,6 +40,8 @@ function onHydrationComplete(): void { // Side-effect imports — register custom elements and trigger hydration import './routes-app/routes-app.js'; +startRuntime(); + // Fallback: if hydration already completed before the listener, log now if (performance.getEntriesByName('webui:hydrate:total', 'measure').length > 0) { onHydrationComplete(); diff --git a/examples/app/streaming/src/index.ts b/examples/app/streaming/src/index.ts index 6449ba42a..434d04ecc 100644 --- a/examples/app/streaming/src/index.ts +++ b/examples/app/streaming/src/index.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; /** * Streaming priority-hydration entry point. * @@ -43,6 +45,8 @@ function logHydrationTiming(): void { import './message-composer/message-composer.js'; import './feed-item/feed-item.js'; +startRuntime(); + // Fallback: if hydration already completed before the listener attached, // log now instead of missing the event. if (performance.getEntriesByName('webui:hydrate:total', 'measure').length > 0) { diff --git a/examples/app/todo-webui/src/index.ts b/examples/app/todo-webui/src/index.ts index b60e19ef2..0529752ed 100644 --- a/examples/app/todo-webui/src/index.ts +++ b/examples/app/todo-webui/src/index.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; /** * Todo-webui entry point — bootstraps WebUI Framework hydration. * @@ -33,6 +35,8 @@ function logHydrationTiming(): void { import './todo-app/todo-app.js'; import './todo-item/todo-item.js'; +startRuntime(); + // Fallback: if hydration already completed before the listener, log now if (performance.getEntriesByName('webui:hydrate:total', 'measure').length > 0) { logHydrationTiming(); diff --git a/examples/demo/Dockerfile b/examples/demo/Dockerfile index fd8e91e31..585da2a29 100644 --- a/examples/demo/Dockerfile +++ b/examples/demo/Dockerfile @@ -46,7 +46,7 @@ COPY . . RUN pnpm install --frozen-lockfile # Build framework libraries first (needed by apps with build:deps) -RUN pnpm --filter @microsoft/webui-framework --filter @microsoft/webui-router run build +RUN pnpm --filter @microsoft/webui-framework --filter @microsoft/webui-delivery run build # Build each app's client assets RUN cd examples/app/hello-world && \ @@ -61,6 +61,7 @@ RUN cd examples/app/calculator && \ RUN cd examples/app/todo-webui && pnpm build RUN cd examples/app/contact-book-manager && pnpm build RUN cd examples/app/component-assets && pnpm build +RUN cd examples/app/lazy-ui && pnpm build RUN cd examples/app/routes && pnpm build RUN cd examples/app/commerce && pnpm build RUN cd examples/app/streaming && pnpm build @@ -154,6 +155,13 @@ COPY --from=builder --chown=app:app /build/examples/app/component-assets/dist/ . COPY --from=builder --chown=app:app /build/examples/app/component-assets/data/ ./apps/component-assets/data/ COPY --from=builder --chown=app:app /build/examples/app/component-assets/demo.toml ./apps/component-assets/demo.toml +# lazy-ui (Node state only; WebUI owns lazy resource/state delivery) +COPY --from=builder --chown=app:app /build/examples/app/lazy-ui/src/ ./apps/lazy-ui/src/ +COPY --from=builder --chown=app:app /build/examples/app/lazy-ui/dist/ ./apps/lazy-ui/dist/ +COPY --from=builder --chown=app:app /build/examples/app/lazy-ui/data/ ./apps/lazy-ui/data/ +COPY --from=builder --chown=app:app /build/examples/app/lazy-ui/server/api.mjs /build/examples/app/lazy-ui/server/index.mjs ./apps/lazy-ui/server/ +COPY --from=builder --chown=app:app /build/examples/app/lazy-ui/demo.toml ./apps/lazy-ui/demo.toml + # routes (has Node API) COPY --from=builder --chown=app:app /build/examples/app/routes/src/ ./apps/routes/src/ COPY --from=builder --chown=app:app /build/examples/app/routes/dist/ ./apps/routes/dist/ diff --git a/examples/demo/src/demo-shell/demo-shell.ts b/examples/demo/src/demo-shell/demo-shell.ts index b6d701a87..1ba18bade 100644 --- a/examples/demo/src/demo-shell/demo-shell.ts +++ b/examples/demo/src/demo-shell/demo-shell.ts @@ -85,7 +85,7 @@ export class DemoShell extends WebUIElement { } // Replace the iframe with a fresh element. The hosted apps install a - // Navigation-API listener (webui-router) that intercepts every navigation + // Navigation-API listener (webui-delivery) that intercepts every navigation // in their frame, including external `iframe.src` changes — leaving the // iframe stuck on the previous document. A fresh iframe has no router // installed yet, so the navigation completes normally. diff --git a/examples/demo/src/index.ts b/examples/demo/src/index.ts index e6ad6936d..ca4905abb 100644 --- a/examples/demo/src/index.ts +++ b/examples/demo/src/index.ts @@ -1,8 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; import './demo-shell/demo-shell.js'; +startRuntime(); + window.addEventListener('webui:hydration-complete', () => { // eslint-disable-next-line no-console console.log('[demo-shell] hydration complete'); diff --git a/examples/integration/lazy-ui-bench/README.md b/examples/integration/lazy-ui-bench/README.md new file mode 100644 index 000000000..5e9ec15d2 --- /dev/null +++ b/examples/integration/lazy-ui-bench/README.md @@ -0,0 +1,257 @@ +# Lazy template delivery benchmark + +Compare `` and `` using the real compiler, production-minified +browser bundles, and identical rendered controls. This is not a benchmark of +`w-hydrate="lazy"` or `w-render="lazy"`. + +From the repository root on Windows: + +```powershell +cargo build --release -p microsoft-webui-node +pnpm --filter @microsoft/webui build +$env:WEBUI_ADDON_PATH = Join-Path (Get-Location) 'target\release\webui_node.dll' +node examples\integration\lazy-ui-bench\bench.mjs +cargo bench -p microsoft-webui --bench lazy_bench +``` + +On macOS/Linux point `WEBUI_ADDON_PATH` at the freshly built release +`libwebui_node.dylib` / `libwebui_node.so`. Playwright Chromium must be installed. + +The browser run writes `target/lazy-browser-proof.json`, including every sample, +environment, median, and p90. `WEBUI_BENCH_SAMPLES` defaults to 20 (plus two +discarded warmups); `WEBUI_BENCH_LATENCY_MS` defaults to 40 ms of state-provider +delay. Set it to zero to isolate local processing. An optional argument changes +the output JSON path. + +Three cases expose the trade-off rather than hiding it: + +- **eager-local**: ordinary ``, templates and state already local; no open request. +- **eager-fresh**: ordinary ``, templates local, fresh state fetched on every open. +- **lazy**: ``, metadata and fresh state fetched together; warm inventory + suppresses metadata on later opens. + +All cases use the same core renderer and optional template collection. Only lazy +loads the delivery/renderer adapters. Total compressed startup bytes include those +extra modules, not just the smaller SSR document. Each sample checks every rendered +label/input value and confirms closed bodies have no DOM. + +Fresh browser contexts alternate execution order. Heap readings follow forced GC; +closed-body weak-reference results are reported separately. The live ordinary owner +must retain its eager template, whereas an unused lazy body can be collected. +These measurements describe this synthetic workload, not universal latency or +whole-process memory savings. A small body may cost more because of loader code; +cold lazy opening cannot beat an already-local body over a network. + +## Recorded result: 2026-10-21 + +Windows 10.0.26300, Intel Core Ultra 9 285K, Node 24.12.0, Chromium 153.0.8010.12, +esbuild 0.28.2. Release native addon; 20 browser samples per case, alternating +order, two discarded warmups. Values below are medians unless described otherwise. + +For 1,000 identical controls, comparing ordinary `` with fresh-state requests +against ``: + +| Measurement | Eager-fresh | Lazy | Difference | +| --- | ---: | ---: | ---: | +| Initial HTML, raw bytes | 69,139 | 889 | -98.7% | +| Total startup HTML + JS, gzip bytes | 29,856 | 26,243 | -12.1% | +| Definition + hydration activation | 3.50 ms | 1.80 ms | -48.6% | +| Initial JS heap after GC | 1,983,400 bytes | 1,871,832 bytes | -5.6% | +| JS heap after opening, closing, and collecting | 2,134,140 bytes | 2,051,136 bytes | -3.9% | +| Closed body metadata still retained | 20/20 runs | 0/20 runs | Owner remains connected | +| Cold open, with 40 ms provider delay | 50.50 ms | 53.00 ms | +2.50 ms | +| Warm open, with 40 ms provider delay | 55.40 ms | 51.40 ms | -4.00 ms | + +Activation p90 was 3.80/2.10 ms; cold-open p90 was 60.10/62.10 ms. Warm lazy +responses contained 278 raw bytes and no templates, versus 67,726 raw bytes for +the first lazy response. Timing spreads are in the JSON; the warm-open result +is not a universal speed guarantee. + +The 0 ms provider-delay repeat showed the same 1,000-control startup bytes and +heap. Cold open was 14.0 ms eager-fresh versus 17.8 ms lazy; warm open was +11.3/11.1 ms. Eager-local, which performs no data request, opened in 3.4 ms with +the default run. Lazy delivery is a startup/memory trade-off, not free I/O. + +Small bodies do not necessarily win: total gzip startup was **24,118 bytes eager +versus 26,242 lazy at 250 controls**, and **22,412 versus 26,242 at 25 controls**. +The optional loader cost is included. Prefer ordinary `` for small, +frequently/immediately used UI; lazy helps sufficiently large, initially unused +UI, particularly when the delivery runtime is already needed. + +The release Rust benchmark uses 250 controls, 100 Criterion samples, two seconds +of warmup, and five seconds of measurement per case. Plain rendered HTML is +asserted equal for both open and closed states: + +| Measurement | Eager | Lazy | +| --- | ---: | ---: | +| Closed SSR bytes | 17,544 | 825 | +| Closed SSR median (95% CI) | 7.025 us (6.980-7.089) | 3.512 us (3.490-3.545) | +| Initially open SSR median (95% CI) | 14.393 us (14.196-14.809) | 15.307 us (15.233-15.459) | + +Closed SSR is about 50% faster here, but initially open SSR is about 6.4% slower. +Renaming `defer` to `lazy` itself provides no speedup. No server allocation-count +or whole-renderer/RSS reduction is claimed; the memory figures above are JS heap. + +## UI responsiveness + +Run the separate interactive fixture with the same release addon: + +```powershell +node --test examples\integration\lazy-ui-bench\responsiveness.test.mjs +node examples\integration\lazy-ui-bench\responsiveness.mjs +``` + +Output: `target/lazy-responsiveness.json`, including individual trusted events, +all trials, median/p95, input delay, long tasks, and frame gaps. The defaults are +20 measured contexts per case after two warmups, 250/1,000 controls, native and +4x CPU throttling, and the same 40 ms state-provider delay. + +This fixture is a fixed-column grid of native inputs in a scrollable section, +without virtualization or `content-visibility`. All controls exist after opening. +Its toolbar, CSS, and interactivity are opt-in; the original startup benchmark's +HTML/JS/gzip sizes remain unchanged for all nine cases. + +The driver sends real browser mouse clicks and keyboard input, and checks +`Event.isTrusted`. It measures cold opening, another shell click while data is +pending, five single-field edits with a reactive preview, five edits broadcasting +to every value binding, closing, and warm reopening. Output checks prove that +single-field edits leave the other controls untouched and broadcast edits update +every control. There is no forced GC during interaction measurements. + +**Measurement boundaries matter:** + +- Browser **Event Timing** measures click/keydown to the next paint, with 8 ms + quantization and a 16 ms reporting threshold. Unreported fast events are + retained as `<16 ms`, not omitted or replaced with zero. This is a laboratory + interaction measurement, **not field INP**. +- Opening can paint a loading state before the form exists. Its asynchronous + **form-readiness** measurement therefore runs separately, from the click + timestamp through DOM readiness and two animation frames. It is a render- + opportunity proxy, not an assertion of the exact pixel-presentation time. +- Long Tasks and rAF gaps expose blocking during later mounting. A responsive + loading indicator does not prove a jank-free form mount. +- 4x CPU is a reproducible simulation on this desktop, not evidence from a + physical low-end device. Typing uses 100 keystrokes per case across 20 contexts; + the five keystrokes within each context are not independent page loads. + +### Recorded interaction result: 2026-10-21 + +Same software/hardware as above. Values are **median / p95 milliseconds**. +Eager-fresh and lazy both fetch fresh state on each opening. + +| CPU | Controls | Interaction | Eager-fresh next paint | Lazy next paint | +| --- | ---: | --- | ---: | ---: | +| Native | 250 | Single-field typing | <16 / 16 | <16 / 16 | +| Native | 1,000 | Single-field typing | <16 / 16 | <16 / 16 | +| 4x | 250 | Single-field typing | 24 / 24 | 24 / 24 | +| 4x | 1,000 | Single-field typing | 32 / 40 | 32 / 40 | +| Native | 250 | Whole-form typing | 16 / 16 | 16 / 16 | +| Native | 1,000 | Whole-form typing | 16 / 16 | 16 / 24 | +| 4x | 250 | Whole-form typing | 32 / 48 | 32 / 56 | +| 4x | 1,000 | Whole-form typing | 96 / 152 | 104 / 168 | + +Both fresh-state modes painted the open-button feedback at p95 16 ms natively +and 24 ms at 4x CPU for both counts. The shell click actually occurred while +loading in 20/20 cases for each fresh-state mode/count/CPU combination; it had +no observed long task. This measures input while awaiting data, not input +deliberately injected into the later mount task. + +Cold **form-ready two-rAF proxy**, including the 40 ms provider delay: + +| CPU | Controls | Eager-fresh | Lazy | +| --- | ---: | ---: | ---: | +| Native | 250 | 76.4 / 86.5 ms | 71.8 / 84.6 ms | +| Native | 1,000 | 77.4 / 88.5 ms | 80.4 / 91.9 ms | +| 4x | 250 | 115.3 / 136.7 ms | 127.4 / 152.3 ms | +| 4x | 1,000 | 225.8 / 370.1 ms | 250.7 / 346.9 ms | + +At 4x CPU, both modes encountered long tasks in **all 20 cold opens** of 1,000 +controls: maximum 262 ms eager-fresh and 233 ms lazy, with maximum rAF gaps +300.1/283.3 ms. Whole-form typing encountered long tasks in **97/100 keystrokes** +in both modes. At native speed neither fresh-state mode observed a long task in +these measured actions. The JSON also records warm opening and closing. + +**Conclusion:** lazy delivery postpones work and reduces startup/retention costs; +it does not make the mounted control tree intrinsically faster. A 1,000-control +mount or global update can still visibly stall a CPU-constrained browser. +Single-field edits are much cheaper than broadcast updates. These measurements +do not establish a steady-state responsiveness improvement from ``. + +## Delivery API refactor measurements + +The baseline for this comparison is commit `44192b58`. Both revisions used the +same current release native addon, Node/browser installation, 20 measured +samples after two warmups, and the existing 40 ms state-provider delay. +Baseline JavaScript was built from an isolated archive, without replacing the +working tree. The refactor changes browser APIs and installation, not the +native rendering algorithm. + +Public-import tests now bundle the actual package exports and inspect every +emitted chunk. They also run against clean packed packages in an isolated +consumer directory. Total compressed JavaScript: + +| Public composition | Before gzip | After gzip | +| --- | ---: | ---: | +| Query helper only | 17,668 B | 175 B | +| Decorator only | 26,114 B | 1,020 B | +| Explicit JSON router | 10,350 B | 10,986 B | +| JSON lazy loader | 4,281 B | 4,609 B | +| JSON router plus lazy loader | 12,142 B | 12,732 B | + +Pure helper imports no longer retain a renderer or router. The package's +normal routing path now selects JSON only rather than retaining the previous +all-capability convenience graph. That change is not a like-for-like feature +comparison. The already-lean JSON paths grew modestly while acquiring the +common request/transport contract; no universal bundle reduction is claimed. +No existing streaming or component-asset budget was increased. + +For the **lazy** mode in the actual UI fixture: + +| Controls | Startup HTML + JS gzip, before / after | Cold open median, before / after | Warm open median, before / after | +| --- | ---: | ---: | ---: | +| 25 | 25,635 / 25,982 B | 52.5 / 54.1 ms | 55.6 / 45.3 ms | +| 250 | 25,636 / 25,983 B | 52.2 / 55.8 ms | 51.2 / 48.3 ms | +| 1,000 | 25,636 / 25,983 B | 55.0 / 57.3 ms | 47.3 / 54.7 ms | + +Initial post-GC JS heap was 1,862,204 / 1,863,976 bytes. Closed lazy metadata +was collectible in every sample while the owner remained alive. Small bodies +still do not amortize the loader's fixed cost; ordinary `` remains the +appropriate baseline for them. Cold/open timings also varied in the unchanged +workload of the eager controls, so these runs do not isolate a causal latency change. + +Within the final build, lazy loading adds 4,152 startup gzip bytes versus +`eager-fresh` at 25 controls. At 1,000 controls it instead saves 3,327 startup +gzip bytes and 116,432 initial post-GC JS heap bytes. This is the benefit of +omitting a large inactive body, not a speedup caused by renaming the APIs. + +Trusted-input p95 keydown-to-next-paint for lazy UI: + +| CPU | Controls | Single-field typing, before / after | Whole-form typing, before / after | +| --- | ---: | ---: | ---: | +| Native | 250 | 16 / 16 ms | 16 / 16 ms | +| Native | 1,000 | 16 / 16 ms | 24 / 16 ms | +| 4x | 250 | 32 / 24 ms | 72 / 40 ms | +| 4x | 1,000 | 40 / 32 ms | 160 / 104 ms | + +At 4x CPU, 1,000-control cold opens still produced long tasks in all 20 samples. +Whole-form typing produced them in 85 of 100 measured keystrokes after the +refactor. Eager-control timings improved in that run too; this is not proof +that API consolidation speeds up mounted-tree updates. Event Timing remains +quantized/censored, and two-rAF form readiness remains a proxy, not field INP. + +For those 1,000-control throttled cold opens, median two-rAF readiness was +248.7 / 233.9 ms. The shell click sampled while the state request was pending +had a 16 ms p95 before and after; that does not measure input arriving during +the later long mounting task. + +The 100-distinct-component correctness probe was repeated: an inactive lazy +wrapper delivered none of those component templates, while the resource +response and initially active SSR contained all 100. Its initial document was +870 bytes (519 gzip), versus 177,414 bytes (10,475 gzip) for ordinary ``. + +Raw evidence is written under `target`: +`delivery-public-audit.json`, `delivery-public-after.json`, +`delivery-packed-public.json`, `delivery-startup-before.json`, +`delivery-startup-after.json`, `delivery-responsiveness-before.json`, +`delivery-responsiveness-after.json`, and `lazy-unique-components-proof.json`. +These generated measurements and package archives are not committed. diff --git a/examples/integration/lazy-ui-bench/bench.mjs b/examples/integration/lazy-ui-bench/bench.mjs new file mode 100644 index 000000000..40b57c12c --- /dev/null +++ b/examples/integration/lazy-ui-bench/bench.mjs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import assert from 'node:assert/strict'; +import { writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { cpus, platform, release } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { esbuildVersion, modes, startFixtures } from './fixture.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const require = createRequire(join(root, 'package.json')); +const { chromium } = require('@playwright/test'); +const samples = Number(process.env.WEBUI_BENCH_SAMPLES ?? 20); +const latencyMs = Number(process.env.WEBUI_BENCH_LATENCY_MS ?? 40); +const counts = [25, 250, 1000]; +assert.ok(Number.isSafeInteger(samples) && samples >= 3 && samples <= 100); +assert.ok(Number.isFinite(latencyMs) && latencyMs >= 0 && latencyMs <= 1000); +assert.ok(process.env.WEBUI_ADDON_PATH, 'Set WEBUI_ADDON_PATH to the freshly built release addon.'); + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + const p90 = sorted[Math.ceil(sorted.length * 0.9) - 1]; + return { median, p90, min: sorted[0], max: sorted.at(-1), samples: values }; +} + +async function heap(session) { + await session.send('HeapProfiler.collectGarbage'); + return (await session.send('Runtime.getHeapUsage')).usedSize; +} + +async function correctness(page, count) { + const content = await page.locator('bench-owner').evaluate(element => { + const inputs = element.shadowRoot.querySelectorAll('input'); + return { + count: inputs.length, + values: Array.from(inputs, input => input.value), + labels: Array.from(element.shadowRoot.querySelectorAll('label'), label => label.textContent), + }; + }); + assert.equal(content.count, count); + assert.deepEqual(content.values, Array(count).fill('Fresh')); + assert.deepEqual(content.labels, Array.from({ length: count }, (_, i) => `Setting ${i}`)); +} + +const server = await startFixtures(counts, latencyMs); +let browser; +const rows = new Map(); +try { + browser = await chromium.launch(); + for (let iteration = -2; iteration < samples; iteration++) { + const order = iteration % 2 === 0 ? modes : [...modes].reverse(); + for (const count of counts) for (const mode of order) { + const context = await browser.newContext(); + const page = await context.newPage(); + const session = await context.newCDPSession(page); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + const responses = []; + page.on('response', response => { + if (response.url().endsWith('/state')) responses.push(response); + }); + try { + await session.send('Performance.enable'); + const base = `/${mode}/${count}/`; + await page.goto(server.origin + base); + await page.waitForFunction(() => window.bench && document.querySelector('bench-owner').$ready); + assert.equal(await page.locator('bench-owner input').count(), 0); + const activationMs = await page.evaluate(() => window.bench.activationMs); + const before = await heap(session); + const openMs = await page.evaluate(() => window.bench.open()); + await correctness(page, count); + await page.evaluate(() => window.bench.track()); + const openHeap = await heap(session); + await page.evaluate(() => { window.bench.close(); window.bench.collect(); }); + assert.equal(await page.locator('bench-owner input').count(), 0); + const collectedHeap = await heap(session); + const retained = await page.evaluate(() => window.bench.retained()); + // Collection deliberately invalidates inventory; the next open is cold again. + await page.evaluate(() => window.bench.open()); + await correctness(page, count); + await page.evaluate(() => window.bench.close()); + const warmMs = await page.evaluate(() => window.bench.open()); + await correctness(page, count); + const bodies = await Promise.all(responses.map(response => response.body())); + if (mode === 'lazy') { + assert.equal(responses.length, 3); + const cold = JSON.parse(bodies[0]); + const warm = JSON.parse(bodies[2]); + assert.ok(cold.templates?.['bench-owner::lazy:panel']); + assert.equal(warm.templates, undefined); + assert.equal(warm.state.value, 'Fresh'); + } + assert.deepEqual(errors, []); + if (iteration >= 0) { + let row = rows.get(base); + if (!row) { + const fixture = server.fixtures.get(base); + row = { + mode, controls: count, + initialHtmlBytes: fixture.document.length, + initialJsBytes: fixture.script.length, + initialGzipBytes: fixture.gzipDocument.length + fixture.gzipScript.length, + coldResponseBytes: bodies[0]?.length ?? 0, + warmResponseBytes: bodies[2]?.length ?? 0, + activationMs: [], initialHeapBytes: [], openMs: [], openHeapBytes: [], + collectedHeapBytes: [], warmMs: [], closedBodyRetained: [], + }; + rows.set(base, row); + } + row.activationMs.push(activationMs); + row.initialHeapBytes.push(before); + row.openMs.push(openMs); + row.openHeapBytes.push(openHeap); + row.collectedHeapBytes.push(collectedHeap); + row.warmMs.push(warmMs); + row.closedBodyRetained.push(retained); + } + } finally { + await session.detach(); + await context.close(); + } + } + } + const results = Array.from(rows.values(), row => { + const result = { ...row }; + for (const key of ['activationMs', 'initialHeapBytes', 'openMs', 'openHeapBytes', 'collectedHeapBytes', 'warmMs']) { + result[key] = stats(row[key]); + } + return result; + }); + const report = { + environment: { + node: process.version, chromium: browser.version(), esbuild: esbuildVersion, + os: `${platform()} ${release()}`, cpu: cpus()[0].model, + samples, discardedWarmups: 2, stateDelayMs: latencyMs, + addon: process.env.WEBUI_ADDON_PATH, + }, + methodology: 'Fresh browser contexts, gzip HTML/JS, real compiler/protocol, production-minified bundles. Alternating mode order, 2 warmups, forced-GC heap samples. Eager-local keeps initial state; eager-fresh and lazy both fetch fresh state with identical provider delay. No physical low-end-device claim.', + results, + }; + const output = resolve(process.argv[2] ?? join(root, 'target', 'lazy-browser-proof.json')); + await writeFile(output, JSON.stringify(report, null, 2) + '\n'); + console.table(results.map(row => ({ + mode: row.mode, controls: row.controls, initialGzip: row.initialGzipBytes, + heap: row.initialHeapBytes.median, activateMs: row.activationMs.median, + openMs: row.openMs.median, warmMs: row.warmMs.median, + closedHeap: row.collectedHeapBytes.median, + retainedRuns: row.closedBodyRetained.filter(Boolean).length, + }))); + console.log(output); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/examples/integration/lazy-ui-bench/fixture.mjs b/examples/integration/lazy-ui-bench/fixture.mjs new file mode 100644 index 000000000..5789c98fd --- /dev/null +++ b/examples/integration/lazy-ui-bench/fixture.mjs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { build, Protocol } from '../../../packages/webui/dist/index.js'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const require = createRequire(join(root, 'packages', 'webui-test-support', 'package.json')); +const { build: bundle, version: esbuildVersion } = require('esbuild'); +export { esbuildVersion }; +export const modes = ['eager-local', 'eager-fresh', 'lazy']; + +function entry(mode, interactive = false) { + const source = file => JSON.stringify(join(root, 'packages', 'webui-framework', 'src', file)); + return ` +import { collectTemplates } from ${source('template-cache-entry.ts')}; +import { startRuntime } from ${source('runtime.ts')}; +import { WebUIElement, observable } from ${source('index.ts')}; +${interactive ? `import ${source('styles-entry.ts')};` : ''} +${mode === 'lazy' ? ` +import ${source('lazy-entry.ts')}; +import { createLazyLoader } from ${JSON.stringify(join(root, 'packages', 'webui-delivery', 'src', 'lazy-entry.ts'))}; +const loader = createLazyLoader({ endpoint: new URL('resources', document.baseURI).href });` : ''} +startRuntime(); +class BenchOwner extends WebUIElement { + @observable shown = false; + @observable value = 'Fresh'; + ${interactive ? ` + @observable loading = false; + @observable status = 'Ready'; + @observable shellCount = 0; + @observable broadcast = false; + @observable editedValue = ''; + async onOpen(event) { + const action = window.uiProbe.begin(event, this.loading); + this.loading = true; + this.status = 'Loading'; + await this.open(); + this.loading = false; + this.status = 'Ready'; + this.$flushUpdates(); + window.uiProbe.finish(action); + } + onShell(event) { + const action = window.uiProbe.begin(event, this.loading); + this.shellCount++; + queueMicrotask(() => window.uiProbe.finish(action)); + } + onKeydown(event) { + if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + this.inputAction = window.uiProbe.begin(event, this.loading); + } + } + onInput(event) { + this.editedValue = event.currentTarget.value; + if (this.broadcast) this.value = this.editedValue; + const action = this.inputAction; + this.inputAction = undefined; + if (action) queueMicrotask(() => window.uiProbe.finish(action)); + } + changeBroadcast(event) { this.broadcast = event.currentTarget.checked; } + onClose(event) { + const action = window.uiProbe.begin(event, this.loading); + this.close(); + this.status = 'Closed'; + this.$flushUpdates(); + window.uiProbe.finish(action); + }` : ''} + async open() { + const start = performance.now(); + ${mode === 'lazy' + ? "this.setState(await loader.fetchPartial('bench-owner', 'panel', { url: new URL('state', document.baseURI).href }));" + : mode === 'eager-fresh' + ? "const response = await fetch(new URL('state', document.baseURI)); if (!response.ok) throw new Error('State request failed'); this.setState(await response.json());" + : ''} + this.shown = true; + this.$flushUpdates(); + return performance.now() - start; + } + close() { this.shown = false; this.$flushUpdates(); } +} +const started = performance.now(); +BenchOwner.define('bench-owner'); +window.bench = { + activationMs: performance.now() - started, + open: () => document.querySelector('bench-owner').open(), + close: () => document.querySelector('bench-owner').close(), + collect: collectTemplates, + track() { + const templates = window.__webui.templates; + this.observed = new WeakRef(${mode === 'lazy' ? "templates['bench-owner::lazy:panel']" : "templates['bench-owner'].b[0]"}); + }, + retained() { return this.observed.deref() !== undefined; }, +}; +`; +} + +export async function startFixtures(counts, latencyMs, interactive = false) { + const scratch = await mkdtemp(join(tmpdir(), 'webui-lazy-bench-')); + const fixtures = new Map(); + const initial = { shown: false, value: 'Fresh' }; + if (interactive) Object.assign(initial, { + loading: false, status: 'Ready', shellCount: 0, broadcast: false, editedValue: '', + }); + try { + if (interactive) await writeFile(join(scratch, 'bench-owner.css'), ` + :host { display: block; max-width: 1080px; margin: auto; font: 14px system-ui; } + .toolbar { display: flex; align-items: center; gap: 12px; } + .edit-preview { display: inline-block; width: 180px; overflow: hidden; white-space: nowrap; } + section { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 16px; max-height: 640px; overflow: auto; } + section label { display: flex; flex-direction: column; } + section input { box-sizing: border-box; width: 100%; min-width: 0; } + `); + for (const mode of modes) { + const javascript = await bundle({ + stdin: { contents: entry(mode, interactive), loader: 'ts', resolveDir: root }, + bundle: true, write: false, minify: true, format: 'iife', platform: 'browser', + target: 'es2022', tsconfig: join(root, 'tsconfig.json'), + define: { __WEBUI_DEV__: 'false' }, legalComments: 'none', + }); + const script = Buffer.from(javascript.outputFiles[0].contents); + for (const count of counts) { + const base = `/${mode}/${count}/`; + const html = ``; + const body = Array.from({ length: count }, (_, index) => + ``).join(''); + const toolbar = interactive + ? '
{{shellCount}}{{editedValue}}

{{status}}

' + : ''; + const template = `

Shell

${toolbar}
${body}
`; + await writeFile(join(scratch, 'index.html'), html); + await writeFile(join(scratch, 'bench-owner.html'), template); + await writeFile(join(scratch, 'bench-owner.ts'), entry(mode, interactive)); + const built = build({ appDir: scratch, plugin: 'webui', ...(interactive ? { css: 'style' } : {}) }); + const protocol = new Protocol(built.protocol, { plugin: 'webui' }); + const document = Buffer.from(protocol.render(initial, { entry: 'index.html' })); + fixtures.set(base, { + protocol, document, script, gzipDocument: gzipSync(document), gzipScript: gzipSync(script), + mode, count, + }); + } + } + } finally { + await rm(scratch, { recursive: true, force: true }); + } + const failures = []; + const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url, 'http://127.0.0.1'); + const segments = url.pathname.split('/'); + const fixture = fixtures.get(`/${segments[1]}/${segments[2]}/`); + if (!fixture) { response.writeHead(404).end(); return; } + const resource = segments[3]; + let bytes, type; + if (!resource) { bytes = fixture.gzipDocument; type = 'text/html'; } + else if (resource === 'app.js') { bytes = fixture.gzipScript; type = 'text/javascript'; } + else { + const options = { + inventory: request.headers['x-webui-inventory'] ?? '', + styleInventory: request.headers['x-webui-style-inventory'] ?? '', + buildId: request.headers['x-webui-build'] ?? '', + }; + let json; + if (resource === 'resources') { + json = fixture.protocol.renderLazyResources('bench-owner', 'panel', options); + } else if (resource === 'state') { + await delay(latencyMs); + json = fixture.mode === 'lazy' + ? fixture.protocol.renderLazyPartial({ value: 'Fresh' }, 'bench-owner', 'panel', options) + : JSON.stringify({ value: 'Fresh' }); + } else { response.writeHead(404).end(); return; } + bytes = gzipSync(json); + type = 'application/json'; + } + response.writeHead(200, { + 'Content-Type': type, 'Content-Encoding': 'gzip', + 'Content-Length': bytes.length, 'Cache-Control': 'no-store', + }); + response.end(bytes); + })().catch(error => { + failures.push(error); + console.error(error); + response.writeHead(500).end(String(error)); + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + return { + fixtures, origin: `http://127.0.0.1:${server.address().port}`, + async close() { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections(); + }); + assert.deepEqual(failures, [], 'all fixture requests must succeed'); + }, + }; +} diff --git a/examples/integration/lazy-ui-bench/responsiveness-metrics.mjs b/examples/integration/lazy-ui-bench/responsiveness-metrics.mjs new file mode 100644 index 000000000..9d8306cb5 --- /dev/null +++ b/examples/integration/lazy-ui-bench/responsiveness-metrics.mjs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** Serialized into the benchmark page; it intentionally has no outer dependencies. */ +export function installResponsivenessProbe() { + const supported = PerformanceObserver.supportedEntryTypes; + if (!supported.includes('event') || !supported.includes('longtask')) { + throw new Error('Event Timing and Long Tasks support are required.'); + } + const events = []; + const tasks = []; + const frames = []; + const actions = []; + const eventSink = records => { + for (const entry of records) events.push({ + name: entry.name, start: entry.startTime, duration: entry.duration, + processingStart: entry.processingStart, processingEnd: entry.processingEnd, + interactionId: entry.interactionId, + }); + }; + const taskSink = records => { + for (const entry of records) tasks.push({ start: entry.startTime, duration: entry.duration }); + }; + const eventObserver = new PerformanceObserver(list => eventSink(list.getEntries())); + const taskObserver = new PerformanceObserver(list => taskSink(list.getEntries())); + eventObserver.observe({ type: 'event', durationThreshold: 16, buffered: true }); + taskObserver.observe({ type: 'longtask', buffered: true }); + let previous; + function frame(timestamp) { + if (previous !== undefined) frames.push({ start: previous, duration: timestamp - previous }); + previous = timestamp; + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + let phase; + window.uiProbe = { + actions, + arm(name) { phase = name; }, + begin(event, pending) { + if (!phase || !event.isTrusted) throw new Error('A named, trusted input event is required.'); + const action = { + name: phase, event: event.type, start: event.timeStamp, + handlerStart: performance.now(), trusted: event.isTrusted, pending, + }; + phase = undefined; + actions.push(action); + return action; + }, + finish(action) { + action.domReady = performance.now(); + requestAnimationFrame(() => requestAnimationFrame(() => { + action.nextFrame = performance.now(); + action.complete = true; + })); + }, + read() { + eventSink(eventObserver.takeRecords()); + taskSink(taskObserver.takeRecords()); + return actions.map(action => { + const timing = events.find(entry => + entry.name === action.event && Math.abs(entry.start - action.start) < 1); + const overlaps = entry => + entry.start < action.nextFrame && entry.start + entry.duration > action.start; + const phaseTasks = tasks.filter(overlaps); + const phaseFrames = frames.filter(entry => + entry.start >= action.start && entry.start < action.nextFrame); + return { + name: action.name, trusted: action.trusted, pendingAtInput: action.pending, + eventTimestamp: action.start, + inputDelayMs: action.handlerStart - action.start, + domReadyMs: action.domReady - action.start, + twoRafReadyMs: action.nextFrame - action.start, + eventTimingMs: timing?.duration ?? null, + eventTiming: timing ?? null, + maxLongTaskMs: Math.max(0, ...phaseTasks.map(entry => entry.duration)), + longTaskCount: phaseTasks.length, + maxFrameGapMs: Math.max(0, ...phaseFrames.map(entry => entry.duration)), + frameGapsOver50Ms: phaseFrames.filter(entry => entry.duration > 50).length, + }; + }); + }, + }; +} + +/** Missing Event Timing entries are censored below 16 ms, never silently dropped or zero-filled. */ +export function summarize(values, censorBelowMs) { + if (!values.length) throw new Error('Cannot summarize an empty sample.'); + if (values.some(value => value !== null && (!Number.isFinite(value) || value < 0))) { + throw new Error('Samples must be nonnegative finite numbers.'); + } + if (values.includes(null) && !(censorBelowMs > 0)) throw new Error('Missing samples need a censor bound.'); + const ordered = values.map(value => ({ + value: value ?? censorBelowMs, censored: value === null, + })).sort((a, b) => a.value - b.value || Number(b.censored) - Number(a.censored)); + const quantile = fraction => ordered[Math.ceil(ordered.length * fraction) - 1]; + return { + count: values.length, belowThreshold: values.filter(value => value === null).length, + median: quantile(0.5), p95: quantile(0.95), worst: ordered.at(-1), + }; +} diff --git a/examples/integration/lazy-ui-bench/responsiveness.mjs b/examples/integration/lazy-ui-bench/responsiveness.mjs new file mode 100644 index 000000000..45ade47ce --- /dev/null +++ b/examples/integration/lazy-ui-bench/responsiveness.mjs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import assert from 'node:assert/strict'; +import { writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { cpus, platform, release } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { esbuildVersion, modes, startFixtures } from './fixture.mjs'; +import { installResponsivenessProbe, summarize } from './responsiveness-metrics.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const require = createRequire(join(root, 'package.json')); +const { chromium } = require('@playwright/test'); +const samples = Number(process.env.WEBUI_BENCH_SAMPLES ?? 20); +const latencyMs = Number(process.env.WEBUI_BENCH_LATENCY_MS ?? 40); +const counts = [250, 1000]; +const cpuRates = [1, 4]; +assert.ok(Number.isSafeInteger(samples) && samples >= 3 && samples <= 100); +assert.ok(Number.isFinite(latencyMs) && latencyMs >= 0 && latencyMs <= 1000); +assert.ok(process.env.WEBUI_ADDON_PATH, 'Select the current release native addon.'); + +async function click(page, point, phase) { + await page.evaluate(name => window.uiProbe.arm(name), phase); + await page.mouse.click(point.x + point.width / 2, point.y + point.height / 2); +} + +async function finished(page, phase) { + await page.waitForFunction(name => window.uiProbe.actions.some(action => + action.name === name && action.complete), phase); +} + +async function checkInputs(page, count, value) { + const values = await page.locator('bench-owner section input').evaluateAll(inputs => inputs.map(input => input.value)); + assert.equal(values.length, count); + assert.ok(values.every(actual => actual === value), 'the reactive update must reach every control'); +} + +async function run(browser, origin, mode, count, cpuRate) { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + const session = await context.newCDPSession(page); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + try { + await session.send('Emulation.setCPUThrottlingRate', { rate: cpuRate }); + await page.addInitScript(installResponsivenessProbe); + await page.goto(`${origin}/${mode}/${count}/`); + await page.waitForFunction(() => window.bench && document.querySelector('bench-owner').$ready); + const open = await page.getByRole('button', { name: 'Open panel', exact: true }).boundingBox(); + const shell = await page.getByRole('button', { name: 'Shell click', exact: true }).boundingBox(); + const close = await page.getByRole('button', { name: 'Close panel', exact: true }).boundingBox(); + assert.ok(open && shell && close); + await click(page, open, 'cold-open'); + await click(page, shell, 'shell-during-open'); + await finished(page, 'cold-open'); + await finished(page, 'shell-during-open'); + await checkInputs(page, count, 'Fresh'); + assert.equal(await page.locator('bench-owner .shell-count').textContent(), '1'); + + const input = page.locator('bench-owner section input').first(); + await input.click(); + await input.press('End'); + let value = 'Fresh'; + for (const character of ['a', 'b', 'c', 'd', 'e']) { + const phase = `typing-local-${character}`; + await page.evaluate(name => window.uiProbe.arm(name), phase); + await input.press(character); + await finished(page, phase); + value += character; + assert.equal(await input.inputValue(), value); + assert.equal(await page.locator('bench-owner .edit-preview').textContent(), value); + const untouched = await page.locator('bench-owner section input').evaluateAll(inputs => + inputs.slice(1).every(input => input.value === 'Fresh')); + assert.ok(untouched, 'single-field edits must not update other controls'); + } + await page.getByLabel('Broadcast edits').check(); + await input.click(); + await input.press('End'); + for (const character of ['a', 'b', 'c', 'd', 'e']) { + const phase = `typing-fanout-${character}`; + await page.evaluate(name => window.uiProbe.arm(name), phase); + await input.press(character); + await finished(page, phase); + value += character; + await checkInputs(page, count, value); + } + await click(page, close, 'close'); + await finished(page, 'close'); + await checkInputs(page, 0, ''); + await click(page, open, 'warm-open'); + await finished(page, 'warm-open'); + await checkInputs(page, count, mode === 'eager-local' ? value : 'Fresh'); + // Event Timing notifications may be delivered after the rendering callbacks. + await page.waitForTimeout(150); + const actions = await page.evaluate(() => window.uiProbe.read()); + assert.equal(actions.length, 14); + assert.ok(actions.every(action => action.trusted)); + assert.deepEqual(errors, []); + return actions; + } finally { + await session.detach(); + await context.close(); + } +} + +const server = await startFixtures(counts, latencyMs, true); +let browser; +const raw = []; +try { + browser = await chromium.launch(); + for (let iteration = -2; iteration < samples; iteration++) { + const order = iteration % 2 === 0 ? modes : [...modes].reverse(); + const rates = iteration % 2 === 0 ? cpuRates : [...cpuRates].reverse(); + for (const cpuRate of rates) for (const controls of counts) for (const mode of order) { + const actions = await run(browser, server.origin, mode, controls, cpuRate); + if (iteration >= 0) raw.push({ iteration, cpuRate, controls, mode, actions }); + } + } + const results = []; + for (const cpuRate of cpuRates) for (const controls of counts) for (const mode of modes) { + const trials = raw.filter(row => row.cpuRate === cpuRate && row.controls === controls && row.mode === mode); + for (const phase of ['cold-open', 'shell-during-open', 'typing-local', 'typing-fanout', 'close', 'warm-open']) { + const actions = trials.flatMap(trial => trial.actions.filter(action => + phase.startsWith('typing-') ? action.name.startsWith(`${phase}-`) : action.name === phase)); + results.push({ + cpuRate, controls, mode, phase, + events: summarize(actions.map(action => action.eventTimingMs), 16), + inputDelayMs: summarize(actions.map(action => action.inputDelayMs)), + domReadyMs: summarize(actions.map(action => action.domReadyMs)), + twoRafReadyMs: summarize(actions.map(action => action.twoRafReadyMs)), + maxFrameGapMs: summarize(actions.map(action => action.maxFrameGapMs)), + maxLongTaskMs: summarize(actions.map(action => action.maxLongTaskMs)), + interactionsWithLongTasks: actions.filter(action => action.longTaskCount > 0).length, + interactionsWithFrameGapsOver50Ms: actions.filter(action => action.frameGapsOver50Ms > 0).length, + pendingAtInput: actions.filter(action => action.pendingAtInput).length, + }); + } + } + const report = { + environment: { + node: process.version, chromium: browser.version(), esbuild: esbuildVersion, + os: `${platform()} ${release()}`, cpu: cpus()[0].model, + samples, discardedWarmups: 2, stateDelayMs: latencyMs, cpuRates, + }, + methodology: 'Trusted mouse clicks and keyboard events; single-field typing changes the native field and a reactive preview, while broadcast typing updates all 250/1000 value bindings as a separate stress case. Event Timing is browser-reported click/keydown-to-next-paint, quantized to 8 ms and censored below 16 ms. This is not field INP. Async form readiness uses a separate two-rAF render-opportunity proxy, not measured pixel presentation. Long tasks and rAF frame gaps are recorded through each action. No forced GC during interactions. CPU throttling is simulation, not a measured low-end device.', + results, trials: raw, + }; + const output = resolve(process.argv[2] ?? join(root, 'target', 'lazy-responsiveness.json')); + await writeFile(output, JSON.stringify(report, null, 2) + '\n'); + const value = item => `${item.censored ? '<' : ''}${item.value.toFixed(1)}`; + console.table(results.filter(row => row.phase !== 'close').map(row => ({ + cpu: row.cpuRate, controls: row.controls, mode: row.mode, action: row.phase, + eventP50: value(row.events.median), eventP95: value(row.events.p95), + readyFrameP50: value(row.twoRafReadyMs.median), + readyFrameP95: value(row.twoRafReadyMs.p95), + longTaskWorst: value(row.maxLongTaskMs.worst), + gapWorst: value(row.maxFrameGapMs.worst), + pending: row.pendingAtInput, + }))); + console.log(output); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/examples/integration/lazy-ui-bench/responsiveness.test.mjs b/examples/integration/lazy-ui-bench/responsiveness.test.mjs new file mode 100644 index 000000000..f474fcf9e --- /dev/null +++ b/examples/integration/lazy-ui-bench/responsiveness.test.mjs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { summarize } from './responsiveness-metrics.mjs'; + +test('fast unreported events retain a bound rather than inventing zero latency', () => { + const result = summarize([null, null, null, 16, 40], 16); + assert.deepEqual(result.median, { value: 16, censored: true }); + assert.deepEqual(result.p95, { value: 40, censored: false }); + assert.equal(result.belowThreshold, 3); + assert.equal(result.count, 5); +}); + +test('observed threshold values are distinct from censored values', () => { + assert.deepEqual(summarize([null, 16, 16], 16).median, { value: 16, censored: false }); + assert.deepEqual(summarize([8, 2, 4, 6]).median, { value: 4, censored: false }); +}); + +test('invalid measurements fail instead of looking like successful fast interactions', () => { + for (const values of [[], [NaN], [-1], [Infinity], [null]]) { + assert.throws(() => summarize(values)); + } +}); diff --git a/examples/integration/streaming-browser-bench/tests/lib/fixtures.ts b/examples/integration/streaming-browser-bench/tests/lib/fixtures.ts index 4bf73bf88..d95c982cf 100644 --- a/examples/integration/streaming-browser-bench/tests/lib/fixtures.ts +++ b/examples/integration/streaming-browser-bench/tests/lib/fixtures.ts @@ -5,8 +5,8 @@ * In-memory bundling of the *actual* framework sources into two minified * browser fixtures, plus their exact minified/gzip byte sizes. * - * The ordinary fixture imports `WebUIElement` from the framework default entry - * only; the streaming fixture imports the public streaming entry + * Both fixtures explicitly install rendering and managed styles. The ordinary + * fixture imports `WebUIElement`; the streaming fixture also imports the streaming entry * (`streaming-entry.ts`, side-effect coordinator install) before the default * entry. Both then define one instrumented `bench-island` subclass. Bundling * the real sources (not a synthetic clone) is the whole point: the benchmark @@ -150,10 +150,10 @@ window.__defineBenchIsland = function defineBenchIsland() { `; const ORDINARY_ENTRY = - `import { WebUIElement } from './index.js';\n${ISLAND_DEFINITION}`; + `import { startRuntime } from './runtime.js';\nimport './styles-entry.js';\nimport { WebUIElement } from './index.js';\nstartRuntime();\n${ISLAND_DEFINITION}`; const STREAMING_ENTRY = - `import './streaming-entry.js';\nimport { WebUIElement } from './index.js';\n${ISLAND_DEFINITION}`; + `import './streaming-entry.js';\n${ORDINARY_ENTRY}`; async function bundle(kind: Fixture['kind'], contents: string): Promise { const result = await build({ diff --git a/examples/integration/streaming-browser-bench/tests/lib/lazy-fixtures.ts b/examples/integration/streaming-browser-bench/tests/lib/lazy-fixtures.ts index deb7e432b..65acda197 100644 --- a/examples/integration/streaming-browser-bench/tests/lib/lazy-fixtures.ts +++ b/examples/integration/streaming-browser-bench/tests/lib/lazy-fixtures.ts @@ -34,7 +34,9 @@ export const ITEM_COUNTS = [10, 100, 1_000] as const; export type LazyBenchMode = 'eager' | 'lazy-hydrate' | 'lazy-render'; const TODO_TEMPLATE: TemplateMeta = { + v: 3, h: '
', + s: [1, 3, 15, 1, 1, 1, 1, 1, 1, 1], tr: ['title', 'description', 'priority', 'due'], tx: [ [[3, 0], [['title']]], @@ -55,7 +57,10 @@ function entrySource(mode: LazyBenchMode): string { ? '' : "import './lazy-hydration-entry.js';\n"; return ` -${optionalImport}import { WebUIElement } from './index.js'; +${optionalImport}import { startRuntime } from './runtime.js'; +import './styles-entry.js'; +import { WebUIElement } from './index.js'; +startRuntime(); function noteHydration(el, started) { const win = window; diff --git a/examples/integration/streaming-browser-bench/tests/lib/scenarios.ts b/examples/integration/streaming-browser-bench/tests/lib/scenarios.ts index 518872cee..04e7a0843 100644 --- a/examples/integration/streaming-browser-bench/tests/lib/scenarios.ts +++ b/examples/integration/streaming-browser-bench/tests/lib/scenarios.ts @@ -64,7 +64,9 @@ const BENCH_DECLARATION_ID = 0; * the hydration-mismatch check, so the SSR text need not equal the streamed state. */ export const ISLAND_TEMPLATE = { + v: 3, h: '', + s: [2, 5, 1, 1], tr: ['label', 'note'], tx: [ [[1, 0], [['label']]], diff --git a/package.json b/package.json index f1ea0e819..711cbdc62 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "build": "pnpm --filter @microsoft/webui --filter webui-framework --filter webui-router run build", + "build": "pnpm --filter @microsoft/webui --filter webui-framework --filter webui-delivery run build", "test": "echo \"Error: no test specified\" && exit 1" }, "engines": { diff --git a/packages/webui-delivery/README.md b/packages/webui-delivery/README.md new file mode 100644 index 000000000..10d8420b4 --- /dev/null +++ b/packages/webui-delivery/README.md @@ -0,0 +1,80 @@ +# @microsoft/webui-delivery + +Templates, styles, and fresh state for [WebUI](https://microsoft.github.io/webui) +navigation and lazy UI. Delivery never imports a renderer or starts routing on +import. + +```bash +npm install @microsoft/webui-delivery +``` + +| Entry | Use | +| --- | --- | +| `router.js` | Server-authoritative navigation and optional route policies | +| `lazy.js` | Preload resources and fetch fresh state for an owner-local `` | +| `resources.js` | Shared JSON/NDJSON transport and resource registration | + +## Routing + +```typescript +import { WebUIRouter } from '@microsoft/webui-delivery/router.js'; + +export const router = new WebUIRouter(); +router.start(); +``` + +The default requests JSON. Select and configure optional features once in +`start()`, not in the constructor: + +```typescript +import { + WebUIRouter, navigationCache, routeActions, routePreload, + routeBoundaries, viewTransitions, +} from '@microsoft/webui-delivery/router.js'; +import { ndjson } from '@microsoft/webui-delivery/resources.js'; + +const router = new WebUIRouter(); +router.start({ + transport: ndjson(), + cache: navigationCache({ maxEntries: 20, staleTime: 10_000 }), + actions: routeActions(), + preload: routePreload(), + boundaries: routeBoundaries, + transitions: viewTransitions, + loaders: { 'settings-page': () => import('./settings-page.js') }, +}); +``` + +Selected listeners and transport implementations are ready at startup. +Application component modules load on demand. Calling `start()` again while +active is an error; use `destroy()` before restarting. Do not mutate loader maps +after startup. For pre-hydration intent loading, call a preload policy's +`prepare()` before activation and pass the same policy to `start()`. + +## Lazy regions + +```typescript +import { createLazyLoader } from '@microsoft/webui-delivery/lazy.js'; +import { ndjson } from '@microsoft/webui-delivery/resources.js'; + +const panels = createLazyLoader({ transport: ndjson() }); +await panels.ensureLoaded('app-shell', 'settings'); +const state = await panels.fetchPartial('app-shell', 'settings', { + url: '/api/settings', + signal, +}); +``` + +Preloading creates no UI and requests no application state. `fetchPartial()` +returns fresh state after resource readiness; the caller applies it to the +existing owner and changes its condition. Routing instead owns outlet +reconciliation and per-route state. Both share resource registration, +build-scoped inventories, cancellation, and response framing. + +The request deadline defaults to 10 seconds, including a deferred state tail. +`ndjson(maxRecordBytes?)` defaults to 256 KiB per record. JSON-only applications +do not ship the decoder when bundled with tree-shaking. + +See the [Routing guide](https://microsoft.github.io/webui/guide/concepts/routing) +and [Lazy UI guide](https://microsoft.github.io/webui/guide/concepts/lazy) for +HTML declarations, initialization ordering, server APIs, and cancellation. diff --git a/packages/webui-router/package.json b/packages/webui-delivery/package.json similarity index 50% rename from packages/webui-router/package.json rename to packages/webui-delivery/package.json index ee9b0ffec..4401d39a1 100644 --- a/packages/webui-router/package.json +++ b/packages/webui-delivery/package.json @@ -1,8 +1,8 @@ { - "description": "Lightweight client-side router for WebUI apps. Intercepts navigation, matches routes locally or via server route-data, and hydrates components.", + "description": "Renderer-independent resource and state delivery for WebUI routing and lazy template regions.", "devDependencies": { "@microsoft/webui-framework": "workspace:*", - "@microsoft/webui-router": "workspace:*", + "@microsoft/webui-delivery": "workspace:*", "@playwright/test": "catalog:", "@types/dom-navigation": "catalog:", "@types/dom-view-transitions": "catalog:", @@ -12,29 +12,39 @@ "urlpattern-polyfill": "catalog:" }, "exports": { - ".": { + "./lazy.js": { "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "types": "./dist/lazy-entry.d.ts", + "default": "./dist/lazy-entry.js" } }, - "./preload.js": { + "./router.js": { "import": { - "types": "./dist/preload-entry.d.ts", - "default": "./dist/preload-entry.js" + "types": "./dist/router.d.ts", + "default": "./dist/router.js" + } + }, + "./resources.js": { + "import": { + "types": "./dist/resources-entry.d.ts", + "default": "./dist/resources-entry.js" } } }, "files": [ - "dist/" + "dist/", + "!dist/**/*.test.*", + "!dist/browser-shim.*" ], "license": "MIT", - "name": "@microsoft/webui-router", + "name": "@microsoft/webui-delivery", "scripts": { "build": "tsc", - "build:e2e": "esbuild tests/fixtures/router-app/src/index.ts tests/fixtures/router-app/src/interaction-entry.ts --bundle --outdir=tests/fixtures/router-app/dist --format=esm --splitting --sourcemap --tsconfig=tsconfig.e2e.json", + "prepack": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc --incremental false", + "build:e2e": "esbuild tests/fixtures/router-app/src/index.ts tests/fixtures/router-app/src/interaction-entry.ts --bundle --outdir=tests/fixtures/router-app/dist --format=esm --splitting --sourcemap --tsconfig=tsconfig.e2e.json && esbuild tests/fixtures/router-app/src/lean-entry.ts --bundle --outfile=tests/fixtures/router-app/dist/lean-entry.js --format=esm --sourcemap --tsconfig=tsconfig.e2e.json", "start:server": "cargo run -p microsoft-webui-cli -- serve ./tests/fixtures/router-app/src --plugin=webui --servedir ./tests/fixtures/router-app/dist --state ./tests/fixtures/router-app/data/state.json --port 39102", - "test": "tsc -p tsconfig.unit-test.json && node --test dist/**/*.test.js && pnpm build:e2e && playwright test", + "test:unit": "tsc -p tsconfig.unit-test.json --incremental false && node --test \"dist/**/*.test.js\"", + "test": "pnpm test:unit && pnpm build:e2e && playwright test", "test:e2e": "playwright test", "test:update-snapshots": "playwright test --update-snapshots", "typecheck": "tsc --noEmit" diff --git a/packages/webui-router/playwright.config.ts b/packages/webui-delivery/playwright.config.ts similarity index 100% rename from packages/webui-router/playwright.config.ts rename to packages/webui-delivery/playwright.config.ts diff --git a/packages/webui-delivery/src/abortable.ts b/packages/webui-delivery/src/abortable.ts new file mode 100644 index 000000000..4a0ca0483 --- /dev/null +++ b/packages/webui-delivery/src/abortable.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export function abortable(work: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = (): void => reject(signal.reason); + if (signal.aborted) reject(signal.reason); + else signal.addEventListener('abort', abort, { once: true }); + void work.then( + value => { signal.removeEventListener('abort', abort); resolve(value); }, + error => { signal.removeEventListener('abort', abort); reject(error); }, + ); + }); +} diff --git a/packages/webui-router/src/actions.ts b/packages/webui-delivery/src/actions.ts similarity index 96% rename from packages/webui-router/src/actions.ts rename to packages/webui-delivery/src/actions.ts index 07a6fa04e..ea1a418db 100644 --- a/packages/webui-router/src/actions.ts +++ b/packages/webui-delivery/src/actions.ts @@ -19,6 +19,11 @@ export interface ActionContext { invalidateTags(tags: string[]): void; } +/** Select form interception; listeners are installed synchronously by router.start(). */ +export function routeActions(): (context: ActionContext) => () => void { + return setupFormInterception; +} + /** Resolve the exact active chain entry that owns a route element. */ export function findActionRouteEntry( activeChain: readonly RouteChainEntry[], diff --git a/packages/webui-delivery/src/boundaries.ts b/packages/webui-delivery/src/boundaries.ts new file mode 100644 index 000000000..f6186e262 --- /dev/null +++ b/packages/webui-delivery/src/boundaries.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PendingState } from './pending.js'; +import { findErrorComponent, findPendingComponent } from './route-boundary.js'; +import type { RouteBoundaries } from './capabilities.js'; + +/** Opt-in pending/error UI for compiler-authored route boundaries. */ +export const routeBoundaries: RouteBoundaries = { + pending: findPendingComponent, + error: findErrorComponent, + create: () => new PendingState(), +}; diff --git a/packages/webui-router/src/browser-shim.ts b/packages/webui-delivery/src/browser-shim.ts similarity index 97% rename from packages/webui-router/src/browser-shim.ts rename to packages/webui-delivery/src/browser-shim.ts index 5adc80b52..7890d2fbd 100644 --- a/packages/webui-router/src/browser-shim.ts +++ b/packages/webui-delivery/src/browser-shim.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. /** - * Minimal browser API stubs so webui-router modules can load in Node. + * Minimal browser API stubs so webui-delivery modules can load in Node. * Must be imported before any router code. */ diff --git a/packages/webui-delivery/src/bundle-boundaries.test.ts b/packages/webui-delivery/src/bundle-boundaries.test.ts new file mode 100644 index 000000000..dfd3e508f --- /dev/null +++ b/packages/webui-delivery/src/bundle-boundaries.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { brotliCompressSync, gzipSync } from 'node:zlib'; +import { build } from 'esbuild'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const consumerRoot = process.env.WEBUI_PACKAGE_TEST_ROOT + ? resolve(process.env.WEBUI_PACKAGE_TEST_ROOT) + : resolve(root, 'packages', 'webui-delivery'); +const component = `import { WebUIElement } from '@microsoft/webui-framework'; window.Widget = class extends WebUIElement {};`; +const renderer = `import { startRuntime } from '@microsoft/webui-framework/runtime.js'; ${component} startRuntime();`; + +interface Bundle { + initialBytes: number; + initialGzip: number; + totalBytes: number; + totalGzip: number; + initialBrotli: number; + totalBrotli: number; + initialRequests: number; + chunks: number; + inputs: Set; + initialInputs: Set; +} + +const entries = { + buffered: renderer, + styled: `import '@microsoft/webui-framework/styles.js'; ${renderer}`, + templates: `export { getTemplate, registerTemplateData } from '@microsoft/webui-framework/templates.js';`, + progressive: `import '@microsoft/webui-framework/streaming.js';`, + jsonRouter: `import { WebUIRouter } from '@microsoft/webui-delivery/router.js'; new WebUIRouter().start();`, + ndjsonRouter: `import { WebUIRouter } from '@microsoft/webui-delivery/router.js'; import { ndjson } from '@microsoft/webui-delivery/resources.js'; new WebUIRouter().start({ transport: ndjson() });`, + fullRouter: `import { WebUIRouter, navigationCache, routeActions, routePreload, routeBoundaries, viewTransitions, routeDiagnostics } from '@microsoft/webui-delivery/router.js'; import { ndjson } from '@microsoft/webui-delivery/resources.js'; new WebUIRouter().start({ transport: ndjson(), cache: navigationCache(), actions: routeActions(), preload: routePreload(), boundaries: routeBoundaries, transitions: viewTransitions, diagnostics: routeDiagnostics });`, + resources: `export { fetchComponentTemplates, readJsonPartial } from '@microsoft/webui-delivery/resources.js';`, + records: `export { ndjson } from '@microsoft/webui-delivery/resources.js';`, + deferredJson: `export { createLazyLoader } from '@microsoft/webui-delivery/lazy.js';`, + deferredStream: `import { createLazyLoader } from '@microsoft/webui-delivery/lazy.js'; import { ndjson } from '@microsoft/webui-delivery/resources.js'; window.loader = createLazyLoader({ transport: ndjson() });`, + combined: `import { WebUIRouter } from '@microsoft/webui-delivery/router.js'; import { createLazyLoader } from '@microsoft/webui-delivery/lazy.js'; new WebUIRouter().start(); window.loader = createLazyLoader();`, + queryHelper: `export { parseQuery } from '@microsoft/webui-delivery/router.js';`, + decoratorOnly: `export { observable } from '@microsoft/webui-framework';`, +}; +type Profile = keyof typeof entries; +const bundles = new Map>(); + +function bundle(profile: Profile): Promise { + let pending = bundles.get(profile); + if (!pending) { + pending = buildProfile(profile); + bundles.set(profile, pending); + } + return pending; +} + +async function buildProfile(profile: Profile): Promise { + const result = await build({ + absWorkingDir: root, + stdin: { + contents: entries[profile], + sourcefile: `${profile}.ts`, + resolveDir: consumerRoot, + loader: 'ts', + }, + outdir: resolve(root, 'target', 'runtime-profile-tests', profile), + bundle: true, + splitting: true, + format: 'esm', + minify: true, + treeShaking: true, + write: false, + metafile: true, + platform: 'browser', + target: 'es2022', + supported: { 'import-attributes': true }, + tsconfig: resolve(root, 'tsconfig.json'), + define: { __WEBUI_DEV__: 'false' }, + logLevel: 'silent', + }); + const outputs = result.metafile.outputs; + const entry = Object.keys(outputs).find(path => + outputs[path].entryPoint?.replaceAll('\\', '/').endsWith(`/${profile}.ts`)); + assert.ok(entry, 'the application entry must be present'); + const initial = new Set(); + const pending = [entry]; + while (pending.length > 0) { + const path = pending.pop()!; + if (initial.has(path)) continue; + initial.add(path); + for (const edge of outputs[path].imports) { + if (!edge.external && edge.kind !== 'dynamic-import') pending.push(edge.path); + } + } + const files = new Map(result.outputFiles.map(file => [resolve(file.path), file.contents])); + const measured: Bundle = { + initialBytes: 0, initialGzip: 0, totalBytes: 0, totalGzip: 0, + initialBrotli: 0, totalBrotli: 0, initialRequests: initial.size, chunks: Object.keys(outputs).length, + inputs: new Set(), initialInputs: new Set(), + }; + for (const [path, output] of Object.entries(outputs)) { + const bytes = files.get(resolve(root, path)); + assert.ok(bytes); + const gzip = gzipSync(bytes).byteLength; + const brotli = brotliCompressSync(bytes).byteLength; + measured.totalBytes += bytes.byteLength; + measured.totalGzip += gzip; + measured.totalBrotli += brotli; + if (initial.has(path)) { + measured.initialBytes += bytes.byteLength; + measured.initialGzip += gzip; + measured.initialBrotli += brotli; + } + for (const [input, contribution] of Object.entries(output.inputs)) { + if (contribution.bytesInOutput === 0) continue; + const normalized = input.replaceAll('\\', '/'); + measured.inputs.add(normalized); + if (initial.has(path)) measured.initialInputs.add(normalized); + } + } + return measured; +} + +function excludes(inputs: ReadonlySet, files: readonly string[]): void { + for (const file of files) { + assert.equal( + [...inputs].some(input => input.endsWith(file)), + false, + `${file} must contribute no bytes to this composition`, + ); + } +} + +test('public helper imports do not retain navigation or rendering implementations', async () => { + const [query, decorator] = await Promise.all([bundle('queryHelper'), bundle('decoratorOnly')]); + excludes(query.inputs, [ + 'router-core.js', 'streaming.js', 'ndjson-reader.js', + 'cache.js', 'actions.js', 'pending.js', 'preload.js', 'view-transitions.js', + ]); + excludes(decorator.inputs, [ + 'template-element.js', 'element.js', 'static-host.js', + 'element/styles.js', 'element/link-styles.js', 'styles-entry.js', + ]); +}); + +test('buffered core excludes CSS preparation and optional coordinators with a measured size reduction', async () => { + const [core, styled] = await Promise.all([bundle('buffered'), bundle('styled')]); + excludes(core.inputs, [ + 'element/styles.js', 'element/link-styles.js', 'styles-entry.js', + 'template-cache.js', 'streaming-coordinator.js', 'lazy-hydration-coordinator.js', + ]); + assert.ok(core.initialBytes <= 66_000, `core startup: ${core.initialBytes} bytes`); + assert.ok(core.initialGzip <= 22_000, `core startup gzip: ${core.initialGzip} bytes`); + assert.ok(core.initialBytes < styled.initialBytes * 0.85, 'core should save at least 15% versus full style installation'); +}); + +test('JSON-only routing excludes streaming and unselected policies from all emitted chunks', async () => { + const [json, full] = await Promise.all([bundle('jsonRouter'), bundle('fullRouter')]); + excludes(json.inputs, [ + 'ndjson.js', 'ndjson-reader.js', 'cache.js', 'actions.js', 'pending.js', + 'preload.js', 'prepared-preload.js', 'route-boundary.js', 'view-transitions.js', + 'diagnostics.js', + ]); + assert.ok(json.totalBytes < full.totalBytes * 0.9, 'lean routing should exclude at least 10% of the full dependency graph'); +}); + +test('NDJSON routing adds only the partial reader, not unrelated router policies', async () => { + const streamed = await bundle('ndjsonRouter'); + assert.ok([...streamed.inputs].some(input => input.endsWith('/ndjson-reader.js'))); + excludes(streamed.inputs, [ + 'streaming-coordinator.js', 'cache.js', 'actions.js', 'pending.js', 'preload.js', + 'route-boundary.js', 'view-transitions.js', + ]); +}); + +test('resource and record consumers have no route or rendering engine dependency', async () => { + const [resources, records, templates] = await Promise.all([ + bundle('resources'), bundle('records'), bundle('templates'), + ]); + for (const output of [resources, records, templates]) { + excludes(output.inputs, [ + 'router-core.js', 'chain.js', 'route-element.js', + 'template-element.js', 'element.js', 'element/styles.js', + 'element/link-styles.js', 'streaming-coordinator.js', + ]); + } + excludes(resources.inputs, ['streaming.js', 'ndjson-reader.js']); +}); + +test('deferred delivery stays hydration-agnostic and JSON consumers exclude NDJSON', async () => { + const [json, streamed] = await Promise.all([bundle('deferredJson'), bundle('deferredStream')]); + for (const output of [json, streamed]) { + excludes(output.inputs, [ + 'router-core.js', 'chain.js', 'route-element.js', + 'template-element.js', 'element.js', 'element/styles.js', + 'streaming-coordinator.js', 'pending.js', 'view-transitions.js', + ]); + } + excludes(json.inputs, ['ndjson-reader.js', 'ndjson.js']); + assert.ok([...streamed.inputs].some(input => input.endsWith('/ndjson-reader.js'))); +}); +test('early progressive HTML remains separate from network partial decoding and authored rendering', async () => { + const progressive = await bundle('progressive'); + excludes(progressive.inputs, ['router-core.js', 'ndjson-reader.js']); + excludes(progressive.initialInputs, ['template-element.js', 'element.js', 'element/link-styles.js']); + assert.ok(progressive.initialBytes <= 32 * 1024, `early bootstrap: ${progressive.initialBytes} bytes`); + assert.ok(progressive.initialGzip <= 11 * 1024, `early bootstrap gzip: ${progressive.initialGzip} bytes`); +}); + +test('combined delivery reuses shared code and records public bundle measurements', async () => { + const names = Object.keys(entries) as Profile[]; + const profiles = await Promise.all(names.map(bundle)); + const combined = await bundle('combined'); + const json = await bundle('jsonRouter'); + const lazy = await bundle('deferredJson'); + assert.ok(combined.totalBytes < (json.totalBytes + lazy.totalBytes) * 0.9); + const output = resolve(root, 'target', process.env.WEBUI_PACKAGE_TEST_ROOT + ? 'delivery-packed-public.json' : 'delivery-public-after.json'); + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, JSON.stringify(Object.fromEntries(names.map((name, index) => { + const { inputs, initialInputs, ...sizes } = profiles[index]; + return [name, { ...sizes, inputs: [...inputs], initialInputs: [...initialInputs] }]; + })), null, 2)); +}); diff --git a/packages/webui-router/src/cache.ts b/packages/webui-delivery/src/cache.ts similarity index 90% rename from packages/webui-router/src/cache.ts rename to packages/webui-delivery/src/cache.ts index a8ca5ed73..7569ca545 100644 --- a/packages/webui-router/src/cache.ts +++ b/packages/webui-delivery/src/cache.ts @@ -7,9 +7,10 @@ */ import type { CacheConfig, ComponentStyles } from './types.js'; +import type { TemplateResourceResponse } from './templates.js'; /** JSON partial response from the server. */ -export interface PartialResponse { +export interface PartialResponse extends TemplateResourceResponse { /** Top-level application state (non-streaming responses). */ state?: Record; /** Versioned component stylesheet definitions and ordered closures. */ @@ -80,6 +81,17 @@ export interface CacheEntry { /** Maximum age (ms) for a preloaded partial before it's considered stale. */ const PRELOAD_TTL = 5_000; +/** Select and configure a navigation response cache. Resource inventories remain separate. */ +export function navigationCache(config: CacheConfig = {}): NavigationCache { + const resolved = { staleTime: 0, gcTime: 300_000, maxEntries: 50, ...config }; + if (!Number.isSafeInteger(resolved.maxEntries) || resolved.maxEntries <= 0 || + !Number.isFinite(resolved.staleTime) || resolved.staleTime < 0 || + !Number.isFinite(resolved.gcTime) || resolved.gcTime < 0) { + throw new RangeError('[WebUI] Cache maxEntries must be a positive integer and lifetimes must be finite and nonnegative.'); + } + return new NavigationCache(resolved); +} + export class NavigationCache { private cache = new Map(); private tagIndex = new Map>(); diff --git a/packages/webui-delivery/src/capabilities.test.ts b/packages/webui-delivery/src/capabilities.test.ts new file mode 100644 index 000000000..15a810087 --- /dev/null +++ b/packages/webui-delivery/src/capabilities.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import './browser-shim.js'; +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { WebUIRouter } from './router.js'; +import { ndjson } from './ndjson.js'; +import { startDeferredStream } from './partial-response.js'; +import { navigationCache } from './cache.js'; +import { readJsonPartial } from './partial-json.js'; +import { routeBoundaries } from './boundaries.js'; +import { viewTransitions } from './view-transitions.js'; +import { validateRoutes } from './diagnostics.js'; +import { PendingState } from './pending.js'; +import { routeActions } from './actions.js'; +import { routePreload } from './preload.js'; + +interface PartialRouter { + fetchPartial(path: string): Promise; + deferredReader: Promise | null; + partialControllers: Set; +} + +test('selected action and preload listeners are ready before start returns', t => { + window.__webui = { inventory: '', nonce: '' }; + const listeners = new Map(); + t.mock.method(document, 'addEventListener', (type: string, listener: EventListenerOrEventListenerObject) => { + listeners.set(type, listener); + }); + t.mock.method(document, 'removeEventListener', (type: string) => { listeners.delete(type); }); + const preload = routePreload(); + assert.equal(listeners.size, 0, 'selecting a policy does not install it'); + preload.prepare(); + assert.ok(listeners.has('pointermove'), 'early intent preparation is explicit'); + const router = new WebUIRouter(); + try { + router.start({ cache: navigationCache(), preload, actions: routeActions() }); + assert.ok(listeners.has('submit')); + assert.ok(listeners.has('pointermove')); + assert.throws(() => router.start(), /already started/); + assert.throws(() => preload.prepare(), /before router.start/); + assert.ok(listeners.has('submit'), 'a rejected reconfiguration does not destroy the active router'); + } finally { + router.destroy(); + } + assert.equal(listeners.has('submit'), false); + assert.equal(listeners.has('pointermove'), false); + assert.equal(preload.prepared, undefined); +}); + +test('a declared preload policy can be disposed after adoption without retaining listeners', t => { + let count = 0; + t.mock.method(document, 'addEventListener', () => { count++; }); + t.mock.method(document, 'removeEventListener', () => { count--; }); + const policy = routePreload(); + const dispose = policy.install({ + basePath: '', excludePaths: [], currentRequestPath: '/', inventory: '', + hasCache: () => false, storeCache() {}, fetchPartial: async () => null, + }); + assert.equal(count, 1); + policy.destroy(); + dispose(); + assert.equal(count, 0); +}); + +test('lean routing requests JSON without opting into streaming negotiation', async t => { + window.__webui = Object.assign({ inventory: '', nonce: '' }, { templates: {} }); + let accept = ''; + t.mock.method(globalThis, 'fetch', async (_input: unknown, options?: RequestInit) => { + accept = new Headers(options?.headers).get('accept') ?? ''; + return Response.json({ chain: [], templates: {}, path: '/json' }); + }); + const router = new WebUIRouter(); + const data = await (router as unknown as PartialRouter).fetchPartial('/json'); + assert.ok(data); + assert.equal(accept, 'application/json'); + router.destroy(); +}); + +test('selecting NDJSON readies the partial decoder without changing commit ownership', async t => { + window.__webui = Object.assign({ inventory: '', nonce: '' }, { templates: {} }); + let accept = ''; + t.mock.method(globalThis, 'fetch', async (_input: unknown, options?: RequestInit) => { + accept = new Headers(options?.headers).get('accept') ?? ''; + return new Response('{"chain":[],"templates":{},"path":"/stream"}\n{"states":[]}\n', { + headers: { 'content-type': 'application/x-ndjson' }, + }); + }); + const router = new WebUIRouter(); + router.start({ transport: ndjson() }); + const internals = router as unknown as PartialRouter; + const data = await internals.fetchPartial('/stream') as Parameters[0]; + assert.ok(data); + assert.equal(accept, 'application/x-ndjson, application/json'); + assert.equal(internals.deferredReader, null); + startDeferredStream(data); + assert.ok(internals.deferredReader); + await internals.deferredReader; + router.destroy(); +}); + +test('invalid policy flags fail with actionable configuration errors', () => { + for (const [config, capability] of [ + ['{"cache":{}}', 'navigationCache'], + ['{"preload":true}', 'routePreload'], + ['{"actions":true}', 'routeActions'], + ['{"transport":{}}', 'transport'], + ]) { + assert.throws(() => new WebUIRouter().start(JSON.parse(config)), new RegExp(capability)); + } +}); + +test('explicit cache and boundary implementations can be composed independently', () => { + const cache = navigationCache({ staleTime: 1000, gcTime: 2000, maxEntries: 2 }); + assert.equal(cache.has('/missing'), false); + const previous = Object.getOwnPropertyDescriptor(document.body, 'querySelectorAll'); + Object.defineProperty(document.body, 'querySelectorAll', { configurable: true, value: () => [] }); + try { + assert.equal(routeBoundaries.pending([], '/missing'), null); + assert.equal(routeBoundaries.error([], '/missing'), null); + } finally { + if (previous) Object.defineProperty(document.body, 'querySelectorAll', previous); + else Reflect.deleteProperty(document.body, 'querySelectorAll'); + } + const pending = routeBoundaries.create(); + assert.ok(!(pending instanceof Promise)); + pending.destroy(); + validateRoutes([]); +}); + +for (const start of [false, true]) { + test(`the partial deadline releases a ${start ? 'started' : 'unread'} state tail`, async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + window.__webui = Object.assign({ inventory: '', nonce: '' }, { templates: {} }); + let signal: AbortSignal | null | undefined; + let cancelled = false; + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"chain":[],"templates":{},"path":"/slow"}\n')); + }, + cancel() { cancelled = true; }, + }), { headers: { 'content-type': 'application/x-ndjson' } }); + t.mock.method(globalThis, 'fetch', async (_input: unknown, options?: RequestInit) => { + signal = options?.signal; + return response; + }); + const router = new WebUIRouter(); + router.start({ transport: ndjson() }); + const internals = router as unknown as PartialRouter; + try { + const data = await internals.fetchPartial('/slow') as Parameters[0]; + assert.ok(data); + if (start) startDeferredStream(data); + const pending = internals.deferredReader; + if (start) assert.ok(pending); + else assert.equal(pending, null); + t.mock.timers.tick(10_001); + await pending; + assert.equal(signal?.aborted, true); + assert.equal(cancelled, true); + assert.equal(response.body?.locked, false); + assert.equal(internals.partialControllers.size, 0); + } finally { + router.destroy(); + } + }); +} + +test('boundary requests reuse one synchronous instance and destruction releases it', () => { + let creations = 0; + let destroyed = false; + const pending = new PendingState(); + pending.destroy = () => { destroyed = true; }; + const router = new WebUIRouter(); + router.start({ + boundaries: { + ...routeBoundaries, + create: () => { creations++; return pending; }, + }, + }); + const internals = router as unknown as { + pendingState(): PendingState; + pending: PendingState | null; + }; + const first = internals.pendingState(); + const second = internals.pendingState(); + assert.equal(creations, 1); + assert.equal(first, pending); + assert.equal(second, pending); + router.destroy(); + assert.equal(destroyed, true); + assert.equal(internals.pending, null); +}); + +test('the transition capability guards unsupported browsers and removes its own style', t => { + const original = document.startViewTransition; + Object.defineProperty(document, 'startViewTransition', { configurable: true, writable: true, value: undefined }); + assert.equal(viewTransitions.supported(), false); + viewTransitions.install()(); + let removed = false; + const style = { textContent: '', nonce: '', remove() { removed = true; } }; + t.mock.method(document, 'createElement', () => style as unknown as HTMLStyleElement); + t.mock.method(document.head, 'appendChild', (node: T): T => node); + Object.defineProperty(document, 'startViewTransition', { + configurable: true, writable: true, + value: (commit: () => void) => { commit(); return { updateCallbackDone: Promise.resolve() }; }, + }); + try { + window.__webui = { nonce: 'nonce' }; + const dispose = viewTransitions.install(); + assert.equal(style.textContent, '@view-transition { navigation: none; }'); + assert.equal(style.nonce, 'nonce'); + let committed = false; + viewTransitions.startViewTransition(() => { committed = true; }); + assert.equal(committed, true); + dispose(); + assert.equal(removed, true); + } finally { + document.startViewTransition = original; + } +}); + +test('the JSON resource reader validates shape and respects cancellation', async () => { + const context = { nonce: '', injectedCss: new Set(), updateInventory() {} }; + await assert.rejects(readJsonPartial(Response.json([]), context), /must be a JSON object/); + const controller = new AbortController(); + controller.abort(); + assert.equal( + await readJsonPartial(Response.json({ templates: { 'x-cancelled': { h: '' } } }), context, controller.signal), + null, + ); +}); diff --git a/packages/webui-delivery/src/capabilities.ts b/packages/webui-delivery/src/capabilities.ts new file mode 100644 index 000000000..4ec237db9 --- /dev/null +++ b/packages/webui-delivery/src/capabilities.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { PendingState } from './pending.js'; +import type { RouteChainEntry } from './cache.js'; +import type { RouteBoundary } from './route-boundary.js'; + +/** Optional presentation policy for compiled route pending/error declarations. */ +export interface RouteBoundaries { + pending(chain: RouteChainEntry[], path: string): RouteBoundary | null; + error(chain: RouteChainEntry[], path: string): RouteBoundary | null; + create(): PendingState; +} + +/** Optional same-document transition policy, independent of navigation correctness. */ +export interface RouteTransitions { + supported(): boolean; + install(): () => void; + startViewTransition(commit: () => void): ViewTransition; +} diff --git a/packages/webui-router/src/chain.test.ts b/packages/webui-delivery/src/chain.test.ts similarity index 100% rename from packages/webui-router/src/chain.test.ts rename to packages/webui-delivery/src/chain.test.ts diff --git a/packages/webui-router/src/chain.ts b/packages/webui-delivery/src/chain.ts similarity index 100% rename from packages/webui-router/src/chain.ts rename to packages/webui-delivery/src/chain.ts diff --git a/packages/webui-delivery/src/diagnostics.ts b/packages/webui-delivery/src/diagnostics.ts new file mode 100644 index 000000000..bf0eaeca1 --- /dev/null +++ b/packages/webui-delivery/src/diagnostics.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { isExact, renderRoot, routeComponent, routePath, ROUTE_SELECTOR } from './route-element.js'; +import type { RouteChainEntry } from './cache.js'; + +/** Opt-in development warnings for route declarations inconsistent with their outlets. */ +export function validateRoutes(chain: RouteChainEntry[]): void { + for (const { el } of chain) { + if (!el) continue; + const component = routeComponent(el); + if (!component) continue; + const instance = el.querySelector(component); + if (!instance) continue; + const root = renderRoot(instance); + const hasOutlet = root.querySelector('outlet') !== null; + const hasChildren = root.querySelector(ROUTE_SELECTOR) !== null; + const path = routePath(el); + if (!hasChildren && !hasOutlet && !isExact(el) && path !== '/') { + console.warn( + `[Router Dev] Route "${path}" (${component}) is a leaf route without "exact". ` + + 'Add "exact" to prevent unintended prefix matching.', + ); + } + if (hasOutlet && isExact(el)) { + console.warn( + `[Router Dev] Route "${path}" (${component}) has with "exact". ` + + 'Remove "exact" - child routes will never match.', + ); + } + } +} diff --git a/packages/webui-router/src/fallback-navigation.test.ts b/packages/webui-delivery/src/fallback-navigation.test.ts similarity index 100% rename from packages/webui-router/src/fallback-navigation.test.ts rename to packages/webui-delivery/src/fallback-navigation.test.ts diff --git a/packages/webui-router/src/fallback-navigation.ts b/packages/webui-delivery/src/fallback-navigation.ts similarity index 100% rename from packages/webui-router/src/fallback-navigation.ts rename to packages/webui-delivery/src/fallback-navigation.ts diff --git a/packages/webui-router/src/framework-agnostic.test.ts b/packages/webui-delivery/src/framework-agnostic.test.ts similarity index 100% rename from packages/webui-router/src/framework-agnostic.test.ts rename to packages/webui-delivery/src/framework-agnostic.test.ts diff --git a/packages/webui-delivery/src/inventory.test.ts b/packages/webui-delivery/src/inventory.test.ts new file mode 100644 index 000000000..a27cbeeea --- /dev/null +++ b/packages/webui-delivery/src/inventory.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import './browser-shim.js'; +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { acceptInventory, clearInventory, inventoryEpoch } from './inventory.js'; + +test('concurrent resource responses merge rather than replace unrelated bits', () => { + window.__webui = { inventory: '01' }; + const epoch = inventoryEpoch(); + acceptInventory('05', epoch); + acceptInventory('09', epoch); + assert.equal(window.__webui.inventory, '0d'); + acceptInventory('8001', epoch); + assert.equal(window.__webui.inventory, '8d01'); +}); + +test('an old resource response cannot resurrect inventory after cache collection', () => { + window.__webui = { inventory: 'ff' }; + const epoch = inventoryEpoch(); + clearInventory(); + assert.equal(window.__webui.inventory, ''); + assert.equal(inventoryEpoch(), epoch + 1); + acceptInventory('ff', epoch); + assert.equal(window.__webui.inventory, ''); + acceptInventory('02', inventoryEpoch()); + assert.equal(window.__webui.inventory, '02'); +}); + +test('malformed inventories fail without mutating the committed value', () => { + window.__webui = { inventory: '01' }; + assert.throws(() => acceptInventory('0z', inventoryEpoch()), /Invalid component inventory/); + assert.equal(window.__webui.inventory, '01'); +}); diff --git a/packages/webui-delivery/src/inventory.ts b/packages/webui-delivery/src/inventory.ts new file mode 100644 index 000000000..fc34d227e --- /dev/null +++ b/packages/webui-delivery/src/inventory.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export function inventoryEpoch(): number { + return window.__webui?.inventoryEpoch ?? 0; +} + +export function clearInventory(): void { + const runtime = window.__webui ??= {}; + runtime.inventory = ''; + runtime.inventoryEpoch = (runtime.inventoryEpoch ?? 0) + 1; +} + +/** Merge same-epoch responses; an old request cannot resurrect an evicted bit. */ +export function acceptInventory(inventory: string, epoch: number): void { + if (epoch !== inventoryEpoch()) return; + const runtime = window.__webui ??= {}; + runtime.inventory = mergeInventory(runtime.inventory ?? '', inventory); +} + +export function acceptStyleInventory(inventory: string, epoch: number): void { + if (epoch !== inventoryEpoch()) return; + const runtime = window.__webui ??= {}; + runtime.styleInventory = mergeInventory(runtime.styleInventory ?? '', inventory); +} + +function mergeInventory(current: string, inventory: string): string { + const length = Math.max(current.length, inventory.length); + let merged = ''; + for (let i = 0; i < length; i++) { + const left = i < current.length ? hexDigit(current.charCodeAt(i)) : 0; + const right = i < inventory.length ? hexDigit(inventory.charCodeAt(i)) : 0; + merged += (left | right).toString(16); + } + return merged; +} + +function hexDigit(code: number): number { + if (code >= 48 && code <= 57) return code - 48; + if (code >= 97 && code <= 102) return code - 87; + if (code >= 65 && code <= 70) return code - 55; + throw new Error('[WebUI] Invalid component inventory. Use the hexadecimal inventory from the matching protocol.'); +} diff --git a/packages/webui-delivery/src/lazy-data.ts b/packages/webui-delivery/src/lazy-data.ts new file mode 100644 index 000000000..be9054add --- /dev/null +++ b/packages/webui-delivery/src/lazy-data.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { TemplateResourceResponse } from './templates.js'; +import type { PartialBody } from './transport.js'; +import { abortable } from './abortable.js'; + +/** Framework-independent resource and optional state record for a lazy body. */ +export interface LazyRecord extends TemplateResourceResponse { + type: 'webui-lazy'; + version: 1; + fragment: string; + owner: string; + buildId: string; + inventory: string; + styleInventory: string; + required: string[]; + state?: Record; +} + +/** One request's resource preparation, independent of its hydration engine. */ +export interface LazyReadContext { + readonly owner: string; + readonly fragment: string; + readonly signal: AbortSignal; + prepare(record: LazyRecord): Promise | undefined; + abort(reason: unknown): void; +} + +/** Stable address of a named region; applications should not hardcode anonymous ids. */ +export function lazyFragment(owner: string, id: string): string { + if (!owner || owner.length > 512 || !id || id.length > 128) { + throw new Error('[WebUI] Lazy loading requires an owning template and a bounded region id.'); + } + return `${owner}::lazy:${id}`; +} + +export function lazyRecord(value: unknown, context: Pick): LazyRecord { + if (!isObject(value)) throw new Error('[WebUI] Lazy responses must contain JSON objects.'); + if (value.error !== undefined) throw new Error('[WebUI] The server could not load lazy state.', { cause: value.error }); + if (value.type !== 'webui-lazy' || value.version !== 1 || + value.fragment !== context.fragment || value.owner !== context.owner || + typeof value.buildId !== 'string' || value.buildId.length === 0 || + !validInventory(value.inventory) || !validInventory(value.styleInventory) || + !Array.isArray(value.required) || !value.required.includes(context.fragment) || + value.required.some(id => typeof id !== 'string' || id.length === 0)) { + throw new Error('[WebUI] Invalid or mismatched lazy response. Serve the requested region using the matching protocol.'); + } + if (value.state !== undefined && !isObject(value.state)) { + throw new Error('[WebUI] Lazy state must be a JSON object.'); + } + return value as unknown as LazyRecord; +} + +function validInventory(value: unknown): value is string { + if (typeof value !== 'string' || value.length % 2 !== 0) return false; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (!((code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102))) return false; + } + return true; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Region policy consumes shared framing without creating or updating a view. */ +export async function readLazyBody(body: PartialBody, context: LazyReadContext): Promise> { + const records = body.records; + let preparation: Promise | undefined; + let state: Record | undefined; + let preparationError: unknown; + let count = 0; + const consume = (value: unknown): void => { + if (++count > 2 || state !== undefined) { + throw new Error('[WebUI] Lazy responses must contain resources followed by one terminal state record.'); + } + const record = lazyRecord(value, context); + const ready = context.prepare(record); + if (ready) { + preparation = (preparation ? Promise.all([preparation, ready]) : ready).catch(error => { + preparationError ??= error; + context.abort(error); + }); + } + state = record.state; + }; + try { + consume(body.data); + if (records) await records.drain(consume); + if (state === undefined) throw new Error('[WebUI] Lazy response ended before its state record.'); + if (preparation) await abortable(preparation, context.signal); + if (preparationError !== undefined) throw preparationError; + context.signal.throwIfAborted(); + return state; + } catch (error) { + throw preparationError ?? error; + } finally { + if (records) await records.cancel(); + } +} diff --git a/packages/webui-delivery/src/lazy-entry.ts b/packages/webui-delivery/src/lazy-entry.ts new file mode 100644 index 000000000..c6fb8c3cc --- /dev/null +++ b/packages/webui-delivery/src/lazy-entry.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { createLazyLoader } from './lazy-loader.js'; +export type { LazyLoader } from './lazy-loader.js'; +export type { LazyLoaderOptions, LazyFetchOptions } from './lazy-loader.js'; +export { lazyFragment } from './lazy-data.js'; +export type { LazyRecord } from './lazy-data.js'; diff --git a/packages/webui-delivery/src/lazy-loader.test.ts b/packages/webui-delivery/src/lazy-loader.test.ts new file mode 100644 index 000000000..f0d36d84a --- /dev/null +++ b/packages/webui-delivery/src/lazy-loader.test.ts @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import './browser-shim.js'; +import { strict as assert } from 'node:assert'; +import { beforeEach, test } from 'node:test'; +import { createLazyLoader } from './lazy-loader.js'; +import { lazyFragment, type LazyRecord } from './lazy-data.js'; +import { ndjson } from './ndjson.js'; +import { clearInventory } from './inventory.js'; +import { jsonTransport } from './transport.js'; + +const owner = 'app-shell'; +const id = 'settings'; +const fragment = lazyFragment(owner, id); + +function record(state?: Record): LazyRecord { + return { + type: 'webui-lazy', version: 1, owner, fragment, buildId: 'build', + inventory: '01', styleInventory: '', required: [fragment], + templates: { [fragment]: { h: '

Lazy

', f: fragment, o: owner, v: 3 } }, + ...(state === undefined ? {} : { state }), + }; +} + +function templates(): Record { + return Reflect.get(window.__webui ?? {}, 'templates') as Record; +} + +beforeEach(() => { + window.__webui = Object.assign({ buildId: 'build', inventory: '', styleInventory: '', state: {} }, { templates: {} }); + window.__webuiResolveResources = undefined; + window.__webuiPrepareResources = undefined; + window.__webuiPrepareTemplates = undefined; + window.__webuiRetainTemplate = undefined; +}); + +test('the former deferred exports and response discriminator are not aliases', async t => { + assert.throws(() => import.meta.resolve('@microsoft/webui-delivery/deferred.js'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); + assert.throws(() => import.meta.resolve('@microsoft/webui-framework/deferred.js'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); + t.mock.method(globalThis, 'fetch', async () => Response.json({ + ...record({ value: 'old' }), type: 'webui-deferred', + })); + await assert.rejects(createLazyLoader().fetchPartial(owner, id, { url: '/state' }), /mismatched lazy response/); + assert.deepEqual(templates(), {}); +}); + +test('concurrent preload shares metadata and configured modules without fetching state', async t => { + let requests = 0; + let modules = 0; + t.mock.method(globalThis, 'fetch', async () => { + requests++; + return Response.json(record()); + }); + const loader = createLazyLoader({ loaders: { [fragment]: async () => { modules++; } } }); + await Promise.all([loader.ensureLoaded(owner, id), loader.ensureLoaded(owner, id)]); + await loader.ensureLoaded(owner, id); + assert.equal(requests, 1); + assert.equal(modules, 1); + assert.deepEqual(window.__webui?.state, {}); + assert.ok(templates()[fragment]); +}); + +test('fully warm requests still return independent fresh state', async t => { + let states = 0; + t.mock.method(globalThis, 'fetch', async (url: unknown, options?: RequestInit) => { + if (String(url).includes('_webui/lazy')) return Response.json(record()); + assert.equal(new Headers(options?.headers).get('X-WebUI-Inventory'), '01'); + const warm = record({ revision: ++states }); + delete warm.templates; + return Response.json(warm); + }); + const loader = createLazyLoader(); + await loader.ensureLoaded(owner, id); + const first = await loader.fetchPartial(owner, id, { url: '/state' }); + const second = await loader.fetchPartial(owner, id, { url: '/state' }); + assert.deepEqual(first, { revision: 1 }); + assert.deepEqual(second, { revision: 2 }); + assert.deepEqual(window.__webui?.state, {}, 'state is returned to its caller, not published globally'); +}); + +test('loader configuration is selected once rather than mutated by the caller later', async t => { + const options = { transport: jsonTransport }; + const loader = createLazyLoader(options); + options.transport = { accept: 'invalid', read: async () => { throw new Error('changed configuration'); } }; + t.mock.method(globalThis, 'fetch', async (_url: unknown, init?: RequestInit) => { + assert.equal(new Headers(init?.headers).get('Accept'), 'application/json'); + return Response.json(record({ ready: true })); + }); + assert.deepEqual(await loader.fetchPartial(owner, id, { url: '/state' }), { ready: true }); +}); + +for (const preload of [false, true]) { + test(`HTTP failure cancels the unread ${preload ? 'resource' : 'state'} response`, async t => { + let signal: AbortSignal | null | undefined; + t.mock.method(globalThis, 'fetch', async (_url: unknown, init?: RequestInit) => { + signal = init?.signal; + return new Response('Unavailable', { status: 503 }); + }); + const loader = createLazyLoader(); + await assert.rejects(preload + ? loader.ensureLoaded(owner, id) + : loader.fetchPartial(owner, id, { url: '/state' }), /failed: 503/); + assert.equal(signal?.aborted, true, 'failed requests must not leave unread bodies active'); + assert.deepEqual(templates(), {}); + }); +} + +for (const streamed of [false, true]) { + test(`failed resource readiness is surfaced without an unhandled tail (${streamed ? 'NDJSON' : 'JSON'})`, async t => { + const failure = new Error('styles failed'); + window.__webuiPrepareResources = () => Promise.reject(failure); + const response = streamed + ? new Response(`${JSON.stringify(record())}\n${JSON.stringify(record({ ready: true }))}\n`, { + headers: { 'Content-Type': 'application/x-ndjson' }, + }) + : Response.json(record({ ready: true })); + t.mock.method(globalThis, 'fetch', async () => response); + const loader = createLazyLoader({ transport: streamed ? ndjson() : jsonTransport }); + await assert.rejects(loader.fetchPartial(owner, id, { url: '/state' }), failure); + if (streamed) assert.equal(response.body?.locked, false); + }); +} + +test('an in-flight preload does not delay the backend state request', async t => { + const metadata = Promise.withResolvers(); + let states = 0; + t.mock.method(globalThis, 'fetch', async (url: unknown) => { + if (String(url).includes('_webui/lazy')) return metadata.promise; + states++; + return Response.json(record({ ready: true })); + }); + const loader = createLazyLoader(); + const preload = loader.ensureLoaded(owner, id); + const state = await loader.fetchPartial(owner, id, { url: '/state' }); + assert.equal(states, 1); + assert.deepEqual(state, { ready: true }); + metadata.resolve(Response.json(record())); + await preload; +}); + +test('aborting one preload waiter does not cancel shared resource work', async t => { + const response = Promise.withResolvers(); + let requestSignal: AbortSignal | null | undefined; + t.mock.method(globalThis, 'fetch', async (_url: unknown, options?: RequestInit) => { + requestSignal = options?.signal; + return response.promise; + }); + const loader = createLazyLoader(); + const controller = new AbortController(); + const cancelled = loader.ensureLoaded(owner, id, controller.signal); + const other = loader.ensureLoaded(owner, id); + controller.abort(); + await assert.rejects(cancelled, { name: 'AbortError' }); + assert.equal(requestSignal?.aborted, false); + response.resolve(Response.json(record())); + await other; +}); + +test('NDJSON prepares resources before state and releases a cancelled reader', async t => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`${JSON.stringify(record())}\n`)); + }, + cancel() { cancelled = true; }, + }); + t.mock.method(globalThis, 'fetch', async () => new Response(body, { + headers: { 'content-type': 'application/x-ndjson' }, + })); + const loader = createLazyLoader({ transport: ndjson() }); + const controller = new AbortController(); + const pending = loader.fetchPartial(owner, id, { url: '/state', signal: controller.signal }); + await new Promise(setImmediate); + assert.ok(templates()[fragment]); + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(cancelled, true); + assert.equal(body.locked, false); +}); + +test('metadata-only EOF is not a successful empty state result', async t => { + const response = new Response(`${JSON.stringify(record())}\n`, { + headers: { 'content-type': 'application/x-ndjson' }, + }); + t.mock.method(globalThis, 'fetch', async () => response); + const loader = createLazyLoader({ transport: ndjson() }); + await assert.rejects(loader.fetchPartial(owner, id, { url: '/state' }), /before its state record/); + assert.equal(response.body?.locked, false); + assert.deepEqual(window.__webui?.state, {}); +}); + +test('the deadline also cancels a stalled module after the state body arrived', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + t.mock.method(globalThis, 'fetch', async () => Response.json(record({ ready: true }))); + const loader = createLazyLoader({ + timeoutMs: 50, + loaders: { [fragment]: () => new Promise(() => {}) }, + }); + const pending = loader.fetchPartial(owner, id, { url: '/state' }); + await new Promise(setImmediate); + t.mock.timers.tick(51); + await assert.rejects(pending, { name: 'TimeoutError' }); +}); + +test('a build mismatch fails before templates or inventory are mutated', async t => { + t.mock.method(globalThis, 'fetch', async () => Response.json({ ...record({ ready: true }), buildId: 'other-build' })); + await assert.rejects(createLazyLoader().fetchPartial(owner, id, { url: '/state' }), /different build/); + assert.deepEqual(templates(), {}); + assert.equal(window.__webui?.inventory, ''); +}); + +test('collection during a state request retries with fresh inventory and state', async t => { + templates()[fragment] = record().templates![fragment]; + window.__webui!.inventory = '01'; + const firstResponse = Promise.withResolvers(); + let requests = 0; + t.mock.method(globalThis, 'fetch', async (_url: unknown, options?: RequestInit) => { + requests++; + if (requests === 1) return firstResponse.promise; + assert.equal(new Headers(options?.headers).get('X-WebUI-Inventory'), null); + return Response.json(record({ revision: 2 })); + }); + const pending = createLazyLoader().fetchPartial(owner, id, { url: '/state' }); + clearInventory(); + delete templates()[fragment]; + const old = record({ revision: 1 }); + delete old.templates; + firstResponse.resolve(Response.json(old)); + assert.deepEqual(await pending, { revision: 2 }); + assert.equal(requests, 2); +}); + +test('invalid record identity, state, and inventory fail explicitly', async t => { + const malformed = [ + { ...record({}), fragment: 'different' }, + { ...record({}), inventory: 'f' }, + { ...record({}), state: [] }, + { ...record({}), required: [] }, + ]; + t.mock.method(globalThis, 'fetch', async () => Response.json(malformed.shift())); + for (let i = 0; i < 4; i++) { + await assert.rejects(createLazyLoader().fetchPartial(owner, id, { url: '/state' })); + } +}); + +test('a late incompatible document bootstrap fails before starting a request', async t => { + window.__webui = Object.assign({ buildId: 'preloaded-build' }, { templates: {} }); + t.mock.method(document, 'getElementById', () => ({ + textContent: '{"buildId":"document-build","state":{},"templates":{}}', + remove() {}, + })); + const network = t.mock.method(globalThis, 'fetch', async () => Response.json(record())); + await assert.rejects(createLazyLoader().ensureLoaded(owner, id), /different builds/); + assert.equal(network.mock.callCount(), 0); +}); diff --git a/packages/webui-delivery/src/lazy-loader.ts b/packages/webui-delivery/src/lazy-loader.ts new file mode 100644 index 000000000..59cdf7125 --- /dev/null +++ b/packages/webui-delivery/src/lazy-loader.ts @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { acceptInventory, acceptStyleInventory, inventoryEpoch } from './inventory.js'; +import { registerTemplatesAndStyles, waitForTemplateReadiness } from './templates.js'; +import { loadRuntimeBootstrap } from './runtime-bootstrap.js'; +import { abortable } from './abortable.js'; +import { ResourceRequest, resourceHeaders, validateTimeout, DEFAULT_TIMEOUT_MS } from './request.js'; +import { jsonTransport, type PartialTransport } from './transport.js'; +import { + lazyFragment, lazyRecord, readLazyBody, + type LazyReadContext, type LazyRecord, +} from './lazy-data.js'; + +/** Resource loading policy; no renderer or router is installed by this module. */ +export interface LazyLoaderOptions { + /** Resource-only endpoint. Defaults to /_webui/lazy. */ + endpoint?: string; + /** Resource id -> optional behavior import. Fragment bodies are not components. */ + loaders?: Record Promise>; + /** JSON by default; select ndjson() from resources.js for two-phase delivery. */ + transport?: PartialTransport; + /** Whole-request deadline, including lazy state. Defaults to 10 seconds. */ + timeoutMs?: number; +} + +/** The application's state endpoint and instance-local cancellation signal. */ +export interface LazyFetchOptions { + url: string; + signal?: AbortSignal; +} + +/** Shared resource preload with independent, uncached state requests. */ +export class LazyLoader { + private readonly pending = new Map }>(); + private readonly prepared = new Map(); + private readonly modules = new Map>(); + private readonly timeoutMs: number; + private readonly options: LazyLoaderOptions; + private epoch = -1; + + constructor(options: LazyLoaderOptions = {}) { + this.options = { ...options }; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + validateTimeout(this.timeoutMs); + } + + /** Preload an ordinary resource closure without fetching state or creating DOM. */ + async ensureLoaded(owner: string, id: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + loadRuntimeBootstrap(); + const fragment = lazyFragment(owner, id); + for (let attempt = 0; attempt < 2; attempt++) { + const epoch = this.currentEpoch(); + if (this.prepared.get(fragment) === epoch) return; + let pending = this.pending.get(fragment); + if (!pending || pending.epoch !== epoch) { + const entry = { epoch, promise: Promise.resolve() }; + entry.promise = this.preload(owner, id, fragment, epoch).finally(() => { + if (this.pending.get(fragment) === entry) this.pending.delete(fragment); + }); + this.pending.set(fragment, entry); + pending = entry; + } + if (!await waitForTemplateReadiness(pending.promise, signal)) signal?.throwIfAborted(); + if (epoch === inventoryEpoch()) return; + } + throw new Error('[WebUI] Resources were repeatedly collected during preload. Retry after collection finishes.'); + } + + /** Fetch fresh scoped state; the caller applies it to the existing owner. */ + async fetchPartial = Record>( + owner: string, + id: string, + options: LazyFetchOptions, + ): Promise { + loadRuntimeBootstrap(); + const fragment = lazyFragment(owner, id); + const request = new ResourceRequest(options.signal, this.timeoutMs); + const { controller, signal } = request; + const transport = this.options.transport ?? jsonTransport; + let failure: unknown; + try { + const initialModule = this.module(fragment); + if (initialModule) void initialModule.catch(error => { failure = error; controller.abort(error); }); + for (let attempt = 0; attempt < 2; attempt++) { + const epoch = this.currentEpoch(); + const response = await fetch(options.url, { headers: resourceHeaders(transport.accept), signal }); + if (!response.ok) throw new Error(`[WebUI] Lazy request failed: ${response.status} ${response.statusText}`); + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.includes('json')) throw new Error('[WebUI] Lazy state endpoints must return JSON or NDJSON.'); + const context: LazyReadContext = { + owner, fragment, signal, + abort: reason => controller.abort(reason), + prepare: record => this.prepare(record, epoch), + }; + const body = await transport.read(response, signal); + const state = await abortable(readLazyBody(body, context), signal); + if (initialModule) await abortable(initialModule, signal); + signal.throwIfAborted(); + if (epoch === inventoryEpoch()) return state as State; + } + throw new Error('[WebUI] Resources were repeatedly collected during loading. Retry after collection finishes.'); + } catch (error) { + controller.abort(failure ?? error); + throw failure ?? error; + } finally { + request.dispose(); + } + } + + private currentEpoch(): number { + const current = inventoryEpoch(); + if (current !== this.epoch) { + this.epoch = current; + this.prepared.clear(); + } + return current; + } + + private async preload(owner: string, id: string, fragment: string, epoch: number): Promise { + const request = new ResourceRequest(undefined, this.timeoutMs); + const { controller } = request; + let failure: unknown; + try { + const initialModule = this.module(fragment); + if (initialModule) void initialModule.catch(error => { failure = error; controller.abort(error); }); + const existing = window.__webuiResolveResources?.(fragment); + if (existing) { + const preparing = this.prepareRequired(existing); + if (preparing) await abortable(preparing, controller.signal); + if (epoch === inventoryEpoch()) this.prepared.set(fragment, epoch); + return; + } + const url = new URL(this.options.endpoint ?? '/_webui/lazy', window.location.href); + url.searchParams.set('owner', owner); + url.searchParams.set('id', id); + const response = await fetch(url, { headers: resourceHeaders('application/json'), signal: controller.signal }); + if (!response.ok) throw new Error(`[WebUI] Lazy preload failed: ${response.status} ${response.statusText}`); + if (!response.headers.get('content-type')?.includes('application/json')) { + throw new Error('[WebUI] Lazy resource endpoints must return JSON.'); + } + const record = lazyRecord(await response.json(), { owner, fragment }); + if (record.state !== undefined) throw new Error('[WebUI] Resource preloading must not fetch application state.'); + const preparing = this.prepare(record, epoch); + if (preparing && !await waitForTemplateReadiness(preparing, controller.signal)) controller.signal.throwIfAborted(); + if (initialModule) await abortable(initialModule, controller.signal); + controller.signal.throwIfAborted(); + } catch (error) { + controller.abort(failure ?? error); + throw failure ?? error; + } finally { + request.dispose(); + } + } + + private prepare(record: LazyRecord, epoch: number): Promise | undefined { + // The caller retries with fresh inventory rather than restoring an evicted batch. + if (epoch !== inventoryEpoch()) return; + const expected = window.__webui?.buildId; + if (expected && expected !== record.buildId) { + throw new Error('[WebUI] Lazy resources belong to a different build. Reload the document.'); + } + const runtime = window.__webui ??= {}; + if (!expected) { + const templates = Reflect.get(runtime, 'templates') as Record | undefined; + if (templates && Object.keys(templates).length > 0) { + throw new Error('[WebUI] Existing templates have no artifact identity. Rebuild the initial document.'); + } + runtime.buildId = record.buildId; + } + const available = Reflect.get(runtime, 'templates') as Record | undefined; + for (const id of record.required) { + if (!(record.templates && Object.hasOwn(record.templates, id)) && + !(available && Object.hasOwn(available, id)) && !window.__webuiRetainTemplate?.(id)) { + throw new Error(`[WebUI] Lazy response did not supply required resource "${id}". Retry without stale inventory.`); + } + } + if (!record.templates && !record.componentStyles && !record.templateFunctions && + record.required.every(id => this.prepared.get(id) === epoch)) { + acceptInventory(record.inventory, epoch); + acceptStyleInventory(record.styleInventory, epoch); + return; + } + const ready = registerTemplatesAndStyles( + record, runtime.nonce ?? '', + (inventory, styles) => { + acceptInventory(inventory, epoch); + if (styles !== undefined) acceptStyleInventory(styles, epoch); + }, + ); + const modules = this.prepareRequired(record.required); + const prepared = (): void => { + if (epoch !== inventoryEpoch()) return; + const required = record.required; + this.prepared.set(record.fragment, epoch); + for (let i = 0; i < required.length; i++) this.prepared.set(required[i], epoch); + }; + const pending = ready && modules ? Promise.all([ready, modules]) : ready ?? modules; + if (pending) return pending.then(prepared); + prepared(); + } + + private prepareRequired(required: readonly string[]): Promise | undefined { + let work: Promise[] | undefined; + for (let i = 0; i < required.length; i++) { + const pending = this.module(required[i]); + if (pending) (work ??= []).push(pending); + } + const resources = window.__webuiPrepareResources?.(required); + if (resources) (work ??= []).push(resources); + return work ? Promise.all(work).then(() => {}) : undefined; + } + + private module(id: string): Promise | undefined { + const load = this.options.loaders?.[id]; + if (!load) return undefined; + let pending = this.modules.get(id); + if (!pending) { + pending = Promise.resolve().then(load).then(() => {}); + this.modules.set(id, pending); + void pending.catch(() => { + if (this.modules.get(id) === pending) this.modules.delete(id); + }); + } + return pending; + } +} + +/** Create a loader without starting routing or installing a hydration runtime. */ +export function createLazyLoader(options?: LazyLoaderOptions): LazyLoader { + return new LazyLoader(options); +} diff --git a/packages/webui-router/src/loaders.ts b/packages/webui-delivery/src/loaders.ts similarity index 100% rename from packages/webui-router/src/loaders.ts rename to packages/webui-delivery/src/loaders.ts diff --git a/packages/webui-router/src/navigation-path.test.ts b/packages/webui-delivery/src/navigation-path.test.ts similarity index 100% rename from packages/webui-router/src/navigation-path.test.ts rename to packages/webui-delivery/src/navigation-path.test.ts diff --git a/packages/webui-router/src/navigation-path.ts b/packages/webui-delivery/src/navigation-path.ts similarity index 100% rename from packages/webui-router/src/navigation-path.ts rename to packages/webui-delivery/src/navigation-path.ts diff --git a/packages/webui-delivery/src/ndjson-reader.test.ts b/packages/webui-delivery/src/ndjson-reader.test.ts new file mode 100644 index 000000000..fe2f8332c --- /dev/null +++ b/packages/webui-delivery/src/ndjson-reader.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { NdjsonReader } from './ndjson-reader.js'; + +const encoder = new TextEncoder(); + +function body(parts: readonly Uint8Array[], cancel?: () => void): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const part of parts) controller.enqueue(part); + controller.close(); + }, + cancel, + }); +} + +test('reads split UTF-8, CRLF, blank lines, and an unterminated final record', async () => { + const bytes = encoder.encode('\n{"name":"café"}\r\n \n{"count":2}'); + const stream = body(Array.from(bytes, byte => new Uint8Array([byte]))); + const reader = new NdjsonReader(stream); + assert.deepEqual(await reader.read(), { name: 'café' }); + assert.deepEqual(await reader.read(), { count: 2 }); + assert.equal(await reader.read(), undefined); + assert.equal(stream.locked, false); +}); + +test('bounds individual record bytes rather than a coalesced transport chunk', async () => { + const stream = body([encoder.encode('{"n":1}\n{"n":2}\n{"n":3}\n')]); + const reader = new NdjsonReader(stream, { maxRecordBytes: 7 }); + assert.deepEqual(await reader.read(), { n: 1 }); + assert.deepEqual(reader.readBuffered(), { n: 2 }); + assert.deepEqual(reader.readBuffered(), { n: 3 }); + assert.equal(reader.readBuffered(), undefined); + assert.equal(await reader.read(), undefined); +}); + +test('drains the remaining records in order without retaining the first record', async () => { + const stream = body([encoder.encode('{"n":1}\n{"n":2}\n'), encoder.encode('{"n":3}')]); + const reader = new NdjsonReader(stream); + assert.deepEqual(await reader.read(), { n: 1 }); + const remaining: unknown[] = []; + await reader.drain(value => { remaining.push(value); }); + assert.deepEqual(remaining, [{ n: 2 }, { n: 3 }]); + assert.equal(stream.locked, false); +}); + +test('the record limit counts UTF-8 bytes, not decoded string length', async () => { + const bytes = encoder.encode('"é"'); + const exact = new NdjsonReader(body([bytes]), { maxRecordBytes: 4 }); + assert.equal(await exact.read(), 'é'); + assert.equal(await exact.read(), undefined); + + const oversized = new NdjsonReader(body([bytes]), { maxRecordBytes: 3 }); + await assert.rejects(oversized.read(), /exceeds 3 bytes/); +}); + +test('oversized split records fail before allocating an unbounded decoded string', async () => { + const stream = body([encoder.encode('{"a":'), encoder.encode('"1234567890"}\n')]); + const reader = new NdjsonReader(stream, { maxRecordBytes: 8 }); + await assert.rejects(reader.read(), /exceeds 8 bytes/); + assert.equal(stream.locked, false); +}); + +for (const [name, bytes, error] of [ + ['malformed JSON', encoder.encode('{"bad":}\n'), /Invalid NDJSON record/], + ['invalid UTF-8', new Uint8Array([34, 0xff, 34, 10]), /Invalid UTF-8/], +] as const) { + test(`${name} fails explicitly and releases the reader`, async () => { + const stream = body([bytes]); + const reader = new NdjsonReader(stream); + await assert.rejects(reader.read(), error); + assert.equal(stream.locked, false); + }); +} + +test('abort releases a stalled read without waiting for another network chunk', async () => { + let cancelled = false; + const stream = new ReadableStream({ + cancel() { cancelled = true; }, + }); + const controller = new AbortController(); + const reader = new NdjsonReader(stream, { signal: controller.signal }); + const pending = reader.read(); + controller.abort(new DOMException('Superseded', 'AbortError')); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(cancelled, true); + assert.equal(stream.locked, false); +}); + +test('explicit cancellation discards an unread tail and is idempotent', async () => { + let cancellations = 0; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"first":true}\n{"second":true}\n')); + }, + cancel() { cancellations++; }, + }); + const reader = new NdjsonReader(stream); + assert.deepEqual(await reader.read(), { first: true }); + await reader.cancel(); + await reader.cancel(); + assert.equal(cancellations, 1); + assert.equal(reader.readBuffered(), undefined); + assert.equal(await reader.read(), undefined); + assert.equal(stream.locked, false); +}); + +test('invalid buffered records also release an abandoned body', async () => { + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { controller.enqueue(encoder.encode('{}\ninvalid\n')); }, + cancel() { cancelled = true; }, + }); + const reader = new NdjsonReader(stream); + assert.deepEqual(await reader.read(), {}); + assert.throws(() => reader.readBuffered(), /Invalid NDJSON record/); + assert.equal(cancelled, true); + assert.equal(stream.locked, false); +}); + +test('concurrent reads fail without stealing the first operation', async () => { + let source!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { source = controller; }, + }); + const reader = new NdjsonReader(stream); + const first = reader.read(); + await assert.rejects(reader.read(), /Await the current NDJSON read/); + assert.throws(() => reader.readBuffered(), /Await the current NDJSON read/); + source.enqueue(encoder.encode('{"ok":true}\n')); + source.close(); + assert.deepEqual(await first, { ok: true }); + assert.equal(await reader.read(), undefined); +}); + +test('an aborted constructor does not lock the response body', () => { + const stream = body([]); + const controller = new AbortController(); + controller.abort(); + assert.throws(() => new NdjsonReader(stream, { signal: controller.signal }), { name: 'AbortError' }); + assert.equal(stream.locked, false); +}); + +test('invalid record budgets are rejected before reading', () => { + for (const maxRecordBytes of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1.5]) { + const stream = body([]); + assert.throws(() => new NdjsonReader(stream, { maxRecordBytes }), /positive safe integer/); + assert.equal(stream.locked, false); + } +}); diff --git a/packages/webui-delivery/src/ndjson-reader.ts b/packages/webui-delivery/src/ndjson-reader.ts new file mode 100644 index 000000000..388a4335f --- /dev/null +++ b/packages/webui-delivery/src/ndjson-reader.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** Bounds one UTF-8 record, excluding its LF terminator. */ +export interface NdjsonReaderOptions { + /** Defaults to 256 KiB. A coalesced read may contain several bounded records. */ + maxRecordBytes?: number; + /** Aborting cancels the body and releases its reader immediately. */ + signal?: AbortSignal; +} + +const DEFAULT_RECORD_BYTES = 256 * 1024; + +/** + * A bounded NDJSON reader independent of routing, resources, and DOM rendering. + * + * Retains at most one transport chunk and a reusable bounded record buffer. + * Records are decoded once, including UTF-8 sequences split across reads. + * Consumers own record validation and must cancel an abandoned response. + */ +export class NdjsonReader { + private reader: ReadableStreamDefaultReader | undefined; + private chunk: Uint8Array | undefined; + private offset = 0; + private scratch: Uint8Array | undefined; + private length = 0; + private ended = false; + private reading = false; + private readonly limit: number; + private readonly signal: AbortSignal | undefined; + private readonly decoder = new TextDecoder('utf-8', { fatal: true }); + + constructor(body: ReadableStream, options: NdjsonReaderOptions = {}) { + this.limit = options.maxRecordBytes ?? DEFAULT_RECORD_BYTES; + if (!Number.isSafeInteger(this.limit) || this.limit <= 0) { + throw new RangeError('[WebUI] NDJSON maxRecordBytes must be a positive safe integer.'); + } + this.signal = options.signal; + this.signal?.throwIfAborted(); + this.reader = body.getReader(); + this.signal?.addEventListener('abort', this.onAbort, { once: true }); + } + + /** Read the next nonblank record, or undefined after a clean end of body. */ + async read(): Promise { + if (this.reading) throw new Error('[WebUI] Await the current NDJSON read before starting another.'); + this.reading = true; + try { + while (true) { + this.signal?.throwIfAborted(); + const record = this.takeBuffered(); + if (record !== undefined || this.ended) return record; + const reader = this.reader; + if (!reader) return undefined; + const result = await reader.read(); + this.signal?.throwIfAborted(); + if (result.done) { + this.ended = true; + this.releaseReader(); + } else { + this.chunk = result.value; + this.offset = 0; + } + } + } catch (error) { + try { + await this.cancel(error); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], '[WebUI] NDJSON reading and cancellation failed.'); + } + throw error; + } finally { + this.reading = false; + } + } + + /** + * Consume a complete record already received, without requesting more bytes. + * Returns undefined when no complete record is buffered. + */ + readBuffered(): unknown | undefined { + if (this.reading) throw new Error('[WebUI] Await the current NDJSON read before consuming buffered records.'); + try { + return this.takeBuffered(); + } catch (error) { + void this.cancel(error).catch(cleanupError => { + console.warn('[WebUI] Failed to cancel an invalid NDJSON body:', cleanupError); + }); + throw error; + } + } + + /** Deliver remaining records without retaining them or imposing a consumer policy. */ + async drain(consume: (value: unknown) => void): Promise { + while (true) { + const value = await this.read(); + if (value === undefined) return; + consume(value); + } + } + + private takeBuffered(): unknown | undefined { + while (this.chunk) { + const chunk = this.chunk; + const newline = chunk.indexOf(10, this.offset); + const end = newline === -1 ? chunk.length : newline; + const part = chunk.subarray(this.offset, end); + const size = this.length + part.length; + if (size > this.limit) throw this.recordLimit(); + this.offset = end + 1; + if (this.offset >= chunk.length) { + this.chunk = undefined; + this.offset = 0; + } + if (newline === -1) { + this.append(part); + break; + } + let record: unknown; + if (this.length > 0) { + this.append(part); + record = this.decode(this.scratch!.subarray(0, this.length)); + this.length = 0; + } else { + record = this.decode(part); + } + if (record !== undefined) return record; + } + if (this.ended && this.length > 0) { + const record = this.decode(this.scratch!.subarray(0, this.length)); + this.length = 0; + this.scratch = undefined; + return record; + } + if (this.ended) this.scratch = undefined; + return undefined; + } + + /** Release buffered bytes and cancel an unread body. Safe to call repeatedly. */ + async cancel(reason?: unknown): Promise { + const reader = this.reader; + this.ended = true; + this.chunk = undefined; + this.scratch = undefined; + this.length = 0; + this.offset = 0; + if (!reader) return; + try { + const cancellation = reader.cancel(reason); + this.releaseReader(); + await cancellation; + } finally { + this.releaseReader(); + } + } + + private readonly onAbort = (): void => { + void this.cancel(this.signal?.reason).catch(error => { + console.warn('[WebUI] Failed to cancel an aborted NDJSON body:', error); + }); + }; + + private releaseReader(): void { + this.signal?.removeEventListener('abort', this.onAbort); + this.reader?.releaseLock(); + this.reader = undefined; + } + + private append(part: Uint8Array): void { + if (part.length === 0) return; + const size = this.length + part.length; + if (size > this.limit) throw this.recordLimit(); + if (!this.scratch || this.scratch.length < size) { + const capacity = Math.min(this.limit, Math.max(size, (this.scratch?.length ?? 1024) * 2)); + const next = new Uint8Array(capacity); + if (this.scratch) next.set(this.scratch.subarray(0, this.length)); + this.scratch = next; + } + this.scratch.set(part, this.length); + this.length = size; + } + + private decode(bytes: Uint8Array): unknown { + let text: string; + try { + text = this.decoder.decode(bytes); + } catch (cause) { + throw new Error('[WebUI] Invalid UTF-8 in NDJSON response. Send UTF-8 encoded JSON records.', { cause }); + } + if (!text.trim()) return undefined; + try { + return JSON.parse(text); + } catch (cause) { + throw new Error('[WebUI] Invalid NDJSON record. Send one complete JSON value per line.', { cause }); + } + } + + private recordLimit(): RangeError { + return new RangeError( + `[WebUI] NDJSON record exceeds ${this.limit} bytes. Reduce the response record or select an explicit larger budget.`, + ); + } +} diff --git a/packages/webui-delivery/src/ndjson.ts b/packages/webui-delivery/src/ndjson.ts new file mode 100644 index 000000000..5183ac1fc --- /dev/null +++ b/packages/webui-delivery/src/ndjson.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { NdjsonReader } from './ndjson-reader.js'; +import { jsonTransport, type PartialTransport } from './transport.js'; + +/** Select bounded NDJSON delivery, accepting a complete JSON response as well. */ +export function ndjson(maxRecordBytes = 256 * 1024): PartialTransport { + if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes <= 0) { + throw new RangeError('[WebUI] maxRecordBytes must be a positive safe integer.'); + } + return { + accept: 'application/x-ndjson, application/json', + async read(response, signal) { + if (!response.headers.get('content-type')?.includes('ndjson')) { + return jsonTransport.read(response, signal); + } + if (!response.body) throw new Error('[WebUI] NDJSON response has no body.'); + const records = new NdjsonReader(response.body, { signal, maxRecordBytes }); + try { + return { data: await records.read(), records }; + } catch (error) { + await records.cancel(error); + throw error; + } + }, + }; +} diff --git a/packages/webui-delivery/src/partial-json.ts b/packages/webui-delivery/src/partial-json.ts new file mode 100644 index 000000000..6ff68eb80 --- /dev/null +++ b/packages/webui-delivery/src/partial-json.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + injectCssLinks, + registerTemplatesAndStyles, + waitForTemplateReadiness, +} from './templates.js'; +import type { TemplateResourceResponse } from './templates.js'; + +/** Resource publication/readiness shared by non-streaming partial consumers. */ +export interface PartialResourceContext { + readonly nonce: string; + readonly injectedCss: Set; + updateInventory(inventory: string, styleInventory?: string): void; +} + +/** Decode a JSON partial and prepare its resources without route reconciliation. */ +export async function readJsonPartial( + response: Response, + context: PartialResourceContext, + signal?: AbortSignal, +): Promise { + const data: unknown = await response.json(); + return preparePartial(data, context, signal); +} + +/** Publish a decoded resource envelope without imposing navigation or state policy. */ +export async function preparePartial( + data: unknown, + context: PartialResourceContext, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return null; + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('[WebUI] A partial response must be a JSON object.'); + } + const partial = data as T; + const ready = registerTemplatesAndStyles( + partial, context.nonce, (inventory, styles) => context.updateInventory(inventory, styles), + ); + injectCssLinks(partial, context.injectedCss); + if (ready && !await waitForTemplateReadiness(ready, signal)) return null; + return signal?.aborted ? null : partial; +} diff --git a/packages/webui-delivery/src/partial-response.ts b/packages/webui-delivery/src/partial-response.ts new file mode 100644 index 000000000..b23418182 --- /dev/null +++ b/packages/webui-delivery/src/partial-response.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { PartialResponse } from './cache.js'; + +/** Partial metadata that may still own an unread response tail. */ +export interface StreamingPartialResponse extends PartialResponse { + readonly _deferredStream?: true; + inventory?: string; + styleInventory?: string; + buildId?: string; +} + +export interface DeferredStreamControl { + cancel(): Promise; + start(): void; +} + +const deferredStreams = new WeakMap(); + +export function attachDeferredStream( + data: StreamingPartialResponse, + control: DeferredStreamControl, +): void { + deferredStreams.set(data, control); + Object.defineProperty(data, '_deferredStream', { configurable: true, value: true }); +} + +export function releaseDeferredStream(data: PartialResponse): void { + deferredStreams.delete(data); + Reflect.deleteProperty(data, '_deferredStream'); +} + +/** Whether a partial response still owns deferred state delivery. */ +export function hasDeferredStream(data: PartialResponse): data is StreamingPartialResponse { + return deferredStreams.has(data); +} + +/** Begin deferred state delivery only after the consumer commits its UI. */ +export function startDeferredStream(data: PartialResponse): void { + deferredStreams.get(data)?.start(); +} + +/** Cancel an abandoned partial without importing a stream decoder. */ +export async function cancelDeferredStream(data: PartialResponse): Promise { + const control = deferredStreams.get(data); + releaseDeferredStream(data); + await control?.cancel(); +} diff --git a/packages/webui-router/src/pending.ts b/packages/webui-delivery/src/pending.ts similarity index 100% rename from packages/webui-router/src/pending.ts rename to packages/webui-delivery/src/pending.ts diff --git a/packages/webui-router/src/preload-path.ts b/packages/webui-delivery/src/preload-path.ts similarity index 100% rename from packages/webui-router/src/preload-path.ts rename to packages/webui-delivery/src/preload-path.ts diff --git a/packages/webui-router/src/preload.ts b/packages/webui-delivery/src/preload.ts similarity index 66% rename from packages/webui-router/src/preload.ts rename to packages/webui-delivery/src/preload.ts index 2d785f8bb..68a4d9921 100644 --- a/packages/webui-router/src/preload.ts +++ b/packages/webui-delivery/src/preload.ts @@ -7,8 +7,44 @@ */ import { pointerPreloadPath } from './preload-path.js'; +import { PreparedRoutePreload, type PreparedRoutePreloadOptions } from './prepared-preload.js'; import type { PartialResponse } from './cache.js'; import type { StreamingPartialResponse } from './streaming.js'; +import { cancelDeferredStream, startDeferredStream } from './partial-response.js'; + +/** One intent policy, optionally prepared before application activation and adopted at start. */ +export interface RoutePreload { + readonly prepared: PreparedRoutePreload | undefined; + prepare(): void; + destroy(): void; + install(context: PreloadContext): () => void; +} + +/** Select intent preloading; call prepare() only to preload before router startup. */ +export function routePreload(options: PreparedRoutePreloadOptions = {}): RoutePreload { + let prepared: PreparedRoutePreload | undefined; + let active: (() => void) | undefined; + const destroy = (): void => { + active?.(); + active = undefined; + prepared?.destroy(); + prepared = undefined; + }; + return { + get prepared() { return prepared; }, + prepare() { + if (active) throw new Error('[Router] Prepare intent preloading before router.start(), not after it.'); + prepared ??= new PreparedRoutePreload(options); + }, + destroy, + install(context) { + if (active) throw new Error('[Router] A preload policy is already installed.'); + prepared?.detach(); + active = setupPreloadListeners(context); + return destroy; + }, + }; +} /** Context needed by preload listeners to interact with router state. */ export interface PreloadContext { @@ -65,11 +101,10 @@ export function setupPreloadListeners(ctx: PreloadContext): () => void { } if (!data?._deferredStream) return; - const streaming = await import('./streaming.js'); if (gen === preloadGeneration && !controller.signal.aborted) { - streaming.startDeferredStream(data); + startDeferredStream(data); } else { - await streaming.cancelDeferredStream(data); + await cancelDeferredStream(data); } }) .catch(() => {}) // Speculative — silently discard errors diff --git a/packages/webui-router/src/prepared-preload.test.ts b/packages/webui-delivery/src/prepared-preload.test.ts similarity index 100% rename from packages/webui-router/src/prepared-preload.test.ts rename to packages/webui-delivery/src/prepared-preload.test.ts diff --git a/packages/webui-router/src/prepared-preload.ts b/packages/webui-delivery/src/prepared-preload.ts similarity index 99% rename from packages/webui-router/src/prepared-preload.ts rename to packages/webui-delivery/src/prepared-preload.ts index 18f4a4474..bea220be5 100644 --- a/packages/webui-router/src/prepared-preload.ts +++ b/packages/webui-delivery/src/prepared-preload.ts @@ -220,7 +220,7 @@ async function fetchRawPartial( const signal = controller.signal; try { const headers: Record = { - Accept: 'application/x-ndjson, application/json', + Accept: 'application/json', }; if (inventory) headers['X-WebUI-Inventory'] = inventory; const response = await fetch(path, { headers, signal }); diff --git a/packages/webui-delivery/src/public-api.test.ts b/packages/webui-delivery/src/public-api.test.ts new file mode 100644 index 000000000..8d4b63253 --- /dev/null +++ b/packages/webui-delivery/src/public-api.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import './browser-shim.js'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import type { RouterConfig } from '@microsoft/webui-delivery/router.js'; +import type { LazyLoaderOptions } from '@microsoft/webui-delivery/lazy.js'; +import type { PartialTransport } from '@microsoft/webui-delivery/resources.js'; + +test('delivery has three task entrypoints and no compatibility facade', () => { + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + assert.equal(manifest.name, '@microsoft/webui-delivery'); + assert.deepEqual(Object.keys(manifest.exports).sort(), ['./lazy.js', './resources.js', './router.js']); + for (const entry of [ + '@microsoft/webui-delivery', '@microsoft/webui-delivery/core.js', + '@microsoft/webui-delivery/preload.js', '@microsoft/webui-delivery/lazy-ndjson.js', + '@microsoft/webui-framework/core.js', + ]) { + assert.throws(() => import.meta.resolve(entry), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + } +}); + +test('importing public delivery APIs does not construct or start a router', async t => { + let listeners = 0; + t.mock.method(window, 'addEventListener', () => { listeners++; }); + t.mock.method(document, 'addEventListener', () => { listeners++; }); + const api = await import('@microsoft/webui-delivery/router.js'); + assert.equal(typeof api.WebUIRouter, 'function'); + assert.equal(typeof api.parseQuery, 'function'); + assert.equal(Reflect.has(api, 'Router'), false); + assert.equal(listeners, 0); +}); + +test('one public transport type configures both consumers', async () => { + const { ndjson } = await import('@microsoft/webui-delivery/resources.js'); + const transport: PartialTransport = ndjson(); + const routing: RouterConfig = { transport }; + const regions: LazyLoaderOptions = { transport }; + assert.equal(routing.transport, regions.transport); + for (const invalid of [0, -1, NaN, Infinity, 0.5]) { + assert.throws(() => ndjson(invalid), RangeError); + } + const signal = new AbortController().signal; + const body = await transport.read(Response.json({ state: { title: 'Ready' } }), signal); + assert.deepEqual(body.data, { state: { title: 'Ready' } }); + assert.equal(body.records, undefined); +}); diff --git a/packages/webui-delivery/src/request.test.ts b/packages/webui-delivery/src/request.test.ts new file mode 100644 index 000000000..2dfcc7f83 --- /dev/null +++ b/packages/webui-delivery/src/request.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import './browser-shim.js'; +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { ResourceRequest, resourceHeaders } from './request.js'; + +test('resource request headers share build and both inventory identities', () => { + const previous = window.__webui; + try { + window.__webui = { inventory: '01', styleInventory: '04', buildId: 'build-a' }; + assert.deepEqual(resourceHeaders('application/json'), { + Accept: 'application/json', + 'X-WebUI-Inventory': '01', + 'X-WebUI-Style-Inventory': '04', + 'X-WebUI-Build': 'build-a', + }); + } finally { + window.__webui = previous; + } +}); + +test('resource request cancellation preserves the reason and releases its deadline', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const controller = new AbortController(); + const request = new ResourceRequest(controller.signal, 20); + const reason = new DOMException('Superseded', 'AbortError'); + controller.abort(reason); + assert.equal(request.signal.aborted, true); + assert.equal(request.signal.reason, reason); + t.mock.timers.tick(21); + assert.equal(request.signal.reason, reason); + request.dispose(); +}); + +test('completed resource requests release their timer and external abort listener', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const external = new AbortController(); + const request = new ResourceRequest(external.signal, 20); + request.dispose(); + external.abort(); + t.mock.timers.tick(21); + assert.equal(request.signal.aborted, false); +}); + +test('resource deadlines bound the entire request and reject invalid configuration', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + for (const timeout of [0, -1, Infinity, NaN]) { + assert.throws(() => new ResourceRequest(undefined, timeout), RangeError); + } + const request = new ResourceRequest(undefined, 20); + t.mock.timers.tick(21); + assert.equal(request.signal.reason.name, 'TimeoutError'); + const external = new AbortController(); + external.abort(); + assert.throws(() => new ResourceRequest(external.signal), { name: 'AbortError' }); +}); diff --git a/packages/webui-delivery/src/request.ts b/packages/webui-delivery/src/request.ts new file mode 100644 index 000000000..6280d5457 --- /dev/null +++ b/packages/webui-delivery/src/request.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** Whole-request defaults, including an unread or deferred state tail. */ +export const DEFAULT_TIMEOUT_MS = 10_000; + +export function validateTimeout(timeoutMs: number): void { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError('[WebUI] timeoutMs must be positive and finite.'); + } +} + +/** Request lifetime shared by route and region consumers; neither owns shared resources. */ +export class ResourceRequest { + readonly controller = new AbortController(); + readonly signal: AbortSignal; + private timer: ReturnType | undefined; + + constructor(private readonly externalSignal?: AbortSignal, timeoutMs = DEFAULT_TIMEOUT_MS) { + validateTimeout(timeoutMs); + externalSignal?.throwIfAborted(); + this.signal = this.controller.signal; + this.timer = setTimeout(() => { + this.controller.abort(new DOMException('Partial response timed out', 'TimeoutError')); + }, timeoutMs); + this.signal.addEventListener('abort', this.dispose, { once: true }); + externalSignal?.addEventListener('abort', this.abort, { once: true }); + } + + private readonly abort = (): void => this.controller.abort(this.externalSignal?.reason); + + readonly dispose = (): void => { + clearTimeout(this.timer); + this.timer = undefined; + this.signal.removeEventListener('abort', this.dispose); + this.externalSignal?.removeEventListener('abort', this.abort); + }; +} + +/** Resource availability is build-scoped; state freshness is not inferred from it. */ +export function resourceHeaders(accept: string): Record { + const runtime = window.__webui; + const headers: Record = { Accept: accept }; + if (runtime?.inventory) headers['X-WebUI-Inventory'] = runtime.inventory; + if (runtime?.styleInventory) headers['X-WebUI-Style-Inventory'] = runtime.styleInventory; + if (runtime?.buildId) headers['X-WebUI-Build'] = runtime.buildId; + return headers; +} diff --git a/packages/webui-delivery/src/resources-entry.ts b/packages/webui-delivery/src/resources-entry.ts new file mode 100644 index 000000000..dc6037dea --- /dev/null +++ b/packages/webui-delivery/src/resources-entry.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { + fetchComponentTemplates, + registerTemplatesAndStyles, + waitForTemplateReadiness, +} from './templates.js'; +export { readJsonPartial } from './partial-json.js'; +export type { PartialResourceContext } from './partial-json.js'; +export { ndjson } from './ndjson.js'; +export type { PartialTransport, PartialBody, PartialRecords } from './transport.js'; +export type { TemplateResourceResponse } from './templates.js'; +export type { ComponentStyles, ComponentStyleResource } from './types.js'; diff --git a/packages/webui-router/src/route-boundary.test.ts b/packages/webui-delivery/src/route-boundary.test.ts similarity index 100% rename from packages/webui-router/src/route-boundary.test.ts rename to packages/webui-delivery/src/route-boundary.test.ts diff --git a/packages/webui-router/src/route-boundary.ts b/packages/webui-delivery/src/route-boundary.ts similarity index 100% rename from packages/webui-router/src/route-boundary.ts rename to packages/webui-delivery/src/route-boundary.ts diff --git a/packages/webui-router/src/route-element.ts b/packages/webui-delivery/src/route-element.ts similarity index 100% rename from packages/webui-router/src/route-element.ts rename to packages/webui-delivery/src/route-element.ts diff --git a/packages/webui-router/src/router.ts b/packages/webui-delivery/src/router-core.ts similarity index 77% rename from packages/webui-router/src/router.ts rename to packages/webui-delivery/src/router-core.ts index 9533d88b8..e23094e1a 100644 --- a/packages/webui-router/src/router.ts +++ b/packages/webui-delivery/src/router-core.ts @@ -26,15 +26,16 @@ import { setupFallbackNavigation, } from './fallback-navigation.js'; import { isStateful } from './types.js'; -import type { RouterConfig, NavigationEvent, CacheConfig } from './types.js'; +import type { RouterConfig, NavigationEvent } from './types.js'; +import { ResourceRequest, resourceHeaders, validateTimeout, DEFAULT_TIMEOUT_MS } from './request.js'; +import { jsonTransport } from './transport.js'; +import { readStreamingPartial } from './streaming.js'; +import { acceptInventory, acceptStyleInventory, clearInventory, inventoryEpoch } from './inventory.js'; +import { loadRuntimeBootstrap } from './runtime-bootstrap.js'; import type { NavigationTarget } from './navigation-path.js'; import { ROUTE_SELECTOR, hasState, - renderRoot, - routePath, - isExact, - routeComponent, parseQuery, filterQuery, activateRoute, @@ -55,17 +56,18 @@ import type { RawPreloadedPartial, } from './prepared-preload.js'; import { - registerTemplatesAndStyles, - injectCssLinks, fetchComponentTemplates, notifyTemplatesRegistered, registerInitialTemplatesAndStyles, waitForTemplateReadiness, } from './templates.js'; -import type { - StreamingContext, - StreamingPartialResponse, -} from './streaming.js'; +import type { StreamingContext } from './streaming.js'; +import { + cancelDeferredStream, + hasDeferredStream, + startDeferredStream, + type StreamingPartialResponse, +} from './partial-response.js'; import { ensureComponentLoaded, resolveLoaders, LOADER_FAILED } from './loaders.js'; import { buildChainFromSSR, @@ -73,15 +75,10 @@ import { findOrCreateRouteElement, sameRouteDeclaration, } from './chain.js'; -import { findErrorComponent, findPendingComponent } from './route-boundary.js'; export { parseQuery, filterQuery, WebUIRouteElement }; const SSR_PRELOAD_SELECTOR = 'link[data-webui-ssr-preload]'; -const WEBUI_DATA_ID = 'webui-data'; -const DISABLE_DOCUMENT_VIEW_TRANSITION = '@view-transition { navigation: none; }'; -const PARTIAL_FETCH_TIMEOUT_MS = 10_000; -let webuiDataLoaded = false; type RouterRuntimeGlobal = WebUIRuntimeGlobal & { templates?: Record; @@ -93,8 +90,14 @@ interface PartialReadResult { readonly hasDeferredReader: boolean; } +interface PartialReadOptions { + speculative?: boolean; + epoch?: number; +} + export class WebUIRouter { private config: RouterConfig = {}; + private timeoutMs = DEFAULT_TIMEOUT_MS; private started = false; private cleanupFns: Array<() => void> = []; private isInitialNavigation = true; @@ -107,11 +110,7 @@ export class WebUIRouter { private navGeneration = 0; private currentRequestPath = '/'; private navCache: import('./cache.js').NavigationCache | null = null; - private navCacheLoad: Promise | null = null; private preparedPreload: PreparedRoutePreload | null = null; - private cacheLoadGeneration = 0; - private cacheEnabled = false; - private cacheConfig: Required = { staleTime: 0, gcTime: 300_000, maxEntries: 50 }; private actionController: AbortController | null = null; private deferredReader: Promise | null = null; private deferredGeneration = 0; @@ -134,11 +133,11 @@ export class WebUIRouter { return leaf?.component ?? ''; } - private async pendingState(): Promise { + private pendingState(): PendingState { if (this.pending) return this.pending; - const { PendingState } = await import('./pending.js'); - this.pending = new PendingState(); - return this.pending; + const create = this.config.boundaries?.create; + if (!create) throw new Error('[Router] Route boundaries require the boundaries capability.'); + return this.pending = create(); } private clearPendingTimer(): void { @@ -162,6 +161,16 @@ export class WebUIRouter { this.partialCleanups.delete(controller); } + private trackPartialRequest(controller: AbortController, cleanup: () => void): void { + this.partialControllers.add(controller); + const release = (): void => this.releasePartialRequest(controller); + controller.signal.addEventListener('abort', release, { once: true }); + this.partialCleanups.set(controller, () => { + controller.signal.removeEventListener('abort', release); + cleanup(); + }); + } + private invalidateBoundaryState(expectedGeneration?: number): number | null { if ( expectedGeneration !== undefined && @@ -189,7 +198,7 @@ export class WebUIRouter { /** Start the router. Lazily registers the `` custom element. */ start(config: RouterConfig = {}): void { - if (this.started) return; + if (this.started) throw new Error('[Router] Routing is already started. Destroy it before starting with another configuration.'); this.started = true; try { this.initialize(config); @@ -200,29 +209,22 @@ export class WebUIRouter { } private initialize(config: RouterConfig): void { - this.config = config; + this.validateCapabilities(config); + this.config = { ...config }; + this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + validateTimeout(this.timeoutMs); this.loaders = config.loaders ?? {}; this.basePath = document.querySelector('base')?.getAttribute('href')?.replace(/\/+$/, '') ?? ''; this.excludePaths = config.excludePaths ?? []; - this.preparedPreload = - typeof config.preload === 'object' ? config.preload : null; + this.preparedPreload = config.preload?.prepared ?? null; this.preparedPreload?.detach(); - this.cacheEnabled = config.cache !== undefined || !!config.preload; - - if (config.cache) { - this.cacheConfig = { - staleTime: config.cache.staleTime ?? 0, - gcTime: config.cache.gcTime ?? 300_000, - maxEntries: config.cache.maxEntries ?? 50, - }; - } - if (this.cacheEnabled) void this.ensureNavigationCache(); + this.navCache = config.cache ?? null; if (!customElements.get(ROUTE_SELECTOR)) { customElements.define(ROUTE_SELECTOR, WebUIRouteElement); } - loadWebUIDataBlock(); + loadRuntimeBootstrap(); // Normalize window.__webui — ensure it exists with sensible defaults. // Serves as the single source of truth for SSR metadata. @@ -290,15 +292,8 @@ export class WebUIRouter { if (config.preload) { const self = this; - let cleanup: (() => void) | undefined; - let cancelled = false; - this.cleanupFns.push(() => { - cancelled = true; - cleanup?.(); - }); - void Promise.all([this.ensureNavigationCache(), import('./preload.js')]).then(([cache, { setupPreloadListeners }]) => { - if (cancelled || !this.started) return; - cleanup = setupPreloadListeners({ + const cache = this.navCache!; + this.cleanupFns.push(config.preload.install({ basePath: this.basePath, excludePaths: this.excludePaths, get currentRequestPath() { return self.currentRequestPath; }, @@ -307,34 +302,33 @@ export class WebUIRouter { storeCache: (p, d, pre, streaming) => cache.store(p, d, pre, streaming), fetchPartial: (p, s, spec) => this.fetchPartial(p, s, spec), - }); - if (cancelled) cleanup(); - }); + })); } if (config.actions) { const selfAction = this; - let cleanup: (() => void) | undefined; - let cancelled = false; - this.cleanupFns.push(() => { - cancelled = true; - cleanup?.(); - }); - void import('./actions.js').then(({ setupFormInterception }) => { - if (cancelled || !this.started) return; - cleanup = setupFormInterception({ - get activeChain() { return selfAction.activeChain; }, - get currentRequestPath() { return selfAction.currentRequestPath; }, - setActionController: (c) => { this.actionController = c; }, - invalidateTags: (tags) => this.invalidateTags(tags), - }); - if (cancelled) cleanup(); - }); + this.cleanupFns.push(config.actions({ + get activeChain() { return selfAction.activeChain; }, + get currentRequestPath() { return selfAction.currentRequestPath; }, + setActionController: (c) => { this.actionController = c; }, + invalidateTags: (tags) => this.invalidateTags(tags), + })); } this.startInitialNavigation(meta); } + private validateCapabilities(config: RouterConfig): void { + if (config.cache && typeof config.cache.lookup !== 'function') + throw new Error('[Router] Select a configured navigationCache() in start().'); + if (config.preload && (!config.cache || typeof config.preload.install !== 'function')) + throw new Error('[Router] Select routePreload() with a configured navigationCache().'); + if (config.actions && typeof config.actions !== 'function') + throw new Error('[Router] Select routeActions() in start().'); + if (config.transport && (typeof config.transport.read !== 'function' || !config.transport.accept)) + throw new Error('[Router] Select a valid partial transport in start().'); + } + /** Navigate to a new path. */ navigate(path: string): void { const fullPath = prependBasePath(path, this.basePath); @@ -373,7 +367,7 @@ export class WebUIRouter { const missing: string[] = []; for (const tag of tags) { - if (!registry?.[tag] && !this.loadPromises.has(tag)) { + if (!registry?.[tag] && !window.__webuiRetainTemplate?.(tag) && !this.loadPromises.has(tag)) { missing.push(tag); } } @@ -381,6 +375,7 @@ export class WebUIRouter { const promises: Promise[] = []; if (missing.length > 0) { + const epoch = inventoryEpoch(); const inv = window.__webui!.inventory!; const endpoint = this.config.templateEndpoint ?? '/_webui/templates'; const fetchPromise = fetchComponentTemplates( @@ -388,10 +383,11 @@ export class WebUIRouter { inv, endpoint, window.__webui!.nonce!, - this.cssSet, - (inv) => this.updateInventory(inv), + (inv, styles) => this.updateInventory(inv, epoch, styles), ).finally(() => { - for (const tag of missing) this.loadPromises.delete(tag); + for (const tag of missing) { + if (this.loadPromises.get(tag) === fetchPromise) this.loadPromises.delete(tag); + } }); for (const tag of missing) this.loadPromises.set(tag, fetchPromise); promises.push(fetchPromise); @@ -407,9 +403,28 @@ export class WebUIRouter { /** Garbage-collect all cached templates to free memory. */ gc(): void { + const collect = window.__webuiCollectTemplates; + if (collect) { + const keep: string[] = []; + for (const entry of this.activeChain) { + if (entry.pendingComponent) keep.push(entry.pendingComponent); + if (entry.errorComponent) keep.push(entry.errorComponent); + } + collect(keep); + this.navCache?.clear(); + return; + } const runtime = window.__webui as RouterRuntimeGlobal | undefined; const registry = runtime?.templates; if (registry) { + for (const template of Object.values(registry)) { + if (template && typeof template === 'object') { + throw new Error( + '[Router] Template collection requires an ownership-aware cache adapter. ' + + "Import your renderer's template-cache entry before component definitions.", + ); + } + } for (const tag of Object.keys(registry)) { delete registry[tag]; } @@ -420,7 +435,8 @@ export class WebUIRouter { delete functionRegistry[tag]; } } - if (window.__webui) window.__webui.inventory = ''; + clearInventory(); + this.navCache?.clear(); } /** Tear down. */ @@ -441,12 +457,8 @@ export class WebUIRouter { this.currentRequestPath = '/'; this.navCache?.clear(); this.navCache = null; - this.navCacheLoad = null; this.preparedPreload?.destroy(); this.preparedPreload = null; - this.cacheLoadGeneration += 1; - this.cacheEnabled = false; - this.cacheConfig = { staleTime: 0, gcTime: 300_000, maxEntries: 50 }; this.actionController?.abort(); this.actionController = null; this.navGeneration++; @@ -454,6 +466,7 @@ export class WebUIRouter { this.destroyPending(); this.abortPartialRequests(); this.deferredReader = null; + this.config = {}; } // ── Core Navigation ───────────────────────────────────────────── @@ -496,9 +509,7 @@ export class WebUIRouter { // DOMContentLoaded — free it to reduce memory. delete window.__webui!.state; - if (this.config.dev) { - this.validateRoutes(); - } + this.config.diagnostics?.(this.activeChain); } else { this.clearSsrPreloads(); this.actionController?.abort(); @@ -510,7 +521,7 @@ export class WebUIRouter { try { if (thisGen !== this.navGeneration) return; this.abortPartialRequests(); - const navCache = this.cacheEnabled ? await this.ensureNavigationCache() : null; + const navCache = this.navCache; if (thisGen !== this.navGeneration) return; navCache?.gc(); @@ -519,12 +530,13 @@ export class WebUIRouter { if (cached) { partialData = cached; } else { - const pendingBoundary = findPendingComponent(this.activeChain, requestPath); + const pendingBoundary = this.config.boundaries?.pending(this.activeChain, requestPath); if (pendingBoundary && !pendingBoundary.keepAlive) { this.pendingTimer = setTimeout(() => { if (boundaryGen !== this.boundaryGeneration) return; this.pendingTimer = null; - void this.pendingState().then((pending) => { + try { + const pending = this.pendingState(); if ( thisGen === this.navGeneration && boundaryGen === this.boundaryGeneration @@ -534,7 +546,10 @@ export class WebUIRouter { pendingBoundary.container, ); } - }); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + console.error('[Router] Pending UI preparation failed:', error); + } }, 150); } @@ -543,11 +558,11 @@ export class WebUIRouter { ?? await this.fetchPartial(requestPath, signal); if (!partialData && !signal?.aborted && thisGen === this.navGeneration) { - const errorBoundary = findErrorComponent(this.activeChain, requestPath); + const errorBoundary = this.config.boundaries?.error(this.activeChain, requestPath); if (errorBoundary) { const errorBoundaryGen = this.invalidateBoundaryState(boundaryGen); if (errorBoundaryGen === null) return; - const pending = await this.pendingState(); + const pending = this.pendingState(); if ( thisGen !== this.navGeneration || errorBoundaryGen !== this.boundaryGeneration @@ -570,13 +585,10 @@ export class WebUIRouter { } } - const streaming = partialData?._deferredStream - ? await import('./streaming.js') - : undefined; if (!partialData || signal?.aborted || thisGen !== this.navGeneration) { this.invalidateBoundaryState(boundaryGen); - if (partialData && streaming) { - await streaming.cancelDeferredStream(partialData); + if (partialData) { + await cancelDeferredStream(partialData); } this.preparedPreload?.release(requestPath); return; @@ -587,14 +599,14 @@ export class WebUIRouter { if (partialData.path !== requestPathname && partialData.path !== requestPath) { console.warn(`[Router] Response path mismatch: expected ${requestPathname}, got ${partialData.path}`); this.invalidateBoundaryState(boundaryGen); - await streaming?.cancelDeferredStream(partialData); + await cancelDeferredStream(partialData); this.preparedPreload?.release(requestPath); return; } } if (!cached) { - const isStreaming = streaming?.hasDeferredStream(partialData) ?? false; + const isStreaming = hasDeferredStream(partialData); navCache?.store(requestPath, partialData, undefined, isStreaming); this.preparedPreload?.release(requestPath); } @@ -611,17 +623,17 @@ export class WebUIRouter { ); } catch (error) { this.invalidateBoundaryState(boundaryGen); - await streaming?.cancelDeferredStream(partialData); + await cancelDeferredStream(partialData); if (!cached) navCache?.evict(requestPath); throw error; } if (!committed || signal?.aborted || thisGen !== this.navGeneration) { this.invalidateBoundaryState(boundaryGen); - await streaming?.cancelDeferredStream(partialData); + await cancelDeferredStream(partialData); if (!cached) navCache?.evict(requestPath); return; } - streaming?.startDeferredStream(partialData); + startDeferredStream(partialData); } finally { this.invalidateBoundaryState(boundaryGen); } @@ -644,20 +656,15 @@ export class WebUIRouter { signal?: AbortSignal, speculative?: boolean, ): Promise { + const epoch = inventoryEpoch(); const fullPath = prependBasePath(requestPath, this.basePath); - const headers: Record = { 'Accept': 'application/x-ndjson, application/json' }; - if (window.__webui!.inventory) headers['X-WebUI-Inventory'] = window.__webui!.inventory!; + const headers = resourceHeaders((this.config.transport ?? jsonTransport).accept); - const requestController = new AbortController(); + const request = new ResourceRequest(signal, this.timeoutMs); + const requestController = request.controller; if (!speculative) this.abortPartialRequests(); - this.partialControllers.add(requestController); - const timeout = setTimeout( - () => requestController.abort(new DOMException('Partial response timed out', 'TimeoutError')), - PARTIAL_FETCH_TIMEOUT_MS, - ); - const requestSignal = signal - ? AbortSignal.any([signal, requestController.signal]) - : requestController.signal; + this.trackPartialRequest(requestController, request.dispose); + const requestSignal = request.signal; let hasDeferredReader = false; try { @@ -667,15 +674,15 @@ export class WebUIRouter { requestPath, requestController, requestSignal, - speculative, + { speculative, epoch }, ); hasDeferredReader = result.hasDeferredReader; return result.data; } catch (error) { + requestController.abort(error); if (signal?.aborted) throw error; return null; } finally { - clearTimeout(timeout); if (!hasDeferredReader) this.releasePartialRequest(requestController); } } @@ -684,6 +691,7 @@ export class WebUIRouter { requestPath: string, signal?: AbortSignal, ): Promise { + const epoch = inventoryEpoch(); const raw = await this.preparedPreload?.take( requestPath, window.__webui!.inventory!, @@ -691,24 +699,20 @@ export class WebUIRouter { ); if (!raw) return null; - const requestController = new AbortController(); - this.partialControllers.add(requestController); - const timeout = setTimeout( - () => requestController.abort(new DOMException('Partial response timed out', 'TimeoutError')), - PARTIAL_FETCH_TIMEOUT_MS, - ); - const requestSignal = signal - ? AbortSignal.any([signal, requestController.signal]) - : requestController.signal; + const request = new ResourceRequest(signal, this.timeoutMs); + const requestController = request.controller; + const requestSignal = request.signal; const abortRaw = (): void => raw.controller.abort(requestSignal.reason); + this.trackPartialRequest(requestController, () => { + // The owning controller can abort before AbortSignal.any dispatches. + if (requestController.signal.aborted) raw.controller.abort(requestController.signal.reason); + request.dispose(); + requestSignal.removeEventListener('abort', abortRaw); + }); if (requestSignal.aborted) { abortRaw(); } else { requestSignal.addEventListener('abort', abortRaw, { once: true }); - this.partialCleanups.set( - requestController, - () => requestSignal.removeEventListener('abort', abortRaw), - ); } let hasDeferredReader = false; try { @@ -717,17 +721,17 @@ export class WebUIRouter { requestPath, requestController, requestSignal, - true, + { speculative: true, epoch }, ); hasDeferredReader = result.hasDeferredReader; if (!result.data) this.preparedPreload?.release(requestPath); return result.data; } catch (error) { + requestController.abort(error); this.preparedPreload?.release(requestPath); if (signal?.aborted) throw error; return null; } finally { - clearTimeout(timeout); if (!hasDeferredReader) this.releasePartialRequest(requestController); } } @@ -737,7 +741,7 @@ export class WebUIRouter { requestPath: string, requestController: AbortController, signal: AbortSignal, - speculative = false, + options: PartialReadOptions = {}, ): Promise { if (!response.ok) { requestController.abort(); @@ -745,40 +749,17 @@ export class WebUIRouter { } const contentType = response.headers.get('content-type') ?? ''; if (!contentType.includes('json') && !contentType.includes('ndjson')) { - if (!speculative && !signal.aborted) this.navigateDocument(requestPath); + if (!options.speculative && !signal.aborted) this.navigateDocument(requestPath); requestController.abort(); return { data: null, hasDeferredReader: false }; } - if (contentType.includes('ndjson') && response.body) { - const streaming = await import('./streaming.js'); - const data = await streaming.readStreamingPartial( - response, - requestPath, - this.streamingContext(requestController), - signal, - ); - return { - data, - hasDeferredReader: - data !== null && streaming.hasDeferredStream(data), - }; - } - - const data = await response.json() as StreamingPartialResponse; - if (signal.aborted) return { data: null, hasDeferredReader: false }; - const stylesReady = registerTemplatesAndStyles( - data, - window.__webui!.nonce!, - this.cssSet, - (inventory) => this.updateInventory(inventory), + const data = await readStreamingPartial( + response, requestPath, this.streamingContext(requestController, options.epoch), + signal, this.config.transport ?? jsonTransport, ); - injectCssLinks(data, this.cssSet); - if (stylesReady && !await waitForTemplateReadiness(stylesReady, signal)) { - return { data: null, hasDeferredReader: false }; - } return { - data: signal.aborted ? null : data, - hasDeferredReader: false, + data, + hasDeferredReader: data !== null && hasDeferredStream(data), }; } @@ -833,7 +814,7 @@ export class WebUIRouter { // ── Helpers ───────────────────────────────────────────────────── - private streamingContext(requestController?: AbortController): StreamingContext { + private streamingContext(requestController?: AbortController, epoch = inventoryEpoch()): StreamingContext { const self = this; let trackedReader: Promise | null = null; return { @@ -852,7 +833,7 @@ export class WebUIRouter { if (requestController) self.releasePartialRequest(requestController); }, setDeferredGeneration(g) { self.deferredGeneration = g; }, - updateInventory(inv) { self.updateInventory(inv); }, + updateInventory(inv, styles) { self.updateInventory(inv, epoch, styles); }, markCacheComplete(p) { if (requestController?.signal.aborted) return; const entry = self.navCache?.getEntry(p); @@ -861,50 +842,6 @@ export class WebUIRouter { }; } - private ensureNavigationCache(): Promise { - if (this.navCache) return Promise.resolve(this.navCache); - if (!this.navCacheLoad) { - const config = this.cacheConfig; - const generation = this.cacheLoadGeneration; - this.navCacheLoad = import('./cache.js').then(({ NavigationCache }) => { - const cache = new NavigationCache(config); - if (generation === this.cacheLoadGeneration) { - this.navCache = cache; - } - return cache; - }); - } - return this.navCacheLoad; - } - - private validateRoutes(): void { - for (const { el } of this.activeChain) { - if (!el) continue; - const comp = routeComponent(el); - if (!comp) continue; - const compEl = el.querySelector(comp); - if (!compEl) continue; - - const hasOutlet = renderRoot(compEl).querySelector('outlet') !== null; - const hasChildren = renderRoot(compEl).querySelector(ROUTE_SELECTOR) !== null; - const path = routePath(el); - - if (!hasChildren && !hasOutlet && !isExact(el) && path !== '/') { - console.warn( - `[Router Dev] Route "${path}" (${comp}) is a leaf route without "exact". ` + - `Add "exact" to prevent unintended prefix matching.`, - ); - } - - if (hasOutlet && isExact(el)) { - console.warn( - `[Router Dev] Route "${path}" (${comp}) has with "exact". ` + - `Remove "exact" — child routes will never match.`, - ); - } - } - } - private clearSsrPreloads(): void { if (this.ssrPreloadsCleared) return; this.ssrPreloadsCleared = true; @@ -917,8 +854,9 @@ export class WebUIRouter { return buildNavigationTarget(new URL(window.location.href), this.basePath); } - private updateInventory(serverInventory: string): void { - window.__webui!.inventory = serverInventory; + private updateInventory(serverInventory: string, epoch = inventoryEpoch(), styles?: string): void { + acceptInventory(serverInventory, epoch); + if (styles !== undefined) acceptStyleInventory(styles, epoch); } private navigateDocument(requestPath: string): void { @@ -932,14 +870,8 @@ export class WebUIRouter { } private installDocumentTransitionOverride(): void { - if (typeof document.startViewTransition !== 'function') return; - - const style = document.createElement('style'); - style.textContent = DISABLE_DOCUMENT_VIEW_TRANSITION; - const nonce = window.__webui?.nonce; - if (nonce) style.nonce = nonce; - document.head.appendChild(style); - this.cleanupFns.push(() => style.remove()); + const transitions = this.config.transitions; + if (transitions) this.cleanupFns.push(transitions.install()); } private startInitialNavigation(meta: RouterRuntimeGlobal): void { @@ -1007,15 +939,7 @@ export class WebUIRouter { .filter(entry => entry.component) .map(entry => ensureComponentLoaded(entry.component, this.loaders, this.loaderPromises)), ); - if (signal) { - const aborted = new Promise<'aborted'>(resolve => { - signal.addEventListener('abort', () => resolve('aborted'), { once: true }); - }); - const result = await Promise.race([preload.then(() => 'loaded' as const), aborted]); - if (result === 'aborted') return false; - } else { - await preload; - } + if (!await waitForTemplateReadiness(preload, signal)) return false; if (signal?.aborted || navigationGeneration !== this.navGeneration) { return false; } @@ -1131,8 +1055,8 @@ export class WebUIRouter { committed = true; }; - if (document.startViewTransition && !isQueryOnlyChange) { - const transition = document.startViewTransition(commitNavigation); + if (this.config.transitions?.supported() && !isQueryOnlyChange) { + const transition = this.config.transitions.startViewTransition(commitNavigation); // Animation skips are non-fatal; updateCallbackDone still owns commit errors. void transition.ready.catch(ignoreTransitionRejection); void transition.finished.catch(ignoreTransitionRejection); @@ -1147,29 +1071,6 @@ export class WebUIRouter { function ignoreTransitionRejection(): void {} -function loadWebUIDataBlock(): void { - const runtime = window.__webui as RouterRuntimeGlobal | undefined; - if (webuiDataLoaded || runtime?.state !== undefined) return; - const el = document.getElementById(WEBUI_DATA_ID); - if (!el) { - webuiDataLoaded = true; - return; - } - - const text = el.textContent; - if (text) { - const templateFns = runtime?.templateFns; - const parsed = JSON.parse(text) as RouterRuntimeGlobal; - if (templateFns) parsed.templateFns = templateFns; - window.__webui = parsed; - } - el.remove(); - webuiDataLoaded = true; -} - function rawResponse(raw: RawPreloadedPartial): Response { return raw.response; } - -/** Singleton router instance. */ -export const Router = new WebUIRouter(); diff --git a/packages/webui-router/src/router.test.ts b/packages/webui-delivery/src/router.test.ts similarity index 95% rename from packages/webui-router/src/router.test.ts rename to packages/webui-delivery/src/router.test.ts index 45bcae150..5d23891f6 100644 --- a/packages/webui-router/src/router.test.ts +++ b/packages/webui-delivery/src/router.test.ts @@ -7,6 +7,9 @@ import './browser-shim.js'; import { strict as assert } from 'node:assert'; import { describe, test, beforeEach, afterEach } from 'node:test'; import { WebUIRouter } from './router.js'; +import { ndjson } from './ndjson.js'; +import { navigationCache } from './cache.js'; +import { viewTransitions } from './view-transitions.js'; import { parseQuery, filterQuery } from './route-element.js'; import { resolveLoaders } from './loaders.js'; import { ensureComponentLoaded } from './loaders.js'; @@ -34,6 +37,7 @@ interface RouteChainEntry { interface RouterInternals { inventory: string; activeChain: RouteChainEntry[]; + fetchPartial(path: string, signal?: AbortSignal): Promise; commitWithData( data: PartialResponse, path: string, @@ -178,7 +182,7 @@ describe('WebUIRouter', () => { }); describe('gc', () => { - test('clears all templates and resets inventory', () => { + test('delegates template and inventory collection to the configured runtime', () => { const router = new WebUIRouter(); const registry = globals().__webui!.templates!; registry['shell'] = { h: '
Shell
' }; @@ -191,14 +195,56 @@ describe('WebUIRouter', () => { globals().__webui!.inventory = inventoryWith('shell', 'page-x', 'page-y'); - router.gc(); + const previous = window.__webuiCollectTemplates; + let collected = false; + window.__webuiCollectTemplates = keep => { + assert.deepEqual(keep, []); + collected = true; + for (const tag of Object.keys(registry)) delete registry[tag]; + for (const tag of Object.keys(globals().__webui!.templateFns!)) { + delete globals().__webui!.templateFns![tag]; + } + globals().__webui!.inventory = ''; + return 3; + }; + try { + router.gc(); + assert.equal(collected, true); + assert.equal(registry['shell'], undefined, 'shell should be cleared'); + assert.equal(registry['page-x'], undefined, 'page-x should be cleared'); + assert.equal(registry['page-y'], undefined, 'page-y should be cleared'); + assert.equal(globals().__webui!.templateFns!.shell, undefined, 'shell functions should be cleared'); + assert.equal(globals().__webui!.templateFns!['page-x'], undefined, 'page-x functions should be cleared'); + assert.equal(globals().__webui!.inventory, '', 'inventory should be reset to empty'); + } finally { + window.__webuiCollectTemplates = previous; + } + }); - assert.equal(registry['shell'], undefined, 'shell should be cleared'); - assert.equal(registry['page-x'], undefined, 'page-x should be cleared'); - assert.equal(registry['page-y'], undefined, 'page-y should be cleared'); - assert.equal(globals().__webui!.templateFns!.shell, undefined, 'shell functions should be cleared'); - assert.equal(globals().__webui!.templateFns!['page-x'], undefined, 'page-x functions should be cleared'); - assert.equal(globals().__webui!.inventory, '', 'inventory should be reset to empty'); + test('requires an ownership-aware collector before evicting WebUI metadata', () => { + const router = new WebUIRouter(); + const registry = globals().__webui!.templates!; + registry['x-page'] = { h: '

' }; + globals().__webui!.inventory = '01'; + assert.throws(() => router.gc(), /ownership-aware cache adapter/); + assert.ok(registry['x-page']); + assert.equal(globals().__webui!.inventory, '01'); + }); + + test('retains active pending and error resources for an offline boundary', () => { + const router = new WebUIRouter(); + Object.defineProperty(router, 'activeChain', { + value: [{ pendingComponent: 'x-pending', errorComponent: 'x-error' }], + }); + const previous = window.__webuiCollectTemplates; + let retained: readonly string[] | undefined; + window.__webuiCollectTemplates = keep => { retained = keep; return 0; }; + try { + router.gc(); + assert.deepEqual(retained, ['x-pending', 'x-error']); + } finally { + window.__webuiCollectTemplates = previous; + } }); test('is a no-op when no templates are registered', () => { @@ -299,7 +345,8 @@ describe('WebUIRouter', () => { assert.equal((router as any).isInitialNavigation, false); }); - test('clears in-flight loadPromises so the router can be restarted cleanly', async () => { + test('clears in-flight loadPromises so the router can be restarted cleanly', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); const router = new WebUIRouter(); // Initialize navCache so destroy() can call .clear() (router as any).navCache = new NavigationCache({ staleTime: 0, gcTime: 300_000, maxEntries: 50 }); @@ -781,6 +828,22 @@ describe('WebUIRouter', () => { }); describe('navigation abort signal', () => { + test('a rejected transport cancels the unread navigation response', async t => { + let signal: AbortSignal | null | undefined; + const failure = new Error('transport rejected the response'); + t.mock.method(globalThis, 'fetch', async (_url: unknown, init?: RequestInit) => { + signal = init?.signal; + return new Response('unread', { headers: { 'content-type': 'application/json' } }); + }); + const router = new WebUIRouter(); + Reflect.set(router, 'config', { + transport: { accept: 'application/json', read: async () => { throw failure; } }, + }); + assert.equal(await internals(router).fetchPartial('/test'), null); + assert.equal(signal?.aborted, true); + assert.equal(signal?.reason, failure); + }); + test('fetchPartial propagates the navigation abort to fetch', async () => { const origFetch = (globalThis as any).fetch; let capturedSignal: AbortSignal | undefined; @@ -848,10 +911,8 @@ describe('WebUIRouter', () => { const controller = new AbortController(); controller.abort(); - const result = await fetchPartial('/test', controller.signal); - - assert.equal(result, null, 'should return null for aborted navigation'); - assert.equal(capturedSignal?.aborted, true, 'an already-aborted signal should abort the request'); + await assert.rejects(fetchPartial('/test', controller.signal), { name: 'AbortError' }); + assert.equal(capturedSignal, undefined, 'an already-aborted navigation must not start a request'); assert.equal(globals().__webui?.templates?.['abort-test'], undefined, 'should not register templates after abort'); } finally { (globalThis as any).fetch = origFetch; @@ -1049,6 +1110,7 @@ describe('WebUIRouter', () => { try { const router = new WebUIRouter(); + Reflect.set(router, 'config', { transport: ndjson() }); const cache = new NavigationCache({ staleTime: 30_000, gcTime: 30_000, @@ -1225,6 +1287,7 @@ describe('WebUIRouter', () => { }; }; const router = new WebUIRouter(); + Reflect.set(router, 'config', { transitions: viewTransitions }); let settled = false; const commit = internals(router).commitWithData( partial, '/next', {}, 0, 0, @@ -1258,6 +1321,7 @@ describe('WebUIRouter', () => { }; }; const router = new WebUIRouter(); + Reflect.set(router, 'config', { transitions: viewTransitions }); await assert.rejects( internals(router).commitWithData( partial, '/next', {}, 0, 0, @@ -1586,15 +1650,13 @@ describe('WebUIRouter', () => { assert.equal(priv.navCache, null, 'destroy should release the optional cache instance'); }); - test('destroy prevents pending cache import from restoring cache state', async () => { + test('cache selection is synchronous and destruction releases it', () => { const router = new WebUIRouter(); - const priv = router as any; - - const load = priv.ensureNavigationCache(); + const cache = navigationCache(); + router.start({ cache }); + assert.equal(Reflect.get(router, 'navCache'), cache); router.destroy(); - await load; - - assert.equal(priv.navCache, null, 'pending optional cache load should not survive destroy'); + assert.equal(Reflect.get(router, 'navCache'), null); }); test('setupPreloadListeners registers pointermove listener on document', () => { @@ -1970,6 +2032,7 @@ describe('WebUIRouter', () => { test('disables automatic cross-document view transitions while active', () => { const router = new WebUIRouter(); + Reflect.set(router, 'config', { transitions: viewTransitions }); const originalCreateElement = document.createElement; const originalAppendChild = document.head.appendChild; const originalStartViewTransition = document.startViewTransition; @@ -2087,6 +2150,7 @@ describe('WebUIRouter', () => { try { const router = new WebUIRouter(); + Reflect.set(router, 'config', { transport: ndjson() }); await (router as any).fetchPartial.call(router, '/test'); const accept = capturedHeaders['Accept']; assert.ok(accept, 'should send Accept header'); diff --git a/packages/webui-delivery/src/router.ts b/packages/webui-delivery/src/router.ts new file mode 100644 index 000000000..98fc67e2f --- /dev/null +++ b/packages/webui-delivery/src/router.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { WebUIRouter } from './router-core.js'; +export { parseQuery, filterQuery, WebUIRouteElement } from './route-element.js'; +export { navigationCache } from './cache.js'; +export { routeActions } from './actions.js'; +export { routePreload } from './preload.js'; +export { routeBoundaries } from './boundaries.js'; +export { viewTransitions } from './view-transitions.js'; +export { validateRoutes as routeDiagnostics } from './diagnostics.js'; +export { isStateful } from './types.js'; +export type { RouteBoundaries, RouteTransitions } from './capabilities.js'; +export type { RoutePreload } from './preload.js'; +export type { + RouterConfig, NavigationEvent, RouteLoaderContext, CacheConfig, + RouteActionContext, RouteActionResult, ActionCompleteEvent, StatefulElement, +} from './types.js'; +export type { PartialResponse, RouteChainEntry } from './cache.js'; diff --git a/packages/webui-delivery/src/runtime-bootstrap.ts b/packages/webui-delivery/src/runtime-bootstrap.ts new file mode 100644 index 000000000..6605a0c48 --- /dev/null +++ b/packages/webui-delivery/src/runtime-bootstrap.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export type RuntimeBootstrap = WebUIRuntimeGlobal & { + templates?: Record; + templateFns?: Record; + componentAssetStyles?: Record; +}; + +let loaded = false; + +export function loadRuntimeBootstrap(): void { + const runtime = window.__webui as RuntimeBootstrap | undefined; + if (loaded || runtime?.state !== undefined) return; + const element = document.getElementById('webui-data'); + if (!element) { loaded = true; return; } + const text = element.textContent; + if (text) { + const parsed = JSON.parse(text) as RuntimeBootstrap; + if (runtime?.buildId && parsed.buildId && runtime.buildId !== parsed.buildId) { + throw new Error('[WebUI] Resources use different builds. Reload the document.'); + } + if (runtime?.templateFns) parsed.templateFns = runtime.templateFns; + if (runtime?.componentAssetStyles) parsed.componentAssetStyles = runtime.componentAssetStyles; + window.__webui = parsed; + } + element.remove(); + loaded = true; +} diff --git a/packages/webui-delivery/src/streaming.ts b/packages/webui-delivery/src/streaming.ts new file mode 100644 index 000000000..eb0ee03e0 --- /dev/null +++ b/packages/webui-delivery/src/streaming.ts @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { jsonTransport, type PartialRecords, type PartialTransport } from './transport.js'; +import { preparePartial } from './partial-json.js'; +import { hasState, mountedRouteComponent } from './route-element.js'; +import { isStateful } from './types.js'; +import { + injectCssLinks, + registerTemplatesAndStyles, + waitForTemplateReadiness, +} from './templates.js'; +import { + attachDeferredStream, + releaseDeferredStream, + type StreamingPartialResponse, +} from './partial-response.js'; +import type { RouteChainEntry } from './cache.js'; +export { + cancelDeferredStream, + hasDeferredStream, + startDeferredStream, +} from './partial-response.js'; +export type { StreamingPartialResponse } from './partial-response.js'; + +/** Route-specific ownership and commit policy for a two-phase partial. */ +export interface StreamingContext { + readonly navGeneration: number; + readonly currentRequestPath: string; + readonly activeChain: RouteChainEntry[]; + readonly nonce: string; + readonly injectedCss: Set; + setDeferredReader(reader: Promise | null): void; + setDeferredGeneration(gen: number): void; + updateInventory(inv: string, styleInventory?: string): void; + markCacheComplete(requestPath: string): void; +} + +/** + * Prepare the resource/chain record and leave deferred state paused until commit. + * The record reader owns framing; this adapter owns route state and generations. + */ +export async function readStreamingPartial( + response: Response, + requestPath: string, + context: StreamingContext, + signal?: AbortSignal, + transport: PartialTransport = jsonTransport, +): Promise { + const requestSignal = signal ?? new AbortController().signal; + const body = await transport.read(response, requestSignal); + const records = body.records; + if (!records) return preparePartial(body.data, context, signal); + const generation = context.navGeneration; + try { + const data = readMetadata(body.data); + let receivedState = data.state !== undefined; + let buffered = records.readBuffered(); + while (buffered !== undefined) { + persistDeferredStates(data, readStates(buffered)); + receivedState = true; + buffered = records.readBuffered(); + } + + const ready = registerTemplatesAndStyles( + data, context.nonce, (inventory, styles) => context.updateInventory(inventory, styles), + ); + injectCssLinks(data, context.injectedCss); + if ( + (ready && !await waitForTemplateReadiness(ready, signal)) || + signal?.aborted || + generation !== context.navGeneration + ) { + await records.cancel(); + return null; + } + + let started = false; + attachDeferredStream(data, { + async cancel() { + started = true; + await records.cancel(); + context.setDeferredReader(null); + }, + start() { + if (started) return; + started = true; + Reflect.deleteProperty(data, '_deferredStream'); + context.setDeferredGeneration(generation); + const pending = continueDeferredRead( + records, data, requestPath, { context, generation, signal, receivedState }, + ).catch((error: unknown) => { + if (!signal?.aborted && generation === context.navGeneration) { + console.warn('[Router] Deferred state reader failed:', error); + } + }).finally(() => { + releaseDeferredStream(data); + context.setDeferredReader(null); + }); + context.setDeferredReader(pending); + }, + }); + return data; + } catch (error) { + await records.cancel(error); + if (signal?.aborted) return null; + throw error; + } +} + +function readMetadata(value: unknown): StreamingPartialResponse { + if (!value || typeof value !== 'object' || !('chain' in value) || !Array.isArray(value.chain)) { + throw new Error('[Router] NDJSON must start with a route-chain/resource record.'); + } + return value as StreamingPartialResponse; +} + +function readStates(value: unknown): (Record | null)[] { + if (!value || typeof value !== 'object') { + throw new Error('[Router] Expected a deferred-state object in the NDJSON response.'); + } + if ('error' in value) { + throw new Error('[Router] The server could not complete deferred state.', { cause: value.error }); + } + if (!('states' in value) || !Array.isArray(value.states)) { + throw new Error('[Router] NDJSON deferred-state records must contain a states array.'); + } + for (const state of value.states) { + if (state !== null && (typeof state !== 'object' || Array.isArray(state))) { + throw new Error('[Router] Each deferred state must be an object or null.'); + } + } + return value.states; +} + +interface DeferredReadOptions { + context: StreamingContext; + generation: number; + signal: AbortSignal | undefined; + receivedState: boolean; +} + +async function continueDeferredRead( + records: PartialRecords, + data: StreamingPartialResponse, + requestPath: string, + options: DeferredReadOptions, +): Promise { + const { context, generation, signal } = options; + let receivedState = options.receivedState; + try { + await records.drain(value => { + if (signal?.aborted || generation !== context.navGeneration) { + throw new DOMException('Navigation superseded', 'AbortError'); + } + const states = readStates(value); + persistDeferredStates(data, states); + applyDeferredStates(states, requestPath, context); + receivedState = true; + }); + if (signal?.aborted || generation !== context.navGeneration) return; + if (!receivedState) throw new Error('[Router] NDJSON response ended before deferred state arrived.'); + context.markCacheComplete(requestPath); + } finally { + await records.cancel(); + } +} + +function persistDeferredStates( + data: StreamingPartialResponse, + states: readonly (Record | null)[], +): void { + const chain = data.chain; + if (!chain) return; + for (let i = 0; i < states.length && i < chain.length; i++) { + if (states[i] !== null) chain[i].state = states[i]; + } +} + +/** Apply positional route states after commit, without overriding authored loaders. */ +export function applyDeferredStates( + states: (Record | null)[], + requestPath: string, + context: StreamingContext, +): void { + if (requestPath !== context.currentRequestPath) return; + for (let i = 0; i < states.length && i < context.activeChain.length; i++) { + const state = states[i]; + if (!hasState(state)) continue; + const entry = context.activeChain[i]; + if (!entry.el || !entry.component) continue; + const ctor = customElements.get(entry.component) as + (CustomElementConstructor & { loader?: unknown }) | undefined; + if (typeof ctor?.loader === 'function') continue; + + const component = entry.compEl ?? mountedRouteComponent(entry.el, entry.component); + if (!component) continue; + entry.compEl = component; + if (isStateful(component)) component.setState(state); + entry.state = state; + } +} diff --git a/packages/webui-router/src/templates.test.ts b/packages/webui-delivery/src/templates.test.ts similarity index 69% rename from packages/webui-router/src/templates.test.ts rename to packages/webui-delivery/src/templates.test.ts index 6261f43b3..f632de590 100644 --- a/packages/webui-router/src/templates.test.ts +++ b/packages/webui-delivery/src/templates.test.ts @@ -8,20 +8,40 @@ import { test } from 'node:test'; import { WebUIRouter } from './router.js'; import { - readStreamingPartial, + readStreamingPartial as readPartial, startDeferredStream, type StreamingContext, } from './streaming.js'; +import { ndjson } from './ndjson.js'; import { + fetchComponentTemplates, registerTemplatesAndStyles, waitForTemplateReadiness, } from './templates.js'; +test('a failed template request cancels its unread response', async t => { + let signal: AbortSignal | null | undefined; + t.mock.method(globalThis, 'fetch', async (_url: unknown, init?: RequestInit) => { + signal = init?.signal; + return new Response('Unavailable', { status: 503 }); + }); + await assert.rejects( + fetchComponentTemplates(['example-card'], '', '/templates', '', () => {}), + /Template request failed: 503/, + ); + assert.equal(signal?.aborted, true); +}); + interface RegistrationDetail { templates: Record; waitUntil(promise: PromiseLike): void; } +function readStreamingPartial(response: Response, path: string, context: StreamingContext) { + response.headers.set('content-type', 'application/x-ndjson'); + return readPartial(response, path, context, undefined, ndjson()); +} + test('template registration waits for synchronous runtime readiness', async () => { let release!: () => void; const blocker = new Promise(resolve => { @@ -38,7 +58,6 @@ test('template registration waits for synchronous runtime readiness', async () = const ready = registerTemplatesAndStyles( { templates: { 'delayed-card': { h: '

Delayed

' } } }, '', - new Set(), () => {}, ); assert.ok(ready); @@ -61,6 +80,121 @@ test('template registration waits for synchronous runtime readiness', async () = } }); +test('inventory is acknowledged only after resource readiness succeeds', async () => { + const preparation = Promise.withResolvers(); + const listener = (event: Event): void => { + (event as CustomEvent).detail.waitUntil(preparation.promise); + }; + window.addEventListener('webui:templates-registered', listener); + const inventories: string[] = []; + try { + const ready = registerTemplatesAndStyles( + { templates: { 'inventory-card': { h: '

' } }, inventory: '04' }, + '', + inventory => inventories.push(inventory), + ); + assert.ok(ready); + assert.deepEqual(inventories, []); + preparation.resolve(); + await ready; + assert.deepEqual(inventories, ['04']); + } finally { + window.removeEventListener('webui:templates-registered', listener); + } +}); + +test('failed readiness cannot acknowledge templates that did not become usable', async () => { + const failure = new Error('definition failed'); + const listener = (event: Event): void => { + (event as CustomEvent).detail.waitUntil(Promise.reject(failure)); + }; + window.addEventListener('webui:templates-registered', listener); + let acknowledged = false; + try { + const ready = registerTemplatesAndStyles( + { templates: { 'rejected-inventory-card': { h: '

' } }, inventory: '08' }, + '', + () => { acknowledged = true; }, + ); + assert.ok(ready); + await assert.rejects(ready, failure); + assert.equal(acknowledged, false); + } finally { + window.removeEventListener('webui:templates-registered', listener); + } +}); + +test('resource publication and cached responses reuse renderer-canonical templates', () => { + const original = window.__webuiPrepareTemplates; + const canonical = { h: '

Canonical

' }; + window.__webuiPrepareTemplates = templates => { templates['canonical-card'] = canonical; }; + const data = { templates: { 'canonical-card': { h: '

Canonical

' } } }; + try { + registerTemplatesAndStyles(data, '', () => {}); + assert.equal(data.templates['canonical-card'], canonical); + } finally { + window.__webuiPrepareTemplates = original; + } +}); + +test('renderer preparation failures do not publish invalid templates or inventory', () => { + const original = window.__webuiPrepareTemplates; + const failure = new Error('conflicting resource'); + window.__webuiPrepareTemplates = () => { throw failure; }; + let acknowledged = false; + try { + assert.throws(() => registerTemplatesAndStyles( + { templates: { 'unpublished-card': { h: '

' } }, inventory: '20' }, + '', + () => { acknowledged = true; }, + ), failure); + assert.equal(acknowledged, false); + assert.equal(Reflect.get(window.__webui ?? {}, 'templates')?.['unpublished-card'], undefined); + } finally { + window.__webuiPrepareTemplates = original; + } +}); + +for (const tail of ['', '{"states":', '{"error":"data unavailable"}\n']) { + test(`an incomplete or failed state tail cannot make a route cache entry complete: ${JSON.stringify(tail)}`, async () => { + const warnings: unknown[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args); }; + let completed = false; + let pending: Promise | null = null; + const response = new Response( + `{"chain":[],"templates":{}}\n${tail}`, + ); + const context: StreamingContext = { + navGeneration: 1, + currentRequestPath: '/failed-tail', + activeChain: [], + nonce: '', + injectedCss: new Set(), + setDeferredReader(value) { if (value) pending = value; }, + setDeferredGeneration() {}, + updateInventory() {}, + markCacheComplete() { completed = true; }, + }; + try { + if (tail.endsWith('\n')) { + await assert.rejects(readStreamingPartial(response, '/failed-tail', context), /could not complete/); + } else { + const data = await readStreamingPartial(response, '/failed-tail', context); + assert.ok(data); + startDeferredStream(data); + assert.ok(pending); + await pending; + assert.equal(warnings.length, 1); + } + assert.equal(completed, false); + assert.equal(response.body?.locked, false); + } finally { + console.warn = original; + } + }); +} + test('streaming registration rejects a stale navigation after readiness wait', async () => { let release!: () => void; let registrationSeen!: () => void; @@ -402,7 +536,6 @@ test('template registration remains immediate without a runtime listener', () => registerTemplatesAndStyles( { templates: { 'immediate-card': { h: '

Immediate

' } } }, '', - new Set(), () => {}, ), undefined, diff --git a/packages/webui-router/src/templates.ts b/packages/webui-delivery/src/templates.ts similarity index 83% rename from packages/webui-router/src/templates.ts rename to packages/webui-delivery/src/templates.ts index 4c238854b..781421dd8 100644 --- a/packages/webui-router/src/templates.ts +++ b/packages/webui-delivery/src/templates.ts @@ -11,11 +11,23 @@ */ import type { ComponentStyleResource, ComponentStyles } from './types.js'; +import { ResourceRequest, resourceHeaders } from './request.js'; /** Shared event name understood by optional framework runtimes. */ const TEMPLATES_REGISTERED_EVENT = 'webui:templates-registered'; const READINESS_COMPLETE = (): true => true; const IGNORE_READINESS_RESULTS = (): void => {}; +type InventoryReceiver = (inventory: string, styleInventory?: string) => void; + +export interface TemplateResourceResponse { + templates?: Record; + templateFunctions?: Record; + componentStyles?: ComponentStyles; + inventory?: string; + styleInventory?: string; + buildId?: string; + css?: string[]; +} /** * Register templates + inject CSS from a server response. @@ -25,43 +37,20 @@ const IGNORE_READINESS_RESULTS = (): void => {}; * materialized as DOM. */ export function registerTemplatesAndStyles( - data: { - templates?: Record; - templateFunctions?: Record; - componentStyles?: ComponentStyles; - inventory?: string; - }, - nonce: string, - updateInventory: (inv: string) => void, -): Promise | undefined; -export function registerTemplatesAndStyles( - data: { - templates?: Record; - templateFunctions?: Record; - componentStyles?: ComponentStyles; - inventory?: string; - }, - nonce: string, - injectedStyles: Set, - updateInventory: (inv: string) => void, -): Promise | undefined; -export function registerTemplatesAndStyles( - data: { - templates?: Record; - templateFunctions?: Record; - componentStyles?: ComponentStyles; - inventory?: string; - }, + data: TemplateResourceResponse, nonce: string, - injectedStylesOrUpdate: Set | ((inv: string) => void), - maybeUpdateInventory?: (inv: string) => void, + updateInventory: InventoryReceiver, ): Promise | undefined { - const injectedStyles = injectedStylesOrUpdate instanceof Set - ? injectedStylesOrUpdate - : new Set(); - const updateInventory = typeof injectedStylesOrUpdate === 'function' - ? injectedStylesOrUpdate - : maybeUpdateInventory ?? (() => {}); + if (data.buildId !== undefined) { + if (typeof data.buildId !== 'string' || data.buildId.length === 0) { + throw new Error('[WebUI] Invalid resource build identity.'); + } + const expected = window.__webui?.buildId; + if (expected && expected !== data.buildId) { + throw new Error('[WebUI] Resources belong to a different document build. Reload the document.'); + } + (window.__webui ??= {}).buildId = data.buildId; + } const componentStyles = data.componentStyles ? validateComponentStyles(data.componentStyles) : undefined; @@ -69,9 +58,6 @@ export function registerTemplatesAndStyles( const styleRegistration = componentStyles ? registerComponentStyleCatalog(componentStyles) : undefined; - if (data.inventory) { - updateInventory(data.inventory); - } let executableTemplateBody = ''; let registeredTemplates: Record | undefined; @@ -125,11 +111,18 @@ export function registerTemplatesAndStyles( throw new Error(`[Router] Unsupported executable template payload for ${tag}.`); } } else { - w.__webui.templates[tag] = template; if (!registeredTemplates) registeredTemplates = {}; registeredTemplates[tag] = template; } } + if (registeredTemplates) { + window.__webuiPrepareTemplates?.(registeredTemplates); + for (const tag of Object.keys(registeredTemplates)) { + const template = registeredTemplates[tag]; + w.__webui.templates[tag] = template; + data.templates[tag] = template; + } + } } // The bridge already accepted this exact payload. Only fallback listeners @@ -138,9 +131,14 @@ export function registerTemplatesAndStyles( registeredTemplates, styleRegistration?.bridged ? undefined : componentStyles, ); - if (!styleRegistration?.ready) return readiness; - if (!readiness) return styleRegistration.ready; - return Promise.all([styleRegistration.ready, readiness]).then(IGNORE_READINESS_RESULTS); + const complete = styleRegistration?.ready && readiness + ? Promise.all([styleRegistration.ready, readiness]).then(IGNORE_READINESS_RESULTS) + : styleRegistration?.ready ?? readiness; + const inventory = data.inventory; + const styles = data.styleInventory; + if (!inventory && !styles) return complete; + if (complete) return complete.then(() => updateInventory(inventory ?? '', styles)); + updateInventory(inventory ?? '', styles); } /** Register initial bootstrap styles before announcing already-published templates. */ @@ -266,7 +264,7 @@ export function injectCssLinks( /** Await optional runtime readiness while allowing a stale navigation to stop promptly. */ export function waitForTemplateReadiness( - ready: Promise, + ready: Promise, signal?: AbortSignal, ): Promise { if (!signal) return ready.then(READINESS_COMPLETE); @@ -300,26 +298,28 @@ export async function fetchComponentTemplates( inventoryHex: string, templateEndpoint: string, nonce: string, - injectedStylesOrUpdate: Set | ((inv: string) => void), - maybeUpdateInventory?: (inv: string) => void, + updateInventory: InventoryReceiver, ): Promise { const url = `${templateEndpoint}?t=${tags.join(',')}&inv=${encodeURIComponent(inventoryHex)}`; - const resp = await fetch(url); - if (!resp.ok) { - throw new Error(`[Router] ensureLoaded failed: ${resp.status} ${resp.statusText}`); + const request = new ResourceRequest(); + try { + const resp = await fetch(url, { headers: resourceHeaders('application/json'), signal: request.signal }); + if (!resp.ok) { + throw new Error(`[WebUI] Template request failed: ${resp.status} ${resp.statusText}`); + } + const data: unknown = await resp.json(); + request.signal.throwIfAborted(); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('[WebUI] Template response must be a resource object.'); + } + const ready = registerTemplatesAndStyles(data, nonce, updateInventory); + if (ready && !await waitForTemplateReadiness(ready, request.signal)) request.signal.throwIfAborted(); + } catch (error) { + request.controller.abort(error); + throw error; + } finally { + request.dispose(); } - const data = await resp.json(); - - // Register using the same pipeline as partial navigation - const ready = injectedStylesOrUpdate instanceof Set - ? registerTemplatesAndStyles( - data, - nonce, - injectedStylesOrUpdate, - maybeUpdateInventory ?? (() => {}), - ) - : registerTemplatesAndStyles(data, nonce, injectedStylesOrUpdate); - if (ready) await ready; } /** Announce newly registered WebUI templates. */ diff --git a/packages/webui-delivery/src/transport.ts b/packages/webui-delivery/src/transport.ts new file mode 100644 index 000000000..2d166d815 --- /dev/null +++ b/packages/webui-delivery/src/transport.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** Bounded records owned by one response, independent of its UI consumer. */ +export interface PartialRecords { + readBuffered(): unknown | undefined; + drain(consume: (value: unknown) => void): Promise; + cancel(reason?: unknown): Promise; +} + +/** Initial response data and an optional unread record tail. */ +export interface PartialBody { + readonly data: unknown; + readonly records?: PartialRecords; +} + +/** Selected response format shared by routing and lazy conditional loading. */ +export interface PartialTransport { + readonly accept: string; + read(response: Response, signal: AbortSignal): Promise; +} + +/** JSON delivery does not import a stream decoder. */ +export const jsonTransport: PartialTransport = { + accept: 'application/json', + async read(response, signal) { + const type = response.headers.get('content-type') ?? ''; + if (type.includes('ndjson')) { + await response.body?.cancel(); + throw new Error('[WebUI] Received NDJSON for JSON-only delivery. Select ndjson() or return JSON.'); + } + const data: unknown = await response.json(); + signal.throwIfAborted(); + return { data }; + }, +}; diff --git a/packages/webui-router/src/types.ts b/packages/webui-delivery/src/types.ts similarity index 78% rename from packages/webui-router/src/types.ts rename to packages/webui-delivery/src/types.ts index 1a1edf0aa..e9bc91afc 100644 --- a/packages/webui-router/src/types.ts +++ b/packages/webui-delivery/src/types.ts @@ -1,12 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import type { PreparedRoutePreload } from './prepared-preload.js'; +import type { NavigationCache, RouteChainEntry } from './cache.js'; +import type { RouteBoundaries, RouteTransitions } from './capabilities.js'; +import type { ActionContext } from './actions.js'; +import type { RoutePreload } from './preload.js'; +import type { PartialTransport } from './transport.js'; declare global { interface WebUIRuntimeGlobal { chain?: unknown[]; inventory?: string; + buildId?: string; + styleInventory?: string; + inventoryEpoch?: number; nonce?: string; css?: string[]; styles?: string[]; @@ -18,11 +25,16 @@ declare global { interface Window { __webui?: WebUIRuntimeGlobal; __webuiRegisterComponentStyles?: (value: unknown) => Promise | undefined; + __webuiPrepareTemplates?: (templates: Record) => void; + __webuiResolveResources?: (id: string) => readonly string[] | undefined; + __webuiPrepareResources?: (ids: readonly string[]) => Promise | undefined; + __webuiCollectTemplates?: (keep?: readonly string[]) => number; + __webuiRetainTemplate?: (tag: string) => unknown; } } /** - * Public type definitions for @microsoft/webui-router. + * Public type definitions for @microsoft/webui-delivery. */ export type ComponentStyleResource = ( @@ -41,7 +53,7 @@ export interface ComponentStyles { closures: Record; } -/** Configuration passed to `Router.start()`. */ +/** Configure and select routing capabilities once in `WebUIRouter.start()`. */ export interface RouterConfig { /** * Optional lazy-loading map: component tag → async loader function. @@ -49,14 +61,14 @@ export interface RouterConfig { * When a route's `component` attribute matches a key in this map, the * loader is called before the component is mounted. The loader should * dynamically import the component's JS module (which registers the - * custom element via `defineAsync`). + * custom element via `define`). * * Components NOT in this map are assumed to be eagerly loaded (existing * behavior). Each loader runs at most once — the promise is cached. * * @example * ```ts - * Router.start({ + * router.start({ * loaders: { * 'user-detail': () => import('./pages/user-detail.js'), * 'user-settings': () => import('./pages/user-settings.js'), @@ -66,17 +78,25 @@ export interface RouterConfig { */ loaders?: Record Promise>; - /** Enable development mode warnings for common routing mistakes. */ - dev?: boolean; + /** JSON by default. Select ndjson() for two-phase partial responses. */ + transport?: PartialTransport; + /** Deadline for the whole partial response, including deferred state. */ + timeoutMs?: number; + /** Optional compiler-authored pending/error presentation. */ + boundaries?: RouteBoundaries; + /** Optional same-document view transitions. */ + transitions?: RouteTransitions; + /** Optional route diagnostics; omitted implementations are not installed. */ + diagnostics?: (chain: RouteChainEntry[]) => void; /** - * URL for the component template endpoint used by `Router.ensureLoaded()`. + * URL for the component template endpoint used by `router.ensureLoaded()`. * Component tags are appended as a comma-separated `t=` query parameter. * * @default "/_webui/templates" * @example * ```ts - * Router.start({ templateEndpoint: '/api/templates' }); + * router.start({ templateEndpoint: '/api/templates' }); * // ensureLoaded fetches: /api/templates?t=tag1,tag2&inv=... * ``` */ @@ -90,22 +110,22 @@ export interface RouterConfig { * * Only mouse pointers trigger preload (touch taps fire too late to benefit). * - * @default false + * @default undefined * @example * ```ts - * Router.start({ preload: true }); + * router.start({ cache: navigationCache(), preload: routePreload() }); * ``` */ - preload?: boolean | PreparedRoutePreload; + preload?: RoutePreload; /** * Intercept same-origin POST forms and dispatch component `static action()` * handlers. Disabled by default so the core router does not load the action * runtime unless an app opts into route actions. * - * @default false + * @default undefined */ - actions?: boolean; + actions?: (context: ActionContext) => () => void; /** * Path prefixes that the router should NOT intercept. @@ -115,7 +135,7 @@ export interface RouterConfig { * @default [] * @example * ```ts - * Router.start({ excludePaths: ['/auth/', '/api/'] }); + * router.start({ excludePaths: ['/auth/', '/api/'] }); * // Navigating to /auth/logout does a full-page load instead of SPA fetch * ``` */ @@ -129,12 +149,12 @@ export interface RouterConfig { * @default undefined (caching disabled — staleTime defaults to 0) * @example * ```ts - * Router.start({ - * cache: { staleTime: 30_000, gcTime: 300_000, maxEntries: 50 }, + * router.start({ + * cache: navigationCache({ staleTime: 30_000, gcTime: 300_000, maxEntries: 50 }), * }); * ``` */ - cache?: CacheConfig; + cache?: NavigationCache; /** * Skip running route loaders on the initial SSR-bootstrapped navigation. diff --git a/packages/webui-delivery/src/view-transitions.ts b/packages/webui-delivery/src/view-transitions.ts new file mode 100644 index 000000000..cf636c5af --- /dev/null +++ b/packages/webui-delivery/src/view-transitions.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { RouteTransitions } from './capabilities.js'; + +/** Opt-in same-document transitions without enabling cross-document animation. */ +export const viewTransitions: RouteTransitions = { + supported: () => typeof document.startViewTransition === 'function', + install() { + if (!viewTransitions.supported()) return () => {}; + const style = document.createElement('style'); + style.textContent = '@view-transition { navigation: none; }'; + const nonce = window.__webui?.nonce; + if (nonce) style.nonce = nonce; + document.head.appendChild(style); + return () => style.remove(); + }, + startViewTransition: commit => document.startViewTransition(commit), +}; diff --git a/packages/webui-router/tests/fixtures/router-app/data/state.json b/packages/webui-delivery/tests/fixtures/router-app/data/state.json similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/data/state.json rename to packages/webui-delivery/tests/fixtures/router-app/data/state.json diff --git a/packages/webui-router/tests/fixtures/router-app/src/component-definitions.ts b/packages/webui-delivery/tests/fixtures/router-app/src/component-definitions.ts similarity index 91% rename from packages/webui-router/tests/fixtures/router-app/src/component-definitions.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/component-definitions.ts index 364e00eb4..7f7d24c5e 100644 --- a/packages/webui-router/tests/fixtures/router-app/src/component-definitions.ts +++ b/packages/webui-delivery/tests/fixtures/router-app/src/component-definitions.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; import { ErrorDisplay } from './error-display/error-display'; import { LoadingSkeleton } from './loading-skeleton/loading-skeleton'; import { PageAlpha } from './page-alpha/page-alpha'; @@ -16,6 +18,8 @@ import { PageSlow } from './page-slow/page-slow'; import { RouteDashboard } from './route-dashboard/route-dashboard'; import { RouteShell } from './route-shell/route-shell'; +startRuntime(); + RouteShell.define('route-shell'); PageAlpha.define('page-alpha'); PageBeta.define('page-beta'); diff --git a/packages/webui-router/tests/fixtures/router-app/src/error-display/error-display.html b/packages/webui-delivery/tests/fixtures/router-app/src/error-display/error-display.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/error-display/error-display.html rename to packages/webui-delivery/tests/fixtures/router-app/src/error-display/error-display.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/error-display/error-display.ts b/packages/webui-delivery/tests/fixtures/router-app/src/error-display/error-display.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/error-display/error-display.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/error-display/error-display.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/index.html b/packages/webui-delivery/tests/fixtures/router-app/src/index.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/index.html rename to packages/webui-delivery/tests/fixtures/router-app/src/index.html diff --git a/packages/webui-delivery/tests/fixtures/router-app/src/index.ts b/packages/webui-delivery/tests/fixtures/router-app/src/index.ts new file mode 100644 index 000000000..c17f65a88 --- /dev/null +++ b/packages/webui-delivery/tests/fixtures/router-app/src/index.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WebUIRouter, routeBoundaries, viewTransitions } from '@microsoft/webui-delivery/router.js'; +import { ndjson } from '@microsoft/webui-delivery/resources.js'; +import './component-definitions'; +import { routerConfig } from './router-config'; + +const router = new WebUIRouter(); +const start = () => router.start({ + ...routerConfig, transport: ndjson(), boundaries: routeBoundaries, transitions: viewTransitions, +}); +if (performance.getEntriesByName('webui:hydrate:total', 'measure').length > 0) start(); +else window.addEventListener('webui:hydration-complete', start, { once: true }); + +(window as unknown as Record).__testRouter = router; diff --git a/packages/webui-router/tests/fixtures/router-app/src/interaction-entry.ts b/packages/webui-delivery/tests/fixtures/router-app/src/interaction-entry.ts similarity index 52% rename from packages/webui-router/tests/fixtures/router-app/src/interaction-entry.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/interaction-entry.ts index 4022a19d4..4606f54aa 100644 --- a/packages/webui-router/tests/fixtures/router-app/src/interaction-entry.ts +++ b/packages/webui-delivery/tests/fixtures/router-app/src/interaction-entry.ts @@ -6,30 +6,31 @@ import { wakeInteractionHydration, } from '@microsoft/webui-framework/interaction-hydration.js'; import { - prepareRoutePreload, - type PreparedRoutePreload, -} from '@microsoft/webui-router/preload.js'; + routePreload, +} from '@microsoft/webui-delivery/router.js'; import { routerConfig } from './router-config'; let activation: Promise | undefined; -let prepared: PreparedRoutePreload | undefined; +const preload = routePreload({ onIntent: () => wakeInteractionHydration() }); const activate = () => { activation ??= Promise.all([ import('./component-definitions'), - import('@microsoft/webui-router'), - ]).then(([, { Router }]) => { - Router.start({ ...routerConfig, preload: prepared }); + import('@microsoft/webui-delivery/router.js'), + import('@microsoft/webui-delivery/resources.js'), + ]).then(([, { WebUIRouter, navigationCache, routeBoundaries, viewTransitions }, { ndjson }]) => { + new WebUIRouter().start({ + ...routerConfig, preload, cache: navigationCache(), transport: ndjson(), + boundaries: routeBoundaries, transitions: viewTransitions, + }); }); return activation; }; const disposeHydration = installInteractionHydration({ load: activate, - onError: () => prepared?.destroy(), + onError: () => preload.destroy(), }); try { - prepared = prepareRoutePreload({ - onIntent: () => wakeInteractionHydration(), - }); + preload.prepare(); } catch (error) { disposeHydration(); throw error; diff --git a/packages/webui-delivery/tests/fixtures/router-app/src/lean-entry.ts b/packages/webui-delivery/tests/fixtures/router-app/src/lean-entry.ts new file mode 100644 index 000000000..ccae74618 --- /dev/null +++ b/packages/webui-delivery/tests/fixtures/router-app/src/lean-entry.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import '@microsoft/webui-framework/template-cache.js'; +import { WebUIRouter } from '@microsoft/webui-delivery/router.js'; +import './component-definitions'; +import { routerConfig } from './router-config'; + +const router = new WebUIRouter(); +const start = () => router.start(routerConfig); +if (performance.getEntriesByName('webui:hydrate:total', 'measure').length > 0) start(); +else window.addEventListener('webui:hydration-complete', start, { once: true }); + +(window as unknown as { __testRouter: WebUIRouter }).__testRouter = router; diff --git a/packages/webui-router/tests/fixtures/router-app/src/loading-skeleton/loading-skeleton.html b/packages/webui-delivery/tests/fixtures/router-app/src/loading-skeleton/loading-skeleton.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/loading-skeleton/loading-skeleton.html rename to packages/webui-delivery/tests/fixtures/router-app/src/loading-skeleton/loading-skeleton.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/loading-skeleton/loading-skeleton.ts b/packages/webui-delivery/tests/fixtures/router-app/src/loading-skeleton/loading-skeleton.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/loading-skeleton/loading-skeleton.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/loading-skeleton/loading-skeleton.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-alpha/page-alpha.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-alpha/page-alpha.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-alpha/page-alpha.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-alpha/page-alpha.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-alpha/page-alpha.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-alpha/page-alpha.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-alpha/page-alpha.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-alpha/page-alpha.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-beta/page-beta.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-beta/page-beta.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-beta/page-beta.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-beta/page-beta.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-beta/page-beta.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-beta/page-beta.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-beta/page-beta.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-beta/page-beta.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-compose/page-compose.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-compose/page-compose.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-compose/page-compose.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-compose/page-compose.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-compose/page-compose.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-compose/page-compose.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-compose/page-compose.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-compose/page-compose.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-detail/page-detail.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-detail/page-detail.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-detail/page-detail.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-detail/page-detail.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-detail/page-detail.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-detail/page-detail.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-detail/page-detail.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-detail/page-detail.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-failing/page-failing.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-failing/page-failing.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-failing/page-failing.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-failing/page-failing.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-failing/page-failing.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-failing/page-failing.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-failing/page-failing.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-failing/page-failing.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-keepalive/page-keepalive.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-keepalive/page-keepalive.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-keepalive/page-keepalive.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-keepalive/page-keepalive.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-keepalive/page-keepalive.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-keepalive/page-keepalive.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-keepalive/page-keepalive.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-keepalive/page-keepalive.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-live-hydrated/page-live-hydrated.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-live-hydrated/page-live-hydrated.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-live-hydrated/page-live-hydrated.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-live-hydrated/page-live-hydrated.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-live-hydrated/page-live-hydrated.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-live-hydrated/page-live-hydrated.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-live-hydrated/page-live-hydrated.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-live-hydrated/page-live-hydrated.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-live/page-live.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-live/page-live.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-live/page-live.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-live/page-live.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-live/page-live.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-live/page-live.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-live/page-live.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-live/page-live.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-loader/page-loader.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-loader/page-loader.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-loader/page-loader.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-loader/page-loader.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-loader/page-loader.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-loader/page-loader.ts similarity index 85% rename from packages/webui-router/tests/fixtures/router-app/src/page-loader/page-loader.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-loader/page-loader.ts index 2ce7d13bd..748754ec8 100644 --- a/packages/webui-router/tests/fixtures/router-app/src/page-loader/page-loader.ts +++ b/packages/webui-delivery/tests/fixtures/router-app/src/page-loader/page-loader.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { WebUIElement, observable } from '@microsoft/webui-framework'; -import type { RouteLoaderContext } from '@microsoft/webui-router'; +import type { RouteLoaderContext } from '@microsoft/webui-delivery/router.js'; export class PageLoader extends WebUIElement { @observable source = ''; diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-slow/page-slow.html b/packages/webui-delivery/tests/fixtures/router-app/src/page-slow/page-slow.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-slow/page-slow.html rename to packages/webui-delivery/tests/fixtures/router-app/src/page-slow/page-slow.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/page-slow/page-slow.ts b/packages/webui-delivery/tests/fixtures/router-app/src/page-slow/page-slow.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/page-slow/page-slow.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/page-slow/page-slow.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/route-dashboard/route-dashboard.html b/packages/webui-delivery/tests/fixtures/router-app/src/route-dashboard/route-dashboard.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/route-dashboard/route-dashboard.html rename to packages/webui-delivery/tests/fixtures/router-app/src/route-dashboard/route-dashboard.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/route-dashboard/route-dashboard.ts b/packages/webui-delivery/tests/fixtures/router-app/src/route-dashboard/route-dashboard.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/route-dashboard/route-dashboard.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/route-dashboard/route-dashboard.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/route-shell/route-shell.html b/packages/webui-delivery/tests/fixtures/router-app/src/route-shell/route-shell.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/route-shell/route-shell.html rename to packages/webui-delivery/tests/fixtures/router-app/src/route-shell/route-shell.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/route-shell/route-shell.ts b/packages/webui-delivery/tests/fixtures/router-app/src/route-shell/route-shell.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/route-shell/route-shell.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/route-shell/route-shell.ts diff --git a/packages/webui-router/tests/fixtures/router-app/src/router-config.ts b/packages/webui-delivery/tests/fixtures/router-app/src/router-config.ts similarity index 90% rename from packages/webui-router/tests/fixtures/router-app/src/router-config.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/router-config.ts index 41fa3a87f..f6a6d2cae 100644 --- a/packages/webui-router/tests/fixtures/router-app/src/router-config.ts +++ b/packages/webui-delivery/tests/fixtures/router-app/src/router-config.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import type { RouterConfig } from '@microsoft/webui-router'; +import type { RouterConfig } from '@microsoft/webui-delivery/router.js'; export const routerConfig: RouterConfig = { loaders: { diff --git a/packages/webui-router/tests/fixtures/router-app/src/test-dialog/test-dialog.css b/packages/webui-delivery/tests/fixtures/router-app/src/test-dialog/test-dialog.css similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/test-dialog/test-dialog.css rename to packages/webui-delivery/tests/fixtures/router-app/src/test-dialog/test-dialog.css diff --git a/packages/webui-router/tests/fixtures/router-app/src/test-dialog/test-dialog.html b/packages/webui-delivery/tests/fixtures/router-app/src/test-dialog/test-dialog.html similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/test-dialog/test-dialog.html rename to packages/webui-delivery/tests/fixtures/router-app/src/test-dialog/test-dialog.html diff --git a/packages/webui-router/tests/fixtures/router-app/src/test-dialog/test-dialog.ts b/packages/webui-delivery/tests/fixtures/router-app/src/test-dialog/test-dialog.ts similarity index 100% rename from packages/webui-router/tests/fixtures/router-app/src/test-dialog/test-dialog.ts rename to packages/webui-delivery/tests/fixtures/router-app/src/test-dialog/test-dialog.ts diff --git a/packages/webui-delivery/tests/lean-router.spec.ts b/packages/webui-delivery/tests/lean-router.spec.ts new file mode 100644 index 000000000..0e96ba8d8 --- /dev/null +++ b/packages/webui-delivery/tests/lean-router.spec.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +interface TestRouter { + gc(): void; + activeComponent: string; +} + +test.beforeEach(async ({ page }) => { + page.on('pageerror', error => console.error(error)); + page.on('console', message => { + if (message.type() === 'error') console.error(message.text()); + }); + await page.route('**/index.js', async route => { + const response = await route.fetch({ + url: new URL('/lean-entry.js', route.request().url()).href, + }); + await route.fulfill({ response }); + }); + await page.goto('/alpha'); + await page.waitForFunction(() => + (window as unknown as { __testRouter?: TestRouter }).__testRouter?.activeComponent === 'page-alpha', + ); +}); + +test('JSON-only navigation hydrates destination UI without a document reload', async ({ page }) => { + await page.evaluate(() => { document.body.dataset.instance = 'same-document'; }); + const response = page.waitForResponse(response => + new URL(response.url()).pathname === '/beta' && + response.request().headers().accept === 'application/json', + ); + await page.locator('nav a[href="/beta"]').click(); + expect((await response).ok()).toBe(true); + await expect(page.locator('h2')).toContainText('Beta Page'); + await expect(page.locator('body')).toHaveAttribute('data-instance', 'same-document'); +}); + +test('template collection preserves active bindings and allows later navigation', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + const released = await page.evaluate(() => { + const router = (window as unknown as { __testRouter: TestRouter }).__testRouter; + router.gc(); + return { + inventory: window.__webui?.inventory, + epoch: window.__webui?.inventoryEpoch, + }; + }); + expect(released.inventory).toBe(''); + expect(released.epoch).toBe(1); + await expect(page.locator('h2')).toContainText('Alpha Page'); + + await page.locator('nav a[href="/beta"]').click(); + await expect(page.locator('h2')).toContainText('Beta Page'); + await page.goBack(); + await expect(page.locator('h2')).toContainText('Alpha Page'); + expect(errors).toEqual([]); +}); diff --git a/packages/webui-router/tests/router-e2e.spec.ts b/packages/webui-delivery/tests/router-e2e.spec.ts similarity index 100% rename from packages/webui-router/tests/router-e2e.spec.ts rename to packages/webui-delivery/tests/router-e2e.spec.ts diff --git a/packages/webui-router/tsconfig.e2e.json b/packages/webui-delivery/tsconfig.e2e.json similarity index 100% rename from packages/webui-router/tsconfig.e2e.json rename to packages/webui-delivery/tsconfig.e2e.json diff --git a/packages/webui-router/tsconfig.json b/packages/webui-delivery/tsconfig.json similarity index 100% rename from packages/webui-router/tsconfig.json rename to packages/webui-delivery/tsconfig.json diff --git a/packages/webui-router/tsconfig.unit-test.json b/packages/webui-delivery/tsconfig.unit-test.json similarity index 100% rename from packages/webui-router/tsconfig.unit-test.json rename to packages/webui-delivery/tsconfig.unit-test.json diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index 42d8ba096..0fb135b2e 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -5,7 +5,7 @@ Lightweight Web Component runtime for WebUI apps. This package is the browser-side runtime used by `webui build --plugin=webui`. It provides: - `WebUIElement` for SSR hydration and client-created elements -- `@observable`, `@attr`, and `@volatile` decorators +- `@observable` and `@attr` decorators - direct DOM binding updates - Shadow-default components with opt-in global Light and authored Shadow islands - SSR state seeding @@ -49,6 +49,29 @@ TypeScript must enable decorator emit: ## Quick Example +The package root is a pure API barrel. Select rendering and styles once in the +application entry, then start rendering after the imports: + +```typescript +import { startRuntime } from '@microsoft/webui-framework/runtime.js'; +import '@microsoft/webui-framework/styles.js'; +import './counter-card.js'; + +startRuntime(); +``` + +Omit `styles.js` for components without managed CSS. `templates.js` exposes +registration without a renderer, and `template-cache.js` adds lifetime-aware +collection when imported before `startRuntime()` or template lookups. Component +declarations wait for startup and their metadata before registering. See +[Runtime entrypoints](https://microsoft.github.io/webui/guide/concepts/interactivity#runtime-entrypoints) +for requirements and examples. + +`lazy.js` is the opt-in renderer adapter for ordinary `` bodies. +It preserves the existing owner and scope without creating a component or +fetching resources. Loading and inventory remain separate client capabilities. +See [Lazy UI](https://microsoft.github.io/webui/guide/concepts/lazy). + 1. Author a component class in TypeScript 2. Author a WebUI template in HTML 3. Run `webui build --plugin=webui` @@ -57,17 +80,12 @@ TypeScript must enable decorator emit: ### `counter-card.ts` ```ts -import { WebUIElement, attr, observable, volatile } from '@microsoft/webui-framework'; +import { WebUIElement, attr, observable } from '@microsoft/webui-framework'; export class CounterCard extends WebUIElement { @attr label = 'Clicks'; @observable count = 0; - @volatile - get doubled(): number { - return this.count * 2; - } - increment(): void { this.count += 1; } @@ -79,7 +97,7 @@ CounterCard.define('counter-card'); ### `counter-card.html` ```html -

{{label}}: {{count}} ({{doubled}})

+

{{label}}: {{count}}

``` @@ -171,7 +189,9 @@ back to eager; `content-visibility` remains browser-managed. See [Lazy Hydration](https://microsoft.github.io/webui/guide/concepts/hydration#lazy-hydration). Router applications should author `