From a2c4c09de3696b5ff5777cd5df687d7795a61192 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Wed, 21 Oct 2026 11:16:35 -0700 Subject: [PATCH 01/10] feat: add on-demand template bodies and modular runtimes Extend ordinary if fragments with owner-scoped deferred delivery and initial SSR support. Separate rendering, resources, transport, and navigation capabilities while preserving shared inventory, cancellation, and template ownership. Expose matching host APIs, document the contracts, and add native popover/dialog examples with complete deferred contents. Include the TLS dependency patches required by the current security audit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 90a93005-7b2a-4ea4-bce5-902f79da872d --- .github/workflows/pr.yml | 18 +- Cargo.lock | 8 +- Cargo.toml | 2 +- DESIGN.md | 57 +- crates/webui-cli/src/commands/serve.rs | 33 +- .../webui-cli/src/commands/serve/deferred.rs | 465 +++++++ crates/webui-ffi/include/webui_ffi.h | 45 + crates/webui-ffi/src/deferred.rs | 354 +++++ crates/webui-ffi/src/lib.rs | 5 + crates/webui-handler/src/deferred/index.rs | 153 +++ crates/webui-handler/src/deferred/mod.rs | 404 ++++++ crates/webui-handler/src/deferred/scope.rs | 160 +++ crates/webui-handler/src/deferred_response.rs | 554 ++++++++ .../src/deferred_response/serialization.rs | 127 ++ crates/webui-handler/src/lib.rs | 160 ++- crates/webui-handler/src/plugin/webui.rs | 10 +- crates/webui-handler/src/route_handler.rs | 282 +++- .../webui-handler/src/streaming/checkpoint.rs | 10 + .../webui-handler/src/streaming/inventory.rs | 1 + crates/webui-handler/src/streaming/session.rs | 1 + crates/webui-handler/src/streaming/vm.rs | 3 + crates/webui-node/src/lib.rs | 90 ++ crates/webui-parser/src/deferred.rs | 44 + crates/webui-parser/src/diagnostic.rs | 10 + crates/webui-parser/src/error.rs | 11 + crates/webui-parser/src/lib.rs | 91 +- crates/webui-parser/src/plugin/mod.rs | 62 +- crates/webui-parser/src/plugin/webui.rs | 302 ++++- .../src/plugin/webui/deferred_templates.rs | 231 ++++ .../src/plugin/webui/named_repeats.rs | 4 +- .../src/plugin/webui/template_dependencies.rs | 222 +++ crates/webui-protocol/Cargo.toml | 2 + crates/webui-protocol/build.rs | 7 + crates/webui-protocol/proto/webui.proto | 24 + crates/webui-protocol/src/build_hash.rs | 13 + crates/webui-protocol/src/content_identity.rs | 134 ++ crates/webui-protocol/src/deferred.rs | 50 + crates/webui-protocol/src/gen_webui.rs | 42 + crates/webui-protocol/src/lib.rs | 145 ++ .../webui-protocol/src/projection_manifest.rs | 9 +- crates/webui-python/README.md | 6 + .../python/microsoft_webui/__init__.py | 4 + .../python/microsoft_webui/_api.py | 53 + .../python/microsoft_webui/_native.pyi | 8 + crates/webui-python/src/lib.rs | 67 + crates/webui-python/tests/compare_fixture.py | 72 + crates/webui-python/tests/conftest.py | 22 + .../tests/fixtures/deferred-app/index.html | 2 + .../fixtures/deferred-app/test-panel.html | 6 + .../tests/test_compare_fixture.py | 42 + crates/webui-python/tests/test_deferred.py | 40 + crates/webui-python/tests/test_package.py | 2 + crates/webui-wasm/Cargo.toml | 2 +- crates/webui-wasm/src/handler.rs | 60 + crates/webui-wasm/src/parser.rs | 6 + crates/webui/Cargo.toml | 6 +- crates/webui/benches/deferred_bench.rs | 104 ++ .../webui/src/component_assets/traversal.rs | 13 + crates/webui/src/lib.rs | 283 +++- docs/.webui-press/config.json | 4 + docs/ai.md | 49 + docs/guide/cli/index.md | 8 + docs/guide/concepts/deferred.md | 209 +++ docs/guide/concepts/directives/if.md | 19 + docs/guide/concepts/interactivity.md | 68 + docs/guide/concepts/plugins/index.md | 9 + docs/guide/concepts/routing.md | 90 +- docs/guide/integrations/dotnet.md | 23 + docs/guide/integrations/ffi.md | 36 + docs/guide/integrations/node.md | 33 + docs/guide/integrations/python.md | 29 + docs/guide/integrations/rust.md | 27 + docs/guide/integrations/wasm.md | 17 + dotnet/src/Microsoft.WebUI/DeferredOptions.cs | 27 + dotnet/src/Microsoft.WebUI/NativeBindings.cs | 38 + dotnet/src/Microsoft.WebUI/Protocol.cs | 49 + .../Microsoft.WebUI.Tests.csproj | 10 + .../Microsoft.WebUI.Tests/ProtocolTests.cs | 31 + .../fixtures/deferred-app/index.html | 2 + .../fixtures/deferred-app/test-panel.html | 6 + examples/README.md | 2 + examples/app/deferred-ui/README.md | 59 + examples/app/deferred-ui/data/state.json | 42 + examples/app/deferred-ui/demo.toml | 17 + examples/app/deferred-ui/package.json | 32 + examples/app/deferred-ui/playwright.config.ts | 30 + examples/app/deferred-ui/server/api.mjs | 97 ++ examples/app/deferred-ui/server/api.test.mjs | 57 + examples/app/deferred-ui/server/index.mjs | 26 + .../src/deferred-dialog/deferred-dialog.css | 75 ++ .../src/deferred-dialog/deferred-dialog.html | 54 + .../src/deferred-dialog/deferred-dialog.ts | 132 ++ .../src/deferred-popover/deferred-popover.css | 72 + .../deferred-popover/deferred-popover.html | 45 + .../src/deferred-popover/deferred-popover.ts | 101 ++ .../app/deferred-ui/src/deferred-state.ts | 15 + .../deferred-workspace/deferred-workspace.css | 80 ++ .../deferred-workspace.html | 61 + .../deferred-workspace/deferred-workspace.ts | 35 + examples/app/deferred-ui/src/index.html | 34 + examples/app/deferred-ui/src/index.ts | 8 + .../app/deferred-ui/tests/deferred-ui.spec.ts | 352 +++++ examples/app/deferred-ui/tsconfig.json | 8 + examples/app/deferred-ui/tsconfig.test.json | 9 + examples/demo/Dockerfile | 8 + packages/webui-framework/README.md | 13 + packages/webui-framework/package.json | 30 + .../src/bootstrap-identity.test.ts | 57 + packages/webui-framework/src/core.ts | 12 + .../webui-framework/src/deferred-contract.ts | 38 + .../webui-framework/src/deferred-entry.ts | 47 + packages/webui-framework/src/deferred.test.ts | 46 + packages/webui-framework/src/element.ts | 24 +- .../src/element/link-styles.ts | 21 + .../webui-framework/src/element/markers.ts | 2 +- .../src/element/style-markers.ts | 19 + .../src/element/style-runtime.test.ts | 76 ++ .../src/element/style-runtime.ts | 54 + .../webui-framework/src/element/styles.ts | 18 +- packages/webui-framework/src/element/types.ts | 13 +- .../src/index-decoupling.test.ts | 45 +- packages/webui-framework/src/index.ts | 27 +- .../webui-framework/src/static-host.test.ts | 15 + packages/webui-framework/src/static-host.ts | 34 +- .../src/streaming-bootstrap.ts | 22 +- .../src/streaming-pipeline.test.ts | 6 + .../webui-framework/src/streaming-protocol.ts | 4 + .../src/streaming-template-hosts.ts | 5 +- packages/webui-framework/src/styles-entry.ts | 50 + .../src/template-cache-contract.ts | 33 + .../src/template-cache-entry.ts | 8 + .../src/template-cache-order.test.ts | 23 + .../src/template-cache.test.ts | 130 ++ .../webui-framework/src/template-cache.ts | 293 ++++ .../src/template-element.test.ts | 81 ++ .../webui-framework/src/template-element.ts | 102 +- .../src/template-registration-errors.test.ts | 36 + .../webui-framework/src/template-registry.ts | 69 +- .../webui-framework/src/template-roots.ts | 2 +- .../webui-framework/src/template-types.ts | 22 +- packages/webui-framework/src/template.ts | 24 +- .../webui-framework/src/templates-entry.ts | 6 + .../webui-framework/tests/deferred-server.ts | 96 ++ .../tests/fixtures/basics/element.ts | 2 +- .../tests/fixtures/css-link/element.ts | 3 +- .../fixtures/deferred-ui/deferred-ui.spec.ts | 203 +++ .../tests/fixtures/deferred-ui/element.ts | 131 ++ .../tests/fixtures/deferred-ui/src/index.html | 13 + .../test-deferred-dialog.html | 13 + .../test-deferred-light.css | 1 + .../test-deferred-light.html | 23 + .../test-deferred-shadow.css | 1 + .../test-deferred-shadow.html | 23 + .../tests/fixtures/deferred-ui/state.json | 12 + .../fixtures/deferred-ui/webui.config.json | 1 + .../tests/fixtures/template-cache/element.ts | 42 + .../src/cache-child/cache-child.html | 1 + .../src/cache-parent/cache-parent.html | 4 + .../src/cache-unused/cache-unused.html | 1 + .../fixtures/template-cache/src/index.html | 8 + .../tests/fixtures/template-cache/state.json | 5 + .../template-cache/template-cache.spec.ts | 51 + packages/webui-framework/tests/server.ts | 3 + packages/webui-framework/tsconfig.test.json | 1 + packages/webui-router/README.md | 32 +- packages/webui-router/package.json | 74 +- packages/webui-router/src/abortable.ts | 14 + packages/webui-router/src/boundaries.ts | 13 + .../src/bundle-boundaries.test.ts | 183 +++ packages/webui-router/src/cache.ts | 5 + .../webui-router/src/capabilities.test.ts | 189 +++ packages/webui-router/src/capabilities.ts | 52 + packages/webui-router/src/core.ts | 6 + packages/webui-router/src/deferred-data.ts | 85 ++ packages/webui-router/src/deferred-entry.ts | 7 + .../webui-router/src/deferred-loader.test.ts | 202 +++ packages/webui-router/src/deferred-loader.ts | 235 ++++ packages/webui-router/src/deferred-ndjson.ts | 60 + packages/webui-router/src/diagnostics.ts | 32 + packages/webui-router/src/inventory.test.ts | 35 + packages/webui-router/src/inventory.ts | 43 + .../webui-router/src/ndjson-reader.test.ts | 143 ++ packages/webui-router/src/ndjson-reader.ts | 194 +++ packages/webui-router/src/partial-json.ts | 36 + packages/webui-router/src/partial-response.ts | 49 + packages/webui-router/src/preload.ts | 6 +- packages/webui-router/src/resources-entry.ts | 11 + packages/webui-router/src/router-core.ts | 1190 ++++++++++++++++ packages/webui-router/src/router.test.ts | 58 +- packages/webui-router/src/router.ts | 1191 +---------------- .../webui-router/src/runtime-bootstrap.ts | 29 + packages/webui-router/src/streaming.ts | 385 ++---- packages/webui-router/src/templates.test.ts | 115 ++ packages/webui-router/src/templates.ts | 81 +- packages/webui-router/src/types.ts | 8 + packages/webui-router/src/view-transitions.ts | 19 + .../fixtures/router-app/src/lean-entry.ts | 17 + .../webui-router/tests/lean-router.spec.ts | 60 + .../webui-test-support/src/fixture-render.ts | 21 +- packages/webui/package.json | 1 + packages/webui/src/index.ts | 57 + packages/webui/test/integration.test.ts | 39 + packages/webui/test/wasm-deferred.mjs | 70 + pnpm-lock.yaml | 30 + specs/client-delivery.md | 138 ++ specs/hydration.md | 39 +- xtask/src/e2e.rs | 10 + xtask/src/main.rs | 1 + 208 files changed, 12998 insertions(+), 1790 deletions(-) create mode 100644 crates/webui-cli/src/commands/serve/deferred.rs create mode 100644 crates/webui-ffi/src/deferred.rs create mode 100644 crates/webui-handler/src/deferred/index.rs create mode 100644 crates/webui-handler/src/deferred/mod.rs create mode 100644 crates/webui-handler/src/deferred/scope.rs create mode 100644 crates/webui-handler/src/deferred_response.rs create mode 100644 crates/webui-handler/src/deferred_response/serialization.rs create mode 100644 crates/webui-parser/src/deferred.rs create mode 100644 crates/webui-parser/src/plugin/webui/deferred_templates.rs create mode 100644 crates/webui-parser/src/plugin/webui/template_dependencies.rs create mode 100644 crates/webui-protocol/src/build_hash.rs create mode 100644 crates/webui-protocol/src/content_identity.rs create mode 100644 crates/webui-protocol/src/deferred.rs create mode 100644 crates/webui-python/tests/fixtures/deferred-app/index.html create mode 100644 crates/webui-python/tests/fixtures/deferred-app/test-panel.html create mode 100644 crates/webui-python/tests/test_deferred.py create mode 100644 crates/webui/benches/deferred_bench.rs create mode 100644 docs/guide/concepts/deferred.md create mode 100644 dotnet/src/Microsoft.WebUI/DeferredOptions.cs create mode 100644 dotnet/test/Microsoft.WebUI.Tests/fixtures/deferred-app/index.html create mode 100644 dotnet/test/Microsoft.WebUI.Tests/fixtures/deferred-app/test-panel.html create mode 100644 examples/app/deferred-ui/README.md create mode 100644 examples/app/deferred-ui/data/state.json create mode 100644 examples/app/deferred-ui/demo.toml create mode 100644 examples/app/deferred-ui/package.json create mode 100644 examples/app/deferred-ui/playwright.config.ts create mode 100644 examples/app/deferred-ui/server/api.mjs create mode 100644 examples/app/deferred-ui/server/api.test.mjs create mode 100644 examples/app/deferred-ui/server/index.mjs create mode 100644 examples/app/deferred-ui/src/deferred-dialog/deferred-dialog.css create mode 100644 examples/app/deferred-ui/src/deferred-dialog/deferred-dialog.html create mode 100644 examples/app/deferred-ui/src/deferred-dialog/deferred-dialog.ts create mode 100644 examples/app/deferred-ui/src/deferred-popover/deferred-popover.css create mode 100644 examples/app/deferred-ui/src/deferred-popover/deferred-popover.html create mode 100644 examples/app/deferred-ui/src/deferred-popover/deferred-popover.ts create mode 100644 examples/app/deferred-ui/src/deferred-state.ts create mode 100644 examples/app/deferred-ui/src/deferred-workspace/deferred-workspace.css create mode 100644 examples/app/deferred-ui/src/deferred-workspace/deferred-workspace.html create mode 100644 examples/app/deferred-ui/src/deferred-workspace/deferred-workspace.ts create mode 100644 examples/app/deferred-ui/src/index.html create mode 100644 examples/app/deferred-ui/src/index.ts create mode 100644 examples/app/deferred-ui/tests/deferred-ui.spec.ts create mode 100644 examples/app/deferred-ui/tsconfig.json create mode 100644 examples/app/deferred-ui/tsconfig.test.json create mode 100644 packages/webui-framework/src/bootstrap-identity.test.ts create mode 100644 packages/webui-framework/src/core.ts create mode 100644 packages/webui-framework/src/deferred-contract.ts create mode 100644 packages/webui-framework/src/deferred-entry.ts create mode 100644 packages/webui-framework/src/deferred.test.ts create mode 100644 packages/webui-framework/src/element/style-markers.ts create mode 100644 packages/webui-framework/src/element/style-runtime.test.ts create mode 100644 packages/webui-framework/src/element/style-runtime.ts create mode 100644 packages/webui-framework/src/styles-entry.ts create mode 100644 packages/webui-framework/src/template-cache-contract.ts create mode 100644 packages/webui-framework/src/template-cache-entry.ts create mode 100644 packages/webui-framework/src/template-cache-order.test.ts create mode 100644 packages/webui-framework/src/template-cache.test.ts create mode 100644 packages/webui-framework/src/template-cache.ts create mode 100644 packages/webui-framework/src/template-registration-errors.test.ts create mode 100644 packages/webui-framework/src/templates-entry.ts create mode 100644 packages/webui-framework/tests/deferred-server.ts create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/deferred-ui.spec.ts create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/element.ts create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/src/index.html create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/src/test-deferred-dialog/test-deferred-dialog.html create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/src/test-deferred-light/test-deferred-light.css create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/src/test-deferred-light/test-deferred-light.html create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/src/test-deferred-shadow/test-deferred-shadow.css create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/src/test-deferred-shadow/test-deferred-shadow.html create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/state.json create mode 100644 packages/webui-framework/tests/fixtures/deferred-ui/webui.config.json create mode 100644 packages/webui-framework/tests/fixtures/template-cache/element.ts create mode 100644 packages/webui-framework/tests/fixtures/template-cache/src/cache-child/cache-child.html create mode 100644 packages/webui-framework/tests/fixtures/template-cache/src/cache-parent/cache-parent.html create mode 100644 packages/webui-framework/tests/fixtures/template-cache/src/cache-unused/cache-unused.html create mode 100644 packages/webui-framework/tests/fixtures/template-cache/src/index.html create mode 100644 packages/webui-framework/tests/fixtures/template-cache/state.json create mode 100644 packages/webui-framework/tests/fixtures/template-cache/template-cache.spec.ts create mode 100644 packages/webui-router/src/abortable.ts create mode 100644 packages/webui-router/src/boundaries.ts create mode 100644 packages/webui-router/src/bundle-boundaries.test.ts create mode 100644 packages/webui-router/src/capabilities.test.ts create mode 100644 packages/webui-router/src/capabilities.ts create mode 100644 packages/webui-router/src/core.ts create mode 100644 packages/webui-router/src/deferred-data.ts create mode 100644 packages/webui-router/src/deferred-entry.ts create mode 100644 packages/webui-router/src/deferred-loader.test.ts create mode 100644 packages/webui-router/src/deferred-loader.ts create mode 100644 packages/webui-router/src/deferred-ndjson.ts create mode 100644 packages/webui-router/src/diagnostics.ts create mode 100644 packages/webui-router/src/inventory.test.ts create mode 100644 packages/webui-router/src/inventory.ts create mode 100644 packages/webui-router/src/ndjson-reader.test.ts create mode 100644 packages/webui-router/src/ndjson-reader.ts create mode 100644 packages/webui-router/src/partial-json.ts create mode 100644 packages/webui-router/src/partial-response.ts create mode 100644 packages/webui-router/src/resources-entry.ts create mode 100644 packages/webui-router/src/router-core.ts create mode 100644 packages/webui-router/src/runtime-bootstrap.ts create mode 100644 packages/webui-router/src/view-transitions.ts create mode 100644 packages/webui-router/tests/fixtures/router-app/src/lean-entry.ts create mode 100644 packages/webui-router/tests/lean-router.spec.ts create mode 100644 packages/webui/test/wasm-deferred.mjs create mode 100644 specs/client-delivery.md diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a0a3d4e88..28f3e4d59 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -603,7 +603,7 @@ 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] strategy: fail-fast: false matrix: @@ -635,6 +635,11 @@ jobs: name: ${{ matrix.platform.artifact }} path: publish/python + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-deferred-fixture + path: target/python-deferred-test + - name: Install the built wheel and test it shell: bash run: | @@ -705,6 +710,17 @@ jobs: cmp "crates/webui-python/tests/fixtures/greeting-card.css" \ "$output/greeting-card.css" + - name: Build the deferred fixture without committing generated protocol bytes + run: | + cargo run --quiet -p microsoft-webui-cli -- build \ + "crates/webui-python/tests/fixtures/deferred-app" \ + --plugin webui --out "target/python-deferred-test" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-deferred-fixture + path: target/python-deferred-test/protocol.bin + # ── Gate: one stable required check ──────────────────────────────── # Branch protection can only require per-job contexts, so requiring the jobs # individually means editing that list every time one is added, renamed, or diff --git a/Cargo.lock b/Cargo.lock index 106c129f8..b1019cccb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3374,9 +3374,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -3398,9 +3398,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", diff --git a/Cargo.toml b/Cargo.toml index fd256a683..63b928931 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ wasm-bindgen = "=0.2.127" js-sys = "0.3.104" serde-wasm-bindgen = "0.6.5" actix-web = "4.15.0" -rustls = "0.23.45" +rustls = "0.23.46" rcgen = "0.14.9" awc = "3.8.2" hmac = "0.13.0" diff --git a/DESIGN.md b/DESIGN.md index 1219abd58..ed67ac6bc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -63,6 +63,19 @@ 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. Convenience entry points may +compose those capabilities, but lean entry points must not reach unselected +implementations through either static or dynamic imports. + +Shared browser delivery remains hydration-agnostic. Navigation and deferred +template loading consume the same resource availability, build identity, +preload, and transport mechanisms. 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 +109,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 deferred `` 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 +157,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 +216,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 +256,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. +Deferred-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 +278,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. @@ -300,6 +341,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 @@ -500,6 +550,7 @@ engineering another; they are not public application-authoring guides. | 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` | +| Optional client delivery and inventory ownership | [`specs/client-delivery.md`](specs/client-delivery.md); `packages/webui-router/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 dc8d6c341..76f85c2ee 100644 --- a/crates/webui-cli/src/commands/serve.rs +++ b/crates/webui-cli/src/commands/serve.rs @@ -2,6 +2,7 @@ // Licensed under the MIT license. mod component_assets; +mod deferred; mod host; mod streaming_api; @@ -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, @@ -422,8 +423,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(), })); @@ -542,6 +543,8 @@ fn serve_app( "/_webui/templates", web::get().to(handle_component_templates), ) + .route("/_webui/deferred", web::get().to(deferred::resources)) + .route("/_webui/deferred/state", web::get().to(deferred::state)) .route("/{tail:.*}", web::get().to(handle_asset)) .default_service(web::route().to(handle_not_found)) } @@ -908,7 +911,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(), ), @@ -920,7 +924,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(), ), @@ -1074,7 +1079,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())) } @@ -1086,7 +1091,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(), @@ -1792,8 +1797,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) @@ -1861,14 +1866,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(), @@ -2461,7 +2466,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(), @@ -2962,7 +2967,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/deferred.rs b/crates/webui-cli/src/commands/serve/deferred.rs new file mode 100644 index 000000000..c07479c4a --- /dev/null +++ b/crates/webui-cli/src/commands/serve/deferred.rs @@ -0,0 +1,465 @@ +// 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::{DeferredOptions, DeferredRequest, HandlerError, 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!("deferred 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, + "deferred-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, + "deferred-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, + "deferred-invalid-header", + "resource headers must be valid text", + )) + }) + }) + } + Ok(DeferredOptions { + 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, + "deferred-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_deferred_resources( + DeferredRequest::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, + "deferred-not-acceptable", + "accept application/json or application/x-ndjson", + ) + } + }, + Err(_) => { + return failure( + StatusCode::BAD_REQUEST, + "deferred-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, "deferred-state-failed", &error), + }; + return match protocol.render_deferred_partial( + state.value(), + DeferredRequest::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_deferred_resources( + DeferredRequest::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_deferred_partial( + state.value(), + DeferredRequest::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!("Deferred state delivery failed: {message}"); + serde_json::to_vec(&ErrorRecord { + error: ErrorDetail { + code: "deferred-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(port) = context.api_port else { + return Ok(State::Shared(snapshot.state)); + }; + let mut state = fetch_api_state(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::DeferredRequest(error) = error { + let status = match error.code { + "deferred-not-found" => StatusCode::NOT_FOUND, + "deferred-build-mismatch" => StatusCode::CONFLICT, + _ => StatusCode::BAD_REQUEST, + }; + return failure(status, error.code, &error.message); + } + log::error!("Deferred resource delivery failed: {error}"); + failure( + StatusCode::INTERNAL_SERVER_ERROR, + "deferred-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::defer: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/deferred", web::get().to(resources)), + ) + .await; + let response = actix_test::call_service( + &app, + actix_test::TestRequest::get() + .uri("/_webui/deferred?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-deferred"); + assert!(payload.get("state").is_none()); + assert!(payload["templates"]["app-shell::defer: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/deferred/state", web::get().to(state)), + ) + .await; + let response = actix_test::call_service( + &app, + actix_test::TestRequest::get() + .uri("/_webui/deferred/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/deferred", 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/deferred?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..7ef3e2e94 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_deferred_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_deferred_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_deferred_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_deferred_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/deferred.rs b/crates/webui-ffi/src/deferred.rs new file mode 100644 index 000000000..2227559c5 --- /dev/null +++ b/crates/webui-ffi/src/deferred.rs @@ -0,0 +1,354 @@ +// 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::{DeferredOptions, DeferredRequest}; + +struct Arguments<'a> { + protocol: &'a ProtocolContext, + owner: &'a str, + id: &'a str, + options: DeferredOptions, +} + +impl Arguments<'_> { + fn request(&self) -> DeferredRequest<'_> { + DeferredRequest::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 deferred 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() { + DeferredOptions::default() + } else { + // SAFETY: A present options pointer must be readable and terminated. + let text = unsafe { utf8_arg(options, "deferred options") }?; + match serde_json::from_str(text) { + Ok(options) => options, + Err(error) => { + set_last_error(format!("invalid deferred 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!("deferred 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 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. +#[no_mangle] +pub unsafe extern "C" fn webui_protocol_render_deferred_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_deferred_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_deferred_resources(args.request()), + ) + }) +} + +/// 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`. +#[no_mangle] +pub unsafe extern "C" fn webui_protocol_render_deferred_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_deferred_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_deferred_partial_json(state, args.request()), + ) + }) +} + +/// Prepare resource JSON and a small inventory-options JSON acknowledgement. +/// +/// # Safety +/// +/// The protocol/string arguments follow `webui_protocol_render_deferred_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_deferred_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_deferred_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_deferred_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!("deferred 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::defer: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_deferred_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_deferred_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_deferred_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_deferred_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_deferred_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_deferred_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..e7bbb46a2 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 deferred; mod render_capacity; +pub use deferred::{ + webui_protocol_prepare_deferred_resources, webui_protocol_render_deferred_partial, + webui_protocol_render_deferred_resources, +}; use render_capacity::RenderCapacityHint; /// Opaque C handle for a loaded WebUI protocol. diff --git a/crates/webui-handler/src/deferred/index.rs b/crates/webui-handler/src/deferred/index.rs new file mode 100644 index 000000000..cdef2cde8 --- /dev/null +++ b/crates/webui-handler/src/deferred/index.rs @@ -0,0 +1,153 @@ +// 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_DEFERRED: u8 = 1; +pub(super) const HAS_OUTLET: u8 = 2; + +pub(crate) struct DeferredIndex { + 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 DeferredIndex { + 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.deferred) + })) { + 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.deferred { + flags[slot] |= HAS_DEFERRED; + } + 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_DEFERRED != 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/deferred/mod.rs b/crates/webui-handler/src/deferred/mod.rs new file mode 100644 index 000000000..fa05e02aa --- /dev/null +++ b/crates/webui-handler/src/deferred/mod.rs @@ -0,0 +1,404 @@ +// 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_DEFERRED}; +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::DeferredIndex; +pub(crate) type DeferredSelection<'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 DeferredIndex, + 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: DeferredSelection<'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_DEFERRED == 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("deferred 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, deferred) = match fragment { + Some(Fragment::IfCond(condition)) => (condition, condition.deferred), + _ => return Err(invalid_plan()), + }; + let target = self.target(&condition.fragment_id)?; + if !deferred && !self.relevant(target) { + return Ok(()); + } + let expression = condition.condition.as_ref().ok_or_else(invalid_plan)?; + if self.values().condition(expression)? { + if deferred { + 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 deferred-selection instruction; rebuild the protocol".into(), + ) +} + +#[cold] +#[inline(never)] +fn selection_limit() -> HandlerError { + HandlerError::Rendering( + "deferred-selection limit exceeded; reduce nested or repeated deferred regions".into(), + ) +} diff --git a/crates/webui-handler/src/deferred/scope.rs b/crates/webui-handler/src/deferred/scope.rs new file mode 100644 index 000000000..fc1170ec8 --- /dev/null +++ b/crates/webui-handler/src/deferred/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/deferred_response.rs b/crates/webui-handler/src/deferred_response.rs new file mode 100644 index 000000000..c5f8abf97 --- /dev/null +++ b/crates/webui-handler/src/deferred_response.rs @@ -0,0 +1,554 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +mod serialization; + +use crate::{deferred, route_handler, HandlerError, Protocol, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serialization::write_record; +use std::collections::HashSet; +use webui_protocol::{deferred_fragment_id, web_ui_fragment::Fragment}; + +/// Client availability used by deferred-template requests. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub struct DeferredOptions { + /// 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 PreparedDeferredResources { + /// 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: DeferredOptions, +} + +/// An owner-scoped deferred template request. It does not create a component. +#[derive(Clone, Copy, Debug)] +pub struct DeferredRequest<'a> { + /// The owning template, usually a component tag. + pub owner: &'a str, + /// The deferred 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> DeferredRequest<'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 DeferredOptions) -> Self { + self.inventory = &options.inventory; + self.style_inventory = &options.style_inventory; + self.build_id = &options.build_id; + self + } +} + +/// An actionable deferred-resource request failure. +#[derive(Debug)] +pub struct DeferredRequestError { + /// 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_deferred_resources`]. + #[must_use = "flush the resource record and carry its acknowledgement into the state call"] + pub fn prepare_deferred_resources( + &self, + request: DeferredRequest<'_>, + ) -> Result { + let root = validate_request(self, request)?; + let selected = collect_resources(self, root, None)?; + let record = prepare_resources(self, root, request, selected)?; + let options = DeferredOptions { + inventory: record.inventory.clone(), + style_inventory: record.style_inventory.clone(), + build_id: self.protocol().build_id.clone(), + }; + Ok(PreparedDeferredResources { + json: write_record(record, None::<&Value>)?, + options, + }) + } + + /// Render a resource-only record for a deferred template body. + /// + /// Ordinary block/component dependencies are included; nested deferred + /// bodies remain independent. This operation loads no application state. + /// + /// # Errors + /// + /// Rejects unknown regions, incompatible builds, invalid inventories, and + /// malformed compiled metadata. + #[must_use = "handle deferred resource errors before sending a response"] + pub fn render_deferred_resources(&self, request: DeferredRequest<'_>) -> 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 deferred response, or the terminal state record of NDJSON. + /// + /// State belongs to the existing owner/scope. Inventory never suppresses + /// state. If the state activates nested deferred 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 deferred response errors before sending a response"] + pub fn render_deferred_partial( + &self, + state: &Value, + request: DeferredRequest<'_>, + ) -> Result { + let root = validate_request(self, request)?; + if !state.is_object() { + return Err(HandlerError::InvalidState( + "deferred state must be a JSON object".into(), + )); + } + let render = self.render_fragments().resolve(self.protocol()); + let active = self + .deferred_index() + .map(|index| { + deferred::select(deferred::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 deferred state from JSON, borrowing projected values when possible. + /// + /// State is parsed once only when nested deferred decisions need evaluation; + /// parsed-state Rust hosts can use [`Self::render_deferred_partial`] directly. + /// + /// # Errors + /// + /// Returns the same errors as [`Self::render_deferred_partial`], including + /// malformed state JSON. + #[must_use = "handle deferred response errors before sending a response"] + pub fn render_deferred_partial_json( + &self, + state_json: &str, + request: DeferredRequest<'_>, + ) -> Result { + let root = validate_request(self, request)?; + if self.deferred_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_deferred_partial(&state, request); + } + if !state_json.trim_start().starts_with('{') { + return Err(HandlerError::InvalidState( + "deferred 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: DeferredRequest<'_>) -> Result<&'a str> { + protocol.ensure_style_metadata()?; + let document = protocol.protocol(); + if document.build_id.is_empty() { + return Err(request_error( + "deferred-missing-build-id", + "rebuild the protocol with the current compiler before requesting deferred regions", + )); + } + if !request.build_id.is_empty() && request.build_id != document.build_id { + return Err(request_error( + "deferred-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( + "deferred-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( + "deferred-invalid-target", + "use the owning template and an authored deferred 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( + "deferred-invalid-inventory", + "inventory exceeds this artifact's resource count; use inventory from the same build", + )); + } + let key = deferred_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("deferred-not-found", "declare in this owning template and rebuild")) +} + +fn collect_resources<'a>( + protocol: &'a Protocol, + root: &'a str, + active: Option<&deferred::DeferredSelection<'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.deferred + || 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: DeferredRequest<'_>, + 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::DeferredRequest(Box::new(DeferredRequestError { + code, + message: message.into(), + })) +} + +#[cold] +#[inline(never)] +fn invalid_inventory(error: HandlerError) -> HandlerError { + request_error( + "deferred-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 = deferred_fragment_id("app-shell", Some("settings"), 0); + let nested = deferred_fragment_id("app-shell", Some("advanced"), 0); + let mut document = WebUIProtocol::new(HashMap::from([ + ( + root.clone(), + FragmentList { + fragments: vec![WebUIFragment::deferred_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_deferred_bodies() { + let protocol = fixture(); + let prepared = protocol + .prepare_deferred_resources(DeferredRequest::new("app-shell", "settings")) + .unwrap(); + let json: Value = serde_json::from_str(&prepared.json).unwrap(); + assert_eq!(json["type"], "webui-deferred"); + assert!(json.get("state").is_none()); + assert!(json["templates"]["app-shell::defer:settings"].is_object()); + assert!(json["templates"].get("app-shell::defer: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_deferred_resources(DeferredRequest::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_deferred_resources(DeferredRequest::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_deferred_partial( + &state, + DeferredRequest::new("app-shell", "settings").with_options(&prepared.options), + ) + .unwrap(), + ) + .unwrap(); + assert!(payload["templates"] + .get("app-shell::defer:settings") + .is_none()); + assert!(payload["templates"]["app-shell::defer:advanced"].is_object()); + assert_eq!( + payload["state"], + serde_json::json!({ "title": "Fresh", "advanced": true, "details": [1, 2] }) + ); + let options = DeferredOptions { + 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_deferred_partial_json( + &state.to_string(), + DeferredRequest::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_deferred_partial_json( + r#"{"details":"fresh","unrelated":[1,2,3]}"#, + DeferredRequest::new("app-shell", "advanced"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(payload["state"], serde_json::json!({ "details": "fresh" })); + for state in ["[]", "{", r#"{"unrelated":1e999}"#] { + assert!(protocol + .render_deferred_partial_json(state, DeferredRequest::new("app-shell", "advanced")) + .is_err()); + } + } + + #[test] + fn bad_targets_builds_and_inventories_return_distinct_request_errors() { + let protocol = fixture(); + for (request, code) in [ + ( + DeferredRequest::new("app-shell", "missing"), + "deferred-not-found", + ), + ( + DeferredRequest { + build_id: "old", + ..DeferredRequest::new("app-shell", "settings") + }, + "deferred-build-mismatch", + ), + ( + DeferredRequest { + inventory: "01", + ..DeferredRequest::new("app-shell", "settings") + }, + "deferred-missing-build-id", + ), + ( + DeferredRequest { + inventory: "ffffffff", + build_id: "fixture", + ..DeferredRequest::new("app-shell", "settings") + }, + "deferred-invalid-inventory", + ), + ] { + let Err(HandlerError::DeferredRequest(error)) = + protocol.render_deferred_resources(request) + else { + panic!("expected typed request failure"); + }; + assert_eq!(error.code, code); + } + } +} diff --git a/crates/webui-handler/src/deferred_response/serialization.rs b/crates/webui-handler/src/deferred_response/serialization.rs new file mode 100644 index 000000000..45939a45d --- /dev/null +++ b/crates/webui-handler/src/deferred_response/serialization.rs @@ -0,0 +1,127 @@ +// 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("deferred 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 deferred 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-deferred\",\"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 d0bd001b4..f7dfe7c6d 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -7,6 +7,11 @@ //! into final HTML output based on provided data. pub mod css_module; +mod deferred; +mod deferred_response; +pub use deferred_response::{ + DeferredOptions, DeferredRequest, DeferredRequestError, PreparedDeferredResources, +}; pub(crate) mod html_encode; pub mod plugin; mod render_capacity; @@ -72,6 +77,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 deferred delivery. + #[error("deferred request [{}]: {}", .0.code, .0.message)] + DeferredRequest(Box), #[error("Rendering error: {0}")] Rendering(String), @@ -919,6 +927,7 @@ pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> { /// Fragment lists resolve through numeric slots instead of per-fragment /// string hashing. pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>, + pub(crate) deferred_index: Option<&'protocol deferred::DeferredIndex>, /// Build-constant component asset style metadata serialized once when the /// runtime [`Protocol`] is created. pub(crate) component_asset_style_manifest: &'protocol str, @@ -1312,6 +1321,8 @@ pub(crate) enum WebUiBootstrapState<'a> { } pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> { + pub(crate) build_id: &'a str, + pub(crate) style_inventory: &'a str, pub(crate) declaration_id: Option, pub(crate) enclosing_span_instance_id: Option, pub(crate) state: WebUiBootstrapState<'a>, @@ -1886,23 +1897,40 @@ fn collect_component_state_into<'a, 'b>( ) -> bool { keys.clear(); for name in components { - let Some(component) = protocol.components.get(name) else { - return true; - }; - let (mode, component_keys) = match surface { - ComponentStateSurface::Hydration => { - (component.hydration_mode, &component.hydration_keys) + let (mode, component_keys) = if let Some(fragment) = protocol.client_fragments.get(name) { + match surface { + ComponentStateSurface::Hydration => { + let Some(owner) = protocol.components.get(&fragment.owner) else { + return true; + }; + (owner.hydration_mode, &owner.hydration_keys) + } + ComponentStateSurface::Navigation => ( + fragment + .state_mode + .unwrap_or(StateProjectionMode::All as i32), + &fragment.state_keys, + ), + } + } else { + let Some(component) = protocol.components.get(name) else { + return true; + }; + match surface { + ComponentStateSurface::Hydration => { + (component.hydration_mode, &component.hydration_keys) + } + ComponentStateSurface::Navigation => ( + match component.navigation_mode { + Some(mode) => mode, + None if !component.navigation_keys.is_empty() => { + StateProjectionMode::Keys as i32 + } + None => return true, + }, + &component.navigation_keys, + ), } - ComponentStateSurface::Navigation => ( - match component.navigation_mode { - Some(mode) => mode, - None if !component.navigation_keys.is_empty() => { - StateProjectionMode::Keys as i32 - } - None => return true, - }, - &component.navigation_keys, - ), }; if mode == StateProjectionMode::All as i32 { return true; @@ -1928,6 +1956,22 @@ pub(crate) fn write_webui_bootstrap( let mut wrote_field = false; writer.write("{")?; + if !bootstrap.build_id.is_empty() { + write_json_field( + writer, + scratch, + &mut wrote_field, + "buildId", + bootstrap.build_id, + )?; + write_json_field( + writer, + scratch, + &mut wrote_field, + "styleInventory", + bootstrap.style_inventory, + )?; + } if let Some(declaration_id) = bootstrap.declaration_id { write_json_field( writer, @@ -2874,14 +2918,10 @@ impl WebUIHandler { if context.css_strategy != webui_protocol::CssStrategy::Link { return Ok(()); } - let reachable = context.reachable_components.take().unwrap_or_else(|| { - crate::route_handler::collect_reachable_component_order_for_request( - context.protocol, - context.entry_id, - context.request_path, - context.route_index, - ) - }); + let reachable = match context.reachable_components.take() { + Some(components) => components, + None => Self::request_reachable_components(context)?, + }; let result = (|| { for component in &reachable { if context.protocol.component_uses_shadow_dom(component) { @@ -2894,6 +2934,35 @@ impl WebUIHandler { result } + fn request_reachable_components(context: &WebUIProcessContext) -> Result> { + let Some(index) = context.deferred_index else { + return Ok( + route_handler::collect_reachable_component_order_for_request( + context.protocol, + context.entry_id, + context.request_path, + context.route_index, + ), + ); + }; + let active = deferred::select(deferred::SelectionInput { + protocol: context.protocol, + render: &context.render_fragments, + index, + state: context.state, + entry: context.entry_id, + request_path: context.request_path, + routes: context.route_index, + })?; + Ok(route_handler::collect_reachable_with_deferred( + context.protocol, + context.entry_id, + context.request_path, + context.route_index, + Some(&active), + )) + } + fn process_shadow_style_signal( &self, root: &str, @@ -3519,14 +3588,10 @@ impl WebUIHandler { // `` CSS ``/style-module emission order must stay // deterministic across renders, not vary with the process's // randomized hash seed. - let reachable = context.reachable_components.take().unwrap_or_else(|| { - crate::route_handler::collect_reachable_component_order_for_request( - context.protocol, - context.entry_id, - context.request_path, - context.route_index, - ) - }); + let reachable = match context.reachable_components.take() { + Some(components) => components, + None => Self::request_reachable_components(context)?, + }; let state_selection = collect_hydration_state(context.protocol, reachable.iter().map(String::as_str)); @@ -3570,10 +3635,21 @@ impl WebUIHandler { } // Compute the inventory hex from actually rendered components. - let inventory_hex = crate::route_handler::encode_component_inventory( + let mut inventory_hex = crate::route_handler::encode_component_inventory( &context.rendered_components, context.component_index, ); + if !context.protocol.client_fragments.is_empty() { + let mut inventory = route_handler::parse_inventory(&inventory_hex)?; + for name in &reachable { + if context.protocol.client_fragments.contains_key(name) { + if let Some(&index) = context.component_index.get(name) { + route_handler::set_component(&mut inventory, index); + } + } + } + inventory_hex = route_handler::encode_inventory(&inventory); + } // Chain Self::ensure_request_route_chain(context); @@ -3633,13 +3709,25 @@ impl WebUIHandler { style_roots.push(entry.error_component.as_str()); } } - style_roots.extend(reachable.iter().map(String::as_str)); + style_roots.extend( + reachable + .iter() + .map(String::as_str) + .filter(|id| !context.protocol.client_fragments.contains_key(*id)), + ); let component_styles = crate::route_handler::collect_component_styles(context.protocol, style_roots)?; + let style_inventory = if context.protocol.build_id.is_empty() { + String::new() + } else { + route_handler::style_inventory(&component_styles, context.style_resource_index)? + }; write_webui_data_block( context.writer, &mut context.json_scratch, WebUiBootstrap { + build_id: &context.protocol.build_id, + style_inventory: &style_inventory, declaration_id: None, enclosing_span_instance_id: None, state: WebUiBootstrapState::Complete { @@ -3789,6 +3877,9 @@ impl WebUIHandler { } if condition_met { + if if_cond.deferred && context.streaming.is_some() { + record_checkpoint_tag(context, &if_cond.fragment_id); + } if let Some(p) = &mut context.plugin { p.push_scope(); } @@ -4084,6 +4175,7 @@ impl WebUIHandler { let mut context = WebUIProcessContext { protocol: document, render_fragments, + deferred_index: protocol.deferred_index(), component_asset_style_manifest, component_asset_style_links: protocol.component_asset_style_links(), state, diff --git a/crates/webui-handler/src/plugin/webui.rs b/crates/webui-handler/src/plugin/webui.rs index 2c6d3875c..1a67f971f 100644 --- a/crates/webui-handler/src/plugin/webui.rs +++ b/crates/webui-handler/src/plugin/webui.rs @@ -271,12 +271,12 @@ fn webui_collect_payloads<'a, 'b>( ) -> Option>> { let mut templates: Vec> = Vec::new(); for name in tags { - if let Some((tag_name, component)) = protocol.components.get_key_value(name) { - if !component.template_json.is_empty() { + if let Some(payload) = protocol.client_template(name) { + if !payload.metadata.is_empty() { templates.push(super::WebUiTemplatePayload { - tag_name: tag_name.as_str(), - template_json: component.template_json.as_str(), - template_functions: component.template_functions.as_str(), + tag_name: payload.id, + template_json: payload.metadata, + template_functions: payload.functions, }); } } diff --git a/crates/webui-handler/src/route_handler.rs b/crates/webui-handler/src/route_handler.rs index 193ff3b1f..a4c5b65d7 100644 --- a/crates/webui-handler/src/route_handler.rs +++ b/crates/webui-handler/src/route_handler.rs @@ -66,6 +66,7 @@ pub struct Protocol { /// Render plan prepared once at load: numeric fragment slots, per-fragment /// render targets, canonical component prop names, and route presence bits. render_fragments: crate::RenderFragmentIndex, + deferred_index: Option, component_asset_style_manifest: std::result::Result, component_asset_style_links: String, component_index: HashMap, @@ -112,8 +113,30 @@ impl Protocol { fn new_with_style_metadata( mut protocol: WebUIProtocol, - style_metadata_error: Option, + mut style_metadata_error: Option, ) -> Self { + if style_metadata_error.is_none() { + for (tag, component) in &protocol.components { + if !component.template_json.is_empty() { + if let Err(error) = validate_json_inner(&component.template_json) { + style_metadata_error = Some(format!( + "failed to parse template metadata for {tag}: {error}" + )); + break; + } + if style_metadata_error.is_none() { + for (id, fragment) in &protocol.client_fragments { + if let Err(error) = validate_json_inner(&fragment.template_json) { + style_metadata_error = Some(format!( + "failed to parse template metadata for {id}: {error}" + )); + break; + } + } + } + } + } + } let css_strategy = protocol.css_strategy(); let component_asset_style_preloads = std::mem::take(&mut protocol.component_asset_style_preloads); @@ -152,11 +175,14 @@ impl Protocol { // resolves targets through the slot map instead of re-searching by name. let render_fragments = crate::RenderFragmentIndex::new(&protocol, &fragment_ids, &fragment_slots); + let deferred_index = + crate::deferred::DeferredIndex::new(&protocol, &fragment_ids, &fragment_slots); Self { protocol, style_metadata_error, css_strategy, render_fragments, + deferred_index, component_asset_style_manifest, component_asset_style_links, component_index, @@ -191,6 +217,10 @@ impl Protocol { &self.render_fragments } + pub(crate) fn deferred_index(&self) -> Option<&crate::deferred::DeferredIndex> { + self.deferred_index.as_ref() + } + pub(crate) fn component_asset_style_manifest(&self) -> Result<&str, HandlerError> { match &self.component_asset_style_manifest { Ok(manifest) => Ok(manifest), @@ -317,6 +347,14 @@ impl Protocol { inventory_hex: &str, ) -> Result { self.ensure_style_metadata()?; + if self.deferred_index.as_ref().is_some_and(|index| { + self.fragment_slot(entry_id) + .is_some_and(|slot| index.relevant(slot as usize, false)) + }) { + let state = serde_json::from_str(state_json) + .map_err(|error| invalid_state_json(&error.to_string()))?; + return self.render_partial(state, entry_id, request_path, inventory_hex); + } let mut index = self.request_index(); let (response, state_selection, _) = render_partial_indexed_with_state( self.protocol(), @@ -365,7 +403,24 @@ impl Protocol { inventory_hex: &str, ) -> Result { self.ensure_style_metadata()?; + let render = self.render_fragments.resolve(self.protocol()); + let active = self + .deferred_index + .as_ref() + .map(|index| { + crate::deferred::select(crate::deferred::SelectionInput { + protocol: self.protocol(), + render: &render, + index, + state: &state, + entry: entry_id, + request_path, + routes: self.route_index(), + }) + }) + .transpose()?; let mut index = self.request_index(); + index.active_deferred = active.as_ref(); let (response, state_selection, matched) = render_partial_indexed_with_state( self.protocol(), entry_id, @@ -514,8 +569,10 @@ impl Protocol { fn request_index(&self) -> RequestProtocolIndex<'_> { RequestProtocolIndex { component_index: &self.component_index, + style_resource_index: self.style_resource_index(), route_index: &self.route_index, template_metadata_cache: TemplateMetadataCache::Shared(&self.template_metadata_cache), + active_deferred: None, } } } @@ -531,6 +588,7 @@ struct ProtocolIndex { pub component_index: HashMap, /// Authored route template patterns compiled once. pub route_index: CompiledRouteIndex, + style_resource_index: HashMap, /// Parsed WebUI template metadata keyed by component tag. template_metadata_cache: HashMap, } @@ -752,6 +810,7 @@ pub(crate) fn collect_borrowed_component_style_delta<'a>( let mut seen_roots = HashSet::new(); let roots: Vec<&str> = roots .into_iter() + .filter(|root| !protocol.client_fragments.contains_key(*root)) .filter(|root| seen_roots.insert(*root)) .collect(); let client_inventory = StyleInventoryView { @@ -1035,6 +1094,7 @@ impl ProtocolIndex { pub(crate) fn new(protocol: &WebUIProtocol) -> Self { Self { component_index: build_component_index(protocol), + style_resource_index: build_style_resource_index(protocol), route_index: CompiledRouteIndex::new(protocol), template_metadata_cache: HashMap::new(), } @@ -1043,10 +1103,16 @@ impl ProtocolIndex { fn request_index(&mut self) -> RequestProtocolIndex<'_> { RequestProtocolIndex { component_index: &self.component_index, + style_resource_index: if self.style_resource_index.is_empty() { + &self.component_index + } else { + &self.style_resource_index + }, route_index: &self.route_index, template_metadata_cache: TemplateMetadataCache::Exclusive( &mut self.template_metadata_cache, ), + active_deferred: None, } } } @@ -1059,8 +1125,10 @@ enum TemplateMetadataCache<'a> { struct RequestProtocolIndex<'a> { component_index: &'a HashMap, + style_resource_index: &'a HashMap, route_index: &'a CompiledRouteIndex, template_metadata_cache: TemplateMetadataCache<'a>, + active_deferred: Option<&'a crate::deferred::DeferredSelection<'a>>, } // ── Component Inventory ───────────────────────────────────────────────── @@ -1101,8 +1169,10 @@ pub(crate) fn build_component_index(protocol: &WebUIProtocol) -> HashMap (Vec>, Vec, Vec) { let mut distinct: Vec<&str> = Vec::new(); - for name in names { - if let Some(component) = protocol.components.get(name) { - distinct.extend(component.hydration_keys.iter().map(String::as_str)); - } + for component in protocol.components.values() { + distinct.extend(component.hydration_keys.iter().map(String::as_str)); } distinct.sort_unstable(); distinct.dedup(); @@ -1289,8 +1357,17 @@ fn intern_hydration_projections( let mut key_ids = Vec::new(); let mut runs = Vec::with_capacity(names.len()); + let mut owner_runs: HashMap<&str, HydrationRun> = HashMap::new(); for name in names { - let Some(component) = protocol.components.get(name) else { + let owner = protocol + .client_fragments + .get(name) + .map_or(name.as_str(), |data| data.owner.as_str()); + if let Some(run) = owner_runs.get(owner) { + runs.push(*run); + continue; + } + let Some(component) = protocol.components.get(owner) else { // A component with no compiled surface cannot be projected, exactly // as the name-based collector treated a missing entry. runs.push(HydrationRun::FULL_STATE); @@ -1316,10 +1393,12 @@ fn intern_hydration_projections( } } } - match (u32::try_from(start), u32::try_from(key_ids.len() - start)) { - (Ok(start), Ok(len)) if len != u32::MAX => runs.push(HydrationRun { start, len }), - _ => runs.push(HydrationRun::FULL_STATE), - } + let run = match (u32::try_from(start), u32::try_from(key_ids.len() - start)) { + (Ok(start), Ok(len)) if len != u32::MAX => HydrationRun { start, len }, + _ => HydrationRun::FULL_STATE, + }; + owner_runs.insert(owner, run); + runs.push(run); } (keys, key_ids, runs) } @@ -1339,6 +1418,15 @@ fn collect_direct_component_dependencies( let mut seen_components = HashSet::new(); let mut dependencies = Vec::new(); let mut has_route = false; + if let Some(fragment) = protocol.client_fragments.get(root) { + for dependency in &fragment.dependencies { + if let Some(&index) = component_index.get(dependency) { + if seen_components.insert(index) { + dependencies.push(index); + } + } + } + } while let Some(item) = work.pop() { match item { @@ -1364,7 +1452,7 @@ fn collect_direct_component_dependencies( Some(Fragment::ForLoop(for_loop)) => work.push( ComponentDependencyWork::Fragment(for_loop.fragment_id.as_str()), ), - Some(Fragment::IfCond(if_cond)) => work.push( + Some(Fragment::IfCond(if_cond)) if !if_cond.deferred => work.push( ComponentDependencyWork::Fragment(if_cond.fragment_id.as_str()), ), Some(Fragment::Attribute(attribute)) if !attribute.template.is_empty() => { @@ -1418,14 +1506,14 @@ fn propagate_route_dependencies(dependencies: &[Box<[u32]>], route_dependent: &m } /// Check if a component's bit is set in the inventory bitfield. -fn has_component(inventory: &[u8], index: u32) -> bool { +pub(crate) fn has_component(inventory: &[u8], index: u32) -> bool { let byte_idx = (index / 8) as usize; let bit_idx = index % 8; byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0 } /// Set a component's bit in the inventory bitfield. -fn set_component(inventory: &mut Vec, index: u32) { +pub(crate) fn set_component(inventory: &mut Vec, index: u32) { let byte_idx = (index / 8) as usize; let bit_idx = index % 8; if byte_idx >= inventory.len() { @@ -1480,6 +1568,36 @@ pub fn encode_inventory(inv: &[u8]) -> String { encoded } +pub(crate) fn style_inventory( + styles: &Value, + index: &HashMap, +) -> Result { + let mut bits = Vec::new(); + if let Some(resources) = styles.get("resources").and_then(Value::as_object) { + for name in resources.keys() { + let slot = index.get(name).ok_or_else(|| { + HandlerError::Invariant(format!("style resource '{name}' has no inventory index")) + })?; + set_component(&mut bits, *slot); + } + } + Ok(encode_inventory(&bits)) +} + +pub(crate) fn borrowed_style_inventory( + styles: &BorrowedComponentStyleDelta<'_>, + index: &HashMap, +) -> Result { + let mut bits = Vec::new(); + for name in styles.resource_names() { + let slot = index.get(name).ok_or_else(|| { + HandlerError::Invariant(format!("style resource '{name}' has no inventory index")) + })?; + set_component(&mut bits, *slot); + } + Ok(encode_inventory(&bits)) +} + /// Encode the inventory bitfield for a known set of rendered components. pub(crate) fn encode_component_inventory( component_names: &HashSet, @@ -1673,11 +1791,11 @@ impl Serialize for PartialResponseWithState { } } -struct ProjectedRawState<'de> { +pub(crate) struct ProjectedRawState<'de> { entries: Vec<(Cow<'de, str>, &'de RawValue)>, } -enum SelectedRawState<'de> { +pub(crate) enum SelectedRawState<'de> { Full(&'de RawValue), Keys(ProjectedRawState<'de>), } @@ -1736,7 +1854,7 @@ fn project_raw_state<'de>( Ok(state) } -fn select_raw_state<'de>( +pub(crate) fn select_raw_state<'de>( state_json: &'de str, selection: &StateSelection<'_>, ) -> Result, HandlerError> { @@ -2051,6 +2169,30 @@ pub(crate) fn collect_reachable_component_order_for_request( collect_inventoryable_components(protocol, entry_id, Some(request_path), false, route_index) } +pub(crate) fn collect_reachable_with_deferred<'protocol>( + protocol: &'protocol WebUIProtocol, + entry_id: &'protocol str, + request_path: &str, + route_index: &CompiledRouteIndex, + active: Option<&crate::deferred::DeferredSelection<'protocol>>, +) -> Vec { + let mut arena = RouteBaseArena::new(); + collect_inventoryable_components_from_stack( + protocol, + ComponentWalk { + request_path: Some(request_path), + active_deferred: active, + }, + route_index, + vec![QueuedFragment { + id: entry_id, + inventoryable: false, + route_base: RouteBaseArena::ROOT, + }], + &mut arena, + ) +} + /// Filter components against the client's inventory bitfield using sequential indices. /// Zero collisions — each component has a unique bit. /// @@ -2241,7 +2383,10 @@ fn collect_inventoryable_components<'protocol>( }]; collect_inventoryable_components_from_stack( protocol, - request_path, + ComponentWalk { + request_path, + active_deferred: None, + }, route_index, stack, &mut arena, @@ -2280,20 +2425,29 @@ pub(crate) fn collect_reachable_components_from_roots<'protocol>( } collect_inventoryable_components_from_stack( protocol, - Some(request_path), + ComponentWalk { + request_path: Some(request_path), + active_deferred: None, + }, route_index, stack, &mut arena, ) } +struct ComponentWalk<'request, 'protocol> { + request_path: Option<&'request str>, + active_deferred: Option<&'request crate::deferred::DeferredSelection<'protocol>>, +} + fn collect_inventoryable_components_from_stack<'protocol>( protocol: &'protocol WebUIProtocol, - request_path: Option<&str>, + walk: ComponentWalk<'_, 'protocol>, route_index: &CompiledRouteIndex, mut stack: Vec>, arena: &mut RouteBaseArena, ) -> Vec { + let request_path = walk.request_path; let mut visited_fragments = HashSet::new(); let route_sensitive = request_path.is_some(); // Preserve first-discovery (document/traversal) order so Link-strategy @@ -2301,19 +2455,32 @@ fn collect_inventoryable_components_from_stack<'protocol>( // `seen_components` dedups; `component_ids` keeps order (a plain // `HashSet` would lose it). let mut seen_components = HashSet::new(); + let has_client_fragments = !protocol.client_fragments.is_empty(); let mut component_ids: Vec = Vec::new(); while let Some(queued) = stack.pop() { if queued.id.is_empty() { continue; } - if queued.inventoryable && seen_components.insert(queued.id) { + if (queued.inventoryable + || (has_client_fragments && protocol.client_fragments.contains_key(queued.id))) + && seen_components.insert(queued.id) + { component_ids.push(queued.id.to_string()); } if !mark_fragment_visited(&mut visited_fragments, &queued, route_sensitive) { continue; } + if let Some(fragment) = protocol.client_fragments.get(queued.id) { + for dependency in fragment.dependencies.iter().rev() { + stack.push(QueuedFragment { + id: dependency, + inventoryable: true, + route_base: queued.route_base, + }); + } + } let Some(frag_list) = protocol.fragments.get(queued.id) else { continue; @@ -2346,7 +2513,12 @@ fn collect_inventoryable_components_from_stack<'protocol>( route_base: queued.route_base, }); } - Some(Fragment::IfCond(if_cond)) => { + Some(Fragment::IfCond(if_cond)) + if !if_cond.deferred + || walk.active_deferred.is_some_and(|active| { + active.contains(if_cond.fragment_id.as_str()) + }) => + { stack.push(QueuedFragment { id: &if_cond.fragment_id, inventoryable: false, @@ -2750,6 +2922,7 @@ fn collect_inventory_and_chain<'protocol>( // (`render_partial` re-sorts in `collect_component_assets`), but the // list must stay duplicate-free. let mut seen_components = HashSet::new(); + let has_client_fragments = !protocol.client_fragments.is_empty(); let mut component_ids: Vec = Vec::new(); let mut chain = Vec::new(); let mut arena = RouteBaseArena::new(); @@ -2764,13 +2937,25 @@ fn collect_inventory_and_chain<'protocol>( continue; } - if queued.inventoryable && seen_components.insert(queued.id) { + if (queued.inventoryable + || (has_client_fragments && protocol.client_fragments.contains_key(queued.id))) + && seen_components.insert(queued.id) + { component_ids.push(queued.id.to_string()); } if !mark_fragment_visited(&mut visited_fragments, &queued, true) { continue; } + if let Some(fragment) = protocol.client_fragments.get(queued.id) { + for dependency in &fragment.dependencies { + stack.push(QueuedFragment { + id: dependency, + inventoryable: true, + route_base: queued.route_base, + }); + } + } let Some(frag_list) = protocol.fragments.get(queued.id) else { continue; @@ -2801,7 +2986,12 @@ fn collect_inventory_and_chain<'protocol>( route_base: queued.route_base, }); } - Some(Fragment::IfCond(if_cond)) => { + Some(Fragment::IfCond(if_cond)) + if !if_cond.deferred + || index.active_deferred.is_some_and(|active| { + active.contains(if_cond.fragment_id.as_str()) + }) => + { stack.push(QueuedFragment { id: &if_cond.fragment_id, inventoryable: false, @@ -3080,6 +3270,16 @@ fn render_partial_indexed_with_state<'a>( let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect()); let mut result = serde_json::Map::with_capacity(7); + if !protocol.build_id.is_empty() { + result.insert("buildId".into(), Value::String(protocol.build_id.clone())); + result.insert( + "styleInventory".into(), + Value::String(style_inventory( + &assets.component_styles, + index.style_resource_index, + )?), + ); + } result.insert("componentStyles".into(), assets.component_styles); result.insert("templates".into(), Value::Object(assets.templates)); result.insert("templateFunctions".into(), Value::Object(assets.functions)); @@ -3137,7 +3337,7 @@ fn route_chain_matches(chain: &[RouteChainEntry], request_path: &str) -> bool { } } -fn select_owned_state(state: Value, selection: &StateSelection<'_>) -> Value { +pub(crate) fn select_owned_state(state: Value, selection: &StateSelection<'_>) -> Value { let state_keys = match selection { StateSelection::Full => { let mut state = state; @@ -3265,6 +3465,16 @@ fn render_component_templates_indexed( let assets = collect_component_assets(protocol, &tag_refs, &filtered.client_inventory, index)?; let mut result = serde_json::Map::with_capacity(4); + if !protocol.build_id.is_empty() { + result.insert("buildId".into(), Value::String(protocol.build_id.clone())); + result.insert( + "styleInventory".into(), + Value::String(style_inventory( + &assets.component_styles, + index.style_resource_index, + )?), + ); + } result.insert("componentStyles".into(), assets.component_styles); result.insert("templates".into(), Value::Object(assets.templates)); result.insert("templateFunctions".into(), Value::Object(assets.functions)); @@ -3284,7 +3494,9 @@ fn collect_component_assets( ) -> Result { let component_styles = collect_component_styles_for_inventory( protocol, - tags.iter().copied(), + tags.iter() + .copied() + .filter(|tag| !protocol.client_fragments.contains_key(*tag)), client_inventory, index.component_index, )?; @@ -3296,27 +3508,27 @@ fn collect_component_assets( sorted_tags.sort_unstable(); for tag in sorted_tags { - let Some(component) = protocol.components.get(tag) else { + let Some(payload) = protocol.client_template(tag) else { continue; }; - if !has_template_payload(component) { + if payload.metadata.is_empty() && payload.legacy_html.is_empty() { continue; } - if !component.template_json.is_empty() { + if !payload.metadata.is_empty() { let template_value = cached_template_metadata( &mut index.template_metadata_cache, tag, - &component.template_json, + payload.metadata, )?; tmpl_map.insert(tag.to_string(), template_value); - if !component.template_functions.is_empty() { - function_map.insert( - tag.to_string(), - Value::String(component.template_functions.clone()), - ); + if !payload.functions.is_empty() { + function_map.insert(tag.to_string(), Value::String(payload.functions.to_owned())); } } else { - tmpl_map.insert(tag.to_string(), Value::String(component.template.clone())); + tmpl_map.insert( + tag.to_string(), + Value::String(payload.legacy_html.to_owned()), + ); } } diff --git a/crates/webui-handler/src/streaming/checkpoint.rs b/crates/webui-handler/src/streaming/checkpoint.rs index da2e460b6..e17aa5e2e 100644 --- a/crates/webui-handler/src/streaming/checkpoint.rs +++ b/crates/webui-handler/src/streaming/checkpoint.rs @@ -372,10 +372,20 @@ impl WebUIHandler { }, }, }; + let style_inventory = if context.protocol.build_id.is_empty() { + String::new() + } else { + crate::route_handler::borrowed_style_inventory( + &component_styles, + context.style_resource_index, + )? + }; write_webui_bootstrap( context.writer, &mut context.json_scratch, WebUiBootstrap { + build_id: &context.protocol.build_id, + style_inventory: &style_inventory, declaration_id, enclosing_span_instance_id, state: bootstrap_state, diff --git a/crates/webui-handler/src/streaming/inventory.rs b/crates/webui-handler/src/streaming/inventory.rs index 707f1e690..56a637793 100644 --- a/crates/webui-handler/src/streaming/inventory.rs +++ b/crates/webui-handler/src/streaming/inventory.rs @@ -405,6 +405,7 @@ mod tests { let mut context = WebUIProcessContext { protocol: protocol.protocol(), render_fragments: protocol.render_fragments().resolve(protocol.protocol()), + deferred_index: protocol.deferred_index(), component_asset_style_manifest: protocol.component_asset_style_manifest()?, component_asset_style_links: protocol.component_asset_style_links(), state: &state, diff --git a/crates/webui-handler/src/streaming/session.rs b/crates/webui-handler/src/streaming/session.rs index 983960252..8d3131520 100644 --- a/crates/webui-handler/src/streaming/session.rs +++ b/crates/webui-handler/src/streaming/session.rs @@ -676,6 +676,7 @@ impl SessionCore { let mut context = WebUIProcessContext { protocol: protocol.protocol(), render_fragments: protocol.render_fragments().resolve(protocol.protocol()), + deferred_index: protocol.deferred_index(), component_asset_style_manifest, component_asset_style_links: protocol.component_asset_style_links(), state, diff --git a/crates/webui-handler/src/streaming/vm.rs b/crates/webui-handler/src/streaming/vm.rs index d20c58c9b..5fa9a87b5 100644 --- a/crates/webui-handler/src/streaming/vm.rs +++ b/crates/webui-handler/src/streaming/vm.rs @@ -861,6 +861,9 @@ impl ContinuationVm { .as_ref() .ok_or_else(missing_if_condition_error)?; let condition_met = handler.evaluate_condition(condition, context)?; + if condition_met && if_cond.deferred { + record_checkpoint_tag(context, &if_cond.fragment_id); + } if let Some(plugin) = context.plugin.as_mut() { plugin.on_if_start(&if_cond.fragment_id, context.writer)?; } diff --git a/crates/webui-node/src/lib.rs b/crates/webui-node/src/lib.rs index 1ca517afd..824ce5d40 100644 --- a/crates/webui-node/src/lib.rs +++ b/crates/webui-node/src/lib.rs @@ -47,6 +47,47 @@ use webui_protocol::WebUIProtocol; const STREAM_CHUNK_SIZE: usize = 16 * 1024; +/// Client resource availability for a deferred template request. +#[napi(object)] +pub struct JsDeferredOptions { + /// Loaded template-resource inventory. + pub inventory: Option, + /// Loaded stylesheet-definition inventory. + pub style_inventory: Option, + /// Artifact identity associated with both inventories. + pub build_id: Option, +} + +/// A resource record and acknowledgement for two-phase host-owned delivery. +#[napi(object)] +pub struct JsPreparedDeferredResources { + /// Resource JSON to flush immediately. + pub json: String, + /// Availability for the terminal state record. + pub options: JsDeferredOptions, +} + +fn deferred_request<'a>( + owner: &'a str, + id: &'a str, + options: &'a Option, +) -> webui_handler::DeferredRequest<'a> { + let options = options.as_ref(); + webui_handler::DeferredRequest { + owner, + id, + inventory: options + .and_then(|options| options.inventory.as_deref()) + .unwrap_or(""), + style_inventory: options + .and_then(|options| options.style_inventory.as_deref()) + .unwrap_or(""), + build_id: options + .and_then(|options| options.build_id.as_deref()) + .unwrap_or(""), + } +} + /// Build statistics returned from the build function. #[napi(object)] pub struct JsBuildStats { @@ -362,6 +403,55 @@ impl Protocol { .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}"))) } + /// Prepare a named deferred body's resources without fetching application state. + #[napi] + pub fn render_deferred_resources( + &self, + owner: String, + id: String, + options: Option, + ) -> napi::Result { + self.inner + .render_deferred_resources(deferred_request(&owner, &id, &options)) + .map_err(|error| NapiError::from_reason(error.to_string())) + } + + /// Prepare streaming resource JSON together with its acknowledgement. + #[napi] + pub fn prepare_deferred_resources( + &self, + owner: String, + id: String, + options: Option, + ) -> napi::Result { + let prepared = self + .inner + .prepare_deferred_resources(deferred_request(&owner, &id, &options)) + .map_err(|error| NapiError::from_reason(error.to_string()))?; + Ok(JsPreparedDeferredResources { + json: prepared.json, + options: JsDeferredOptions { + inventory: Some(prepared.options.inventory), + style_inventory: Some(prepared.options.style_inventory), + build_id: Some(prepared.options.build_id), + }, + }) + } + + /// Render fresh scoped state and any still-missing deferred resources. + #[napi] + pub fn render_deferred_partial( + &self, + state_json: String, + owner: String, + id: String, + options: Option, + ) -> napi::Result { + self.inner + .render_deferred_partial_json(&state_json, deferred_request(&owner, &id, &options)) + .map_err(|error| NapiError::from_reason(error.to_string())) + } + /// Return CSS token names in build order. #[napi] pub fn tokens(&self) -> Vec { diff --git a/crates/webui-parser/src/deferred.rs b/crates/webui-parser/src/deferred.rs new file mode 100644 index 000000000..52fa2efb2 --- /dev/null +++ b/crates/webui-parser/src/deferred.rs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +pub(crate) use webui_protocol::{ + deferred_block_id as block_id, deferred_fragment_id as fragment_id, +}; + +pub(crate) fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_and_anonymous_regions_have_distinct_owner_scoped_identities() { + assert_eq!( + fragment_id("app-shell", Some("settings"), 0), + "app-shell::defer:settings" + ); + assert_eq!(fragment_id("app-shell", None, 2), "app-shell::defer:@2"); + assert_eq!(block_id("app-shell", 3), "app-shell::block:3"); + assert_ne!( + fragment_id("first-owner", Some("settings"), 0), + fragment_id("second-owner", Some("settings"), 0) + ); + } + + #[test] + fn names_are_static_bounded_identifiers() { + for name in ["settings", "settings-panel", "account.settings", "panel_2"] { + assert!(valid_name(name)); + } + for name in ["", "@0", "{{name}}", "two words", "x/y"] { + assert!(!valid_name(name)); + } + assert!(!valid_name(&"x".repeat(129))); + } +} diff --git a/crates/webui-parser/src/diagnostic.rs b/crates/webui-parser/src/diagnostic.rs index 0a1dbe9b1..9bc146dd6 100644 --- a/crates/webui-parser/src/diagnostic.rs +++ b/crates/webui-parser/src/diagnostic.rs @@ -81,6 +81,14 @@ pub mod codes { pub const MISSING_IF_CONDITION: &str = "missing-if-condition"; /// `` value is not a parseable expression. pub const INVALID_IF_CONDITION: &str = "invalid-if-condition"; + /// A deferred region id is not a static, bounded identifier. + pub const INVALID_DEFERRED_ID: &str = "invalid-deferred-id"; + /// Deferred region ids must be unique within their owning template. + pub const DUPLICATE_DEFERRED_ID: &str = "duplicate-deferred-id"; + /// `defer` is a boolean directive attribute, not a state binding. + pub const INVALID_DEFERRED_ATTRIBUTE: &str = "invalid-deferred-attribute"; + /// The selected client plugin does not support deferred component metadata. + pub const UNSUPPORTED_DEFERRED_PLUGIN: &str = "unsupported-deferred-plugin"; /// A component tag has no matching registered component. pub const UNKNOWN_COMPONENT: &str = "unknown-component"; /// An `@event` handler value is not a valid `{handler()}` expression. @@ -191,6 +199,8 @@ pub mod codes { pub const MULTIPLE_OUTLETS: &str = "multiple-outlets"; /// Static component assets were requested for a graph containing ``. pub const COMPONENT_ASSETS_WITH_ROUTES: &str = "component-assets-with-routes"; + /// Deferred protocol-backed bodies cannot be pruned into static-only assets. + pub const COMPONENT_ASSETS_WITH_DEFERRED: &str = "component-assets-with-deferred"; /// A component's root rendering or hydration policy is malformed. pub const INVALID_COMPONENT_RENDER_POLICY: &str = "invalid-component-render-policy"; /// Lazy rendering omitted the intrinsic block-size estimate needed to diff --git a/crates/webui-parser/src/error.rs b/crates/webui-parser/src/error.rs index 2c2b62290..86c1c7c1c 100644 --- a/crates/webui-parser/src/error.rs +++ b/crates/webui-parser/src/error.rs @@ -54,6 +54,17 @@ pub enum ParserError { #[error("TypeScript parse error: {0}")] TsParseError(String), + /// Invalid compiler-owned client metadata or one of its graph references. + #[error( + "Invalid client template metadata for <{component}>: {reason}. Rebuild the complete protocol with a matching compiler." + )] + ClientTemplateMetadata { + /// Component whose metadata could not be finalized. + component: Box, + /// Specific metadata or compiled-graph invariant that failed. + reason: Box, + }, + /// A build-time template-authoring mistake (e.g. an invalid `@event` /// handler or a non-braced `w-ref`). Carries a structured [`Diagnostic`] /// whose [`std::fmt::Display`] is an actionable, color-free report. The CLI diff --git a/crates/webui-parser/src/lib.rs b/crates/webui-parser/src/lib.rs index d739cb6be..dc6f80834 100644 --- a/crates/webui-parser/src/lib.rs +++ b/crates/webui-parser/src/lib.rs @@ -13,6 +13,7 @@ mod css_light; mod css_link; mod css_parser; mod css_scan; +mod deferred; mod diagnostic; mod error; mod handlebars_parser; @@ -752,6 +753,7 @@ enum ParseOp<'a> { parent: ParseContext, condition: ConditionExpr, fragment_id: String, + deferred: bool, }, } @@ -829,6 +831,8 @@ pub struct HtmlParser { /// Next protocol-wide compile-time boundary declaration identity. next_boundary_declaration_id: u32, + deferred_ordinal: usize, + deferred_names: HashSet, /// Authored boundary names keyed by their owning entry/component template. boundary_names_by_owner: HashMap>, @@ -1546,6 +1550,8 @@ impl HtmlParser { named_for_loops: NamedForLoops::default(), named_for_fragment_ids: HashSet::new(), next_boundary_declaration_id: 0, + deferred_ordinal: 0, + deferred_names: HashSet::new(), boundary_names_by_owner: HashMap::new(), in_boundary: false, boundary_ancestor_depth: 0, @@ -2211,7 +2217,9 @@ impl HtmlParser { pub fn take_plugin_artifacts(&mut self) -> Result { self.plugin .take() - .map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish()) + .map_or(Ok(ParserPluginArtifacts::None), |plugin| { + plugin.finish_with_fragments(&self.fragment_records) + }) } /// Take the accumulated CSS tokens as a sorted, deduplicated `Vec`. @@ -2471,7 +2479,11 @@ impl HtmlParser { .entry(fragment_key.clone()) .or_insert(owner_index); + let previous_deferred_ordinal = std::mem::replace(&mut self.deferred_ordinal, 0); + let previous_deferred_names = std::mem::take(&mut self.deferred_names); let mut result = self.parse_inner(fragment_id, html_content); + self.deferred_ordinal = previous_deferred_ordinal; + self.deferred_names = previous_deferred_names; if is_token_root && result.is_ok() { result = self.finalize_boundary_metadata(); if result.is_ok() { @@ -2548,9 +2560,14 @@ impl HtmlParser { condition: ConditionExpr, fragment_id: String, fragments: &mut Vec, + deferred: bool, ) { self.flush_raw_buffer(fragments); - fragments.push(WebUIFragment::if_cond(condition, fragment_id)); + fragments.push(if deferred { + WebUIFragment::deferred_if(condition, fragment_id) + } else { + WebUIFragment::if_cond(condition, fragment_id) + }); } /// Add a non-raw fragment, flushing the raw buffer first if needed @@ -2850,6 +2867,7 @@ impl HtmlParser { parent, condition, fragment_id, + deferred, } => { self.flush_raw_buffer(fragments); let if_fragment = std::mem::take(fragments); @@ -2863,7 +2881,7 @@ impl HtmlParser { contains_boundary: false, }, ); - self.add_if_fragment(condition, fragment_id, fragments); + self.add_if_fragment(condition, fragment_id, fragments, deferred); } } } @@ -3356,17 +3374,32 @@ impl HtmlParser { .parse(strip_condition_braces(condition_str)) .map_err(|_| self.if_condition_invalid_error(element, condition_str))?; + let deferred = element.has_attr("defer"); + if deferred { + self.validate_deferred_condition(element)?; + } self.flush_raw_buffer(fragments); let parent = ParseContext { fragments: std::mem::take(fragments), raw_buffer: std::mem::take(&mut self.raw_buffer), }; - let fragment_id = self.next_fragment_id("if"); + let fragment_id = if deferred { + let id = deferred::fragment_id( + &self.current_fragment_id, + element.attr("id"), + self.deferred_ordinal, + ); + self.deferred_ordinal += 1; + id + } else { + self.next_fragment_id("if") + }; ops.push(ParseOp::CompleteIf { parent, condition, fragment_id, + deferred, }); ops.push(ParseOp::Parse { range: element.inner(), @@ -3375,6 +3408,56 @@ impl HtmlParser { Ok(()) } + #[cold] + #[inline(never)] + fn validate_deferred_condition(&mut self, element: &Element<'_>) -> Result<()> { + if element.attr("defer").is_some_and(|value| !value.is_empty()) { + return Err(self + .authoring_error_at( + codes::INVALID_DEFERRED_ATTRIBUTE, + "defer must be a boolean attribute", + element, + ) + .element("if") + .help("write ; use condition to control activation") + .into()); + } + if self + .plugin + .as_ref() + .is_some_and(|plugin| !plugin.supports_deferred_fragments()) + { + return Err(self + .authoring_error_at( + codes::UNSUPPORTED_DEFERRED_PLUGIN, + "the selected client plugin does not support deferred template bodies", + element, + ) + .element("if") + .help("select a client plugin with deferred fragment support, or remove defer") + .into()); + } + if let Some(name) = element.attr("id") { + if !deferred::valid_name(name) { + return Err(self.authoring_error_at(codes::INVALID_DEFERRED_ID, + "invalid deferred region id", element).element("if") + .help("use 1-128 ASCII letters, digits, periods, underscores, or hyphens, e.g. id=\"settings\"").into()); + } + if !self.deferred_names.insert(name.to_owned()) { + return Err(self + .authoring_error_at( + codes::DUPLICATE_DEFERRED_ID, + "duplicate deferred region id", + element, + ) + .element("if") + .help("choose a unique id within this owning template") + .into()); + } + } + Ok(()) + } + /// Track whether `name` opens (or shields from) an HTML insertion mode /// that foster-parents unknown elements, pushing a restore op when the /// depth changes. diff --git a/crates/webui-parser/src/plugin/mod.rs b/crates/webui-parser/src/plugin/mod.rs index 4af87c963..5791686b1 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 deferred_templates: Vec, +} + +/// An independently delivered template section without a component host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeferredTemplateArtifact { + /// 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, + deferred_templates: Vec::new(), } } @@ -130,6 +143,7 @@ impl ComponentTemplateArtifact { template_roots: Vec::new(), is_scripted: false, uses_shadow_dom, + deferred_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_deferred_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 cd6e67cd4..6c34e7134 100644 --- a/crates/webui-parser/src/plugin/webui.rs +++ b/crates/webui-parser/src/plugin/webui.rs @@ -78,7 +78,9 @@ use std::fmt::Write; use std::ops::Range; use webui_protocol::{condition_expr, ConditionExpr, WebUIElementData}; +mod deferred_templates; mod named_repeats; +mod template_dependencies; use named_repeats::CompileBlocks; /// A component whose plugin-facing template HTML has been captured for compilation. @@ -199,18 +201,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.deferred_templates = payload.deferred_templates; + out.push(artifact); } Ok(out) } @@ -390,6 +392,10 @@ impl Default for WebUIParserPlugin { } impl ParserPlugin for WebUIParserPlugin { + fn supports_deferred_fragments(&self) -> bool { + true + } + fn begin_fragment(&mut self, _context: FragmentContext<'_>) { self.element_events.set(0); self.next_event_idx.set(0); @@ -453,6 +459,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) { @@ -506,6 +521,8 @@ struct TemplateSectionMeta { conditionals: Vec<(ConditionExpr, usize)>, /// Client conditional anchor slots aligned to `conditionals`. condition_slots: Vec, + /// Present only in sections with explicitly deferred component callsites. + deferred_conditions: Vec, /// For-loop blocks and their optional first-child key paths. repeats: Vec, /// Client repeat anchor slots aligned to `repeats`. @@ -514,6 +531,7 @@ struct TemplateSectionMeta { events: Vec, /// Client event target element indices aligned to `events`. event_targets: Vec, + refs: Vec<(String, usize)>, } #[derive(Clone)] @@ -611,6 +629,13 @@ struct TemplateMeta { /// Root-level events from the `