From 2003ac620c3a6a79e39a37cf1cb0c7e21897f245 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Wed, 19 Aug 2026 22:56:16 -0700 Subject: [PATCH 1/5] feat: support component-local streaming boundaries Replace fixed entry boundaries with runtime occurrences, resumable rendering, span-aware hydration, and start/resume/update APIs across all hosts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 1 + .github/workflows/pr.yml | 856 +++--- DESIGN.md | 1322 ++++----- crates/webui-cli/README.md | 18 +- crates/webui-cli/src/commands/inspect.rs | 1 + crates/webui-cli/src/commands/serve.rs | 10 + .../src/commands/serve/streaming_api.rs | 843 ++++-- crates/webui-ffi/README.md | 18 + crates/webui-ffi/benches/protocol_bench.rs | 2 + crates/webui-ffi/include/webui_ffi.h | 278 +- crates/webui-ffi/src/lib.rs | 785 ++++-- crates/webui-ffi/tests/ffi_test.rs | 639 +++-- crates/webui-handler/README.md | 25 + .../benches/bootstrap_state_bench.rs | 8 + crates/webui-handler/benches/handler_bench.rs | 12 + .../benches/streaming_hydration_bench.rs | 86 +- crates/webui-handler/src/css_module.rs | 44 + crates/webui-handler/src/lib.rs | 2458 +++-------------- crates/webui-handler/src/plugin/fast_v3.rs | 30 + crates/webui-handler/src/plugin/webui.rs | 3 + crates/webui-handler/src/route_handler.rs | 281 +- crates/webui-handler/src/route_matcher.rs | 1 + .../webui-handler/src/streaming/checkpoint.rs | 452 +-- crates/webui-handler/src/streaming/error.rs | 244 +- crates/webui-handler/src/streaming/mod.rs | 370 ++- crates/webui-handler/src/streaming/owned.rs | 312 +-- crates/webui-handler/src/streaming/plan.rs | 181 -- crates/webui-handler/src/streaming/root.rs | 52 +- crates/webui-handler/src/streaming/session.rs | 816 +++--- crates/webui-handler/src/streaming/state.rs | 259 +- crates/webui-handler/src/streaming/vm.rs | 1487 ++++++++++ crates/webui-handler/tests/streaming_v2.rs | 781 ++++++ crates/webui-node/README.md | 22 + crates/webui-node/src/lib.rs | 253 +- crates/webui-parser/src/diagnostic.rs | 23 +- crates/webui-parser/src/lib.rs | 1261 +++++++-- crates/webui-parser/src/plugin/webui.rs | 49 +- crates/webui-parser/src/route_parser.rs | 1 + .../webui-protocol/benches/protocol_bench.rs | 19 + crates/webui-protocol/proto/webui.proto | 57 +- crates/webui-protocol/src/gen_webui.rs | 93 +- crates/webui-protocol/src/lib.rs | 314 ++- crates/webui-python/README.md | 29 +- .../benchmarks/benchmark_renderer.py | 47 +- .../python/microsoft_webui/__init__.py | 12 +- .../python/microsoft_webui/_api.py | 94 +- .../python/microsoft_webui/_native.pyi | 30 +- crates/webui-python/src/lib.rs | 131 +- crates/webui-python/tests/compare_fixture.py | 20 +- crates/webui-python/tests/conftest.py | 16 + .../webui-python/tests/fixtures/protocol.bin | Bin 1017 -> 923 bytes .../tests/fixtures/streaming-app/index.html | 22 + .../tests/fixtures/streaming_protocol.bin | 74 + .../tests/test_compare_fixture.py | 49 + crates/webui-python/tests/test_package.py | 6 + crates/webui-python/tests/test_streaming.py | 236 +- crates/webui-test-utils/src/lib.rs | 62 + crates/webui-wasm/README.md | 23 +- crates/webui-wasm/src/handler.rs | 260 +- .../webui/benches/component_assets_bench.rs | 4 + crates/webui/src/lib.rs | 54 +- crates/webui/src/server.rs | 6 + docs/.webui-press/config.json | 6 +- docs/ai/SKILL.md | 104 +- docs/guide/cli/index.md | 36 +- docs/guide/concepts/directives/boundary.md | 250 +- docs/guide/concepts/directives/index.md | 2 +- docs/guide/concepts/hydration.md | 170 +- docs/guide/concepts/interactivity.md | 6 +- docs/guide/concepts/performance.md | 41 +- docs/guide/installation.md | 3 + docs/guide/integrations/dotnet.md | 90 + docs/guide/integrations/ffi.md | 102 +- docs/guide/integrations/index.md | 2 +- docs/guide/integrations/node.md | 77 +- docs/guide/integrations/python.md | 78 +- docs/guide/integrations/rust.md | 118 +- docs/guide/integrations/wasm.md | 46 +- dotnet/src/Microsoft.WebUI/NativeBindings.cs | 183 +- .../src/Microsoft.WebUI/StreamingSession.cs | 424 ++- .../Microsoft.WebUI.Tests.csproj | 9 +- .../StreamingSessionTests.cs | 228 +- .../fixtures/streaming-app/index.html | 14 +- examples/README.md | 2 +- examples/app/service-worker/README.md | 20 +- .../service-worker/public/api/metrics.json | 2 +- .../service-worker/scripts/check-render.ts | 70 +- examples/app/service-worker/src/index.html | 27 +- .../app/service-worker/src/service-worker.ts | 108 +- .../src/wasm/handler/webui_wasm_handler.d.ts | 33 +- .../tests/service-worker.spec.ts | 12 +- examples/app/streaming/README.md | 46 +- examples/app/streaming/server/src/index.ts | 1 + .../app/streaming/server/src/pacing.test.ts | 87 +- examples/app/streaming/server/src/pacing.ts | 37 +- .../streaming/server/src/stream-protocol.ts | 60 +- examples/app/streaming/src/index.html | 77 +- examples/app/streaming/src/index.ts | 7 +- .../src/streaming-page/streaming-page.css | 25 + .../src/streaming-page/streaming-page.html | 46 + .../app/streaming/tests/streaming.spec.ts | 46 +- examples/integration/node/README.md | 38 +- examples/integration/node/streaming-server.js | 63 +- examples/integration/rust/README.md | 14 + examples/integration/rust/src/main.rs | 58 +- .../streaming-browser-bench/README.md | 37 +- .../tests/hydration_matrix.spec.ts | 71 +- .../tests/lib/lazy-fixtures.ts | 12 +- .../tests/lib/scenarios.ts | 50 +- packages/webui-framework/README.md | 30 +- .../src/streaming-activation.ts | 9 +- .../src/streaming-bootstrap.ts | 14 +- .../webui-framework/src/streaming-cleanup.ts | 73 +- .../src/streaming-coordinator.ts | 199 +- .../webui-framework/src/streaming-deferred.ts | 208 +- packages/webui-framework/src/streaming-dom.ts | 113 +- .../webui-framework/src/streaming-mode.ts | 16 +- .../src/streaming-pipeline.test.ts | 994 ++++++- .../webui-framework/src/streaming-protocol.ts | 44 +- .../webui-framework/src/streaming-spans.ts | 294 ++ .../webui-framework/src/streaming.test.ts | 45 +- .../src/template-element.test.ts | 134 + .../webui-framework/src/template-element.ts | 55 +- packages/webui/README.md | 53 +- packages/webui/src/index.ts | 143 +- packages/webui/test/integration.test.ts | 247 +- 126 files changed, 13949 insertions(+), 7702 deletions(-) delete mode 100644 crates/webui-handler/src/streaming/plan.rs create mode 100644 crates/webui-handler/src/streaming/vm.rs create mode 100644 crates/webui-handler/tests/streaming_v2.rs create mode 100644 crates/webui-python/tests/fixtures/streaming-app/index.html create mode 100644 crates/webui-python/tests/fixtures/streaming_protocol.bin create mode 100644 crates/webui-python/tests/test_compare_fixture.py create mode 100644 docs/guide/integrations/dotnet.md create mode 100644 examples/app/streaming/src/streaming-page/streaming-page.css create mode 100644 examples/app/streaming/src/streaming-page/streaming-page.html create mode 100644 packages/webui-framework/src/streaming-spans.ts diff --git a/.gitattributes b/.gitattributes index 2d7ff98c4..896e62e13 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,6 +17,7 @@ *.pdf binary *.zip binary *.gz binary +*.bin binary *.wasm binary *.woff binary *.woff2 binary diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8d53b1a7c..1283865fa 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,420 +1,436 @@ -name: PR Checks - -on: - pull_request: - branches: [ main ] - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - -jobs: - # ── Phase 1: Lint (fast, fail-fast) ──────────────────────────────── - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - skip-build: 'true' - rust-components: clippy, rustfmt - - - name: License headers - run: cargo xtask license-headers - - - name: Format - run: cargo fmt --all --check - - - name: Clippy - run: cargo clippy --workspace -- -D warnings - - - name: Deny (licenses & advisories) - run: | - cargo install --locked cargo-deny || true - cargo deny check - - # ── Phase 2: Test (Ubuntu) ───────────────────────────────────────── - test: - name: Test - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - skip-build: 'true' - - - name: Test - run: cargo test --workspace - - # ── Phase 2: Build (Linux + macOS + Windows) ──────────────────────── - build-linux: - name: Build (Linux) - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - shared-cache-key: ubuntu-build - - build-macos: - name: Build (macOS) - runs-on: macos-latest-large - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - - build-windows: - name: Build (Windows) - runs-on: windows-latest - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - - # ── Phase 3: E2E (Ubuntu, after Linux build) ─────────────────────── - e2e: - name: E2E (Ubuntu) - runs-on: ubuntu-latest - needs: build-linux - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - shared-cache-key: ubuntu-build - cache-on-failure: 'true' - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - - name: Run E2E tests - run: cargo xtask e2e - - - name: Regenerate baselines (on failure) - if: failure() - run: cargo xtask e2e --update-snapshots - - - name: Upload updated baselines (on failure) - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: e2e-updated-baselines - path: | - examples/app/*/tests/*.spec.ts-snapshots/ - packages/*/tests/*.spec.ts-snapshots/ - retention-days: 7 - - - name: Upload test results (on failure) - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: e2e-test-results - path: | - examples/app/*/test-results/ - packages/*/test-results/ - retention-days: 7 - - # ── Phase 2: WASM (Ubuntu) ───────────────────────────────────────── - wasm: - name: WASM - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/setup-wasm - - # ── Phase 2: Docs ───────────────────────────────────────────────── - docs: - name: Docs - runs-on: ubuntu-latest - needs: build-linux - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - shared-cache-key: ubuntu-build - - - name: Build docs - run: pnpm --filter @webui/docs... build - - # ── Phase 3: FFI bindings (after Linux build) ────────────────────── - # Exercises the C ABI from Python, Go, and C#. Depends on build-linux and - # shares its cache key: these are the same debug workspace crates that job - # already compiled, so the cargo steps below are a cache hit rather than a - # second full build. - ffi: - name: FFI - runs-on: ubuntu-latest - needs: build-linux - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - skip-build: 'true' - shared-cache-key: ubuntu-build - dotnet: '8.0.x' - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.12' - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: '1.21' - - - name: Build FFI library and protocol fixture - run: | - cargo build -p microsoft-webui-ffi - cargo run -p microsoft-webui-cli -- build \ - crates/webui-ffi/tests/fixtures/app \ - --out crates/webui-ffi/tests/fixtures/protocol.bin - - - name: Run Python FFI tests - run: python crates/webui-ffi/tests/python/test_webui_ffi.py -v - env: - LD_LIBRARY_PATH: target/debug - - - name: Run Go FFI tests - working-directory: crates/webui-ffi/tests/go - run: go test -v - env: - LD_LIBRARY_PATH: ${{ github.workspace }}/target/debug - CGO_LDFLAGS: -L${{ github.workspace }}/target/debug -lwebui_ffi -lm -ldl -lpthread - - - name: Run C# FFI tests - working-directory: crates/webui-ffi/tests/csharp - run: dotnet test -v normal - env: - LD_LIBRARY_PATH: ${{ github.workspace }}/target/debug - - # ── Phase 2: Python wheels ───────────────────────────────────────── - # Build every wheel in the release contract. The x64 wheels feed python-test - # below; the ARM64 wheels are cross-compiled exactly as the release pipeline - # cross-compiles them, so an ARM64 build break surfaces on the PR that causes - # it. They are never installed here - that needs ARM64 hardware. - python-wheel: - name: Python wheel (${{ matrix.platform.label }}) - runs-on: ${{ matrix.platform.os }} - needs: lint - strategy: - fail-fast: false - matrix: - platform: - - label: Linux x64 - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - platform_tag: manylinux_2_17_x86_64.manylinux2014_x86_64 - image: messense/manylinux2014-cross:x86_64@sha256:13670ccc63c35e072661938181c046243c01d7bca7976b3914177fb9b162998d - artifact: python-wheel-linux-x64 - - label: macOS x64 - os: macos-latest-large - target: x86_64-apple-darwin - platform_tag: macosx_10_12_x86_64 - artifact: python-wheel-macos-x64 - - label: Windows x64 - os: windows-latest - target: x86_64-pc-windows-msvc - platform_tag: win_amd64 - artifact: python-wheel-windows-x64 - - label: Linux ARM64 - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - platform_tag: manylinux_2_17_aarch64.manylinux2014_aarch64 - image: messense/manylinux2014-cross:aarch64@sha256:32c92568ebee8db53e0598c21bcd06a6dd1649d45d507646a8a49313c50325f8 - artifact: '' - - label: macOS ARM64 - os: macos-latest-large - target: aarch64-apple-darwin - platform_tag: macosx_11_0_arm64 - artifact: '' - - label: Windows ARM64 - os: windows-latest - target: aarch64-pc-windows-msvc - platform_tag: win_arm64 - artifact: '' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # Linux wheels build entirely inside the pinned manylinux image, so the - # host toolchain is only needed for the targets built natively here. - - uses: ./.github/actions/build - if: runner.os != 'Linux' - with: - skip-build: 'true' - rust-targets: ${{ matrix.platform.target }} - shared-cache-key: python-${{ matrix.platform.target }} - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.11' - - # abi3 needs no target interpreter and `generate-import-lib` synthesizes - # the Windows ARM64 import library, so every target cross-compiles here. - - name: Build wheel - if: runner.os != 'Linux' - shell: bash - run: | - set -euo pipefail - python -m pip install --upgrade "maturin==1.14.1" - cargo xtask publish-build --target "${{ matrix.platform.target }}" --python-only - - - name: Build manylinux wheel - if: runner.os == 'Linux' - shell: bash - run: | - set -euo pipefail - docker run --rm --platform linux/amd64 \ - -v "$GITHUB_WORKSPACE:/io" -w /io "${{ matrix.platform.image }}" \ - bash crates/webui-python/scripts/build-manylinux-wheel.sh \ - "${{ matrix.platform.target }}" - - - name: Validate wheel name, tags, and license metadata - shell: bash - run: | - set -euo pipefail - python crates/webui-python/tests/validate_wheel.py \ - publish/python "${{ matrix.platform.platform_tag }}" - python crates/webui-python/tests/validate_artifacts.py publish/python/*.whl - - - name: Upload wheel - if: matrix.platform.artifact != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ matrix.platform.artifact }} - path: publish/python/*.whl - if-no-files-found: error - retention-days: 1 - - # ── Phase 3: Python tests (after wheels) ─────────────────────────── - # One abi3 wheel must serve every supported interpreter, so the Linux wheel is - # installed on each CPython. macOS and Windows only re-verify that the wheel - # loads on its platform, which 3.11 already proves. - python-test: - name: Python test (${{ matrix.platform.label }} / CPython ${{ matrix.python }}) - runs-on: ${{ matrix.platform.os }} - needs: python-wheel - strategy: - fail-fast: false - matrix: - platform: - - label: Linux x64 - os: ubuntu-latest - artifact: python-wheel-linux-x64 - python: ['3.11', '3.12', '3.13', '3.14'] - include: - - platform: - label: macOS x64 - os: macos-latest-large - artifact: python-wheel-macos-x64 - python: '3.11' - - platform: - label: Windows x64 - os: windows-latest - artifact: python-wheel-windows-x64 - python: '3.11' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: ${{ matrix.python }} - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ matrix.platform.artifact }} - path: publish/python - - - name: Install the built wheel and test it - shell: bash - run: | - set -euo pipefail - python -m pip install --upgrade "pytest>=8.3,<10" - python -m pip install --force-reinstall --no-deps --no-index publish/python/*.whl - python -m pytest crates/webui-python/tests -q - - - name: Validate lint, strict typing, and stubs - if: runner.os == 'Linux' && matrix.python == '3.11' - working-directory: crates/webui-python - run: | - python -m pip install --upgrade "mypy>=1.19,<2" "ruff>=0.15,<1" - python -m ruff check python tests benchmarks - python -m mypy - python -m mypy.stubtest microsoft_webui - - # ── Phase 3: Python fixture drift (after Linux build) ────────────── - # The committed protocol fixture is a build output, so rebuild it and fail if - # it drifted, keeping the Python tests from asserting against a stale binary. - python-fixture: - name: Python fixture - runs-on: ubuntu-latest - needs: build-linux - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/build - with: - skip-build: 'true' - shared-cache-key: ubuntu-build - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.11' - - - name: Verify the release-target contract is restated consistently - run: python crates/webui-python/tests/validate_release_targets.py - - - name: Rebuild and compare the protocol fixture - run: | - set -euo pipefail - output=target/python-fixture-check - rm -rf "$output" - cargo run --quiet -p microsoft-webui-cli -- build \ - crates/webui-python/tests/fixtures/app --plugin webui --out "$output" - cargo run --quiet -p microsoft-webui-cli -- inspect \ - crates/webui-python/tests/fixtures/protocol.bin > "$output/committed.json" - cargo run --quiet -p microsoft-webui-cli -- inspect \ - "$output/protocol.bin" > "$output/generated.json" - python crates/webui-python/tests/compare_fixture.py \ - "$output/committed.json" "$output/generated.json" - cmp crates/webui-python/tests/fixtures/greeting-card.css \ - "$output/greeting-card.css" - - # ── 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 - # removed. Require this job instead: it is the only context that has to be - # protected, and it stays correct as the matrix above evolves. - pr-checks: - name: PR Checks - runs-on: ubuntu-latest - if: always() - needs: - - lint - - test - - build-linux - - build-macos - - build-windows - - e2e - - wasm - - docs - - ffi - - python-wheel - - python-test - - python-fixture - steps: - # `needs` collapses each matrix to a single result, so this covers every - # wheel and interpreter leg too. Skipped counts as failure: nothing here - # is conditionally skipped, so a skip only happens when a dependency - # failed and took its dependents with it. - - name: Verify every job succeeded - if: >- - contains(needs.*.result, 'failure') || - contains(needs.*.result, 'cancelled') || - contains(needs.*.result, 'skipped') - run: | - echo "::error::One or more PR checks did not succeed." - exit 1 +name: PR Checks + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + # ── Phase 1: Lint (fast, fail-fast) ──────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + rust-components: clippy, rustfmt + + - name: License headers + run: cargo xtask license-headers + + - name: Format + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --workspace -- -D warnings + + - name: Deny (licenses & advisories) + run: | + cargo install --locked cargo-deny || true + cargo deny check + + # ── Phase 2: Test (Ubuntu) ───────────────────────────────────────── + test: + name: Test + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + + - name: Test + run: cargo test --workspace + + # ── Phase 2: Build (Linux + macOS + Windows) ──────────────────────── + build-linux: + name: Build (Linux) + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + shared-cache-key: ubuntu-build + + build-macos: + name: Build (macOS) + runs-on: macos-latest-large + needs: lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + + build-windows: + name: Build (Windows) + runs-on: windows-latest + needs: lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + + # ── Phase 3: E2E (Ubuntu, after Linux build) ─────────────────────── + e2e: + name: E2E (Ubuntu) + runs-on: ubuntu-latest + needs: build-linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + shared-cache-key: ubuntu-build + cache-on-failure: 'true' + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + - name: Run E2E tests + run: cargo xtask e2e + + - name: Regenerate baselines (on failure) + if: failure() + run: cargo xtask e2e --update-snapshots + + - name: Upload updated baselines (on failure) + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-updated-baselines + path: | + examples/app/*/tests/*.spec.ts-snapshots/ + packages/*/tests/*.spec.ts-snapshots/ + retention-days: 7 + + - name: Upload test results (on failure) + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-test-results + path: | + examples/app/*/test-results/ + packages/*/test-results/ + retention-days: 7 + + # ── Phase 2: WASM (Ubuntu) ───────────────────────────────────────── + wasm: + name: WASM + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-wasm + + # ── Phase 2: Docs ───────────────────────────────────────────────── + docs: + name: Docs + runs-on: ubuntu-latest + needs: build-linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + shared-cache-key: ubuntu-build + + - name: Build docs + run: pnpm --filter @webui/docs... build + + # ── Phase 3: FFI bindings (after Linux build) ────────────────────── + # Exercises the C ABI from Python, Go, and C#. Depends on build-linux and + # shares its cache key: these are the same debug workspace crates that job + # already compiled, so the cargo steps below are a cache hit rather than a + # second full build. + ffi: + name: FFI + runs-on: ubuntu-latest + needs: build-linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + shared-cache-key: ubuntu-build + dotnet: '8.0.x' + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.21' + + - name: Build FFI library and protocol fixture + run: | + cargo build -p microsoft-webui-ffi + cargo run -p microsoft-webui-cli -- build \ + crates/webui-ffi/tests/fixtures/app \ + --out crates/webui-ffi/tests/fixtures/protocol.bin + + - name: Run Python FFI tests + run: python crates/webui-ffi/tests/python/test_webui_ffi.py -v + env: + LD_LIBRARY_PATH: target/debug + + - name: Run Go FFI tests + working-directory: crates/webui-ffi/tests/go + run: go test -v + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/target/debug + CGO_LDFLAGS: -L${{ github.workspace }}/target/debug -lwebui_ffi -lm -ldl -lpthread + + - name: Run C# FFI tests + working-directory: crates/webui-ffi/tests/csharp + run: dotnet test -v normal + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/target/debug + + # ── Phase 2: Python wheels ───────────────────────────────────────── + # Build every wheel in the release contract. The x64 wheels feed python-test + # below; the ARM64 wheels are cross-compiled exactly as the release pipeline + # cross-compiles them, so an ARM64 build break surfaces on the PR that causes + # it. They are never installed here - that needs ARM64 hardware. + python-wheel: + name: Python wheel (${{ matrix.platform.label }}) + runs-on: ${{ matrix.platform.os }} + needs: lint + strategy: + fail-fast: false + matrix: + platform: + - label: Linux x64 + os: ubuntu-latest + target: x86_64-unknown-linux-gnu + platform_tag: manylinux_2_17_x86_64.manylinux2014_x86_64 + image: messense/manylinux2014-cross:x86_64@sha256:13670ccc63c35e072661938181c046243c01d7bca7976b3914177fb9b162998d + artifact: python-wheel-linux-x64 + - label: macOS x64 + os: macos-latest-large + target: x86_64-apple-darwin + platform_tag: macosx_10_12_x86_64 + artifact: python-wheel-macos-x64 + - label: Windows x64 + os: windows-latest + target: x86_64-pc-windows-msvc + platform_tag: win_amd64 + artifact: python-wheel-windows-x64 + - label: Linux ARM64 + os: ubuntu-latest + target: aarch64-unknown-linux-gnu + platform_tag: manylinux_2_17_aarch64.manylinux2014_aarch64 + image: messense/manylinux2014-cross:aarch64@sha256:32c92568ebee8db53e0598c21bcd06a6dd1649d45d507646a8a49313c50325f8 + artifact: '' + - label: macOS ARM64 + os: macos-latest-large + target: aarch64-apple-darwin + platform_tag: macosx_11_0_arm64 + artifact: '' + - label: Windows ARM64 + os: windows-latest + target: aarch64-pc-windows-msvc + platform_tag: win_arm64 + artifact: '' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Linux wheels build entirely inside the pinned manylinux image, so the + # host toolchain is only needed for the targets built natively here. + - uses: ./.github/actions/build + if: runner.os != 'Linux' + with: + skip-build: 'true' + rust-targets: ${{ matrix.platform.target }} + shared-cache-key: python-${{ matrix.platform.target }} + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + + # abi3 needs no target interpreter and `generate-import-lib` synthesizes + # the Windows ARM64 import library, so every target cross-compiles here. + - name: Build wheel + if: runner.os != 'Linux' + shell: bash + run: | + set -euo pipefail + python -m pip install --upgrade "maturin==1.14.1" + cargo xtask publish-build --target "${{ matrix.platform.target }}" --python-only + + - name: Build manylinux wheel + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + docker run --rm --platform linux/amd64 \ + -v "$GITHUB_WORKSPACE:/io" -w /io "${{ matrix.platform.image }}" \ + bash crates/webui-python/scripts/build-manylinux-wheel.sh \ + "${{ matrix.platform.target }}" + + - name: Validate wheel name, tags, and license metadata + shell: bash + run: | + set -euo pipefail + python crates/webui-python/tests/validate_wheel.py \ + publish/python "${{ matrix.platform.platform_tag }}" + python crates/webui-python/tests/validate_artifacts.py publish/python/*.whl + + - name: Upload wheel + if: matrix.platform.artifact != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.platform.artifact }} + path: publish/python/*.whl + if-no-files-found: error + retention-days: 1 + + # ── Phase 3: Python tests (after wheels) ─────────────────────────── + # One abi3 wheel must serve every supported interpreter, so the Linux wheel is + # installed on each CPython. macOS and Windows only re-verify that the wheel + # loads on its platform, which 3.11 already proves. + python-test: + name: Python test (${{ matrix.platform.label }} / CPython ${{ matrix.python }}) + runs-on: ${{ matrix.platform.os }} + needs: python-wheel + strategy: + fail-fast: false + matrix: + platform: + - label: Linux x64 + os: ubuntu-latest + artifact: python-wheel-linux-x64 + python: ['3.11', '3.12', '3.13', '3.14'] + include: + - platform: + label: macOS x64 + os: macos-latest-large + artifact: python-wheel-macos-x64 + python: '3.11' + - platform: + label: Windows x64 + os: windows-latest + artifact: python-wheel-windows-x64 + python: '3.11' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python }} + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.platform.artifact }} + path: publish/python + + - name: Install the built wheel and test it + shell: bash + run: | + set -euo pipefail + python -m pip install --upgrade "pytest>=8.3,<10" + python -m pip install --force-reinstall --no-deps --no-index publish/python/*.whl + python -m pytest crates/webui-python/tests -q + + - name: Validate lint, strict typing, and stubs + if: runner.os == 'Linux' && matrix.python == '3.11' + working-directory: crates/webui-python + run: | + python -m pip install --upgrade "mypy>=1.19,<2" "ruff>=0.15,<1" + python -m ruff check python tests benchmarks + python -m mypy + python -m mypy.stubtest microsoft_webui + + # ── Phase 3: Python fixture drift (after Linux build) ────────────── + # The committed protocol fixtures are build outputs, so rebuild them and fail + # if they drift, keeping the Python tests from asserting against stale binaries. + python-fixture: + name: Python fixture + runs-on: ubuntu-latest + needs: build-linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/build + with: + skip-build: 'true' + shared-cache-key: ubuntu-build + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + + - name: Verify the release-target contract is restated consistently + run: python crates/webui-python/tests/validate_release_targets.py + + - name: Rebuild and compare the protocol fixtures + run: | + set -euo pipefail + output="target/python-fixture-check" + streaming_output="target/python-streaming-fixture-check" + rm -rf "$output" "$streaming_output" + cargo run --quiet -p microsoft-webui-cli -- build \ + "crates/webui-python/tests/fixtures/app" --plugin webui --out "$output" + cargo run --quiet -p microsoft-webui-cli -- build \ + "crates/webui-python/tests/fixtures/streaming-app" \ + --plugin webui --out "$streaming_output" + cargo run --quiet -p microsoft-webui-cli -- inspect \ + "crates/webui-python/tests/fixtures/protocol.bin" \ + > "$output/committed.json" + cargo run --quiet -p microsoft-webui-cli -- inspect \ + "$output/protocol.bin" > "$output/generated.json" + python "crates/webui-python/tests/compare_fixture.py" \ + "$output/committed.json" "$output/generated.json" \ + "crates/webui-python/tests/fixtures/protocol.bin" + cargo run --quiet -p microsoft-webui-cli -- inspect \ + "crates/webui-python/tests/fixtures/streaming_protocol.bin" \ + > "$streaming_output/committed.json" + cargo run --quiet -p microsoft-webui-cli -- inspect \ + "$streaming_output/protocol.bin" \ + > "$streaming_output/generated.json" + python "crates/webui-python/tests/compare_fixture.py" \ + "$streaming_output/committed.json" \ + "$streaming_output/generated.json" \ + "crates/webui-python/tests/fixtures/streaming_protocol.bin" + cmp "crates/webui-python/tests/fixtures/greeting-card.css" \ + "$output/greeting-card.css" + + # ── 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 + # removed. Require this job instead: it is the only context that has to be + # protected, and it stays correct as the matrix above evolves. + pr-checks: + name: PR Checks + runs-on: ubuntu-latest + if: always() + needs: + - lint + - test + - build-linux + - build-macos + - build-windows + - e2e + - wasm + - docs + - ffi + - python-wheel + - python-test + - python-fixture + steps: + # `needs` collapses each matrix to a single result, so this covers every + # wheel and interpreter leg too. Skipped counts as failure: nothing here + # is conditionally skipped, so a skip only happens when a dependency + # failed and took its dependents with it. + - name: Verify every job succeeded + if: >- + contains(needs.*.result, 'failure') || + contains(needs.*.result, 'cancelled') || + contains(needs.*.result, 'skipped') + run: | + echo "::error::One or more PR checks did not succeed." + exit 1 diff --git a/DESIGN.md b/DESIGN.md index 5a42ce93d..b376cc1fe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -44,8 +44,6 @@ pub struct WebUIProtocol { pub initial_state_strategy: InitialStateStrategy, /// Ordered modulepreload hrefs for critical shared JavaScript chunks. pub module_preloads: Vec, - /// Entry fragment boundary names in declaration order. - pub streaming_boundaries: HashMap, /// Deterministic document-level CSS for build-authored component rendering /// policies. Empty when no component uses `w-render="lazy"`. pub component_render_css: String, @@ -108,6 +106,8 @@ pub enum StateProjectionMode { /// A list of fragments (needed because protobuf maps cannot have repeated values directly). pub struct FragmentList { pub fragments: Vec, + /// True when this record directly or transitively reaches a boundary. + pub contains_boundary: bool, } /// A mapping of unique fragment identifiers to their corresponding fragment lists. @@ -119,6 +119,28 @@ pub struct WebUIFragment { pub fragment: Option, } +/// Which end of an inline boundary tape a fragment marks. +pub enum BoundaryPhase { + Start = 0, + End = 1, +} + +/// Compile-time declaration preserved in the fragment graph as an inline tape. +pub struct WebUIFragmentBoundary { + /// Stable build-local declaration identity, shared by the start/end pair. + pub declaration_id: u32, + /// Entry or reusable component template that owns the declaration. + pub owner_fragment_id: String, + /// Static authored name, unique within `owner_fragment_id`. + pub name: String, + /// Optional expression evaluated for each runtime occurrence. + pub key: Option, + /// Conservative build-time result for declarations that may occur repeatedly. + pub may_repeat: bool, + /// Whether this fragment opens or closes the declaration's body. + pub phase: BoundaryPhase, +} + /// The fragment oneof variants. pub enum Fragment { Raw(WebUIFragmentRaw), @@ -130,6 +152,7 @@ pub enum Fragment { Plugin(WebUIFragmentPlugin), Route(WebUIFragmentRoute), Outlet(WebUIFragmentOutlet), + Boundary(WebUIFragmentBoundary), } ``` ### Fragment Types @@ -147,6 +170,29 @@ pub struct WebUIFragmentComponent { pub fragment_id: String, } ``` +#### Boundary Fragment + +`Boundary` is a typed declaration in the normal fragment graph, written as an +**inline tape**: a `Start` marker, the body fragments, and an `End` marker +carrying the same `declaration_id`, all in the owner's own record. Ordinary +rendering therefore walks the body without an extra record lookup and simply +skips both markers, while streaming suspends at `Start` and resumes inside the +record it is already traversing. It may be reached through entries, reusable +components, conditions, loops, outlets, and the selected route. Runtime +traversal, not declaration order, creates response-local occurrences. + +Markers always pair within one record, because a ``'s children are +lexically inside it; constructs that own their own record (``, ``, +components, route content) still nest as separate records. Nesting a boundary +inside another — lexically or transitively through those records — is rejected +at build time, so a response has at most one active occurrence and matching is +stack-free. + +The compiler assigns `declaration_id`, records the authoring +`owner_fragment_id`, and sets `contains_boundary` on every directly or +transitively boundary-bearing `FragmentList`. Field 8 of `WebUIProtocol` and +field 5 of `WebUIFragmentBoundary` are reserved and must not be reused. + #### For Loop Fragment ```rust pub struct WebUIFragmentFor { @@ -925,6 +971,77 @@ The `webui::streaming` module provides: across requests, eliminating per-flush heap allocation in steady-state high-RPS workloads. +### Progressive Response API + +Progressive rendering discovers occurrences while it executes the normal +fragment graph. There is no compile-time boundary count and no name lookup API. + +```rust +pub struct BoundaryDescriptor { + pub instance_id: BoundaryInstanceId, + pub declaration_id: u32, + /// Interned per protocol; discovering an occurrence shares the compiled + /// string instead of allocating a copy. + pub owner: Arc, + pub name: Arc, + pub key: Option, +} + +pub struct StreamStatus { + pub boundary: Option, + pub done: bool, +} + +pub struct StreamStep { + pub bytes: Vec, + pub boundary: Option, + pub done: bool, +} + +impl StreamingResponse<'_, W> { + pub fn start(&mut self, state: &Value) -> Result; + pub fn resume( + &mut self, + instance_id: BoundaryInstanceId, + state: &Value, + mode: BoundaryMode, + ) -> Result; + pub fn update( + &mut self, + instance_id: BoundaryInstanceId, + patch: &Value, + ) -> Result<()>; +} + +impl StreamingSession { + pub fn start(&mut self, state: &Value) -> Result; + pub fn resume( + &mut self, + instance_id: BoundaryInstanceId, + state: &Value, + mode: BoundaryMode, + ) -> Result; + pub fn update( + &mut self, + instance_id: BoundaryInstanceId, + patch: &Value, + ) -> Result>; +} +``` + +`start` renders until the first runtime occurrence or through the terminal. +`resume` must target the descriptor currently returned by the session. It +commits that occurrence, then continues until the next occurrence or terminal. +The final successful call returns `done = true`, no boundary, and bytes that +already contain the terminal record and document suffix. There is no separate +terminal call. + +`update` accepts only an object patch and only a committed `Updatable` +occurrence. It emits projected state bytes and no application markup. A +borrowed `StreamingResponse` writes directly to its `FlushWriter`; an owned +`StreamingSession` returns one complete byte vector per call for language +bindings and host-controlled backpressure. + ### Per-Render HTML Injection For HTML that must be spliced at the structural `` or `` @@ -2028,563 +2145,257 @@ into `` markup. ## Progressive Streaming Hydration -An interactive island that has already arrived in the HTML response hydrates -while `document.readyState === "loading"`, without waiting for a slow response -tail or `DOMContentLoaded`. This works in current browsers using ordinary -in-order incremental HTML parsing; it does not depend on any out-of-order DOM -protocol. - -Streaming is selected per response. A page opts in by placing `` -directives in its entry template and rendering through a `FlushWriter`; -everything else about the framework is unchanged, and non-streaming output is -byte-identical to a build without boundaries. - -Hydration is gated through `TemplateElement`'s deferral seam — -`$deferredSSR` / `$shouldDeferSSRHydration()` / `$activateDeferredSSR()` — the -same primitive the compiler-owned `StaticTemplateHost` uses to stay dormant -until a browser state write. Streaming is its second caller, gated by -streaming-mode plus boundary commit rather than by `setState`, so the framework -has one hydration-gating mechanism rather than two. - -### Stream contract (version 1, normative) - -These invariants are binding. Every one is enforced somewhere — by the -compiler, by the coordinator, or by a test — and none may be relaxed without a -corresponding change here. Any additional transport must satisfy this same -contract rather than introduce a parallel one. - -**Record format** - -1. **Gapless monotonic record order.** Every checkpoint, state update, and - terminal record carries one response-local record sequence starting at `0` - and increasing by exactly one. Any other value is rejected and halts the - stream. There is no reordering buffer and no out-of-order tolerance. -2. **Typed records and exactly one empty terminal.** The five-element envelope - is `[version, record_sequence, kind, target, payload]`. `kind` is `0` for a - final boundary checkpoint, `1` for an updatable boundary checkpoint, `2` - for a state update, and `3` for the terminal. Every response ends with - exactly one markerless `[1, sequence, 3, 0, {}]` after all scriptless tail - bytes. A record arriving after it is corruption: it is rejected, its - scaffolding released, and the stream is halted without disturbing the - successful completion the terminal record already drove. The empty terminal - payload binds the *emitter*; a reader ignores unrecognized terminal payload - fields rather than halting a page that has already fully rendered (rule 21). -3. **Self-sufficient records.** Given all prior records, a record carries - everything needed to commit itself: its own template delta, inventory delta, - and projected state. A record never forward-references a later one, so a - truncated response is always a prefix of a valid one. -4. **Additive global merge; ordered island state.** Global handoff merges - accumulate only: - inventory bits are OR-ed, CSS/style lists are appended with deduplication, - templates are registered additively. No record may overwrite or invalidate - an earlier record's contribution. Boundary state is ephemeral and never - published to `window.__webui.state`. A state-update record is a shallow - patch applied in record order to one already-committed updatable boundary; - repeated writes to the same key are last-writer-wins. -5. **Identity is not placement.** A record never contains a selector, node - path, or DOM position. Checkpoints carry the compiler-assigned integer - boundary ID in `target`; state updates carry the same ID. The integer - resolves through coordinator-owned references captured during the original - range walk and never requires a document scan. Placement remains expressed - only through the marker pair the browser's HTML parser materializes. -6. **Boundary-local payload.** A record carries only the templates and state - reachable from its own roots. Boundary 0 must not contain metadata or state - reachable only from a later boundary. This requires a state-projection - manifest; without one the build falls back to full state and every - checkpoint costs `O(boundaries × full state)`, which the compiler reports as - a `streaming-without-projection` warning. - -**Coordinator** - -7. **One queue, one record in flight.** Sentinels enqueue onto a single shared - task pump; exactly one checkpoint or update commits at a time, and neither - hydration nor a state write runs inside the parser's sentinel-upgrade - callback. No per-record timer, observer, or root listener is created. -8. **Range resolution is the only placement-aware step.** - `resolveBoundaryRange()` is the sole function that inspects DOM adjacency. - Template registration, state seeding, activation, scaffolding removal, and - lifecycle accounting all consume an abstract `HydrationRange`. State updates - bypass range resolution and use only the roots retained by their original - updatable checkpoint. -9. **`data-ws` is per-element deferral state, not a boundary marker.** It is - compiler-owned, identifies exactly the SSR roots the server deferred, and is - removed on activation, rejection, or abandonment. An element without it - mounts normally even while a streaming response is still open. -10. **Definitions and waiters are metadata-gated by tag name.** The browser - snapshots `observedAttributes` during `customElements.define()`, so a - streaming `.define(tag)` request waits until that tag's template metadata is - registered. Undefined custom elements then share one - `customElements.whenDefined` reaction per tag with a bounded root set, - never one promise closure per root instance. -11. **Undefined parents are activation barriers.** If an outer streamed root is - undefined, the range walk counts but does not activate its descendants or - register descendant tag waiters. The retained subtree is revisited only - after the outer definition arrives and activates, preserving parent-first - hydration and preventing children from mutating an unhydrated parent tree. -12. **Retention is opt-in and response-bounded.** A final checkpoint releases - its payload script, sentinel, marker pair, parsed envelope, projected state, - and root references immediately. An updatable checkpoint retains only its - bounded root array until the terminal record or fatal cleanup, when all - update targets and queued state are released together. Final boundaries pay - no target-map or root-retention cost. That array holds **live roots only**: - a root joins when it activates successfully, so one that was ignored, - failed, or was abandoned is never an update target and its element is not - kept alive by the boundary. Liveness is never inferred from `data-ws`, - which rule 9 strips on rejection and abandonment as well as on activation, - and which would therefore mark an inert root as ready to receive. The - retention budget is charged separately, at scan time, against every marked - root the checkpoint saw, so activating a root after its boundary was - retained can never grow that boundary past its bound. Delivering an update - to a live root is consequently an array walk with no DOM access. Because - `setState()` is defined on `TemplateElement` itself, a live root missing it - is a framework invariant violation and halts the stream rather than - reporting the same failure on every later update. -13. **Bounded terminal failure.** On malformed, truncated, or overflow input - the coordinator releases every discoverable scaffold and pending reference - within its configured bounds, balances the pending-boundary count, and - suppresses `webui:hydration-complete`. A halt never leaves a root stuck in - the deferred state and never wedges completion on a stuck pending count. - Valid commits never scan the document; a bounded document sweep is reserved - for fatal cleanup when the malformed stream no longer exposes a complete - marker range. -14. **Post-hydration author code runs exactly once.** `hydratedCallback()` runs - synchronously with the first successful ordinary hydration, client mount, - streamed activation, or dormant static-host wake. A Link-mode client mount - whose CSP blocks the prepaint guard remains resource-deferred until its - native styles load. Reactive writes made during that interval are reconciled - against the staging instance immediately before detached content is - appended; the callback runs only after the live container is installed. - Its latch is set before author code runs, so reconnects and exceptions never - retry it. -15. **Updates never rehydrate.** A state update calls the existing reactive - `setState()` path on each target root. It does not rerun - `$activateDeferredSSR()`, template wiring, or `hydratedCallback()`. If the - target class is not defined or its boundary is still activating, one - bounded shallow patch is queued per target and replayed through that same - `setState()` path immediately after the root activates - never merged into - the state the root hydrates from. Hydration wires bindings against the - server's bytes without evaluating them, so seeding a post-render value - first would bind the branch the DOM actually shows while the element - believed it held the new one, and the next equal-valued write would skip - the patch entirely. A root retained behind an undefined ancestor is - patched after its own activation, parent first. - A state update may reference only an earlier updatable - checkpoint; forward references and updates to final checkpoints are fatal - protocol errors. An application component whose `setState()` or change - handler throws degrades that root alone: the failure is reported and the - walk continues to the remaining targets, including the retained - descendants of a throwing root, because one - component's bug must never strand later boundaries. - -**Compile time** - -16. **`` is a directive, not an element.** It emits no wrapper - node, never nests or overlaps another boundary, and may not cut through a - component template or host content, native raw/inert HTML content, ``, - ``, route, or hydration-marker scope. -17. **Boundaries are rejected in HTML foster-parenting contexts.** Inside - `table`, `thead`, `tbody`, `tfoot`, `tr`, `colgroup`, `select`, or - `optgroup` the browser relocates the unknown `` sentinel out - of the table while the payload ` - - - - - + + + + + + + + + +``` -
Slow tail content
- +```html + +
+

{{title}}

+ + + +
{{slowFeed}}
+
``` -`` is a reserved compile-time directive, resolved by -`webui-parser`, that emits no wrapper element. The compiler: - -- requires a static, unique `name` attribute; -- requires the directive to be inside a currently open native ``; -- compiles the children as one independently complete fragment, assigning it - the next response-local boundary ID (`0`, `1`, …); -- rejects nested or overlapping boundaries; -- rejects a boundary placed in an HTML foster-parenting context (`table`, - `thead`, `tbody`, `tfoot`, `tr`, `colgroup`, `select`, `optgroup`) with - `boundary-in-foster-context`, because the browser would relocate the unknown - `` sentinel out of the table while leaving the payload - ` + +...ntp-page tail... + + + + + ``` -- `` / `` are marker comments, siblings of the - existing `` / `` family documented under "Plugin data and - SSR hydration markers" above — same removal-after-hydration contract. -- The stream envelope is the script-safe tuple - `[version, record_sequence, kind, target, payload]`. A boundary checkpoint - uses kind `0` (final) or `1` (updatable), its compiler-assigned boundary ID as - `target`, and the existing bootstrap object as `payload`. `bootstrap` - reuses the existing object shape (`state`, `templates`, `inventory`, and - optional route/CSS/nonce fields), avoiding a second state-selection or - serialization implementation. Every checkpoint carries projected state and - template/CSS metadata for the transitive component surface reachable from the - tags rendered since the previous checkpoint. `Protocol::new` precomputes a - compact, integer-indexed entry plan only for entries that declare boundary - metadata; ordinary entries allocate no streaming plan. Hand-built protocols - without compiler metadata use a request-local fallback plan. Route-free - checkpoints expand that - graph with one reusable DFS stack; leaf-only checkpoints perform no graph - walk. A component surface containing authored routes uses the request-aware - traversal so unmatched route branches do not leak into the boundary. This - conservative local expansion lets a hydrated condition or repeat create an - initially unrendered descendant without a global state block or server - round-trip. Inventory remains exact and contains only tags with rendered SSR - DOM. Template/CSS metadata is sent once when first reachable; a later rendered - instance receives its inventory delta and checkpoint-local state without - resending metadata. Per-instance positional state tuples are not part of the - wire contract; state is carried as named keys. -- `data-ws` is a compiler-owned, streaming-only identity inserted into every - streamed SSR component opening tag before browser upgrade. It is the sole - parser-time deferral signal when the document also has the streaming mode - marker. The coordinator removes it after activation or bounded failure - cleanup. Ordinary rendering ignores the structural signal, never emits the - attribute, and does not reserve an authored `data-ws` attribute. -- `` is the generated sentinel custom element. Its - `connectedCallback` (via `customElements.define`) is the checkpoint signal - the coordinator needs when the boundary arrives before its component - definitions are loaded (see races below). -- The handler emits one marker pair, one payload, and one sentinel per boundary, - then calls `flush()` (see "Flush contract"). State updates are markerless - `[1, record_sequence, 2, boundary_id, projected_state]` records followed by - the same sentinel and flush; they resolve only through roots captured by the - updatable checkpoint. The coordinator removes every payload and sentinel - after processing and removes checkpoint markers after hydration commits. -- At `body_end`, the handler writes any host-provided body injection and then - emits one empty markerless `[1,next_sequence,3,0,{}]` terminal record. The - terminal flush also commits preceding native/scriptless tail bytes, but those - bytes never manufacture another state or template projection. A static - streaming document with no boundaries therefore emits exactly - `[1,0,3,0,{}]`. Streaming mode does **not** also emit a page-wide - `#webui-data` block. Boundary checkpoints share the existing `WebUiBootstrap` - and `write_selected_state` paths, so there is no second state-selection - implementation. A request-local key scratch vector is cleared and reused - between checkpoints. Any later structural signal, including a boundary - start/end, is a malformed protocol error; no record may follow the - terminal record. -- A small `` mode marker is emitted - at the structural `head_start` signal, before authored head children. It is - therefore available before an async application entry can define component - classes. Route chain, inventory, CSP nonce, CSS bookkeeping, templates, and - projected state deltas arrive in the applicable boundary bootstrap. -- **Streaming does not alter non-streaming output, byte-for-byte.** Streaming - is a distinct, explicitly selected render/session mode. Non-streaming - rendering ignores namespaced raw structural signals; ordinary element and - fragment rendering is identical in both modes. Boundary emission is gated on - session mode and reuses the existing per-signal dedup pattern. +- Every browser record is the five-element tuple + `[2, sequence, kind, target, payload]`. +- Kinds are `0` final checkpoint, `1` updatable checkpoint, `2` state update, + `3` generated span completion, and `4` terminal. +- A checkpoint target is `BoundaryInstanceId`; a span-completion target is + `SpanInstanceId`. They are separate response-local namespaces. Update targets + reuse the committed boundary instance ID. Terminal target is zero. +- `` and `` delimit occurrence `N`. + `` and `` delimit generated component span `N`. +- `data-ws` marks a deferred component root. `data-ws-span="N"` identifies the + unfinished host for span `N`. `data-ws-enclosing="N"` permits an early child + root to bypass exactly that nearest unfinished ancestor. +- Boundary payloads include `declarationId`, optional + `enclosingSpanInstanceId`, projected `state`, and additive template, + inventory, route, nonce, CSS, and style deltas as needed. Span completion + payloads use the same bootstrap fields except declaration identity. +- State updates are markerless `[2, sequence, 2, instanceId, patch]` records. + They carry no templates and insert no application markup. +- Exactly one markerless `[2, sequence, 4, 0, {}]` terminal follows the final + tail bytes. A boundary-free streaming render emits the terminal from + `start`. +- Each record script is followed by one generated `` sentinel. + The coordinator removes scripts, sentinels, range markers, and compiler + attributes when they are no longer needed. +- `` is emitted at `head_start`. + Streaming mode does not emit a page-wide `#webui-data` block. ### Initialization ordering 1. Streaming mode marker at `head_start`, before authored head children and therefore before the async application entry `"); out } +/// Build one import map containing every supplied CSS module. +/// +/// Streaming checkpoints use one element so the payload script remains within +/// the browser coordinator's bounded sentinel lookback even when several +/// reachable-but-not-yet-rendered components first become available together. +pub(crate) fn build_importmap_tag_batch( + modules: &[(&str, &str)], + nonce: Option<&str>, +) -> Option { + if modules.is_empty() { + return None; + } + let mut imports = serde_json::Map::with_capacity(modules.len()); + for &(specifier, css) in modules { + imports.insert(specifier.to_owned(), Value::String(build_data_uri(css))); + } + let mut root = serde_json::Map::with_capacity(1); + root.insert("imports".into(), Value::Object(imports)); + let body = Value::Object(root).to_string(); + let cap = 40 + body.len() + nonce.map_or(0, |value| value.len() + 9); + let mut out = String::with_capacity(cap); + out.push_str(""); + Some(out) +} + fn build_data_uri(css: &str) -> String { let mut out = String::with_capacity("data:text/css,".len() + css.len()); out.push_str("data:text/css,"); @@ -127,6 +161,16 @@ mod tests { assert!(tag.contains(r#""empty":"data:text/css,""#)); } + #[test] + fn batch_importmap_uses_one_script_for_multiple_modules() { + let modules = [("a-card", "a{}"), ("b-card", "b{}")]; + let tag = build_importmap_tag_batch(&modules, Some("nonce")).unwrap(); + assert_eq!(tag.matches(" Result<()> { Ok(()) } + + #[doc(hidden)] + fn stream_begin_component(&mut self) -> Result<()> { + Err(HandlerError::Invariant( + "component opening buffering requires a streaming sink".to_string(), + )) + } + + #[doc(hidden)] + fn stream_mark_component_root(&mut self) -> Result<()> { + Err(HandlerError::Invariant( + "component root buffering requires a streaming sink".to_string(), + )) + } + + #[doc(hidden)] + fn stream_commit_component( + &mut self, + span_id: Option, + enclosing_span_id: Option, + deferred: bool, + ) -> Result<()> { + let _ = (span_id, enclosing_span_id, deferred); + Err(HandlerError::Invariant( + "component opening commit requires a streaming sink".to_string(), + )) + } } /// A response writer that can hand buffered bytes to its transport immediately. @@ -548,6 +578,8 @@ fn recycle_scope_map(pool: &mut Vec>, mut map: HashMap { + pub(crate) declaration_id: Option, + pub(crate) enclosing_span_instance_id: Option, pub(crate) state: &'a Value, pub(crate) state_selection: StateSelection<'a>, pub(crate) chain: &'a [Value], @@ -935,6 +967,24 @@ pub(crate) fn write_webui_bootstrap( let mut wrote_field = false; writer.write("{")?; + if let Some(declaration_id) = bootstrap.declaration_id { + write_json_field( + writer, + scratch, + &mut wrote_field, + "declarationId", + &declaration_id, + )?; + } + if let Some(span_id) = bootstrap.enclosing_span_instance_id { + write_json_field( + writer, + scratch, + &mut wrote_field, + "enclosingSpanInstanceId", + &span_id, + )?; + } if !bootstrap.chain.is_empty() { write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?; } @@ -1192,6 +1242,7 @@ impl WebUIHandler { Some(Fragment::Outlet(_)) => { self.process_outlet(context)?; } + Some(Fragment::Boundary(_)) => {} None => {} } } @@ -1274,6 +1325,10 @@ impl WebUIHandler { write_usize(context.writer, ri)?; context.writer.write("\" active>")?; + if !matched_child.content_fragment_id.is_empty() { + self.process_fragment_id(&matched_child.content_fragment_id, context)?; + } + context.writer.write("<")?; context.writer.write(comp)?; if let Some(p) = &context.plugin { @@ -1415,18 +1470,21 @@ impl WebUIHandler { write_usize(context.writer, ri)?; context.writer.write("\" active>")?; - if !route_frag.fragment_id.is_empty() { - let saved_route_base = context.route_base.clone(); - let saved_route_children = std::mem::take(&mut context.route_children); - if let Some((_, ref rm)) = best_route { - context.route_base = Cow::Owned(route_matcher::compute_route_base( - context.request_path, - rm.consumed_segments, - )); - } + let saved_route_base = context.route_base.clone(); + let saved_route_children = std::mem::take(&mut context.route_children); + if let Some((_, ref rm)) = best_route { + context.route_base = Cow::Owned(route_matcher::compute_route_base( + context.request_path, + rm.consumed_segments, + )); + } + context.route_children = route_frag.children.clone(); - context.route_children = route_frag.children.clone(); + if !route_frag.content_fragment_id.is_empty() { + self.process_fragment_id(&route_frag.content_fragment_id, context)?; + } + if !route_frag.fragment_id.is_empty() { let comp = webui_protocol::WebUIFragmentComponent { fragment_id: route_frag.fragment_id.clone(), }; @@ -1444,10 +1502,9 @@ impl WebUIHandler { context.writer.write("")?; - - context.route_base = saved_route_base; - context.route_children = saved_route_children; } + context.route_base = saved_route_base; + context.route_children = saved_route_children; } else { context.writer.write(" style=\"display:none\">")?; } @@ -1899,6 +1956,8 @@ impl WebUIHandler { context.writer, &mut context.json_scratch, WebUiBootstrap { + declaration_id: None, + enclosing_span_instance_id: None, state: context.state, state_selection, chain: &chain_json, @@ -2240,10 +2299,8 @@ fn handle( #[cfg(test)] mod tests { use super::*; - use crate::streaming::STREAMING_MARKER; use std::cell::RefCell; - use std::sync::Arc; - use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser}; + use webui_parser::HtmlParser; use webui_protocol::{ web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator, WebUIFragmentAttribute, @@ -2300,6 +2357,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Hello, WebUI!")], + contains_boundary: false, }, ); @@ -2338,6 +2396,7 @@ mod tests { WebUIFragment::signal("name", false), WebUIFragment::raw("!"), ], + contains_boundary: false, }, ); @@ -2375,6 +2434,7 @@ mod tests { WebUIFragment::raw("People: "), WebUIFragment::for_loop("person", "people", "person-item"), ], + contains_boundary: false, }, ); @@ -2385,6 +2445,7 @@ mod tests { WebUIFragment::signal("person.name", false), WebUIFragment::raw(", "), ], + contains_boundary: false, }, ); @@ -2432,6 +2493,7 @@ mod tests { ), WebUIFragment::raw("End"), ], + contains_boundary: false, }, ); @@ -2439,6 +2501,7 @@ mod tests { "active-content".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Active")], + contains_boundary: false, }, ); @@ -2518,6 +2581,7 @@ mod tests { WebUIFragment::raw("Component: "), WebUIFragment::component("my-component"), ], + contains_boundary: false, }, ); @@ -2525,6 +2589,7 @@ mod tests { "my-component".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Component Content
")], + contains_boundary: false, }, ); @@ -2562,6 +2627,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::component("missing-component")], + contains_boundary: false, }, ); @@ -2600,6 +2666,7 @@ mod tests { WebUIFragment::signal("missing_field", false), WebUIFragment::raw("!"), ], + contains_boundary: false, }, ); @@ -2639,6 +2706,7 @@ mod tests { ), WebUIFragment::raw(">Click"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2668,6 +2736,7 @@ mod tests { ), WebUIFragment::raw(">Click"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2697,6 +2766,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2730,6 +2800,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2758,6 +2829,7 @@ mod tests { WebUIFragment::attribute("value", "inputValue"), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2784,6 +2856,7 @@ mod tests { WebUIFragment::attribute("handle", "number"), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2815,6 +2888,7 @@ mod tests { WebUIFragment::attribute("href", "value"), WebUIFragment::raw(">demo"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2854,6 +2928,7 @@ mod tests { WebUIFragment::attribute("data-cfg", "cfg"), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2891,6 +2966,7 @@ mod tests { WebUIFragment::attribute_template("value", "attr-1"), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); fragments.insert( @@ -2900,6 +2976,7 @@ mod tests { WebUIFragment::raw("hello "), WebUIFragment::signal("item", false), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2927,6 +3004,7 @@ mod tests { WebUIFragment::signal("html", false), WebUIFragment::signal("html", true), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2958,6 +3036,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outer"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -2968,12 +3047,14 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "inner".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Inner")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3008,6 +3089,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -3018,6 +3100,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -3028,6 +3111,7 @@ mod tests { WebUIFragment::signal("innerItem.name", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3062,6 +3146,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -3073,6 +3158,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -3084,6 +3170,7 @@ mod tests { WebUIFragment::signal("globalInner", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3118,6 +3205,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")], + contains_boundary: false, }, ); fragments.insert( @@ -3127,12 +3215,14 @@ mod tests { ConditionExpr::identifier("item.visible"), "visible-tpl", )], + contains_boundary: false, }, ); fragments.insert( "visible-tpl".to_string(), FragmentList { fragments: vec![WebUIFragment::signal("item.name", false)], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3155,6 +3245,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")], + contains_boundary: false, }, ); fragments.insert( @@ -3164,12 +3255,14 @@ mod tests { ConditionExpr::identifier("item.flag"), "show-tpl", )], + contains_boundary: false, }, ); fragments.insert( "show-tpl".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("yes")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3211,6 +3304,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3221,6 +3316,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3261,6 +3357,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3270,6 +3368,7 @@ mod tests { WebUIFragment::raw("hello "), WebUIFragment::signal("item", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -3280,6 +3379,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3320,6 +3420,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3329,6 +3431,7 @@ mod tests { WebUIFragment::raw("prefix "), WebUIFragment::signal("item", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -3339,6 +3442,7 @@ mod tests { WebUIFragment::signal("dataTitle", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3380,6 +3484,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3392,6 +3498,7 @@ mod tests { WebUIFragment::signal("item.bar", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3432,6 +3539,8 @@ mod tests { WebUIFragment::component("parent"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3456,9 +3565,17 @@ mod tests { WebUIFragment::raw("LabelAfter: "), WebUIFragment::signal("var", false), ], + + contains_boundary: false, + }, + ); + fragments.insert( + "child".to_string(), + FragmentList { + fragments: vec![], + contains_boundary: false, }, ); - fragments.insert("child".to_string(), FragmentList { fragments: vec![] }); let protocol = WebUIProtocol::new(fragments); let state = test_json!({"var": "original"}); let mut writer = TestWriter::new(); @@ -3497,6 +3614,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3506,12 +3625,14 @@ mod tests { ConditionExpr::identifier("disabled"), "show", )], + contains_boundary: false, }, ); fragments.insert( "show".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("disabled!")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3539,6 +3660,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::signal("v", false)], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3656,6 +3778,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3684,6 +3807,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3712,6 +3836,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3741,6 +3866,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3773,6 +3899,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3801,6 +3928,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3829,6 +3957,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3857,6 +3986,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3887,6 +4017,7 @@ mod tests { ), WebUIFragment::raw(">Click"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3916,6 +4047,7 @@ mod tests { ), WebUIFragment::raw(">Click"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3955,6 +4087,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3964,6 +4098,7 @@ mod tests { WebUIFragment::raw("Hello "), WebUIFragment::signal("who", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -3985,6 +4120,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3994,6 +4131,7 @@ mod tests { WebUIFragment::raw("Child of "), WebUIFragment::signal("title", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -4004,6 +4142,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4044,12 +4183,15 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( "p-title".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)], + contains_boundary: false, }, ); fragments.insert( @@ -4071,6 +4213,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4082,6 +4226,7 @@ mod tests { WebUIFragment::raw(")-"), WebUIFragment::signal("cExtra", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -4103,6 +4248,8 @@ mod tests { WebUIFragment::component("grandchild-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4113,6 +4260,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4153,6 +4301,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4162,12 +4312,14 @@ mod tests { WebUIFragment::raw("Parent:"), WebUIFragment::signal("who", false), ], + contains_boundary: false, }, ); fragments.insert( "parent-component".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")], + contains_boundary: false, }, ); fragments.insert( @@ -4189,6 +4341,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4200,6 +4354,7 @@ mod tests { WebUIFragment::raw(" / "), WebUIFragment::signal("title", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -4210,6 +4365,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4270,24 +4426,29 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( "attr-title".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)], + contains_boundary: false, }, ); fragments.insert( "attr-data-title".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)], + contains_boundary: false, }, ); fragments.insert( "attr-aria-label".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)], + contains_boundary: false, }, ); fragments.insert( @@ -4302,6 +4463,7 @@ mod tests { WebUIFragment::signal("ariaLabel", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4343,6 +4505,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4353,6 +4517,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4378,6 +4543,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "loop")], + contains_boundary: false, }, ); fragments.insert( @@ -4400,6 +4566,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4410,6 +4578,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4461,6 +4630,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4475,12 +4646,14 @@ mod tests { WebUIFragment::signal("label", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "disabledTemplate".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Disabled
")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4522,6 +4695,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4532,6 +4707,7 @@ mod tests { WebUIFragment::signal("keyHyphen", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4631,6 +4807,8 @@ mod tests { WebUIFragment::component("test-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4651,6 +4829,7 @@ mod tests { WebUIFragment::signal("ariaLabel", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4704,6 +4883,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4727,6 +4908,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4737,6 +4920,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4778,6 +4962,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4800,6 +4986,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4821,6 +5009,8 @@ mod tests { WebUIFragment::component("grandchild-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4831,6 +5021,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4872,6 +5063,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4884,6 +5077,7 @@ mod tests { WebUIFragment::signal("item.bar", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4913,6 +5107,7 @@ mod tests { "list.items", "listTemplate", )], + contains_boundary: false, }, ); fragments.insert( @@ -4932,6 +5127,8 @@ mod tests { }, WebUIFragment::component("item_component"), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4942,6 +5139,7 @@ mod tests { WebUIFragment::signal("item.name", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4968,6 +5166,7 @@ mod tests { "data.outer", "outerTemplate", )], + contains_boundary: false, }, ); fragments.insert( @@ -4978,6 +5177,7 @@ mod tests { "outer.middle", "middleTemplate", )], + contains_boundary: false, }, ); fragments.insert( @@ -4988,6 +5188,7 @@ mod tests { "middle.inner", "innerTemplate", )], + contains_boundary: false, }, ); fragments.insert( @@ -5032,6 +5233,8 @@ mod tests { WebUIFragment::component("card_component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -5046,6 +5249,7 @@ mod tests { WebUIFragment::signal("inner.label", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5091,6 +5295,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -5106,18 +5312,21 @@ mod tests { "enabledTemplate", ), ], + contains_boundary: false, }, ); fragments.insert( "disabledTemplate".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Disabled")], + contains_boundary: false, }, ); fragments.insert( "enabledTemplate".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Enabled")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5158,6 +5367,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -5173,18 +5384,21 @@ mod tests { "enabledTemplate", ), ], + contains_boundary: false, }, ); fragments.insert( "disabledTemplate".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Disabled")], + contains_boundary: false, }, ); fragments.insert( "enabledTemplate".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Enabled")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5225,6 +5439,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -5250,12 +5466,15 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( "parentDisabledTemplate".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Parent Disabled
")], + contains_boundary: false, }, ); fragments.insert( @@ -5271,18 +5490,21 @@ mod tests { "childEnabledTemplate", ), ], + contains_boundary: false, }, ); fragments.insert( "childDisabledTemplate".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Child Disabled
")], + contains_boundary: false, }, ); fragments.insert( "childEnabledTemplate".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Child Enabled
")], + contains_boundary: false, }, ); @@ -5340,12 +5562,14 @@ mod tests { WebUIFragment::component("custom-element"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "custom-element".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Custom Element
")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5376,12 +5600,14 @@ mod tests { WebUIFragment::component("custom-element"), WebUIFragment::raw("Hello World"), ], + contains_boundary: false, }, ); fragments.insert( "custom-element".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5412,12 +5638,14 @@ mod tests { WebUIFragment::for_loop("item", "items", "templateRepeat"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "custom-button".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("")], + contains_boundary: false, }, ); fragments.insert( @@ -5428,12 +5656,14 @@ mod tests { WebUIFragment::component("custom-child"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "custom-child".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Hello World!

")], + contains_boundary: false, }, ); fragments.insert( @@ -5446,6 +5676,7 @@ mod tests { WebUIFragment::component("custom-button"), WebUIFragment::raw("Ok"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5481,12 +5712,14 @@ mod tests { ), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "if-1".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("If 1")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5527,6 +5760,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "template1"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5537,6 +5771,7 @@ mod tests { WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5547,6 +5782,7 @@ mod tests { WebUIFragment::signal("item.label", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5583,6 +5819,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "template1"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5593,6 +5830,7 @@ mod tests { WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5603,6 +5841,7 @@ mod tests { WebUIFragment::signal("item.label", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5637,6 +5876,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "static")], + contains_boundary: false, }, ); fragments.insert( @@ -5653,6 +5893,7 @@ mod tests { WebUIFragment::for_loop("item", "item.children", "static"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5694,6 +5935,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "templateComponent"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5704,6 +5946,7 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5714,6 +5957,7 @@ mod tests { WebUIFragment::signal("name", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5743,6 +5987,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5755,6 +6000,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5768,6 +6014,7 @@ mod tests { WebUIFragment::signal("innerItem.innerLabel", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5803,6 +6050,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "templateComponent"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5813,6 +6061,7 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5825,6 +6074,7 @@ mod tests { WebUIFragment::signal("globalSuffix", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5855,6 +6105,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "templateComponent"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5865,6 +6116,7 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5877,6 +6129,7 @@ mod tests { WebUIFragment::signal("globalSuffix", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5907,6 +6160,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "template1"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5917,6 +6171,7 @@ mod tests { WebUIFragment::signal("name", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5946,6 +6201,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5961,6 +6217,7 @@ mod tests { ), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5971,6 +6228,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5983,6 +6241,7 @@ mod tests { WebUIFragment::signal("innerItem.innerLabel", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -6020,6 +6279,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6036,6 +6296,7 @@ mod tests { ), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6049,6 +6310,7 @@ mod tests { ), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6059,6 +6321,7 @@ mod tests { WebUIFragment::signal("middleItem.value", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -6095,6 +6358,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6106,6 +6370,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6119,6 +6384,7 @@ mod tests { ), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6129,6 +6395,7 @@ mod tests { WebUIFragment::signal("innerItem.detail", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -6163,6 +6430,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "template1"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6179,6 +6447,7 @@ mod tests { WebUIFragment::signal("item.otherVal", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -6214,6 +6483,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "templateComponent"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6224,6 +6494,7 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6240,6 +6511,7 @@ mod tests { WebUIFragment::signal("item.otherVal", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -6275,6 +6547,7 @@ mod tests { WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6285,6 +6558,7 @@ mod tests { WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6294,6 +6568,7 @@ mod tests { ConditionExpr::identifier("inner_item.flag"), "ifInner", )], + contains_boundary: false, }, ); fragments.insert( @@ -6304,6 +6579,7 @@ mod tests { WebUIFragment::signal("inner_item.value", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -6336,6 +6612,7 @@ mod tests { WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6346,6 +6623,7 @@ mod tests { WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6355,6 +6633,7 @@ mod tests { ConditionExpr::identifier("inner_item.flag"), "ifInner", )], + contains_boundary: false, }, ); fragments.insert( @@ -6365,6 +6644,7 @@ mod tests { WebUIFragment::signal("inner_item.value", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -6397,6 +6677,7 @@ mod tests { WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6408,6 +6689,7 @@ mod tests { WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -6425,6 +6707,7 @@ mod tests { ), "ifInner", )], + contains_boundary: false, }, ); fragments.insert( @@ -6435,6 +6718,7 @@ mod tests { WebUIFragment::signal("inner.value", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -6488,6 +6772,8 @@ mod tests { ..Default::default() }), ], + + contains_boundary: false, }, ); @@ -6496,6 +6782,7 @@ mod tests { "dash-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Dashboard

")], + contains_boundary: false, }, ); @@ -6504,6 +6791,7 @@ mod tests { "detail-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Detail

")], + contains_boundary: false, }, ); @@ -6540,6 +6828,8 @@ mod tests { keep_alive: false, ..Default::default() })], + + contains_boundary: false, }, ); @@ -6550,6 +6840,7 @@ mod tests { WebUIFragment::raw("

Shell

"), WebUIFragment::outlet(), ], + contains_boundary: false, }, ); @@ -6560,6 +6851,7 @@ mod tests { WebUIFragment::raw("

Section

"), WebUIFragment::outlet(), ], + contains_boundary: false, }, ); @@ -6567,6 +6859,7 @@ mod tests { "topic-comp".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Topic content

")], + contains_boundary: false, }, ); @@ -6989,12 +7282,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "my-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw(template.to_string())], + contains_boundary: false, }, ); @@ -7054,12 +7349,14 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::component("my-card")], + contains_boundary: false, }, ); fragments.insert( "my-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw(r#"

hello

"#.to_string())], + contains_boundary: false, }, ); @@ -7099,12 +7396,14 @@ mod tests { WebUIFragment::component("my-card"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "my-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw(template.to_string())], + contains_boundary: false, }, ); @@ -7150,6 +7449,7 @@ mod tests { WebUIFragment::component("my-card"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( @@ -7159,7 +7459,8 @@ mod tests { "" .to_string(), )], - }, + contains_boundary: false, +}, ); let protocol = WebUIProtocol::new(fragments); @@ -7207,12 +7508,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "my-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
card
".to_string())], + contains_boundary: false, }, ); @@ -7280,18 +7583,21 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "o-loading-state".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
loading
".to_string())], + contains_boundary: false, }, ); fragments.insert( "my-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
card
".to_string())], + contains_boundary: false, }, ); @@ -7367,6 +7673,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); @@ -7407,6 +7714,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -7448,18 +7756,21 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "z-widget".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
z
".to_string())], + contains_boundary: false, }, ); fragments.insert( "a-widget".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
a
".to_string())], + contains_boundary: false, }, ); @@ -7527,12 +7838,14 @@ mod tests { WebUIFragment::raw("".to_string()), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "my-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw(r#"

hi

"#.to_string())], + contains_boundary: false, }, ); @@ -7591,12 +7904,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "dash-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw(template.to_string())], + contains_boundary: false, }, ); @@ -7647,18 +7962,21 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "has-css".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

styled

".to_string())], + contains_boundary: false, }, ); fragments.insert( "no-css".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

plain

".to_string())], + contains_boundary: false, }, ); @@ -7721,6 +8039,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); // app-shell contains a cart panel @@ -7731,6 +8050,7 @@ mod tests { WebUIFragment::raw("
Shell
".to_string()), WebUIFragment::component("cart-panel"), ], + contains_boundary: false, }, ); // cart-panel has an block containing product-card @@ -7742,6 +8062,7 @@ mod tests { WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); // cart-items (if block body) contains product-card @@ -7749,12 +8070,14 @@ mod tests { "cart-items".to_string(), FragmentList { fragments: vec![WebUIFragment::component("product-card")], + contains_boundary: false, }, ); fragments.insert( "product-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Card
".to_string())], + contains_boundary: false, }, ); @@ -7882,6 +8205,7 @@ mod tests { structural_fragment("body_start"), WebUIFragment::raw("
Body-only host
".to_string()), ], + contains_boundary: false, }, )]); let mut protocol = WebUIProtocol::new(fragments); @@ -7917,6 +8241,7 @@ mod tests { structural_fragment("head_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, )]); let mut protocol = WebUIProtocol::new(fragments); @@ -7953,6 +8278,7 @@ mod tests { structural_fragment("head_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, )]); let mut protocol = WebUIProtocol::new(fragments); @@ -8020,12 +8346,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "dash-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw(template.to_string())], + contains_boundary: false, }, ); @@ -8076,6 +8404,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( @@ -8085,18 +8414,21 @@ mod tests { ConditionExpr::identifier("hasItems"), "cart-items", )], + contains_boundary: false, }, ); fragments.insert( "cart-items".to_string(), FragmentList { fragments: vec![WebUIFragment::component("product-card")], + contains_boundary: false, }, ); fragments.insert( "product-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Card
".to_string())], + contains_boundary: false, }, ); @@ -8147,12 +8479,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( "app-shell".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("shell".to_string())], + contains_boundary: false, }, ); let index_fragments = fragments @@ -8212,6 +8546,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -8247,12 +8582,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "app-shell".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Shell

")], + contains_boundary: false, }, ); let mut protocol = WebUIProtocol::new(fragments); @@ -8296,12 +8633,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "app-shell".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Shell

")], + contains_boundary: false, }, ); let mut protocol = WebUIProtocol::new(fragments); @@ -8338,12 +8677,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "app-shell".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Shell

")], + contains_boundary: false, }, ); let mut protocol = WebUIProtocol::new(fragments); @@ -8419,6 +8760,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let mut protocol = WebUIProtocol::new(fragments); @@ -8456,12 +8798,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "items-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Items

")], + contains_boundary: false, }, ); let mut protocol = WebUIProtocol::new(fragments); @@ -8646,18 +8990,22 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( "home-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Home

")], + contains_boundary: false, }, ); fragments.insert( "admin-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Admin

")], + contains_boundary: false, }, ); @@ -8779,6 +9127,8 @@ mod tests { keep_alive: false, ..Default::default() })], + + contains_boundary: false, }, ); @@ -8786,18 +9136,21 @@ mod tests { "app-shell".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

App

"), WebUIFragment::outlet()], + contains_boundary: false, }, ); fragments.insert( "compose-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Compose

")], + contains_boundary: false, }, ); fragments.insert( "settings-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Settings

")], + contains_boundary: false, }, ); @@ -8896,6 +9249,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); WebUIProtocol::new(fragments) @@ -8984,6 +9338,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); WebUIProtocol::new(fragments) @@ -9147,12 +9502,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "app-shell".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

Shell

")], + contains_boundary: false, }, ); let mut document = WebUIProtocol::new(fragments); @@ -9258,6 +9615,7 @@ mod tests { structural_fragment("body_start"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9341,6 +9699,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9415,6 +9774,7 @@ mod tests { structural_fragment("body_end"), // duplicate WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9449,6 +9809,7 @@ mod tests { fragments: vec![WebUIFragment::raw( "hi".to_string(), )], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9650,6 +10011,7 @@ mod tests { WebUIFragment::for_loop("item", "outer", "outer_body"), WebUIFragment::raw("]"), ], + contains_boundary: false, }, ); fragments.insert( @@ -9663,6 +10025,7 @@ mod tests { WebUIFragment::signal("item.tag", false), WebUIFragment::raw(")"), ], + contains_boundary: false, }, ); fragments.insert( @@ -9673,6 +10036,7 @@ mod tests { WebUIFragment::signal("item.tag", false), WebUIFragment::raw("]"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9705,2060 +10069,4 @@ mod tests { "outer `item` must stay bound to its iteration value across the inner loop's save/restore" ); } - - #[derive(Default)] - struct FlushTestWriter { - output: String, - flushes: Vec, - fail_flush: bool, - fail_flush_at: Option, - flush_attempts: usize, - ended: bool, - } - - impl ResponseWriter for FlushTestWriter { - fn write(&mut self, content: &str) -> Result<()> { - self.output.push_str(content); - Ok(()) - } - - fn end(&mut self) -> Result<()> { - self.ended = true; - Ok(()) - } - } - - impl FlushWriter for FlushTestWriter { - fn flush(&mut self) -> Result<()> { - let attempt = self.flush_attempts; - self.flush_attempts += 1; - if self.fail_flush || self.fail_flush_at == Some(attempt) { - return Err(HandlerError::ClientDisconnected); - } - self.flushes.push(self.output.len()); - Ok(()) - } - } - - fn streaming_protocol(with_boundaries: bool) -> Protocol { - streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components) - } - - fn streaming_protocol_with_state_strategy( - with_boundaries: bool, - state_strategy: InitialStateStrategy, - ) -> Protocol { - let mut fragments = HashMap::new(); - let mut entry = vec![ - WebUIFragment::raw(""), - structural_fragment("head_start"), - WebUIFragment::raw(""), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - ]; - if with_boundaries { - entry.push(structural_fragment("boundary_start:0")); - } - entry.extend([ - WebUIFragment::raw(""), - WebUIFragment::component("my-counter"), - WebUIFragment::raw(""), - ]); - if with_boundaries { - entry.push(structural_fragment("boundary_end:0")); - } - entry.extend([ - WebUIFragment::raw("slow tail"), - structural_fragment("body_end"), - WebUIFragment::raw(""), - ]); - fragments.insert("index.html".to_string(), FragmentList { fragments: entry }); - fragments.insert( - "my-counter".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("")], - }, - ); - - let mut document = WebUIProtocol::new(fragments); - document.initial_state_strategy = state_strategy as i32; - document.components.insert( - "my-counter".to_string(), - webui_protocol::ComponentData { - template_json: r#"{"h":"","th":1}"#.to_string(), - template_functions: "[function(){return true}]".to_string(), - hydration_mode: StateProjectionMode::Keys as i32, - hydration_keys: vec!["count".to_string()], - ..Default::default() - }, - ); - Protocol::new(document) - } - - #[test] - fn handler_error_stays_small() { - // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and - // therefore `Result<(), HandlerError>` threaded through the hot legacy - // render path — down to a single `String`-sized payload plus a - // discriminant word. If the boundary payload is un-boxed back to - // `{ signal, reason }` it grows to two `String`s (48-byte payload) and - // this fails. - assert!( - std::mem::size_of::() - <= std::mem::size_of::() + std::mem::size_of::(), - "HandlerError grew to {} bytes", - std::mem::size_of::() - ); - } - - #[test] - fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() { - let protocol = streaming_protocol(true); - let handler = WebUIHandler::with_plugin(|| { - Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()) - }); - let state = test_json!({ "count": 1, "serverOnly": "secret" }); - let mut writer = FlushTestWriter::default(); - - handler - .render_streaming( - &protocol, - &state, - &RenderOptions::new("index.html", "/"), - &mut writer, - ) - .unwrap(); - - assert!(writer.ended); - assert_eq!( - writer.flushes.len(), - 2, - "boundary commit plus one coalesced terminal-tail flush" - ); - let first_flush = &writer.output[..writer.flushes[0]]; - assert!(first_flush.contains("")); - assert!(first_flush.contains("")); - assert!(first_flush.contains(r#"[1,0,0,0,{"inventory":"01","state":{"count":1}"#)); - assert!(first_flush.contains(r#""templates":{"my-counter":"#)); - assert!(!first_flush.contains("slow tail")); - assert!(!writer.output.contains("id=\"webui-data\"")); - // The terminal flush commits the scriptless tail without manufacturing - // another state/template projection. - assert!(writer.output.contains("[1,1,3,0,{}]")); - assert!(!writer.output.contains("[1,1,0,1,")); - assert!(!writer.output.contains("[1,2,3,0,{}]")); - - let marker = writer - .output - .find(STREAMING_MARKER) - .expect("streaming marker"); - let attributed_head = writer - .output - .find("") - .expect("attributed mixed-case head"); - let authored_script = writer - .output - .find("src=\"/index.js\"") - .expect("entry script"); - assert!(attributed_head < marker && marker < authored_script); - } - - #[test] - fn streaming_terminal_tail_never_resends_full_state() { - let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full); - let handler = WebUIHandler::with_plugin(|| { - Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()) - }); - let mut writer = FlushTestWriter::default(); - - handler - .render_streaming( - &protocol, - &test_json!({ "count": 1, "serverOnly": "secret" }), - &RenderOptions::new("index.html", "/").with_body_inject(" \n"), - &mut writer, - ) - .unwrap(); - - assert_eq!( - writer.output.matches("serverOnly").count(), - 1, - "full state belongs only to the interactive boundary" - ); - assert!(writer.output.contains("[1,1,3,0,{}]")); - } - - /// Streaming must place the reserved-state injects exactly where the - /// ordinary render does, so a host can switch modes without its - /// boundary HTML moving. - #[test] - fn state_inject_placement_matches_between_render_modes() { - let entry = vec![ - WebUIFragment::raw(""), - structural_fragment("head_start"), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - WebUIFragment::raw("
static
"), - structural_fragment("body_end"), - WebUIFragment::raw(""), - ]; - let fragments = - HashMap::from([("index.html".to_string(), FragmentList { fragments: entry })]); - let protocol = Protocol::new(WebUIProtocol::new(fragments)); - let state = test_json!({ - "$webui": { - "headEnd": "", - "bodyStart": "", - "bodyEnd": "", - } - }); - let options = RenderOptions::new("index.html", "/"); - - let mut ordinary = TestWriter::new(); - WebUIHandler::new() - .render(&protocol, &state, &options, &mut ordinary) - .unwrap(); - let ordinary_html = ordinary.get_content().to_string(); - - let mut streamed = FlushTestWriter::default(); - WebUIHandler::new() - .render_streaming(&protocol, &state, &options, &mut streamed) - .unwrap(); - let streamed_html = &streamed.output; - - for html in [ordinary_html.as_str(), streamed_html.as_str()] { - let head_end = html.find("").expect("headEnd missing"); - let head_close = html.find("").expect(" missing"); - let body_start = html.find("").expect("bodyStart missing"); - let main = html.find("
static
").expect("content missing"); - let body_end = html.find("").expect("bodyEnd missing"); - let body_close = html.find("").expect(" missing"); - assert!(head_end < head_close, "headEnd misplaced: {html}"); - assert!(body_start < main, "bodyStart misplaced: {html}"); - assert!( - main < body_end && body_end < body_close, - "bodyEnd misplaced: {html}" - ); - } - - // The streaming response still terminates with its single empty - // terminal record: an inject must not perturb the record stream. - assert!( - streamed_html.contains(",3,0,{}]"), - "streaming must still end in one empty terminal record: {streamed_html}" - ); - } - - #[test] - fn streaming_state_inject_emits_and_strips_reserved_key() { - let entry = vec![ - WebUIFragment::raw(""), - structural_fragment("head_start"), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - structural_fragment("body_end"), - WebUIFragment::raw(""), - ]; - let fragments = - HashMap::from([("index.html".to_string(), FragmentList { fragments: entry })]); - let protocol = Protocol::new(WebUIProtocol::new(fragments)); - let state = test_json!({ "$webui": { "bodyEnd": "" } }); - - let mut writer = FlushTestWriter::default(); - WebUIHandler::new() - .render_streaming( - &protocol, - &state, - &RenderOptions::new("index.html", "/"), - &mut writer, - ) - .unwrap(); - assert!(writer.output.contains("")); - assert!(!writer.output.contains("$webui")); - } - - #[test] - fn static_streaming_document_uses_one_empty_terminal_record() { - let fragments = HashMap::from([( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - structural_fragment("head_start"), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - WebUIFragment::raw("
static
"), - structural_fragment("body_end"), - WebUIFragment::raw(""), - ], - }, - )]); - let protocol = Protocol::new(WebUIProtocol::new(fragments)); - let mut writer = FlushTestWriter::default(); - - WebUIHandler::new() - .render_streaming( - &protocol, - &test_json!({ "serverOnly": "secret" }), - &RenderOptions::new("index.html", "/"), - &mut writer, - ) - .unwrap(); - - assert_eq!(writer.flushes.len(), 1); - assert!(writer.output.contains(STREAMING_MARKER)); - assert!(writer.output.contains("[1,0,3,0,{}]")); - assert!(!writer.output.contains("serverOnly")); - assert!(!writer.output.contains("id=\"webui-data\"")); - assert!(!writer.output.contains("").expect("end marker"); - let envelope = writer.output[boundary_end..] - .find("data-webui-boundary nonce=\"test-nonce-123\"") - .map(|index| index + boundary_end) - .expect("nonce-bearing envelope"); - let functions = writer - .output - .find("templateFns") - .expect("function side channel"); - let sentinel = writer - .output - .find("") - .expect("hydration sentinel"); - assert!(boundary_end < envelope && envelope < functions && functions < sentinel); - assert!(writer.output.contains("", - ), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - structural_fragment("body_end"), - ], - }, - )]); - let protocol = Protocol::new(WebUIProtocol::new(fragments)); - let mut writer = FlushTestWriter::default(); - - let result = WebUIHandler::new().render_streaming( - &protocol, - &test_json!({}), - &RenderOptions::new("index.html", "/"), - &mut writer, - ); - - assert!(matches!( - result, - Err(HandlerError::MissingStreamingHeadStart { before: "head_end" }) - )); - assert!( - writer.output.is_empty(), - "preflight must fail before output" - ); - assert!(writer.flushes.is_empty()); - assert!(!writer.ended); - } - - #[test] - fn streaming_render_rejects_duplicate_head_start() { - let fragments = HashMap::from([( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - structural_fragment("head_start"), - structural_fragment("head_start"), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - structural_fragment("body_end"), - ], - }, - )]); - let protocol = Protocol::new(WebUIProtocol::new(fragments)); - let mut writer = FlushTestWriter::default(); - - let result = WebUIHandler::new().render_streaming( - &protocol, - &test_json!({}), - &RenderOptions::new("index.html", "/"), - &mut writer, - ); - - assert!(matches!( - result, - Err(HandlerError::DuplicateStreamingHeadStart) - )); - assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1); - assert!(writer.flushes.is_empty()); - } - - #[test] - fn legacy_render_is_byte_identical_when_boundary_signals_are_present() { - let handler = WebUIHandler::with_plugin(|| { - Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()) - }); - let state = test_json!({ - "count": 1, - "head_start": "must not render", - "boundary_start:0": "must not render", - "boundary_end:0": "must not render", - }); - let mut with_boundaries = TestWriter::new(); - let mut without_boundaries = TestWriter::new(); - handler - .render( - &streaming_protocol(true), - &state, - &RenderOptions::new("index.html", "/"), - &mut with_boundaries, - ) - .unwrap(); - handler - .render( - &streaming_protocol(false), - &state, - &RenderOptions::new("index.html", "/"), - &mut without_boundaries, - ) - .unwrap(); - assert_eq!( - with_boundaries.get_content(), - without_boundaries.get_content() - ); - } - - #[test] - fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() { - for source in [ - r#"Tx"#, - r#"Tx"#, - ] { - let mut parser = HtmlParser::new(); - parser - .parse("index.html", source) - .expect("parse head fixture"); - let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records())); - let mut writer = TestWriter::new(); - WebUIHandler::new() - .render( - &protocol, - &test_json!({ "theme": "light" }), - &RenderOptions::new("index.html", "/"), - &mut writer, - ) - .unwrap(); - - assert_eq!( - writer.get_content(), - "Tx" - ); - } - } - - #[test] - fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() { - let source = - r#"Tx"#; - let mut parser = HtmlParser::new(); - parser.parse("index.html", source).expect("parse fixture"); - let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records())); - - let mut ordinary = TestWriter::new(); - WebUIHandler::new() - .render( - &protocol, - &test_json!({}), - &RenderOptions::new("index.html", "/"), - &mut ordinary, - ) - .unwrap(); - assert_eq!(ordinary.get_content(), source); - - let mut streaming = FlushTestWriter::default(); - WebUIHandler::new() - .render_streaming( - &protocol, - &test_json!({}), - &RenderOptions::new("index.html", "/"), - &mut streaming, - ) - .unwrap(); - assert!(streaming.output.contains(r#""#)); - assert!(streaming.output.contains("x")); - let opening = streaming.output.find("").expect("title"); - assert!(opening < marker && marker < title); - assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1); - } - - /// Only `}}}webui:`-namespaced signals are compiler-owned, so an - /// unprefixed `body_end` is ordinary authored content. - #[test] - fn unnamespaced_signal_is_ordinary_content() { - let fragments = HashMap::from([( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - structural_fragment("head_end"), - WebUIFragment::raw(""), - WebUIFragment::signal("body_end".to_string(), false), - structural_fragment("body_end"), - WebUIFragment::raw(""), - ], - }, - )]); - let protocol = Protocol::new(WebUIProtocol::new(fragments)); - let mut writer = TestWriter::new(); - WebUIHandler::new() - .render( - &protocol, - &test_json!({ "body_end": "content" }), - &RenderOptions::new("index.html", "/"), - &mut writer, - ) - .expect("current protocol must render"); - assert!(writer.get_content().contains("content")); - } - - #[test] - fn authored_raw_signal_keys_remain_content_in_both_render_modes() { - let source = concat!( - "", - "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|", - "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}", - "", - ); - let state = test_json!({ - "head_start": "hs", - "head_end": "he", - "body_start": "bs", - "body_end": "be", - "boundary_start:0": "b0s", - "boundary_end:0": "b0e", - "streaming_root:forged": "root", - }); - let mut parser = HtmlParser::new(); - parser.parse("index.html", source).expect("parse fixture"); - let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records())); - let expected = "hs|he|bs|be|b0s|b0e|root"; - - let mut ordinary = TestWriter::new(); - WebUIHandler::new() - .render( - &protocol, - &state, - &RenderOptions::new("index.html", "/"), - &mut ordinary, - ) - .unwrap(); - assert_eq!(ordinary.get_content(), expected); - - let mut streaming = FlushTestWriter::default(); - WebUIHandler::new() - .render_streaming( - &protocol, - &state, - &RenderOptions::new("index.html", "/"), - &mut streaming, - ) - .unwrap(); - assert!(streaming.output.contains("hs|he|bs|be|b0s|b0e|root")); - assert!(!streaming.output.contains("")); - - let mut ordinary = TestWriter::new(); - WebUIHandler::new() - .render( - &protocol, - &test_json!({}), - &RenderOptions::new("index.html", "/"), - &mut ordinary, - ) - .unwrap(); - assert_eq!( - ordinary.get_content(), - "" - ); - } - - /// Build a streaming protocol with one boundary per `hosts` entry. Each - /// boundary wraps a component host whose opening tag is split so the - /// compiler-owned `streaming_root:` signal (optionally emitted) lands - /// inside the tag, exactly as `HtmlParser` produces. Components `comp-a` and - /// `comp-b` carry disjoint templates and disjoint hydration keys. - fn disjoint_streaming_protocol_ext(hosts: &[&str], emit_root_signal: bool) -> Protocol { - let mut fragments = HashMap::new(); - let mut entry = vec![ - WebUIFragment::raw(""), - structural_fragment("head_start"), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - ]; - for (sequence, host) in hosts.iter().enumerate() { - entry.push(structural_fragment(format!("boundary_start:{sequence}"))); - entry.push(WebUIFragment::raw(format!("<{host}"))); - if emit_root_signal { - entry.push(structural_fragment(format!("streaming_root:{host}"))); - } - entry.push(WebUIFragment::raw(">")); - entry.push(WebUIFragment::component(*host)); - entry.push(WebUIFragment::raw(format!(""))); - entry.push(structural_fragment(format!("boundary_end:{sequence}"))); - } - entry.push(structural_fragment("body_end")); - entry.push(WebUIFragment::raw("")); - fragments.insert("index.html".to_string(), FragmentList { fragments: entry }); - fragments.insert( - "comp-a".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("A")], - }, - ); - fragments.insert( - "comp-b".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("B")], - }, - ); - - let mut document = WebUIProtocol::new(fragments); - document.initial_state_strategy = InitialStateStrategy::Components as i32; - document.streaming_boundaries.insert( - "index.html".to_string(), - webui_protocol::StreamingBoundaryList { - names: (0..hosts.len()) - .map(|index| format!("boundary-{index}")) - .collect(), - }, - ); - document.components.insert( - "comp-a".to_string(), - webui_protocol::ComponentData { - template_json: r#"{"h":"A","th":1}"#.to_string(), - hydration_mode: StateProjectionMode::Keys as i32, - hydration_keys: vec!["a_count".to_string()], - ..Default::default() - }, - ); - document.components.insert( - "comp-b".to_string(), - webui_protocol::ComponentData { - template_json: r#"{"h":"B","th":1}"#.to_string(), - hydration_mode: StateProjectionMode::Keys as i32, - hydration_keys: vec!["b_count".to_string()], - ..Default::default() - }, - ); - Protocol::new(document) - } - - fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol { - disjoint_streaming_protocol_ext(hosts, true) - } - - fn streaming_plan_validation_protocol(signals: &[&str], names: &[&str]) -> Protocol { - let mut entry = vec![ - WebUIFragment::raw(""), - structural_fragment("head_start"), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - ]; - entry.extend(signals.iter().map(structural_fragment)); - entry.extend([ - structural_fragment("body_end"), - WebUIFragment::raw(""), - ]); - - let mut document = WebUIProtocol::new(HashMap::from([( - "index.html".to_string(), - FragmentList { fragments: entry }, - )])); - document.streaming_boundaries.insert( - "index.html".to_string(), - webui_protocol::StreamingBoundaryList { - names: names.iter().map(|name| (*name).to_string()).collect(), - }, - ); - Protocol::new(document) - } - - #[test] - fn streaming_response_rejects_cached_malformed_boundary_plan_before_writing() { - let protocol = - streaming_plan_validation_protocol(&["boundary_start:0", "boundary_end:1"], &["one"]); - let handler = WebUIHandler::new(); - let mut writer = FlushTestWriter::default(); - let options = RenderOptions::new("index.html", "/"); - - let error = match handler.stream_response(&protocol, &options, &mut writer) { - Ok(_) => panic!("malformed boundary plan unexpectedly opened a response"), - Err(error) => error, - }; - - assert!( - matches!(error, HandlerError::StreamingBoundary(_)), - "error: {error}" - ); - assert!(writer.output.is_empty()); - assert!(writer.flushes.is_empty()); - } - - #[test] - fn streaming_response_rejects_boundary_name_count_mismatch_before_writing() { - let protocol = streaming_plan_validation_protocol( - &["boundary_start:0", "boundary_end:0"], - &["one", "two"], - ); - let handler = WebUIHandler::new(); - let mut writer = FlushTestWriter::default(); - let options = RenderOptions::new("index.html", "/"); - - let error = match handler.stream_response(&protocol, &options, &mut writer) { - Ok(_) => panic!("mismatched boundary names unexpectedly opened a response"), - Err(error) => error, - }; - - assert!( - matches!(error, HandlerError::Invariant(_)), - "error: {error}" - ); - assert!(writer.output.is_empty()); - assert!(writer.flushes.is_empty()); - } - - #[test] - fn streaming_response_interleaves_projected_updates_with_later_boundaries() { - let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b"]); - let handler = WebUIHandler::with_plugin(|| { - Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()) - }); - let mut writer = FlushTestWriter::default(); - let options = RenderOptions::new("index.html", "/"); - let mut response = handler - .stream_response(&protocol, &options, &mut writer) - .unwrap(); - let first = response.boundary("boundary-0").unwrap(); - let second = response.boundary("boundary-1").unwrap(); - - response.write_shell(&test_json!({})).unwrap(); - response - .write_boundary( - first, - &test_json!({ "a_count": 1, "serverOnly": "secret" }), - BoundaryMode::Updatable, - ) - .unwrap(); - response - .update(first, &test_json!({ "a_count": 7, "serverOnly": "secret" })) - .unwrap(); - response - .write_boundary( - second, - &test_json!({ "b_count": 2, "serverOnly": "secret" }), - BoundaryMode::Final, - ) - .unwrap(); - response.finish(&test_json!({})).unwrap(); - - assert_eq!(writer.flushes.len(), 5); - assert!(writer.output.contains(r#"[1,0,1,0,{"#)); - assert!(writer.output.contains(r#""state":{"a_count":1}"#)); - assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":7}]"#)); - assert!(writer.output.contains(r#"[1,2,0,1,{"#)); - assert!(writer.output.contains(r#""state":{"b_count":2}"#)); - assert!(writer.output.contains("[1,3,3,0,{}]")); - assert_eq!(writer.output.matches("serverOnly").count(), 0); - - let update_start = writer.output.find("[1,1,2,0,").unwrap(); - let update_end = writer.output[update_start..] - .find("") - .map(|offset| update_start + offset) - .unwrap(); - let update = &writer.output[update_start..update_end]; - assert!(!update.contains("inventory")); - assert!(!update.contains("templates")); - } - - #[test] - fn owned_streaming_session_matches_borrowed_response_bytes() { - let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"])); - let handler = Arc::new(WebUIHandler::with_plugin(|| { - Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()) - })); - - let mut writer = FlushTestWriter::default(); - let options = RenderOptions::new("index.html", "/"); - let mut response = handler - .stream_response(&protocol, &options, &mut writer) - .unwrap(); - let first = response.boundary("boundary-0").unwrap(); - let second = response.boundary("boundary-1").unwrap(); - response.write_shell(&test_json!({})).unwrap(); - response - .write_boundary( - first, - &test_json!({ "a_count": 1 }), - BoundaryMode::Updatable, - ) - .unwrap(); - response - .update(first, &test_json!({ "a_count": 7 })) - .unwrap(); - response - .write_boundary(second, &test_json!({ "b_count": 2 }), BoundaryMode::Final) - .unwrap(); - response.finish(&test_json!({})).unwrap(); - - let mut session = StreamingSession::new( - Arc::clone(&handler), - Arc::clone(&protocol), - SessionOptions::new("index.html", "/"), - ) - .unwrap(); - let session_first = session.boundary("boundary-0").unwrap(); - let session_second = session.boundary("boundary-1").unwrap(); - assert_eq!(session_first, first); - assert_eq!(session_second, second); - assert_eq!(session.boundary_count(), 2); - - let chunks: Vec> = vec![ - session.write_shell(&test_json!({})).unwrap(), - session - .write_boundary( - session_first, - &test_json!({ "a_count": 1 }), - BoundaryMode::Updatable, - ) - .unwrap(), - session - .update(session_first, &test_json!({ "a_count": 7 })) - .unwrap(), - session - .write_boundary( - session_second, - &test_json!({ "b_count": 2 }), - BoundaryMode::Final, - ) - .unwrap(), - session.finish(&test_json!({})).unwrap(), - ]; - - // One chunk per host call, matching the borrowed path's flush count. - assert_eq!(chunks.len(), writer.flushes.len()); - assert!(session.is_finished()); - let joined = String::from_utf8(chunks.concat()).unwrap(); - assert_eq!(joined, writer.output); - } - - #[test] - fn owned_streaming_session_rejects_use_after_finish() { - let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"])); - let handler = Arc::new(WebUIHandler::new()); - let mut session = - StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/")) - .unwrap(); - let boundary = session.boundary("boundary-0").unwrap(); - session.write_shell(&test_json!({})).unwrap(); - session - .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final) - .unwrap(); - session.finish(&test_json!({})).unwrap(); - - let error = session.write_shell(&test_json!({})).unwrap_err(); - assert!(error.to_string().contains("already finished")); - assert!(session.finish(&test_json!({})).is_err()); - } - - #[test] - fn owned_streaming_session_surfaces_unknown_boundary_names() { - let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"])); - let handler = Arc::new(WebUIHandler::new()); - let session = - StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/")) - .unwrap(); - let error = session.boundary("boundary-O").unwrap_err().to_string(); - assert!(error.contains("boundary-0")); - } - - #[test] - fn owned_streaming_session_stays_usable_after_a_rejected_call() { - let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"])); - let handler = Arc::new(WebUIHandler::new()); - let mut session = - StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/")) - .unwrap(); - let boundary = session.boundary("boundary-0").unwrap(); - session.write_shell(&test_json!({})).unwrap(); - // Rejected before any byte is written, so the session is not poisoned. - assert!(session.update(boundary, &test_json!({ "a": 1 })).is_err()); - assert!(!session.is_finished()); - session - .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final) - .unwrap(); - session.finish(&test_json!({})).unwrap(); - } - - #[test] - fn owned_streaming_session_recovers_from_a_rejected_finish() { - let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"])); - let handler = Arc::new(WebUIHandler::new()); - let mut session = - StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/")) - .unwrap(); - let first = session.boundary("boundary-0").unwrap(); - let second = session.boundary("boundary-1").unwrap(); - session.write_shell(&test_json!({})).unwrap(); - session - .write_boundary(first, &test_json!({ "a_count": 1 }), BoundaryMode::Final) - .unwrap(); - - // Rejected before any byte is written, so the response must survive. - let error = session.finish(&test_json!({})).unwrap_err(); - assert!( - error - .to_string() - .contains("every boundary must be committed"), - "unexpected error: {error}" - ); - assert!(!session.is_finished()); - - session - .write_boundary(second, &test_json!({ "b_count": 1 }), BoundaryMode::Final) - .unwrap(); - let tail = session.finish(&test_json!({})).unwrap(); - assert!(!tail.is_empty()); - assert!(session.is_finished()); - } - - #[test] - fn owned_streaming_session_rejects_finish_before_the_shell() { - let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"])); - let handler = Arc::new(WebUIHandler::new()); - let mut session = - StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/")) - .unwrap(); - let boundary = session.boundary("boundary-0").unwrap(); - - let error = session.finish(&test_json!({})).unwrap_err(); - assert!( - error.to_string().contains("write_shell must be called"), - "unexpected error: {error}" - ); - assert!(!session.is_finished()); - - session.write_shell(&test_json!({})).unwrap(); - session - .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final) - .unwrap(); - session.finish(&test_json!({})).unwrap(); - assert!(session.is_finished()); - } - - #[test] - fn streaming_response_is_poisoned_after_partial_boundary_flush_failure() { - let protocol = disjoint_streaming_protocol(&["comp-a"]); - let handler = WebUIHandler::new(); - let mut writer = FlushTestWriter { - fail_flush_at: Some(1), - ..FlushTestWriter::default() - }; - let options = RenderOptions::new("index.html", "/"); - let mut response = handler - .stream_response(&protocol, &options, &mut writer) - .unwrap(); - let boundary = response.boundary("boundary-0").unwrap(); - response.write_shell(&test_json!({})).unwrap(); - - let first_error = response - .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final) - .unwrap_err(); - assert!(matches!(first_error, HandlerError::ClientDisconnected)); - - let retry_error = response - .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final) - .unwrap_err(); - assert!( - retry_error - .to_string() - .contains("unusable after a previous render or transport failure"), - "error: {retry_error}" - ); - - drop(response); - assert_eq!(writer.output.matches("").count(), 1); - assert_eq!(writer.flushes.len(), 1); - assert!(!writer.ended); - } - - #[test] - fn streaming_response_rejects_updates_to_final_boundaries() { - let protocol = disjoint_streaming_protocol(&["comp-a"]); - let handler = WebUIHandler::new(); - let mut writer = FlushTestWriter::default(); - let options = RenderOptions::new("index.html", "/"); - let mut response = handler - .stream_response(&protocol, &options, &mut writer) - .unwrap(); - let boundary = response.boundary("boundary-0").unwrap(); - response.write_shell(&test_json!({})).unwrap(); - response - .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final) - .unwrap(); - - let error = response - .update(boundary, &test_json!({ "a_count": 2 })) - .unwrap_err(); - assert!( - error.to_string().contains("committed as final"), - "error: {error}" - ); - } - - #[test] - fn streaming_response_rejects_non_object_updates_before_writing() { - let protocol = disjoint_streaming_protocol(&["comp-a"]); - let handler = WebUIHandler::new(); - let mut writer = FlushTestWriter::default(); - let options = RenderOptions::new("index.html", "/"); - let mut response = handler - .stream_response(&protocol, &options, &mut writer) - .unwrap(); - let boundary = response.boundary("boundary-0").unwrap(); - response.write_shell(&test_json!({})).unwrap(); - response - .write_boundary( - boundary, - &test_json!({ "a_count": 1 }), - BoundaryMode::Updatable, - ) - .unwrap(); - let error = response - .update(boundary, &test_json!("invalid")) - .unwrap_err(); - assert!(error.to_string().contains("JSON object"), "error: {error}"); - - response - .update(boundary, &test_json!({ "a_count": 2 })) - .unwrap(); - response.finish(&test_json!({})).unwrap(); - assert_eq!(writer.flushes.len(), 4); - assert_eq!(writer.output.matches("data-webui-boundary").count(), 3); - assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":2}]"#)); - } - - #[test] - fn streaming_response_unknown_boundary_suggests_valid_name() { - let protocol = disjoint_streaming_protocol(&["comp-a"]); - let handler = WebUIHandler::new(); - let mut writer = FlushTestWriter::default(); - let options = RenderOptions::new("index.html", "/"); - let response = handler - .stream_response(&protocol, &options, &mut writer) - .unwrap(); - - let error = response.boundary("boundry-0").unwrap_err(); - assert!( - error.to_string().contains("did you mean `boundary-0`?"), - "error: {error}" - ); - } - - fn streaming_root_validation_protocol( - mut host_fragments: Vec, - tail: Vec, - ) -> Protocol { - let mut entry = vec![ - WebUIFragment::raw(""), - structural_fragment("head_start"), - structural_fragment("head_end"), - WebUIFragment::raw(""), - structural_fragment("body_start"), - structural_fragment("boundary_start:0"), - ]; - entry.append(&mut host_fragments); - entry.extend(tail); - let fragments = HashMap::from([ - ("index.html".to_string(), FragmentList { fragments: entry }), - ( - "comp-a".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("A")], - }, - ), - ( - "comp-b".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("B")], - }, - ), - ]); - Protocol::new(WebUIProtocol::new(fragments)) - } - - fn completed_streaming_tail() -> Vec { - vec![ - WebUIFragment::raw(""), - structural_fragment("boundary_end:0"), - structural_fragment("body_end"), - WebUIFragment::raw(""), - ] - } - - fn assert_streaming_root_error( - protocol: &Protocol, - expected_signal: &str, - expected_reason: &str, - ) { - let mut writer = FlushTestWriter::default(); - let result = WebUIHandler::new().render_streaming( - protocol, - &test_json!({}), - &RenderOptions::new("index.html", "/"), - &mut writer, - ); - match result { - Err(HandlerError::StreamingBoundary(error)) => { - assert_eq!(error.signal, expected_signal); - assert!( - error.reason.contains(expected_reason), - "reason: {}", - error.reason - ); - } - other => panic!("expected streaming-root rejection, got {other:?}"), - } - } - - #[test] - fn streaming_root_signal_injects_data_ws_inside_boundary() { - let protocol = disjoint_streaming_protocol(&["comp-a"]); - let handler = WebUIHandler::with_plugin(|| { - Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()) - }); - let mut writer = FlushTestWriter::default(); - handler - .render_streaming( - &protocol, - &test_json!({ "a_count": 1 }), - &RenderOptions::new("index.html", "/"), - &mut writer, - ) - .unwrap(); - // The parser-owned signal is consumed to inject exactly ` data-ws` - // inside the host's opening tag, before the custom element upgrades. - assert!( - writer.output.contains(""), - "streamed host must carry data-ws: {}", - writer.output - ); - } - - #[test] - fn streaming_root_signal_preserves_ordinary_output_bytes() { - // Ordinary rendering ignores `streaming_root` byte-for-byte: identical - // output with and without the signal, and never a `data-ws` attribute. - let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true); - let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false); - let state = test_json!({ "a_count": 1, "b_count": 2 }); - let plugin = || { - WebUIHandler::with_plugin( - || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()), - ) - }; - let mut with_writer = TestWriter::new(); - let mut without_writer = TestWriter::new(); - plugin() - .render( - &with_signal, - &state, - &RenderOptions::new("index.html", "/"), - &mut with_writer, - ) - .unwrap(); - plugin() - .render( - &without_signal, - &state, - &RenderOptions::new("index.html", "/"), - &mut without_writer, - ) - .unwrap(); - // The rendered DOM (everything up to the inert data block) is - // deterministic and is where a leaked `data-ws` would appear. The - // ordinary template map is HashSet-ordered, so compare the DOM prefix - // for byte identity rather than the whole document. - let dom_prefix = |content: &str| -> String { - content - .split_once(r#"")?; + if let Some(importmap) = + crate::css_module::build_importmap_tag_batch(&deferred_css_modules, context.nonce) + { + context.writer.write(&importmap)?; + } + if template_payloads.is_none() { + if let Some(plugin) = context.plugin.as_ref() { + plugin.emit_templates_slice( + context.protocol, + &new_template_tags, + context.nonce, + context.writer, + )?; + } + } if let Some(plugin) = context.plugin.as_ref() { plugin.emit_bootstrap_extension_payloads(payloads, context.nonce, context.writer)?; } context.writer.write("")?; flush_streaming_transport(context)?; - if let Some(streaming) = context.streaming.as_mut() { - streaming.bootstrap_sent = true; - if updatable { - let target = boundary_id; - if streaming.update_plans.len() <= target { - streaming.update_plans.resize_with(target + 1, || None); - } - streaming.update_plans[target] = Some(StateUpdatePlan { - requires_full_state, - keys: if requires_full_state { - Vec::new() - } else { - state_key_scratch.iter().copied().map(Box::from).collect() - }, - }); + + let target = usize::try_from(record.target()) + .map_err(|_| invalid_record_target_error(record.target()))?; + if let RangeRecord::Boundary { + updatable: true, .. + } = record + { + let streaming = streaming_state(context)?; + if streaming.update_plans.len() <= target { + streaming.update_plans.resize_with(target + 1, || None); } - state_key_scratch.clear(); - streaming.state_key_scratch = state_key_scratch; - // Reset the exact-capture buffers for the next checkpoint, retaining - // their capacity. - let mut checkpoint_tags = checkpoint_tags; - checkpoint_tags.clear(); - streaming.checkpoint_tags = checkpoint_tags; - let mut checkpoint_names = checkpoint_names; - checkpoint_names.clear(); - streaming.checkpoint_name_scratch = checkpoint_names; - streaming.checkpoint_walk_roots.clear(); - new_template_tags.clear(); - streaming.template_tag_scratch = new_template_tags; - css_hrefs.clear(); - streaming.css_href_scratch = css_hrefs; - style_specs.clear(); - streaming.style_spec_scratch = style_specs; - streaming.checkpoint_seen.fill(0); - streaming.checkpoint_needs_expansion = false; + streaming.update_plans[target] = Some(StateUpdatePlan { + requires_full_state, + keys: if requires_full_state { + Vec::new() + } else { + state_key_scratch.iter().copied().map(Box::from).collect() + }, + }); } + finish_capture( + context, + CapturedBuffers { + checkpoint_tags, + template_tags: new_template_tags, + state_keys: state_key_scratch, + css_hrefs, + style_specs, + }, + ); + streaming_state(context)?.bootstrap_sent = true; Ok(()) } @@ -280,21 +281,11 @@ impl WebUIHandler { record_sequence: usize, context: &mut WebUIProcessContext, ) -> Result<()> { + write_script_open(context)?; + write_record_header(context.writer, record_sequence, RECORD_KIND_TERMINAL, 0)?; context .writer - .write("")?; + .write("{}]")?; flush_streaming_transport(context) } @@ -309,28 +300,20 @@ impl WebUIHandler { } let Some(plan) = context .streaming - .as_ref() - .and_then(|streaming| streaming.update_plans.get(boundary_id)) - .and_then(Option::as_ref) + .as_mut() + .and_then(|streaming| streaming.update_plans.get_mut(boundary_id)) + .and_then(Option::take) else { return Err(super::error::boundary_not_updatable_error(boundary_id)); }; - context - .writer - .write("")?; - flush_streaming_transport(context) + flush_streaming_transport(context)?; + let Some(slot) = context + .streaming + .as_mut() + .and_then(|streaming| streaming.update_plans.get_mut(boundary_id)) + else { + return Err(HandlerError::Invariant( + "streaming update projection slot disappeared".to_string(), + )); + }; + *slot = Some(plan); + Ok(()) + } +} + +fn write_record_open( + context: &mut WebUIProcessContext<'_, '_, '_>, + kind: usize, + target: u32, +) -> Result<()> { + let record_sequence = streaming_state(context)?.next_record_sequence; + let target = usize::try_from(target).map_err(|_| invalid_record_target_error(target))?; + write_script_open(context)?; + write_record_header(context.writer, record_sequence, kind, target) +} + +/// Emit `>[,,,,` in one writer call. +fn write_record_header( + writer: &mut dyn crate::ResponseWriter, + record_sequence: usize, + kind: usize, + target: usize, +) -> Result<()> { + let mut buffer = MarkerBuffer::new(); + buffer.push_str(">[")?; + buffer.push_usize(STREAMING_PROTOCOL_VERSION)?; + buffer.push_str(",")?; + buffer.push_usize(record_sequence)?; + buffer.push_str(",")?; + buffer.push_usize(kind)?; + buffer.push_str(",")?; + buffer.push_usize(target)?; + buffer.push_str(",")?; + buffer.flush_to(writer) +} + +fn write_script_open(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> { + context + .writer + .write("")); +} + +#[test] +fn false_if_discovers_no_occurrence_and_boundary_free_start_completes() { + let protocol = parsed_protocol( + &document( + r#"

no

done

"#, + ), + &[], + ); + let mut session = new_session(protocol, "/"); + let step = session.start(&test_json!({ "show": false })).unwrap(); + + assert!(step.done); + assert!(step.boundary.is_none()); + let html = String::from_utf8(step.bytes).unwrap(); + assert!(!html.contains(""#)); + assert!(hidden_html.contains( + r#" - - - + + +``` - - +```html + +
+ + - +
{{slowFeed}}
+
``` -- `name` is required, non-empty, static, and unique in the entry template. It +- `name` is required, non-empty, static, and unique within its entry or + component owner. It cannot contain a {{binding}}. -- Author boundaries only in the outermost entry template. They cannot appear - inside reusable components, route-shell components, ``, ``, - ``, or another boundary. An entry-level boundary can fully wrap those - complete scopes. -- Every registered WebUI component rendered in streaming mode must be inside - an explicit boundary. Native HTML and unregistered static tail markup may - remain outside. +- Boundaries may appear inside reusable components, true `` paths, each + `` iteration, and selected route content. Authored boundaries may not + contain another authored boundary directly or transitively. +- A declaration that can repeat requires `key`; it must resolve to a unique live + string or finite number. - Never author ``. It is reserved generated runtime output. - Put the async application module in `` before boundary content and import `@microsoft/webui-framework/streaming.js` before component registration modules. -- Boundary HTML commits strictly in document order. For slow backend state, - commit a complete component shell as `BoundaryMode::Updatable`, then call - `StreamingResponse::update` when data resolves. Updates interleave on the - original response and call `setState()` without rerunning hydration or - `hydratedCallback()`. -- Resolve free-form names once with `StreamingResponse::boundary`; hot writes - use integer `BoundaryId` handles. Call `write_shell`, ordered - `write_boundary`, interleavable `update`, then `finish`. +- `start(state)` and `resume(instanceId, state, mode)` return the next runtime + descriptor `{ instanceId, declarationId, owner, name, key }` plus `done`. + The done step already includes terminal and parent tail bytes. +- `update(instanceId, patch)` accepts only a committed updatable occurrence. + It calls `setState()` without inserting markup, rerunning hydration, or + rerunning `hydratedCallback()`. +- State resolution across a suspension is lexical locals, resume state, then + the frozen projected parent state. +- A component-local boundary uses generated parent spans. Its early marked child + may hydrate before the opaque parent tail in light or shadow DOM. - `webui:boundary-hydrated` is emitted only when `window.__WEBUI_STREAMING_DEBUG__ === true`; its `detail.kind` is - `checkpoint`, `update`, or `terminal`. Every commit also emits an + `checkpoint`, `span`, `update`, or `terminal`. Every commit also emits an unconditional `performance.mark()` (`webui:boundary:`, - `webui:boundary::update`, `webui:streaming:terminal`) that tooling can - read retroactively without a listener. + `webui:boundary::update`, `webui:span:`, + `webui:streaming:terminal`) that tooling can read retroactively. `webui:hydration-complete` fires only after the terminal record and eager pending hydration work complete. Visibility-deferred lazy roots do not keep this one-shot startup event open. @@ -779,13 +783,10 @@ directive is removed at compile time and emits no application DOM wrapper. Malformed directives use stable diagnostics: `missing-boundary-name`, `invalid-boundary-name`, -`duplicate-boundary-name`, `nested-boundary`, `boundary-crosses-scope`, and -`authored-webui-hydrate`. - -Dynamic append streams, out-of-order replacement, router-stream reuse, direct -Node/FFI/WASM response sessions, and declarative partial updates are not part of -this contract. Node can drive the CLI bridge, but the CLI remains the Rust -session and transport owner. +`duplicate-boundary-name`, `missing-boundary-key`, +`invalid-boundary-key`, `nested-boundary`, `boundary-crosses-scope`, and +`authored-webui-hydrate`. Malformed browser records fail closed and release +discoverable deferred state within fixed bounds. ### Lazy component mounting @@ -1205,31 +1206,36 @@ renders before spawning it, and configure the transport's flush timeout. ```rust let mut page = handler.stream_response(&protocol, &options, &mut writer)?; -let critical = page.boundary("critical-composer")?; -page.write_shell(&state)?; -page.write_boundary(critical, &state, BoundaryMode::Final)?; -page.finish(&state)?; +let mut step = page.start(&initial_state)?; +while !step.done { + let boundary = step.boundary.as_ref().ok_or_else(missing_boundary)?; + let state = load_state(&boundary.owner, &boundary.name, boundary.key.as_ref())?; + step = page.resume(boundary.instance_id, &state, BoundaryMode::Final)?; +} ``` With `webui serve --api-port`, a Node or other HTTP backend can return newline-delimited control records instead: ```text -{"type":"shell","version":1,"state":{...}} -{"type":"boundary","name":"critical-composer"} -{"type":"finish"} +{"type":"start","version":2,"state":{"query":""}} +{"type":"resume","boundary":{"owner":"ntp-page","name":"search-ready"},"state":{"query":""},"mode":"updatable"} +{"type":"update","boundary":{"owner":"ntp-page","name":"search-ready"},"state":{"query":"webui"}} ``` Honor HTTP write backpressure and cap concurrent streams. The CLI uses a -capacity-one command channel, resolves boundary names once, and keeps the -compiled protocol plus browser-facing bytes in Rust. Returning JSON keeps the -buffered state path. +capacity-one command channel and matches each nested `boundary` target by +descriptor `owner`, `name`, and optional `key` (plus optional +`declarationId`). It keeps response-local instance IDs, the compiled protocol, +and browser-facing bytes in Rust. The control stream ends after the start or +resume that completes the response. Returning JSON keeps the buffered state +path. If the backend refuses a stream request (non-success status such as a `503` -from its own concurrency cap), no boundary was ever sent, so `webui serve` logs +from its own concurrency cap), no response bytes were sent, so `webui serve` logs one warning and renders the page from fallback state rather than replacing the app with the upstream error body. A failure *after* the stream is live still -fails the response, because boundaries already flushed cannot be rewound. +fails the response, because bytes already flushed cannot be rewound. Equivalent APIs exist for WebAssembly, Python (native `microsoft-webui` package), Go (cgo), and C#. For `Router.ensureLoaded()`, expose diff --git a/docs/guide/cli/index.md b/docs/guide/cli/index.md index e25562d23..a5139629b 100644 --- a/docs/guide/cli/index.md +++ b/docs/guide/cli/index.md @@ -299,21 +299,29 @@ For progressive HTML, the server sends backend can return a versioned NDJSON control stream: ```text -{"type":"shell","version":1,"state":{"feedBatch1":[]}} -{"type":"boundary","name":"weather-shell","mode":"updatable"} -{"type":"boundary","name":"composer-ready"} -{"type":"update","name":"weather-shell","state":{"status":"ready"}} -{"type":"finish"} +{"type":"start","version":2,"state":{"query":""}} +{"type":"resume","boundary":{"owner":"ntp-page","name":"search-ready"},"state":{"query":""},"mode":"updatable"} +{"type":"update","boundary":{"owner":"ntp-page","name":"search-ready"},"state":{"query":"webui"}} ``` -The CLI keeps the compiled protocol and browser transport. Boundary names -resolve once to integer handles, a capacity-one command channel preserves -backpressure, and each record is capped at 2,000,000 bytes. Omitted boundary or -finish state reuses the shell state. Before HTTP 200, initial shell chunks are -staged without copying up to a 4,000,000-byte limit; larger shells return an -error. Dropping the browser response cancels the backend stream. The backend -must honor its HTTP writer's backpressure signal and cap concurrent streams. -Returning JSON retains the ordinary buffered behavior. See +`start` appears once. It renders until the first runtime occurrence or terminal. +Each `resume.boundary` must match the descriptor currently returned by WebUI +using `owner`, `name`, and `key`; omit `key` only when that descriptor has none. +An optional `declarationId` can tighten the match. Resume `state` is passed to +that occurrence and `mode` is `final` by default or `updatable`. +`update.boundary` uses the same identity to target one previously committed +updatable occurrence and requires object-valued `state`. + +The stream ends after the `start` or `resume` that completes the response. The +final bytes already contain the terminal record, so there is no separate end +command. + +The CLI owns response-local instance IDs and the browser transport. A +capacity-one command channel preserves backpressure, and each record is capped +at 2,000,000 bytes. Before HTTP 200, bytes from `start` are staged without +copying up to a 4,000,000-byte limit. Dropping the browser response cancels the +backend stream. The backend must honor its HTTP writer's backpressure signal and +cap concurrent streams. Returning JSON retains ordinary buffered behavior. See [``](/guide/concepts/directives/boundary) and `examples/app/streaming`. @@ -323,7 +331,7 @@ cap, `webui serve` logs one warning and still renders the page from fallback state. A refused request never started a stream, so it degrades the same way an unreachable backend does instead of replacing your app with the upstream error body. A failure that occurs *after* the stream is live still fails the response, -because boundaries already sent to the browser cannot be rewound. +because bytes already sent to the browser cannot be rewound. After generated assets and `--servedir` files miss, route fallback is based on the `Accept` header. Requests that explicitly accept `text/html` or diff --git a/docs/guide/concepts/directives/boundary.md b/docs/guide/concepts/directives/boundary.md index 3bc0266c1..483c733b2 100644 --- a/docs/guide/concepts/directives/boundary.md +++ b/docs/guide/concepts/directives/boundary.md @@ -1,171 +1,173 @@ # Streaming Boundaries -`` splits an entry page into complete regions that WebUI can flush -and hydrate before the full response arrives. +`` marks a complete region that WebUI can render, flush, and hydrate +before the rest of the response arrives. It is a compile-time directive and +does not create a DOM wrapper. -A boundary is a compile-time directive, not a DOM element. WebUI removes it -from the rendered HTML and streams its children in normal document order. +## Put boundaries where readiness changes -## 1. Author the checkpoints +An entry can contain one component while that component owns the useful +checkpoint: -Put boundaries around independently useful page regions, ordered by priority: +```html + + + + + + + + + +``` ```html - - - - - - - + +
+

{{title}}

- - + + - - - - +
{{slowFeed}}
+
``` -Import the streaming coordinator before component registration modules: +The server discovers `search-ready` while rendering ``. It can commit +and hydrate `` before the remaining parent content arrives. WebUI +creates the required parent span automatically. Do not add an outer boundary +around ``. + +Load the coordinator before component registrations: ```typescript import '@microsoft/webui-framework/streaming.js'; -import './weather-panel/weather-panel.js'; -import './message-composer/message-composer.js'; -import './activity-feed/activity-feed.js'; +import './ntp-page.js'; +import './search-box.js'; ``` -The application module must be `async` and appear in `` before boundary -content so early checkpoints can hydrate while the document is still parsing. +The application entry must load early with `async`, or an equivalent +non-blocking strategy, in ``. -## 2. Choose how the server drives the response +## Runtime occurrences -| Need | Use | -|---|---| -| Render all boundaries immediately with one state value | Rust `WebUIHandler::render_streaming` | -| Control when each boundary commits or send later state | Rust `WebUIHandler::stream_response` | -| Let an API backend control readiness while `webui serve` owns rendering | `webui serve --api-port` | -| Stream directly from Node, WASM, .NET, or C | That handler's streaming session API | +A declaration becomes an occurrence only when rendering reaches it. Boundaries +are allowed in: -All paths produce the same ordered browser protocol. +- entry templates +- reusable component templates +- true `` branches +- each `` iteration +- selected route content and outlets -## 3. Drive a host-controlled response +False conditions, empty loops, and unselected routes produce no occurrence. +The host receives the next occurrence as: -Resolve authored names once to integer boundary handles. Then use these four -operations: +```text +{ instanceId, declarationId, owner, name, key } +``` -| Operation | Purpose | -|---|---| -| `write_shell(state)` | Flush everything before the first boundary | -| `write_boundary(id, state, mode)` | Render and flush the next boundary | -| `update(id, state)` | Patch an earlier boundary committed as `Updatable` | -| `finish(state)` | Render the tail, emit the terminal record, and end the response | +- `instanceId` identifies this occurrence in one response. +- `declarationId` identifies the compiled declaration. +- `owner` is the entry or component template that authored it. +- `name` is unique only within that owner. +- `key` identifies a repeated occurrence. -The required order is: +Use `owner`, `name`, and `key` to decide what state to load. Pass `instanceId` +back to the session. -```text -write_shell -> write_boundary* -> finish +### Repeated boundaries need keys + +```html + + + + + ``` -`update` may run between boundary writes, but only after its target has -committed as updatable. +A repeated declaration must have a key. At runtime the key must resolve to a +string or finite JSON number, and live occurrences of that declaration must +have unique keys. This also applies when a component containing a boundary is +rendered more than once. -```rust -use webui::{BoundaryMode, RenderOptions, WebUIHandler}; +## Drive the response + +Every host binding exposes the same three operations: + +| Operation | Result | +|---|---| +| `start(state)` | Bytes through the first occurrence, or a completed step | +| `resume(instanceId, state, mode)` | Commit that pending occurrence and continue | +| `update(instanceId, patch)` | State-only bytes for a committed updatable occurrence | + +`start` and `resume` return bytes, `done`, and the next optional descriptor. +When `done` is true, those bytes already include the parent tail, terminal +record, and document close. -let options = RenderOptions::new("index.html", "/"); +```rust let mut response = handler.stream_response(&protocol, &options, &mut writer)?; - -let weather = response.boundary("weather-shell")?; -let composer = response.boundary("composer-ready")?; -let feed = response.boundary("feed")?; - -response.write_shell(&page_state)?; -response.write_boundary( - weather, - &loading_weather, - BoundaryMode::Updatable, -)?; -response.write_boundary( - composer, - &composer_state, - BoundaryMode::Final, -)?; - -response.update(weather, &ready_weather)?; -response.write_boundary(feed, &feed_state, BoundaryMode::Final)?; -response.finish(&tail_state)?; +let mut step = response.start(&initial_state)?; + +while !step.done { + let boundary = step.boundary.as_ref().expect("pending descriptor"); + let state = load_state(&boundary.owner, &boundary.name, boundary.key.as_ref()); + step = response.resume( + boundary.instance_id, + &state, + BoundaryMode::Final, + )?; +} ``` -Boundary HTML always commits once in declaration order. Backend work may run -concurrently, but a later boundary cannot overtake an earlier one. +The example uses `expect` only for brevity. Production code should return an +error if an unfinished step has no descriptor. -### Final or updatable? +### Final or updatable -| Mode | Use it when | Browser retention | -|---|---|---| -| `Final` | The boundary needs no later server state | Releases boundary roots after hydration | -| `Updatable` | A complete shell should hydrate now and receive state later | Retains only successfully activated roots until `finish` | +| Mode | Use when | +|---|---| +| `Final` | No later server state is needed | +| `Updatable` | Complete HTML should hydrate now and accept state later | -Use `Final` by default. An update calls the component's normal `setState()` -path and never re-runs hydration or `hydratedCallback()`. If the component -module is still loading, WebUI hydrates the server-rendered DOM first, then -replays the latest queued patch through `setState()`. +An update is a shallow projected state patch. It uses the component's normal +reactive `setState()` path. It does not insert markup, replace DOM, or rerun +hydration or `hydratedCallback()`. -## 4. Drive streaming through `webui serve` +## State at a suspension -With `webui serve --api-port`, the API backend can return newline-delimited -control records: +WebUI freezes only the projected parent keys needed to continue, plus lexical +locals such as the current loop item and component attributes. Resume state +overlays that frozen parent state. Resolution order remains: -```text -{"type":"shell","version":1,"state":{"feed":[]}} -{"type":"boundary","name":"weather-shell","mode":"updatable"} -{"type":"boundary","name":"composer-ready"} -{"type":"update","name":"weather-shell","state":{"status":"ready"}} -{"type":"boundary","name":"feed"} -{"type":"finish"} -``` +1. lexical locals +2. state supplied to `resume` +3. frozen parent state -These records go from the backend to the CLI, not to the browser. The CLI -resolves names, renders the compiled template in Rust, and streams the resulting -HTML. See [`webui serve --api-port`](/guide/cli/) for limits and fallback -behavior. +This lets a loop body keep `item` while a host supplies fresh boundary data. ## Authoring rules -- `name` is required, static, non-empty, and unique in the entry template. -- Author boundaries only in the outermost entry template. -- Boundaries cannot nest or appear inside ``, ``, or ``. - They may wrap a complete directive scope. -- Do not place a boundary inside registered component host content, raw or - inert elements such as ` -
- - - -
- - - - + + +``` - - +```html + +
+

{{title}}

+ + - +
{{slowFeed}}
+
``` -Import the streaming entry before component registration modules: +Import the coordinator before registrations: ```typescript import '@microsoft/webui-framework/streaming.js'; -import './weather-panel/weather-panel.js'; -import './message-composer/message-composer.js'; -import './activity-feed/activity-feed.js'; +import './ntp-page.js'; +import './search-box.js'; ``` -The streaming entry installs the coordinator synchronously. It is separate from -the default framework entry so non-streaming applications do not download, -parse, or initialize streaming code. The application module must use `async`, -or an equivalent non-blocking loading strategy, in `` before the first -boundary. A normal module script is deferred until parsing completes and -defeats early hydration. The parser currently validates boundary syntax and -placement, but does not validate this script loading order. - -The server commits boundary HTML in document order. In this example the weather -shell commits first as an updatable boundary, so it never delays the critical -composer. The host starts forecast work concurrently and sends a projected state -record to the weather boundary whenever it resolves, including between feed -checkpoints. - -The state record uses the original open HTML response. It invokes component -reactivity without rerunning hydration or `hydratedCallback()`, and it does not -replace or relocate server markup. If the weather class is still downloading, -WebUI merges the patch into its pending activation state and activates once with -the newest values. This keeps the critical island's time to interactive -independent of the slow surface without a client fetch. - -See `examples/app/streaming` for a complete working version of this pattern. - -Every registered WebUI component rendered through `render_streaming` must be -inside an explicit boundary. Native HTML and unregistered static tail markup can -remain outside. This lets the handler mark each streamed SSR component before -custom-element upgrade and guarantees that a later checkpoint can activate it. -The checkpoint also includes metadata and projected state for descendants that -are reachable inside those roots but initially hidden by a false condition or -empty repeat. It does not include unrelated components rooted in later -boundaries, and its inventory marks only SSR roots that actually rendered. +The entry uses one ``. When traversal reaches its internal boundary, +WebUI pauses and returns a runtime descriptor to the host. Resuming that +occurrence commits ``, which can become interactive before the +remaining parent section arrives. Boundaries can also occur in true +conditions, loop iterations, and selected route content. ### Timing and lifecycle -When a component calls `.define()` before its streamed template metadata -arrives, WebUI delays the native custom-element definition. Browsers snapshot -`observedAttributes` at definition time, so defining early would permanently -lose template-derived attribute observation. When a boundary arrives before its -component module, it waits on one custom-element definition reaction per tag. -An undefined outer root is an activation barrier for its descendants. Once the -outer definition and metadata are ready, WebUI hydrates parent first, then -descendants, without waiting for `DOMContentLoaded`. +When a boundary pauses inside a component, WebUI generates a span around the +unfinished parent. The early child is compiler-marked to bypass exactly that +nearest parent barrier. Other descendants stay opaque until the parent span +completes. The same bounded traversal works in light DOM and across open shadow +roots and slots. + +When a component calls `.define()` before streamed template metadata arrives, +WebUI delays native definition because browsers snapshot `observedAttributes` +at definition time. When a checkpoint arrives first, undefined roots share one +definition waiter per tag. Parents hydrate before ordinary descendants. Use `hydratedCallback()` for setup that needs bindings, events, or `w-ref` references. It runs synchronously exactly once after the first successful @@ -268,17 +240,12 @@ WebUI dispatches these events on `window`: - `webui:boundary-hydrated` after each commit, only when `window.__WEBUI_STREAMING_DEBUG__ === true`. Its `CustomEvent.detail` contains - `{ sequence, terminal, kind }`, where `kind` is `"checkpoint"`, `"update"`, or - `"terminal"`. Sequence numbers are response order, not authored boundary - names. Keep this diagnostics flag off in production to avoid one event - allocation per commit. + `{ sequence, terminal, kind }`. Sequence numbers are response order, not + authored names. Keep this diagnostics flag off in production. - `webui:hydration-complete` once the empty terminal record has arrived and no - eager component or boundary remains pending. On a streaming page, it means the - complete response hydration lifecycle is done, not merely that the first - interactive boundary is ready. On ordinary parser startup, WebUI waits through - `DOMContentLoaded` and the first intersection result for each lazy root. - Initially visible roots finish before the event; roots classified as dormant - do not keep it open and do not redispatch it when they activate later. + eager component, checkpoint, generated span, definition waiter, or ancestor + barrier remains pending. It means the complete response lifecycle is done, + not merely that the first interactive child is ready. ### Measuring commits in production @@ -292,6 +259,7 @@ no listener: | --- | --- | | `webui:boundary:` | A checkpoint commits | | `webui:boundary::update` | A projected state update is applied | +| `webui:span:` | A generated parent span completes | | `webui:streaming:terminal` | The terminal record settles | Because marks sit in the performance timeline, they can be read at any later @@ -303,16 +271,15 @@ const commits = performance .filter((entry) => entry.name.startsWith("webui:")); ``` -`` is the integer boundary ID, not the authored name — name strings never -reach the response. The ID is the boundary's declaration index, so your build -manifest maps it back to the authored name offline. +Boundary `` values are response-local occurrence IDs, not declaration IDs +or authored names. Span IDs use a separate response-local namespace. ### Hydrating across several tasks By default the coordinator drains its queue in one pass, which reaches interactivity soonest. That assumes boundaries arrive spread across the -response. If an intermediary buffers and coalesces the response, they can all -arrive at once and hydrate in a single long task — exactly what streaming is +response. If an intermediary buffers and coalesces the response, records can all +arrive at once and hydrate in a single long task - exactly what streaming is meant to avoid. Set a millisecond budget to make the coordinator yield to the browser between @@ -327,30 +294,16 @@ the last boundary's interactivity for responsiveness during hydration, so leave it unset unless you have measured a long task. Record order and every correctness guarantee are unchanged. -After a checkpoint commits, WebUI removes its generated payload, sentinel, and -marker nodes, plus the temporary streamed-host identity. Final boundaries -release their root list immediately. Updatable boundaries retain only their root -references and latest shallow patch until the terminal record. Boundary-local -state is never copied into `window.__webui.state`. Applications should not query -or depend on generated scaffolding. - -At `body_end`, the handler emits one markerless empty terminal envelope: -`[1,nextSequence,3,0,{}]`. Its flush also commits any preceding native or static -tail HTML, but terminal records never repeat template metadata or state. A -truncated or malformed stream, or one exceeding a client work bound such as the -queued-boundary or marker-scan limit, logs an error, suppresses -`webui:hydration-complete`, and releases discoverable deferred state within -fixed bounds. Valid commits perform no document-wide scan; a bounded sweep is a -fatal-cleanup fallback only. - -The client trusts records past three checks, because the same WebUI version -wrote them: `JSON.parse` (which alone detects any truncation, since a cut-off -record is never valid JSON), a five-element array, and the envelope `version`. -Everything else is enforced where it is actually knowable — a sequence or -boundary-target mismatch halts the stream, and a defective payload fails the -commit closed. Unrecognized *additive* payload fields are ignored rather than -fatal, so a cached older bundle keeps working against a newer server; anything -incompatible bumps `version` instead. +After a checkpoint commits, WebUI removes its generated payload, sentinel, +markers, and temporary attributes. Final occurrences release roots immediately. +Updatable occurrences retain only live roots and a pending shallow patch until +terminal. + +The browser protocol is `[2, sequence, kind, target, payload]`. Kinds are final +checkpoint, updatable checkpoint, update, generated span completion, and +terminal. A malformed, truncated, out-of-order, or over-limit stream fails +closed, suppresses successful completion, and releases discoverable deferred +state within fixed bounds. ### CSP and delivery @@ -363,18 +316,13 @@ HTTP transport. A server adapter, compression layer, CDN, or reverse proxy can still buffer those bytes. Disable response buffering where appropriate and verify early delivery through the production path. -### Current limits - -Progressive streaming hydration is exposed by the Rust handler and browser -coordinator. Boundary markup is strictly in authored order, while markerless -state records can interleave. The following are not implemented APIs: +### Updates -- Dynamic ``, `page.append()`, or - `begin_append()` / `commit()` APIs -- Out-of-order same-response replacement -- Streaming reuse by router partial navigations -- Node, FFI/.NET, WASM, or other host-language response sessions -- Declarative partial-update APIs +Commit an occurrence as updatable only when complete SSR markup should become +interactive before its slow state resolves. `update(instanceId, patch)` applies +projected state through normal reactivity. It never inserts or replaces markup +and never reruns hydration. If the class or parent barrier is still pending, +WebUI queues one shallow patch and applies it after successful activation. ## Build-Time State Projection diff --git a/docs/guide/concepts/interactivity.md b/docs/guide/concepts/interactivity.md index 0fa2954f6..ab3d333be 100644 --- a/docs/guide/concepts/interactivity.md +++ b/docs/guide/concepts/interactivity.md @@ -653,8 +653,8 @@ Follow these rules to stay correct: On a normal buffered page or client-created mount, `super.connectedCallback()` hydrates synchronously. A progressive streaming host -can connect while its `data-ws` boundary is incomplete, however, so the same call -returns while hydration is still deferred. `hydratedCallback()` is the +can connect while its checkpoint or generated parent span is incomplete, so the +same call returns while hydration is still deferred. `hydratedCallback()` is the cross-mode lifecycle: WebUI invokes it synchronously exactly once after the first successful hydration or mount. Its once-latch is set before author code, so reconnecting the element or throwing from the callback does not retry it. @@ -664,7 +664,7 @@ non-async ES module script or a classic `defer` script. If a classic script blocks parsing, place it after every SSR instance it may upgrade. An opt-in [progressive streaming page](/guide/concepts/hydration#progressive-streaming-hydration) instead loads an early async module and gates each component until its complete -streaming boundary commits. +streaming occurrence or generated span commits. Descendants must not structurally mutate a containing WebUI component's SSR subtree before that component hydrates. Inserting, removing, or reordering nodes diff --git a/docs/guide/concepts/performance.md b/docs/guide/concepts/performance.md index f5402d910..33cf7a9a3 100644 --- a/docs/guide/concepts/performance.md +++ b/docs/guide/concepts/performance.md @@ -194,30 +194,29 @@ Each layer of the architecture contributes to the overall performance profile: build deterministic indices once at startup rather than repeating that work per request. -- **Streaming output with explicit checkpoints.** The Rust - `webui::streaming::StreamingWriter` coalesces writes into chunks and uses a - bounded `tokio::mpsc` channel for backpressure. A shared `ChunkPool` can - recycle buffers across requests, and a configurable flush deadline bounds - how long a render thread waits on a slow consumer. With - `WebUIHandler::render_streaming`, authored `` checkpoints - request a semantic transport flush and can hydrate in document order before - the response completes. Each checkpoint emits state and newly needed metadata - only for the local component surface reachable from its rendered roots, - including initially hidden descendants; inventory still records only actual - SSR roots. The runtime protocol lazily indexes route-free component - dependencies once, on its first streaming render; ordinary rendering never - allocates this index. Checkpoints reuse an integer DFS stack, and leaf-only - boundaries need no graph walk. Request-local buffers retain capacity for - reuse. The separately imported streaming coordinator passes this ephemeral - state directly to components and removes checkpoint scaffolding after commit. - `webui serve --api-port` can translate a capacity-one, versioned backend - control stream into the same Rust session, preserving browser-to-backend - backpressure without exposing a callback-heavy Node renderer session. +- **Runtime-discovered streaming.** The continuation VM walks only the selected + entry, component, condition, loop, and route path. It is iterative and keeps + bounded frames, projected parent keys, lexical locals, occurrence keys, and + generated component spans instead of cloning the full state or prebuilding a + request plan. Boundary-free fragment records are skipped through a build-time + `contains_boundary` bit. Capture and projection scratch buffers retain + capacity across checkpoints. +- **Bounded browser activation.** Each checkpoint or generated span completion + walks one root-local marker range, including open shadow roots, and removes + its scaffolding after commit. Final occurrences retain no root list. + Updatable occurrences retain only successfully activated roots until + terminal. The valid path has no `MutationObserver`, polling loop, or + document-wide selector; only fatal cleanup may perform one bounded sweep. +- **Host-owned backpressure.** `StreamingWriter` uses a bounded `tokio::mpsc` + channel, reusable `ChunkPool`, and configurable flush deadline. Owned + `StreamingSession` calls return one byte chunk for `start`, `resume`, or + `update`, so Node, WASM, Python, C, and .NET hosts write through their native + backpressure APIs. `webui serve --api-port` uses a capacity-one version-2 + control channel over the same session. Hosts must also bound concurrent blocking renders before calling `spawn_blocking`; channel backpressure bounds bytes after a task starts, not the runtime's queued blocking-task count. Reject saturation before spawning - (for example, with `Semaphore::try_acquire_owned`) so retained request state - stays bounded. + so retained request state stays bounded. Intermediaries can still buffer the response, so production deployments must configure and verify their full delivery path. diff --git a/docs/guide/installation.md b/docs/guide/installation.md index e23cd6ec7..257ac6408 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -88,6 +88,9 @@ dotnet add package Microsoft.WebUI It targets .NET 8 and .NET 9. The package restores platform-specific `Microsoft.WebUI.Runtime.*` packages transitively, and .NET selects the matching native asset. Release builds stage `.nupkg` and `.snupkg` artifacts with Source Link and repository metadata for downstream signing and publishing. NuGet.org publishing is not automatic until an approved Microsoft-certificate signing path is available for `.nupkg` packages. +See the [.NET integration guide](/guide/integrations/dotnet) for buffered and +progressive ASP.NET response examples. + Prepare `protocol.bin` once for repeated rendering: ```csharp diff --git a/docs/guide/integrations/dotnet.md b/docs/guide/integrations/dotnet.md new file mode 100644 index 000000000..b2f5cce0a --- /dev/null +++ b/docs/guide/integrations/dotnet.md @@ -0,0 +1,90 @@ +# .NET + +`Microsoft.WebUI` wraps the native C ABI with safe handles and managed result +types. It targets .NET 8 and .NET 9. + +## Installation + +```bash +dotnet add package Microsoft.WebUI +``` + +Load the protocol once and reuse it: + +```csharp +using var protocol = new Protocol( + await File.ReadAllBytesAsync("dist/protocol.bin")); +using var handler = new WebUIHandler("webui"); + +string html = handler.Render( + protocol, + """{"title":"Home"}""", + "index.html", + "/"); +``` + +## Progressive streaming + +`StreamResponse` creates a single-driver session. `Start` and `Resume` return a +`StreamingStep` containing `Bytes`, `Done`, and an optional `Boundary` +descriptor. + +```csharp +using var session = handler.StreamResponse(protocol, "index.html", "/"); + +Response.ContentType = "text/html; charset=utf-8"; +StreamingStep step = session.Start(initialStateJson); + +while (true) +{ + await Response.Body.WriteAsync(step.Bytes); + await Response.Body.FlushAsync(); + if (step.Done) break; + + BoundaryDescriptor boundary = step.Boundary + ?? throw new InvalidOperationException("Missing boundary descriptor"); + string state = await LoadBoundaryStateAsync( + boundary.Owner, + boundary.Name, + boundary.Key); + step = session.Resume( + boundary.InstanceId, + state, + BoundaryMode.Final); +} +``` + +The descriptor contains: + +- `InstanceId`, unique within this response +- `DeclarationId`, stable within the compiled protocol +- `Owner`, the entry or component template that authored the declaration +- `Name`, local to that owner +- `Key`, a `BoundaryKey` with `Type`, `StringValue`, and `NumberValue` + +Commit an occurrence as `BoundaryMode.Updatable` to send later state: + +```csharp +byte[] chunk = session.Update( + searchInstanceId, + """{"query":"webui"}"""); +await Response.Body.WriteAsync(chunk); +await Response.Body.FlushAsync(); +``` + +Updates apply projected state to existing roots. They do not insert markup or +rerun hydration. The step that reports `Done` already includes the response +tail and terminal record. + +Drive one session from one request flow at a time. Independent sessions may run +concurrently against the same handler and protocol. `WebUIException` carries +the native diagnostic for invalid state, ordering, keys, or rendering. + +## Native assets + +The managed package restores the matching `Microsoft.WebUI.Runtime.` +package transitively. Use `WEBUI_LIB_PATH` only when testing a custom local +native build. + +See [Streaming Boundaries](/guide/concepts/directives/boundary) and the +[C ABI](./ffi) for the shared contract. diff --git a/docs/guide/integrations/ffi.md b/docs/guide/integrations/ffi.md index 20d0d8e75..20c97a788 100644 --- a/docs/guide/integrations/ffi.md +++ b/docs/guide/integrations/ffi.md @@ -206,30 +206,46 @@ erase the startup-only performance model. ### Progressive streaming sessions A streaming session lets a C host render one response in chunks it writes -itself. Every chunk function returns a heap byte pointer plus its length; WebUI -never touches your socket, so backpressure and cancellation stay yours. +itself. Start and resume return owned step handles with borrowed byte slices; +update returns an owned byte buffer. WebUI never touches your socket, so +backpressure and cancellation stay yours. ```c webui_streaming_session_t *session = webui_streaming_session_create( handler, protocol, "index.html", "/"); -uint32_t rows = 0; -if (!webui_streaming_session_boundary(session, "rows", &rows)) { - fprintf(stderr, "%s\n", webui_last_error()); /* lists the valid names */ +webui_streaming_step_t *step = + webui_streaming_session_start(session, initial_state_json); +if (step == NULL) { + fprintf(stderr, "%s\n", webui_last_error()); } -size_t len = 0; -uint8_t *chunk = webui_streaming_session_write_shell(session, "{}", &len); -if (chunk == NULL) { - fprintf(stderr, "%s\n", webui_last_error()); -} else { - send_all(socket, chunk, len); - webui_free(chunk); +while (step != NULL) { + uintptr_t bytes_len = 0; + const uint8_t *bytes = webui_streaming_step_bytes(step, &bytes_len); + send_all(socket, bytes, bytes_len); + if (webui_streaming_step_done(step)) { + webui_streaming_step_destroy(step); + break; + } + + /* Copies owner, name, typed key, and IDs from the step accessors. */ + struct app_boundary target = copy_boundary_descriptor(step); + webui_streaming_step_destroy(step); + + const char *state_json = load_state(&target); + step = webui_streaming_session_resume( + session, + target.instance_id, + state_json, + WEBUI_BOUNDARY_MODE_FINAL); + free_boundary_descriptor(&target); + if (step == NULL) { + fprintf(stderr, "%s\n", webui_last_error()); + break; + } } -/* ... write_boundary / update ... then: */ -chunk = webui_streaming_session_finish(session, "{}", &len); -/* send + free */ webui_streaming_session_destroy(session); ``` @@ -237,23 +253,24 @@ webui_streaming_session_destroy(session); |----------|--------| | `webui_streaming_session_create(handler, protocol, entry_id, request_path)` | Session handle, or `NULL`. Inherits the handler's nonce (set with `webui_handler_set_nonce`); head/body injection travels through the reserved `$webui` state key on `state_json`, not through this call. | | `webui_streaming_session_destroy(session)` | Releases the session. `NULL` is a safe no-op. | -| `webui_streaming_session_boundary(session, name, out_id)` | `true` plus the integer handle, or `false` and an error listing the valid names | -| `webui_streaming_session_boundary_count(session)` | Boundaries declared by the entry | -| `webui_streaming_session_is_finished(session)` | Whether the terminal record was written | -| `webui_streaming_session_write_shell(session, state_json, out_len)` | Document prefix through the first semantic flush | -| `webui_streaming_session_write_boundary(session, id, state_json, mode, out_len)` | One boundary's markup and checkpoint. `mode` is `0` final, `1` updatable. | -| `webui_streaming_session_update(session, id, state_json, out_len)` | Projected state patch for an updatable boundary | -| `webui_streaming_session_finish(session, state_json, out_len)` | Tail checkpoint, terminal record, and document suffix | - -**Chunks are binary-safe.** Always use `*out_len`. Chunks are **not** -NUL-terminated, and a checkpoint payload may legitimately contain a zero byte. -Free every non-`NULL` chunk with `webui_free`. +| `webui_streaming_session_start(session, state_json)` | Owned step through the first runtime occurrence or terminal, or `NULL` | +| `webui_streaming_session_resume(session, instance_id, state_json, mode)` | Commit the pending occurrence, then return the next owned step | +| `webui_streaming_session_update(session, instance_id, patch_json, out_len)` | Projected state bytes for a committed updatable occurrence | +| `webui_streaming_step_bytes(step, out_len)` | Borrow binary-safe step bytes until destroy | +| `webui_streaming_step_done(step)` / `webui_streaming_step_has_boundary(step)` | Read completion and descriptor presence | +| `webui_streaming_step_boundary_*` | Read IDs, owner/name slices, key type, and typed key | +| `webui_streaming_step_destroy(step)` | Release the opaque step and all borrowed pointers | + +`webui_streaming_step_t` is opaque. Step bytes, owner, name, and string keys are +borrowed slices with explicit lengths and are not NUL-terminated. Key type is +none, string, or number; numeric keys are returned as `double`. Copy any +descriptor values needed after destroying the step. Free update bytes with +`webui_free`. The session clones its own references to the handler and protocol, so you may -destroy them in any order. A rejected call returns `NULL` but leaves the session -usable, so bad state input does not cost you the response. See +destroy them in any order. Drive a session from one thread at a time. See [Streaming Boundaries](/guide/concepts/directives/boundary) for the authoring -side and the ordering rules. +and occurrence rules. ## Error Handling @@ -287,7 +304,8 @@ Two rules to remember: |---|---|---| | `webui_handler_render` | Caller | `webui_free(ptr)` | | Partial, component-template, and token strings | Caller | `webui_free(ptr)` | -| Streaming session chunks | Caller | `webui_free(ptr)` | +| Streaming update bytes | Caller | `webui_free(ptr)` | +| Streaming step handle and borrowed fields | Caller | `webui_streaming_step_destroy(step)` | | `webui_last_error` | Library (do **not** free) | Replaced on next call | | `webui_handler_create` | Caller | `webui_handler_destroy(ptr)` | | `webui_handler_create_with_plugin` | Caller | `webui_handler_destroy(ptr)` | @@ -462,19 +480,27 @@ progressive response without touching the native ABI: ```csharp using var session = handler.StreamResponse(protocol, "index.html", "/"); -uint rows = session.Boundary("rows"); - Response.ContentType = "text/html; charset=utf-8"; -await Response.Body.WriteAsync(session.WriteShell("{}")); -await Response.Body.FlushAsync(); - -await Response.Body.WriteAsync(session.WriteBoundary(rows, await LoadRowsAsync())); -await Response.Body.WriteAsync(session.Finish("{}")); +StreamingStep step = session.Start(initialState); +while (true) +{ + await Response.Body.WriteAsync(step.Bytes); + await Response.Body.FlushAsync(); + if (step.Done) break; + + BoundaryDescriptor boundary = step.Boundary + ?? throw new InvalidOperationException("Missing boundary descriptor"); + string state = await LoadStateAsync( + boundary.Owner, + boundary.Name, + boundary.Key); + step = session.Resume(boundary.InstanceId, state, BoundaryMode.Final); +} ``` Each call returns a `byte[]`, so `HttpResponse.Body` keeps its own write and flush semantics. Failures throw `WebUIException` carrying the same diagnostic -`webui_last_error()` would report. +`webui_last_error()` would report. See the [.NET integration](./dotnet). ## Other Languages diff --git a/docs/guide/integrations/index.md b/docs/guide/integrations/index.md index 0e140e089..749ea9be7 100644 --- a/docs/guide/integrations/index.md +++ b/docs/guide/integrations/index.md @@ -6,7 +6,7 @@ Pick the handler that matches your stack: - [**Rust**](./rust), High-performance native rendering with the Rust programming language - [**Node**](./node), Buffered and streaming SSR via a native addon built with napi-rs for Node, Bun, and Deno -- [**.NET**](/guide/installation#net), Managed `Microsoft.WebUI` NuGet bindings with transitive native runtime packages +- [**.NET**](./dotnet), Managed `Microsoft.WebUI` NuGet bindings with progressive ASP.NET streaming - [**Python**](./python), Native `microsoft-webui` package (PyO3) with buffered, partial, and host-driven streaming rendering - [**Electron**](./electron), Desktop apps via Electron with custom `webui://` protocol - [**WebAssembly**](./wasm), Split parser, handler, and combined browser bundles diff --git a/docs/guide/integrations/node.md b/docs/guide/integrations/node.md index caf017140..1759f257b 100644 --- a/docs/guide/integrations/node.md +++ b/docs/guide/integrations/node.md @@ -219,30 +219,35 @@ runs, so a `false` result from `response.write()` cannot pause it. That is fine for whole-document rendering, but it cannot express a response your server paces. `protocol.streamResponse()` inverts that. It opens a **session** whose methods -return the bytes they produced, so your server owns the socket, the write order, -and the backpressure contract: +return bytes, so your server owns the socket and backpressure: ```js -import { once } from 'node:events'; - -const session = protocol.streamResponse({ entry: 'index.html', requestPath: '/' }); - -// Authored boundary names resolve to integer handles once, outside the loop. -const status = session.boundary('job-status'); -const rows = session.boundary('rows'); +const session = protocol.streamResponse({ + entry: 'index.html', + requestPath: '/', +}); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'X-Accel-Buffering': 'no', }); -await write(res, session.writeShell(baseState)); -await write(res, session.writeBoundary(status, statusState, 'updatable')); -await write(res, session.writeBoundary(rows, await loadRows())); +let step = session.start(initialState); +await write(res, step.bytes); -// Patches an already-hydrated island on this same response. -await write(res, session.update(status, { jobState: 'succeeded' })); -res.end(session.finish({})); +while (!step.done) { + const boundary = step.boundary; + if (!boundary) throw new Error('unfinished step has no boundary'); + + const state = await loadBoundaryState( + boundary.owner, + boundary.name, + boundary.key, + ); + step = session.resume(boundary.instanceId, state, 'final'); + await write(res, step.bytes); +} +res.end(); async function write(res, chunk) { if (res.write(chunk)) return; @@ -263,29 +268,29 @@ async function write(res, chunk) { } ``` -That `write` helper is the entire transport integration, which is why -the same session drops into Express, Fastify, Hapi, or a raw socket unchanged. - -The page's entry template must declare -[`` directives](/guide/concepts/directives/boundary); `boundaryCount` -reports how many it has. +The same shape works behind Express, Fastify, Hapi, or a raw socket. Boundaries +are discovered at runtime through entries, reusable components, conditions, +loops, and the selected route. ### StreamingSession | Member | Description | |--------|-------------| -| `boundary(name)` | Resolve an authored boundary name to its integer handle. Throws with the valid names and a "did you mean …?" suggestion on a typo. | -| `boundaryCount` | Number of boundaries the entry declares | -| `finished` | Whether `finish()` has been called | -| `writeShell(state)` | Bytes for the document prefix through the first semantic flush | -| `writeBoundary(id, state, mode?)` | Bytes for one boundary's markup, metadata delta, and checkpoint. `mode` is `"final"` (default) or `"updatable"` | -| `update(id, state)` | Bytes for a projected state patch to a boundary committed as `"updatable"` | -| `finish(state)` | Bytes for the tail checkpoint, terminal record, and document suffix | - -Ordering is enforced: the shell first, boundaries in declaration order, updates -only to updatable boundaries already committed, and `finish()` last. A rejected -call throws and leaves the session usable, so invalid state does not cost you the -response. Sessions are independent, so hold one per in-flight request. - -**Runnable example.** `examples/integration/node/streaming-server.js` is a -complete `node:http` server built on this API, with no sidecar process. +| `start(state)` | Return `{ bytes, done, boundary? }` through the first occurrence or terminal | +| `resume(instanceId, state, mode?)` | Commit the pending occurrence, then return the next step | +| `update(instanceId, patch)` | Return projected state bytes for a committed updatable occurrence | + +A descriptor contains `instanceId`, `declarationId`, `owner`, `name`, and an +optional string or numeric `key`. Use those fields to load state, then pass +`instanceId` back to `resume`. The final step already contains tail and terminal +bytes. `mode` is `"final"` by default or `"updatable"`. + +An update never inserts markup or reruns hydration: + +```js +const patch = session.update(searchInstanceId, { query: 'webui' }); +await write(res, patch); +``` + +Sessions are single-driver and independent. Hold one per in-flight request and +stop driving it after a rendering or transport failure. diff --git a/docs/guide/integrations/python.md b/docs/guide/integrations/python.md index a7caccfe7..ad6691890 100644 --- a/docs/guide/integrations/python.md +++ b/docs/guide/integrations/python.md @@ -123,9 +123,9 @@ Both return `bytes`; write them directly as the response body for `renderer.stream_response()` opens a `StreamingSession` whose methods return the bytes they produced instead of writing anywhere themselves. **WebUI never -touches your socket, so your server owns the write order and backpressure — +touches your socket, so your server owns the write order and backpressure - the same contract as every other host binding** (see -[Streaming Boundaries](/guide/concepts/directives/boundary#3-drive-a-host-controlled-response) +[Streaming Boundaries](/guide/concepts/directives/boundary#drive-the-response) for the authoring side and full ordering rules). ### WSGI @@ -136,13 +136,25 @@ directly onto the session: ```python def app(environ, start_response): session = renderer.stream_response(request_path=environ.get("PATH_INFO", "/")) - rows = session.boundary("rows") # resolved once, outside the write loop def body(): - yield session.write_shell({"title": "Home"}) - yield session.write_boundary(rows, {"rows": []}, mode="updatable") - yield session.update(rows, {"rows": load_rows()}) - yield session.finish({}) + step = session.start(initial_state) + yield step.bytes + while not step.done: + boundary = step.boundary + if boundary is None: + raise RuntimeError("unfinished step has no boundary") + state = load_boundary_state( + boundary.owner, + boundary.name, + boundary.key, + ) + step = session.resume( + boundary.instance_id, + state, + mode="final", + ) + yield step.bytes start_response("200 OK", [ ("Content-Type", "text/html; charset=utf-8"), @@ -162,25 +174,37 @@ from starlette.responses import StreamingResponse async def index(request): session = renderer.stream_response(request_path=request.url.path) - rows = session.boundary("rows") async def body(): - yield await anyio.to_thread.run_sync(session.write_shell, {"title": "Home"}) - yield await anyio.to_thread.run_sync( - lambda: session.write_boundary(rows, {"rows": []}, mode="updatable"), - ) - rows_data = await load_rows() # your own async backend call - yield await anyio.to_thread.run_sync(session.update, rows, {"rows": rows_data}) - yield await anyio.to_thread.run_sync(session.finish, {}) + step = await anyio.to_thread.run_sync(session.start, initial_state) + yield step.bytes + while not step.done: + boundary = step.boundary + if boundary is None: + raise RuntimeError("unfinished step has no boundary") + state = await load_boundary_state( + boundary.owner, + boundary.name, + boundary.key, + ) + step = await anyio.to_thread.run_sync( + lambda: session.resume( + boundary.instance_id, + state, + mode="final", + ), + ) + yield step.bytes return StreamingResponse(body(), media_type="text/html; charset=utf-8") ``` -Ordering is enforced by the session itself: the shell first, boundaries in -declaration order, `update()` only on boundaries already committed -`"updatable"`, and `finish()` last. A rejected call raises and leaves the -session usable, so bad state input doesn't cost you the response. Sessions -are **not** thread-safe — drive one session from one thread at a time; +`start()` and `resume()` return a `StreamStep` with `bytes`, `done`, and an +optional descriptor. The descriptor provides `instance_id`, `declaration_id`, +`owner`, `name`, and `key`. The completed step already includes the tail and +terminal bytes. + +Sessions are **not** multi-driver - drive one session from one thread at a time; independent sessions on the same `Renderer` may run concurrently. ## API reference @@ -200,13 +224,9 @@ independent sessions on the same `Renderer` may run concurrently. | Member | Description | |--------|-------------| -| `boundary(name) -> int` | Resolve an authored boundary name to its integer handle. Raises with the valid names and a "did you mean …?" suggestion on a typo | -| `boundary_count` | Number of boundaries the entry declares | -| `finished` | Whether `finish()` successfully emitted the terminal record | -| `write_shell(state) -> bytes` | Document prefix through the first semantic flush | -| `write_boundary(id, state, mode=BoundaryMode.FINAL) -> bytes` | One boundary's markup, metadata delta, and checkpoint | -| `update(id, state) -> bytes` | Projected state patch for a boundary already committed `updatable` | -| `finish(state) -> bytes` | Tail checkpoint, terminal record, and document suffix | +| `start(state) -> StreamStep` | Bytes through the first runtime occurrence or terminal | +| `resume(instance_id, state, mode=BoundaryMode.FINAL) -> StreamStep` | Commit the pending occurrence and continue | +| `update(instance_id, patch) -> bytes` | Projected state for a committed updatable occurrence | ### `Plugin` and `BoundaryMode` @@ -217,8 +237,8 @@ typo-checked by static analysis: ```python from microsoft_webui import BoundaryMode -session.write_boundary(rows, state, mode=BoundaryMode.UPDATABLE) -session.write_boundary(rows, state, mode="updatable") # equivalent +session.resume(boundary.instance_id, state, mode=BoundaryMode.UPDATABLE) +session.resume(boundary.instance_id, state, mode="updatable") # equivalent ``` ## Fast path: pre-serialized state diff --git a/docs/guide/integrations/rust.md b/docs/guide/integrations/rust.md index 7f78917b9..570fb1ce7 100644 --- a/docs/guide/integrations/rust.md +++ b/docs/guide/integrations/rust.md @@ -217,33 +217,54 @@ HttpResponse::Ok() ### Host-driven boundaries and state updates -`stream_response` returns a synchronous response session. Resolve authored names -once, then use integer handles for every write: +`stream_response` returns a synchronous session that discovers runtime +occurrences as it renders: ```rust -use webui::{BoundaryMode, RenderOptions, WebUIHandler}; +use webui::{BoundaryMode, HandlerError, RenderOptions, WebUIHandler}; let options = RenderOptions::new("index.html", "/"); let mut response = handler.stream_response(&protocol, &options, &mut writer)?; -let weather = response.boundary("weather-shell")?; -let composer = response.boundary("composer-ready")?; -let feed_1 = response.boundary("feed-batch-1")?; - -response.write_shell(&page_state)?; -response.write_boundary(weather, &loading_weather, BoundaryMode::Updatable)?; -response.write_boundary(composer, &composer_state, BoundaryMode::Final)?; - -// Await or receive backend work between these synchronous calls. -response.update(weather, &ready_weather)?; -response.write_boundary(feed_1, &feed_state, BoundaryMode::Final)?; -response.finish(&tail_state)?; +let mut step = response.start(&initial_state)?; + +while !step.done { + let boundary = step.boundary.as_ref().ok_or_else(|| { + HandlerError::Invariant( + "unfinished streaming step has no descriptor".to_string(), + ) + })?; + let state = load_state( + &boundary.owner, + &boundary.name, + boundary.key.as_ref(), + )?; + step = response.resume( + boundary.instance_id, + &state, + BoundaryMode::Final, + )?; +} ``` -Boundary HTML must be written once in declaration order. `update` can be called -between any two boundary writes, but only for a committed `Updatable` boundary. -It applies the same compiled state projection as that boundary's initial -checkpoint, requires a JSON object, emits no marker range, and flushes -immediately. `finish` requires all compiled boundaries to be committed. +`start` writes through the first occurrence, or completes immediately when the +selected path has none. `resume` must use the currently pending +`BoundaryInstanceId`, commits that occurrence, and continues to the next one or +terminal. The final returned status has `done == true`, no descriptor, and the +writer already contains the tail and terminal. + +Every descriptor contains `instance_id`, `declaration_id`, `owner`, `name`, and +an optional string or numeric `key`. A declaration inside a repeated path +requires a key, and live keys must be unique. + +To send later state, resume the occurrence as `BoundaryMode::Updatable`, retain +its instance ID, then call: + +```rust +response.update(search_instance, &json!({ "query": "webui" }))?; +``` + +`update` accepts an object patch, emits a projected markerless state record, and +flushes immediately. It inserts no markup and does not rerun hydration. The session borrows each state value only for its call. It does not await, allocate a task, or synchronize concurrent callers. An async server should use a @@ -259,12 +280,10 @@ pub trait FlushWriter: ResponseWriter { ``` `render_streaming` and `stream_response` accept a `FlushWriter`; -`StreamingWriter` implements that trait. Each explicit boundary is completed, -followed by its hydration checkpoint and a semantic flush. At `body_end`, any -native or scriptless tail HTML is followed by one empty markerless terminal -record and one final flush. The terminal record never repeats state or template -metadata. The normal `render` method still accepts any `ResponseWriter` and does -not progressively hydrate authored boundaries. +`StreamingWriter` implements that trait. Each resumed occurrence is followed by +its hydration checkpoint and a semantic flush. Generated component span +completions, state updates, and terminal records also flush. The normal +`render` method still accepts any `ResponseWriter`. The entry template must load its application module with an early ` - - - - - - + + + + + + + +

always-complete

diff --git a/examples/README.md b/examples/README.md index 579bf1646..a1f8881c6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,7 +20,7 @@ Current entries: | `app/commerce` | WebUI Framework hydration app with a Rust backend for commerce demo app, dozens of controls | | `app/routes` | Nested declarative routing demo showing 4-level deep routes, full server side and client handoff | | `app/service-worker` | Static/CDN service worker app using `webui_wasm_handler` to stream WASM-rendered chunks from public API state | -| `app/streaming` | Progressive streaming hydration demo - a Node API paces named `` records while `webui serve` owns the Rust response session and bounded browser stream | +| `app/streaming` | Progressive hydration demo with runtime-discovered component boundaries, descriptor-driven backend pacing, and a bounded browser stream | | `integration/node` | Node.js integration via native addon | | `integration/node-addon-bench` | Node/V8/N-API runtime benchmark for the native addon | | `integration/rust` | Rust integration via `webui-handler` | diff --git a/examples/app/service-worker/README.md b/examples/app/service-worker/README.md index 3728c94e7..7f9473bb0 100644 --- a/examples/app/service-worker/README.md +++ b/examples/app/service-worker/README.md @@ -11,9 +11,10 @@ The browser loads only static assets: - a service worker that streams the navigation response No application server is required. The service worker fetches public API state, -constructs one `webui_wasm_handler.Protocol`, renders matching fragments with -`Protocol.renderStream()`, and enqueues each section into a `ReadableStream` as -soon as that API response resolves. +constructs one `webui_wasm_handler.Protocol`, opens a +`Protocol.streamResponse()` session, and enqueues every `start()` / `resume()` +step into a `ReadableStream`. API fetches run concurrently, while each resume +uses the runtime descriptor returned by the previous `StreamStep`. Because the example renders public API data, the service worker validates URL fields before calling the handler. Keep that boundary in copied code so @@ -77,9 +78,9 @@ pnpm --filter service-worker-example test ``` The Playwright smoke test verifies that the page is controlled by the service -worker, renders all WebUI chunks, and receives the chunks in async completion -order rather than source order. Each chunk is wrapped with a `data-chunk` -marker so the stream order is visible and easy to assert. +worker, renders all WebUI chunks, and commits them in the cursor's authored +order while their API fetches overlap. Each chunk is wrapped with a +`data-chunk` marker so the stream order is visible and easy to assert. ## Why this matters @@ -88,9 +89,10 @@ from public APIs. The serverless edge path can be: 1. CDN serves static files. 2. Browser service worker loads `protocol.bin`. -3. Public APIs return JSON state. -4. WebUI WASM handler renders HTML chunks locally. -5. The service worker streams the response to the page. +3. Public APIs return JSON state concurrently. +4. `start()` returns the first pending boundary descriptor. +5. The worker resumes each descriptor with its matching state and streams the + returned bytes to the page; the final resume emits the terminal automatically. ## Source layout diff --git a/examples/app/service-worker/public/api/metrics.json b/examples/app/service-worker/public/api/metrics.json index 362f971df..abdf3078e 100644 --- a/examples/app/service-worker/public/api/metrics.json +++ b/examples/app/service-worker/public/api/metrics.json @@ -3,7 +3,7 @@ "delayMs": 160, "state": { "label": "Public API: /api/metrics.json", - "title": "Chunks render as async state resolves", + "title": "Concurrent fetches feed an ordered cursor", "metrics": [ { "value": "1", diff --git a/examples/app/service-worker/scripts/check-render.ts b/examples/app/service-worker/scripts/check-render.ts index 665e74c2a..43a49524a 100644 --- a/examples/app/service-worker/scripts/check-render.ts +++ b/examples/app/service-worker/scripts/check-render.ts @@ -16,6 +16,18 @@ const themeCssPath = resolve(exampleRoot, "public/theme.css"); const apiFiles = ["shell", "hero", "metrics", "activity"]; const baseUrl = new URL("http://localhost:4175/"); +interface WasmBoundaryDescriptor { + instanceId: number; + owner: string; + name: string; +} + +interface WasmStreamStep { + bytes: Uint8Array; + done: boolean; + boundary?: WasmBoundaryDescriptor; +} + await initWasm({ module_or_path: await readFile(wasmPath) }); const protocol = new Protocol( @@ -23,36 +35,60 @@ const protocol = new Protocol( "webui", ); +const payloads = new Map>(); for (const name of apiFiles) { const payload = JSON.parse( await readFile(resolve(exampleRoot, `public/api/${name}.json`), "utf-8"), ); - const sanitized = sanitizePayload(payload, `api/${name}.json`, baseUrl); - let html = ""; - const onChunk = (chunk: string): void => { - html += chunk; - }; - protocol.renderStream( - JSON.stringify(sanitized.state), - onChunk, - { entry: sanitized.entry, requestPath: "/" }, - ); - if (!html.includes("card")) { - throw new Error(`Rendered ${sanitized.entry} did not include expected card markup`); + payloads.set(name, sanitizePayload(payload, `api/${name}.json`, baseUrl)); +} + +const themeCss = await readFile(themeCssPath, "utf-8"); +const session = protocol.streamResponse("index.html", "/", { + headInject: ``, +}); +const decoder = new TextDecoder(); +let html = ""; +let step = session.start("{}") as WasmStreamStep; +html += decoder.decode(step.bytes); +while (!step.done) { + const boundary = step.boundary; + if (!boundary) { + throw new Error("Streaming session returned no pending boundary"); } - if (!html.includes("`, + }); + const pending = new Map< + string, + Promise<{ chunk: ApiChunk; payload: ApiPayload }> + >( + API_CHUNKS.map((chunk) => [ + chunk.label, + fetchChunk(chunk).then((payload) => ({ chunk, payload })), + ]), + ); - await streamChunksAsReady(controller, protocol); - controller.enqueue(encode(documentEnd())); + let step = session.start('{}'); + controller.enqueue(step.bytes); + while (!step.done) { + const boundary = pendingBoundary(step); + const result = await pending.get(boundary.name); + if (!result) { + throw new Error( + `Unexpected streaming boundary ${boundary.owner}/${boundary.name}`, + ); + } + pending.delete(boundary.name); + validateBoundaryPayload(boundary, result.chunk, result.payload); + step = session.resume( + boundary.instanceId, + JSON.stringify(result.payload.state), + ); + controller.enqueue(step.bytes); + } } async function loadThemeCss(): Promise { @@ -109,33 +137,27 @@ function loadWasm(): Promise { return wasmReady; } -async function streamChunksAsReady( - controller: ReadableStreamDefaultController, - protocol: Protocol, -): Promise { - const pending = API_CHUNKS.map((chunk) => - fetchChunk(chunk).then((payload) => ({ chunk, payload })), - ); - - while (pending.length > 0) { - const indexed = pending.map((promise, index) => - promise.then((result) => ({ index, result })), - ); - const { index, result } = await Promise.race(indexed); - pending.splice(index, 1); +function pendingBoundary(step: StreamStep): BoundaryDescriptor { + if (step.done || !step.boundary) { + throw new Error('Streaming session returned no pending boundary'); + } + return step.boundary; +} - controller.enqueue( - encode(`
\n`), +function validateBoundaryPayload( + boundary: BoundaryDescriptor, + chunk: ApiChunk, + payload: ApiPayload, +): void { + if (boundary.owner !== 'index.html' || boundary.name !== chunk.label) { + throw new Error( + `Expected index.html/${chunk.label}, received ${boundary.owner}/${boundary.name}`, ); - protocol.renderStream( - JSON.stringify(result.payload.state), - (chunk: string) => controller.enqueue(encode(chunk)), - { - entry: result.payload.entry, - requestPath: '/', - }, + } + if (payload.entry !== `${chunk.label}-panel`) { + throw new Error( + `API payload for ${chunk.label} targets unexpected entry ${payload.entry}`, ); - controller.enqueue(encode("\n
\n")); } } @@ -165,28 +187,6 @@ function encode(value: string): Uint8Array { return encoder.encode(value); } -function documentStart(themeCss: string): string { - return ` - - - - - WebUI Service Worker Streaming - - - -
-
Streaming from service worker + WebUI WASM handler
-`; -} - -function documentEnd(): string { - return `
- - -`; -} - function renderError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return `

Render failed

${escapeHtml(message)}

`; diff --git a/examples/app/service-worker/src/wasm/handler/webui_wasm_handler.d.ts b/examples/app/service-worker/src/wasm/handler/webui_wasm_handler.d.ts index cc5a6a82f..d7ffbe313 100644 --- a/examples/app/service-worker/src/wasm/handler/webui_wasm_handler.d.ts +++ b/examples/app/service-worker/src/wasm/handler/webui_wasm_handler.d.ts @@ -10,12 +10,33 @@ export default function init( export class Protocol { constructor(protocolBytes: Uint8Array, plugin?: string | null); - renderStream( - stateJson: string, - onChunk: (html: string) => void, + streamResponse( + entry: string, + requestPath: string, options?: { - entry?: string; - requestPath?: string; + nonce?: string; + headInject?: string; + bodyInject?: string; }, - ): void; + ): StreamingSession; +} + +export interface BoundaryDescriptor { + instanceId: number; + declarationId: number; + owner: string; + name: string; + key?: string | number; +} + +export interface StreamStep { + bytes: Uint8Array; + done: boolean; + boundary?: BoundaryDescriptor; +} + +export class StreamingSession { + start(stateJson: string): StreamStep; + resume(instanceId: number, stateJson: string, mode?: 'final' | 'updatable'): StreamStep; + update(instanceId: number, stateJson: string): Uint8Array; } diff --git a/examples/app/service-worker/tests/service-worker.spec.ts b/examples/app/service-worker/tests/service-worker.spec.ts index 903f92e90..285c1b401 100644 --- a/examples/app/service-worker/tests/service-worker.spec.ts +++ b/examples/app/service-worker/tests/service-worker.spec.ts @@ -22,13 +22,13 @@ test('streams WebUI-rendered chunks from a service worker', async ({ page }) => await expect(page.getByRole('heading', { name: 'WebUI rendered in a service worker' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Serverless HTML without an app server' })).toBeVisible(); - await expect(page.getByText('Chunks render as async state resolves')).toBeVisible(); + await expect(page.getByText('Concurrent fetches feed an ordered cursor')).toBeVisible(); await expect(page.getByText('Streaming timeline')).toBeVisible(); await expect(page.locator('.error-card')).toHaveCount(0); const headings = await page.locator('.card h1, .card h2').allTextContents(); const shell = headings.indexOf('WebUI rendered in a service worker'); - const metrics = headings.indexOf('Chunks render as async state resolves'); + const metrics = headings.indexOf('Concurrent fetches feed an ordered cursor'); const hero = headings.indexOf('Serverless HTML without an app server'); const activity = headings.indexOf('Streaming timeline'); @@ -36,12 +36,12 @@ test('streams WebUI-rendered chunks from a service worker', async ({ page }) => const chunkOrder = await page.locator('.stream-chunk').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-chunk')), ); - expect(chunkOrder).toEqual(['shell', 'metrics', 'hero', 'activity']); + expect(chunkOrder).toEqual(['shell', 'hero', 'metrics', 'activity']); expect(shell).toBeGreaterThan(-1); - expect(metrics).toBeGreaterThan(shell); - expect(hero).toBeGreaterThan(metrics); - expect(activity).toBeGreaterThan(hero); + expect(hero).toBeGreaterThan(shell); + expect(metrics).toBeGreaterThan(hero); + expect(activity).toBeGreaterThan(metrics); await expect(page.locator('style')).toHaveCount(5); const themeCss = await page.locator('style').first().evaluate((node) => node.textContent ?? ''); diff --git a/examples/app/streaming/README.md b/examples/app/streaming/README.md index 2c599cbfb..ed88d7522 100644 --- a/examples/app/streaming/README.md +++ b/examples/app/streaming/README.md @@ -11,7 +11,10 @@ interactive before `DOMContentLoaded` while the response is still open; a **`weather-panel`** shows its own skeleton and receives server state through the same response; a three-batch **feed** streams in afterward, each batch's `feed-item` islands hydrating independently as their own -`` commits. +`` commits. The document entry contains only one +`` host. Its component template owns all five boundaries, so +the composer becomes interactive before the parent page's final tail and span +completion arrive. ```bash # Install JS dependencies @@ -44,7 +47,9 @@ instruction instead of silently falling back to full-state payloads. The Node API feed gaps are bounded by `--feed-delay-min-ms` (500) and `--feed-delay-max-ms` (1000) and are re-rolled per request, so repeated loads do not look mechanically identical. The forecast resolves independently, so its -state record can appear between any two feed checkpoints: +state record can appear between any two feed checkpoints. If it is still +pending at the last gap, the API sends the update before the final resume, +because that resume completes the session and closes its update window: ```bash pnpm start:api -- --feed-delay-min-ms 200 --feed-delay-max-ms 400 @@ -52,22 +57,25 @@ pnpm start:api -- --feed-delay-min-ms 200 --feed-delay-max-ms 400 For an HTML request, `webui serve` advertises `Accept: application/x-webui-stream, application/json`. The API selects -streaming with the first media type and writes versioned NDJSON commands: +streaming with the first media type and writes version-2 NDJSON controls: ```text -{"type":"shell","version":1,"state":{...}} -{"type":"boundary","name":"weather-shell","mode":"updatable"} -{"type":"boundary","name":"composer-ready"} -{"type":"update","name":"weather-shell","state":{...}} -{"type":"finish"} +{"type":"start","version":2,"state":{...}} +{"type":"resume","boundary":{"owner":"streaming-page","name":"weather-shell"},"mode":"updatable","state":{}} +{"type":"resume","boundary":{"owner":"streaming-page","name":"composer-ready"},"state":{}} +{"type":"update","boundary":{"owner":"streaming-page","name":"weather-shell"},"state":{...}} ``` In this topology Node controls readiness and order, but does not render WebUI. -The CLI resolves each name once to an integer boundary handle and owns the -compiled protocol, `StreamingResponse`, pooled `StreamingWriter`, and -browser-facing record format. A capacity-one command channel and Node's -`response.write()` / `drain` contract propagate backpressure across the loopback -bridge. +Each `resume` echoes the expected runtime descriptor's owner, name, and optional +typed key. The one-way channel has no acknowledgement carrying instance IDs, so +the CLI validates that selector against the current `StreamStep`, remembers the +committed descriptor-to-instance mapping for later updates, and rejects stale or +ambiguous targets. `declarationId` may be included as an extra validation field. +The final `resume` returns `done`, writes the terminal automatically, and the API +closes its NDJSON body; there is no finish control. A capacity-one command +channel and Node's `response.write()` / `drain` contract propagate backpressure +across the loopback bridge. **This is one of two supported topologies.** This example uses the **API-proxy** topology, which is the right fit when you want the CLI to own @@ -84,7 +92,7 @@ ordering, projection, the wire format, and every diagnostic are identical. ### Why weather uses a state record -Boundary HTML is delivered strictly in document order, so the weather boundary +Runtime boundary HTML is delivered in discovery order, so the weather boundary ships a complete `weather-panel` in its `loading` state immediately. The host commits it as `BoundaryMode::Updatable`, starts forecast work concurrently, and calls `StreamingResponse::update` when the forecast resolves. The typed state @@ -184,10 +192,10 @@ by the application bundle, not by CSS. See ### How it stays deterministic `server/src/pacing.ts` races weather readiness against each feed delay and -writes commands in completion order. `server/src/stream-protocol.ts` emits one -record per semantic write, honors Node HTTP backpressure, and ends immediately -after `finish`. The API caps concurrent admitted streams before sending a 200 -response. +writes controls in completion order. `server/src/stream-protocol.ts` emits one +record per semantic write, honors Node HTTP backpressure, and closes the body +after the final resume. The API caps concurrent admitted streams before sending +a 200 response. Inside `webui serve`, one blocking worker owns the real `WebUIHandler::stream_response` and `StreamingWriter` for the response @@ -196,7 +204,7 @@ the existing capacity-four browser channel and 30-second flush timeout remain in force. The example never manufactures browser envelopes or duplicates protocol rendering in JavaScript. -Three feed batches are three explicit `` groups — WebUI does not +Three feed batches are three explicit component-local `` groups — WebUI does not implement an open-ended `` directive. The feed's `
` container is never itself hydrated: each `feed-item` carries its own state in its own attributes, so one batch's items can never read or mutate another diff --git a/examples/app/streaming/server/src/index.ts b/examples/app/streaming/server/src/index.ts index a286434f2..f9aeb62e5 100644 --- a/examples/app/streaming/server/src/index.ts +++ b/examples/app/streaming/server/src/index.ts @@ -91,6 +91,7 @@ async function renderPage( testSession, signal: abort.signal, }); + response.end(); } catch (error) { if (!abort.signal.aborted) { const message = error instanceof Error ? error.message : String(error); diff --git a/examples/app/streaming/server/src/pacing.test.ts b/examples/app/streaming/server/src/pacing.test.ts index 3f23595bd..fe29c66eb 100644 --- a/examples/app/streaming/server/src/pacing.test.ts +++ b/examples/app/streaming/server/src/pacing.test.ts @@ -6,36 +6,40 @@ import { test } from 'node:test'; import { STREAMING_STATE } from './data.js'; import { streamPage } from './pacing.js'; -import { acceptsWebUIStream, type StreamSink } from './stream-protocol.js'; +import { + acceptsWebUIStream, + type BoundaryTarget, + type StreamSink, +} from './stream-protocol.js'; import { TestControls } from './test-controls.js'; interface RecordedCommand { - type: 'shell' | 'boundary' | 'update' | 'finish'; - name?: string; + type: 'start' | 'resume' | 'update'; + boundary?: BoundaryTarget; } class RecordingSink implements StreamSink { readonly commands: RecordedCommand[] = []; #changed: (() => void) | undefined; - shell(): Promise { - return this.#record({ type: 'shell' }); + start(): Promise { + return this.#record({ type: 'start' }); } - boundary(name: string): Promise { - return this.#record({ type: 'boundary', name }); + resume(boundary: BoundaryTarget): Promise { + return this.#record({ type: 'resume', boundary }); } - update(name: string): Promise { - return this.#record({ type: 'update', name }); - } - - finish(): Promise { - return this.#record({ type: 'finish' }); + update(boundary: BoundaryTarget): Promise { + return this.#record({ type: 'update', boundary }); } async waitFor(type: RecordedCommand['type'], name?: string): Promise { - while (!this.commands.some((command) => command.type === type && command.name === name)) { + while ( + !this.commands.some( + (command) => command.type === type && command.boundary?.name === name, + ) + ) { await new Promise((resolve) => { this.#changed = resolve; }); @@ -61,20 +65,22 @@ test('ready weather can arrive between feed boundaries', async () => { testSession: session, }); - await sink.waitFor('boundary', 'composer-ready'); + await sink.waitFor('resume', 'composer-ready'); session.releaseNextFeedGap(); - await sink.waitFor('boundary', 'feed-batch-1'); + await sink.waitFor('resume', 'feed-batch-1'); session.releaseWeather(); await sink.waitFor('update', 'weather-shell'); assert.equal( - sink.commands.some((command) => command.name === 'feed-batch-2'), + sink.commands.some((command) => command.boundary?.name === 'feed-batch-2'), false, ); session.releaseAll(); await streaming; assert.deepEqual( - sink.commands.filter((command) => command.type === 'boundary').map((command) => command.name), + sink.commands + .filter((command) => command.type === 'resume') + .map((command) => command.boundary?.name), [ 'weather-shell', 'composer-ready', @@ -83,7 +89,46 @@ test('ready weather can arrive between feed boundaries', async () => { 'feed-batch-3', ], ); - assert.equal(sink.commands.at(-1)?.type, 'finish'); + assert.equal(sink.commands.at(-1)?.boundary?.name, 'feed-batch-3'); + assert.equal( + sink.commands + .filter((command) => command.boundary) + .every((command) => command.boundary?.owner === 'streaming-page'), + true, + ); +}); + +test('a late weather update is sent before the final resume completes the stream', async () => { + const controls = new TestControls(); + const session = controls.session('late-weather'); + assert.ok(session); + const sink = new RecordingSink(); + const streaming = streamPage(sink, { + feedDelayMinMs: 0, + feedDelayMaxMs: 0, + testSession: session, + }); + + await sink.waitFor('resume', 'composer-ready'); + session.releaseNextFeedGap(); + await sink.waitFor('resume', 'feed-batch-1'); + session.releaseNextFeedGap(); + await sink.waitFor('resume', 'feed-batch-2'); + session.releaseNextFeedGap(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + sink.commands.some((command) => command.boundary?.name === 'feed-batch-3'), + false, + ); + + session.releaseWeather(); + await streaming; + const update = sink.commands.findIndex((command) => command.type === 'update'); + const finalResume = sink.commands.findIndex( + (command) => command.boundary?.name === 'feed-batch-3', + ); + assert.ok(update >= 0); + assert.ok(finalResume > update); }); test('every feed post carries the fields bound by feed-item', () => { @@ -124,11 +169,11 @@ test('disconnect cancellation stops before the next paced boundary', async () => signal: abort.signal, }); - await sink.waitFor('boundary', 'composer-ready'); + await sink.waitFor('resume', 'composer-ready'); abort.abort(); await assert.rejects(streaming, { name: 'AbortError' }); assert.equal( - sink.commands.some((command) => command.name === 'feed-batch-1'), + sink.commands.some((command) => command.boundary?.name === 'feed-batch-1'), false, ); session.releaseAll(); diff --git a/examples/app/streaming/server/src/pacing.ts b/examples/app/streaming/server/src/pacing.ts index 66a2030ad..596df813e 100644 --- a/examples/app/streaming/server/src/pacing.ts +++ b/examples/app/streaming/server/src/pacing.ts @@ -4,14 +4,20 @@ import { randomInt } from 'node:crypto'; import { createForecast, STREAMING_STATE, type ForecastState } from './data.js'; -import type { StreamSink } from './stream-protocol.js'; +import type { BoundaryTarget, StreamSink } from './stream-protocol.js'; import type { TestSession } from './test-controls.js'; export const FEED_BATCH_COUNT = 3; export const WEATHER_DELAY_MIN_MS = 700; const WEATHER_DELAY_MAX_MS = 1_400; -const FEED_BOUNDARIES = ['feed-batch-1', 'feed-batch-2', 'feed-batch-3'] as const; +const WEATHER_BOUNDARY = pageBoundary('weather-shell'); +const COMPOSER_BOUNDARY = pageBoundary('composer-ready'); +const FEED_BOUNDARIES = [ + pageBoundary('feed-batch-1'), + pageBoundary('feed-batch-2'), + pageBoundary('feed-batch-3'), +] as const; export interface PacingOptions { feedDelayMinMs: number; @@ -25,9 +31,9 @@ type ReadyWork = | { type: 'feed' }; export async function streamPage(sink: StreamSink, options: PacingOptions): Promise { - await sink.shell(STREAMING_STATE); - await sink.boundary('weather-shell', 'updatable'); - await sink.boundary('composer-ready'); + await sink.start(STREAMING_STATE); + await sink.resume(WEATHER_BOUNDARY, {}, 'updatable'); + await sink.resume(COMPOSER_BOUNDARY, {}); const weather = loadForecast(options.testSession, options.signal).then( (forecast): ReadyWork => ({ type: 'weather', forecast }), @@ -39,23 +45,26 @@ export async function streamPage(sink: StreamSink, options: PacingOptions): Prom if (weatherPending) { const ready = await Promise.race([weather, feed]); if (ready.type === 'weather') { - await sink.update('weather-shell', ready.forecast); + await sink.update(WEATHER_BOUNDARY, ready.forecast); weatherPending = false; await feed; } } else { await feed; } - await sink.boundary(FEED_BOUNDARIES[batch]); - } - - if (weatherPending) { - const ready = await weather; - if (ready.type === 'weather') { - await sink.update('weather-shell', ready.forecast); + if (batch === FEED_BATCH_COUNT - 1 && weatherPending) { + const ready = await weather; + if (ready.type === 'weather') { + await sink.update(WEATHER_BOUNDARY, ready.forecast); + weatherPending = false; + } } + await sink.resume(FEED_BOUNDARIES[batch], {}); } - await sink.finish(); +} + +function pageBoundary(name: string): BoundaryTarget { + return { owner: 'streaming-page', name }; } async function waitForFeed(options: PacingOptions, batch: number): Promise { diff --git a/examples/app/streaming/server/src/stream-protocol.ts b/examples/app/streaming/server/src/stream-protocol.ts index 3369c4f08..b7248370a 100644 --- a/examples/app/streaming/server/src/stream-protocol.ts +++ b/examples/app/streaming/server/src/stream-protocol.ts @@ -5,7 +5,7 @@ import type { ServerResponse } from 'node:http'; export const WEBUI_STREAM_MEDIA_TYPE = 'application/x-webui-stream'; -const WEBUI_STREAM_VERSION = 1; +const WEBUI_STREAM_VERSION = 2; const MAX_RECORD_BYTES = 2_000_000; export type JsonValue = @@ -20,17 +20,31 @@ export interface JsonObject { readonly [key: string]: JsonValue; } +export interface BoundaryTarget { + readonly owner: string; + readonly name: string; + readonly key?: string | number; + readonly declarationId?: number; +} + type StreamRecord = - | { type: 'shell'; version: typeof WEBUI_STREAM_VERSION; state: JsonObject } - | { type: 'boundary'; name: string; mode?: 'updatable'; state?: JsonObject } - | { type: 'update'; name: string; state: JsonObject } - | { type: 'finish'; state?: JsonObject }; + | { type: 'start'; version: typeof WEBUI_STREAM_VERSION; state: JsonObject } + | { + type: 'resume'; + boundary: BoundaryTarget; + mode?: 'updatable'; + state: JsonObject; + } + | { type: 'update'; boundary: BoundaryTarget; state: JsonObject }; export interface StreamSink { - shell(state: JsonObject): Promise; - boundary(name: string, mode?: 'final' | 'updatable', state?: JsonObject): Promise; - update(name: string, state: JsonObject): Promise; - finish(state?: JsonObject): Promise; + start(state: JsonObject): Promise; + resume( + boundary: BoundaryTarget, + state: JsonObject, + mode?: 'final' | 'updatable', + ): Promise; + update(boundary: BoundaryTarget, state: JsonObject): Promise; } export function acceptsWebUIStream(header: string | undefined): boolean { @@ -65,36 +79,24 @@ export class WebUIStreamWriter implements StreamSink { this.#response = response; } - shell(state: JsonObject): Promise { - return this.#write({ type: 'shell', version: WEBUI_STREAM_VERSION, state }); + start(state: JsonObject): Promise { + return this.#write({ type: 'start', version: WEBUI_STREAM_VERSION, state }); } - boundary( - name: string, + resume( + boundary: BoundaryTarget, + state: JsonObject, mode: 'final' | 'updatable' = 'final', - state?: JsonObject, ): Promise { - const record: StreamRecord = { type: 'boundary', name }; + const record: StreamRecord = { type: 'resume', boundary, state }; if (mode === 'updatable') { record.mode = mode; } - if (state !== undefined) { - record.state = state; - } return this.#write(record); } - update(name: string, state: JsonObject): Promise { - return this.#write({ type: 'update', name, state }); - } - - async finish(state?: JsonObject): Promise { - const record: StreamRecord = { type: 'finish' }; - if (state !== undefined) { - record.state = state; - } - await this.#write(record); - this.#response.end(); + update(boundary: BoundaryTarget, state: JsonObject): Promise { + return this.#write({ type: 'update', boundary, state }); } async #write(record: StreamRecord): Promise { diff --git a/examples/app/streaming/src/index.html b/examples/app/streaming/src/index.html index c70f6c1f9..9939391fe 100644 --- a/examples/app/streaming/src/index.html +++ b/examples/app/streaming/src/index.html @@ -35,79 +35,16 @@ line-height: var(--line-height-base); } - header { - margin-bottom: var(--spacing-xl); - } - - main { - display: flex; - flex-direction: column; - gap: var(--spacing-xl); - } - - section[aria-label="Feed"] { - display: flex; - flex-direction: column; - gap: var(--spacing-m); + streaming-page { + display: block; } -
-

Streaming Home

- - - - - - -
- -
- - - - -
- - - - - - - - - - - - - - - - - -
-
+ diff --git a/examples/app/streaming/src/index.ts b/examples/app/streaming/src/index.ts index 7d2864a78..6449ba42a 100644 --- a/examples/app/streaming/src/index.ts +++ b/examples/app/streaming/src/index.ts @@ -4,9 +4,8 @@ /** * Streaming priority-hydration entry point. * - * The server streams `index.html` as priority-ordered `` - * chunks, using the Progressive Streaming Hydration contract from - * DESIGN.md ("Progressive Streaming Hydration"): + * The entry renders one `` whose component template suspends + * at priority-ordered runtime boundaries: * * 1. The weather boundary commits first. It carries no server data, so it is * the cheapest checkpoint on the page — `weather-panel` hydrates while the @@ -15,7 +14,7 @@ * 2. The composer boundary (`message-composer`) commits next and must be * interactive before `DOMContentLoaded`, while the response is still open. * 3. Three feed boundaries commit afterward, each with its own `feed-item` - * islands, hydrating independently and in order as their chunks arrive. + * islands, before the parent page component emits its tail and completes. * * The component modules import `@microsoft/webui-framework`, which no longer * installs the streaming coordinator on its own. This entry imports diff --git a/examples/app/streaming/src/streaming-page/streaming-page.css b/examples/app/streaming/src/streaming-page/streaming-page.css new file mode 100644 index 000000000..b5fe7d452 --- /dev/null +++ b/examples/app/streaming/src/streaming-page/streaming-page.css @@ -0,0 +1,25 @@ +:host { + display: block; +} + +header { + margin-bottom: var(--spacing-xl); +} + +main { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +section[aria-label="Feed"] { + display: flex; + flex-direction: column; + gap: var(--spacing-m); +} + +footer { + margin-top: var(--spacing-xl); + color: var(--color-neutral-700); + font-size: var(--font-size-m); +} diff --git a/examples/app/streaming/src/streaming-page/streaming-page.html b/examples/app/streaming/src/streaming-page/streaming-page.html new file mode 100644 index 000000000..35abcf4a2 --- /dev/null +++ b/examples/app/streaming/src/streaming-page/streaming-page.html @@ -0,0 +1,46 @@ +
+

Streaming Home

+ + + + + + +
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + +
+
+ +
+ The parent component tail arrives only after its interactive children. +
diff --git a/examples/app/streaming/tests/streaming.spec.ts b/examples/app/streaming/tests/streaming.spec.ts index 6bfe5a4ef..6fbde341d 100644 --- a/examples/app/streaming/tests/streaming.spec.ts +++ b/examples/app/streaming/tests/streaming.spec.ts @@ -8,8 +8,9 @@ import { test, expect, type Page } from '@playwright/test'; * composer / weather / feed priority ordering, built on the boundary * contract in DESIGN.md ("Progressive Streaming Hydration"). * - * Checkpoint boundary IDs follow document order. Response record sequence - * numbers also include state updates and the terminal record: + * Runtime boundary IDs follow discovery order inside ``. + * Response record sequences also include state updates, the page component's + * span completion, and the terminal record: * * | Boundary ID | Boundary | Delivery | * | ----------- | ------------- | ----------------------------------- | @@ -20,8 +21,8 @@ import { test, expect, type Page } from '@playwright/test'; * | 4 | feed batch 3 | jittered 500-1000ms | * * In the full controlled release, the weather state update consumes response - * sequence 3 between feed batch 1 and feed batch 2; the terminal record follows - * the final checkpoint. + * sequence 3 between feed batch 1 and feed batch 2. The page span completion + * follows the final checkpoint, then the terminal closes the response. * * The Node API (`server/src/pacing.ts`) paces only the gaps that precede feed * batches, bounded by `--feed-delay-min-ms` / `--feed-delay-max-ms`, so @@ -37,7 +38,7 @@ import { test, expect, type Page } from '@playwright/test'; interface BoundaryEvent { sequence: number; terminal: boolean; - kind: 'checkpoint' | 'update' | 'terminal'; + kind: 'checkpoint' | 'span' | 'update' | 'terminal'; t: number; } @@ -145,10 +146,12 @@ test.describe('streaming priority hydration', () => { // still open at this point too — DOMContentLoaded needs the whole // (still-paced) response to finish parsing. expect(await page.evaluate(() => window.__dclFired)).toBe(false); + await expect(page.getByTestId('page-tail')).toHaveCount(0); await release(page, session, 'all'); await page.waitForLoadState('domcontentloaded'); expect(await page.evaluate(() => window.__dclFired)).toBe(true); + await expect(page.getByTestId('page-tail')).toBeVisible(); }); test('feed batch 1 hydrates and is interactive before batch 2 is delivered', async ({ page }) => { @@ -179,6 +182,7 @@ test.describe('streaming priority hydration', () => { const events = await boundaryEvents(page); const sequences = events.map((e) => e.sequence); expect(events.filter((event) => event.kind === 'checkpoint')).toHaveLength(5); + expect(events.filter((event) => event.kind === 'span')).toHaveLength(1); expect(events.filter((event) => event.kind === 'update')).toHaveLength(1); for (let i = 1; i < sequences.length; i++) { expect(sequences[i]).toBeGreaterThan(sequences[i - 1]); @@ -368,30 +372,43 @@ test.describe('streaming priority hydration', () => { await page.waitForFunction(() => window.__hydrationCompleteFired); const leftovers = await page.evaluate(() => { - const scripts = document.querySelectorAll('script[data-webui-boundary]').length; - const sentinels = document.querySelectorAll('webui-hydrate').length; + const pageRoot = document.querySelector('streaming-page')?.shadowRoot; + const roots: ParentNode[] = pageRoot ? [document, pageRoot] : [document]; + const scripts = roots.reduce( + (count, root) => count + root.querySelectorAll('script[data-webui-boundary]').length, + 0, + ); + const sentinels = roots.reduce( + (count, root) => count + root.querySelectorAll('webui-hydrate').length, + 0, + ); // `` conditions compile to an inline `templateFns` script emitted // between each payload and its sentinel. Those are boundary // scaffolding too, so a non-zero count means // `removeBoundaryScaffolding` missed them. The weather island's own // loader also lives in , inside its boundary, so it is excluded // by src — teardown must not treat authored content as scaffolding. - const bodyScripts = Array.from(document.body.querySelectorAll('script')).filter( + const bodyScripts = Array.from(pageRoot?.querySelectorAll('script') ?? []).filter( (script) => !script.src.endsWith('/weather-panel.js'), ).length; - const walker = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_COMMENT); let markers = 0; - let node: Node | null; - while ((node = walker.nextNode())) { - if (/^\/?wb:\d+$/.test((node as Comment).data)) markers++; + const markerRoots: Node[] = pageRoot + ? [document.documentElement, pageRoot] + : [document.documentElement]; + for (const root of markerRoots) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT); + let node: Node | null; + while ((node = walker.nextNode())) { + if (/^\/?w[bs]:\d+$/.test((node as Comment).data)) markers++; + } } // The island loader is authored markup, not scaffolding: it must // survive the teardown that removes everything else above. - const islandLoaders = document.querySelectorAll( + const islandLoaders = pageRoot?.querySelectorAll( 'script[src$="/weather-panel.js"]', - ).length; + ).length ?? 0; return { scripts, sentinels, markers, bodyScripts, islandLoaders }; }); @@ -615,4 +632,3 @@ test.describe('streaming reload recovery', () => { expect(errors, 'the degraded page produced errors').toEqual([]); }); }); - diff --git a/examples/integration/node/README.md b/examples/integration/node/README.md index cfd8f1940..a257f841d 100644 --- a/examples/integration/node/README.md +++ b/examples/integration/node/README.md @@ -62,14 +62,23 @@ they produced rather than writing them anywhere. Your server keeps the socket: ```js const session = protocol.streamResponse({ entry: 'index.html', requestPath: '/' }); -const status = session.boundary('job-status'); // resolve names once res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); -await write(res, session.writeShell(baseState)); -await write(res, session.writeBoundary(status, statusState, 'updatable')); +let step = session.start(baseState); +await write(res, step.bytes); + +const status = step.boundary; // runtime descriptor discovered by start() +step = session.resume(status.instanceId, statusState, 'updatable'); +await write(res, step.bytes); // ... later, on the same response: -await write(res, session.update(status, { jobState: 'succeeded' })); -res.end(session.finish({})); +await write(res, session.update(status.instanceId, { jobState: 'succeeded' })); + +while (!step.done) { + const boundary = step.boundary; + step = session.resume(boundary.instanceId, await stateFor(boundary)); + await write(res, step.bytes); +} +res.end(); async function write(res, chunk) { if (res.write(chunk)) return; @@ -99,19 +108,22 @@ client to see the chunk timing: ``` status 200 at 7 ms -chunk 1 627B at 8 ms shell ( ships before any work finishes) -chunk 2 670B at 8 ms job-status boundary, committed as updatable -chunk 3 875B at 418 ms log batch 1 -chunk 4 687B at 823 ms log batch 2 -chunk 5 156B at 918 ms update -> job-status, on this same response -chunk 6 626B at 1229 ms log batch 3 -chunk 7 128B at 1230 ms terminal record +step 1 start ( ships before any work finishes) +step 2 resume job-status as updatable +step 3 resume log batch 1 +step 4 resume log batch 2 +update patch job-status on this same response +step 5 resume log batch 3 and emit the terminal automatically ``` -Chunk 5 is the interesting one: the slow job finishes *after* its boundary was +The update is the interesting one: the slow job finishes *after* its boundary was already committed and hydrated, so the server patches it with a 156-byte state record instead of forcing a client-side fetch or replacing DOM. +The host never resolves authored names before `start()`. Every runtime +descriptor comes from the preceding `StreamStep`, which also covers boundaries +discovered through loops, routes, conditions, or component templates. + ### Scope This example covers the **response** half of streaming: chunking, ordering, diff --git a/examples/integration/node/streaming-server.js b/examples/integration/node/streaming-server.js index 63c03b2eb..e84adc679 100644 --- a/examples/integration/node/streaming-server.js +++ b/examples/integration/node/streaming-server.js @@ -84,21 +84,13 @@ async function handleRequest(request, response) { /** * Render one progressive response, one chunk per call. * - * Ordering is enforced by the session, so the shape below is the contract: - * shell first, then every boundary in declaration order, updates only to - * boundaries committed as `updatable`, and `finish()` last. + * `start()` discovers the first runtime occurrence. Every `resume()` uses the + * descriptor returned by the previous step, so loops, conditions, component + * boundaries, and routes need no pre-resolved name table. */ async function streamPage(response) { const session = protocol.streamResponse({ entry: "index.html", requestPath: "/" }); - // Names are authored strings; resolve them once, outside the write loop. - const jobStatus = session.boundary("job-status"); - const logBatches = [ - session.boundary("log-batch-1"), - session.boundary("log-batch-2"), - session.boundary("log-batch-3"), - ]; - response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", @@ -106,15 +98,17 @@ async function streamPage(response) { "X-Accel-Buffering": "no", }); - // The shell carries the state the document prefix needs; each boundary below - // carries only its own. - await write(response, session.writeShell({ jobState: "running", jobDetail: "" })); + let step = session.start({ jobState: "running", jobDetail: "" }); + await write(response, step.bytes); // Committed before its data exists, so nothing waits on the slow job. - await write( - response, - session.writeBoundary(jobStatus, { jobState: "running", jobDetail: "starting" }, "updatable"), + const jobStatus = pendingBoundary(step, "job-status"); + step = session.resume( + jobStatus.instanceId, + { jobState: "running", jobDetail: "starting" }, + "updatable", ); + await write(response, step.bytes); // Started, not awaited: the job races the log batches below. const job = runSlowJob(options.jobDelayMs); @@ -128,7 +122,7 @@ async function streamPage(response) { if (ready !== "batch") { // The job won: patch the already-committed boundary on this same // response. No second request, no DOM replacement, no re-hydration. - await write(response, session.update(jobStatus, ready)); + await write(response, session.update(jobStatus.instanceId, ready)); jobPending = false; await batch; } @@ -136,17 +130,34 @@ async function streamPage(response) { await batch; } - await write( - response, - session.writeBoundary(logBatches[index], { [`batch${index + 1}`]: LOG_BATCHES[index] }), - ); + if (index === LOG_BATCHES.length - 1 && jobPending) { + await write(response, session.update(jobStatus.instanceId, await job)); + jobPending = false; + } + const boundary = pendingBoundary(step, `log-batch-${index + 1}`); + step = session.resume(boundary.instanceId, { + [`batch${index + 1}`]: LOG_BATCHES[index], + }); + await write(response, step.bytes); } - if (jobPending) { - await write(response, session.update(jobStatus, await job)); + if (!step.done) { + throw new Error("streaming session did not complete after the final log batch"); } + response.end(); +} - response.end(session.finish({})); +function pendingBoundary(step, expectedName) { + const boundary = step.boundary; + if (step.done || !boundary) { + throw new Error(`expected pending boundary ${expectedName}, but the stream is done`); + } + if (boundary.owner !== "index.html" || boundary.name !== expectedName) { + throw new Error( + `expected index.html/${expectedName}, received ${boundary.owner}/${boundary.name}`, + ); + } + return boundary; } /** @@ -227,7 +238,7 @@ function reportFailure(response, error) { if (response.destroyed) { return; } - // Once the shell is on the wire the status line is already committed, so a + // Once start bytes are on the wire the status line is already committed, so a // late failure can only be signalled by dropping the connection. if (response.headersSent) { response.destroy(); diff --git a/examples/integration/rust/README.md b/examples/integration/rust/README.md index 4e032d0cf..ddee92d02 100644 --- a/examples/integration/rust/README.md +++ b/examples/integration/rust/README.md @@ -17,3 +17,17 @@ cargo run -- ../../app/hello-world/dist/protocol.bin ../../app/hello-world/data/ ``` This loads `protocol.bin`, passes the state from `state.json`, and prints the rendered HTML to stdout. + +For a protocol containing runtime `` declarations, drive the clean +cursor API directly: + +```bash +cargo run -- streaming-protocol.bin state.json --plugin=webui --streaming +``` + +The example calls `StreamingResponse::start()`, then resumes each +`BoundaryDescriptor::instance_id` returned by the preceding step. The final +resume emits the terminal automatically when `StreamStatus::done` becomes true; +there is no separate finish call. Real servers can commit an occurrence as +`BoundaryMode::Updatable` and call `update(instance_id, patch)` before the final +step. diff --git a/examples/integration/rust/src/main.rs b/examples/integration/rust/src/main.rs index 4597a98a8..f5a874d0a 100644 --- a/examples/integration/rust/src/main.rs +++ b/examples/integration/rust/src/main.rs @@ -13,12 +13,18 @@ //! //! # Render with WebUI Framework hydration markers //! cargo run -- ../../app/contact-book-manager/dist/protocol.bin ../../app/contact-book-manager/data/state.json --plugin=webui +//! +//! # Drive a progressive protocol through runtime boundary cursors +//! cargo run -- streaming-protocol.bin state.json --plugin=webui --streaming use anyhow::{Context, Result}; use std::env; use std::fs; +use std::io::Write; use webui_handler::plugin::webui::WebUIHydrationPlugin; -use webui_handler::{Protocol, RenderOptions, ResponseWriter, WebUIHandler}; +use webui_handler::{ + BoundaryMode, FlushWriter, Protocol, RenderOptions, ResponseWriter, WebUIHandler, +}; struct StdoutWriter; @@ -34,11 +40,17 @@ impl ResponseWriter for StdoutWriter { } } +impl FlushWriter for StdoutWriter { + fn flush(&mut self) -> webui_handler::Result<()> { + std::io::stdout().flush().map_err(Into::into) + } +} + fn main() -> Result<()> { let args: Vec = env::args().collect(); if args.len() < 3 { eprintln!( - "Usage: {} [--plugin=webui]", + "Usage: {} [--plugin=webui] [--streaming]", args[0] ); std::process::exit(1); @@ -68,14 +80,40 @@ fn main() -> Result<()> { None => WebUIHandler::new(), }; let mut writer = StdoutWriter; - handler - .render( - &protocol, - &state, - &RenderOptions::new("index.html", "/"), - &mut writer, - ) - .context("Failed to render")?; + let options = RenderOptions::new("index.html", "/"); + if args.iter().any(|argument| argument == "--streaming") { + render_streaming(&handler, &protocol, &state, &options, &mut writer)?; + } else { + handler + .render(&protocol, &state, &options, &mut writer) + .context("Failed to render")?; + } Ok(()) } + +fn render_streaming( + handler: &WebUIHandler, + protocol: &Protocol, + state: &serde_json::Value, + options: &RenderOptions<'_>, + writer: &mut StdoutWriter, +) -> Result<()> { + let mut session = handler + .stream_response(protocol, options, writer) + .context("Failed to open streaming session")?; + let mut step = session.start(state).context("Failed to start stream")?; + while !step.done { + let boundary = step + .boundary + .as_ref() + .context("Streaming step is unfinished but has no boundary descriptor")?; + let instance_id = boundary.instance_id; + let owner = boundary.owner.clone(); + let name = boundary.name.clone(); + step = session + .resume(instance_id, state, BoundaryMode::Final) + .with_context(|| format!("Failed to resume boundary {owner}/{name}"))?; + } + Ok(()) +} diff --git a/examples/integration/streaming-browser-bench/README.md b/examples/integration/streaming-browser-bench/README.md index c78287d31..a4acf0a4e 100644 --- a/examples/integration/streaming-browser-bench/README.md +++ b/examples/integration/streaming-browser-bench/README.md @@ -72,23 +72,28 @@ roots (1500) and the **same** total projected state value bytes (24 KiB of `label` values); only the boundary count (and marker layout) changes. "Projected state value bytes" counts the streamed `label` values a real app would ship, and deliberately excludes unavoidable per-boundary protocol/property overhead (the -`[1,seq,kind,target,{...}]` envelope framing, the first-boundary `templates` block, -and the tiny fixed `note` property) - that overhead is inherent to having more -boundaries, not equal work to hold constant. It is projected-state value bytes, -not total wire bytes. +v2 `[2,recordSequence,kind,target,{...}]` envelope framing, required +`declarationId`, the first-boundary `templates` block, and the tiny fixed `note` +property) - that overhead is inherent to having more boundaries, not equal work +to hold constant. It is projected-state value bytes, not total wire bytes. -Each streamed boundary is real wire format - `` markers + SSR roots -carrying `data-ws` + an inert `[data-webui-boundary]` JSON script + a +Each streamed boundary uses the v2 browser contract - `` markers + SSR +roots carrying `data-ws` + an inert `[data-webui-boundary]` JSON script + a `` sentinel - appended one at a time, with the driver spinning the coordinator's microtask pump until that boundary's scaffolding is removed (a -deterministic "committed + cleaned" signal) before the next, then a terminal -envelope. Consecutive chunks enter through separate `MessageChannel` tasks, -matching browser response-chunk scheduling without the nested timer clamp that -would add synthetic delay at 100 boundaries. The control uses the real inert -`#webui-data` bootstrap (present in the base document so the framework's lazy -loader latches on the real block); only its SSR roots are inserted at run time -(after the baseline heap sample) so its empty->populated peak-heap transition -matches the streaming arms. +deterministic "committed + cleaned" signal) before the next, then a kind-4 +terminal envelope. Final/updatable checkpoints use kinds 0/1, state updates use +kind 2, and kind 3 is reserved for generated span completion (unused by these flat +entry-boundary scenarios). Every checkpoint bootstrap carries a deterministic +`declarationId`; flat entry boundaries omit `enclosingSpanInstanceId`. Runtime +boundary instance IDs and record sequences are gapless, and updates target the +committed boundary instance ID. Consecutive chunks enter through separate +`MessageChannel` tasks, matching browser response-chunk scheduling without the +nested timer clamp that would add synthetic delay at 100 boundaries. The control +uses the real inert `#webui-data` bootstrap (present in the base document so the +framework's lazy loader latches on the real block); only its SSR roots are +inserted at run time (after the baseline heap sample) so its empty->populated +peak-heap transition matches the streaming arms. Coverage includes flat and deeply nested marker ranges (one root at each successive `
` depth) and the boundary-before-definition race (the class @@ -138,8 +143,8 @@ comments, `[data-ws]`), no globally-published streamed state (`window.__webui.state` stays unset), the ordinary bundle contains no coordinator tokens (`webui-hydrate` / `data-webui-boundary`), measured component CPU is non-zero, distinct boundary-local states reach only their own real activation -hooks, and the streaming entry adds no more than 10.625 KiB minified / 3.75 KiB -gzip. Esbuild output is deterministic and the cap retains under 4% headroom, so +hooks, and the streaming entry adds no more than 17.5 KiB minified / 6 KiB +gzip. Esbuild output is deterministic and the cap retains roughly 4% headroom, so further growth still fails. Opt-in via `WEBUI_STREAMING_HYDRATION_ENFORCE=1` (noisy, off by default), each diff --git a/examples/integration/streaming-browser-bench/tests/hydration_matrix.spec.ts b/examples/integration/streaming-browser-bench/tests/hydration_matrix.spec.ts index 74a932684..cd375da9f 100644 --- a/examples/integration/streaming-browser-bench/tests/hydration_matrix.spec.ts +++ b/examples/integration/streaming-browser-bench/tests/hydration_matrix.spec.ts @@ -95,17 +95,13 @@ const PEAK_HEAP_ABS_FLOOR_BYTES = 512 * 1024; const PEAK_HEAP_TOLERANCE_PCT = 15; /** Deterministic production coordinator size caps. Raising either requires * explicit review because every streaming application pays these bytes. - * Typed checkpoints, retained-root state records, and terminal cleanup add - * ~1.9KiB minified / ~650B gzip over the final-only coordinator; trusting our - * own serializer instead of re-validating its output gives ~1.2KiB / ~440B - * back. Commit `performance.mark`s and the opt-in time-sliced drain add ~650B - * minified / ~260B gzip: the marks are unconditional because a consumer that - * loads after hydration cannot have subscribed in time, and the drain is the - * only way to keep hydration off one long task when an intermediary coalesces - * the response. The caps leave under 3% headroom, so further growth still - * fails. */ -const STREAMING_INCREMENTAL_MINIFIED_CAP_BYTES = 11_520; -const STREAMING_INCREMENTAL_GZIP_CAP_BYTES = 4_096; + * The v2 coordinator includes checkpoint declaration/span validation, retained + * roots for state updates, span completion, terminal cleanup, commit marks, and + * the opt-in time-sliced drain. The reviewed production bundle is 17,147 bytes + * minified / 5,885 bytes gzip incrementally; these caps leave roughly 4% + * headroom, so further growth still fails. */ +const STREAMING_INCREMENTAL_MINIFIED_CAP_BYTES = 17.5 * 1024; +const STREAMING_INCREMENTAL_GZIP_CAP_BYTES = 6 * 1024; /** Marginal elapsed-time cap per added boundary. The relative allowance scales * with slower hosts while the absolute floor absorbs sub-millisecond noise. */ const COORDINATOR_MARGINAL_ABS_CAP_MS = 0.25; @@ -260,7 +256,56 @@ function balancedArmOrder(armCount: number, round: number): number[] { return order.slice(shift).concat(order.slice(0, shift)); } +function parseBoundaryRecord(fragment: string): unknown[] { + const prefix = '', start + prefix.length); + if (start < 0 || end < 0) { + throw new Error('benchmark fragment is missing its boundary record'); + } + const parsed: unknown = JSON.parse(fragment.slice(start + prefix.length, end)); + if (!Array.isArray(parsed)) { + throw new Error('benchmark boundary record is not an array'); + } + return parsed; +} + test.describe('progressive streaming hydration matrix', () => { + test('generates gapless v2 checkpoint, update, and terminal records', () => { + const checkpointScenario = buildStreamingScenario(3, 'flat', 'eager', true); + const checkpointRecords = [ + ...checkpointScenario.boundaries, + checkpointScenario.terminal, + ].map(parseBoundaryRecord); + + expect(checkpointRecords.map((record) => record.length)).toEqual([5, 5, 5, 5, 5]); + expect(checkpointRecords.map((record) => record[0])).toEqual([2, 2, 2, 2, 2]); + expect(checkpointRecords.map((record) => record[1])).toEqual([0, 1, 2, 3, 4]); + expect(checkpointRecords.map((record) => record[2])).toEqual([0, 0, 0, 0, 4]); + expect(checkpointRecords.map((record) => record[3])).toEqual([0, 1, 2, 3, 0]); + for (const record of checkpointRecords.slice(0, -1)) { + const bootstrap = record[4] as Record; + expect(bootstrap.declarationId).toBe(0); + expect( + Object.prototype.hasOwnProperty.call(bootstrap, 'enclosingSpanInstanceId'), + 'flat entry checkpoints omit enclosingSpanInstanceId', + ).toBe(false); + } + expect(checkpointRecords[4]).toEqual([2, 4, 4, 0, {}]); + + const updateScenario = buildStateUpdateScenario(3, 100); + const updateRecords = [ + ...updateScenario.boundaries, + updateScenario.terminal, + ].map(parseBoundaryRecord); + expect(updateRecords.map((record) => record[0])).toEqual([2, 2, 2, 2, 2]); + expect(updateRecords.map((record) => record[1])).toEqual([0, 1, 2, 3, 4]); + expect(updateRecords.map((record) => record[2])).toEqual([1, 2, 2, 2, 4]); + expect(updateRecords.map((record) => record[3])).toEqual([0, 0, 0, 0, 0]); + expect((updateRecords[0][4] as Record).declarationId).toBe(0); + expect(updateRecords[4]).toEqual([2, 4, 4, 0, {}]); + }); + test('measures real coordinator + WebUIElement hydration across boundary counts', async ({ browser }) => { const compareName = process.env.WEBUI_BENCH_COMPARE; const baseline = compareName ? loadSnapshot(compareName, ENFORCE) : null; @@ -287,11 +332,11 @@ test.describe('progressive streaming hydration matrix', () => { expect(coordinatorTokensIn(fixtures.streaming.code).length).toBeGreaterThan(0); expect( fixtures.streamingIncrementalBytes, - 'streaming coordinator incremental minified bytes stay within the reviewed 10.625KiB cap', + 'streaming coordinator incremental minified bytes stay within the reviewed 17.5KiB cap', ).toBeLessThanOrEqual(STREAMING_INCREMENTAL_MINIFIED_CAP_BYTES); expect( fixtures.streamingIncrementalGzipBytes, - 'streaming coordinator incremental gzip bytes stay within the reviewed 3.75KiB cap', + 'streaming coordinator incremental gzip bytes stay within the reviewed 6KiB cap', ).toBeLessThanOrEqual(STREAMING_INCREMENTAL_GZIP_CAP_BYTES); const bundle: BundleSizes = { diff --git a/examples/integration/streaming-browser-bench/tests/lib/lazy-fixtures.ts b/examples/integration/streaming-browser-bench/tests/lib/lazy-fixtures.ts index 7fabbef26..2ee42e790 100644 --- a/examples/integration/streaming-browser-bench/tests/lib/lazy-fixtures.ts +++ b/examples/integration/streaming-browser-bench/tests/lib/lazy-fixtures.ts @@ -36,15 +36,15 @@ const TODO_TEMPLATE = { h: '
', tr: ['title', 'description', 'priority', 'due'], tx: [ - [[[0, 1], 0], [['title']]], - [[[0, 2], 0], [['description']]], - [[[0, 3], 0], [['priority']]], - [[[0, 4], 0], [['due']]], + [[3, 0], [['title']]], + [[4, 0], [['description']]], + [[5, 0], [['priority']]], + [[6, 0], [['due']]], ], eg: [ ['click', [ - ['toggle', [], [0, 5]], - ['remove', [], [0, 6]], + ['toggle', [], 7], + ['remove', [], 8], ]], ], } as const; diff --git a/examples/integration/streaming-browser-bench/tests/lib/scenarios.ts b/examples/integration/streaming-browser-bench/tests/lib/scenarios.ts index 19d5ba634..153163324 100644 --- a/examples/integration/streaming-browser-bench/tests/lib/scenarios.ts +++ b/examples/integration/streaming-browser-bench/tests/lib/scenarios.ts @@ -15,7 +15,7 @@ * coordinator (`packages/webui-framework/src/streaming.ts`) parses: * * ... - * + * * * * No whitespace is emitted between the `` end marker and the @@ -37,10 +37,11 @@ export const TOTAL_ROOTS = 1500; * This counts only the bytes of the streamed `label` values (the dominant * projected state a real app would ship), summed across all boundaries. It * deliberately excludes the unavoidable per-boundary protocol/property overhead - * — the `[1,seq,kind,target,{...}]` envelope framing, the `templates` block (first - * boundary only), and the tiny fixed `note` property — because that overhead is - * inherent to having more boundaries and is not "equal work" to hold constant. - * It is therefore projected-state value bytes, not total wire bytes. + * — the v2 `[2,recordSequence,kind,target,{...}]` envelope framing, required + * `declarationId`, the `templates` block (first boundary only), and the tiny fixed + * `note` property — because that overhead is inherent to having more boundaries + * and is not "equal work" to hold constant. It is therefore projected-state + * value bytes, not total wire bytes. */ export const TOTAL_STATE_VALUE_BYTES = 24_000; @@ -50,6 +51,9 @@ export const BOUNDARY_COUNTS = [1, 3, 10, 100] as const; /** Custom-element tag every SSR root upgrades to. */ export const ISLAND_TAG = 'bench-island'; +/** Stable compiler declaration identity used by every benchmark checkpoint. */ +const BENCH_DECLARATION_ID = 0; + /** * Compiled template metadata for `bench-island`. * @@ -63,8 +67,8 @@ export const ISLAND_TEMPLATE = { h: '', tr: ['label', 'note'], tx: [ - [[[0], 0], [['label']]], - [[[1], 0], [['note']]], + [[1, 0], [['label']]], + [[2, 0], [['note']]], ], } as const; @@ -157,7 +161,8 @@ function nestedRoots(startCell: number, count: number): string { * first boundary; state (`label` value + fixed `note`) is emitted in every * boundary. `labelChars` is the projected-state value size for this boundary. */ function boundaryFragment( - seq: number, + recordSequence: number, + boundaryInstanceId: number, cellStart: number, rootCount: number, label: string, @@ -166,14 +171,21 @@ function boundaryFragment( kind: 0 | 1 = 0, ): string { const bootstrap: Record = { + declarationId: BENCH_DECLARATION_ID, state: { label, note: 'n' }, }; if (withTemplates) bootstrap.templates = { [ISLAND_TAG]: ISLAND_TEMPLATE }; - const envelope = JSON.stringify([1, seq, kind, seq, bootstrap]); + const envelope = JSON.stringify([ + 2, + recordSequence, + kind, + boundaryInstanceId, + bootstrap, + ]); const roots = layout === 'flat' ? flatRoots(cellStart, rootCount) : nestedRoots(cellStart, rootCount); - return `${roots}` + return `${roots}` + `` + `<${'webui-hydrate'}>`; } @@ -184,7 +196,7 @@ function stateUpdateFragment( label: string, ): string { const envelope = JSON.stringify([ - 1, + 2, recordSequence, 2, boundaryId, @@ -194,9 +206,9 @@ function stateUpdateFragment( + `<${'webui-hydrate'}>`; } -/** The terminal envelope: no markers, terminal kind 3, empty payload. */ -function terminalFragment(seq: number): string { - const envelope = JSON.stringify([1, seq, 3, 0, {}]); +/** The v2 terminal envelope: no markers, terminal kind 4, empty payload. */ +function terminalFragment(recordSequence: number): string { + const envelope = JSON.stringify([2, recordSequence, 4, 0, {}]); return `` + `<${'webui-hydrate'}>`; } @@ -205,11 +217,14 @@ function terminalFragment(seq: number): string { * content boundary parses roots after the class has the same template metadata. */ function templateSetupFragment(): string { const envelope = JSON.stringify([ - 1, + 2, 0, 0, 0, - { templates: { [ISLAND_TAG]: ISLAND_TEMPLATE } }, + { + declarationId: BENCH_DECLARATION_ID, + templates: { [ISLAND_TAG]: ISLAND_TEMPLATE }, + }, ]); return '' + `` @@ -235,6 +250,7 @@ export function buildStreamingScenario( for (let seq = 0; seq < boundaryCount; seq++) { boundaries.push( boundaryFragment( + seq + sequenceOffset, seq + sequenceOffset, cell, rootsPer[seq], @@ -273,6 +289,7 @@ export function buildStateDeliveryScenario( for (let seq = 0; seq < labels.length; seq++) { const label = labels[seq]; boundaries[seq] = boundaryFragment( + seq, seq, cell, rootsPerBoundary, @@ -303,6 +320,7 @@ export function buildStateUpdateScenario( ): StateUpdateScenario { const boundaries = new Array(updateCount + 1); boundaries[0] = boundaryFragment( + 0, 0, 0, rootCount, diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index 18cd2575b..3d92c45b2 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -199,12 +199,24 @@ authored `` directives through `@microsoft/webui-framework` entry has no dependency on the coordinator, so normal applications pay no streaming bundle or initialization cost. -Each committed boundary receives its own ephemeral state object directly during +Boundaries may be authored in entries and reusable components, including +runtime conditions, loops, outlets, and selected routes. A component-local +boundary uses a generated parent span, so an early compiler-marked child can +hydrate before the opaque parent tail in light or shadow DOM. Authored +boundaries cannot nest. + +Each runtime occurrence receives an ephemeral state object directly during activation. The coordinator does not publish that state to -`window.__webui.state`, and it removes generated checkpoint scaffolding after -commit. Every commit also emits a `performance.mark()` — `webui:boundary:`, -`webui:boundary::update`, or `webui:streaming:terminal` — which needs no -flag and no listener, so tooling that loads after hydration can still read it. +`window.__webui.state`, and it removes generated checkpoint and span +scaffolding after commit. Updates apply state to retained roots and never insert +markup or rerun hydration. + +The browser reads version-2 +`[2, sequence, kind, target, payload]` records for final checkpoints, +updatable checkpoints, updates, span completions, and terminal. Every commit +also emits a `performance.mark()` - `webui:boundary:`, +`webui:boundary::update`, `webui:span:`, or +`webui:streaming:terminal` - which needs no flag or listener. Set `window.__WEBUI_STREAMING_DEBUG__ = true` only when tooling needs the live `webui:boundary-hydrated` event as well. @@ -553,9 +565,8 @@ is driven by data (template metadata + state values), not code. Any language that can read the compiled metadata and produce HTML can serve as the SSR backend. No comment markers or data attributes are needed — the runtime resolves ordinary buffered SSR nodes via the lockstep hydration walk. -Progressive streaming uses temporary checkpoint scaffolding only to delay -activation until a complete region arrives; it removes that scaffolding after -commit. +Progressive streaming uses temporary checkpoint and generated-span scaffolding +to activate complete runtime regions; it removes that scaffolding after commit. ### Build → Serve → Hydrate → Update @@ -909,7 +920,8 @@ stylesheet specifier for a component. Unlike frameworks that use comment markers or data attributes to locate each dynamic binding, this framework uses **compiled element indices** — each -binding names its element by pre-order position within its compiled section. Progressive streaming's temporary boundary markers locate complete +binding names its element by pre-order position within its compiled section. +Progressive streaming's temporary boundary and span markers locate complete activation regions, not individual bindings. ### Client-created resolution (`collectTemplateElements`) diff --git a/packages/webui-framework/src/streaming-activation.ts b/packages/webui-framework/src/streaming-activation.ts index 910c3fe39..acd39b238 100644 --- a/packages/webui-framework/src/streaming-activation.ts +++ b/packages/webui-framework/src/streaming-activation.ts @@ -22,6 +22,7 @@ export function activateRootsBetween( endMarker: Comment, state: Record | undefined, updates?: PendingBoundaryUpdates, + bypassSpanInstanceId?: number, ): void { const root = startMarker.parentNode; if (!root) { @@ -35,7 +36,13 @@ export function activateRootsBetween( root, endMarker, state, - updates ? { updates, countRetention: true } : undefined, + updates || bypassSpanInstanceId !== undefined + ? { + updates, + countRetention: updates !== undefined, + bypassSpanInstanceId, + } + : undefined, ); if (!failure) return; abandonDeferredRange(startMarker, endMarker); diff --git a/packages/webui-framework/src/streaming-bootstrap.ts b/packages/webui-framework/src/streaming-bootstrap.ts index 7ccdfe945..cfe89ccf5 100644 --- a/packages/webui-framework/src/streaming-bootstrap.ts +++ b/packages/webui-framework/src/streaming-bootstrap.ts @@ -2,11 +2,14 @@ // Licensed under the MIT license. import { registerTemplateData } from './template.js'; -import type { BoundaryBootstrap } from './streaming-protocol.js'; +import type { + BoundaryBootstrap, + SpanCompletionPayload, +} from './streaming-protocol.js'; /** Register template data and merge response-scoped checkpoint metadata. */ export function applyBoundaryBootstrap( - bootstrap: BoundaryBootstrap, + bootstrap: BoundaryBootstrap | SpanCompletionPayload, ): void { if (bootstrap.templates) registerTemplateData(bootstrap.templates); @@ -17,7 +20,12 @@ export function applyBoundaryBootstrap( const key = keys[i]; // Templates are merged by registerTemplateData; boundary state remains // ephemeral and is handed directly to roots by the activation walk. - if (key === 'templates' || key === 'state') continue; + if ( + key === 'templates' || + key === 'state' || + key === 'declarationId' || + key === 'enclosingSpanInstanceId' + ) continue; if (key === 'inventory') { w.__webui.inventory = mergeInventory( w.__webui.inventory, diff --git a/packages/webui-framework/src/streaming-cleanup.ts b/packages/webui-framework/src/streaming-cleanup.ts index 9e0db7c8b..c992ae805 100644 --- a/packages/webui-framework/src/streaming-cleanup.ts +++ b/packages/webui-framework/src/streaming-cleanup.ts @@ -2,13 +2,24 @@ // Licensed under the MIT license. import { + BOUNDARY_END_PREFIX, + BOUNDARY_SCRIPT_ATTR, + BOUNDARY_START_PREFIX, firstNodeWithin, MAX_MARKER_SCAN_NODES, + nextAfterSubtreeWithin, nextWithinRoot, safeRemoveAttribute, + safeRemove, + SPAN_END_PREFIX, + SPAN_START_PREFIX, streamingErrorMessage, } from './streaming-dom.js'; -import { STREAMED_HOST_ATTR } from './streaming-mode.js'; +import { + STREAMED_HOST_ATTR, + STREAMING_ENCLOSING_SPAN_ATTR, + STREAMING_SPAN_HOST_ATTR, +} from './streaming-mode.js'; const STREAMING_BOUNDARY_ABANDON = Symbol.for( 'microsoft.webui.boundaryAbandon', @@ -20,10 +31,11 @@ type BoundaryAbandonable = Element & { /** Clear both coordinator and element-owned streaming state from one root. */ export function abandonDeferredElement(el: Element): void { + const marked = hasStreamingAttribute(el); try { - if (!el.hasAttribute(STREAMED_HOST_ATTR)) return; + if (!marked) return; } catch { - safeRemoveAttribute(el, STREAMED_HOST_ATTR); + removeStreamingAttributes(el); return; } try { @@ -38,10 +50,22 @@ export function abandonDeferredElement(el: Element): void { }>: ${streamingErrorMessage(error)}`, ); } finally { - safeRemoveAttribute(el, STREAMED_HOST_ATTR); + removeStreamingAttributes(el); } } +function hasStreamingAttribute(el: Element): boolean { + return el.hasAttribute(STREAMED_HOST_ATTR) || + el.hasAttribute(STREAMING_SPAN_HOST_ATTR) || + el.hasAttribute(STREAMING_ENCLOSING_SPAN_ATTR); +} + +function removeStreamingAttributes(el: Element): void { + safeRemoveAttribute(el, STREAMED_HOST_ATTR); + safeRemoveAttribute(el, STREAMING_SPAN_HOST_ATTR); + safeRemoveAttribute(el, STREAMING_ENCLOSING_SPAN_ATTR); +} + /** Release a retained subtree after its undefined outer fails activation. */ export function abandonDeferredDescendants(root: Element): void { abandonDeferredNodes(firstNodeWithin(root), root, null); @@ -83,6 +107,13 @@ export function abandonDeferredDocumentRoots(): void { return; } + const documentRoot = document.documentElement; + if (documentRoot) { + abandonStreamingNodes(documentRoot.firstChild, documentRoot); + abandonDeferredElement(documentRoot); + return; + } + const elements = document.getElementsByTagName('*'); let visited = 0; for ( @@ -103,4 +134,38 @@ export function abandonDeferredDocumentRoots(): void { node = nextWithinRoot(node, shadowRoot!); } } + + function abandonStreamingNodes(first: Node | null, root: Node): void { + let node = first; + let visited = 0; + while (node && visited < MAX_MARKER_SCAN_NODES) { + visited++; + if (node.nodeType === 8 /* COMMENT_NODE */) { + const next = nextWithinRoot(node, root); + if (isStreamingMarker((node as Comment).data)) safeRemove(node); + node = next; + continue; + } + if (node.nodeType === 1 /* ELEMENT_NODE */) { + const el = node as Element; + const scaffold = el.tagName === 'WEBUI-HYDRATE' || + el.hasAttribute(BOUNDARY_SCRIPT_ATTR); + const next = scaffold + ? nextAfterSubtreeWithin(node, root) + : nextWithinRoot(node, root); + abandonDeferredElement(el); + if (scaffold) safeRemove(el); + node = next; + continue; + } + node = nextWithinRoot(node, root); + } + } + + function isStreamingMarker(data: string): boolean { + return data.startsWith(BOUNDARY_START_PREFIX) || + data.startsWith(BOUNDARY_END_PREFIX) || + data.startsWith(SPAN_START_PREFIX) || + data.startsWith(SPAN_END_PREFIX); + } } diff --git a/packages/webui-framework/src/streaming-coordinator.ts b/packages/webui-framework/src/streaming-coordinator.ts index 83e76962c..38ab9cec7 100644 --- a/packages/webui-framework/src/streaming-coordinator.ts +++ b/packages/webui-framework/src/streaming-coordinator.ts @@ -26,6 +26,7 @@ import { abandonPendingWaiters, configureStreamingFailureHandler, elementHasPendingStateForTests, + pendingBarrierRootCountForTests, pendingTagWaiterCountForTests, pendingUndefinedRootCountForTests, resetDeferredActivationForTests, @@ -33,33 +34,47 @@ import { import type { PendingBoundaryUpdates } from './streaming-deferred.js'; import { findBoundaryScript, - findEndMarkerByPrefix, - findStartMarkerByPrefix, + findRangeEndMarkerByPrefix, + findRangeStartMarkerByPrefix, removeBoundaryScaffolding, resolveBoundaryRange, + resolveMarkerlessRecord, + resolveSpanRange, streamingErrorMessage, } from './streaming-dom.js'; import type { HydrationRange } from './streaming-dom.js'; import { parseBoundaryEnvelope, + RECORD_KIND_FINAL_CHECKPOINT, + RECORD_KIND_SPAN_COMPLETION, RECORD_KIND_STATE_UPDATE, RECORD_KIND_TERMINAL, RECORD_KIND_UPDATABLE_CHECKPOINT, } from './streaming-protocol.js'; -import type { BoundaryBootstrap } from './streaming-protocol.js'; +import type { + BoundaryBootstrap, + SpanCompletionPayload, +} from './streaming-protocol.js'; import { applyStateUpdate } from './streaming-state.js'; +import { + abandonOpenSpans, + completeSpan, + hasOpenSpans, + openSpanCountForTests, + registerEnclosingSpans, + registerSpanCompletionTarget, + validateSpanCompletion, +} from './streaming-spans.js'; const MAX_QUEUED_BOUNDARIES = 512; const MAX_UPDATABLE_BOUNDARIES = 128; const MAX_RETAINED_UPDATE_ROOTS = 50_000; const BOUNDARY_HYDRATED_EVENT = 'webui:boundary-hydrated'; -/** `performance.mark()` label prefix. The suffix is the compile-time boundary - * ID — its declaration index — so a mark resolves back to the authored - * `` through the build manifest without any name string ever - * reaching the wire (rule 18). */ +/** `performance.mark()` prefix; suffixes are runtime BoundaryInstanceIds. */ const BOUNDARY_MARK_PREFIX = 'webui:boundary:'; const UPDATE_MARK_SUFFIX = ':update'; const TERMINAL_MARK = 'webui:streaming:terminal'; +const SPAN_MARK_PREFIX = 'webui:span:'; /** Captured once: marks and the slice clock must not re-resolve per commit. */ const perf = (globalThis as { performance?: Performance }).performance; @@ -71,7 +86,7 @@ let pumpScheduled = false; let slicedDrainActive = false; let halted = false; let nextExpectedRecordSequence = 0; -let nextExpectedBoundaryId = 0; +let nextExpectedBoundaryInstanceId = 0; let terminalCommitted = false; let pendingTerminalSequence: number | null = null; let terminalValidationScheduled = false; @@ -201,6 +216,7 @@ function fail(reason: string): void { // successful completion event while their failure cleanup drains. abortStreamingGate(); abandonPendingWaiters(); + abandonOpenSpans(); clearUpdatableBoundaries(); settlePendingTerminal(false); for (let i = queueHead; i < queue.length; i++) { @@ -221,8 +237,8 @@ function discardRejectedBoundary(sentinel: Element): void { let endMarker: Comment | null = null; let startMarker: Comment | null = null; if (scriptEl) { - endMarker = findEndMarkerByPrefix(scriptEl); - if (endMarker) startMarker = findStartMarkerByPrefix(endMarker); + endMarker = findRangeEndMarkerByPrefix(scriptEl); + if (endMarker) startMarker = findRangeStartMarkerByPrefix(endMarker); } if (startMarker && endMarker) { abandonDeferredRange(startMarker, endMarker); @@ -269,6 +285,11 @@ function processSentinel(sentinel: Element): void { } if (kind === RECORD_KIND_STATE_UPDATE) { + const markerless = resolveMarkerlessRecord(scriptEl, 'state update'); + if (!markerless.ok) { + failBoundary(sentinel, markerless.reason); + return; + } const boundary = updatableBoundaries.get(target); if (!boundary) { failBoundary( @@ -290,9 +311,16 @@ function processSentinel(sentinel: Element): void { } if (kind === RECORD_KIND_TERMINAL) { - const resolved = resolveBoundaryRange(scriptEl, 0, true); - if (!resolved.ok) { - failBoundary(sentinel, resolved.reason); + const markerless = resolveMarkerlessRecord(scriptEl, 'terminal'); + if (!markerless.ok) { + failBoundary(sentinel, markerless.reason); + return; + } + if (hasOpenSpans()) { + failBoundary( + sentinel, + 'terminal record arrived before every component span completed', + ); return; } nextExpectedRecordSequence++; @@ -300,34 +328,47 @@ function processSentinel(sentinel: Element): void { return; } - if (target !== nextExpectedBoundaryId) { + if (kind === RECORD_KIND_SPAN_COMPLETION) { + const resolved = resolveSpanRange(scriptEl, target); + if (!resolved.ok) { + failRangeResolution(sentinel, scriptEl, resolved); + return; + } + nextExpectedRecordSequence++; + commitSpanCompletion( + payload as SpanCompletionPayload, + resolved.range, + sequence, + target, + sentinel, + scriptEl, + ); + return; + } + + if ( + kind !== RECORD_KIND_FINAL_CHECKPOINT && + kind !== RECORD_KIND_UPDATABLE_CHECKPOINT + ) { + failBoundary(sentinel, `unsupported streaming record kind ${kind}`); + return; + } + + if (target !== nextExpectedBoundaryInstanceId) { failBoundary( sentinel, - `expected boundary ID ${nextExpectedBoundaryId}, received ${target}`, + `expected boundary instance ${nextExpectedBoundaryInstanceId}, received ${target}`, ); return; } - const resolved = resolveBoundaryRange(scriptEl, target, false); + const resolved = resolveBoundaryRange(scriptEl, target); if (!resolved.ok) { - if (resolved.truncated) { - if (resolved.start) { - abandonDeferredRange(resolved.start, scriptEl); - } - removeBoundaryScaffolding( - sentinel, - scriptEl, - resolved.start, - null, - ); - fail(resolved.reason); - } else { - failBoundary(sentinel, resolved.reason); - } + failRangeResolution(sentinel, scriptEl, resolved); return; } nextExpectedRecordSequence++; - nextExpectedBoundaryId++; + nextExpectedBoundaryInstanceId++; commitCheckpoint( payload as BoundaryBootstrap, resolved.range, @@ -351,21 +392,33 @@ function commitCheckpoint( markBoundaryPending(); let committed = false; try { + if ( + bootstrap.enclosingSpanInstanceId !== undefined && + range.start?.parentNode + ) { + const invalid = registerEnclosingSpans( + range.start.parentNode, + bootstrap.enclosingSpanInstanceId, + ); + if (invalid) throw new Error(invalid); + } applyBoundaryBootstrap(bootstrap); if (range.start && range.end) { const boundary: UpdatableBoundary | undefined = updatable - ? { roots: [], retained: 0, pendingRoots: 0 } + ? { roots: [], active: true, retained: 0, pendingRoots: 0 } : undefined; activateRootsBetween( range.start, range.end, bootstrap.state, boundary, + bootstrap.enclosingSpanInstanceId, ); if (boundary) retainUpdatableBoundary(target, boundary); } else if (updatable) { retainUpdatableBoundary(target, { roots: [], + active: true, retained: 0, pendingRoots: 0, }); @@ -391,6 +444,72 @@ function commitCheckpoint( } } +function commitSpanCompletion( + payload: SpanCompletionPayload, + range: HydrationRange, + sequence: number, + target: number, + sentinel: Element, + scriptEl: Element, +): void { + markBoundaryPending(); + let committed = false; + try { + const registrationError = registerSpanCompletionTarget(target, range); + if (registrationError) throw new Error(registrationError); + const invalid = validateSpanCompletion(target, range); + if (invalid) throw new Error(invalid); + applyBoundaryBootstrap(payload); + if (!range.start || !range.end) { + throw new Error(`span ${target} completion is markerless`); + } + activateRootsBetween( + range.start, + range.end, + payload.state, + ); + completeSpan(target); + committed = true; + } catch (error) { + fail( + `error completing span ${target}: ${streamingErrorMessage(error)}`, + ); + } finally { + removeBoundaryScaffolding( + sentinel, + scriptEl, + range.start, + range.end, + ); + if (committed) { + notifyCommit(`${SPAN_MARK_PREFIX}${target}`, sequence, 'span'); + } + markBoundaryCommitted(false); + } +} + +function failRangeResolution( + sentinel: Element, + scriptEl: Element, + resolved: Exclude< + ReturnType, + { readonly ok: true } + >, +): void { + if (!resolved.truncated) { + failBoundary(sentinel, resolved.reason); + return; + } + if (resolved.start) abandonDeferredRange(resolved.start, scriptEl); + removeBoundaryScaffolding( + sentinel, + scriptEl, + resolved.start, + null, + ); + fail(resolved.reason); +} + function retainUpdatableBoundary( target: number, boundary: UpdatableBoundary, @@ -466,11 +585,18 @@ function commitTerminal( removeBoundaryScaffolding(sentinel, scriptEl, null, null); terminalCommitted = true; pendingTerminalSequence = sequence; - clearUpdatableBoundaries(); + clearUpdatableBoundaries(true); scheduleTerminalValidation(); } -function clearUpdatableBoundaries(): void { +function clearUpdatableBoundaries(preservePendingPatches = false): void { + for (const boundary of updatableBoundaries.values()) { + boundary.active = false; + boundary.roots.length = 0; + if (!preservePendingPatches || boundary.pendingRoots === 0) { + boundary.patch = undefined; + } + } updatableBoundaries.clear(); retainedUpdateRoots = 0; } @@ -570,6 +696,7 @@ function onDomContentLoaded(generation: number): void { /** Reset coordinator singletons and invalidate queued promise reactions. */ export function resetStreamingCoordinatorStateForTests(): void { resetDeferredActivationForTests(); + abandonOpenSpans(); settlePendingTerminal(false); clearUpdatableBoundaries(); coordinatorGeneration++; @@ -579,7 +706,7 @@ export function resetStreamingCoordinatorStateForTests(): void { slicedDrainActive = false; halted = false; nextExpectedRecordSequence = 0; - nextExpectedBoundaryId = 0; + nextExpectedBoundaryInstanceId = 0; terminalCommitted = false; pendingTerminalSequence = null; terminalValidationScheduled = false; @@ -595,6 +722,8 @@ export function streamingRetentionStateForTests(): readonly [number, number] { export { elementHasPendingStateForTests, + openSpanCountForTests, + pendingBarrierRootCountForTests, pendingTagWaiterCountForTests, pendingUndefinedRootCountForTests, }; diff --git a/packages/webui-framework/src/streaming-deferred.ts b/packages/webui-framework/src/streaming-deferred.ts index 1513cced4..0b00bcc17 100644 --- a/packages/webui-framework/src/streaming-deferred.ts +++ b/packages/webui-framework/src/streaming-deferred.ts @@ -20,7 +20,9 @@ import { } from './streaming-dom.js'; import { PENDING_ROOT_CONNECTED, + STREAMING_ENCLOSING_SPAN_ATTR, STREAMED_HOST_ATTR, + STREAMING_SPAN_HOST_ATTR, STREAMING_BOUNDARY_ACTIVATE, } from './streaming-mode.js'; import { applyStateUpdate } from './streaming-state.js'; @@ -28,14 +30,19 @@ import { applyStateUpdate } from './streaming-state.js'; const ACTIVATION_ACTIVATED = 1; const ACTIVATION_STATIC_HOST_OPT_OUT = 2; export const ACTIVATION_MISSING_TEMPLATE = 3; +const ACTIVATION_ANCESTOR_BARRIER = 4; export const ELEMENT_IGNORED = 0; export const ELEMENT_DEFERRED = 4; export const ELEMENT_LIMIT_FAILURE = 5; +const ELEMENT_ACTIVATED_FROM_PENDING = 6; +const ELEMENT_BARRIER_LIMIT_FAILURE = 7; export const MAX_PENDING_UNDEFINED_ROOTS = 50_000; +export const MAX_PENDING_BARRIER_ROOTS = 50_000; type BoundaryActivatable = Element & { [STREAMING_BOUNDARY_ACTIVATE]?: ( state?: Record, + bypassSpanInstanceId?: number, ) => number; }; @@ -45,13 +52,16 @@ interface PendingTagWaiter { } const pendingTagWaiters = new Map(); +const pendingBarrierRoots = new Set(); let pendingUndefinedRoots = 0; let activationGeneration = 0; let failureHandler: ((reason: string) => void) | null = null; const PENDING_BOUNDARY_STATE = Symbol(); const PENDING_BOUNDARY_UPDATES = Symbol(); +const PENDING_BYPASS_SPAN = Symbol(); const NO_BOUNDARY_STATE: unique symbol = Symbol(); +const NO_BYPASS_SPAN: unique symbol = Symbol(); /** One boundary-owned shallow patch shared by every deferred root. */ export interface PendingBoundaryUpdates { @@ -62,6 +72,12 @@ export interface PendingBoundaryUpdates { * absence means "finished with", not "activated". */ readonly roots: Element[]; + /** + * True while the response can still address this target. A successful + * terminal clears it but may leave `patch` alive until already-pending roots + * replay that last committed state. Fatal cleanup clears both. + */ + active: boolean; /** * Marked roots seen by the checkpoint scan, including ones still deferred or * destined to fail. Bounds retention pessimistically so a boundary cannot @@ -74,6 +90,8 @@ export interface PendingBoundaryUpdates { export interface DeferredActivationOptions { updates?: PendingBoundaryUpdates; + /** Span barrier this boundary's compiler-marked early roots may bypass. */ + bypassSpanInstanceId?: number; /** * Set only by the checkpoint scan, which owns the boundary's retention * budget. A late activation re-walks a subtree the scan already counted, so @@ -131,11 +149,33 @@ function activateMarkedElement( el: Element, state: Record | undefined, updates?: PendingBoundaryUpdates, + bypassSpanInstanceId?: number, ): number { const tag = el.tagName.toLowerCase(); if (tag.indexOf('-') === -1) return ELEMENT_IGNORED; - if (customElements.get(tag)) return invokeActivationHook(el, state); + if (customElements.get(tag)) { + if (pendingBarrierRoots.has(el)) { + return activatePendingBarrierRoot(el); + } + // A definition waiter still owns this root until its shared reaction runs. + // Consuming its state here would leave the waiter count and lifecycle stuck. + if (hasPendingState(el)) return ELEMENT_DEFERRED; + const outcome = invokeActivationHook( + el, + state, + bypassSpanInstanceId, + ); + if (outcome !== ACTIVATION_ANCESTOR_BARRIER) return outcome; + if (pendingBarrierRoots.size >= MAX_PENDING_BARRIER_ROOTS) { + return ELEMENT_BARRIER_LIMIT_FAILURE; + } + stashPendingState(el, state, updates, bypassSpanInstanceId); + pendingBarrierRoots.add(el); + (el as PendingRoot)[PENDING_ROOT_CONNECTED] = resumeBarrierRoot; + return ELEMENT_DEFERRED; + } + if ( !hasPendingState(el) && pendingUndefinedRoots >= MAX_PENDING_UNDEFINED_ROOTS @@ -143,7 +183,9 @@ function activateMarkedElement( return ELEMENT_LIMIT_FAILURE; } - stashPendingState(el, state, updates); + if (!hasPendingState(el)) { + stashPendingState(el, state, updates, bypassSpanInstanceId); + } let waiter = pendingTagWaiters.get(tag); if (!waiter) { waiter = { generation: activationGeneration, roots: new Set() }; @@ -162,6 +204,65 @@ function activateMarkedElement( return ELEMENT_DEFERRED; } +function activatePendingBarrierRoot(el: Element): number { + if (!pendingBarrierRoots.delete(el)) return ELEMENT_IGNORED; + delete (el as PendingRoot)[PENDING_ROOT_CONNECTED]; + let updates: PendingBoundaryUpdates | undefined; + try { + updates = takePendingUpdates(el); + const state = takePendingState(el); + const bypassSpanInstanceId = takePendingBypassSpan(el); + const outcome = invokeActivationHook( + el, + state, + bypassSpanInstanceId, + ); + if (outcome === ACTIVATION_ANCESTOR_BARRIER) { + stashPendingState( + el, + state, + updates, + bypassSpanInstanceId, + ); + pendingBarrierRoots.add(el); + (el as PendingRoot)[PENDING_ROOT_CONNECTED] = resumeBarrierRoot; + return ELEMENT_DEFERRED; + } + if ( + updates && + (outcome === ACTIVATION_ACTIVATED || + outcome === ACTIVATION_STATIC_HOST_OPT_OUT) + ) { + if (updates.active) updates.roots.push(el); + if (updates.patch) requireStateUpdate(el, updates.patch); + } + return outcome === ACTIVATION_MISSING_TEMPLATE + ? outcome + : ELEMENT_ACTIVATED_FROM_PENDING; + } finally { + if (updates?.pendingRoots === 0) updates.patch = undefined; + } +} + +/** Resume coordinator-owned activation when a component barrier releases. */ +function resumeBarrierRoot(this: Element): void { + try { + const outcome = activatePendingBarrierRoot(this); + if (outcome === ACTIVATION_MISSING_TEMPLATE) { + abandonDeferredDescendants(this); + abandonDeferredElement(this); + fail( + `template metadata missing while activating <${ + this.tagName.toLowerCase() + }>`, + ); + } + } catch (error) { + abandonDeferredDescendants(this); + reportActivationFailure(this.tagName.toLowerCase(), error); + } +} + function onTagDefined(tag: string, generation: number): void { if (generation !== activationGeneration) return; const waiter = pendingTagWaiters.get(tag); @@ -221,18 +322,42 @@ function activatePendingRoot( try { updates = takePendingUpdates(el); const state = takePendingState(el); - const outcome = invokeActivationHook(el, state); + const bypassSpanInstanceId = takePendingBypassSpan(el); + const outcome = invokeActivationHook( + el, + state, + bypassSpanInstanceId, + ); if (outcome === ACTIVATION_MISSING_TEMPLATE) { abandonDeferredDescendants(el); abandonDeferredElement(el); fail(`template metadata missing while activating <${tag}>`); return; } + if (outcome === ACTIVATION_ANCESTOR_BARRIER) { + if (pendingBarrierRoots.size >= MAX_PENDING_BARRIER_ROOTS) { + abandonDeferredDescendants(el); + abandonDeferredElement(el); + fail( + `pending ancestor-barrier root count exceeds ${MAX_PENDING_BARRIER_ROOTS}`, + ); + return; + } + stashPendingState( + el, + state, + updates, + bypassSpanInstanceId, + ); + pendingBarrierRoots.add(el); + (el as PendingRoot)[PENDING_ROOT_CONNECTED] = resumeBarrierRoot; + return; + } // Parent first, and before the descendant walk: this root's own patch may // tear down the branch its retained descendants live in, and activating a // root inside an already-discarded branch is worse than never reaching it. if (updates) { - updates.roots.push(el); + if (updates.active) updates.roots.push(el); if (updates.patch) requireStateUpdate(el, updates.patch); } const failure = activateDeferredTree( @@ -240,7 +365,11 @@ function activatePendingRoot( el, null, state, - updates ? { updates } : undefined, + updates + ? { updates, bypassSpanInstanceId } + : bypassSpanInstanceId === undefined + ? undefined + : { bypassSpanInstanceId }, ); if (failure) fail(failure); } catch (error) { @@ -274,6 +403,7 @@ export function activateDeferredTree( // Hoisted out of the walk: these are read once per node otherwise, and this // loop runs over every node of every boundary. const updates = options?.updates; + const bypassSpanInstanceId = options?.bypassSpanInstanceId; const countRetention = options?.countRetention === true && updates !== undefined; let node = first; @@ -315,7 +445,12 @@ export function activateDeferredTree( if (marked) { const el = node as Element; try { - const outcome = activateMarkedElement(el, state, updates); + const outcome = activateMarkedElement( + el, + state, + updates, + bypassSpanInstanceId, + ); if (outcome === ACTIVATION_MISSING_TEMPLATE) { return `template metadata missing while activating <${ el.tagName.toLowerCase() @@ -324,17 +459,21 @@ export function activateDeferredTree( if (outcome === ELEMENT_LIMIT_FAILURE) { return `pending undefined root count exceeds ${MAX_PENDING_UNDEFINED_ROOTS}`; } + if (outcome === ELEMENT_BARRIER_LIMIT_FAILURE) { + return `pending ancestor-barrier root count exceeds ${MAX_PENDING_BARRIER_ROOTS}`; + } if (outcome === ELEMENT_DEFERRED) { resumeAfterDeferred = nextAfterSubtreeWithin(node, root); skippingDeferredDescendants = true; } else if ( updates && + outcome !== ELEMENT_ACTIVATED_FROM_PENDING && (outcome === ACTIVATION_ACTIVATED || outcome === ACTIVATION_STATIC_HOST_OPT_OUT) ) { // Joining here, on a known-good outcome, is what keeps a failed or // ignored element out of the update set for the life of the page. - updates.roots.push(el); + if (updates.active) updates.roots.push(el); // Replayed rather than merged into hydration state: `$hydrate` wires // bindings against the server's bytes without evaluating them, so // seeding a post-render value first would bind the old branch while @@ -365,20 +504,25 @@ function reportActivationFailure(tag: string, error: unknown): void { /** Balance and clear every pending undefined-tag waiter exactly once. */ export function abandonPendingWaiters(): void { - if (pendingTagWaiters.size === 0) { - pendingUndefinedRoots = 0; - return; + if (pendingBarrierRoots.size !== 0) { + for (const el of pendingBarrierRoots) clearPendingRoot(el); + pendingBarrierRoots.clear(); } - for (const waiter of pendingTagWaiters.values()) { - for (const el of waiter.roots) clearPendingRoot(el); - settleLateActivation(); + if (pendingTagWaiters.size !== 0) { + for (const waiter of pendingTagWaiters.values()) { + for (const el of waiter.roots) clearPendingRoot(el); + settleLateActivation(); + } + pendingTagWaiters.clear(); } - pendingTagWaiters.clear(); pendingUndefinedRoots = 0; } function clearPendingRoot(el: Element): void { - if (hasPendingState(el)) takePendingState(el); + if (hasPendingState(el)) { + takePendingState(el); + takePendingBypassSpan(el); + } takePendingUpdates(el); delete (el as PendingRoot)[PENDING_ROOT_CONNECTED]; abandonDeferredDescendants(el); @@ -389,10 +533,14 @@ function stashPendingState( el: Element, state: Record | undefined, updates?: PendingBoundaryUpdates, + bypassSpanInstanceId?: number, ): void { const store = el as unknown as Record; store[PENDING_BOUNDARY_STATE] = state === undefined ? NO_BOUNDARY_STATE : state; + store[PENDING_BYPASS_SPAN] = bypassSpanInstanceId === undefined + ? NO_BYPASS_SPAN + : bypassSpanInstanceId; if (updates) { store[PENDING_BOUNDARY_UPDATES] = updates; updates.pendingRoots++; @@ -417,39 +565,55 @@ function takePendingState( : (stored as Record | undefined); } +function takePendingBypassSpan(el: Element): number | undefined { + const store = el as unknown as Record; + const stored = store[PENDING_BYPASS_SPAN]; + delete store[PENDING_BYPASS_SPAN]; + return stored === NO_BYPASS_SPAN ? undefined : stored as number | undefined; +} + function takePendingUpdates(el: Element): PendingBoundaryUpdates | undefined { const store = el as unknown as Record; const updates = store[PENDING_BOUNDARY_UPDATES] as | PendingBoundaryUpdates | undefined; delete store[PENDING_BOUNDARY_UPDATES]; - if (updates) updates.pendingRoots--; - return updates; + if (!updates) return undefined; + updates.pendingRoots--; + return updates.active || updates.patch !== undefined ? updates : undefined; } function invokeActivationHook( el: Element, state: Record | undefined, + bypassSpanInstanceId?: number, ): number { const hook = (el as BoundaryActivatable)[STREAMING_BOUNDARY_ACTIVATE]; if (typeof hook !== 'function') return ACTIVATION_MISSING_TEMPLATE; let outcome: number; try { - outcome = hook.call(el, state); + outcome = hook.call(el, state, bypassSpanInstanceId); } catch (error) { - safeRemoveAttribute(el, STREAMED_HOST_ATTR); + removeStreamingAttributes(el); throw error; } + if (outcome === ACTIVATION_ANCESTOR_BARRIER) return outcome; if ( outcome !== ACTIVATION_ACTIVATED && outcome !== ACTIVATION_STATIC_HOST_OPT_OUT ) { return ACTIVATION_MISSING_TEMPLATE; } - safeRemoveAttribute(el, STREAMED_HOST_ATTR); + removeStreamingAttributes(el); return outcome; } +function removeStreamingAttributes(el: Element): void { + safeRemoveAttribute(el, STREAMED_HOST_ATTR); + safeRemoveAttribute(el, STREAMING_SPAN_HOST_ATTR); + safeRemoveAttribute(el, STREAMING_ENCLOSING_SPAN_ATTR); +} + /** Reset retained activation state and invalidate uncancellable waiters. */ export function resetDeferredActivationForTests(): void { abandonPendingWaiters(); @@ -464,6 +628,10 @@ export function pendingUndefinedRootCountForTests(): number { return pendingUndefinedRoots; } +export function pendingBarrierRootCountForTests(): number { + return pendingBarrierRoots.size; +} + export function elementHasPendingStateForTests(el: Element): boolean { return hasPendingState(el); } diff --git a/packages/webui-framework/src/streaming-dom.ts b/packages/webui-framework/src/streaming-dom.ts index 053ef975b..a86f36d04 100644 --- a/packages/webui-framework/src/streaming-dom.ts +++ b/packages/webui-framework/src/streaming-dom.ts @@ -11,8 +11,12 @@ export function streamingErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -const BOUNDARY_START_PREFIX = 'wb:'; -const BOUNDARY_END_PREFIX = '/wb:'; +/** Root-local runtime BoundaryInstanceId marker prefixes. */ +export const BOUNDARY_START_PREFIX = 'wb:'; +export const BOUNDARY_END_PREFIX = '/wb:'; +/** Root-local runtime SpanInstanceId marker prefixes. */ +export const SPAN_START_PREFIX = 'ws:'; +export const SPAN_END_PREFIX = '/ws:'; /** The DOM span one committed boundary activates. */ export interface HydrationRange { @@ -32,40 +36,81 @@ export type RangeResolution = const MARKERLESS_RANGE: HydrationRange = { start: null, end: null }; -/** Resolve the in-order marker range for one checkpoint. */ +/** Resolve the root-local marker range for one boundary occurrence. */ export function resolveBoundaryRange( scriptEl: Element, - sequence: number, - terminal: boolean, + instanceId: number, ): RangeResolution { - const end = findEndMarker(scriptEl, sequence); + return resolveMarkerRange( + scriptEl, + instanceId, + BOUNDARY_START_PREFIX, + BOUNDARY_END_PREFIX, + 'boundary', + ); +} + +/** Resolve the root-local marker range for one completed component span. */ +export function resolveSpanRange( + scriptEl: Element, + instanceId: number, +): RangeResolution { + return resolveMarkerRange( + scriptEl, + instanceId, + SPAN_START_PREFIX, + SPAN_END_PREFIX, + 'span', + ); +} + +function resolveMarkerRange( + scriptEl: Element, + instanceId: number, + startPrefix: string, + endPrefix: string, + kind: string, +): RangeResolution { + const end = findEndMarker(scriptEl, instanceId, endPrefix); if (end) { - if (terminal) { - return { - ok: false, - reason: `terminal boundary ${sequence} must be markerless`, - truncated: false, - }; - } - const start = findStartMarkerBefore(end, sequence); + const start = findStartMarkerBefore(end, instanceId, startPrefix); if (!start) { return { ok: false, - reason: `missing start marker for boundary ${sequence}`, + reason: `missing start marker for ${kind} ${instanceId}`, truncated: false, }; } return { ok: true, range: { start, end } }; } - if (terminal) return { ok: true, range: MARKERLESS_RANGE }; return { ok: false, - reason: `missing end marker for boundary ${sequence}`, + reason: `missing end marker for ${kind} ${instanceId}`, truncated: true, - start: findStartMarkerBefore(scriptEl, sequence), + start: findStartMarkerBefore(scriptEl, instanceId, startPrefix), }; } +/** Require an update or terminal record to carry no range markers. */ +export function resolveMarkerlessRecord( + scriptEl: Element, + kind: string, +): RangeResolution { + const marker = previousComment(scriptEl); + if ( + marker && + (marker.data.startsWith(BOUNDARY_END_PREFIX) || + marker.data.startsWith(SPAN_END_PREFIX)) + ) { + return { + ok: false, + reason: `${kind} record must be markerless`, + truncated: false, + }; + } + return { ok: true, range: MARKERLESS_RANGE }; +} + export function findBoundaryScript(sentinel: Element): Element | null { let node: Element | null = sentinel.previousElementSibling; for ( @@ -86,40 +131,52 @@ export function findBoundaryScript(sentinel: Element): Element | null { function findEndMarker( scriptEl: Element, - sequence: number, + instanceId: number, + prefix: string, ): Comment | null { const marker = previousComment(scriptEl); - return marker?.data === `${BOUNDARY_END_PREFIX}${sequence}` + return marker?.data === `${prefix}${instanceId}` ? marker : null; } function findStartMarkerBefore( nodeBefore: Node, - sequence: number, + instanceId: number, + prefix: string, ): Comment | null { return findCommentBefore( nodeBefore, - `${BOUNDARY_START_PREFIX}${sequence}`, + `${prefix}${instanceId}`, ); } -/** Find a rejected boundary's end marker without a valid parsed sequence. */ -export function findEndMarkerByPrefix( +/** Find a rejected record's end marker without a valid parsed target. */ +export function findRangeEndMarkerByPrefix( scriptEl: Element, ): Comment | null { const marker = previousComment(scriptEl); - return marker?.data.startsWith(BOUNDARY_END_PREFIX) ? marker : null; + return marker && + (marker.data.startsWith(BOUNDARY_END_PREFIX) || + marker.data.startsWith(SPAN_END_PREFIX)) + ? marker + : null; } /** Find the start marker paired with a structurally discovered end marker. */ -export function findStartMarkerByPrefix( +export function findRangeStartMarkerByPrefix( endMarker: Comment, ): Comment | null { + const endPrefix = endMarker.data.startsWith(BOUNDARY_END_PREFIX) + ? BOUNDARY_END_PREFIX + : SPAN_END_PREFIX; + const startPrefix = endPrefix === BOUNDARY_END_PREFIX + ? BOUNDARY_START_PREFIX + : SPAN_START_PREFIX; return findCommentBefore( endMarker, - `${BOUNDARY_START_PREFIX}${endMarker.data.slice( - BOUNDARY_END_PREFIX.length, + `${startPrefix}${endMarker.data.slice( + endPrefix.length, )}`, ); } diff --git a/packages/webui-framework/src/streaming-mode.ts b/packages/webui-framework/src/streaming-mode.ts index 16fffe09f..d36a7bfa8 100644 --- a/packages/webui-framework/src/streaming-mode.ts +++ b/packages/webui-framework/src/streaming-mode.ts @@ -17,12 +17,26 @@ let cached: boolean | undefined; export const STREAMING_BOUNDARY_ACTIVATE = Symbol.for( 'microsoft.webui.boundaryActivate', ); -/** Shared reconnect hook retained by a detached undefined streamed root. */ +/** Shared resume hook for definition and ancestor-barrier deferred roots. */ export const PENDING_ROOT_CONNECTED = Symbol.for( 'microsoft.webui.pendingRootConnected', ); /** Compiler-owned marker for an uncommitted streamed host. */ export const STREAMED_HOST_ATTR = 'data-ws'; +/** + * Compiler-owned SpanInstanceId on an unfinished component host. + * + * The value is a canonical base-10 integer. It identifies the root-local + * `...` range that will eventually activate this host. + */ +export const STREAMING_SPAN_HOST_ATTR = 'data-ws-span'; +/** + * Compiler-owned enclosing SpanInstanceId on an early boundary child root. + * + * Matching this value to `data-ws-span` lets that root bypass exactly one + * unfinished ancestor barrier. Unmarked or mismatched roots stay dormant. + */ +export const STREAMING_ENCLOSING_SPAN_ATTR = 'data-ws-enclosing'; /** * Whether this document was served in streaming-hydration mode. diff --git a/packages/webui-framework/src/streaming-pipeline.test.ts b/packages/webui-framework/src/streaming-pipeline.test.ts index 87af2d898..7edfc94f2 100644 --- a/packages/webui-framework/src/streaming-pipeline.test.ts +++ b/packages/webui-framework/src/streaming-pipeline.test.ts @@ -41,11 +41,15 @@ interface FakeElement extends FakeNode { shadowRoot: FakeNode | null; readonly isConnected: boolean; hasAttribute(name: string): boolean; + getAttribute(name: string): string | null; setAttribute(name: string, value: string): void; removeAttribute(name: string): void; readonly textContent: string; setState?: (state: Record) => void; - [ACTIVATE]?: (state?: Record) => number; + [ACTIVATE]?: ( + state?: Record, + bypassSpanInstanceId?: number, + ) => number; [ABANDON]?: () => void; [RESUME_PENDING]?: () => void; } @@ -102,7 +106,10 @@ function comment(data: string): FakeNode & { data: string } { interface ElementSpec { attrs?: Record; text?: string; - hook?: (state?: Record) => void; + hook?: ( + state?: Record, + bypassSpanInstanceId?: number, + ) => void | number; activationOutcome?: number; abandon?: () => void; children?: Array; @@ -124,6 +131,11 @@ function element(tagName: string, spec: ElementSpec = {}): FakeElement { hasAttribute(name: string): boolean { return Object.prototype.hasOwnProperty.call(attrs, name); }, + getAttribute(name: string): string | null { + return Object.prototype.hasOwnProperty.call(attrs, name) + ? attrs[name] + : null; + }, setAttribute(name: string, value: string): void { attrs[name] = value; }, @@ -142,9 +154,11 @@ function element(tagName: string, spec: ElementSpec = {}): FakeElement { } as unknown as FakeElement & { _children: FakeNode[] }; addSiblingGetters(node); if (spec.hook) { - node[ACTIVATE] = (state) => { - spec.hook!(state); - return spec.activationOutcome ?? 1; + node[ACTIVATE] = (state, bypassSpanInstanceId?: number) => { + const outcome = spec.hook!(state, bypassSpanInstanceId); + return typeof outcome === 'number' + ? outcome + : spec.activationOutcome ?? 1; }; } if (spec.abandon) node[ABANDON] = spec.abandon; @@ -353,6 +367,11 @@ const { __streamingRetentionStateForTests, } = await import('./streaming.js'); +const { + openSpanCountForTests: __openSpanCountForTests, + pendingBarrierRootCountForTests: __pendingBarrierRootCountForTests, +} = await import('./streaming-coordinator.js'); + const { beginStreamingGate, __resetLifecycleForTests, @@ -374,6 +393,12 @@ function hasWs(el: FakeElement): boolean { return el.hasAttribute('data-ws'); } +function hasStreamingAttrs(el: FakeElement): boolean { + return hasWs(el) || + el.hasAttribute('data-ws-span') || + el.hasAttribute('data-ws-enclosing'); +} + /** The live `window.__webui` object the coordinator writes its handoff into. */ function webuiGlobal(): Record { return (globalThis as unknown as { window: { __webui: Record } }).window.__webui; @@ -447,11 +472,13 @@ function buildBoundary(sequence: number, terminal: number, roots: FakeElement[], const scriptEl = element('script', { attrs: { 'data-webui-boundary': '' }, text: JSON.stringify([ - 1, + 2, sequence, - terminal === 1 ? 3 : 0, + terminal === 1 ? 4 : 0, terminal === 1 ? 0 : sequence, - bootstrap, + terminal === 1 + ? bootstrap + : { declarationId: sequence, ...bootstrap }, ]), }); const sentinel = element('webui-hydrate'); @@ -464,7 +491,15 @@ function buildBoundary(sequence: number, terminal: number, roots: FakeElement[], function buildMarkerless(sequence: number, terminal: number, bootstrap: object): BuiltBoundary { const scriptEl = element('script', { attrs: { 'data-webui-boundary': '' }, - text: JSON.stringify([1, sequence, terminal === 1 ? 3 : 0, 0, bootstrap]), + text: JSON.stringify([ + 2, + sequence, + terminal === 1 ? 4 : 0, + 0, + terminal === 1 + ? bootstrap + : { declarationId: 0, ...bootstrap }, + ]), }); const sentinel = element('webui-hydrate'); const root = body(); @@ -483,7 +518,13 @@ function buildUpdatableBoundary( const end = comment(`/wb:${boundaryId}`); const scriptEl = element('script', { attrs: { 'data-webui-boundary': '' }, - text: JSON.stringify([1, recordSequence, 1, boundaryId, bootstrap]), + text: JSON.stringify([ + 2, + recordSequence, + 1, + boundaryId, + { declarationId: boundaryId, ...bootstrap }, + ]), }); const sentinel = element('webui-hydrate'); const root = body(); @@ -498,7 +539,7 @@ function buildStateUpdate( ): BuiltBoundary { const scriptEl = element('script', { attrs: { 'data-webui-boundary': '' }, - text: JSON.stringify([1, recordSequence, 2, boundaryId, patch]), + text: JSON.stringify([2, recordSequence, 2, boundaryId, patch]), }); const sentinel = element('webui-hydrate'); const root = body(); @@ -506,6 +547,34 @@ function buildStateUpdate( return { root, sentinel, scriptEl, startMarker: null, endMarker: null, roots: [] }; } +/** Build a generated component span completion with no prior checkpoint. */ +function buildSpanCompletion( + recordSequence: number, + spanId: number, + host: FakeElement, + bootstrap: object, +): BuiltBoundary { + host.setAttribute('data-ws', ''); + host.setAttribute('data-ws-span', String(spanId)); + const start = comment(`ws:${spanId}`); + const end = comment(`/ws:${spanId}`); + const scriptEl = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([2, recordSequence, 3, spanId, bootstrap]), + }); + const sentinel = element('webui-hydrate'); + const root = body(); + link(root, [start, host, end, scriptEl, sentinel]); + return { + root, + sentinel, + scriptEl, + startMarker: start, + endMarker: end, + roots: [host], + }; +} + /** Build a boundary whose payload script carries arbitrary (possibly * malformed) text, while keeping a real marker pair around `roots`. Used to * prove that a rejected boundary's scaffolding is still fully cleaned. */ @@ -520,6 +589,115 @@ function buildRawBoundary(sequence: number, rawText: string, roots: FakeElement[ return { root, sentinel, scriptEl, startMarker: start, endMarker: end, roots }; } +interface BuiltSpanScenario { + root: FakeNode; + boundaryStart: FakeNode & { data: string }; + boundaryEnd: FakeNode & { data: string }; + boundaryScript: FakeElement; + boundarySentinel: FakeElement; + spanStart: FakeNode & { data: string }; + spanEnd: FakeNode & { data: string }; + spanScript: FakeElement; + spanSentinel: FakeElement; + parent: FakeElement; + child: FakeElement; +} + +/** Build one early boundary inside an unfinished component host. */ +function buildSpanScenario( + parent: FakeElement, + child: FakeElement, + options: { + updatable?: boolean; + spanId?: number; + enclosingMarker?: number; + boundarySequence?: number; + spanSequence?: number; + nestedLight?: boolean; + includeCompletion?: boolean; + } = {}, +): BuiltSpanScenario { + const spanId = options.spanId ?? 0; + const boundarySequence = options.boundarySequence ?? 0; + const spanSequence = options.spanSequence ?? boundarySequence + 1; + parent.setAttribute('data-ws', ''); + parent.setAttribute('data-ws-span', String(spanId)); + child.setAttribute('data-ws', ''); + child.setAttribute( + 'data-ws-enclosing', + String(options.enclosingMarker ?? spanId), + ); + + const boundaryStart = comment('wb:0'); + const boundaryEnd = comment('/wb:0'); + const boundaryScript = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([ + 2, + boundarySequence, + options.updatable ? 1 : 0, + 0, + { + declarationId: 9, + enclosingSpanInstanceId: spanId, + state: { scope: 'child' }, + }, + ]), + }); + const boundarySentinel = element('webui-hydrate'); + const boundaryNodes: FakeNode[] = [ + boundaryStart, + child, + boundaryEnd, + boundaryScript, + boundarySentinel, + ]; + + if (parent.shadowRoot) { + link(parent.shadowRoot, boundaryNodes); + } else if (options.nestedLight) { + const wrapper = element('section'); + link(wrapper, boundaryNodes); + link(parent, [wrapper]); + } else { + link(parent, boundaryNodes); + } + + const spanStart = comment(`ws:${spanId}`); + const spanEnd = comment(`/ws:${spanId}`); + const spanScript = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([ + 2, + spanSequence, + 3, + spanId, + { state: { scope: 'parent' } }, + ]), + }); + const spanSentinel = element('webui-hydrate'); + const root = body(); + link( + root, + options.includeCompletion === false + ? [spanStart, parent] + : [spanStart, parent, spanEnd, spanScript, spanSentinel], + ); + return { + root, + boundaryStart, + boundaryEnd, + boundaryScript, + boundarySentinel, + spanStart, + spanEnd, + spanScript, + spanSentinel, + parent, + child, + }; +} + /** Assert a boundary left no discoverable scaffolding (sentinel, payload * script, and marker pair all detached) while every SSR root remains in the * tree — the invariant every reject path must uphold. */ @@ -567,6 +745,63 @@ describe('streaming coordinator pipeline', () => { assert.equal(documentWalkCalls, 0, 'valid streams never scan the document'); }); + test('orders runtime boundary occurrences independently of repeated declaration IDs', async () => { + const activated: string[] = []; + const firstRoot = element('repeated-boundary-root', { + hook() { + activated.push('first'); + }, + }); + const secondRoot = element('repeated-boundary-root', { + hook() { + activated.push('second'); + }, + }); + predefine('repeated-boundary-root'); + + enqueue(buildBoundary( + 0, + 0, + [firstRoot], + { declarationId: 17 }, + ).sentinel); + await flush(); + enqueue(buildBoundary( + 1, + 0, + [secondRoot], + { declarationId: 17 }, + ).sentinel); + await flush(); + + assert.deepEqual(activated, ['first', 'second']); + assert.equal(__isHaltedForTests(), false); + }); + + test('rejects a gap in runtime BoundaryInstanceId occurrence order', async () => { + const previousError = console.error; + console.error = () => {}; + try { + const root = element('gap-boundary-root', { hook() {} }); + predefine('gap-boundary-root'); + const boundary = buildUpdatableBoundary( + 0, + 1, + [root], + { declarationId: 4 }, + ); + + enqueue(boundary.sentinel); + await flush(); + + assert.equal(__isHaltedForTests(), true); + assertScaffoldCleaned(boundary); + assert.equal(hasStreamingAttrs(root), false); + } finally { + console.error = previousError; + } + }); + test('applies state updates without reactivating an updatable boundary', async () => { const activations: Array | undefined> = []; const updates: Array> = []; @@ -746,6 +981,44 @@ describe('streaming coordinator pipeline', () => { assert.equal(__getLifecycleStateForTests().completed, true); }); + test('terminal cleanup preserves a queued update until late hydration', async () => { + const activations: Array | undefined> = []; + const updates: Array> = []; + const late = element('terminal-late-panel', { + hook(state) { + activations.push(state); + }, + setState(state) { + updates.push(state); + }, + }); + + enqueue(buildUpdatableBoundary( + 0, + 0, + [late], + { state: { status: 'loading' } }, + ).sentinel); + await flush(); + enqueue(buildStateUpdate(1, 0, { status: 'stale' }).sentinel); + await flush(); + enqueue(buildMarkerless(2, 1, {}).sentinel); + await flush(); + + assert.deepEqual(__streamingRetentionStateForTests(), [0, 0]); + defineTag('terminal-late-panel'); + await flush(); + + assert.deepEqual(activations, [{ status: 'loading' }]); + assert.deepEqual( + plainPatches(updates), + [{ status: 'stale' }], + 'the last committed update replays exactly once after terminal', + ); + assert.equal(hasPending(late), false); + assert.equal(__getLifecycleStateForTests().completed, true); + }); + test('a root whose activation fails never receives later updates', async () => { const previousError = console.error; const errors: string[] = []; @@ -1153,7 +1426,11 @@ describe('streaming coordinator pipeline', () => { const previousError = console.error; console.error = () => {}; try { - const boundary = buildBoundary(0, 0, [], null as unknown as object); + const boundary = buildRawBoundary( + 0, + JSON.stringify([2, 0, 0, 0, null]), + [], + ); enqueue(boundary.sentinel); await flush(); @@ -1305,7 +1582,7 @@ describe('streaming coordinator pipeline', () => { hook() {}, abandon() { abandoned++; }, }); - const b = buildRawBoundary(0, '[1,0,0,{', [root0]); + const b = buildRawBoundary(0, '[2,0,0,{', [root0]); enqueue(b.sentinel); await flush(); @@ -1335,7 +1612,10 @@ describe('streaming coordinator pipeline', () => { test('a missing start marker halts and cleans the end marker + payload', async () => { // End marker present but no matching start marker: reject + clean. const end = comment('/wb:0'); - const scriptEl = element('script', { attrs: { 'data-webui-boundary': '' }, text: JSON.stringify([1, 0, 0, 0, {}]) }); + const scriptEl = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([2, 0, 0, 0, { declarationId: 0 }]), + }); const sentinel = element('webui-hydrate'); const root = body(); link(root, [end, scriptEl, sentinel]); @@ -1399,6 +1679,682 @@ describe('streaming coordinator pipeline', () => { assertScaffoldCleaned(b); }); + test('registers and completes a component span with no prior boundary', async () => { + const states: Array | undefined> = []; + const host = element('zero-span-host', { + hook(state) { + states.push(state); + }, + }); + const span = buildSpanCompletion(0, 0, host, { + state: { scope: 'zero' }, + }); + predefine('zero-span-host'); + + enqueue(span.sentinel); + await flush(); + + assert.deepEqual(states, [{ scope: 'zero' }]); + assert.equal(__openSpanCountForTests(), 0); + assert.equal(hasStreamingAttrs(host), false); + assertScaffoldCleaned(span); + assert.equal(documentWalkCalls, 0, 'completion discovery stays range-local'); + + enqueue(buildMarkerless(1, 1, {}).sentinel); + await flush(); + assert.equal(__isHaltedForTests(), false); + assert.equal(__getLifecycleStateForTests().completed, true); + }); + + test('registers nested zero-occurrence spans outer-first and completes them inner-first', async () => { + const order: string[] = []; + let outerActive = false; + const inner = element('zero-inner-host', { + hook() { + if (!outerActive) return 4; + order.push('inner'); + return 1; + }, + }); + const outer = element('zero-outer-host', { + hook() { + outerActive = true; + order.push('outer'); + }, + }); + inner.setAttribute('data-ws', ''); + inner.setAttribute('data-ws-span', '1'); + outer.setAttribute('data-ws', ''); + outer.setAttribute('data-ws-span', '0'); + + const innerStart = comment('ws:1'); + const innerEnd = comment('/ws:1'); + const innerScript = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([ + 2, + 0, + 3, + 1, + { state: { scope: 'inner' } }, + ]), + }); + const innerSentinel = element('webui-hydrate'); + link(outer, [ + innerStart, + inner, + innerEnd, + innerScript, + innerSentinel, + ]); + + const outerStart = comment('ws:0'); + const outerEnd = comment('/ws:0'); + const outerScript = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([ + 2, + 1, + 3, + 0, + { state: { scope: 'outer' } }, + ]), + }); + const outerSentinel = element('webui-hydrate'); + const root = body(); + link(root, [ + outerStart, + outer, + outerEnd, + outerScript, + outerSentinel, + ]); + predefine('zero-inner-host', 'zero-outer-host'); + + enqueue(innerSentinel); + await flush(); + assert.deepEqual(order, [], 'the inner host remains behind the outer barrier'); + assert.equal(__openSpanCountForTests(), 1); + + enqueue(outerSentinel); + await flush(); + assert.deepEqual(order, ['outer', 'inner']); + assert.equal(__openSpanCountForTests(), 0); + assert.equal(hasStreamingAttrs(inner), false); + assert.equal(hasStreamingAttrs(outer), false); + assert.equal(documentWalkCalls, 0, 'nested discovery stays range-local'); + + enqueue(buildMarkerless(2, 1, {}).sentinel); + await flush(); + assert.equal(__isHaltedForTests(), false); + assert.equal(__getLifecycleStateForTests().completed, true); + }); + + test('fails closed when a zero-occurrence span host declares another ID', async () => { + let activations = 0; + const host = element('malformed-zero-span', { + hook() { + activations++; + }, + }); + const span = buildSpanCompletion(0, 0, host, { state: {} }); + host.setAttribute('data-ws-span', '1'); + predefine('malformed-zero-span'); + + const previousError = console.error; + console.error = () => {}; + try { + enqueue(span.sentinel); + await flush(); + } finally { + console.error = previousError; + } + + assert.equal(__isHaltedForTests(), true); + assert.equal(activations, 0); + assert.equal(__openSpanCountForTests(), 0); + assert.equal(hasStreamingAttrs(host), false); + assertScaffoldCleaned(span); + assert.equal(__getLifecycleStateForTests().completed, false); + }); + + test('keeps later checkpoint span numbering gapless after a zero-occurrence completion', async () => { + const order: string[] = []; + const zeroHost = element('first-zero-span', { + hook() { + order.push('zero'); + }, + }); + const zero = buildSpanCompletion(0, 0, zeroHost, { + state: { scope: 'zero' }, + }); + predefine('first-zero-span'); + enqueue(zero.sentinel); + await flush(); + + let parentActive = false; + let child!: FakeElement; + child = element('later-span-child', { + hook(_state, bypassSpanInstanceId) { + if ( + !parentActive && + child.getAttribute('data-ws-enclosing') !== + String(bypassSpanInstanceId) + ) return 4; + order.push('child'); + return 1; + }, + }); + const parent = element('later-span-parent', { + hook() { + parentActive = true; + order.push('parent'); + }, + }); + const later = buildSpanScenario(parent, child, { + spanId: 1, + boundarySequence: 1, + spanSequence: 2, + }); + predefine('later-span-child', 'later-span-parent'); + + enqueue(later.boundarySentinel); + await flush(); + assert.deepEqual(order, ['zero', 'child']); + assert.equal(__openSpanCountForTests(), 1); + assert.equal(__isHaltedForTests(), false); + + enqueue(later.spanSentinel); + await flush(); + assert.deepEqual(order, ['zero', 'child', 'parent']); + assert.equal(__openSpanCountForTests(), 0); + + enqueue(buildMarkerless(3, 1, {}).sentinel); + await flush(); + assert.equal(__isHaltedForTests(), false); + assert.equal(__getLifecycleStateForTests().completed, true); + }); + + test('activates a marked child before its spanning parent closes, then completes the parent exactly once', async () => { + const order: string[] = []; + const childStates: Array | undefined> = []; + const parentStates: Array | undefined> = []; + let parentActive = false; + let child!: FakeElement; + child = element('early-child', { + hook(state, bypassSpanInstanceId) { + if ( + !parentActive && + child.getAttribute('data-ws-enclosing') !== + String(bypassSpanInstanceId) + ) return 4; + order.push('child'); + childStates.push(state); + return 1; + }, + }); + const parent = element('spanning-parent', { + hook(state) { + parentActive = true; + order.push('parent'); + parentStates.push(state); + }, + }); + const scenario = buildSpanScenario(parent, child); + predefine('early-child', 'spanning-parent'); + + enqueue(scenario.boundarySentinel); + await flush(); + + assert.deepEqual(order, ['child']); + assert.deepEqual(childStates, [{ scope: 'child' }]); + assert.equal(__openSpanCountForTests(), 1); + assert.equal(__pendingBarrierRootCountForTests(), 0); + assert.equal(hasStreamingAttrs(child), false); + assert.equal(hasStreamingAttrs(parent), true); + assert.equal(webuiGlobal().declarationId, undefined); + assert.equal(webuiGlobal().enclosingSpanInstanceId, undefined); + assert.equal(documentWalkCalls, 0, 'valid early commits stay root-local'); + + enqueue(scenario.spanSentinel); + await flush(); + + assert.deepEqual(order, ['child', 'parent']); + assert.deepEqual(parentStates, [{ scope: 'parent' }]); + assert.deepEqual(childStates, [{ scope: 'child' }], 'parent completion does not rehydrate the early child'); + assert.equal(__openSpanCountForTests(), 0); + assert.equal(hasStreamingAttrs(parent), false); + assert.equal(scenario.spanStart.parentNode, null); + assert.equal(scenario.spanEnd.parentNode, null); + assert.equal(scenario.spanScript.parentNode, null); + assert.equal(scenario.spanSentinel.parentNode, null); + + enqueue(buildMarkerless(2, 1, {}).sentinel); + await flush(); + assert.equal(__getLifecycleStateForTests().completed, true); + }); + + test('keeps an unmarked or mismatched early child behind the ancestor barrier', async () => { + const order: string[] = []; + let parentActive = false; + let child!: FakeElement; + child = element('mismatch-child', { + hook(_state, bypassSpanInstanceId) { + if ( + !parentActive && + child.getAttribute('data-ws-enclosing') !== + String(bypassSpanInstanceId) + ) return 4; + order.push('child'); + return 1; + }, + }); + const parent = element('mismatch-parent', { + hook() { + parentActive = true; + order.push('parent'); + }, + }); + const scenario = buildSpanScenario(parent, child, { + enclosingMarker: 1, + }); + const unmarked = element('unmarked-child', { + hook() { + if (!parentActive) return 4; + order.push('unmarked'); + return 1; + }, + }); + unmarked.setAttribute('data-ws', ''); + link(parent, [ + scenario.boundaryStart, + child, + unmarked, + scenario.boundaryEnd, + scenario.boundaryScript, + scenario.boundarySentinel, + ]); + predefine('mismatch-child', 'unmarked-child', 'mismatch-parent'); + + enqueue(scenario.boundarySentinel); + await flush(); + + assert.deepEqual(order, []); + assert.equal(__pendingBarrierRootCountForTests(), 2); + assert.equal(hasStreamingAttrs(child), true); + assert.equal(hasStreamingAttrs(unmarked), true); + + enqueue(scenario.spanSentinel); + await flush(); + + assert.deepEqual(order, ['parent', 'child', 'unmarked']); + assert.equal(__pendingBarrierRootCountForTests(), 0); + assert.equal(hasStreamingAttrs(child), false); + assert.equal(hasStreamingAttrs(unmarked), false); + assert.equal(__isHaltedForTests(), false); + }); + + test('updates a live early child by BoundaryInstanceId before parent completion', async () => { + const activations: Array | undefined> = []; + const updates: Array> = []; + let parentActive = false; + let child!: FakeElement; + child = element('updatable-early-child', { + hook(state, bypassSpanInstanceId) { + if ( + !parentActive && + child.getAttribute('data-ws-enclosing') !== + String(bypassSpanInstanceId) + ) return 4; + activations.push(state); + return 1; + }, + setState(state) { + updates.push(state); + }, + }); + const parent = element('updatable-span-parent', { + hook() { + parentActive = true; + }, + }); + const scenario = buildSpanScenario(parent, child, { + updatable: true, + spanSequence: 2, + }); + predefine('updatable-early-child', 'updatable-span-parent'); + + enqueue(scenario.boundarySentinel); + await flush(); + enqueue(buildStateUpdate(1, 0, { status: 'ready' }).sentinel); + await flush(); + + assert.deepEqual(activations, [{ scope: 'child' }]); + assert.deepEqual(updates, [{ status: 'ready' }]); + assert.deepEqual(__streamingRetentionStateForTests(), [1, 1]); + + enqueue(scenario.spanSentinel); + await flush(); + assert.deepEqual(activations, [{ scope: 'child' }], 'span completion does not duplicate activation'); + assert.deepEqual(updates, [{ status: 'ready' }]); + + enqueue(buildMarkerless(3, 1, {}).sentinel); + await flush(); + assert.deepEqual(__streamingRetentionStateForTests(), [0, 0]); + }); + + test('replays an update once when an undefined early child defines before span completion', async () => { + const childActivations: Array | undefined> = []; + const childUpdates: Array> = []; + const bypasses: Array = []; + let parentActivations = 0; + let parentActive = false; + let child!: FakeElement; + child = element('late-early-child', { + hook(state, bypassSpanInstanceId) { + bypasses.push(bypassSpanInstanceId); + if ( + !parentActive && + child.getAttribute('data-ws-enclosing') !== + String(bypassSpanInstanceId) + ) return 4; + childActivations.push(state); + return 1; + }, + setState(state) { + childUpdates.push(state); + }, + }); + const parent = element('late-early-parent', { + hook() { + parentActive = true; + parentActivations++; + }, + }); + const scenario = buildSpanScenario(parent, child, { + updatable: true, + spanSequence: 2, + }); + predefine('late-early-parent'); + + enqueue(scenario.boundarySentinel); + await flush(); + assert.equal(__pendingUndefinedRootCountForTests(), 1); + assert.deepEqual(__streamingRetentionStateForTests(), [1, 1]); + + enqueue(buildStateUpdate(1, 0, { status: 'ready' }).sentinel); + await flush(); + assert.deepEqual(childUpdates, []); + + defineTag('late-early-child'); + await flush(); + assert.deepEqual(bypasses, [0], 'the pending root retains its enclosing-span bypass'); + assert.deepEqual(childActivations, [{ scope: 'child' }]); + assert.deepEqual(plainPatches(childUpdates), [{ status: 'ready' }]); + assert.equal(parentActivations, 0, 'the child activates while its parent span is unfinished'); + + enqueue(scenario.spanSentinel); + await flush(); + assert.equal(parentActivations, 1); + assert.deepEqual( + childActivations, + [{ scope: 'child' }], + 'span completion must not rehydrate the already-live child', + ); + assert.deepEqual( + plainPatches(childUpdates), + [{ status: 'ready' }], + 'span completion must not overwrite or replay the newer update', + ); + + enqueue(buildMarkerless(3, 1, {}).sentinel); + await flush(); + assert.deepEqual(__streamingRetentionStateForTests(), [0, 0]); + assert.equal(__pendingUndefinedRootCountForTests(), 0); + assert.equal(hasPending(child), false); + assert.equal(__getLifecycleStateForTests().completed, true); + }); + + test('resolves a nested light-DOM boundary inside its actual component render root', async () => { + const order: string[] = []; + let parentActive = false; + let child!: FakeElement; + child = element('light-span-child', { + hook(_state, bypassSpanInstanceId) { + if ( + !parentActive && + child.getAttribute('data-ws-enclosing') !== + String(bypassSpanInstanceId) + ) return 4; + order.push('child'); + return 1; + }, + }); + const parent = element('light-span-parent', { + hook() { + parentActive = true; + order.push('parent'); + }, + }); + const scenario = buildSpanScenario(parent, child, { + nestedLight: true, + }); + predefine('light-span-child', 'light-span-parent'); + + enqueue(scenario.boundarySentinel); + await flush(); + enqueue(scenario.spanSentinel); + await flush(); + + assert.deepEqual(order, ['child', 'parent']); + assert.equal(documentWalkCalls, 0); + assert.equal(__isHaltedForTests(), false); + }); + + test('completes nested light-DOM component spans parent-first with only one barrier bypassed', async () => { + const order: string[] = []; + let outerActive = false; + let innerActive = false; + let child!: FakeElement; + child = element('nested-span-child', { + hook(_state, bypassSpanInstanceId) { + const bypassesInner = + child.getAttribute('data-ws-enclosing') === + String(bypassSpanInstanceId); + if ((!innerActive && !bypassesInner) || !outerActive) return 4; + order.push('child'); + return 1; + }, + }); + const inner = element('nested-inner-parent', { + hook() { + if (!outerActive) return 4; + innerActive = true; + order.push('inner'); + return 1; + }, + }); + const outer = element('nested-outer-parent', { + hook() { + outerActive = true; + order.push('outer'); + }, + }); + outer.setAttribute('data-ws', ''); + outer.setAttribute('data-ws-span', '0'); + inner.setAttribute('data-ws', ''); + inner.setAttribute('data-ws-span', '1'); + child.setAttribute('data-ws', ''); + child.setAttribute('data-ws-enclosing', '1'); + + const boundaryStart = comment('wb:0'); + const boundaryEnd = comment('/wb:0'); + const boundaryScript = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([ + 2, + 0, + 0, + 0, + { + declarationId: 12, + enclosingSpanInstanceId: 1, + state: { scope: 'child' }, + }, + ]), + }); + const boundarySentinel = element('webui-hydrate'); + link(inner, [ + boundaryStart, + child, + boundaryEnd, + boundaryScript, + boundarySentinel, + ]); + + const innerStart = comment('ws:1'); + const innerEnd = comment('/ws:1'); + const innerScript = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([ + 2, + 1, + 3, + 1, + { state: { scope: 'inner' } }, + ]), + }); + const innerSentinel = element('webui-hydrate'); + link(outer, [ + innerStart, + inner, + innerEnd, + innerScript, + innerSentinel, + ]); + + const outerStart = comment('ws:0'); + const outerEnd = comment('/ws:0'); + const outerScript = element('script', { + attrs: { 'data-webui-boundary': '' }, + text: JSON.stringify([ + 2, + 2, + 3, + 0, + { state: { scope: 'outer' } }, + ]), + }); + const outerSentinel = element('webui-hydrate'); + const root = body(); + link(root, [ + outerStart, + outer, + outerEnd, + outerScript, + outerSentinel, + ]); + predefine( + 'nested-span-child', + 'nested-inner-parent', + 'nested-outer-parent', + ); + + enqueue(boundarySentinel); + await flush(); + assert.deepEqual(order, [], 'the marker bypasses inner, not outer'); + assert.equal(__openSpanCountForTests(), 2); + + enqueue(innerSentinel); + await flush(); + assert.deepEqual(order, [], 'inner completion remains behind outer'); + assert.equal(__openSpanCountForTests(), 1); + + enqueue(outerSentinel); + await flush(); + assert.deepEqual(order, ['outer', 'inner', 'child']); + assert.equal(__pendingBarrierRootCountForTests(), 0); + assert.equal(__openSpanCountForTests(), 0); + assert.equal(documentWalkCalls, 0); + }); + + test('resolves an early boundary inside an open declarative shadow root', async () => { + const order: string[] = []; + let parentActive = false; + let child!: FakeElement; + child = element('shadow-span-child', { + hook(_state, bypassSpanInstanceId) { + if ( + !parentActive && + child.getAttribute('data-ws-enclosing') !== + String(bypassSpanInstanceId) + ) return 4; + order.push('child'); + return 1; + }, + }); + const parent = element('shadow-span-parent', { + shadowChildren: [], + hook() { + parentActive = true; + order.push('parent'); + }, + }); + const scenario = buildSpanScenario(parent, child); + predefine('shadow-span-child', 'shadow-span-parent'); + + enqueue(scenario.boundarySentinel); + await flush(); + enqueue(scenario.spanSentinel); + await flush(); + + assert.deepEqual(order, ['child', 'parent']); + assert.equal(documentWalkCalls, 0); + assert.equal(hasStreamingAttrs(child), false); + assert.equal(hasStreamingAttrs(parent), false); + }); + + test('span truncation releases open-span state, markers, and compiler attributes', async () => { + __installTruncationGuardForTests(); + let parentActive = false; + let child!: FakeElement; + child = element('truncated-span-child', { + hook(_state, bypassSpanInstanceId) { + if ( + !parentActive && + child.getAttribute('data-ws-enclosing') !== + String(bypassSpanInstanceId) + ) return 4; + return 1; + }, + }); + const parent = element('truncated-span-parent', { + hook() { + parentActive = true; + }, + }); + const scenario = buildSpanScenario(parent, child, { + includeCompletion: false, + }); + predefine('truncated-span-child', 'truncated-span-parent'); + + enqueue(scenario.boundarySentinel); + await flush(); + assert.equal(__openSpanCountForTests(), 1); + assert.equal(scenario.spanStart.parentNode, scenario.root); + + fireDomContentLoaded(); + await flush(); + + assert.equal(__isHaltedForTests(), true); + assert.equal(__openSpanCountForTests(), 0); + assert.equal(__pendingBarrierRootCountForTests(), 0); + assert.equal(scenario.spanStart.parentNode, null); + assert.equal(hasStreamingAttrs(parent), false); + assert.equal(hasStreamingAttrs(child), false); + assert.equal(__getLifecycleStateForTests().completed, false); + }); + test('a nested island is activated exactly once in a single walk', async () => { const activated: string[] = []; const inner = element('my-inner', { hook() { activated.push('my-inner'); } }); @@ -1731,7 +2687,7 @@ describe('streaming coordinator pipeline', () => { assert.equal(__getLifecycleStateForTests().pendingLateActivations, 1); // Halt via a malformed boundary at the next sequence. - const bad = buildRawBoundary(1, '[1,1,0,{', []); + const bad = buildRawBoundary(1, '[2,1,0,{', []); enqueue(bad.sentinel); await flush(); assert.equal(__isHaltedForTests(), true); @@ -1768,7 +2724,7 @@ describe('streaming coordinator pipeline', () => { await flush(); detach(outer); - const bad = buildRawBoundary(1, '[1,1,0,{', []); + const bad = buildRawBoundary(1, '[2,1,0,{', []); enqueue(bad.sentinel); await flush(); @@ -1920,7 +2876,7 @@ describe('streaming coordinator pipeline', () => { test('an illegal record queued behind terminal aborts before hydration-complete', async () => { const terminal = buildMarkerless(0, 1, {}); - const post = buildRawBoundary(1, '[1,1,0,{}]', []); + const post = buildRawBoundary(1, '[2,1,0,{}]', []); // Both records are present before the single pump runs. The terminal must // remain tentative until the queue validates the record behind it. @@ -2162,7 +3118,7 @@ describe('streaming coordinator pipeline', () => { test('a rejected (malformed) boundary strips data-ws from its roots, keeping them', async () => { const root0 = element('my-ws', { attrs: { 'data-ws': '' }, hook() {} }); assert.equal(hasWs(root0), true, 'root starts marked as a streamed host'); - const b = buildRawBoundary(0, '[1,0,0,{', [root0]); + const b = buildRawBoundary(0, '[2,0,0,{', [root0]); enqueue(b.sentinel); await flush(); @@ -2193,7 +3149,7 @@ describe('streaming coordinator pipeline', () => { assert.equal(hasWs(late), true, 'a deferred undefined-tag root keeps data-ws until activation'); // Halt via a malformed follow-up boundary. - const bad = buildRawBoundary(1, '[1,1,0,{', []); + const bad = buildRawBoundary(1, '[2,1,0,{', []); enqueue(bad.sentinel); await flush(); diff --git a/packages/webui-framework/src/streaming-protocol.ts b/packages/webui-framework/src/streaming-protocol.ts index 62f0647fb..137597336 100644 --- a/packages/webui-framework/src/streaming-protocol.ts +++ b/packages/webui-framework/src/streaming-protocol.ts @@ -3,10 +3,20 @@ import type { TemplateMeta } from './template.js'; -const SUPPORTED_VERSION = 1; +/** Clean-break component-local streaming protocol version. */ +export const STREAMING_PROTOCOL_VERSION = 2; /** Boundary-local data carried by one streamed hydration checkpoint. */ export interface BoundaryBootstrap { + /** Compiler declaration that produced this runtime boundary occurrence. */ + declarationId: number; + /** + * Nearest unfinished component span enclosing this boundary occurrence. + * + * When present, only roots carrying the same compiler-owned enclosing-span + * attribute may bypass that one ancestor's hydration barrier. + */ + enclosingSpanInstanceId?: number; state?: Record; templates?: Record; inventory?: string; @@ -23,17 +33,39 @@ export const RECORD_KIND_FINAL_CHECKPOINT = 0; export const RECORD_KIND_UPDATABLE_CHECKPOINT = 1; /** Apply projected state to one previously committed updatable checkpoint. */ export const RECORD_KIND_STATE_UPDATE = 2; +/** Finish one component span and activate its previously unfinished host. */ +export const RECORD_KIND_SPAN_COMPLETION = 3; /** Close the response and release all response-scoped references. */ -export const RECORD_KIND_TERMINAL = 3; +export const RECORD_KIND_TERMINAL = 4; export type BoundaryRecordKind = | typeof RECORD_KIND_FINAL_CHECKPOINT | typeof RECORD_KIND_UPDATABLE_CHECKPOINT | typeof RECORD_KIND_STATE_UPDATE + | typeof RECORD_KIND_SPAN_COMPLETION | typeof RECORD_KIND_TERMINAL; +/** + * Frozen state and metadata emitted when an unfinished component host closes. + * + * It deliberately reuses the bootstrap fields that register template and asset + * deltas. Unlike a boundary checkpoint it has no boundary declaration identity: + * the record target is a runtime SpanInstanceId in a separate kind namespace. + */ +export interface SpanCompletionPayload { + state?: Record; + templates?: Record; + inventory?: string; + nonce?: string; + chain?: unknown[]; + css?: string[]; + styles?: string[]; + [key: string]: unknown; +} + export type BoundaryRecordPayload = | BoundaryBootstrap + | SpanCompletionPayload | Record; /** Compact versioned wire record for one streamed response operation. */ @@ -65,9 +97,9 @@ function invalid(reason: string): ParseBoundaryEnvelopeResult { * * Past those checks the tuple was written by our own serializer and is not * re-validated. That makes `version` load-bearing: any new record kind or tuple - * shape must bump it, because a stale client reads an unrecognized kind as a - * final checkpoint. Sequence and target ordering are document state and are - * checked by the coordinator, which fails the stream closed on a mismatch. + * shape must bump it. Sequence, kind, and target ordering are document state + * and are checked by the coordinator, which fails the stream closed on a + * mismatch. */ export function parseBoundaryEnvelope(text: string): ParseBoundaryEnvelopeResult { let parsed: unknown; @@ -82,7 +114,7 @@ export function parseBoundaryEnvelope(text: string): ParseBoundaryEnvelopeResult 'boundary envelope must be a 5-element [version, recordSequence, kind, target, payload] array', ); } - if (parsed[0] !== SUPPORTED_VERSION) { + if (parsed[0] !== STREAMING_PROTOCOL_VERSION) { return invalid( `unsupported boundary envelope version ${JSON.stringify(parsed[0])}`, ); diff --git a/packages/webui-framework/src/streaming-spans.ts b/packages/webui-framework/src/streaming-spans.ts new file mode 100644 index 000000000..ba63e0d4e --- /dev/null +++ b/packages/webui-framework/src/streaming-spans.ts @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + MAX_MARKER_SCAN_NODES, + safeRemove, + safeRemoveAttribute, + SPAN_START_PREFIX, +} from './streaming-dom.js'; +import type { HydrationRange } from './streaming-dom.js'; +import { STREAMING_SPAN_HOST_ATTR } from './streaming-mode.js'; + +/** Maximum unfinished component hosts retained by one response. */ +export const MAX_OPEN_SPANS = 128; +/** Maximum runtime component ancestry crossed by one early boundary. */ +export const MAX_SPAN_NESTING = 32; + +interface OpenSpan { + readonly host: Element; + readonly start: Comment; + readonly parentId: number | undefined; + openChildren: number; +} + +const openSpans = new Map(); +// Reused by the single-record pump while it validates one ancestor chain. +const hostScratch: Element[] = []; +const idScratch: number[] = []; +let nextExpectedSpanInstanceId = 0; + +/** + * Register the unfinished component ancestry enclosing an early boundary. + * + * Span IDs are allocated where `` starts, outer-first and gapless. + * Walking from the boundary render root discovers them inner-first, so the two + * module-level scratch arrays are replayed backwards without per-record maps. + */ +export function registerEnclosingSpans( + renderRoot: Node, + enclosingSpanInstanceId: number, +): string | null { + hostScratch.length = 0; + idScratch.length = 0; + let current: Node | null = renderRoot; + let hops = 0; + let firstSpan = true; + + while (current && hops < MAX_MARKER_SCAN_NODES) { + hops++; + const element = elementForNode(current); + if ( + element && + typeof element.hasAttribute === 'function' && + element.hasAttribute(STREAMING_SPAN_HOST_ATTR) + ) { + if (hostScratch.length >= MAX_SPAN_NESTING) { + clearScratch(); + return `runtime component span nesting exceeds ${MAX_SPAN_NESTING}`; + } + const id = parseInstanceId( + element.getAttribute(STREAMING_SPAN_HOST_ATTR), + ); + if (id === null) { + clearScratch(); + return `invalid ${STREAMING_SPAN_HOST_ATTR} value`; + } + if (firstSpan && id !== enclosingSpanInstanceId) { + clearScratch(); + return `boundary declares enclosing span ${enclosingSpanInstanceId}, but its nearest spanning ancestor is ${id}`; + } + firstSpan = false; + hostScratch.push(element); + idScratch.push(id); + } + current = parentAcrossRenderRoot(current, element); + } + + if (firstSpan) { + clearScratch(); + return `boundary declares enclosing span ${enclosingSpanInstanceId}, but no spanning ancestor was found`; + } + if (current) { + clearScratch(); + return `spanning ancestor walk exceeds ${MAX_MARKER_SCAN_NODES} nodes`; + } + + for (let i = hostScratch.length - 1; i >= 0; i--) { + const error = registerSpan( + idScratch[i], + hostScratch[i], + i + 1 < idScratch.length ? idScratch[i + 1] : undefined, + ); + if (error) { + clearScratch(); + return error; + } + } + clearScratch(); + return null; +} + +function registerSpan( + id: number, + host: Element, + parentId: number | undefined, +): string | null { + const existing = openSpans.get(id); + if (existing) { + return existing.host === host && existing.parentId === parentId + ? null + : `span instance ${id} is already open for another ancestry`; + } + if (id !== nextExpectedSpanInstanceId) { + return `expected span instance ${nextExpectedSpanInstanceId}, received ${id}`; + } + if (openSpans.size >= MAX_OPEN_SPANS) { + return `open component span count exceeds ${MAX_OPEN_SPANS}`; + } + const marker = host.previousSibling; + if ( + marker?.nodeType !== 8 /* COMMENT_NODE */ || + (marker as Comment).data !== `${SPAN_START_PREFIX}${id}` + ) { + return `missing root-local start marker for span ${id}`; + } + const parent = parentId === undefined ? undefined : openSpans.get(parentId); + if (parentId !== undefined && !parent) { + return `parent span ${parentId} is not open`; + } + + openSpans.set(id, { + host, + start: marker as Comment, + parentId, + openChildren: 0, + }); + if (parent) parent.openChildren++; + nextExpectedSpanInstanceId++; + return null; +} + +/** + * Discover a previously unseen completion target from its concrete marker range. + * + * Zero-occurrence component spans have no checkpoint to register their + * ancestry, so their completion must do the same bounded, root-local discovery. + */ +export function registerSpanCompletionTarget( + id: number, + range: HydrationRange, +): string | null { + if (openSpans.has(id)) return null; + if (!range.start || !range.end) { + return `span ${id} completion is markerless`; + } + + let node = range.start.nextSibling; + let hops = 0; + while (node && node !== range.end) { + if (hops >= MAX_MARKER_SCAN_NODES) { + return `span ${id} host lookup exceeds ${MAX_MARKER_SCAN_NODES} nodes`; + } + hops++; + if (node.nodeType === 1 /* ELEMENT_NODE */) { + const element = node as Element; + if ( + typeof element.hasAttribute === 'function' && + element.hasAttribute(STREAMING_SPAN_HOST_ATTR) + ) { + const hostId = parseInstanceId( + element.getAttribute(STREAMING_SPAN_HOST_ATTR), + ); + if (hostId === null) { + return `invalid ${STREAMING_SPAN_HOST_ATTR} value`; + } + if (hostId !== id) { + return `span completion targets span ${id}, but its host declares span ${hostId}`; + } + return registerEnclosingSpans(element, id); + } + } + node = node.nextSibling; + } + return `span completion targets span ${id}, but no spanning host was found inside its markers`; +} + +/** Validate one span completion before mutating or hydrating its range. */ +export function validateSpanCompletion( + id: number, + range: HydrationRange, +): string | null { + const span = openSpans.get(id); + if (!span) return `span completion targets span ${id}, which is not open`; + if (span.openChildren !== 0) { + return `span ${id} completed before its nested component spans`; + } + if ( + !range.start || + !range.end || + range.start !== span.start || + span.host.parentNode !== range.start.parentNode + ) { + return `span completion markers do not match the open span ${id}`; + } + + let node = range.start.nextSibling; + let hops = 0; + while (node && node !== range.end && node !== span.host) { + if (hops >= MAX_MARKER_SCAN_NODES) { + return `span ${id} host lookup exceeds ${MAX_MARKER_SCAN_NODES} nodes`; + } + hops++; + node = node.nextSibling; + } + return node === span.host + ? null + : `span ${id} host is outside its completion markers`; +} + +/** Release one successfully completed span and its ancestry accounting. */ +export function completeSpan(id: number): void { + const span = openSpans.get(id); + if (!span) return; + openSpans.delete(id); + safeRemoveAttribute(span.host, STREAMING_SPAN_HOST_ATTR); + if (span.parentId === undefined) return; + const parent = openSpans.get(span.parentId); + if (parent && parent.openChildren > 0) parent.openChildren--; +} + +/** Fail-closed release of every retained span host and opening marker. */ +export function abandonOpenSpans(): void { + for (const span of openSpans.values()) { + safeRemoveAttribute(span.host, STREAMING_SPAN_HOST_ATTR); + safeRemove(span.start); + } + openSpans.clear(); + clearScratch(); + nextExpectedSpanInstanceId = 0; +} + +function elementForNode(node: Node): Element | null { + if (node.nodeType === 1 /* ELEMENT_NODE */) return node as Element; + if (node.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) { + return (node as ShadowRoot).host ?? null; + } + return null; +} + +function parentAcrossRenderRoot( + node: Node, + element: Element | null, +): Node | null { + let current = node; + let currentElement = element; + if (node.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) { + const host = (node as ShadowRoot).host; + if (!host) return null; + current = host; + currentElement = host; + } + if (currentElement?.assignedSlot) return currentElement.assignedSlot; + const parent = current.parentNode; + if (parent?.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) { + return (parent as ShadowRoot).host ?? null; + } + return parent; +} + +function parseInstanceId(raw: string | null): number | null { + if (raw === null || raw.length === 0) return null; + let value = 0; + for (let i = 0; i < raw.length; i++) { + const code = raw.charCodeAt(i) - 48; + if (code < 0 || code > 9) return null; + value = value * 10 + code; + if (!Number.isSafeInteger(value)) return null; + } + return String(value) === raw ? value : null; +} + +function clearScratch(): void { + hostScratch.length = 0; + idScratch.length = 0; +} + +export function openSpanCountForTests(): number { + return openSpans.size; +} + +/** Whether terminal validation still has unfinished component spans. */ +export function hasOpenSpans(): boolean { + return openSpans.size !== 0; +} diff --git a/packages/webui-framework/src/streaming.test.ts b/packages/webui-framework/src/streaming.test.ts index 10cc6bf2d..e38002a39 100644 --- a/packages/webui-framework/src/streaming.test.ts +++ b/packages/webui-framework/src/streaming.test.ts @@ -20,34 +20,35 @@ const { parseBoundaryEnvelope } = await import('./streaming.js'); describe('parseBoundaryEnvelope', () => { test('accepts a well-formed non-terminal boundary envelope', () => { const result = parseBoundaryEnvelope( - '[1,0,0,0,{"inventory":"01","state":{"count":1},"templates":{"my-counter":{"h":""}}}]', + '[2,0,0,0,{"declarationId":7,"inventory":"01","state":{"count":1},"templates":{"my-counter":{"h":""}}}]', ); assert.equal(result.ok, true); if (!result.ok) return; const [version, sequence, kind, target, payload] = result.envelope; const bootstrap = payload as BoundaryBootstrap; - assert.equal(version, 1); + assert.equal(version, 2); assert.equal(sequence, 0); assert.equal(kind, 0); assert.equal(target, 0); + assert.equal(bootstrap.declarationId, 7); assert.equal(bootstrap.inventory, '01'); assert.deepEqual(bootstrap.state, { count: 1 }); assert.ok(bootstrap.templates?.['my-counter']); }); test('accepts the empty terminal boundary envelope', () => { - const result = parseBoundaryEnvelope('[1,2,3,0,{}]'); + const result = parseBoundaryEnvelope('[2,2,4,0,{}]'); assert.equal(result.ok, true); if (!result.ok) return; const [, sequence, kind, target, bootstrap] = result.envelope; assert.equal(sequence, 2); - assert.equal(kind, 3); + assert.equal(kind, 4); assert.equal(target, 0); assert.deepEqual(bootstrap, {}); }); test('accepts a projected state update record', () => { - const result = parseBoundaryEnvelope('[1,2,2,0,{"forecast":"Sunny"}]'); + const result = parseBoundaryEnvelope('[2,2,2,0,{"forecast":"Sunny"}]'); assert.equal(result.ok, true); if (!result.ok) return; const [, sequence, kind, target, patch] = result.envelope; @@ -57,29 +58,45 @@ describe('parseBoundaryEnvelope', () => { assert.deepEqual(patch, { forecast: 'Sunny' }); }); + test('accepts a component span completion in its separate target namespace', () => { + const result = parseBoundaryEnvelope( + '[2,3,3,0,{"state":{"parent":"complete"},"inventory":"03"}]', + ); + assert.equal(result.ok, true); + if (!result.ok) return; + const [, sequence, kind, target, payload] = result.envelope; + assert.equal(sequence, 3); + assert.equal(kind, 3); + assert.equal(target, 0); + assert.deepEqual(payload, { + state: { parent: 'complete' }, + inventory: '03', + }); + }); + test('rejects invalid JSON', () => { - const result = parseBoundaryEnvelope('[1,0,0,0,{'); + const result = parseBoundaryEnvelope('[2,0,0,0,{'); assert.equal(result.ok, false); if (result.ok) return; assert.match(result.reason, /not valid JSON/); }); test('rejects a non-array envelope', () => { - const result = parseBoundaryEnvelope('{"version":1}'); + const result = parseBoundaryEnvelope('{"version":2}'); assert.equal(result.ok, false); if (result.ok) return; assert.match(result.reason, /5-element/); }); test('rejects an envelope with the wrong element count', () => { - const result = parseBoundaryEnvelope('[1,0,0,0]'); + const result = parseBoundaryEnvelope('[2,0,0,0]'); assert.equal(result.ok, false); if (result.ok) return; assert.match(result.reason, /5-element/); }); test('rejects an unsupported version', () => { - const result = parseBoundaryEnvelope('[2,0,0,0,{}]'); + const result = parseBoundaryEnvelope('[1,0,0,0,{}]'); assert.equal(result.ok, false); if (result.ok) return; assert.match(result.reason, /unsupported boundary envelope version/); @@ -91,11 +108,11 @@ describe('parseBoundaryEnvelope', () => { // coordinator instead, which is covered in streaming-pipeline.test.ts. test('passes malformed trailing fields through to the coordinator', () => { for (const record of [ - '[1,-1,0,0,{}]', - '[1,0,9,0,{}]', - '[1,0,0,-1,{}]', - '[1,0,0,0,null]', - '[1,2,3,0,{"state":{"count":1}}]', + '[2,-1,0,0,{}]', + '[2,0,9,0,{}]', + '[2,0,0,-1,{}]', + '[2,0,0,0,null]', + '[2,2,4,0,{"state":{"count":1}}]', ]) { const result = parseBoundaryEnvelope(record); assert.equal(result.ok, true, `expected ${record} to parse`); diff --git a/packages/webui-framework/src/template-element.test.ts b/packages/webui-framework/src/template-element.test.ts index 04efdaf0f..a3737f20b 100644 --- a/packages/webui-framework/src/template-element.test.ts +++ b/packages/webui-framework/src/template-element.test.ts @@ -88,6 +88,7 @@ const { /** The activation hook the streaming coordinator invokes on a committed boundary. */ const STREAMING_BOUNDARY_ACTIVATE = Symbol.for('microsoft.webui.boundaryActivate'); const STREAMING_BOUNDARY_ABANDON = Symbol.for('microsoft.webui.boundaryAbandon'); +const PENDING_ROOT_CONNECTED = Symbol.for('microsoft.webui.pendingRootConnected'); /** Register template metadata for a tag exactly like `registerTemplateData()`. */ function registerTemplate(tag: string): void { @@ -198,6 +199,53 @@ describe('TemplateElement.connectedCallback — streamed-host (data-ws) deferral } }); + test('lets a pending-definition resume own activation without replaying ordinary bootstrap state', () => { + const tag = 'test-pending-resume-widget'; + registerTemplate(tag); + let ordinaryDeferrals = 0; + let received: Record | undefined; + + class PendingResumeElement extends TemplateElement { + protected override $didDeferSSRHydration(): void { + ordinaryDeferrals++; + } + } + + const el = new PendingResumeElement(); + const raw = el as unknown as { + tagName: string; + $deferredSSR: boolean; + $hydrated: boolean; + setAttribute(name: string, value: string): void; + removeAttribute(name: string): void; + [PENDING_ROOT_CONNECTED]?: () => void; + [STREAMING_BOUNDARY_ACTIVATE]( + state?: Record, + ): number; + }; + raw.tagName = tag; + raw.$hydrated = true; + raw.setAttribute('data-ws', ''); + raw[PENDING_ROOT_CONNECTED] = () => { + received = { status: 'ready' }; + assert.equal( + raw[STREAMING_BOUNDARY_ACTIVATE](received), + 1, + ); + raw.removeAttribute('data-ws'); + }; + + el.connectedCallback(); + + assert.deepEqual(received, { status: 'ready' }); + assert.equal(raw.$deferredSSR, false); + assert.equal( + ordinaryDeferrals, + 0, + 'ordinary deferral would replay older page bootstrap state after the queued update', + ); + }); + test('warns for an UNMARKED client-created element with missing metadata (no silent defer)', () => { resetStreamingModeForTests(); @@ -342,6 +390,92 @@ describe('TemplateElement — streamed-host activation ownership', () => { assert.deepEqual(received, { detached: true }); }); + test('a matching compiler span marker bypasses exactly the unfinished spanning ancestor', () => { + const parentTag = 'test-spanning-parent'; + const childTag = 'test-early-span-child'; + registerTemplate(parentTag); + registerTemplate(childTag); + + const parent = new TemplateElement(); + const parentRaw = parent as unknown as { + tagName: string; + parentElement: Element | null; + $deferredSSR: boolean; + setAttribute(name: string, value: string): void; + }; + parentRaw.tagName = parentTag; + parentRaw.parentElement = null; + parentRaw.$deferredSSR = true; + parentRaw.setAttribute('data-ws-span', '4'); + + const child = new TemplateElement(); + const childRaw = child as unknown as { + tagName: string; + parentElement: Element; + $deferredSSR: boolean; + $hydrated: boolean; + setAttribute(name: string, value: string): void; + [STREAMING_BOUNDARY_ACTIVATE]( + state?: Record, + bypassSpanInstanceId?: number, + ): number; + }; + childRaw.tagName = childTag; + childRaw.parentElement = parent as unknown as Element; + childRaw.setAttribute('data-ws', ''); + childRaw.setAttribute('data-ws-enclosing', '4'); + child.connectedCallback(); + childRaw.$hydrated = true; + + assert.equal( + childRaw[STREAMING_BOUNDARY_ACTIVATE]({ child: true }, 4), + 1, + ); + assert.equal(childRaw.$deferredSSR, false); + }); + + test('a mismatched compiler span marker preserves the parent-first barrier', () => { + const parentTag = 'test-mismatch-span-parent'; + const childTag = 'test-mismatch-span-child'; + registerTemplate(parentTag); + registerTemplate(childTag); + + const parent = new TemplateElement(); + const parentRaw = parent as unknown as { + tagName: string; + parentElement: Element | null; + $deferredSSR: boolean; + setAttribute(name: string, value: string): void; + }; + parentRaw.tagName = parentTag; + parentRaw.parentElement = null; + parentRaw.$deferredSSR = true; + parentRaw.setAttribute('data-ws-span', '4'); + + const child = new TemplateElement(); + const childRaw = child as unknown as { + tagName: string; + parentElement: Element; + $deferredSSR: boolean; + setAttribute(name: string, value: string): void; + [STREAMING_BOUNDARY_ACTIVATE]( + state?: Record, + bypassSpanInstanceId?: number, + ): number; + }; + childRaw.tagName = childTag; + childRaw.parentElement = parent as unknown as Element; + childRaw.setAttribute('data-ws', ''); + childRaw.setAttribute('data-ws-enclosing', '5'); + child.connectedCallback(); + + assert.equal( + childRaw[STREAMING_BOUNDARY_ACTIVATE]({ child: true }, 4), + 4, + ); + assert.equal(childRaw.$deferredSSR, true); + }); + test('authored components do not globally defer unmarked SSR-shaped light DOM', () => { const el = new TemplateElement() as unknown as { $shouldDeferSSRHydration(): boolean; diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index 447a836d6..77d7126c2 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -60,7 +60,9 @@ import { hydrationStart, hydrationEnd } from './lifecycle.js'; import { isStreamingHydrationMode, PENDING_ROOT_CONNECTED, + STREAMING_ENCLOSING_SPAN_ATTR, STREAMED_HOST_ATTR, + STREAMING_SPAN_HOST_ATTR, STREAMING_BOUNDARY_ACTIVATE, } from './streaming-mode.js'; import { @@ -189,6 +191,7 @@ const STREAMING_BOUNDARY_ABANDON = Symbol.for('microsoft.webui.boundaryAbandon') const ACTIVATION_ACTIVATED = 1; const ACTIVATION_STATIC_HOST_OPT_OUT = 2; const ACTIVATION_MISSING_TEMPLATE = 3; +const ACTIVATION_ANCESTOR_BARRIER = 4; const templateMetaByCtor = new WeakMap(); const pendingAncestorDescendants = new WeakMap(); @@ -424,7 +427,10 @@ export class TemplateElement extends HTMLElement { * state, handed straight through to hydration instead of via the global * `window.__webui.state` handoff. */ - [STREAMING_BOUNDARY_ACTIVATE](state?: Record): number { + [STREAMING_BOUNDARY_ACTIVATE]( + state?: Record, + bypassSpanInstanceId?: number, + ): number { // `customElements.upgrade()` installs this class on detached roots without // invoking connectedCallback(). Preserve the same marker-driven dormant // state those roots would have entered while connected before activation. @@ -441,13 +447,19 @@ export class TemplateElement extends HTMLElement { } this.$meta = meta; if (!this.$shouldActivateOnBoundaryCommit()) return ACTIVATION_STATIC_HOST_OPT_OUT; - const ancestor = this.$nearestHydrationBarrier(); + const ancestor = this.$nearestHydrationBarrier(bypassSpanInstanceId); if (ancestor) { this.$deferredByAncestor = true; this.$ancestorBoundaryState = state; this.$hasAncestorBoundaryState = true; this.$registerWithHydrationBarrier(ancestor); - return ACTIVATION_ACTIVATED; + return ACTIVATION_ANCESTOR_BARRIER; + } + if (this.$deferredByAncestor) { + this.$detachDeferredAncestor(); + this.$deferredByAncestor = undefined; + this.$ancestorBoundaryState = undefined; + this.$hasAncestorBoundaryState = undefined; } this.$activatingDeferredSSR = true; try { @@ -546,7 +558,14 @@ export class TemplateElement extends HTMLElement { this.$deferredSSR = true; this.$ready = true; const resume = (this as unknown as { [PENDING_ROOT_CONNECTED]?: () => void })[PENDING_ROOT_CONNECTED]; - if (typeof resume === 'function') resume.call(this); + if (typeof resume === 'function') { + // The coordinator owns every continuation after a pending definition: + // eager activation, span-barrier registration, lazy observation, or + // static-host opt-out. Re-entering ordinary deferral here can replay + // older page bootstrap state over a queued boundary update. + resume.call(this); + return; + } this.$didDeferSSRHydration(); return; } @@ -934,7 +953,16 @@ export class TemplateElement extends HTMLElement { return this.$meta ?? this.$templateMeta(); } - private $nearestHydrationBarrier(): Element | undefined { + private $nearestHydrationBarrier( + bypassSpanInstanceId?: number, + ): Element | undefined { + const bypassSpan = bypassSpanInstanceId === undefined + ? undefined + : String(bypassSpanInstanceId); + const mayBypass = bypassSpan !== undefined && + this.getAttribute(STREAMING_ENCLOSING_SPAN_ATTR) === + bypassSpan; + let bypassed = false; let current: Element = this; while (true) { let parent: Element | null = @@ -952,6 +980,16 @@ export class TemplateElement extends HTMLElement { : null; } if (!parent) return undefined; + if ( + mayBypass && + !bypassed && + parent.getAttribute(STREAMING_SPAN_HOST_ATTR) === + bypassSpan + ) { + bypassed = true; + current = parent; + continue; + } if (parent instanceof TemplateElement) { const parentMeta = parent.$meta ?? parent.$templateMeta(); if (parentMeta?.th) { @@ -1072,6 +1110,13 @@ export class TemplateElement extends HTMLElement { const boundaryState = this.$ancestorBoundaryState; this.$hasAncestorBoundaryState = undefined; this.$ancestorBoundaryState = undefined; + const resume = ( + this as unknown as { [PENDING_ROOT_CONNECTED]?: () => void } + )[PENDING_ROOT_CONNECTED]; + if (typeof resume === 'function') { + resume.call(this); + return; + } const meta = this.$meta; if (meta && this.$shouldDeferSSRHydration(meta)) { if (hasBoundaryState) { diff --git a/packages/webui/README.md b/packages/webui/README.md index 8f95759df..0dd9b3e32 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -165,23 +165,31 @@ protocol.renderStream(state, (chunk) => { ### `protocol.streamResponse(options?): StreamingSession` -Opens a progressive streaming session for an entry that declares `` -directives. Unlike `renderStream`, the session **returns** each chunk, so your -server keeps the socket, the write order, and the backpressure contract. +Opens a runtime-discovered progressive session. Unlike `renderStream`, each +session call returns bytes so your server keeps the socket and backpressure. ```js -import { once } from 'node:events'; - -const session = protocol.streamResponse({ entry: 'index.html', requestPath: '/' }); -const status = session.boundary('job-status'); // resolve names once +const session = protocol.streamResponse({ + entry: 'index.html', + requestPath: '/', +}); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); -await write(res, session.writeShell(baseState)); -await write(res, session.writeBoundary(status, statusState, 'updatable')); - -// Patches an already-hydrated island on this same response. -await write(res, session.update(status, { jobState: 'succeeded' })); -res.end(session.finish({})); +let step = session.start(initialState); +await write(res, step.bytes); + +while (!step.done) { + const boundary = step.boundary; + if (!boundary) throw new Error('unfinished step has no boundary'); + const state = await loadBoundaryState( + boundary.owner, + boundary.name, + boundary.key, + ); + step = session.resume(boundary.instanceId, state, 'final'); + await write(res, step.bytes); +} +res.end(); async function write(res, chunk) { if (res.write(chunk)) return; @@ -204,17 +212,14 @@ async function write(res, chunk) { | Member | Returns | Description | |--------|---------|-------------| -| `boundary(name)` | `number` | Integer handle for an authored boundary name | -| `boundaryCount` | `number` | Boundaries declared by the entry | -| `finished` | `boolean` | Whether `finish()` has been called | -| `writeShell(state)` | `Buffer` | Document prefix through the first semantic flush | -| `writeBoundary(id, state, mode?)` | `Buffer` | One boundary's markup and checkpoint (`'final'` \| `'updatable'`) | -| `update(id, state)` | `Buffer` | Projected state patch to an updatable boundary | -| `finish(state)` | `Buffer` | Tail checkpoint, terminal record, and document suffix | - -Ordering is enforced. A rejected call throws and leaves the session usable, so -invalid state does not cost you the response. Sessions are independent — hold -one per in-flight request. +| `start(state)` | `StreamStep` | Bytes through the first occurrence or terminal | +| `resume(instanceId, state, mode?)` | `StreamStep` | Commit the pending occurrence and continue (`'final'` \| `'updatable'`) | +| `update(instanceId, patch)` | `Buffer` | Projected state for a committed updatable occurrence | + +`StreamStep` contains `bytes`, `done`, and optional +`{ instanceId, declarationId, owner, name, key }`. The completed step already +contains tail and terminal bytes. Updates insert no markup and never rerun +hydration. Sessions are single-driver and independent; hold one per request. ### `inspect(protocol: Buffer): string` diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 234f60462..649106c7d 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -96,6 +96,30 @@ export interface ProtocolOptions { */ export type BoundaryMode = "final" | "updatable"; +/** A runtime-discovered boundary occurrence waiting to be resumed. */ +export interface BoundaryDescriptor { + /** Gapless response-local occurrence ID passed to `resume()` and `update()`. */ + instanceId: number; + /** Stable build-local ID for the authored boundary declaration. */ + declarationId: number; + /** Entry or component template that owns the declaration. */ + owner: string; + /** Free-form authored boundary name. */ + name: string; + /** Evaluated repeat key, preserving its authored JSON type. */ + key?: string | number; +} + +/** Bytes and continuation state produced by `start()` and `resume()`. */ +export interface StreamStep { + /** Complete bytes produced by this semantic step. */ + bytes: Buffer; + /** Whether the document tail and terminal record have been emitted. */ + done: boolean; + /** Next runtime boundary occurrence, absent when `done` is true. */ + boundary?: BoundaryDescriptor; +} + /** Per-response settings for a host-driven streaming session. */ export interface StreamOptions { /** Fragment ID to start rendering from (default: "index.html"). */ @@ -189,13 +213,23 @@ interface NativeProtocol { } interface NativeStreamingSession { - readonly boundaryCount: number; - readonly finished: boolean; - boundary(name: string): number; - writeShell(stateJson: string): Buffer; - writeBoundary(boundary: number, stateJson: string, mode?: BoundaryMode): Buffer; - update(boundary: number, stateJson: string): Buffer; - finish(stateJson: string): Buffer; + start(stateJson: string): NativeStreamStep; + resume(instanceId: number, stateJson: string, mode?: BoundaryMode): NativeStreamStep; + update(instanceId: number, patchJson: string): Buffer; +} + +interface NativeBoundaryDescriptor { + instanceId: number; + declarationId: number; + owner: string; + name: string; + key?: string | number | null; +} + +interface NativeStreamStep { + bytes: Buffer; + done: boolean; + boundary?: NativeBoundaryDescriptor | null; } let addon: NativeAddon | undefined; @@ -359,24 +393,23 @@ export class Protocol { * caller decides when they reach the socket and can await `drain` between * chunks. The session holds no transport and never blocks on one. * - * Ordering is enforced: the shell first, then each boundary exactly once in - * declaration order, `update()` only after its boundary commits as - * `updatable`, and `finish()` last. A violation throws before any byte is - * produced. + * `start()` discovers the first runtime occurrence. Each `resume()` commits the + * pending occurrence and discovers the next one, or returns a completed step + * containing the document tail and terminal record. `update()` targets a + * previously committed `updatable` occurrence. * * ```js * const session = protocol.streamResponse({ requestPath: req.url }); - * const weather = session.boundary("weather-shell"); - * - * res.write(session.writeShell(shellState)); - * res.write(session.writeBoundary(weather, weatherShell, "updatable")); + * let step = session.start(shellState); + * res.write(step.bytes); * - * const forecast = await forecastReady; - * if (!res.write(session.update(weather, forecast))) { - * await once(res, "drain"); + * while (!step.done) { + * const boundary = step.boundary; + * const state = await loadBoundary(boundary.owner, boundary.name, boundary.key); + * step = session.resume(boundary.instanceId, state); + * res.write(step.bytes); * } - * - * res.end(session.finish({})); + * res.end(); * ``` */ export class StreamingSession { @@ -387,49 +420,51 @@ export class StreamingSession { this.#native = native; } - /** Number of compile-time boundaries declared by this entry. */ - get boundaryCount(): number { - return this.#native.boundaryCount; - } - - /** Whether the terminal record has been written. */ - get finished(): boolean { - return this.#native.finished; + /** Render until the first runtime boundary occurrence or terminal. */ + start(state: object | string): StreamStep { + return toStreamStep(this.#native.start(toStateJson(state))); } - /** - * Resolve an authored boundary name to a stable integer handle. - * - * Resolve once outside the write loop; reusing the handle costs nothing. - * An unknown name throws with the valid names and a suggestion. - */ - boundary(name: string): number { - return this.#native.boundary(name); - } - - /** Render everything before the first boundary. */ - writeShell(state: object | string): Buffer { - return this.#native.writeShell(toStateJson(state)); - } - - /** Render and commit the next boundary in declaration order. */ - writeBoundary( - boundary: number, + /** Commit the pending occurrence and advance to the next one or terminal. */ + resume( + instanceId: number, state: object | string, mode: BoundaryMode = "final", - ): Buffer { - return this.#native.writeBoundary(boundary, toStateJson(state), mode); + ): StreamStep { + return toStreamStep(this.#native.resume(instanceId, toStateJson(state), mode)); } - /** Push a projected state patch to a committed `updatable` boundary. */ - update(boundary: number, state: object | string): Buffer { - return this.#native.update(boundary, toStateJson(state)); + /** Push a projected state patch to a committed `updatable` occurrence. */ + update(instanceId: number, patch: object | string): Buffer { + return this.#native.update(instanceId, toStateJson(patch)); } +} + +function toStreamStep(step: NativeStreamStep): StreamStep { + const boundary = step.boundary; + if (!boundary) { + return { bytes: step.bytes, done: step.done }; + } + return { + bytes: step.bytes, + done: step.done, + boundary: toBoundaryDescriptor(boundary), + }; +} - /** Render the document tail and emit the terminal record. */ - finish(state: object | string = {}): Buffer { - return this.#native.finish(toStateJson(state)); +function toBoundaryDescriptor( + boundary: NativeBoundaryDescriptor, +): BoundaryDescriptor { + const descriptor: BoundaryDescriptor = { + instanceId: boundary.instanceId, + declarationId: boundary.declarationId, + owner: boundary.owner, + name: boundary.name, + }; + if (boundary.key !== undefined && boundary.key !== null) { + descriptor.key = boundary.key; } + return descriptor; } function toStateJson(state: object | string): string { diff --git a/packages/webui/test/integration.test.ts b/packages/webui/test/integration.test.ts index 49a59efd0..d0c0fd042 100644 --- a/packages/webui/test/integration.test.ts +++ b/packages/webui/test/integration.test.ts @@ -13,7 +13,11 @@ import { inspect, Protocol, } from '@microsoft/webui'; -import type { ComponentTemplatesResponse } from '@microsoft/webui'; +import type { + BoundaryDescriptor, + ComponentTemplatesResponse, + StreamStep, +} from '@microsoft/webui'; import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { createServer, get } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http'; @@ -77,6 +81,26 @@ before(() => { +`); + writeFileSync(join(appDir, 'index-stream-repeat.html'), ` + + + + + + + + + + + +`); + writeFileSync(join(appDir, 'index-stream-empty.html'), ` + + + +

boundary-free

+ `); }); @@ -250,134 +274,128 @@ describe('renderStream', () => { describe('streamResponse', () => { const streamOptions = { entry: 'index-stream.html', requestPath: '/' }; - function streamingProtocol(): Protocol { + function streamingProtocol(entry = 'index-stream.html'): Protocol { return new Protocol( - build({ appDir, entry: 'index-stream.html', plugin: 'webui' }).protocol, + build({ appDir, entry, plugin: 'webui' }).protocol, ); } - test('returns one chunk per host call and reassembles a complete document', () => { + function boundaryOf(step: StreamStep): BoundaryDescriptor { + assert.ok(step.boundary); + return step.boundary; + } + + test('discovers runtime boundary descriptors from start', () => { const session = streamingProtocol().streamResponse(streamOptions); - assert.equal(session.boundaryCount, 2); - - const first = session.boundary('first'); - const second = session.boundary('second'); - assert.equal(first, 0); - assert.equal(second, 1); - - const chunks = [ - session.writeShell({}), - session.writeBoundary(first, { firstLabel: 'alpha' }, 'updatable'), - session.update(first, { firstLabel: 'alpha-2' }), - session.writeBoundary(second, { secondLabel: 'beta' }), - session.finish({}), - ]; - - for (const chunk of chunks) { - assert.ok(Buffer.isBuffer(chunk)); - } - assert.equal(session.finished, true); + const step = session.start({}); + const boundary = boundaryOf(step); + + assert.ok(Buffer.isBuffer(step.bytes)); + assert.equal(step.done, false); + assert.equal(boundary.instanceId, 0); + assert.equal(typeof boundary.declarationId, 'number'); + assert.equal(boundary.owner, 'index-stream.html'); + assert.equal(boundary.name, 'first'); + assert.equal(boundary.key, undefined); + }); - const html = Buffer.concat(chunks).toString('utf8'); + test('resume discovers the next boundary and returns a completed final step', () => { + const session = streamingProtocol().streamResponse(streamOptions); + const start = session.start({}); + const first = boundaryOf(start); + const next = session.resume(first.instanceId, { firstLabel: 'alpha' }); + const second = boundaryOf(next); + + assert.ok(Buffer.isBuffer(next.bytes)); + assert.equal(next.done, false); + assert.equal(second.instanceId, 1); + assert.equal(second.name, 'second'); + + const done = session.resume(second.instanceId, { secondLabel: 'beta' }); + assert.ok(Buffer.isBuffer(done.bytes)); + assert.equal(done.done, true); + assert.equal(done.boundary, undefined); + + const html = Buffer.concat([start.bytes, next.bytes, done.bytes]).toString('utf8'); assert.ok(html.includes('')); assert.ok(html.includes('alpha')); - assert.ok(html.includes('alpha-2')); assert.ok(html.includes('beta')); - assert.ok(html.trimEnd().endsWith('')); + assert.equal(html.match(/class="item"/g)?.length, 2); + assert.ok(html.includes('')); }); - test('renders every boundary exactly once into the reassembled document', () => { - const protocol = streamingProtocol(); - const state = { firstLabel: 'alpha', secondLabel: 'beta' }; - - const session = protocol.streamResponse(streamOptions); - const streamed = Buffer.concat([ - session.writeShell(state), - session.writeBoundary(session.boundary('first'), state), - session.writeBoundary(session.boundary('second'), state), - session.finish(state), - ]).toString('utf8'); - - // Streaming reorders delivery, never content. - assert.equal(streamed.match(/class="item"/g)?.length, 2); + test('preserves string and number repeat keys', () => { + const entry = 'index-stream-repeat.html'; + const session = streamingProtocol(entry).streamResponse({ + entry, + requestPath: '/', + }); + const state = { + items: [ + { id: 'alpha', label: 'first' }, + { id: 20, label: 'second' }, + ], + }; + + const start = session.start(state); + const first = boundaryOf(start); + assert.equal(first.key, 'alpha'); + + const next = session.resume(first.instanceId, {}); + const second = boundaryOf(next); + assert.equal(second.instanceId, 1); + assert.equal(second.declarationId, first.declarationId); + assert.equal(second.key, 20); + + const done = session.resume(second.instanceId, {}); + assert.equal(done.done, true); }); - test('rejects boundaries written out of declaration order', () => { + test('updates a committed updatable occurrence', () => { const session = streamingProtocol().streamResponse(streamOptions); - session.writeShell({}); - assert.throws( - () => session.writeBoundary(session.boundary('second'), { secondLabel: 'beta' }), - /order/i, + const start = session.start({}); + const first = boundaryOf(start); + const next = session.resume( + first.instanceId, + { firstLabel: 'alpha' }, + 'updatable', ); - }); + const update = session.update(first.instanceId, { + firstLabel: 'alpha-2', + }); - test('rejects updates to a boundary committed as final', () => { - const session = streamingProtocol().streamResponse(streamOptions); - const first = session.boundary('first'); - session.writeShell({}); - session.writeBoundary(first, { firstLabel: 'alpha' }); - assert.throws(() => session.update(first, { firstLabel: 'alpha-2' }), /updatable/i); - }); + assert.ok(Buffer.isBuffer(update)); + assert.match(update.toString('utf8'), /alpha-2/); - test('rejects an unknown boundary name with the valid names', () => { - const session = streamingProtocol().streamResponse(streamOptions); - assert.throws(() => session.boundary('firts'), /first/); + const second = boundaryOf(next); + const done = session.resume(second.instanceId, { secondLabel: 'beta' }); + assert.equal(done.done, true); }); - test('rejects an unknown boundary mode', () => { - const session = streamingProtocol().streamResponse(streamOptions); - session.writeShell({}); - assert.throws( - () => - session.writeBoundary( - session.boundary('first'), - { firstLabel: 'alpha' }, - 'sometimes' as 'final', - ), - /unknown boundary mode/, - ); - }); + test('start completes a boundary-free document', () => { + const entry = 'index-stream-empty.html'; + const session = streamingProtocol(entry).streamResponse({ + entry, + requestPath: '/', + }); + const done = session.start({}); - test('rejects every call after finish', () => { - const session = streamingProtocol().streamResponse(streamOptions); - session.writeShell({}); - session.writeBoundary(session.boundary('first'), { firstLabel: 'alpha' }); - session.writeBoundary(session.boundary('second'), { secondLabel: 'beta' }); - session.finish({}); - - assert.equal(session.finished, true); - assert.throws(() => session.writeShell({}), /already finished/); - assert.throws(() => session.finish({}), /already finished/); + assert.ok(Buffer.isBuffer(done.bytes)); + assert.equal(done.done, true); + assert.equal(done.boundary, undefined); + assert.match(done.bytes.toString('utf8'), /boundary-free/); + assert.ok(done.bytes.toString('utf8').includes('')); }); - test('an out-of-order finish leaves the session usable', () => { + test('does not expose legacy wrapper members', () => { const session = streamingProtocol().streamResponse(streamOptions); - session.writeShell({}); - session.writeBoundary(session.boundary('first'), { firstLabel: 'alpha' }); - - // Rejected before any byte is written, so the open response survives. - assert.throws(() => session.finish({}), /every boundary must be committed/); - assert.equal(session.finished, false); - - session.writeBoundary(session.boundary('second'), { secondLabel: 'beta' }); - assert.ok(session.finish({}).length > 0); - assert.equal(session.finished, true); - }); - - test('keeps concurrent sessions independent', () => { - const protocol = streamingProtocol(); - const a = protocol.streamResponse(streamOptions); - const b = protocol.streamResponse(streamOptions); - - a.writeShell({}); - b.writeShell({}); - const fromA = a.writeBoundary(a.boundary('first'), { firstLabel: 'from-a' }).toString('utf8'); - const fromB = b.writeBoundary(b.boundary('first'), { firstLabel: 'from-b' }).toString('utf8'); - - assert.ok(fromA.includes('from-a')); - assert.ok(!fromA.includes('from-b')); - assert.ok(fromB.includes('from-b')); - assert.ok(!fromB.includes('from-a')); + const members = session as unknown as Record; + assert.equal(members.boundary, undefined); + assert.equal(members.boundaryCount, undefined); + assert.equal(members.finished, undefined); + assert.equal(members.writeShell, undefined); + assert.equal(members.writeBoundary, undefined); + assert.equal(members.finish, undefined); }); }); @@ -409,18 +427,21 @@ describe('streamResponse over node:http', () => { const server = createServer((_request, response) => { void (async () => { const session = protocol.streamResponse(streamOptions); - const first = session.boundary('first'); - const second = session.boundary('second'); response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - await write(response, session.writeShell({})); - await write(response, session.writeBoundary(first, { firstLabel: 'alpha' })); + let step = session.start({}); + await write(response, step.bytes); + assert.ok(step.boundary); + step = session.resume(step.boundary.instanceId, { firstLabel: 'alpha' }); + await write(response, step.bytes); // Only reached if the client already has the bytes above. await clientSawFirstBoundary; - await write(response, session.writeBoundary(second, { secondLabel: 'beta' })); - response.end(session.finish({})); + assert.ok(step.boundary); + step = session.resume(step.boundary.instanceId, { secondLabel: 'beta' }); + assert.equal(step.done, true); + response.end(step.bytes); })().catch((error: unknown) => { serverError = error; response.destroy(); @@ -456,7 +477,7 @@ describe('streamResponse over node:http', () => { assert.equal(sawTailBeforeRelease, false); assert.ok(received.includes('alpha')); assert.ok(received.includes('beta')); - assert.ok(received.trimEnd().endsWith('')); + assert.ok(received.includes('')); } finally { server.close(); await once(server, 'close'); From 710b87b260a33db47ccb2f4875dbe35bc23e05be Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 20 Aug 2026 01:05:27 -0700 Subject: [PATCH 2/5] perf: optimize streaming continuation hot paths Cache fragment records, reuse projection buffers, avoid redundant state overlays, remove plan locking, and reduce optional browser streaming code. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 5 +- .../benches/streaming_hydration_bench.rs | 102 +++++ crates/webui-handler/src/lib.rs | 166 ++++++-- crates/webui-handler/src/route_handler.rs | 309 ++++++++++++++- crates/webui-handler/src/route_renderer.rs | 12 +- .../webui-handler/src/streaming/checkpoint.rs | 105 ++--- crates/webui-handler/src/streaming/mod.rs | 9 +- crates/webui-handler/src/streaming/session.rs | 350 +++++++++++++++- crates/webui-handler/src/streaming/state.rs | 47 ++- crates/webui-handler/src/streaming/vm.rs | 123 ++++-- .../streaming-browser-bench/README.md | 30 +- .../tests/hydration_matrix.spec.ts | 83 +++- .../tests/lib/fixtures.ts | 22 +- packages/webui-framework/README.md | 7 + .../src/index-decoupling.test.ts | 63 +++ .../src/streaming-activation.ts | 11 +- .../webui-framework/src/streaming-cleanup.ts | 129 +++--- .../src/streaming-coordinator.ts | 196 ++++----- .../webui-framework/src/streaming-deferred.ts | 373 +++++++++--------- packages/webui-framework/src/streaming-dom.ts | 92 +++-- .../webui-framework/src/streaming-mode.ts | 21 +- .../src/streaming-pipeline.test.ts | 164 ++++---- .../webui-framework/src/streaming-spans.ts | 186 +++++---- .../src/template-element.test.ts | 82 +++- .../webui-framework/src/template-element.ts | 110 +++--- .../lazy-hydration/lazy-hydration.spec.ts | 8 +- 26 files changed, 2010 insertions(+), 795 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b376cc1fe..5547c556a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2178,7 +2178,10 @@ regions while the document is still loading. lexical locals, component attributes, route state, inventories, and continuation frames. Resume state overlays the frozen parent projection for selected keys. Expression resolution remains lexical first, then the - boundary resume overlay, then frozen parent state. + boundary resume overlay, then frozen parent state. The one-shot + `WebUIHandler::render_streaming` helper resumes every occurrence directly + against its original start snapshot, avoiding redundant overlays when one + state value drives the complete response. 6. **Generated component spans.** When traversal suspends inside a reusable component, the handler opens a generated component span around its unfinished host. An early child checkpoint may bypass exactly its nearest unfinished diff --git a/crates/webui-handler/benches/streaming_hydration_bench.rs b/crates/webui-handler/benches/streaming_hydration_bench.rs index 783f182c6..af7b0838b 100644 --- a/crates/webui-handler/benches/streaming_hydration_bench.rs +++ b/crates/webui-handler/benches/streaming_hydration_bench.rs @@ -27,6 +27,8 @@ use webui_parser::{ComponentRegistration, CssStrategy, HtmlParser}; use webui_protocol::{ComponentData, InitialStateStrategy, StateProjectionMode, WebUIProtocol}; const BOUNDARY_COUNTS: &[usize] = &[1, 3, 10, 100]; +const LARGE_STATE_BOUNDARIES: &[usize] = &[1, 8]; +const LARGE_STATE_ROWS: usize = 128; const WRITER_CAPACITY: usize = 32 * 1024; const ENTRY_ID: &str = "index.html"; const REQUEST_PATH: &str = "/"; @@ -545,6 +547,106 @@ fn bench_streaming_hydration(c: &mut Criterion) { ); } update_group.finish(); + + bench_large_state_boundaries(c); +} + +/// Time a full-state continuation across several boundaries. +/// +/// A protocol whose reachable component projects `ALL` forces the response to +/// retain the caller's whole state for the life of the response. The per- +/// boundary cost must stay flat in the size of that state: the snapshot is +/// taken once when the response starts, not re-merged at every occurrence. +fn bench_large_state_boundaries(c: &mut Criterion) { + let state = large_state(LARGE_STATE_ROWS); + let mut group = c.benchmark_group("streaming_large_state"); + for boundaries in LARGE_STATE_BOUNDARIES.iter().copied() { + let protocol = full_state_protocol(boundaries); + let handler = hydration_handler(); + let mut writer = BenchWriter::new(boundaries + 2); + render_streaming(&handler, &protocol, &state, &mut writer); + let bytes = writer.output.len(); + assert_eq!( + occurrences(&writer.output, "data-webui-boundary"), + boundaries + 1, + "each boundary plus the terminal needs one envelope" + ); + println!( + "streaming_large_state boundaries={boundaries}: rows={LARGE_STATE_ROWS}, output_bytes={bytes}" + ); + + group.throughput(Throughput::Bytes(bytes as u64)); + group.bench_with_input( + BenchmarkId::from_parameter(boundaries), + &protocol, + |b, protocol| { + let handler = hydration_handler(); + let mut writer = BenchWriter::new(boundaries + 2); + b.iter(|| { + writer.reset(); + render_streaming( + &handler, + black_box(protocol), + black_box(&state), + &mut writer, + ); + black_box(writer.output.len()); + }); + }, + ); + } + group.finish(); +} + +/// Build the same authored page as [`parser_protocol`] with an island whose +/// compiled hydration surface is `ALL`, which is what forces the continuation +/// to retain full state. +fn full_state_protocol(boundaries: usize) -> Protocol { + let entry_html = entry_html(boundaries); + let mut parser = + HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), CssStrategy::Style); + if let Err(error) = parser + .component_registry_mut() + .register_component(ComponentRegistration { + tag_name: ISLAND_TAG, + html_content: "", + css_content: None, + is_client_owned: false, + }) + { + panic!("registering failed: {error}"); + } + if let Err(error) = parser.parse(ENTRY_ID, &entry_html) { + panic!("parsing benchmark entry failed: {error}"); + } + let mut document = WebUIProtocol::new(parser.into_fragment_records()); + document.initial_state_strategy = InitialStateStrategy::Components as i32; + document.components.insert( + ISLAND_TAG.to_string(), + ComponentData { + template_json: r#"{"h":"","th":1}"#.to_string(), + hydration_mode: StateProjectionMode::All as i32, + ..Default::default() + }, + ); + Protocol::new(document) +} + +/// A state whose payload makes a per-boundary copy unmistakable. +fn large_state(rows: usize) -> Value { + let mut items = Vec::with_capacity(rows); + for row in 0..rows { + items.push(json!({ + "id": row, + "label": format!("row-{row}"), + "tags": ["alpha", "beta", "gamma"], + })); + } + json!({ + "count": 42, + "title": "Hydration benchmark", + "rows": items, + }) } criterion_group!(benches, bench_streaming_hydration); diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index eec8a3a88..2170fbfca 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -30,6 +30,7 @@ pub use html_encode::encode_safe; use plugin::BootstrapExtensionContext; use plugin::HandlerPlugin; use plugin::WebUiTemplatePayload; +use route_handler::ComponentReachabilityIndex; use route_matcher::CompiledRouteIndex; use serde::ser::SerializeMap; use serde::Serialize; @@ -711,7 +712,7 @@ where /// carry nothing hydratable and serialize as an empty object. struct ProjectedState<'a> { value: &'a Value, - keys: &'a [&'a str], + keys: KeyView<'a>, } impl Serialize for ProjectedState<'_> { @@ -726,15 +727,15 @@ impl Serialize for ProjectedState<'_> { let mut out = serializer.serialize_map(None)?; if self.keys.len() < map.len() { let mut previous = None; - for key in self.keys { - if *key == STATE_INJECT_KEY { + for key in self.keys.iter() { + if key == STATE_INJECT_KEY { continue; } - if previous == Some(*key) { + if previous == Some(key) { continue; } - previous = Some(*key); - if let Some(value) = map.get(*key) { + previous = Some(key); + if let Some(value) = map.get(key) { out.serialize_entry(key, value)?; } } @@ -743,11 +744,7 @@ impl Serialize for ProjectedState<'_> { if key == STATE_INJECT_KEY { continue; } - if self - .keys - .binary_search_by(|candidate| candidate.cmp(&key.as_str())) - .is_ok() - { + if self.keys.contains(key.as_str()) { out.serialize_entry(key, value)?; } } @@ -822,8 +819,8 @@ pub(crate) fn write_selected_state( ) -> Result<()> { let keys = match selection { StateSelection::Full => return write_full_state(writer, scratch, state), - StateSelection::Keys(keys) => keys.as_slice(), - StateSelection::BorrowedKeys(keys) => *keys, + StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()), + StateSelection::KeyIds(selection) => KeyView::Ids(*selection), }; if keys.is_empty() { return writer.write("{}"); @@ -834,12 +831,14 @@ pub(crate) fn write_selected_state( // deduped at build time; this guard makes hand-built protocols that violate // the invariant fail loudly in tests at zero release cost. debug_assert!( - keys.windows(2).all(|pair| pair[0] <= pair[1]), + keys.iter() + .zip(keys.iter().skip(1)) + .all(|(left, right)| left <= right), "hydration keys must be sorted for binary-search projection" ); if let Value::Object(map) = state { let selects_entire_map = - keys.len() == map.len() && keys.iter().copied().eq(map.keys().map(String::as_str)); + keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str)); if selects_entire_map { return write_full_state(writer, scratch, state); } @@ -857,8 +856,79 @@ pub(crate) enum StateSelection<'a> { Full, /// Project an object to a sorted, deduplicated key allowlist. Keys(Vec<&'a str>), - /// Project using request-local scratch owned by the streaming render. - BorrowedKeys(&'a [&'a str]), + /// Project using the streaming continuation's interned hydration key IDs. + /// + /// IDs are assigned in lexicographic order, so a sorted ID slice is a + /// sorted key slice and the projection needs no borrowed-string buffer at + /// all — the streaming record keeps its scratch as plain integers that + /// survive every semantic step. + KeyIds(HydrationKeySelection<'a>), +} + +/// A projection expressed as interned hydration key IDs. +#[derive(Clone, Copy)] +pub(crate) struct HydrationKeySelection<'a> { + pub(crate) ids: &'a [u32], + pub(crate) index: &'a ComponentReachabilityIndex, +} + +impl<'a> HydrationKeySelection<'a> { + fn key(self, position: usize) -> Option<&'a str> { + let id = self.ids.get(position).copied()?; + self.index.hydration_key(id) + } +} + +/// A sorted, deduplicated key allowlist in whichever form its producer holds. +#[derive(Clone, Copy)] +enum KeyView<'a> { + Borrowed(&'a [&'a str]), + Ids(HydrationKeySelection<'a>), +} + +impl<'a> KeyView<'a> { + fn len(self) -> usize { + match self { + Self::Borrowed(keys) => keys.len(), + Self::Ids(selection) => selection.ids.len(), + } + } + + fn is_empty(self) -> bool { + self.len() == 0 + } + + fn get(self, position: usize) -> Option<&'a str> { + match self { + Self::Borrowed(keys) => keys.get(position).copied(), + Self::Ids(selection) => selection.key(position), + } + } + + fn iter(self) -> impl Iterator { + (0..self.len()).map_while(move |position| self.get(position)) + } + + /// Sorted-membership probe used when the state object is smaller than the + /// allowlist. + fn contains(self, key: &str) -> bool { + match self { + Self::Borrowed(keys) => keys + .binary_search_by(|candidate| str::cmp(candidate, key)) + .is_ok(), + Self::Ids(selection) => selection + .ids + .binary_search_by(|id| { + selection + .index + .hydration_key(*id) + .map_or(std::cmp::Ordering::Less, |candidate| { + str::cmp(candidate, key) + }) + }) + .is_ok(), + } + } } #[derive(Clone, Copy)] @@ -882,16 +952,32 @@ pub(crate) fn collect_hydration_state<'a, 'b>( collect_component_state(protocol, components, ComponentStateSurface::Hydration) } -pub(crate) fn collect_hydration_state_into<'a, 'b>( - protocol: &'a WebUIProtocol, - components: impl IntoIterator, - keys: &mut Vec<&'a str>, +/// Fill a reusable hydration key-ID allowlist for a streaming record. +/// +/// Components arrive as inventory indexes, so the projection never resolves a +/// component name, never hashes it against the compiled component map, and +/// never sorts strings: the interned runs concatenate and the integer sort +/// leaves the IDs in lexicographic key order. Returns `true` when correctness +/// requires sending full state instead. +pub(crate) fn collect_hydration_key_ids_into( + protocol: &WebUIProtocol, + index: &ComponentReachabilityIndex, + components: impl IntoIterator, + ids: &mut Vec, ) -> bool { + ids.clear(); if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 { - keys.clear(); return true; } - collect_component_state_into(protocol, components, ComponentStateSurface::Hydration, keys) + for component in components { + if index.extend_hydration_keys(component, ids) { + ids.clear(); + return true; + } + } + ids.sort_unstable(); + ids.dedup(); + false } /// Select state for client-created components reachable during navigation. @@ -8729,7 +8815,7 @@ mod tests { match collect_navigation_state(&protocol, ["app-shell"]) { StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]), - StateSelection::Full | StateSelection::BorrowedKeys(_) => { + StateSelection::Full | StateSelection::KeyIds(_) => { panic!("legacy navigation keys should remain owned and projected") } } @@ -9574,12 +9660,38 @@ mod tests { } #[test] - fn write_selected_state_strips_reserved_key_from_borrowed_projection() { + fn write_selected_state_strips_reserved_key_from_key_id_projection() { + // The streaming record projects through interned key IDs, so the + // reserved inject key must be filtered on that path too. + let mut protocol = WebUIProtocol::new(HashMap::new()); + protocol.initial_state_strategy = InitialStateStrategy::Components as i32; + protocol.fragments.insert( + "keep-card".to_string(), + webui_protocol::FragmentList::default(), + ); + protocol.components.insert( + "keep-card".to_string(), + webui_protocol::ComponentData { + hydration_mode: StateProjectionMode::Keys as i32, + hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()], + ..Default::default() + }, + ); + let protocol = Protocol::new(protocol); + let index = protocol.component_reachability(); + let component = protocol.component_index()["keep-card"]; + let mut ids = Vec::new(); + assert!(!collect_hydration_key_ids_into( + protocol.protocol(), + index, + [component], + &mut ids + )); + let state = test_json!({ "$webui": { "bodyEnd": "x" }, "keep": 1, }); - let keys = [STATE_INJECT_KEY, "keep", "missing"]; let mut sink = TestWriter::new(); let mut scratch = Vec::new(); @@ -9587,7 +9699,7 @@ mod tests { &mut sink, &mut scratch, &state, - &StateSelection::BorrowedKeys(&keys), + &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }), ) .unwrap(); diff --git a/crates/webui-handler/src/route_handler.rs b/crates/webui-handler/src/route_handler.rs index 3045cad50..440dbc2e0 100644 --- a/crates/webui-handler/src/route_handler.rs +++ b/crates/webui-handler/src/route_handler.rs @@ -20,7 +20,8 @@ use std::collections::{HashMap, HashSet}; use std::fmt; use std::sync::{Arc, OnceLock, RwLock}; use webui_protocol::{ - web_ui_fragment::Fragment, CssStrategy, DomStrategy, WebUIFragmentRoute, WebUIProtocol, + web_ui_fragment::Fragment, CssStrategy, DomStrategy, StateProjectionMode, WebUIFragmentRoute, + WebUIProtocol, }; use crate::streaming::PreparedContinuationStatePlan; @@ -46,7 +47,7 @@ pub struct Protocol { fragment_slots: HashMap, u32>, route_index: CompiledRouteIndex, boundary_declarations: OnceLock>, - continuation_state_plans: RwLock, Arc>>, + continuation_state_plans: OnceLock]>>, template_metadata_cache: RwLock>, } @@ -114,7 +115,7 @@ impl Protocol { fragment_slots, route_index, boundary_declarations: OnceLock::new(), - continuation_state_plans: RwLock::new(HashMap::new()), + continuation_state_plans: OnceLock::new(), template_metadata_cache: RwLock::new(HashMap::new()), } } @@ -178,24 +179,32 @@ impl Protocol { /// /// The graph walk that decides which top-level state keys a continuation /// retains depends only on the compiled protocol, so it runs at most once - /// per entry for the lifetime of this [`Protocol`]. Responses clone a - /// pointer instead of rebuilding the surface. Failures are captured and - /// replayed so the memo never re-walks a graph that is known to be - /// unusable. + /// per entry for the lifetime of this [`Protocol`]. Memoization is a + /// slot-indexed table of [`OnceLock`] cells: after the first response for + /// an entry, every later response reads the plan through one acquire load + /// with no lock, no hash, and no reference-count traffic. The table itself + /// is allocated on first streaming use, so protocols that never stream pay + /// nothing. Failures are captured and replayed so the memo never re-walks a + /// graph that is known to be unusable. pub(crate) fn continuation_state_plan( &self, entry_id: &str, - ) -> Arc { - if let Ok(plans) = self.continuation_state_plans.read() { - if let Some(plan) = plans.get(entry_id) { - return Arc::clone(plan); - } - } - let plan = Arc::new(PreparedContinuationStatePlan::new(&self.protocol, entry_id)); - if let Ok(mut plans) = self.continuation_state_plans.write() { - return Arc::clone(plans.entry(entry_id.into()).or_insert(plan)); - } - plan + ) -> Result<&PreparedContinuationStatePlan, HandlerError> { + let slot = self + .fragment_slot(entry_id) + .ok_or_else(|| HandlerError::MissingFragment(entry_id.to_string()))?; + let index = usize::try_from(slot).map_err(|_| { + HandlerError::Invariant("continuation plan slot does not fit usize".to_string()) + })?; + let plans = self.continuation_state_plans.get_or_init(|| { + (0..self.fragment_ids.len()) + .map(|_| OnceLock::new()) + .collect() + }); + let cell = plans + .get(index) + .ok_or_else(|| HandlerError::MissingFragment(entry_id.to_string()))?; + Ok(cell.get_or_init(|| PreparedContinuationStatePlan::new(&self.protocol, entry_id))) } /// Borrow the build-time CSS token list. @@ -374,10 +383,41 @@ pub(crate) fn build_component_index(protocol: &WebUIProtocol) -> HashMap, dependencies: Vec>, route_dependent: Vec, + hydration_keys: Vec>, + hydration_key_ids: Vec, + hydration_runs: Vec, +} + +/// One component's interned hydration projection. +/// +/// `len == FULL_STATE_RUN` marks a compiled surface that is not expressible as +/// a key allowlist, which forces the whole record to full state exactly as the +/// name-based collector did. +#[derive(Clone, Copy)] +struct HydrationRun { + start: u32, + len: u32, +} + +impl HydrationRun { + const FULL_STATE: Self = Self { + start: 0, + len: u32::MAX, + }; + + const fn requires_full_state(self) -> bool { + self.len == u32::MAX + } } impl ComponentReachabilityIndex { @@ -394,11 +434,16 @@ impl ComponentReachabilityIndex { route_dependent.push(has_route); } propagate_route_dependencies(&dependencies, &mut route_dependent); + let (hydration_keys, hydration_key_ids, hydration_runs) = + intern_hydration_projections(protocol, &names); Self { names, dependencies, route_dependent, + hydration_keys, + hydration_key_ids, + hydration_runs, } } @@ -417,6 +462,84 @@ impl ComponentReachabilityIndex { pub(crate) fn requires_expansion(&self, index: u32) -> Option { Some(self.is_route_dependent(index)? || !self.dependencies.get(index as usize)?.is_empty()) } + + /// Resolve one interned hydration key ID. + pub(crate) fn hydration_key(&self, id: u32) -> Option<&str> { + self.hydration_keys.get(id as usize).map(Box::as_ref) + } + + /// Append one component's hydration key IDs to `ids`. + /// + /// Returns `true` when the component's compiled surface requires full + /// state, in which case `ids` is meaningless for this record. + pub(crate) fn extend_hydration_keys(&self, index: u32, ids: &mut Vec) -> bool { + let Some(run) = self.hydration_runs.get(index as usize).copied() else { + return true; + }; + if run.requires_full_state() { + return true; + } + let start = run.start as usize; + let end = start.saturating_add(run.len as usize); + match self.hydration_key_ids.get(start..end) { + Some(run) => ids.extend_from_slice(run), + None => return true, + } + false + } +} + +/// Intern every component's compiled hydration projection into lexicographic +/// key IDs plus one flat run per component. +fn intern_hydration_projections( + protocol: &WebUIProtocol, + names: &[String], +) -> (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)); + } + } + distinct.sort_unstable(); + distinct.dedup(); + let keys: Vec> = distinct.iter().map(|key| Box::from(*key)).collect(); + + let mut key_ids = Vec::new(); + let mut runs = Vec::with_capacity(names.len()); + for name in names { + let Some(component) = protocol.components.get(name) 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); + continue; + }; + let mode = component.hydration_mode; + let component_keys = &component.hydration_keys; + let projects_keys = mode == StateProjectionMode::Keys as i32 + || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty()); + if mode == StateProjectionMode::All as i32 + || (!projects_keys && mode != StateProjectionMode::None as i32) + { + runs.push(HydrationRun::FULL_STATE); + continue; + } + let start = key_ids.len(); + if projects_keys { + for key in component_keys { + if let Ok(position) = distinct.binary_search(&key.as_str()) { + if let Ok(id) = u32::try_from(position) { + key_ids.push(id); + } + } + } + } + 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), + } + } + (keys, key_ids, runs) } enum ComponentDependencyWork<'a> { @@ -842,7 +965,10 @@ fn select_raw_state<'de>( .map_err(|error| invalid_state_json(&error.to_string())); } StateSelection::Keys(keys) => keys.as_slice(), - StateSelection::BorrowedKeys(keys) => *keys, + // Key-ID projections are produced only by the streaming continuation, + // which serializes through `write_selected_state` and never reaches + // partial navigation's raw-JSON projection. + StateSelection::KeyIds(_) => return Err(unexpected_key_id_selection()), }; project_raw_state(state_json, state_keys).map(SelectedRawState::Keys) } @@ -1104,6 +1230,14 @@ fn invalid_state_json(message: &str) -> HandlerError { HandlerError::InvalidState(message.to_string()) } +#[cold] +#[inline(never)] +fn unexpected_key_id_selection() -> HandlerError { + HandlerError::Invariant( + "streaming hydration key-ID projection reached partial navigation".to_string(), + ) +} + #[cold] #[inline(never)] fn partial_serialize_error(message: &str) -> HandlerError { @@ -2033,7 +2167,8 @@ fn select_owned_state(state: Value, selection: &StateSelection<'_>) -> Value { let state_keys = match selection { StateSelection::Full => return state, StateSelection::Keys(keys) => keys.as_slice(), - StateSelection::BorrowedKeys(keys) => *keys, + // Streaming's key-ID projection never reaches partial navigation. + StateSelection::KeyIds(_) => return Value::Object(Map::new()), }; let Value::Object(mut source) = state else { return Value::Object(Map::new()); @@ -2518,6 +2653,140 @@ mod tests { assert_eq!(prepared.tokens(), ["colorBrand"]); } + #[test] + fn continuation_state_plans_memoize_per_entry_without_locking() { + // Every response for an entry must read the same prepared plan through + // the slot table: no lock, no hash, and no per-response rebuild. + let mut fragments = HashMap::new(); + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("

plan

")], + contains_boundary: false, + }, + ); + let prepared = Protocol::new(WebUIProtocol::new(fragments)); + + let first = prepared.continuation_state_plan("index.html").unwrap(); + let second = prepared.continuation_state_plan("index.html").unwrap(); + assert!( + std::ptr::eq(first, second), + "a memoized plan must be borrowed, not rebuilt or cloned" + ); + // The table reserves one cell per compiled record, so the cell must stay + // small enough that a large protocol's lazy table is a rounding error. + let cell = std::mem::size_of::>(); + assert!( + cell <= 40, + "continuation plan memo cell grew to {cell} bytes" + ); + + let barrier = Arc::new(Barrier::new(4)); + let address = std::ptr::from_ref(first).addr(); + thread::scope(|scope| { + for _ in 0..4 { + let barrier = Arc::clone(&barrier); + let prepared = &prepared; + scope.spawn(move || { + barrier.wait(); + let plan = prepared.continuation_state_plan("index.html").unwrap(); + assert_eq!( + std::ptr::from_ref(plan).addr(), + address, + "concurrent responses must share one initialization" + ); + assert!(plan.resolve().is_ok()); + }); + } + }); + } + + #[test] + fn continuation_state_plans_replay_captured_failures() { + // A malformed entry is diagnosed identically on every response, and an + // unknown entry still reports the missing record rather than a slot. + let mut fragments = HashMap::new(); + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![WebUIFragment::component("missing-card")], + contains_boundary: false, + }, + ); + let prepared = Protocol::new(WebUIProtocol::new(fragments)); + + for _ in 0..2 { + match prepared + .continuation_state_plan("index.html") + .and_then(|plan| plan.resolve()) + .err() + { + Some(HandlerError::MissingFragment(id)) => assert_eq!(id, "missing-card"), + other => panic!("expected a replayable missing-record failure, got {other:?}"), + } + } + match prepared.continuation_state_plan("absent.html").err() { + Some(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent.html"), + other => panic!("expected a missing-entry diagnostic, got {other:?}"), + } + } + + #[test] + fn component_reachability_interns_hydration_projections() { + // Streaming records collect their projection from component indexes, so + // the interned runs must reproduce the compiled per-component surface + // in lexicographic ID order. + let mut fragments = HashMap::new(); + fragments.insert("keyed-card".to_string(), FragmentList::default()); + fragments.insert("all-card".to_string(), FragmentList::default()); + fragments.insert("bare-card".to_string(), FragmentList::default()); + let mut protocol = WebUIProtocol::new(fragments); + protocol.components.insert( + "keyed-card".to_string(), + webui_protocol::ComponentData { + hydration_mode: StateProjectionMode::Keys as i32, + hydration_keys: vec!["zebra".to_string(), "alpha".to_string()], + ..Default::default() + }, + ); + protocol.components.insert( + "all-card".to_string(), + webui_protocol::ComponentData { + hydration_mode: StateProjectionMode::All as i32, + ..Default::default() + }, + ); + let prepared = Protocol::new(protocol); + let index = prepared.component_reachability(); + let component = prepared.component_index(); + + let mut ids = Vec::new(); + assert!( + !index.extend_hydration_keys(component["keyed-card"], &mut ids), + "a keyed surface projects keys" + ); + ids.sort_unstable(); + let keys: Vec<&str> = ids + .iter() + .filter_map(|id| index.hydration_key(*id)) + .collect(); + assert_eq!( + keys, + ["alpha", "zebra"], + "sorted IDs must be lexicographically sorted keys" + ); + + let mut ids = Vec::new(); + assert!( + index.extend_hydration_keys(component["all-card"], &mut ids), + "an ALL surface forces full state" + ); + assert!( + index.extend_hydration_keys(component["bare-card"], &mut ids), + "a component without compiled metadata forces full state" + ); + } + #[test] fn protocol_retains_only_serialized_component_asset_styles() { let mut protocol = WebUIProtocol::default(); diff --git a/crates/webui-handler/src/route_renderer.rs b/crates/webui-handler/src/route_renderer.rs index 023d8ca33..049b55cb3 100644 --- a/crates/webui-handler/src/route_renderer.rs +++ b/crates/webui-handler/src/route_renderer.rs @@ -75,6 +75,11 @@ pub(crate) fn write_escaped_state_attr(writer: &mut dyn ResponseWriter, value: & /// This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal + 1 param). /// /// `route_base` is used to resolve relative paths (starting with `./`). +/// +/// Request segmentation is deferred until a route fragment is actually seen: +/// every record entry calls this, and the overwhelming majority of records — +/// component bodies, conditions, loop bodies — carry no routes at all, so a +/// route-free record must not pay for a segment vector. pub(crate) fn find_best_route_match( fragments: &[WebUIFragment], request_path: &str, @@ -82,16 +87,17 @@ pub(crate) fn find_best_route_match( route_index: &CompiledRouteIndex, ) -> Option<(String, route_matcher::RouteMatch)> { let mut best: Option<(String, route_matcher::RouteMatch)> = None; - - let request_segments = route_matcher::split_request_path(request_path); + let mut request_segments: Option> = None; for item in fragments { if let Some(Fragment::Route(route_frag)) = item.fragment.as_ref() { + let segments = request_segments + .get_or_insert_with(|| route_matcher::split_request_path(request_path)); if let Some(m) = route_matcher::match_route_indexed_with_segments( route_index, &route_frag.path, route_base, - &request_segments, + segments, route_frag.exact, ) { let is_better = best diff --git a/crates/webui-handler/src/streaming/checkpoint.rs b/crates/webui-handler/src/streaming/checkpoint.rs index db9a3a107..71233e1b2 100644 --- a/crates/webui-handler/src/streaming/checkpoint.rs +++ b/crates/webui-handler/src/streaming/checkpoint.rs @@ -11,8 +11,9 @@ use super::state::StateUpdatePlan; use super::{flush_streaming_transport, streaming_state, MarkerBuffer}; use crate::plugin::WebUiTemplatePayload; use crate::{ - collect_hydration_state_into, write_selected_state, write_webui_bootstrap, HandlerError, - Result, StateSelection, WebUIHandler, WebUIProcessContext, WebUiBootstrap, + collect_hydration_key_ids_into, write_selected_state, write_webui_bootstrap, HandlerError, + HydrationKeySelection, Result, StateSelection, WebUIHandler, WebUIProcessContext, + WebUiBootstrap, }; pub(super) const RECORD_KIND_FINAL_CHECKPOINT: usize = 0; @@ -121,19 +122,18 @@ impl WebUIHandler { let template_payloads = context.plugin.as_ref().and_then(|plugin| { plugin.collect_template_payloads_slice(context.protocol, &new_template_tags) }); - let (mut state_key_scratch, checkpoint_reachability) = { + let (mut state_key_ids, checkpoint_reachability) = { let streaming = streaming_state(context)?; ( - std::mem::take(&mut streaming.state_key_scratch), + std::mem::take(&mut streaming.state_key_ids), streaming.component_reachability, ) }; - let requires_full_state = collect_hydration_state_into( + let requires_full_state = collect_hydration_key_ids_into( context.protocol, - checkpoint_tags - .iter() - .filter_map(|&index| checkpoint_reachability.name(index)), - &mut state_key_scratch, + checkpoint_reachability, + checkpoint_tags.iter().copied(), + &mut state_key_ids, ); let chain = if first_checkpoint { crate::route_handler::collect_route_chain( @@ -198,7 +198,10 @@ impl WebUIHandler { let state_selection = if requires_full_state { StateSelection::Full } else { - StateSelection::BorrowedKeys(&state_key_scratch) + StateSelection::KeyIds(HydrationKeySelection { + ids: &state_key_ids, + index: checkpoint_reachability, + }) }; let inventory = context .streaming @@ -253,21 +256,27 @@ impl WebUIHandler { if streaming.update_plans.len() <= target { streaming.update_plans.resize_with(target + 1, || None); } - streaming.update_plans[target] = Some(StateUpdatePlan { - requires_full_state, - keys: if requires_full_state { - Vec::new() - } else { - state_key_scratch.iter().copied().map(Box::from).collect() - }, - }); + // Reuse the slot's existing key buffer so a boundary that commits + // updatable more than once in a response does not re-allocate. + let mut plan = streaming.update_plans[target] + .take() + .unwrap_or(StateUpdatePlan { + requires_full_state, + key_ids: Vec::new(), + }); + plan.requires_full_state = requires_full_state; + plan.key_ids.clear(); + if !requires_full_state { + plan.key_ids.extend_from_slice(&state_key_ids); + } + streaming.update_plans[target] = Some(plan); } finish_capture( context, CapturedBuffers { checkpoint_tags, template_tags: new_template_tags, - state_keys: state_key_scratch, + state_key_ids, css_hrefs, style_specs, }, @@ -298,6 +307,9 @@ impl WebUIHandler { if !context.state.is_object() { return Err(super::error::state_update_type_error()); } + // The plan is moved out for the duration of the write so the record can + // borrow the writer mutably, then handed straight back: an update never + // rebuilds or reallocates the projection it committed with. let Some(plan) = context .streaming .as_mut() @@ -314,32 +326,37 @@ impl WebUIHandler { RECORD_KIND_STATE_UPDATE, boundary_id, )?; - let selection = if plan.requires_full_state { - StateSelection::Full - } else { - StateSelection::Keys(plan.keys.iter().map(Box::as_ref).collect()) + let result = match context.streaming.as_ref() { + Some(streaming) if !plan.requires_full_state => write_selected_state( + context.writer, + &mut context.json_scratch, + context.state, + &StateSelection::KeyIds(HydrationKeySelection { + ids: &plan.key_ids, + index: streaming.component_reachability, + }), + ), + _ => write_selected_state( + context.writer, + &mut context.json_scratch, + context.state, + &StateSelection::Full, + ), }; - write_selected_state( - context.writer, - &mut context.json_scratch, - context.state, - &selection, - )?; - context - .writer - .write("]")?; - flush_streaming_transport(context)?; - let Some(slot) = context + // Restore the plan before propagating a write failure so a poisoned + // response still owns its buffers instead of leaking their capacity. + if let Some(slot) = context .streaming .as_mut() .and_then(|streaming| streaming.update_plans.get_mut(boundary_id)) - else { - return Err(HandlerError::Invariant( - "streaming update projection slot disappeared".to_string(), - )); - }; - *slot = Some(plan); - Ok(()) + { + *slot = Some(plan); + } + result?; + context + .writer + .write("]")?; + flush_streaming_transport(context) } } @@ -392,7 +409,7 @@ fn write_script_open(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<() struct CapturedBuffers<'a> { checkpoint_tags: Vec, template_tags: Vec<&'a str>, - state_keys: Vec<&'a str>, + state_key_ids: Vec, css_hrefs: Vec<&'a str>, style_specs: Vec<&'a str>, } @@ -409,8 +426,8 @@ fn finish_capture<'a>( streaming.checkpoint_walk_roots.clear(); buffers.template_tags.clear(); streaming.template_tag_scratch = buffers.template_tags; - buffers.state_keys.clear(); - streaming.state_key_scratch = buffers.state_keys; + buffers.state_key_ids.clear(); + streaming.state_key_ids = buffers.state_key_ids; buffers.css_hrefs.clear(); streaming.css_href_scratch = buffers.css_hrefs; buffers.style_specs.clear(); diff --git a/crates/webui-handler/src/streaming/mod.rs b/crates/webui-handler/src/streaming/mod.rs index 88be732cf..8ec63f9b8 100644 --- a/crates/webui-handler/src/streaming/mod.rs +++ b/crates/webui-handler/src/streaming/mod.rs @@ -335,8 +335,11 @@ impl WebUIHandler { /// Render every runtime boundary in document order as final. /// - /// Hosts that need asynchronous work between occurrences should use - /// [`Self::stream_response`] or [`StreamingSession`]. + /// The value is snapshotted once when the response starts and every + /// occurrence resumes against that snapshot, so a large state is projected + /// once per response rather than re-merged once per boundary. Hosts that + /// need asynchronous work — or genuinely new state — between occurrences + /// should use [`Self::stream_response`] or [`StreamingSession`]. pub fn render_streaming<'a, W: FlushWriter + ?Sized>( &self, protocol: &'a Protocol, @@ -352,7 +355,7 @@ impl WebUIHandler { "unfinished streaming step has no pending boundary".to_string(), )); }; - status = response.resume(boundary.instance_id, state, BoundaryMode::Final)?; + status = response.resume_current(boundary.instance_id, BoundaryMode::Final)?; } Ok(()) } diff --git a/crates/webui-handler/src/streaming/session.rs b/crates/webui-handler/src/streaming/session.rs index d686385d3..81e740a84 100644 --- a/crates/webui-handler/src/streaming/session.rs +++ b/crates/webui-handler/src/streaming/session.rs @@ -231,6 +231,27 @@ impl StreamingResponse<'_, W> { ) } + /// Commit the pending occurrence without merging new state. + /// + /// Used by [`WebUIHandler::render_streaming`], which drives every + /// occurrence from the single value it already snapshotted at start. + pub(crate) fn resume_current( + &mut self, + instance_id: BoundaryInstanceId, + mode: BoundaryMode, + ) -> Result { + self.core.resume_current( + SessionCall { + handler: self.handler, + protocol: self.protocol, + options: &self.options, + writer: &mut self.sink, + }, + instance_id, + mode, + ) + } + /// Whether the response has emitted its terminal and ended its writer. #[must_use] pub fn is_done(&self) -> bool { @@ -274,8 +295,7 @@ impl SessionCore { if !protocol.protocol().fragments.contains_key(entry_id) { return Err(HandlerError::MissingFragment(entry_id.to_string())); } - let prepared = protocol.continuation_state_plan(entry_id); - let state_plan = prepared.resolve()?; + let state_plan = protocol.continuation_state_plan(entry_id)?.resolve()?; Ok(Self { vm: ContinuationVm::new(entry_id, protocol)?, frozen_keys: Arc::clone(&state_plan.keys), @@ -342,6 +362,28 @@ impl SessionCore { self.run_advance(call, Some((instance_id, mode))) } + /// Commit the pending occurrence against the snapshot the response already + /// holds. + /// + /// Callers that never introduce new state between occurrences — the + /// one-shot [`WebUIHandler::render_streaming`] helper is the canonical one — + /// would otherwise re-merge an identical value into the retained snapshot + /// once per boundary, making a large state cost O(boundaries × state size) + /// for a result that is byte-for-byte what the previous step already held. + /// The public [`Self::resume`] entry point keeps the overlay for hosts that + /// genuinely resolve new data per occurrence. + pub(crate) fn resume_current( + &mut self, + call: SessionCall<'_, '_>, + instance_id: BoundaryInstanceId, + mode: BoundaryMode, + ) -> Result { + self.require_usable("resume")?; + self.require_started("resume")?; + self.vm.validate_resume(instance_id)?; + self.run_advance(call, Some((instance_id, mode))) + } + pub(crate) fn update( &mut self, call: SessionCall<'_, '_>, @@ -504,3 +546,307 @@ impl SessionCore { fn missing_progress_error() -> HandlerError { HandlerError::Invariant("streaming progress is unavailable".to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FlushWriter, ResponseWriter}; + use webui_parser::{ComponentRegistration, HtmlParser}; + use webui_protocol::{ComponentData, InitialStateStrategy, StateProjectionMode, WebUIProtocol}; + use webui_test_utils::test_json; + + const ISLAND_TAG: &str = "state-island"; + + struct TestSink { + output: String, + } + + impl ResponseWriter for TestSink { + fn write(&mut self, content: &str) -> Result<()> { + self.output.push_str(content); + Ok(()) + } + + fn end(&mut self) -> Result<()> { + Ok(()) + } + } + + impl FlushWriter for TestSink { + fn flush(&mut self) -> Result<()> { + Ok(()) + } + } + + /// Build a parser-produced entry with `boundaries` runtime occurrences, + /// each hosting one island component. + fn boundary_protocol(boundaries: usize, hydration_mode: StateProjectionMode) -> Protocol { + let mut html = String::from(""); + for sequence in 0..boundaries { + html.push_str("
<"); + html.push_str(ISLAND_TAG); + html.push_str(">
"); + } + html.push_str(""); + + let mut parser = HtmlParser::new(); + match parser + .component_registry_mut() + .register_component(ComponentRegistration::new( + ISLAND_TAG, + "", + None, + true, + )) { + Ok(()) => {} + Err(error) => panic!("registering the island failed: {error}"), + } + if let Err(error) = parser.parse("index.html", &html) { + panic!("parsing the streaming entry failed: {error}"); + } + let mut document = WebUIProtocol::new(parser.into_fragment_records()); + document.initial_state_strategy = InitialStateStrategy::Components as i32; + document.components.insert( + ISLAND_TAG.to_string(), + ComponentData { + template_json: r#"{"h":"","th":1}"#.to_string(), + hydration_mode: hydration_mode as i32, + hydration_keys: if matches!(hydration_mode, StateProjectionMode::Keys) { + vec!["count".to_string(), "title".to_string()] + } else { + Vec::new() + }, + ..Default::default() + }, + ); + Protocol::new(document) + } + + /// A state whose payload is large enough that a per-boundary copy would be + /// unmistakable in both time and allocation. + fn large_state(rows: usize) -> Value { + let mut items = Vec::with_capacity(rows); + for row in 0..rows { + items.push(test_json!({ + "id": row, + "label": format!("row-{row}"), + "tags": ["alpha", "beta", "gamma"], + })); + } + test_json!({ + "count": 42, + "title": "large state", + "rows": items, + }) + } + + /// Heap address of the retained snapshot's `rows` buffer, or `None` when + /// the snapshot does not hold it. + fn rows_address(state: &Value) -> Option { + state + .get("rows") + .and_then(Value::as_array) + .map(|rows| rows.as_ptr().addr()) + } + + fn options<'a>() -> RenderOptions<'a> { + RenderOptions::new("index.html", "/") + } + + #[test] + fn render_streaming_projects_full_state_once_per_response() -> Result<()> { + // Full-state protocols retain the caller's whole tree. Committing each + // occurrence against the snapshot the response already holds must not + // re-copy that tree, so the retained buffer keeps its identity for the + // entire response no matter how many boundaries commit. + let protocol = boundary_protocol(8, StateProjectionMode::All); + let handler = WebUIHandler::new(); + let state = large_state(256); + let render_options = options(); + let mut sink = TestSink { + output: String::new(), + }; + let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?; + + let mut status = response.start(&state)?; + let snapshot = rows_address(&response.core.frozen_state); + assert!( + snapshot.is_some(), + "a full-state protocol must retain the caller's payload" + ); + assert_ne!( + snapshot, + rows_address(&state), + "the response owns its snapshot rather than borrowing the caller's tree" + ); + + let mut committed = 0usize; + while !status.done { + let Some(boundary) = status.boundary.as_ref() else { + panic!("an unfinished step must carry a pending boundary"); + }; + status = response.resume_current(boundary.instance_id, BoundaryMode::Final)?; + committed += 1; + assert_eq!( + rows_address(&response.core.frozen_state), + snapshot, + "committing occurrence {committed} must not re-copy the retained snapshot" + ); + } + assert_eq!(committed, 8, "every authored boundary must commit"); + + // The one-shot helper drives exactly this loop, so its bytes must match + // the snapshot-only path it delegates to. + let mut helper_sink = TestSink { + output: String::new(), + }; + handler.render_streaming(&protocol, &state, &render_options, &mut helper_sink)?; + assert_eq!( + helper_sink.output, sink.output, + "render_streaming must resume against the retained snapshot" + ); + Ok(()) + } + + #[test] + fn resume_overlays_new_state_and_reuses_unchanged_subtrees() -> Result<()> { + // The public resume keeps its patch semantics: a changed key lands in + // the snapshot, an omitted key survives, and an unchanged subtree is + // left in place instead of being copied again. + let protocol = boundary_protocol(2, StateProjectionMode::All); + let handler = WebUIHandler::new(); + let state = large_state(64); + let render_options = options(); + let mut sink = TestSink { + output: String::new(), + }; + let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?; + + let status = response.start(&state)?; + let snapshot = rows_address(&response.core.frozen_state); + let Some(boundary) = status.boundary.as_ref() else { + panic!("the first occurrence must suspend"); + }; + + let mut next = state.clone(); + if let Some(object) = next.as_object_mut() { + object.insert("title".to_string(), Value::String("second".to_string())); + object.remove("count"); + } + let status = response.resume(boundary.instance_id, &next, BoundaryMode::Final)?; + + assert_eq!( + response.core.frozen_state.get("title"), + Some(&Value::String("second".to_string())), + "a changed key must land in the snapshot" + ); + assert_eq!( + response.core.frozen_state.get("count"), + Some(&test_json!(42)), + "an omitted key keeps the value the snapshot already holds" + ); + assert_eq!( + rows_address(&response.core.frozen_state), + snapshot, + "an unchanged subtree must not be copied again" + ); + assert!(status.boundary.is_some(), "the second occurrence follows"); + Ok(()) + } + + #[test] + fn semantic_steps_reuse_projection_scratch() -> Result<()> { + // The record projection scratch lives in the retained progress as plain + // integers, so after the first record every later step reuses the same + // allocation instead of rebuilding one per step. + let protocol = boundary_protocol(6, StateProjectionMode::Keys); + let handler = WebUIHandler::new(); + let state = test_json!({ "count": 1, "title": "scratch", "unused": "x" }); + let render_options = options(); + let mut sink = TestSink { + output: String::new(), + }; + let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?; + + let mut status = response.start(&state)?; + let mut retained: Option<(usize, usize)> = None; + while !status.done { + let Some(boundary) = status.boundary.as_ref() else { + panic!("an unfinished step must carry a pending boundary"); + }; + status = response.resume_current(boundary.instance_id, BoundaryMode::Updatable)?; + let Some(progress) = response.core.streaming.as_ref() else { + panic!("a suspended response must retain its progress"); + }; + let observed = ( + progress.state_key_ids.capacity(), + progress.state_key_ids.as_ptr().addr(), + ); + assert!( + observed.0 > 0, + "the projection scratch must survive the record that filled it" + ); + match retained { + None => retained = Some(observed), + Some(previous) => assert_eq!( + observed, previous, + "a later step must reuse the projection buffer, not allocate a new one" + ), + } + } + Ok(()) + } + + #[test] + fn updates_reuse_their_committed_projection_buffer() -> Result<()> { + // An update writes through the plan captured at commit time: no key + // list is rebuilt, so the plan's buffer keeps its identity across every + // update it serves. + let protocol = boundary_protocol(2, StateProjectionMode::Keys); + let handler = WebUIHandler::new(); + let state = test_json!({ "count": 1, "title": "updates" }); + let render_options = options(); + let mut sink = TestSink { + output: String::new(), + }; + let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?; + + let status = response.start(&state)?; + let Some(boundary) = status.boundary.as_ref() else { + panic!("the first occurrence must suspend"); + }; + let instance_id = boundary.instance_id; + response.resume(instance_id, &state, BoundaryMode::Updatable)?; + + let mut retained: Option<(usize, usize)> = None; + for _ in 0..4 { + response.update(instance_id, &state)?; + let Some(progress) = response.core.streaming.as_ref() else { + panic!("a live response must retain its progress"); + }; + let Some(Some(plan)) = progress.update_plans.first() else { + panic!("an updatable occurrence must retain its projection plan"); + }; + let observed = (plan.key_ids.capacity(), plan.key_ids.as_ptr().addr()); + assert!(!plan.requires_full_state, "keyed islands project keys"); + assert!(observed.0 > 0, "the plan must retain its key buffer"); + match retained { + None => retained = Some(observed), + Some(previous) => assert_eq!( + observed, previous, + "every update must reuse the committed projection buffer" + ), + } + } + assert_eq!( + sink.output.matches(",2,0,{").count(), + 4, + "each update emits exactly one typed state-update record" + ); + Ok(()) + } +} diff --git a/crates/webui-handler/src/streaming/state.rs b/crates/webui-handler/src/streaming/state.rs index f0c737148..52fe818a3 100644 --- a/crates/webui-handler/src/streaming/state.rs +++ b/crates/webui-handler/src/streaming/state.rs @@ -59,7 +59,12 @@ pub(crate) struct StreamingRenderState<'data> { pub(super) checkpoint_walk_roots: Vec<(u32, Option>)>, pub(super) checkpoint_seen: Vec, pub(super) checkpoint_needs_expansion: bool, - pub(super) state_key_scratch: Vec<&'data str>, + /// Interned hydration key IDs for the record being committed. + /// + /// Integers instead of borrowed keys: the buffer outlives every semantic + /// step in [`StreamingProgress`], so a checkpoint or update never allocates + /// a fresh projection scratch. + pub(super) state_key_ids: Vec, pub(super) template_tag_scratch: Vec<&'data str>, pub(super) css_href_scratch: Vec<&'data str>, pub(super) style_spec_scratch: Vec<&'data str>, @@ -69,7 +74,7 @@ pub(crate) struct StreamingRenderState<'data> { pub(super) struct StateUpdatePlan { pub(super) requires_full_state: bool, - pub(super) keys: Vec>, + pub(super) key_ids: Vec, } /// Owned state retained between calls by borrowed and host-owned sessions. @@ -90,6 +95,7 @@ pub(crate) struct StreamingProgress { pub(super) checkpoint_walk_roots: Vec<(u32, Option>)>, pub(super) checkpoint_seen: Vec, pub(super) checkpoint_needs_expansion: bool, + pub(super) state_key_ids: Vec, pub(super) reachability_stack: Vec, pub(super) update_plans: Vec>, } @@ -114,6 +120,7 @@ impl StreamingProgress { checkpoint_walk_roots: Vec::new(), checkpoint_seen: vec![0; inventory_bytes], checkpoint_needs_expansion: false, + state_key_ids: Vec::new(), reachability_stack: Vec::new(), update_plans: Vec::new(), } @@ -128,7 +135,9 @@ impl<'data> StreamingRenderState<'data> { Self { component_reachability, pending_root: None, - state_key_scratch: Vec::with_capacity(crate::INITIAL_KEY_CAPACITY), + // Borrowed template/CSS scratch starts empty: only a record that + // delivers first-time component metadata ever fills it, so a + // steady-state step allocates nothing here. template_tag_scratch: Vec::new(), css_href_scratch: Vec::new(), style_spec_scratch: Vec::new(), @@ -148,6 +157,7 @@ impl<'data> StreamingRenderState<'data> { checkpoint_walk_roots: progress.checkpoint_walk_roots, checkpoint_seen: progress.checkpoint_seen, checkpoint_needs_expansion: progress.checkpoint_needs_expansion, + state_key_ids: progress.state_key_ids, reachability_stack: progress.reachability_stack, update_plans: progress.update_plans, } @@ -171,6 +181,7 @@ impl<'data> StreamingRenderState<'data> { checkpoint_walk_roots: self.checkpoint_walk_roots, checkpoint_seen: self.checkpoint_seen, checkpoint_needs_expansion: self.checkpoint_needs_expansion, + state_key_ids: self.state_key_ids, reachability_stack: self.reachability_stack, update_plans: self.update_plans, } @@ -235,9 +246,16 @@ pub(crate) fn selected_state_snapshot( /// Merge the caller's state for this step into the retained continuation /// snapshot. /// -/// Keys already present reuse their existing entry, so a host that supplies -/// the same surface on every step pays no key allocation and lets -/// [`serde_json::Value::clone_from`] reuse the previous value's buffers. +/// Merging is *patch*, not replace: a key the caller omits keeps the value the +/// snapshot already holds, and no key is ever removed. Only the projected +/// surface is considered, so state a continuation never reads is not retained. +/// +/// A value that is already identical is left alone, so a host resuming with the +/// same surface every step copies nothing — the comparison walks the shared +/// shape and stops at the first difference, while a copy would allocate a fresh +/// tree for data the snapshot already holds. Keys that do change reuse their +/// existing entry, letting [`serde_json::Value::clone_from`] reuse the previous +/// value's buffers. pub(crate) fn overlay_selected_state( frozen: &mut serde_json::Value, state: &serde_json::Value, @@ -257,7 +275,11 @@ pub(crate) fn overlay_selected_state( continue; }; match target.get_mut(key.as_ref()) { - Some(slot) => slot.clone_from(value), + Some(slot) => { + if slot != value { + slot.clone_from(value); + } + } None => { target.insert(key.to_string(), value.clone()); } @@ -265,6 +287,11 @@ pub(crate) fn overlay_selected_state( } } +/// Merge every top-level key of the caller's state into the retained snapshot. +/// +/// Same patch semantics as [`overlay_selected_state`]: omitted keys keep their +/// snapshot value, nothing is removed, and an unchanged subtree is neither +/// copied nor reallocated. pub(crate) fn overlay_full_state(frozen: &mut serde_json::Value, state: &serde_json::Value) { let serde_json::Value::Object(source) = state else { return; @@ -277,7 +304,11 @@ pub(crate) fn overlay_full_state(frozen: &mut serde_json::Value, state: &serde_j }; for (key, value) in source { match target.get_mut(key) { - Some(slot) => slot.clone_from(value), + Some(slot) => { + if slot != value { + slot.clone_from(value); + } + } None => { target.insert(key.clone(), value.clone()); } diff --git a/crates/webui-handler/src/streaming/vm.rs b/crates/webui-handler/src/streaming/vm.rs index 974815056..b637ff2a6 100644 --- a/crates/webui-handler/src/streaming/vm.rs +++ b/crates/webui-handler/src/streaming/vm.rs @@ -46,6 +46,50 @@ const CAPTURE_POOL_LIMIT: usize = 8; /// a component host, and one conditional or loop body) so the common response /// never reallocates its frame stack. const INITIAL_FRAME_CAPACITY: usize = 16; +/// Records kept resolved while one semantic step walks the graph. +/// +/// A step touches the record it entered, the parent it returns to, and at most +/// a couple of enclosing hosts, so four entries cover the common continuation +/// without turning the probe into a search. +const RECORD_CACHE_SIZE: usize = 4; + +/// Bounded slot→record cache scoped to a single [`ContinuationVm::advance`]. +/// +/// Resolving a slot costs a dense-vector read plus a hash of the compiled +/// record ID, and a step re-resolves the same few records every time it +/// descends into a child and unwinds back to the parked parent. Caching the +/// borrow for the duration of one step collapses those repeats to a handful of +/// integer comparisons while keeping the VM itself lifetime-free between calls. +struct RecordCache<'data> { + entries: [Option<(u32, &'data webui_protocol::FragmentList)>; RECORD_CACHE_SIZE], + next: usize, +} + +impl<'data> RecordCache<'data> { + const fn new() -> Self { + Self { + entries: [None; RECORD_CACHE_SIZE], + next: 0, + } + } + + /// Borrow the record for `slot`, resolving and retaining it on a miss. + fn record( + &mut self, + protocol: &'data crate::Protocol, + slot: u32, + ) -> Result<&'data webui_protocol::FragmentList> { + for (cached, list) in self.entries.iter().flatten() { + if *cached == slot { + return Ok(list); + } + } + let list = slot_fragment(protocol, slot)?; + self.entries[self.next] = Some((slot, list)); + self.next = (self.next + 1) % RECORD_CACHE_SIZE; + Ok(list) + } +} pub(crate) struct ContinuationVm { frames: Vec, @@ -77,8 +121,10 @@ pub(crate) struct ContinuationStatePlan { /// Building the plan can fail on a malformed protocol. Capturing that failure /// keeps the memo authoritative: a bad entry is diagnosed identically on every /// response without re-walking a graph that is already known to be unusable. +/// The failure is boxed so the memo table stores one small cell per compiled +/// record instead of reserving the diagnostic's payload for every slot. pub(crate) struct PreparedContinuationStatePlan { - result: std::result::Result, + result: std::result::Result>, } enum ContinuationStatePlanError { @@ -95,14 +141,14 @@ impl PreparedContinuationStatePlan { entry_id, super::session::MAX_FROZEN_STATE_KEYS, ) - .map_err(ContinuationStatePlanError::capture), + .map_err(|error| Box::new(ContinuationStatePlanError::capture(error))), } } pub(crate) fn resolve(&self) -> Result<&ContinuationStatePlan> { self.result .as_ref() - .map_err(ContinuationStatePlanError::to_handler_error) + .map_err(|error| error.to_handler_error()) } } @@ -295,16 +341,23 @@ impl ContinuationVm { protocol: &'data crate::Protocol, context: &mut WebUIProcessContext<'data, '_, '_>, ) -> Result { + let mut records = RecordCache::new(); while let Some(frame) = self.frames.pop() { match frame { Frame::EnterFragment(slot) => { - let frame = self.open_fragment(slot, protocol, context)?; - if let Some(status) = self.run_fragment(frame, handler, protocol, context)? { + let list = records.record(protocol, slot)?; + let frame = open_fragment(slot, list, context); + if let Some(status) = + self.run_fragment(frame, list, (handler, protocol), context)? + { return Ok(status); } } Frame::Fragment(frame) => { - if let Some(status) = self.run_fragment(frame, handler, protocol, context)? { + let list = records.record(protocol, frame.slot)?; + if let Some(status) = + self.run_fragment(frame, list, (handler, protocol), context)? + { return Ok(status); } } @@ -391,41 +444,21 @@ impl ContinuationVm { }) } - fn open_fragment( - &mut self, - slot: u32, - protocol: &crate::Protocol, - context: &WebUIProcessContext<'_, '_, '_>, - ) -> Result { - let list = slot_fragment(protocol, slot)?; - let best_route = crate::route_renderer::find_best_route_match( - &list.fragments, - context.request_path, - &context.route_base, - context.route_index, - ); - Ok(FragmentFrame { - slot, - index: 0, - best_route, - }) - } - /// Walk one fragment record until it descends, suspends, or ends. /// - /// The record is resolved once per entry and inert fragments never touch - /// the frame stack, so a boundary body costs one map lookup rather than one - /// per fragment. Only a construct that owns a child record (component, - /// condition, loop, route, outlet) or a discovered boundary parks the frame - /// and returns to the caller. + /// The record is resolved by the caller — once per step for the whole + /// descend/unwind cycle — and inert fragments never touch the frame stack, + /// so a boundary body costs no record lookups at all. Only a construct that + /// owns a child record (component, condition, loop, route, outlet) or a + /// discovered boundary parks the frame and returns to the caller. fn run_fragment<'data>( &mut self, mut frame: FragmentFrame, - handler: &WebUIHandler, - protocol: &'data crate::Protocol, + list: &'data webui_protocol::FragmentList, + runtime: (&WebUIHandler, &'data crate::Protocol), context: &mut WebUIProcessContext<'data, '_, '_>, ) -> Result> { - let list = slot_fragment(protocol, frame.slot)?; + let (handler, protocol) = runtime; loop { let index = frame.index; let Some(fragment) = list.fragments.get(index) else { @@ -1366,6 +1399,28 @@ fn fragment_slot(protocol: &crate::Protocol, id: &str) -> Result { .ok_or_else(|| HandlerError::MissingFragment(id.to_string())) } +/// Park a freshly entered record, pre-selecting its best route match. +/// +/// The record is already resolved by the caller's step-local cache, so entering +/// a child costs no additional lookup. +fn open_fragment( + slot: u32, + list: &webui_protocol::FragmentList, + context: &WebUIProcessContext<'_, '_, '_>, +) -> FragmentFrame { + let best_route = crate::route_renderer::find_best_route_match( + &list.fragments, + context.request_path, + &context.route_base, + context.route_index, + ); + FragmentFrame { + slot, + index: 0, + best_route, + } +} + /// Borrow the record a continuation frame is walking. fn slot_fragment(protocol: &crate::Protocol, slot: u32) -> Result<&webui_protocol::FragmentList> { let id = protocol diff --git a/examples/integration/streaming-browser-bench/README.md b/examples/integration/streaming-browser-bench/README.md index a4acf0a4e..53be6d64c 100644 --- a/examples/integration/streaming-browser-bench/README.md +++ b/examples/integration/streaming-browser-bench/README.md @@ -141,11 +141,31 @@ root proven hydrated** (successful-hydration count and reactive `setState`-probe count both equal 1500), zero residual scaffolding (scripts, sentinels, `wb:` comments, `[data-ws]`), no globally-published streamed state (`window.__webui.state` stays unset), the ordinary bundle contains no coordinator -tokens (`webui-hydrate` / `data-webui-boundary`), measured component CPU is -non-zero, distinct boundary-local states reach only their own real activation -hooks, and the streaming entry adds no more than 17.5 KiB minified / 6 KiB -gzip. Esbuild output is deterministic and the cap retains roughly 4% headroom, so -further growth still fails. +tokens (`webui-hydrate`, `data-webui-boundary`, the `data-ws-span` / +`data-ws-enclosing` compiler span attributes, or open-span registry code), +measured component CPU is non-zero, distinct boundary-local states reach only +their own real activation hooks, and four absolute bundle-byte caps hold. + +The byte caps come in two kinds, and both are required: + +| Cap | Bytes | Measured | Headroom | +|---|---|---|---| +| ordinary minified | 63,500 | 60,528 | 4.7% | +| ordinary gzip | 19,850 | 18,993 | 4.3% | +| streaming incremental minified | 17,400 | 16,652 | 4.3% | +| streaming incremental gzip | 6,190 | 5,928 | 4.2% | + +The **ordinary** caps are absolute because they bound what a *non-streaming* app +downloads. The incremental caps alone cannot: they subtract the ordinary bundle, +so bytes added to the always-shipped entry cancel out of them entirely. Component +spans are the concrete case — the compiler attribute names and open-span registry +live only in the opt-in coordinator, and `TemplateElement` receives an +already-resolved bypass ancestor element it compares by identity. + +Esbuild output is deterministic, so ~4-5% headroom absorbs a minifier or +toolchain nudge while still failing on real growth. The spec logs measured bytes +and remaining headroom on every run; update the recorded numbers and the caps +together, never a cap alone. Opt-in via `WEBUI_STREAMING_HYDRATION_ENFORCE=1` (noisy, off by default), each printing its effective cap: diff --git a/examples/integration/streaming-browser-bench/tests/hydration_matrix.spec.ts b/examples/integration/streaming-browser-bench/tests/hydration_matrix.spec.ts index cd375da9f..60810ca6d 100644 --- a/examples/integration/streaming-browser-bench/tests/hydration_matrix.spec.ts +++ b/examples/integration/streaming-browser-bench/tests/hydration_matrix.spec.ts @@ -17,9 +17,10 @@ * * Deterministic correctness is always enforced (equal live roots, zero residual * scaffolding, no globally-published streamed state, coordinator-free ordinary - * bundle, bounded coordinator bundle bytes). The noisy performance/memory gates - * (component CPU <= single-boundary streaming one-shot * 1.05, bounded retained- - * heap slope, bounded peak heap, linear elapsed growth) are opt-in via + * bundle, absolute ordinary bundle byte caps, bounded coordinator bundle + * bytes). The noisy performance/memory gates (component CPU <= single-boundary + * streaming one-shot * 1.05, bounded retained-heap slope, bounded peak heap, + * linear elapsed growth) are opt-in via * `WEBUI_STREAMING_HYDRATION_ENFORCE=1` so ordinary CI stays stable. * * # Baseline workflow (distinct from the transport snapshot) @@ -93,15 +94,39 @@ const RETAINED_SLOPE_PCT = 2; * scaffolding the control never allocates. */ const PEAK_HEAP_ABS_FLOOR_BYTES = 512 * 1024; const PEAK_HEAP_TOLERANCE_PCT = 15; -/** Deterministic production coordinator size caps. Raising either requires - * explicit review because every streaming application pays these bytes. - * The v2 coordinator includes checkpoint declaration/span validation, retained - * roots for state updates, span completion, terminal cleanup, commit marks, and - * the opt-in time-sliced drain. The reviewed production bundle is 17,147 bytes - * minified / 5,885 bytes gzip incrementally; these caps leave roughly 4% - * headroom, so further growth still fails. */ -const STREAMING_INCREMENTAL_MINIFIED_CAP_BYTES = 17.5 * 1024; -const STREAMING_INCREMENTAL_GZIP_CAP_BYTES = 6 * 1024; +/** + * Deterministic production bundle-size caps. Raising any of them requires + * explicit review because every application pays these bytes. + * + * Two kinds are enforced, and both are needed: + * + * - **Absolute ordinary caps.** The always-shipped `@microsoft/webui-framework` + * entry is what a *non-streaming* app downloads. Only an absolute cap catches + * growth here, because the incremental metric below subtracts the ordinary + * bundle — so code added to the always-shipped entry silently cancels out of + * it. That is exactly how span-attribute parsing once reached every app while + * the incremental number looked unchanged. + * - **Incremental coordinator caps.** Streaming bytes an app pays *on top of* + * the ordinary entry: checkpoint/span validation, retained roots for state + * updates, span completion, terminal cleanup, commit marks, and the opt-in + * time-sliced drain. + * + * Measured with this exact fixture pipeline (esbuild, `minify: true`, + * `__WEBUI_DEV__=false`, `format: 'iife'`, `target: es2022`): + * + * ordinary 60,528 minified / 18,993 gzip + * streaming 77,180 minified / 24,921 gzip + * incremental 16,652 minified / 5,928 gzip + * + * Each cap is that measurement plus ~4.5% headroom, so real growth fails while + * a minifier or toolchain nudge does not. The test logs the live numbers and + * the remaining headroom on every run — update both together, never the cap + * alone. + */ +const ORDINARY_MINIFIED_CAP_BYTES = 63_500; +const ORDINARY_GZIP_CAP_BYTES = 19_850; +const STREAMING_INCREMENTAL_MINIFIED_CAP_BYTES = 17_400; +const STREAMING_INCREMENTAL_GZIP_CAP_BYTES = 6_190; /** Marginal elapsed-time cap per added boundary. The relative allowance scales * with slower hosts while the absolute floor absorbs sub-millisecond noise. */ const COORDINATOR_MARGINAL_ABS_CAP_MS = 0.25; @@ -327,16 +352,30 @@ test.describe('progressive streaming hydration matrix', () => { const fixtures: BuiltFixtures = await buildFixtures(); const ordinaryTokens = coordinatorTokensIn(fixtures.ordinary.code); - expect(ordinaryTokens, 'ordinary bundle must be coordinator-free').toEqual([]); + expect( + ordinaryTokens, + 'ordinary bundle must be coordinator-free (no boundary transport, no span attributes, no open-span registry)', + ).toEqual([]); // Sanity: the streaming bundle *does* carry the coordinator it advertises. expect(coordinatorTokensIn(fixtures.streaming.code).length).toBeGreaterThan(0); + + // Absolute caps on the always-shipped entry. These are what stop growth + // there from being cancelled out of the incremental numbers below. + expect( + fixtures.ordinary.minifiedBytes, + `ordinary (always-shipped) minified bytes stay within ${ORDINARY_MINIFIED_CAP_BYTES}`, + ).toBeLessThanOrEqual(ORDINARY_MINIFIED_CAP_BYTES); + expect( + fixtures.ordinary.gzipBytes, + `ordinary (always-shipped) gzip bytes stay within ${ORDINARY_GZIP_CAP_BYTES}`, + ).toBeLessThanOrEqual(ORDINARY_GZIP_CAP_BYTES); expect( fixtures.streamingIncrementalBytes, - 'streaming coordinator incremental minified bytes stay within the reviewed 17.5KiB cap', + `streaming coordinator incremental minified bytes stay within ${STREAMING_INCREMENTAL_MINIFIED_CAP_BYTES}`, ).toBeLessThanOrEqual(STREAMING_INCREMENTAL_MINIFIED_CAP_BYTES); expect( fixtures.streamingIncrementalGzipBytes, - 'streaming coordinator incremental gzip bytes stay within the reviewed 6KiB cap', + `streaming coordinator incremental gzip bytes stay within ${STREAMING_INCREMENTAL_GZIP_CAP_BYTES}`, ).toBeLessThanOrEqual(STREAMING_INCREMENTAL_GZIP_CAP_BYTES); const bundle: BundleSizes = { @@ -354,6 +393,20 @@ test.describe('progressive streaming hydration matrix', () => { console.log(`ordinary | ${String(bundle.ordinaryMinifiedBytes).padStart(9)} | ${String(bundle.ordinaryGzipBytes).padStart(8)}`); console.log(`streaming | ${String(bundle.streamingMinifiedBytes).padStart(9)} | ${String(bundle.streamingGzipBytes).padStart(8)}`); console.log(`incremental | ${String(bundle.streamingIncrementalBytes).padStart(9)} | ${String(bundle.streamingIncrementalGzipBytes).padStart(8)}`); + console.log('\nCap headroom (measured vs cap):'); + for ( + const [label, value, cap] of [ + ['ordinary minified', bundle.ordinaryMinifiedBytes, ORDINARY_MINIFIED_CAP_BYTES], + ['ordinary gzip', bundle.ordinaryGzipBytes, ORDINARY_GZIP_CAP_BYTES], + ['incremental minified', bundle.streamingIncrementalBytes, STREAMING_INCREMENTAL_MINIFIED_CAP_BYTES], + ['incremental gzip', bundle.streamingIncrementalGzipBytes, STREAMING_INCREMENTAL_GZIP_CAP_BYTES], + ] as const + ) { + console.log( + `${label} | ${String(value).padStart(6)} / ${String(cap).padStart(6)}` + + ` | ${(100 * (1 - value / cap)).toFixed(2)}% headroom`, + ); + } // ── 2. Verify equal-total-work invariants across scenarios ─────── const control = buildOrdinaryScenario(); diff --git a/examples/integration/streaming-browser-bench/tests/lib/fixtures.ts b/examples/integration/streaming-browser-bench/tests/lib/fixtures.ts index 075f859a9..4bf73bf88 100644 --- a/examples/integration/streaming-browser-bench/tests/lib/fixtures.ts +++ b/examples/integration/streaming-browser-bench/tests/lib/fixtures.ts @@ -28,8 +28,26 @@ const here = dirname(fileURLToPath(import.meta.url)); * the framework's `./foo.js` intra-package imports to their `.ts` sources. */ const FRAMEWORK_SRC = resolve(here, '..', '..', '..', '..', '..', 'packages', 'webui-framework', 'src'); -/** Coordinator wire tokens that must never leak into the ordinary bundle. */ -export const COORDINATOR_TOKENS = ['webui-hydrate', 'data-webui-boundary'] as const; +/** + * Coordinator wire tokens that must never leak into the ordinary bundle. + * + * The first two are the boundary transport. The rest are component-span + * hydration: the compiler attribute names and the open-span registry that + * resolves them are coordinator-owned, and `TemplateElement` only ever + * receives an already-resolved bypass ancestor element it compares by + * identity. A leak here means a non-streaming app is paying for spans, which + * the incremental metric alone cannot detect — subtracting a grown ordinary + * bundle from a grown streaming bundle hides exactly this. + */ +export const COORDINATOR_TOKENS = [ + 'webui-hydrate', + 'data-webui-boundary', + 'data-ws-span', + 'data-ws-enclosing', + 'registerEnclosingSpans', + 'prepareSpanCompletion', + 'component span', +] as const; export interface Fixture { readonly kind: 'ordinary' | 'streaming'; diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index 3d92c45b2..7b90e0b2b 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -205,6 +205,13 @@ boundary uses a generated parent span, so an early compiler-marked child can hydrate before the opaque parent tail in light or shadow DOM. Authored boundaries cannot nest. +Span resolution is entirely coordinator-owned: the generated `data-ws-span` and +`data-ws-enclosing` attributes, and the open-span registry that pairs them, live +only in the opt-in streaming entry. It resolves the one ancestor an entitled +early child may skip and passes that element to the activation hook, which +compares it by identity. The always-shipped entry therefore carries no span +attribute name and no span bookkeeping at all. + Each runtime occurrence receives an ephemeral state object directly during activation. The coordinator does not publish that state to `window.__webui.state`, and it removes generated checkpoint and span diff --git a/packages/webui-framework/src/index-decoupling.test.ts b/packages/webui-framework/src/index-decoupling.test.ts index b2536fa12..c125f2f53 100644 --- a/packages/webui-framework/src/index-decoupling.test.ts +++ b/packages/webui-framework/src/index-decoupling.test.ts @@ -41,6 +41,33 @@ import { fileURLToPath } from 'node:url'; */ const ALLOWED_STREAMING_MODULES = new Set(['./streaming-mode.js']); +/** + * Tokens that must never reach the always-shipped bundle. + * + * Component-span hydration is an opt-in streaming feature: the compiler + * attribute names and the open-span registry that resolves them belong to the + * coordinator graph. `TemplateElement` receives an already-resolved bypass + * ancestor element and compares it by identity, so a non-streaming app pays + * nothing for spans. One leaked string literal is enough to show that contract + * broke — and because `streaming-mode.js` is allow-listed above, a leak there + * would slip past the module-reachability walk. So the reachable graph is also + * checked as text. + */ +const FORBIDDEN_ORDINARY_TOKENS = [ + // Compiler-owned span attribute names. + 'data-ws-span', + 'data-ws-enclosing', + // Open-span registry surface. + 'registerEnclosingSpans', + 'prepareSpanCompletion', + 'spanHostFor', + 'openSpans', + 'SpanInstanceId', + // Span registry diagnostics. + 'span instance ', + 'component span', +] as const; + /** * The one lazy-hydration module the default entry is allowed to reach. * @@ -130,6 +157,42 @@ describe('default entry decoupling', () => { assert.ok(visited.size > 3, 'import-graph walk should visit multiple modules'); }); + test('the reachable default-entry graph contains no span attribute or registry token', () => { + const distDir = dirname(fileURLToPath(import.meta.url)); + const visited = walkIndexImportGraph(distDir); + + const leaks: string[] = []; + for (const spec of visited) { + let source: string; + try { + source = readFileSync(resolve(distDir, spec), 'utf8'); + } catch { + continue; + } + for (const token of FORBIDDEN_ORDINARY_TOKENS) { + if (source.includes(token)) leaks.push(`${spec}: ${token}`); + } + } + + assert.deepEqual( + leaks, + [], + 'the default index entry must ship no component-span attribute names or ' + + `open-span registry code, but found: ${leaks.join(', ')}`, + ); + // Sanity: the tokens are real — the opt-in streaming graph does carry them. + const dom = readFileSync(resolve(distDir, 'streaming-dom.js'), 'utf8'); + assert.ok( + dom.includes('data-ws-span') && dom.includes('data-ws-enclosing'), + 'expected the opt-in streaming graph to own the compiler span attributes', + ); + const spans = readFileSync(resolve(distDir, 'streaming-spans.js'), 'utf8'); + assert.ok( + spans.includes('registerEnclosingSpans'), + 'expected the opt-in streaming graph to own the open-span registry', + ); + }); + test('dist/index.js has no static or dynamic path to the lazy-hydration coordinator', () => { const distDir = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/webui-framework/src/streaming-activation.ts b/packages/webui-framework/src/streaming-activation.ts index acd39b238..c2266a38c 100644 --- a/packages/webui-framework/src/streaming-activation.ts +++ b/packages/webui-framework/src/streaming-activation.ts @@ -9,6 +9,7 @@ import { } from './streaming-deferred.js'; import type { PendingBoundaryUpdates, + SpanBypass, } from './streaming-deferred.js'; /** @@ -22,7 +23,7 @@ export function activateRootsBetween( endMarker: Comment, state: Record | undefined, updates?: PendingBoundaryUpdates, - bypassSpanInstanceId?: number, + bypass?: SpanBypass, ): void { const root = startMarker.parentNode; if (!root) { @@ -36,12 +37,8 @@ export function activateRootsBetween( root, endMarker, state, - updates || bypassSpanInstanceId !== undefined - ? { - updates, - countRetention: updates !== undefined, - bypassSpanInstanceId, - } + updates || bypass + ? { updates, bypass, countRetention: updates !== undefined } : undefined, ); if (!failure) return; diff --git a/packages/webui-framework/src/streaming-cleanup.ts b/packages/webui-framework/src/streaming-cleanup.ts index c992ae805..d35c5fbd3 100644 --- a/packages/webui-framework/src/streaming-cleanup.ts +++ b/packages/webui-framework/src/streaming-cleanup.ts @@ -2,29 +2,39 @@ // Licensed under the MIT license. import { - BOUNDARY_END_PREFIX, BOUNDARY_SCRIPT_ATTR, BOUNDARY_START_PREFIX, firstNodeWithin, + isRangeEndMarker, MAX_MARKER_SCAN_NODES, nextAfterSubtreeWithin, nextWithinRoot, safeRemoveAttribute, safeRemove, - SPAN_END_PREFIX, SPAN_START_PREFIX, - streamingErrorMessage, -} from './streaming-dom.js'; -import { - STREAMED_HOST_ATTR, STREAMING_ENCLOSING_SPAN_ATTR, STREAMING_SPAN_HOST_ATTR, -} from './streaming-mode.js'; + streamingErrorMessage, +} from './streaming-dom.js'; +import { STREAMED_HOST_ATTR } from './streaming-mode.js'; const STREAMING_BOUNDARY_ABANDON = Symbol.for( 'microsoft.webui.boundaryAbandon', ); +/** + * Every compiler-owned attribute one streamed root can still be carrying. + * + * Listed once so the abandon check and the post-activation strip cannot drift + * apart, and so a future marker costs one array entry instead of two more + * call sites. + */ +const STREAMING_ROOT_ATTRS = [ + STREAMED_HOST_ATTR, + STREAMING_SPAN_HOST_ATTR, + STREAMING_ENCLOSING_SPAN_ATTR, +] as const; + type BoundaryAbandonable = Element & { [STREAMING_BOUNDARY_ABANDON]?: () => void; }; @@ -55,15 +65,17 @@ export function abandonDeferredElement(el: Element): void { } function hasStreamingAttribute(el: Element): boolean { - return el.hasAttribute(STREAMED_HOST_ATTR) || - el.hasAttribute(STREAMING_SPAN_HOST_ATTR) || - el.hasAttribute(STREAMING_ENCLOSING_SPAN_ATTR); + for (let i = 0; i < STREAMING_ROOT_ATTRS.length; i++) { + if (el.hasAttribute(STREAMING_ROOT_ATTRS[i])) return true; + } + return false; } -function removeStreamingAttributes(el: Element): void { - safeRemoveAttribute(el, STREAMED_HOST_ATTR); - safeRemoveAttribute(el, STREAMING_SPAN_HOST_ATTR); - safeRemoveAttribute(el, STREAMING_ENCLOSING_SPAN_ATTR); +/** Strip every compiler-owned streaming marker from one finished root. */ +export function removeStreamingAttributes(el: Element): void { + for (let i = 0; i < STREAMING_ROOT_ATTRS.length; i++) { + safeRemoveAttribute(el, STREAMING_ROOT_ATTRS[i]); + } } /** Release a retained subtree after its undefined outer fails activation. */ @@ -98,7 +110,14 @@ function abandonDeferredNodes( } } -/** Bounded failure-only sweep for roots preceding a missing sentinel. */ +/** + * Bounded failure-only sweep for roots preceding a missing sentinel. + * + * One walk, not two: the shared `abandonStreamingNodes` scan handles the + * document element in a real browser and each shadow root reached by the + * `getElementsByTagName('*')` fallback that a documentElement-less document + * (only reachable outside a parsed HTML document) falls back to. + */ export function abandonDeferredDocumentRoots(): void { if ( typeof document === 'undefined' || @@ -115,57 +134,47 @@ export function abandonDeferredDocumentRoots(): void { } const elements = document.getElementsByTagName('*'); - let visited = 0; - for ( - let i = 0; - i < elements.length && visited < MAX_MARKER_SCAN_NODES; - i++ - ) { + const limit = elements.length < MAX_MARKER_SCAN_NODES + ? elements.length + : MAX_MARKER_SCAN_NODES; + for (let i = 0; i < limit; i++) { const el = elements[i]; - visited++; abandonDeferredElement(el); const shadowRoot = el.shadowRoot; - let node: Node | null = shadowRoot?.firstChild ?? null; - while (node && visited < MAX_MARKER_SCAN_NODES) { - visited++; - if (node.nodeType === 1 /* ELEMENT_NODE */) { - abandonDeferredElement(node as Element); - } - node = nextWithinRoot(node, shadowRoot!); - } + if (shadowRoot) abandonStreamingNodes(shadowRoot.firstChild, shadowRoot); } +} - function abandonStreamingNodes(first: Node | null, root: Node): void { - let node = first; - let visited = 0; - while (node && visited < MAX_MARKER_SCAN_NODES) { - visited++; - if (node.nodeType === 8 /* COMMENT_NODE */) { - const next = nextWithinRoot(node, root); - if (isStreamingMarker((node as Comment).data)) safeRemove(node); - node = next; - continue; - } - if (node.nodeType === 1 /* ELEMENT_NODE */) { - const el = node as Element; - const scaffold = el.tagName === 'WEBUI-HYDRATE' || - el.hasAttribute(BOUNDARY_SCRIPT_ATTR); - const next = scaffold - ? nextAfterSubtreeWithin(node, root) - : nextWithinRoot(node, root); - abandonDeferredElement(el); - if (scaffold) safeRemove(el); - node = next; - continue; - } - node = nextWithinRoot(node, root); +/** Strip streamed roots, generated scaffolding, and markers from one tree. */ +function abandonStreamingNodes(first: Node | null, root: Node): void { + let node = first; + let visited = 0; + while (node && visited < MAX_MARKER_SCAN_NODES) { + visited++; + if (node.nodeType === 8 /* COMMENT_NODE */) { + const next = nextWithinRoot(node, root); + if (isStreamingMarker((node as Comment).data)) safeRemove(node); + node = next; + continue; + } + if (node.nodeType === 1 /* ELEMENT_NODE */) { + const el = node as Element; + const scaffold = el.tagName === 'WEBUI-HYDRATE' || + el.hasAttribute(BOUNDARY_SCRIPT_ATTR); + const next = scaffold + ? nextAfterSubtreeWithin(node, root) + : nextWithinRoot(node, root); + abandonDeferredElement(el); + if (scaffold) safeRemove(el); + node = next; + continue; } + node = nextWithinRoot(node, root); } +} - function isStreamingMarker(data: string): boolean { - return data.startsWith(BOUNDARY_START_PREFIX) || - data.startsWith(BOUNDARY_END_PREFIX) || - data.startsWith(SPAN_START_PREFIX) || - data.startsWith(SPAN_END_PREFIX); - } +function isStreamingMarker(data: string): boolean { + return data.startsWith(BOUNDARY_START_PREFIX) || + data.startsWith(SPAN_START_PREFIX) || + isRangeEndMarker(data); } diff --git a/packages/webui-framework/src/streaming-coordinator.ts b/packages/webui-framework/src/streaming-coordinator.ts index 38ab9cec7..cc4d0e803 100644 --- a/packages/webui-framework/src/streaming-coordinator.ts +++ b/packages/webui-framework/src/streaming-coordinator.ts @@ -31,14 +31,14 @@ import { pendingUndefinedRootCountForTests, resetDeferredActivationForTests, } from './streaming-deferred.js'; -import type { PendingBoundaryUpdates } from './streaming-deferred.js'; +import type { PendingBoundaryUpdates, SpanBypass } from './streaming-deferred.js'; import { findBoundaryScript, findRangeEndMarkerByPrefix, findRangeStartMarkerByPrefix, + markerlessRecordViolation, removeBoundaryScaffolding, resolveBoundaryRange, - resolveMarkerlessRecord, resolveSpanRange, streamingErrorMessage, } from './streaming-dom.js'; @@ -61,9 +61,9 @@ import { completeSpan, hasOpenSpans, openSpanCountForTests, + prepareSpanCompletion, registerEnclosingSpans, - registerSpanCompletionTarget, - validateSpanCompletion, + spanHostFor, } from './streaming-spans.js'; const MAX_QUEUED_BOUNDARIES = 512; @@ -284,10 +284,26 @@ function processSentinel(sentinel: Element): void { return; } - if (kind === RECORD_KIND_STATE_UPDATE) { - const markerless = resolveMarkerlessRecord(scriptEl, 'state update'); - if (!markerless.ok) { - failBoundary(sentinel, markerless.reason); + if (kind === RECORD_KIND_STATE_UPDATE || kind === RECORD_KIND_TERMINAL) { + const terminal = kind === RECORD_KIND_TERMINAL; + const violation = markerlessRecordViolation( + scriptEl, + terminal ? 'terminal' : 'state update', + ); + if (violation) { + failBoundary(sentinel, violation); + return; + } + if (terminal) { + if (hasOpenSpans()) { + failBoundary( + sentinel, + 'terminal record arrived before every component span completed', + ); + return; + } + nextExpectedRecordSequence++; + commitTerminal(sequence, sentinel, scriptEl); return; } const boundary = updatableBoundaries.get(target); @@ -310,24 +326,6 @@ function processSentinel(sentinel: Element): void { return; } - if (kind === RECORD_KIND_TERMINAL) { - const markerless = resolveMarkerlessRecord(scriptEl, 'terminal'); - if (!markerless.ok) { - failBoundary(sentinel, markerless.reason); - return; - } - if (hasOpenSpans()) { - failBoundary( - sentinel, - 'terminal record arrived before every component span completed', - ); - return; - } - nextExpectedRecordSequence++; - commitTerminal(sequence, sentinel, scriptEl); - return; - } - if (kind === RECORD_KIND_SPAN_COMPLETION) { const resolved = resolveSpanRange(scriptEl, target); if (!resolved.ok) { @@ -335,11 +333,13 @@ function processSentinel(sentinel: Element): void { return; } nextExpectedRecordSequence++; - commitSpanCompletion( + commitRecord( + true, payload as SpanCompletionPayload, resolved.range, sequence, target, + false, sentinel, scriptEl, ); @@ -369,7 +369,8 @@ function processSentinel(sentinel: Element): void { nextExpectedRecordSequence++; nextExpectedBoundaryInstanceId++; - commitCheckpoint( + commitRecord( + false, payload as BoundaryBootstrap, resolved.range, sequence, @@ -380,8 +381,19 @@ function processSentinel(sentinel: Element): void { ); } -function commitCheckpoint( - bootstrap: BoundaryBootstrap, +/** + * Run one range-hydrating record inside the shared commit shell. + * + * Checkpoints and span completions differ only in what they hydrate; the + * lifecycle accounting, failure policy, scaffold removal, and commit + * notification are identical, so they are written once here. The body is + * selected by a numeric op rather than a callback so a commit still allocates + * nothing, and both the mark and the failure message are built only on the + * path that actually needs them. + */ +function commitRecord( + span: boolean, + payload: BoundaryBootstrap | SpanCompletionPayload, range: HydrationRange, sequence: number, target: number, @@ -392,43 +404,23 @@ function commitCheckpoint( markBoundaryPending(); let committed = false; try { - if ( - bootstrap.enclosingSpanInstanceId !== undefined && - range.start?.parentNode - ) { - const invalid = registerEnclosingSpans( - range.start.parentNode, - bootstrap.enclosingSpanInstanceId, - ); - if (invalid) throw new Error(invalid); - } - applyBoundaryBootstrap(bootstrap); - if (range.start && range.end) { - const boundary: UpdatableBoundary | undefined = updatable - ? { roots: [], active: true, retained: 0, pendingRoots: 0 } - : undefined; - activateRootsBetween( - range.start, - range.end, - bootstrap.state, - boundary, - bootstrap.enclosingSpanInstanceId, + if (span) { + hydrateSpanCompletion(payload as SpanCompletionPayload, range, target); + } else { + hydrateCheckpoint( + payload as BoundaryBootstrap, + range, + target, + updatable, ); - if (boundary) retainUpdatableBoundary(target, boundary); - } else if (updatable) { - retainUpdatableBoundary(target, { - roots: [], - active: true, - retained: 0, - pendingRoots: 0, - }); } committed = true; } catch (error) { + const detail = streamingErrorMessage(error); fail( - `error committing boundary ${sequence}: ${ - streamingErrorMessage(error) - }`, + span + ? `error completing span ${target}: ${detail}` + : `error committing boundary ${sequence}: ${detail}`, ); } finally { removeBoundaryScaffolding( @@ -438,56 +430,68 @@ function commitCheckpoint( range.end, ); if (committed) { - notifyCommit(`${BOUNDARY_MARK_PREFIX}${target}`, sequence, 'checkpoint'); + notifyCommit( + `${span ? SPAN_MARK_PREFIX : BOUNDARY_MARK_PREFIX}${target}`, + sequence, + span ? 'span' : 'checkpoint', + ); } markBoundaryCommitted(false); } } -function commitSpanCompletion( - payload: SpanCompletionPayload, +function hydrateCheckpoint( + bootstrap: BoundaryBootstrap, range: HydrationRange, - sequence: number, target: number, - sentinel: Element, - scriptEl: Element, + updatable: boolean, ): void { - markBoundaryPending(); - let committed = false; - try { - const registrationError = registerSpanCompletionTarget(target, range); - if (registrationError) throw new Error(registrationError); - const invalid = validateSpanCompletion(target, range); + let bypass: SpanBypass | undefined; + const enclosing = bootstrap.enclosingSpanInstanceId; + if (enclosing !== undefined && range.start?.parentNode) { + const invalid = registerEnclosingSpans(range.start.parentNode, enclosing); if (invalid) throw new Error(invalid); - applyBoundaryBootstrap(payload); - if (!range.start || !range.end) { - throw new Error(`span ${target} completion is markerless`); - } + // Resolved once per boundary, never per root: the activation walk compares + // this element by identity, so `TemplateElement` never sees a span + // attribute name and the always-shipped bundle never carries one. + const host = spanHostFor(enclosing); + if (host) bypass = { id: String(enclosing), host }; + } + applyBoundaryBootstrap(bootstrap); + if (range.start && range.end) { + const boundary: UpdatableBoundary | undefined = updatable + ? newUpdatableBoundary() + : undefined; activateRootsBetween( range.start, range.end, - payload.state, - ); - completeSpan(target); - committed = true; - } catch (error) { - fail( - `error completing span ${target}: ${streamingErrorMessage(error)}`, - ); - } finally { - removeBoundaryScaffolding( - sentinel, - scriptEl, - range.start, - range.end, + bootstrap.state, + boundary, + bypass, ); - if (committed) { - notifyCommit(`${SPAN_MARK_PREFIX}${target}`, sequence, 'span'); - } - markBoundaryCommitted(false); + if (boundary) retainUpdatableBoundary(target, boundary); + } else if (updatable) { + retainUpdatableBoundary(target, newUpdatableBoundary()); } } +function newUpdatableBoundary(): UpdatableBoundary { + return { roots: [], active: true, retained: 0, pendingRoots: 0 }; +} + +function hydrateSpanCompletion( + payload: SpanCompletionPayload, + range: HydrationRange, + target: number, +): void { + const invalid = prepareSpanCompletion(target, range); + if (invalid) throw new Error(invalid); + applyBoundaryBootstrap(payload); + // `prepareSpanCompletion` already rejected a markerless range. + activateRootsBetween(range.start!, range.end!, payload.state); + completeSpan(target); +} + function failRangeResolution( sentinel: Element, scriptEl: Element, diff --git a/packages/webui-framework/src/streaming-deferred.ts b/packages/webui-framework/src/streaming-deferred.ts index 0b00bcc17..106f9e03e 100644 --- a/packages/webui-framework/src/streaming-deferred.ts +++ b/packages/webui-framework/src/streaming-deferred.ts @@ -8,6 +8,7 @@ import { import { abandonDeferredDescendants, abandonDeferredElement, + removeStreamingAttributes, } from './streaming-cleanup.js'; import { firstNodeWithin, @@ -15,14 +16,12 @@ import { MAX_MARKER_SCAN_NODES, nextAfterSubtreeWithin, nextWithinRoot, - safeRemoveAttribute, + STREAMING_ENCLOSING_SPAN_ATTR, streamingErrorMessage, } from './streaming-dom.js'; import { PENDING_ROOT_CONNECTED, - STREAMING_ENCLOSING_SPAN_ATTR, STREAMED_HOST_ATTR, - STREAMING_SPAN_HOST_ATTR, STREAMING_BOUNDARY_ACTIVATE, } from './streaming-mode.js'; import { applyStateUpdate } from './streaming-state.js'; @@ -42,26 +41,50 @@ export const MAX_PENDING_BARRIER_ROOTS = 50_000; type BoundaryActivatable = Element & { [STREAMING_BOUNDARY_ACTIVATE]?: ( state?: Record, - bypassSpanInstanceId?: number, + bypassAncestor?: Element, ) => number; }; +/** + * The one unfinished component host an early boundary's marked roots may skip. + * + * Allocated once per boundary that declares an enclosing span, never per root. + * `id` is the canonical decimal SpanInstanceId the compiler wrote onto both the + * host (`data-ws-span`) and the entitled early roots (`data-ws-enclosing`); + * `host` is the element that attribute already resolved to. Matching here and + * handing the element itself to the activation hook is what keeps every span + * attribute name out of the always-shipped bundle. + */ +export interface SpanBypass { + readonly id: string; + readonly host: Element; +} + interface PendingTagWaiter { readonly generation: number; readonly roots: Set; } +/** + * Everything one deferred root must replay when it finally activates. + * + * Held as a single symbol-keyed record rather than three parallel properties: + * one hidden-class transition per retained root instead of three, one delete + * instead of three, and no absent-vs-`undefined` sentinels. + */ +interface PendingRootRecord { + readonly state: Record | undefined; + readonly updates: PendingBoundaryUpdates | undefined; + readonly bypass: SpanBypass | undefined; +} + const pendingTagWaiters = new Map(); const pendingBarrierRoots = new Set(); let pendingUndefinedRoots = 0; let activationGeneration = 0; let failureHandler: ((reason: string) => void) | null = null; -const PENDING_BOUNDARY_STATE = Symbol(); -const PENDING_BOUNDARY_UPDATES = Symbol(); -const PENDING_BYPASS_SPAN = Symbol(); -const NO_BOUNDARY_STATE: unique symbol = Symbol(); -const NO_BYPASS_SPAN: unique symbol = Symbol(); +const PENDING_RECORD = Symbol(); /** One boundary-owned shallow patch shared by every deferred root. */ export interface PendingBoundaryUpdates { @@ -91,7 +114,7 @@ export interface PendingBoundaryUpdates { export interface DeferredActivationOptions { updates?: PendingBoundaryUpdates; /** Span barrier this boundary's compiler-marked early roots may bypass. */ - bypassSpanInstanceId?: number; + bypass?: SpanBypass; /** * Set only by the checkpoint scan, which owns the boundary's retention * budget. A late activation re-walks a subtree the scan already counted, so @@ -118,6 +141,18 @@ function fail(reason: string): void { failureHandler(reason); } +function tagOf(el: Element): string { + return el.tagName.toLowerCase(); +} + +function missingTemplateReason(tag: string): string { + return `template metadata missing while activating <${tag}>`; +} + +function barrierLimitReason(): string { + return `pending ancestor-barrier root count exceeds ${MAX_PENDING_BARRIER_ROOTS}`; +} + /** * Deliver a replayed patch, halting when the target cannot accept one. * @@ -133,9 +168,22 @@ function requireStateUpdate( patch: Record, ): void { if (applyStateUpdate(el, patch)) return; - fail( - `<${el.tagName.toLowerCase()}> activated without a setState() method`, - ); + fail(`<${tagOf(el)}> activated without a setState() method`); +} + +/** Join one activated root to its boundary and replay any collapsed patch. */ +function joinBoundaryUpdates( + el: Element, + updates: PendingBoundaryUpdates, +): void { + // Joining only on a known-good outcome is what keeps a failed or ignored + // element out of the update set for the life of the page. + if (updates.active) updates.roots.push(el); + // Replayed rather than merged into hydration state: `$hydrate` wires + // bindings against the server's bytes without evaluating them, so seeding a + // post-render value first would bind the old branch while the element + // believed it held the new one. + if (updates.patch) requireStateUpdate(el, updates.patch); } /** @@ -149,9 +197,9 @@ function activateMarkedElement( el: Element, state: Record | undefined, updates?: PendingBoundaryUpdates, - bypassSpanInstanceId?: number, + bypass?: SpanBypass, ): number { - const tag = el.tagName.toLowerCase(); + const tag = tagOf(el); if (tag.indexOf('-') === -1) return ELEMENT_IGNORED; if (customElements.get(tag)) { @@ -160,31 +208,21 @@ function activateMarkedElement( } // A definition waiter still owns this root until its shared reaction runs. // Consuming its state here would leave the waiter count and lifecycle stuck. - if (hasPendingState(el)) return ELEMENT_DEFERRED; - const outcome = invokeActivationHook( - el, - state, - bypassSpanInstanceId, - ); + if (hasPendingRecord(el)) return ELEMENT_DEFERRED; + const outcome = invokeActivationHook(el, state, bypass); if (outcome !== ACTIVATION_ANCESTOR_BARRIER) return outcome; if (pendingBarrierRoots.size >= MAX_PENDING_BARRIER_ROOTS) { return ELEMENT_BARRIER_LIMIT_FAILURE; } - stashPendingState(el, state, updates, bypassSpanInstanceId); - pendingBarrierRoots.add(el); - (el as PendingRoot)[PENDING_ROOT_CONNECTED] = resumeBarrierRoot; + deferBehindBarrier(el, state, updates, bypass); return ELEMENT_DEFERRED; } - if ( - !hasPendingState(el) && - pendingUndefinedRoots >= MAX_PENDING_UNDEFINED_ROOTS - ) { - return ELEMENT_LIMIT_FAILURE; - } - - if (!hasPendingState(el)) { - stashPendingState(el, state, updates, bypassSpanInstanceId); + if (!hasPendingRecord(el)) { + if (pendingUndefinedRoots >= MAX_PENDING_UNDEFINED_ROOTS) { + return ELEMENT_LIMIT_FAILURE; + } + stashPendingRecord(el, state, updates, bypass); } let waiter = pendingTagWaiters.get(tag); if (!waiter) { @@ -204,38 +242,26 @@ function activateMarkedElement( return ELEMENT_DEFERRED; } +/** Retain one root whose hook reported an unfinished ancestor barrier. */ +function deferBehindBarrier( + el: Element, + state: Record | undefined, + updates: PendingBoundaryUpdates | undefined, + bypass: SpanBypass | undefined, +): void { + stashPendingRecord(el, state, updates, bypass); + pendingBarrierRoots.add(el); + (el as PendingRoot)[PENDING_ROOT_CONNECTED] = resumeBarrierRoot; +} + function activatePendingBarrierRoot(el: Element): number { if (!pendingBarrierRoots.delete(el)) return ELEMENT_IGNORED; delete (el as PendingRoot)[PENDING_ROOT_CONNECTED]; - let updates: PendingBoundaryUpdates | undefined; + const record = takePendingRecord(el); + const updates = releaseUpdates(record); try { - updates = takePendingUpdates(el); - const state = takePendingState(el); - const bypassSpanInstanceId = takePendingBypassSpan(el); - const outcome = invokeActivationHook( - el, - state, - bypassSpanInstanceId, - ); - if (outcome === ACTIVATION_ANCESTOR_BARRIER) { - stashPendingState( - el, - state, - updates, - bypassSpanInstanceId, - ); - pendingBarrierRoots.add(el); - (el as PendingRoot)[PENDING_ROOT_CONNECTED] = resumeBarrierRoot; - return ELEMENT_DEFERRED; - } - if ( - updates && - (outcome === ACTIVATION_ACTIVATED || - outcome === ACTIVATION_STATIC_HOST_OPT_OUT) - ) { - if (updates.active) updates.roots.push(el); - if (updates.patch) requireStateUpdate(el, updates.patch); - } + const outcome = resumeRetainedRoot(el, record, updates); + if (outcome === ACTIVATION_ANCESTOR_BARRIER) return ELEMENT_DEFERRED; return outcome === ACTIVATION_MISSING_TEMPLATE ? outcome : ELEMENT_ACTIVATED_FROM_PENDING; @@ -244,22 +270,44 @@ function activatePendingBarrierRoot(el: Element): number { } } +/** + * Re-run one retained root's activation and hand back the raw outcome. + * + * Shared by both retention reasons — an undefined tag and an unfinished + * ancestor barrier — because they differ only in the bookkeeping around the + * call. A root still behind a barrier is re-retained here so neither caller + * can forget to, and an activated root joins its boundary parent-first, + * before any descendant walk. + */ +function resumeRetainedRoot( + el: Element, + record: PendingRootRecord | undefined, + updates: PendingBoundaryUpdates | undefined, +): number { + const outcome = invokeActivationHook(el, record?.state, record?.bypass); + if (outcome === ACTIVATION_ANCESTOR_BARRIER) { + deferBehindBarrier(el, record?.state, updates, record?.bypass); + } else if ( + updates && + (outcome === ACTIVATION_ACTIVATED || + outcome === ACTIVATION_STATIC_HOST_OPT_OUT) + ) { + joinBoundaryUpdates(el, updates); + } + return outcome; +} + /** Resume coordinator-owned activation when a component barrier releases. */ function resumeBarrierRoot(this: Element): void { try { const outcome = activatePendingBarrierRoot(this); if (outcome === ACTIVATION_MISSING_TEMPLATE) { - abandonDeferredDescendants(this); - abandonDeferredElement(this); - fail( - `template metadata missing while activating <${ - this.tagName.toLowerCase() - }>`, - ); + abandonDeferredTree(this); + fail(missingTemplateReason(tagOf(this))); } } catch (error) { abandonDeferredDescendants(this); - reportActivationFailure(this.tagName.toLowerCase(), error); + reportActivationFailure(tagOf(this), error); } } @@ -297,7 +345,7 @@ function onTagDefined(tag: string, generation: number): void { /** Shared reconnect seam for roots that were undefined at checkpoint time. */ function resumePendingRoot(this: Element): void { - const tag = this.tagName.toLowerCase(); + const tag = tagOf(this); const waiter = pendingTagWaiters.get(tag); if ( !waiter || @@ -318,58 +366,31 @@ function activatePendingRoot( if (!waiter.roots.delete(el)) return; pendingUndefinedRoots--; delete (el as PendingRoot)[PENDING_ROOT_CONNECTED]; - let updates: PendingBoundaryUpdates | undefined; + const record = takePendingRecord(el); + const updates = releaseUpdates(record); + const state = record?.state; + const bypass = record?.bypass; try { - updates = takePendingUpdates(el); - const state = takePendingState(el); - const bypassSpanInstanceId = takePendingBypassSpan(el); - const outcome = invokeActivationHook( - el, - state, - bypassSpanInstanceId, - ); + const outcome = resumeRetainedRoot(el, record, updates); if (outcome === ACTIVATION_MISSING_TEMPLATE) { - abandonDeferredDescendants(el); - abandonDeferredElement(el); - fail(`template metadata missing while activating <${tag}>`); + abandonDeferredTree(el); + fail(missingTemplateReason(tag)); return; } if (outcome === ACTIVATION_ANCESTOR_BARRIER) { - if (pendingBarrierRoots.size >= MAX_PENDING_BARRIER_ROOTS) { - abandonDeferredDescendants(el); - abandonDeferredElement(el); - fail( - `pending ancestor-barrier root count exceeds ${MAX_PENDING_BARRIER_ROOTS}`, - ); - return; + // Re-retained by `resumeRetainedRoot`; only the budget is enforced here. + if (pendingBarrierRoots.size > MAX_PENDING_BARRIER_ROOTS) { + abandonDeferredTree(el); + fail(barrierLimitReason()); } - stashPendingState( - el, - state, - updates, - bypassSpanInstanceId, - ); - pendingBarrierRoots.add(el); - (el as PendingRoot)[PENDING_ROOT_CONNECTED] = resumeBarrierRoot; return; } - // Parent first, and before the descendant walk: this root's own patch may - // tear down the branch its retained descendants live in, and activating a - // root inside an already-discarded branch is worse than never reaching it. - if (updates) { - if (updates.active) updates.roots.push(el); - if (updates.patch) requireStateUpdate(el, updates.patch); - } const failure = activateDeferredTree( firstNodeWithin(el), el, null, state, - updates - ? { updates, bypassSpanInstanceId } - : bypassSpanInstanceId === undefined - ? undefined - : { bypassSpanInstanceId }, + updates || bypass ? { updates, bypass } : undefined, ); if (failure) fail(failure); } catch (error) { @@ -403,7 +424,7 @@ export function activateDeferredTree( // Hoisted out of the walk: these are read once per node otherwise, and this // loop runs over every node of every boundary. const updates = options?.updates; - const bypassSpanInstanceId = options?.bypassSpanInstanceId; + const bypass = options?.bypass; const countRetention = options?.countRetention === true && updates !== undefined; let node = first; @@ -445,45 +466,28 @@ export function activateDeferredTree( if (marked) { const el = node as Element; try { - const outcome = activateMarkedElement( - el, - state, - updates, - bypassSpanInstanceId, - ); + const outcome = activateMarkedElement(el, state, updates, bypass); if (outcome === ACTIVATION_MISSING_TEMPLATE) { - return `template metadata missing while activating <${ - el.tagName.toLowerCase() - }>`; + return missingTemplateReason(tagOf(el)); } if (outcome === ELEMENT_LIMIT_FAILURE) { return `pending undefined root count exceeds ${MAX_PENDING_UNDEFINED_ROOTS}`; } if (outcome === ELEMENT_BARRIER_LIMIT_FAILURE) { - return `pending ancestor-barrier root count exceeds ${MAX_PENDING_BARRIER_ROOTS}`; + return barrierLimitReason(); } if (outcome === ELEMENT_DEFERRED) { resumeAfterDeferred = nextAfterSubtreeWithin(node, root); skippingDeferredDescendants = true; } else if ( updates && - outcome !== ELEMENT_ACTIVATED_FROM_PENDING && (outcome === ACTIVATION_ACTIVATED || outcome === ACTIVATION_STATIC_HOST_OPT_OUT) ) { - // Joining here, on a known-good outcome, is what keeps a failed or - // ignored element out of the update set for the life of the page. - if (updates.active) updates.roots.push(el); - // Replayed rather than merged into hydration state: `$hydrate` wires - // bindings against the server's bytes without evaluating them, so - // seeding a post-render value first would bind the old branch while - // the element believed it held the new one. - if (updates.patch) { - requireStateUpdate(el, updates.patch); - } + joinBoundaryUpdates(el, updates); } } catch (error) { - reportActivationFailure(el.tagName.toLowerCase(), error); + reportActivationFailure(tagOf(el), error); } } } @@ -502,6 +506,12 @@ function reportActivationFailure(tag: string, error: unknown): void { ); } +/** Release one failed root together with the subtree it was gating. */ +function abandonDeferredTree(el: Element): void { + abandonDeferredDescendants(el); + abandonDeferredElement(el); +} + /** Balance and clear every pending undefined-tag waiter exactly once. */ export function abandonPendingWaiters(): void { if (pendingBarrierRoots.size !== 0) { @@ -519,65 +529,47 @@ export function abandonPendingWaiters(): void { } function clearPendingRoot(el: Element): void { - if (hasPendingState(el)) { - takePendingState(el); - takePendingBypassSpan(el); - } - takePendingUpdates(el); + releaseUpdates(takePendingRecord(el)); delete (el as PendingRoot)[PENDING_ROOT_CONNECTED]; - abandonDeferredDescendants(el); - abandonDeferredElement(el); + abandonDeferredTree(el); } -function stashPendingState( +function stashPendingRecord( el: Element, state: Record | undefined, - updates?: PendingBoundaryUpdates, - bypassSpanInstanceId?: number, + updates: PendingBoundaryUpdates | undefined, + bypass: SpanBypass | undefined, ): void { - const store = el as unknown as Record; - store[PENDING_BOUNDARY_STATE] = - state === undefined ? NO_BOUNDARY_STATE : state; - store[PENDING_BYPASS_SPAN] = bypassSpanInstanceId === undefined - ? NO_BYPASS_SPAN - : bypassSpanInstanceId; - if (updates) { - store[PENDING_BOUNDARY_UPDATES] = updates; - updates.pendingRoots++; - } + (el as unknown as Record)[PENDING_RECORD] = { + state, + updates, + bypass, + }; + if (updates) updates.pendingRoots++; } -function hasPendingState(el: Element): boolean { - return Object.prototype.hasOwnProperty.call( - el, - PENDING_BOUNDARY_STATE, - ); +function hasPendingRecord(el: Element): boolean { + return Object.prototype.hasOwnProperty.call(el, PENDING_RECORD); } -function takePendingState( - el: Element, -): Record | undefined { - const store = el as unknown as Record; - const stored = store[PENDING_BOUNDARY_STATE]; - delete store[PENDING_BOUNDARY_STATE]; - return stored === NO_BOUNDARY_STATE - ? undefined - : (stored as Record | undefined); -} - -function takePendingBypassSpan(el: Element): number | undefined { - const store = el as unknown as Record; - const stored = store[PENDING_BYPASS_SPAN]; - delete store[PENDING_BYPASS_SPAN]; - return stored === NO_BYPASS_SPAN ? undefined : stored as number | undefined; -} - -function takePendingUpdates(el: Element): PendingBoundaryUpdates | undefined { - const store = el as unknown as Record; - const updates = store[PENDING_BOUNDARY_UPDATES] as - | PendingBoundaryUpdates - | undefined; - delete store[PENDING_BOUNDARY_UPDATES]; +function takePendingRecord(el: Element): PendingRootRecord | undefined { + const store = el as unknown as Record; + const record = store[PENDING_RECORD]; + delete store[PENDING_RECORD]; + return record; +} + +/** + * Balance one taken record against its boundary's pending-root accounting. + * + * Returns the boundary only while it can still receive this root: a terminal + * that already dropped both the live set and the collapsed patch leaves + * nothing to join or replay. + */ +function releaseUpdates( + record: PendingRootRecord | undefined, +): PendingBoundaryUpdates | undefined { + const updates = record?.updates; if (!updates) return undefined; updates.pendingRoots--; return updates.active || updates.patch !== undefined ? updates : undefined; @@ -586,13 +578,20 @@ function takePendingUpdates(el: Element): PendingBoundaryUpdates | undefined { function invokeActivationHook( el: Element, state: Record | undefined, - bypassSpanInstanceId?: number, + bypass: SpanBypass | undefined, ): number { const hook = (el as BoundaryActivatable)[STREAMING_BOUNDARY_ACTIVATE]; if (typeof hook !== 'function') return ACTIVATION_MISSING_TEMPLATE; + // The compiler entitles a specific set of early roots to skip the enclosing + // span host, so the attribute is matched here and the resolved element is + // handed over. One read, and only for a boundary that declares a span. + const bypassAncestor = bypass !== undefined && + el.getAttribute(STREAMING_ENCLOSING_SPAN_ATTR) === bypass.id + ? bypass.host + : undefined; let outcome: number; try { - outcome = hook.call(el, state, bypassSpanInstanceId); + outcome = hook.call(el, state, bypassAncestor); } catch (error) { removeStreamingAttributes(el); throw error; @@ -608,12 +607,6 @@ function invokeActivationHook( return outcome; } -function removeStreamingAttributes(el: Element): void { - safeRemoveAttribute(el, STREAMED_HOST_ATTR); - safeRemoveAttribute(el, STREAMING_SPAN_HOST_ATTR); - safeRemoveAttribute(el, STREAMING_ENCLOSING_SPAN_ATTR); -} - /** Reset retained activation state and invalidate uncancellable waiters. */ export function resetDeferredActivationForTests(): void { abandonPendingWaiters(); @@ -633,5 +626,5 @@ export function pendingBarrierRootCountForTests(): number { } export function elementHasPendingStateForTests(el: Element): boolean { - return hasPendingState(el); + return hasPendingRecord(el); } diff --git a/packages/webui-framework/src/streaming-dom.ts b/packages/webui-framework/src/streaming-dom.ts index a86f36d04..b1d5a27c7 100644 --- a/packages/webui-framework/src/streaming-dom.ts +++ b/packages/webui-framework/src/streaming-dom.ts @@ -6,6 +6,27 @@ export const MAX_ELEMENTS_PER_BOUNDARY = 10_000; export const MAX_MARKER_SCAN_NODES = 50_000; export const MAX_BOUNDARY_SCRIPT_SCAN = 8; +/** + * Compiler-owned SpanInstanceId on an unfinished component host. + * + * The value is a canonical base-10 integer. It identifies the root-local + * `...` range that will eventually activate this host. + * + * It lives here, in the opt-in streaming graph, rather than in + * `streaming-mode.ts`: only the coordinator ever reads or writes it, so the + * always-shipped bundle must not carry the name. + */ +export const STREAMING_SPAN_HOST_ATTR = 'data-ws-span'; +/** + * Compiler-owned enclosing SpanInstanceId on an early boundary child root. + * + * Matching this value to `data-ws-span` lets that root bypass exactly one + * unfinished ancestor barrier. Unmarked or mismatched roots stay dormant. The + * coordinator resolves the match and hands `TemplateElement` the concrete + * ancestor element, so nothing outside this graph parses the value. + */ +export const STREAMING_ENCLOSING_SPAN_ATTR = 'data-ws-enclosing'; + /** Normalize an unknown exception for cold streaming diagnostics. */ export function streamingErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -18,6 +39,17 @@ export const BOUNDARY_END_PREFIX = '/wb:'; export const SPAN_START_PREFIX = 'ws:'; export const SPAN_END_PREFIX = '/ws:'; +/** + * Whether one comment closes a boundary or span range. + * + * Every end marker is its start marker prefixed with `/`, which is also what + * lets `findRangeStartMarkerByPrefix` pair the two with a single slice. + */ +export function isRangeEndMarker(data: string): boolean { + return data.startsWith(BOUNDARY_END_PREFIX) || + data.startsWith(SPAN_END_PREFIX); +} + /** The DOM span one committed boundary activates. */ export interface HydrationRange { readonly start: Comment | null; @@ -34,7 +66,8 @@ export type RangeResolution = readonly start: Comment | null; }; -const MARKERLESS_RANGE: HydrationRange = { start: null, end: null }; +const MARKER_KIND_BOUNDARY = 'boundary'; +const MARKER_KIND_SPAN = 'span'; /** Resolve the root-local marker range for one boundary occurrence. */ export function resolveBoundaryRange( @@ -46,7 +79,7 @@ export function resolveBoundaryRange( instanceId, BOUNDARY_START_PREFIX, BOUNDARY_END_PREFIX, - 'boundary', + MARKER_KIND_BOUNDARY, ); } @@ -60,7 +93,7 @@ export function resolveSpanRange( instanceId, SPAN_START_PREFIX, SPAN_END_PREFIX, - 'span', + MARKER_KIND_SPAN, ); } @@ -92,23 +125,13 @@ function resolveMarkerRange( } /** Require an update or terminal record to carry no range markers. */ -export function resolveMarkerlessRecord( +export function markerlessRecordViolation( scriptEl: Element, kind: string, -): RangeResolution { - const marker = previousComment(scriptEl); - if ( - marker && - (marker.data.startsWith(BOUNDARY_END_PREFIX) || - marker.data.startsWith(SPAN_END_PREFIX)) - ) { - return { - ok: false, - reason: `${kind} record must be markerless`, - truncated: false, - }; - } - return { ok: true, range: MARKERLESS_RANGE }; +): string | null { + return previousRangeEndMarker(scriptEl) + ? `${kind} record must be markerless` + : null; } export function findBoundaryScript(sentinel: Element): Element | null { @@ -155,30 +178,25 @@ function findStartMarkerBefore( export function findRangeEndMarkerByPrefix( scriptEl: Element, ): Comment | null { - const marker = previousComment(scriptEl); - return marker && - (marker.data.startsWith(BOUNDARY_END_PREFIX) || - marker.data.startsWith(SPAN_END_PREFIX)) - ? marker - : null; + return previousRangeEndMarker(scriptEl); } -/** Find the start marker paired with a structurally discovered end marker. */ +/** + * Find the start marker paired with a structurally discovered end marker. + * + * `/wb:7` pairs with `wb:7` and `/ws:7` with `ws:7`, so dropping the leading + * `/` is the pairing for both range kinds. + */ export function findRangeStartMarkerByPrefix( endMarker: Comment, ): Comment | null { - const endPrefix = endMarker.data.startsWith(BOUNDARY_END_PREFIX) - ? BOUNDARY_END_PREFIX - : SPAN_END_PREFIX; - const startPrefix = endPrefix === BOUNDARY_END_PREFIX - ? BOUNDARY_START_PREFIX - : SPAN_START_PREFIX; - return findCommentBefore( - endMarker, - `${startPrefix}${endMarker.data.slice( - endPrefix.length, - )}`, - ); + return findCommentBefore(endMarker, endMarker.data.slice(1)); +} + +/** The preceding comment when it closes a boundary or span range. */ +function previousRangeEndMarker(node: Node): Comment | null { + const marker = previousComment(node); + return marker && isRangeEndMarker(marker.data) ? marker : null; } function previousComment(node: Node): Comment | null { diff --git a/packages/webui-framework/src/streaming-mode.ts b/packages/webui-framework/src/streaming-mode.ts index d36a7bfa8..f4ce86db8 100644 --- a/packages/webui-framework/src/streaming-mode.ts +++ b/packages/webui-framework/src/streaming-mode.ts @@ -9,6 +9,13 @@ * (`streaming.ts`), which itself imports `static-host.ts` → * `template-element.ts`. A direct import from `template-element.ts` back to * `streaming.ts` would close that cycle. + * + * It carries only what the always-shipped bundle genuinely needs: mode + * detection, the two shared hook symbols, and the single `data-ws` dormancy + * marker. Everything span-shaped — the `data-ws-span` / `data-ws-enclosing` + * attribute names and the open-span registry that resolves them — lives in the + * opt-in streaming graph (`streaming-dom.ts`, `streaming-spans.ts`), so a + * non-streaming app never downloads a byte of it. */ let cached: boolean | undefined; @@ -23,20 +30,6 @@ export const PENDING_ROOT_CONNECTED = Symbol.for( ); /** Compiler-owned marker for an uncommitted streamed host. */ export const STREAMED_HOST_ATTR = 'data-ws'; -/** - * Compiler-owned SpanInstanceId on an unfinished component host. - * - * The value is a canonical base-10 integer. It identifies the root-local - * `...` range that will eventually activate this host. - */ -export const STREAMING_SPAN_HOST_ATTR = 'data-ws-span'; -/** - * Compiler-owned enclosing SpanInstanceId on an early boundary child root. - * - * Matching this value to `data-ws-span` lets that root bypass exactly one - * unfinished ancestor barrier. Unmarked or mismatched roots stay dormant. - */ -export const STREAMING_ENCLOSING_SPAN_ATTR = 'data-ws-enclosing'; /** * Whether this document was served in streaming-hydration mode. diff --git a/packages/webui-framework/src/streaming-pipeline.test.ts b/packages/webui-framework/src/streaming-pipeline.test.ts index 7edfc94f2..c1c7d86b5 100644 --- a/packages/webui-framework/src/streaming-pipeline.test.ts +++ b/packages/webui-framework/src/streaming-pipeline.test.ts @@ -48,7 +48,7 @@ interface FakeElement extends FakeNode { setState?: (state: Record) => void; [ACTIVATE]?: ( state?: Record, - bypassSpanInstanceId?: number, + bypassAncestor?: FakeElement, ) => number; [ABANDON]?: () => void; [RESUME_PENDING]?: () => void; @@ -108,7 +108,7 @@ interface ElementSpec { text?: string; hook?: ( state?: Record, - bypassSpanInstanceId?: number, + bypassAncestor?: FakeElement, ) => void | number; activationOutcome?: number; abandon?: () => void; @@ -154,8 +154,8 @@ function element(tagName: string, spec: ElementSpec = {}): FakeElement { } as unknown as FakeElement & { _children: FakeNode[] }; addSiblingGetters(node); if (spec.hook) { - node[ACTIVATE] = (state, bypassSpanInstanceId?: number) => { - const outcome = spec.hook!(state, bypassSpanInstanceId); + node[ACTIVATE] = (state, bypassAncestor?: FakeElement) => { + const outcome = spec.hook!(state, bypassAncestor); return typeof outcome === 'number' ? outcome : spec.activationOutcome ?? 1; @@ -1833,19 +1833,16 @@ describe('streaming coordinator pipeline', () => { await flush(); let parentActive = false; + let parent!: FakeElement; let child!: FakeElement; child = element('later-span-child', { - hook(_state, bypassSpanInstanceId) { - if ( - !parentActive && - child.getAttribute('data-ws-enclosing') !== - String(bypassSpanInstanceId) - ) return 4; + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return 4; order.push('child'); return 1; }, }); - const parent = element('later-span-parent', { + parent = element('later-span-parent', { hook() { parentActive = true; order.push('parent'); @@ -1880,20 +1877,17 @@ describe('streaming coordinator pipeline', () => { const childStates: Array | undefined> = []; const parentStates: Array | undefined> = []; let parentActive = false; + let parent!: FakeElement; let child!: FakeElement; child = element('early-child', { - hook(state, bypassSpanInstanceId) { - if ( - !parentActive && - child.getAttribute('data-ws-enclosing') !== - String(bypassSpanInstanceId) - ) return 4; + hook(state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return 4; order.push('child'); childStates.push(state); return 1; }, }); - const parent = element('spanning-parent', { + parent = element('spanning-parent', { hook(state) { parentActive = true; order.push('parent'); @@ -1937,19 +1931,16 @@ describe('streaming coordinator pipeline', () => { test('keeps an unmarked or mismatched early child behind the ancestor barrier', async () => { const order: string[] = []; let parentActive = false; + let parent!: FakeElement; let child!: FakeElement; child = element('mismatch-child', { - hook(_state, bypassSpanInstanceId) { - if ( - !parentActive && - child.getAttribute('data-ws-enclosing') !== - String(bypassSpanInstanceId) - ) return 4; + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return 4; order.push('child'); return 1; }, }); - const parent = element('mismatch-parent', { + parent = element('mismatch-parent', { hook() { parentActive = true; order.push('parent'); @@ -1994,18 +1985,68 @@ describe('streaming coordinator pipeline', () => { assert.equal(__isHaltedForTests(), false); }); + test('activates the retained subtree of a barrier-deferred root after release', async () => { + const order: string[] = []; + let parentActive = false; + let parent!: FakeElement; + const nested = element('barrier-nested-root', { + hook() { + order.push('nested'); + return 1; + }, + }); + nested.setAttribute('data-ws', ''); + const child = element('barrier-outer-root', { + hook() { + if (!parentActive) return 4; + order.push('child'); + return 1; + }, + children: [nested], + }); + parent = element('barrier-span-parent', { + hook() { + parentActive = true; + order.push('parent'); + }, + }); + // `enclosingMarker: 1` mismatches span 0, so the outer child is retained + // behind the barrier and the coordinator's walk skips its whole subtree. + const scenario = buildSpanScenario(parent, child, { enclosingMarker: 1 }); + predefine( + 'barrier-outer-root', + 'barrier-nested-root', + 'barrier-span-parent', + ); + + enqueue(scenario.boundarySentinel); + await flush(); + assert.deepEqual(order, []); + assert.equal(__pendingBarrierRootCountForTests(), 1); + assert.equal(hasWs(nested), true, 'the skipped descendant stays marked'); + + enqueue(scenario.spanSentinel); + await flush(); + + assert.deepEqual( + order, + ['parent', 'child', 'nested'], + 'the released root activates its own retained subtree, parent-first', + ); + assert.equal(hasWs(nested), false); + assert.equal(__pendingBarrierRootCountForTests(), 0); + assert.equal(__isHaltedForTests(), false); + }); + test('updates a live early child by BoundaryInstanceId before parent completion', async () => { const activations: Array | undefined> = []; const updates: Array> = []; let parentActive = false; + let parent!: FakeElement; let child!: FakeElement; child = element('updatable-early-child', { - hook(state, bypassSpanInstanceId) { - if ( - !parentActive && - child.getAttribute('data-ws-enclosing') !== - String(bypassSpanInstanceId) - ) return 4; + hook(state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return 4; activations.push(state); return 1; }, @@ -2013,7 +2054,7 @@ describe('streaming coordinator pipeline', () => { updates.push(state); }, }); - const parent = element('updatable-span-parent', { + parent = element('updatable-span-parent', { hook() { parentActive = true; }, @@ -2046,18 +2087,15 @@ describe('streaming coordinator pipeline', () => { test('replays an update once when an undefined early child defines before span completion', async () => { const childActivations: Array | undefined> = []; const childUpdates: Array> = []; - const bypasses: Array = []; + const bypasses: Array = []; let parentActivations = 0; let parentActive = false; + let parent!: FakeElement; let child!: FakeElement; child = element('late-early-child', { - hook(state, bypassSpanInstanceId) { - bypasses.push(bypassSpanInstanceId); - if ( - !parentActive && - child.getAttribute('data-ws-enclosing') !== - String(bypassSpanInstanceId) - ) return 4; + hook(state, bypassAncestor) { + bypasses.push(bypassAncestor); + if (!parentActive && bypassAncestor !== parent) return 4; childActivations.push(state); return 1; }, @@ -2065,7 +2103,7 @@ describe('streaming coordinator pipeline', () => { childUpdates.push(state); }, }); - const parent = element('late-early-parent', { + parent = element('late-early-parent', { hook() { parentActive = true; parentActivations++; @@ -2088,7 +2126,7 @@ describe('streaming coordinator pipeline', () => { defineTag('late-early-child'); await flush(); - assert.deepEqual(bypasses, [0], 'the pending root retains its enclosing-span bypass'); + assert.deepEqual(bypasses, [parent], 'the pending root retains its enclosing-span bypass'); assert.deepEqual(childActivations, [{ scope: 'child' }]); assert.deepEqual(plainPatches(childUpdates), [{ status: 'ready' }]); assert.equal(parentActivations, 0, 'the child activates while its parent span is unfinished'); @@ -2118,19 +2156,16 @@ describe('streaming coordinator pipeline', () => { test('resolves a nested light-DOM boundary inside its actual component render root', async () => { const order: string[] = []; let parentActive = false; + let parent!: FakeElement; let child!: FakeElement; child = element('light-span-child', { - hook(_state, bypassSpanInstanceId) { - if ( - !parentActive && - child.getAttribute('data-ws-enclosing') !== - String(bypassSpanInstanceId) - ) return 4; + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return 4; order.push('child'); return 1; }, }); - const parent = element('light-span-parent', { + parent = element('light-span-parent', { hook() { parentActive = true; order.push('parent'); @@ -2155,18 +2190,17 @@ describe('streaming coordinator pipeline', () => { const order: string[] = []; let outerActive = false; let innerActive = false; + let inner!: FakeElement; let child!: FakeElement; child = element('nested-span-child', { - hook(_state, bypassSpanInstanceId) { - const bypassesInner = - child.getAttribute('data-ws-enclosing') === - String(bypassSpanInstanceId); + hook(_state, bypassAncestor) { + const bypassesInner = bypassAncestor === inner; if ((!innerActive && !bypassesInner) || !outerActive) return 4; order.push('child'); return 1; }, }); - const inner = element('nested-inner-parent', { + inner = element('nested-inner-parent', { hook() { if (!outerActive) return 4; innerActive = true; @@ -2281,19 +2315,16 @@ describe('streaming coordinator pipeline', () => { test('resolves an early boundary inside an open declarative shadow root', async () => { const order: string[] = []; let parentActive = false; + let parent!: FakeElement; let child!: FakeElement; child = element('shadow-span-child', { - hook(_state, bypassSpanInstanceId) { - if ( - !parentActive && - child.getAttribute('data-ws-enclosing') !== - String(bypassSpanInstanceId) - ) return 4; + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return 4; order.push('child'); return 1; }, }); - const parent = element('shadow-span-parent', { + parent = element('shadow-span-parent', { shadowChildren: [], hook() { parentActive = true; @@ -2317,18 +2348,15 @@ describe('streaming coordinator pipeline', () => { test('span truncation releases open-span state, markers, and compiler attributes', async () => { __installTruncationGuardForTests(); let parentActive = false; + let parent!: FakeElement; let child!: FakeElement; child = element('truncated-span-child', { - hook(_state, bypassSpanInstanceId) { - if ( - !parentActive && - child.getAttribute('data-ws-enclosing') !== - String(bypassSpanInstanceId) - ) return 4; + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return 4; return 1; }, }); - const parent = element('truncated-span-parent', { + parent = element('truncated-span-parent', { hook() { parentActive = true; }, diff --git a/packages/webui-framework/src/streaming-spans.ts b/packages/webui-framework/src/streaming-spans.ts index ba63e0d4e..30a8c4137 100644 --- a/packages/webui-framework/src/streaming-spans.ts +++ b/packages/webui-framework/src/streaming-spans.ts @@ -6,15 +6,18 @@ import { safeRemove, safeRemoveAttribute, SPAN_START_PREFIX, + STREAMING_SPAN_HOST_ATTR, } from './streaming-dom.js'; import type { HydrationRange } from './streaming-dom.js'; -import { STREAMING_SPAN_HOST_ATTR } from './streaming-mode.js'; /** Maximum unfinished component hosts retained by one response. */ export const MAX_OPEN_SPANS = 128; /** Maximum runtime component ancestry crossed by one early boundary. */ export const MAX_SPAN_NESTING = 32; +const INVALID_SPAN_ID = -1; +const NOT_A_SPAN_HOST = -2; + interface OpenSpan { readonly host: Element; readonly start: Comment; @@ -28,6 +31,43 @@ const hostScratch: Element[] = []; const idScratch: number[] = []; let nextExpectedSpanInstanceId = 0; +/** + * Read one element's declared SpanInstanceId. + * + * `NOT_A_SPAN_HOST` when the element carries no marker at all and + * `INVALID_SPAN_ID` when it carries one that is not a canonical base-10 + * integer, so both discovery walks share one attribute read and one parse. + */ +function spanIdOf(element: Element): number { + if (typeof element.hasAttribute !== 'function') return NOT_A_SPAN_HOST; + if (!element.hasAttribute(STREAMING_SPAN_HOST_ATTR)) return NOT_A_SPAN_HOST; + const raw = element.getAttribute(STREAMING_SPAN_HOST_ATTR); + if (raw === null || raw.length === 0) return INVALID_SPAN_ID; + let value = 0; + for (let i = 0; i < raw.length; i++) { + const code = raw.charCodeAt(i) - 48; + if (code < 0 || code > 9) return INVALID_SPAN_ID; + value = value * 10 + code; + if (!Number.isSafeInteger(value)) return INVALID_SPAN_ID; + } + return String(value) === raw ? value : INVALID_SPAN_ID; +} + +function invalidSpanAttrReason(): string { + return `invalid ${STREAMING_SPAN_HOST_ATTR} value`; +} + +/** + * The element one already-registered span will eventually activate. + * + * The coordinator hands this to the activation walk so an entitled early root + * can skip that exact ancestor by identity — no attribute name, and no span + * bookkeeping, ever reaches the always-shipped bundle. + */ +export function spanHostFor(id: number): Element | undefined { + return openSpans.get(id)?.host; +} + /** * Register the unfinished component ancestry enclosing an early boundary. * @@ -47,32 +87,28 @@ export function registerEnclosingSpans( while (current && hops < MAX_MARKER_SCAN_NODES) { hops++; - const element = elementForNode(current); - if ( - element && - typeof element.hasAttribute === 'function' && - element.hasAttribute(STREAMING_SPAN_HOST_ATTR) - ) { + const element = current.nodeType === 1 /* ELEMENT_NODE */ + ? current as Element + : null; + const id = element ? spanIdOf(element) : NOT_A_SPAN_HOST; + if (id !== NOT_A_SPAN_HOST) { if (hostScratch.length >= MAX_SPAN_NESTING) { clearScratch(); return `runtime component span nesting exceeds ${MAX_SPAN_NESTING}`; } - const id = parseInstanceId( - element.getAttribute(STREAMING_SPAN_HOST_ATTR), - ); - if (id === null) { + if (id === INVALID_SPAN_ID) { clearScratch(); - return `invalid ${STREAMING_SPAN_HOST_ATTR} value`; + return invalidSpanAttrReason(); } if (firstSpan && id !== enclosingSpanInstanceId) { clearScratch(); return `boundary declares enclosing span ${enclosingSpanInstanceId}, but its nearest spanning ancestor is ${id}`; } firstSpan = false; - hostScratch.push(element); + hostScratch.push(element as Element); idScratch.push(id); } - current = parentAcrossRenderRoot(current, element); + current = ascendRenderRoots(current); } if (firstSpan) { @@ -140,81 +176,59 @@ function registerSpan( } /** - * Discover a previously unseen completion target from its concrete marker range. + * Resolve and validate one span completion before it mutates or hydrates. * - * Zero-occurrence component spans have no checkpoint to register their - * ancestry, so their completion must do the same bounded, root-local discovery. + * One bounded, root-local sibling scan serves both jobs. A span opened by an + * earlier checkpoint is already registered and only needs its recorded host and + * marker confirmed; a zero-occurrence span has never been seen at all, so the + * same scan discovers the host its ancestry is registered from. */ -export function registerSpanCompletionTarget( +export function prepareSpanCompletion( id: number, range: HydrationRange, ): string | null { - if (openSpans.has(id)) return null; - if (!range.start || !range.end) { - return `span ${id} completion is markerless`; - } + const start = range.start; + const end = range.end; + if (!start || !end) return `span ${id} completion is markerless`; - let node = range.start.nextSibling; + let host: Element | undefined; + let node: Node | null = start.nextSibling; let hops = 0; - while (node && node !== range.end) { + while (node && node !== end) { if (hops >= MAX_MARKER_SCAN_NODES) { return `span ${id} host lookup exceeds ${MAX_MARKER_SCAN_NODES} nodes`; } hops++; if (node.nodeType === 1 /* ELEMENT_NODE */) { - const element = node as Element; - if ( - typeof element.hasAttribute === 'function' && - element.hasAttribute(STREAMING_SPAN_HOST_ATTR) - ) { - const hostId = parseInstanceId( - element.getAttribute(STREAMING_SPAN_HOST_ATTR), - ); - if (hostId === null) { - return `invalid ${STREAMING_SPAN_HOST_ATTR} value`; - } + const hostId = spanIdOf(node as Element); + if (hostId === INVALID_SPAN_ID) return invalidSpanAttrReason(); + if (hostId !== NOT_A_SPAN_HOST) { if (hostId !== id) { return `span completion targets span ${id}, but its host declares span ${hostId}`; } - return registerEnclosingSpans(element, id); + host = node as Element; + break; } } node = node.nextSibling; } - return `span completion targets span ${id}, but no spanning host was found inside its markers`; -} + if (!host) { + return `span completion targets span ${id}, but no spanning host was found inside its markers`; + } -/** Validate one span completion before mutating or hydrating its range. */ -export function validateSpanCompletion( - id: number, - range: HydrationRange, -): string | null { + if (!openSpans.has(id)) { + const error = registerEnclosingSpans(host, id); + if (error) return error; + } const span = openSpans.get(id); if (!span) return `span completion targets span ${id}, which is not open`; if (span.openChildren !== 0) { return `span ${id} completed before its nested component spans`; } - if ( - !range.start || - !range.end || - range.start !== span.start || - span.host.parentNode !== range.start.parentNode - ) { - return `span completion markers do not match the open span ${id}`; - } - - let node = range.start.nextSibling; - let hops = 0; - while (node && node !== range.end && node !== span.host) { - if (hops >= MAX_MARKER_SCAN_NODES) { - return `span ${id} host lookup exceeds ${MAX_MARKER_SCAN_NODES} nodes`; - } - hops++; - node = node.nextSibling; - } - return node === span.host + return span.host === host && span.start === start && + host.parentNode === start.parentNode ? null - : `span ${id} host is outside its completion markers`; + : `span completion markers do not match the open span ${id}`; } /** Release one successfully completed span and its ancestry accounting. */ @@ -239,44 +253,22 @@ export function abandonOpenSpans(): void { nextExpectedSpanInstanceId = 0; } -function elementForNode(node: Node): Element | null { - if (node.nodeType === 1 /* ELEMENT_NODE */) return node as Element; +/** + * Step one level out of the current render root. + * + * A shadow root resolves to its host, a slotted element to its assigned slot, + * and anything else to its parent node — so one function crosses every render + * root boundary an ancestry walk can hit. + */ +function ascendRenderRoots(node: Node): Node | null { if (node.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) { return (node as ShadowRoot).host ?? null; } - return null; -} - -function parentAcrossRenderRoot( - node: Node, - element: Element | null, -): Node | null { - let current = node; - let currentElement = element; - if (node.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) { - const host = (node as ShadowRoot).host; - if (!host) return null; - current = host; - currentElement = host; - } - if (currentElement?.assignedSlot) return currentElement.assignedSlot; - const parent = current.parentNode; - if (parent?.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) { - return (parent as ShadowRoot).host ?? null; - } - return parent; -} - -function parseInstanceId(raw: string | null): number | null { - if (raw === null || raw.length === 0) return null; - let value = 0; - for (let i = 0; i < raw.length; i++) { - const code = raw.charCodeAt(i) - 48; - if (code < 0 || code > 9) return null; - value = value * 10 + code; - if (!Number.isSafeInteger(value)) return null; + if (node.nodeType === 1 /* ELEMENT_NODE */) { + const slot = (node as Element).assignedSlot; + if (slot) return slot; } - return String(value) === raw ? value : null; + return node.parentNode; } function clearScratch(): void { diff --git a/packages/webui-framework/src/template-element.test.ts b/packages/webui-framework/src/template-element.test.ts index a3737f20b..d0b8ef448 100644 --- a/packages/webui-framework/src/template-element.test.ts +++ b/packages/webui-framework/src/template-element.test.ts @@ -390,7 +390,7 @@ describe('TemplateElement — streamed-host activation ownership', () => { assert.deepEqual(received, { detached: true }); }); - test('a matching compiler span marker bypasses exactly the unfinished spanning ancestor', () => { + test('a coordinator-resolved bypass ancestor is skipped exactly once', () => { const parentTag = 'test-spanning-parent'; const childTag = 'test-early-span-child'; registerTemplate(parentTag); @@ -401,12 +401,10 @@ describe('TemplateElement — streamed-host activation ownership', () => { tagName: string; parentElement: Element | null; $deferredSSR: boolean; - setAttribute(name: string, value: string): void; }; parentRaw.tagName = parentTag; parentRaw.parentElement = null; parentRaw.$deferredSSR = true; - parentRaw.setAttribute('data-ws-span', '4'); const child = new TemplateElement(); const childRaw = child as unknown as { @@ -417,24 +415,26 @@ describe('TemplateElement — streamed-host activation ownership', () => { setAttribute(name: string, value: string): void; [STREAMING_BOUNDARY_ACTIVATE]( state?: Record, - bypassSpanInstanceId?: number, + bypassAncestor?: Element, ): number; }; childRaw.tagName = childTag; childRaw.parentElement = parent as unknown as Element; childRaw.setAttribute('data-ws', ''); - childRaw.setAttribute('data-ws-enclosing', '4'); child.connectedCallback(); childRaw.$hydrated = true; assert.equal( - childRaw[STREAMING_BOUNDARY_ACTIVATE]({ child: true }, 4), + childRaw[STREAMING_BOUNDARY_ACTIVATE]( + { child: true }, + parent as unknown as Element, + ), 1, ); assert.equal(childRaw.$deferredSSR, false); }); - test('a mismatched compiler span marker preserves the parent-first barrier', () => { + test('an unrelated bypass ancestor preserves the parent-first barrier', () => { const parentTag = 'test-mismatch-span-parent'; const childTag = 'test-mismatch-span-child'; registerTemplate(parentTag); @@ -445,12 +445,13 @@ describe('TemplateElement — streamed-host activation ownership', () => { tagName: string; parentElement: Element | null; $deferredSSR: boolean; - setAttribute(name: string, value: string): void; }; parentRaw.tagName = parentTag; parentRaw.parentElement = null; parentRaw.$deferredSSR = true; - parentRaw.setAttribute('data-ws-span', '4'); + + const unrelated = new TemplateElement(); + (unrelated as unknown as { tagName: string }).tagName = parentTag; const child = new TemplateElement(); const childRaw = child as unknown as { @@ -460,17 +461,74 @@ describe('TemplateElement — streamed-host activation ownership', () => { setAttribute(name: string, value: string): void; [STREAMING_BOUNDARY_ACTIVATE]( state?: Record, - bypassSpanInstanceId?: number, + bypassAncestor?: Element, ): number; }; childRaw.tagName = childTag; childRaw.parentElement = parent as unknown as Element; childRaw.setAttribute('data-ws', ''); - childRaw.setAttribute('data-ws-enclosing', '5'); child.connectedCallback(); assert.equal( - childRaw[STREAMING_BOUNDARY_ACTIVATE]({ child: true }, 4), + childRaw[STREAMING_BOUNDARY_ACTIVATE]( + { child: true }, + unrelated as unknown as Element, + ), + 4, + ); + assert.equal(childRaw.$deferredSSR, true); + }); + + test('only one barrier is bypassed when barriers nest', () => { + const outerTag = 'test-nested-bypass-outer'; + const innerTag = 'test-nested-bypass-inner'; + const childTag = 'test-nested-bypass-child'; + registerTemplate(outerTag); + registerTemplate(innerTag); + registerTemplate(childTag); + + const outer = new TemplateElement(); + const outerRaw = outer as unknown as { + tagName: string; + parentElement: Element | null; + $deferredSSR: boolean; + }; + outerRaw.tagName = outerTag; + outerRaw.parentElement = null; + outerRaw.$deferredSSR = true; + + const inner = new TemplateElement(); + const innerRaw = inner as unknown as { + tagName: string; + parentElement: Element | null; + $deferredSSR: boolean; + }; + innerRaw.tagName = innerTag; + innerRaw.parentElement = outer as unknown as Element; + innerRaw.$deferredSSR = true; + + const child = new TemplateElement(); + const childRaw = child as unknown as { + tagName: string; + parentElement: Element; + $deferredSSR: boolean; + setAttribute(name: string, value: string): void; + [STREAMING_BOUNDARY_ACTIVATE]( + state?: Record, + bypassAncestor?: Element, + ): number; + }; + childRaw.tagName = childTag; + childRaw.parentElement = inner as unknown as Element; + childRaw.setAttribute('data-ws', ''); + child.connectedCallback(); + + // `inner` is stepped over, but `outer` is still an unfinished barrier. + assert.equal( + childRaw[STREAMING_BOUNDARY_ACTIVATE]( + { child: true }, + inner as unknown as Element, + ), 4, ); assert.equal(childRaw.$deferredSSR, true); diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index 77d7126c2..d2c8a7ad4 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -60,9 +60,7 @@ import { hydrationStart, hydrationEnd } from './lifecycle.js'; import { isStreamingHydrationMode, PENDING_ROOT_CONNECTED, - STREAMING_ENCLOSING_SPAN_ATTR, STREAMED_HOST_ATTR, - STREAMING_SPAN_HOST_ATTR, STREAMING_BOUNDARY_ACTIVATE, } from './streaming-mode.js'; import { @@ -426,10 +424,17 @@ export class TemplateElement extends HTMLElement { * result objects. The optional `state` is this element's boundary-local SSR * state, handed straight through to hydration instead of via the global * `window.__webui.state` handoff. + * + * `bypassAncestor`, when supplied, is one already-resolved ancestor element + * this root may skip exactly once while looking for its hydration barrier. + * The coordinator owns that resolution — which compiler attributes name the + * ancestor, and whether this root is entitled to skip it — so the + * always-shipped bundle carries a plain identity comparison and no streaming + * attribute names at all. */ [STREAMING_BOUNDARY_ACTIVATE]( state?: Record, - bypassSpanInstanceId?: number, + bypassAncestor?: Element, ): number { // `customElements.upgrade()` installs this class on detached roots without // invoking connectedCallback(). Preserve the same marker-driven dormant @@ -447,7 +452,7 @@ export class TemplateElement extends HTMLElement { } this.$meta = meta; if (!this.$shouldActivateOnBoundaryCommit()) return ACTIVATION_STATIC_HOST_OPT_OUT; - const ancestor = this.$nearestHydrationBarrier(bypassSpanInstanceId); + const ancestor = this.$nearestHydrationBarrier(bypassAncestor); if (ancestor) { this.$deferredByAncestor = true; this.$ancestorBoundaryState = state; @@ -455,12 +460,9 @@ export class TemplateElement extends HTMLElement { this.$registerWithHydrationBarrier(ancestor); return ACTIVATION_ANCESTOR_BARRIER; } - if (this.$deferredByAncestor) { - this.$detachDeferredAncestor(); - this.$deferredByAncestor = undefined; - this.$ancestorBoundaryState = undefined; - this.$hasAncestorBoundaryState = undefined; - } + // A root re-activated after its barrier lifted must not keep the stale + // registration; the boundary state it carried is superseded by `state`. + if (this.$deferredByAncestor) this.$clearAncestorDeferral(); this.$activatingDeferredSSR = true; try { this.$activateDeferredSSR(state); @@ -472,18 +474,47 @@ export class TemplateElement extends HTMLElement { /** Clear element-owned streaming deferral after a fatal stream failure. */ [STREAMING_BOUNDARY_ABANDON](): void { - this.$detachDeferredAncestor(); + this.$clearAncestorDeferral(); this.$abandonDeferredDescendants(); this.$deferredSSR = false; - this.$deferredByAncestor = undefined; - this.$ancestorBoundaryState = undefined; - this.$hasAncestorBoundaryState = undefined; this.$activatingDeferredSSR = false; this.$ready = false; this.$preReadyWrites = null; if (this.$deferredWrites) this.$deferredWrites = undefined; } + /** + * Drop any registration behind an ancestor hydration barrier. + * + * Shared by abandon, destroy, and re-activation so the four pieces of + * barrier bookkeeping can never be cleared partially. + */ + private $clearAncestorDeferral(): void { + this.$detachDeferredAncestor(); + this.$deferredByAncestor = undefined; + this.$ancestorBoundaryState = undefined; + this.$hasAncestorBoundaryState = undefined; + } + + /** + * Hand control back to whoever retained this root, if anyone did. + * + * The streaming coordinator installs the hook on roots it is holding — for a + * pending definition or a pending ancestor barrier — and owns every + * continuation from there: eager activation, re-registration behind a + * barrier, lazy observation, or a static-host opt-out. Re-entering ordinary + * deferral instead can replay older page bootstrap state over a queued + * boundary update. + */ + private $resumeRetainedRoot(): boolean { + const resume = ( + this as unknown as { [PENDING_ROOT_CONNECTED]?: () => void } + )[PENDING_ROOT_CONNECTED]; + if (typeof resume !== 'function') return false; + resume.call(this); + return true; + } + /** * Register this constructor for a tag and install template-derived observers. */ @@ -557,15 +588,7 @@ export class TemplateElement extends HTMLElement { ) { this.$deferredSSR = true; this.$ready = true; - const resume = (this as unknown as { [PENDING_ROOT_CONNECTED]?: () => void })[PENDING_ROOT_CONNECTED]; - if (typeof resume === 'function') { - // The coordinator owns every continuation after a pending definition: - // eager activation, span-barrier registration, lazy observation, or - // static-host opt-out. Re-entering ordinary deferral here can replay - // older page bootstrap state over a queued boundary update. - resume.call(this); - return; - } + if (this.$resumeRetainedRoot()) return; this.$didDeferSSRHydration(); return; } @@ -820,12 +843,9 @@ export class TemplateElement extends HTMLElement { this.$resetClientShadow = true; } if (!this.$root) { - this.$detachDeferredAncestor(); + this.$clearAncestorDeferral(); this.$abandonDeferredDescendants(); this.$deferredSSR = false; - this.$deferredByAncestor = undefined; - this.$ancestorBoundaryState = undefined; - this.$hasAncestorBoundaryState = undefined; this.$ready = false; this.$hasUnknownScopes = false; if (this.$deferredWrites) this.$deferredWrites = undefined; @@ -953,16 +973,19 @@ export class TemplateElement extends HTMLElement { return this.$meta ?? this.$templateMeta(); } + /** + * Walk up to the nearest ancestor that must hydrate before this instance. + * + * `bypassAncestor` is an opt-in, coordinator-resolved escape hatch: exactly + * one occurrence of that element is stepped over, which is how an early + * streamed child hydrates ahead of the still-unfinished component host that + * encloses it. Every other barrier — including a second, outer one — is + * still honoured, so parent-first ordering holds. + */ private $nearestHydrationBarrier( - bypassSpanInstanceId?: number, + bypassAncestor?: Element, ): Element | undefined { - const bypassSpan = bypassSpanInstanceId === undefined - ? undefined - : String(bypassSpanInstanceId); - const mayBypass = bypassSpan !== undefined && - this.getAttribute(STREAMING_ENCLOSING_SPAN_ATTR) === - bypassSpan; - let bypassed = false; + let bypass = bypassAncestor; let current: Element = this; while (true) { let parent: Element | null = @@ -980,13 +1003,8 @@ export class TemplateElement extends HTMLElement { : null; } if (!parent) return undefined; - if ( - mayBypass && - !bypassed && - parent.getAttribute(STREAMING_SPAN_HOST_ATTR) === - bypassSpan - ) { - bypassed = true; + if (parent === bypass) { + bypass = undefined; current = parent; continue; } @@ -1110,13 +1128,7 @@ export class TemplateElement extends HTMLElement { const boundaryState = this.$ancestorBoundaryState; this.$hasAncestorBoundaryState = undefined; this.$ancestorBoundaryState = undefined; - const resume = ( - this as unknown as { [PENDING_ROOT_CONNECTED]?: () => void } - )[PENDING_ROOT_CONNECTED]; - if (typeof resume === 'function') { - resume.call(this); - return; - } + if (this.$resumeRetainedRoot()) return; const meta = this.$meta; if (meta && this.$shouldDeferSSRHydration(meta)) { if (hasBoundaryState) { diff --git a/packages/webui-framework/tests/fixtures/lazy-hydration/lazy-hydration.spec.ts b/packages/webui-framework/tests/fixtures/lazy-hydration/lazy-hydration.spec.ts index 9cc26305d..9559d5e4d 100644 --- a/packages/webui-framework/tests/fixtures/lazy-hydration/lazy-hydration.spec.ts +++ b/packages/webui-framework/tests/fixtures/lazy-hydration/lazy-hydration.spec.ts @@ -405,7 +405,13 @@ test.describe('component lazy hydration', () => { hydratedBefore: [false, false], hydratedAfterParent: [false, false], hydratedAfter: [false, false], - outcomes: [1, 1], + // The parent is a lazy (visibility-deferred) host, so its own activation + // is an accepted commit that simply does not hydrate yet. The child sits + // behind that unfinished barrier, which the hook reports distinctly + // (`ACTIVATION_ANCESTOR_BARRIER`) so the coordinator retains it and + // replays activation when the barrier lifts, rather than believing it + // already activated. + outcomes: [1, 4], streamingMode: true, }); await expect(page.locator('#streamed-nested')).not.toHaveAttribute( From dd4c4b3821ea16f4309f0984a6abf51c6fb8ce2a Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 20 Aug 2026 07:29:19 -0700 Subject: [PATCH 3/5] docs: document streaming performance profile Clarify one-shot state snapshot reuse and record the final server and browser bundle measurements. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/ai/SKILL.md | 3 +++ docs/guide/concepts/performance.md | 42 +++++++++++++++++++++++++++--- docs/guide/integrations/rust.md | 6 +++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/ai/SKILL.md b/docs/ai/SKILL.md index 58d0ed589..a32d21800 100644 --- a/docs/ai/SKILL.md +++ b/docs/ai/SKILL.md @@ -764,6 +764,9 @@ loops, outlets, and selected route content. rerunning `hydratedCallback()`. - State resolution across a suspension is lexical locals, resume state, then the frozen projected parent state. +- `render_streaming` projects its one state value once for the complete + response. Host-driven `stream_response` sessions use each `resume` state as + an overlay for newly resolved occurrence data. - A component-local boundary uses generated parent spans. Its early marked child may hydrate before the opaque parent tail in light or shadow DOM. - `webui:boundary-hydrated` is emitted only when diff --git a/docs/guide/concepts/performance.md b/docs/guide/concepts/performance.md index 33cf7a9a3..f6446e093 100644 --- a/docs/guide/concepts/performance.md +++ b/docs/guide/concepts/performance.md @@ -197,10 +197,11 @@ Each layer of the architecture contributes to the overall performance profile: - **Runtime-discovered streaming.** The continuation VM walks only the selected entry, component, condition, loop, and route path. It is iterative and keeps bounded frames, projected parent keys, lexical locals, occurrence keys, and - generated component spans instead of cloning the full state or prebuilding a - request plan. Boundary-free fragment records are skipped through a build-time - `contains_boundary` bit. Capture and projection scratch buffers retain - capacity across checkpoints. + generated component spans instead of cloning full state for every boundary or + prebuilding a request plan. Full-state fallback snapshots once per response; + `render_streaming` reuses that snapshot for every occurrence. Boundary-free + fragment records are skipped through a build-time `contains_boundary` bit. + Capture and projection scratch buffers retain capacity across checkpoints. - **Bounded browser activation.** Each checkpoint or generated span completion walks one root-local marker range, including open shadow roots, and removes its scaffolding after commit. Final occurrences retain no root list. @@ -228,6 +229,39 @@ Each layer of the architecture contributes to the overall performance profile: only the affected DOM nodes - not entire subtrees. This keeps hydration and reactive updates fast even in large documents. +### Progressive streaming cost profile + +The progressive path does more work than the former fixed entry-boundary model: +it discovers runtime occurrences, preserves continuation state, and completes +generated component spans. Interleaved release-mode measurements against that +fixed model produced: + +| Boundaries | Fixed model | Runtime-discovered model | +|-----------:|------------:|-------------------------:| +| 1 | 2.20 us | 2.57 us | +| 3 | 4.25 us | 4.58 us | +| 10 | 9.95 us | 11.05 us | +| 100 | 76.1 us | 98.3 us | + +The ordinary renderer remains effectively unchanged in the same comparison +(732 ns versus 741 ns). A separate large-state benchmark verifies that frozen +state is projected once per response: optimization reduced an eight-boundary +render from 354.6 us to 114.1 us. + +The optional browser coordinator is also measured independently from the +always-shipped framework entry: + +| Production bundle | Minified | Gzip | +|-------------------|---------:|-----:| +| Ordinary framework entry | 60,528 bytes | 18,993 bytes | +| Streaming-only increment | 16,652 bytes | 5,928 bytes | + +The hydration matrix enforces absolute ordinary and incremental limits with +4-5% headroom, so moving optional streaming code into the default entry cannot +hide behind subtraction. These figures are workload and machine specific; use +`examples/integration/streaming-browser-bench` and +`streaming_hydration_bench` for changes to either hot path. + ## Light DOM vs Shadow DOM Shadow DOM provides style encapsulation but has a performance cost. Benchmark diff --git a/docs/guide/integrations/rust.md b/docs/guide/integrations/rust.md index 570fb1ce7..04204fa6a 100644 --- a/docs/guide/integrations/rust.md +++ b/docs/guide/integrations/rust.md @@ -252,6 +252,12 @@ selected path has none. `resume` must use the currently pending terminal. The final returned status has `done == true`, no descriptor, and the writer already contains the tail and terminal. +`WebUIHandler::render_streaming` uses one state value for the complete response: +it projects and freezes that value once, then resumes each occurrence directly +against the same snapshot. The lower-level `stream_response` API keeps the +public `resume` overlay because hosts may supply newly resolved state for each +occurrence. + Every descriptor contains `instance_id`, `declaration_id`, `owner`, `name`, and an optional string or numeric `key`. A declaration inside a repeated path requires a key, and live keys must be unique. From aba3a9bc3e88ddeb0a6389b137aa85ffa3a921a9 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 20 Aug 2026 16:16:39 -0700 Subject: [PATCH 4/5] feat: separate boundary commits from continuation Make resume return boundary-only bytes, add advance for parent and tail bytes, and reject boundaries reached from repeat bodies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 302 ++++++--- crates/webui-cli/README.md | 19 +- .../src/commands/serve/streaming_api.rs | 149 ++++- crates/webui-ffi/README.md | 19 +- crates/webui-ffi/include/webui_ffi.h | 30 +- crates/webui-ffi/src/lib.rs | 54 +- crates/webui-ffi/tests/ffi_test.rs | 281 +++++--- crates/webui-handler/README.md | 45 +- .../benches/streaming_hydration_bench.rs | 24 +- crates/webui-handler/src/streaming/error.rs | 11 + crates/webui-handler/src/streaming/mod.rs | 18 +- crates/webui-handler/src/streaming/owned.rs | 209 ++++-- crates/webui-handler/src/streaming/session.rs | 305 ++++++--- crates/webui-handler/src/streaming/vm.rs | 207 ++++-- crates/webui-handler/tests/streaming_v2.rs | 559 ++++++++++++---- crates/webui-node/README.md | 33 +- crates/webui-node/src/lib.rs | 136 +++- crates/webui-parser/src/diagnostic.rs | 9 +- crates/webui-parser/src/lib.rs | 603 ++++++++++++++++-- crates/webui-protocol/proto/webui.proto | 4 +- crates/webui-protocol/src/gen_webui.rs | 4 +- crates/webui-python/README.md | 30 +- .../benchmarks/benchmark_renderer.py | 9 +- .../python/microsoft_webui/_api.py | 6 +- .../python/microsoft_webui/_native.pyi | 1 + crates/webui-python/src/lib.rs | 11 + crates/webui-python/tests/conftest.py | 9 +- .../tests/fixtures/streaming-app/index.html | 15 +- .../tests/fixtures/streaming_protocol.bin | 88 +-- crates/webui-python/tests/test_streaming.py | 102 ++- crates/webui-wasm/README.md | 31 +- crates/webui-wasm/src/handler.rs | 121 +++- crates/webui/README.md | 37 ++ docs/ai/SKILL.md | 62 +- docs/guide/cli/index.md | 17 +- docs/guide/concepts/directives/boundary.md | 98 ++- docs/guide/concepts/hydration.md | 15 +- docs/guide/concepts/performance.md | 26 +- docs/guide/integrations/dotnet.md | 48 +- docs/guide/integrations/ffi.md | 61 +- docs/guide/integrations/node.md | 37 +- docs/guide/integrations/python.md | 73 ++- docs/guide/integrations/rust.md | 92 ++- docs/guide/integrations/wasm.md | 37 +- dotnet/src/Microsoft.WebUI/NativeBindings.cs | 11 + dotnet/src/Microsoft.WebUI/README.md | 41 ++ .../src/Microsoft.WebUI/StreamingSession.cs | 42 +- .../Microsoft.WebUI.Tests.csproj | 8 +- .../StreamingSessionTests.cs | 99 ++- .../fixtures/streaming-app/index.html | 12 +- examples/app/service-worker/README.md | 16 +- .../service-worker/scripts/check-render.ts | 8 +- .../app/service-worker/src/service-worker.ts | 5 + .../src/wasm/handler/webui_wasm_handler.d.ts | 1 + examples/app/streaming/README.md | 28 +- .../app/streaming/server/src/pacing.test.ts | 2 +- .../streaming/server/src/stream-protocol.ts | 4 + examples/integration/node/README.md | 35 +- examples/integration/node/streaming-server.js | 12 +- examples/integration/rust/README.md | 14 +- examples/integration/rust/src/main.rs | 21 +- packages/webui-framework/README.md | 12 +- packages/webui/README.md | 30 +- packages/webui/src/index.ts | 24 +- packages/webui/test/integration.test.ts | 114 +++- 65 files changed, 3507 insertions(+), 1079 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5547c556a..14bb4e942 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -135,7 +135,9 @@ pub struct WebUIFragmentBoundary { pub name: String, /// Optional expression evaluated for each runtime occurrence. pub key: Option, - /// Conservative build-time result for declarations that may occur repeatedly. + /// Conservative build-time result for declarations rendered from more than + /// one static callsite. A `` repeat never contributes: the build + /// rejects every boundary a repeat body reaches (`boundary-in-repeat`). pub may_repeat: bool, /// Whether this fragment opens or closes the declaration's body. pub phase: BoundaryPhase, @@ -176,10 +178,11 @@ pub struct WebUIFragmentComponent { **inline tape**: a `Start` marker, the body fragments, and an `End` marker carrying the same `declaration_id`, all in the owner's own record. Ordinary rendering therefore walks the body without an extra record lookup and simply -skips both markers, while streaming suspends at `Start` and resumes inside the -record it is already traversing. It may be reached through entries, reusable -components, conditions, loops, outlets, and the selected route. Runtime -traversal, not declaration order, creates response-local occurrences. +skips both markers. Streaming suspends at `Start`; public `resume` renders only +the body and its checkpoint, then public `advance` continues the owning record. +The declaration may be reached through entries, reusable components, +conditions, outlets, and the selected route. Runtime traversal, not declaration +order, creates response-local occurrences. Markers always pair within one record, because a ``'s children are lexically inside it; constructs that own their own record (``, ``, @@ -1006,11 +1009,13 @@ impl StreamingResponse<'_, W> { state: &Value, mode: BoundaryMode, ) -> Result; + pub fn advance(&mut self) -> Result; pub fn update( &mut self, instance_id: BoundaryInstanceId, patch: &Value, ) -> Result<()>; + pub fn is_done(&self) -> bool; } impl StreamingSession { @@ -1021,26 +1026,55 @@ impl StreamingSession { state: &Value, mode: BoundaryMode, ) -> Result; + pub fn advance(&mut self) -> Result; pub fn update( &mut self, instance_id: BoundaryInstanceId, patch: &Value, ) -> Result>; + pub fn is_done(&self) -> bool; } ``` -`start` renders until the first runtime occurrence or through the terminal. -`resume` must target the descriptor currently returned by the session. It -commits that occurrence, then continues until the next occurrence or terminal. -The final successful call returns `done = true`, no boundary, and bytes that -already contain the terminal record and document suffix. There is no separate -terminal call. +Every step is one independently writable, independently flushed segment: + +- `start` renders the shell prefix and stops **before** the first occurrence, or + runs through the terminal when the document declares none. +- `resume` must target the descriptor the session currently reports. It renders + **only** that occurrence, through its checkpoint record, and returns + immediately. Its bytes contain the occurrence's `` … `` + range and record, never the parent or tail bytes that follow it. +- `advance` renders the ordinary parent/shell bytes that follow a committed + occurrence until the next occurrence suspends or the terminal record and + document suffix complete. It is valid only after `resume`. + +The `(boundary, done)` pair names exactly one state: + +| `boundary` | `done` | meaning | +|------------|--------|---------| +| `Some` | `false`| the occurrence is waiting for `resume` | +| `None` | `false`| the committed occurrence flushed; call `advance` | +| `None` | `true` | the terminal record and document suffix completed | + +There is no separate terminal call. Out-of-order calls (`advance` before any +`resume`, a second `resume` before `advance`, or any step after `done`) are +rejected with an actionable `StreamingBoundary` error **before** any byte is +written, so the response is not poisoned and the host can retry with the +correct step. `update` accepts only an object patch and only a committed `Updatable` -occurrence. It emits projected state bytes and no application markup. A -borrowed `StreamingResponse` writes directly to its `FlushWriter`; an owned +occurrence. It emits projected state bytes and no application markup, and is +valid between `resume` and `advance` as well as afterwards, so a host can revise +the occurrence it just committed while the response stays open. A borrowed +`StreamingResponse` writes directly to its `FlushWriter`; an owned `StreamingSession` returns one complete byte vector per call for language -bindings and host-controlled backpressure. +bindings and host-controlled backpressure. Each owned step's bytes end exactly +on the transport flush boundary that closed the step, so a host that writes and +flushes once per step reproduces the borrowed writer's flush positions byte for +byte. + +`WebUIHandler::render_streaming` drives the whole `start → resume → advance → …` +cycle internally against one frozen state snapshot. ### Per-Render HTML Injection @@ -2153,36 +2187,58 @@ regions while the document is still loading. ### Normative invariants 1. **Runtime discovery.** `` is valid in entries and reusable - components, including runtime ``, ``, outlet, and selected-route - paths. A false branch, empty loop, or unselected route produces no - occurrence. Authored boundaries must not directly or transitively contain - another authored boundary in this version. -2. **Local declaration identity.** `name` is static, non-empty, and unique only + components, including runtime ``, outlet, and selected-route paths. A + false branch or unselected route produces no occurrence. Authored boundaries + must not directly or transitively contain another authored boundary in this + version. +2. **Repeats are boundary-free.** A boundary must never execute inside a `` + repeat body, directly or transitively behind ``, a route, an outlet, or + a reusable component reached from the body. A repeat iteration cannot + suspend, so the build rejects the whole reachable set with + `boundary-in-repeat` and names the repeat, the declaration, and its owner. + The inverse is allowed and is the intended pattern: a `` **inside** one + boundary makes the whole finite list one atomic independently paced region, + and a boundary may appear before or after a repeat. The continuation VM + therefore keeps no resumable repeat state: a repeat is walked to completion + inside the step that opens it, and a boundary discovered while a repeat is + open is rejected as a malformed protocol. +3. **Local declaration identity.** `name` is static, non-empty, and unique only within its owning entry or component template. `declarationId` is a stable build-local integer. Each runtime occurrence receives a gapless response-local `instanceId` and the host receives `{ instanceId, declarationId, owner, name, key }`. -3. **Repeated occurrence identity.** A declaration that can occur more than - once must author `key`. The expression must resolve in that occurrence's - lexical scope to a finite JSON number or string. Keys for simultaneously - live occurrences of one declaration must be unique. -4. **Pull session.** `start(state)` renders until the first occurrence or - terminal. `resume(instanceId, state, mode)` must target the currently pending - descriptor, commits it, and renders until the next occurrence or terminal. - `update(instanceId, patch)` targets only a committed updatable occurrence and - returns or writes one markerless update record. A final step has - `done = true`, has no descriptor, and already includes the document tail, - terminal record, final flush, and writer end. -5. **Frozen continuation state.** At `start`, the handler projects and freezes +4. **Multiple static occurrences.** A declaration in a reusable component + reached from more than one static callsite in one entry traversal must author + `key`; the build rejects an unkeyed declaration with `missing-boundary-key`. + Independent entries that each reach the component once do not make the + declaration repeatable. A `` never creates keyed boundary occurrences + because every boundary its body reaches is rejected. The expression must + resolve in that occurrence's lexical scope to a finite JSON number or string. + Keys for simultaneously live occurrences of one declaration must be unique. +5. **Pull session.** `start(state)` renders the shell prefix and stops before the + first occurrence, or runs through the terminal when there is none. + `resume(instanceId, state, mode)` must target the currently pending + descriptor and renders **only** that occurrence, through its checkpoint; + its bytes contain no following parent or tail bytes. `advance()` renders the + ordinary parent bytes that follow a committed occurrence until the next + occurrence or the terminal, and is valid only after `resume`. + `update(instanceId, patch)` targets only a committed updatable occurrence, + returns or writes one markerless update record, and is valid between `resume` + and `advance`. Every step is one independently writable, independently + flushed segment; the final step has `done = true`, no descriptor, and + includes the document tail, terminal record, final flush, and writer end. An + out-of-order step is rejected before any byte is written and does not poison + the response. +6. **Frozen continuation state.** At `start`, the handler projects and freezes only top-level state keys reachable by the continuation. It also preserves lexical locals, component attributes, route state, inventories, and continuation frames. Resume state overlays the frozen parent projection for selected keys. Expression resolution remains lexical first, then the boundary resume overlay, then frozen parent state. The one-shot - `WebUIHandler::render_streaming` helper resumes every occurrence directly - against its original start snapshot, avoiding redundant overlays when one - state value drives the complete response. -6. **Generated component spans.** When traversal suspends inside a reusable + `WebUIHandler::render_streaming` helper drives `start → resume → advance → …` + directly against its original start snapshot, avoiding redundant overlays + when one state value drives the complete response. +7. **Generated component spans.** When traversal suspends inside a reusable component, the handler opens a generated component span around its unfinished host. An early child checkpoint may bypass exactly its nearest unfinished spanning ancestor. Other descendants remain opaque behind that parent until @@ -2190,28 +2246,28 @@ regions while the document is still loading. inner-first. This rule is identical for light and shadow DOM; the browser crosses open shadow roots, slots, and hosts without leaving the bounded range. -7. **Exactly-once activation.** Streamed roots hydrate parent-first and +8. **Exactly-once activation.** Streamed roots hydrate parent-first and `hydratedCallback()` runs exactly once after their first successful activation. Undefined roots wait by tag. An undefined or unfinished parent is an activation barrier except for a compiler-marked early child matching the nearest span ID. -8. **Updates are state only.** An update applies the existing projected +9. **Updates are state only.** An update applies the existing projected `setState()` path to roots retained by an updatable checkpoint. It never inserts, replaces, relocates, or reparses application markup and never re-runs hydration or `hydratedCallback()`. A patch that arrives before a retained root activates is shallow-merged and replayed after activation. -9. **Ordered, self-sufficient wire.** Records use a gapless response-local - sequence starting at zero. Each checkpoint or span completion carries all - template, inventory, CSS, route, nonce, and projected-state deltas needed to - commit it after prior records. Global metadata merges additively. Boundary - state remains ephemeral and is not published to `window.__webui.state`. -10. **Fail closed.** Version, tuple arity, record sequence, occurrence sequence, +10. **Ordered, self-sufficient wire.** Records use a gapless response-local + sequence starting at zero. Each checkpoint or span completion carries all + template, inventory, CSS, route, nonce, and projected-state deltas needed to + commit it after prior records. Global metadata merges additively. Boundary + state remains ephemeral and is not published to `window.__webui.state`. +11. **Fail closed.** Version, tuple arity, record sequence, occurrence sequence, target kind, marker closure, span ancestry, and all configured work and retention limits are mandatory. Malformed, truncated, stale, duplicate, or overflowing input halts the coordinator, suppresses successful completion, and releases discoverable scripts, sentinels, markers, waiters, span state, and update roots within fixed bounds. -11. **Mode isolation.** Streaming is explicitly selected per response and +12. **Mode isolation.** Streaming is explicitly selected per response and requires a `FlushWriter` or owned `StreamingSession`. Ordinary `render`, partial navigation, and component-template operations do not emit streaming markers or browser records. @@ -2221,9 +2277,10 @@ regions while the document is still loading. `` is a bare compile-time directive. The parser erases its tags and brackets its body with a `WebUIFragmentBoundary` start/end pair emitted inline in the owner's record. It is valid in an entry or reusable component and may be -reached through conditions, loops, outlets, and route content. Component -templates strip directive tags from their browser template HTML, while the -server fragment graph retains the typed declaration. +reached through conditions, outlets, and route content. A boundary may enclose +a boundary-free ``. Component templates strip directive tags from their +browser template HTML, while the server fragment graph retains the typed +declaration. ```html @@ -2258,8 +2315,14 @@ The compiler enforces: - `name` is required, static, non-empty, and unique within the current owner. - Direct and transitive authored boundary nesting is rejected. -- A declaration that is lexically inside `` requires `key`; graph analysis - conservatively marks declarations reached repeatedly through components too. +- A boundary reachable from a `` repeat body, directly or transitively + through ``, a component, a route, or an outlet mount, is rejected with + `boundary-in-repeat`. Wrapping the whole `` in one boundary is the + supported alternative. +- A declaration in a reusable component reached from more than one static + callsite in one entry traversal requires `key`; graph analysis marks those + declarations conservatively. Independent entries that each call it once do + not. - `key` is a non-empty expression whose runtime value must be a string or finite JSON number. - Entry boundaries must be inside ``. Component-local boundaries use the @@ -2341,11 +2404,13 @@ produce: therefore before the async application entry `")); + assert!(tail.contains("[2,2,4,0,{}]")); + assert!(tail.contains("")); } #[test] -fn false_if_discovers_no_occurrence_and_boundary_free_start_completes() { +fn resume_writes_only_the_committed_boundary_and_advance_writes_the_tail() { let protocol = parsed_protocol( - &document( - r#"

no

done

"#, - ), + &document(concat!( + r#"

results

"#, + "
tail
", + )), &[], ); let mut session = new_session(protocol, "/"); - let step = session.start(&test_json!({ "show": false })).unwrap(); - assert!(step.done); - assert!(step.boundary.is_none()); - let html = String::from_utf8(step.bytes).unwrap(); - assert!(!html.contains("

results

")); + assert!(!bytes.contains("
tail
")); + assert!(!bytes.contains("[2,1,4,0,{}]")); + + let tail = session.advance().unwrap(); + assert!(tail.done); + let tail = String::from_utf8(tail.bytes).unwrap(); + assert!(tail.starts_with("
tail
")); + assert!(tail.contains("[2,1,4,0,{}]")); + assert!(!tail.contains("")); } #[test] -fn component_false_if_emits_generated_span_without_occurrence() { +fn multiple_boundaries_alternate_descriptor_commit_and_advance() { let protocol = parsed_protocol( - &document(r#""#), - &[( - "conditional-card", - r#"

yes

"#, - )], + &document(concat!( + r#"

1

"#, + "
", + r#"

2

"#, + "
tail
", + )), + &[], ); - let mut hidden = new_session(Arc::clone(&protocol), "/"); - let hidden = hidden.start(&test_json!({ "show": false })).unwrap(); - assert!(hidden.done); - let hidden_html = String::from_utf8(hidden.bytes).unwrap(); - assert!(!hidden_html.contains(""#)); - assert!(hidden_html.contains( - r#" - - - - - + + + +

between

+ + + +
tail
`); @@ -299,24 +303,46 @@ describe('streamResponse', () => { assert.equal(boundary.key, undefined); }); - test('resume discovers the next boundary and returns a completed final step', () => { + test('resume stops at the checkpoint and advance discovers the next boundary', () => { const session = streamingProtocol().streamResponse(streamOptions); const start = session.start({}); const first = boundaryOf(start); - const next = session.resume(first.instanceId, { firstLabel: 'alpha' }); + const resumedFirst = session.resume(first.instanceId, { firstLabel: 'alpha' }); + assert.ok(Buffer.isBuffer(resumedFirst.bytes)); + assert.equal(resumedFirst.done, false); + assert.equal(resumedFirst.boundary, undefined); + assert.match(resumedFirst.bytes.toString('utf8'), /alpha/); + assert.doesNotMatch(resumedFirst.bytes.toString('utf8'), /second/); + + const next = session.advance(); const second = boundaryOf(next); assert.ok(Buffer.isBuffer(next.bytes)); assert.equal(next.done, false); assert.equal(second.instanceId, 1); assert.equal(second.name, 'second'); + assert.match(next.bytes.toString('utf8'), /between/); + assert.doesNotMatch(next.bytes.toString('utf8'), /beta/); - const done = session.resume(second.instanceId, { secondLabel: 'beta' }); + const resumedSecond = session.resume(second.instanceId, { secondLabel: 'beta' }); + assert.ok(Buffer.isBuffer(resumedSecond.bytes)); + assert.equal(resumedSecond.done, false); + assert.equal(resumedSecond.boundary, undefined); + assert.match(resumedSecond.bytes.toString('utf8'), /beta/); + assert.doesNotMatch(resumedSecond.bytes.toString('utf8'), /<\/html>/); + + const done = session.advance(); assert.ok(Buffer.isBuffer(done.bytes)); assert.equal(done.done, true); assert.equal(done.boundary, undefined); - const html = Buffer.concat([start.bytes, next.bytes, done.bytes]).toString('utf8'); + const html = Buffer.concat([ + start.bytes, + resumedFirst.bytes, + next.bytes, + resumedSecond.bytes, + done.bytes, + ]).toString('utf8'); assert.ok(html.includes('')); assert.ok(html.includes('alpha')); assert.ok(html.includes('beta')); @@ -324,42 +350,55 @@ describe('streamResponse', () => { assert.ok(html.includes('')); }); - test('preserves string and number repeat keys', () => { - const entry = 'index-stream-repeat.html'; + test('preserves string and number keys on static sibling occurrences', () => { + const entry = 'index-stream-keys.html'; const session = streamingProtocol(entry).streamResponse({ entry, requestPath: '/', }); const state = { - items: [ - { id: 'alpha', label: 'first' }, - { id: 20, label: 'second' }, - ], + stringId: 'alpha', + firstLabel: 'first', + numberId: 20, + secondLabel: 'second', }; const start = session.start(state); const first = boundaryOf(start); assert.equal(first.key, 'alpha'); - const next = session.resume(first.instanceId, {}); + const resumedFirst = session.resume(first.instanceId, state); + assert.equal(resumedFirst.done, false); + assert.equal(resumedFirst.boundary, undefined); + assert.doesNotMatch(resumedFirst.bytes.toString('utf8'), /between/); + + const next = session.advance(); const second = boundaryOf(next); assert.equal(second.instanceId, 1); - assert.equal(second.declarationId, first.declarationId); + assert.notEqual(second.declarationId, first.declarationId); assert.equal(second.key, 20); + assert.match(next.bytes.toString('utf8'), /between/); - const done = session.resume(second.instanceId, {}); + const resumedSecond = session.resume(second.instanceId, state); + assert.equal(resumedSecond.done, false); + assert.equal(resumedSecond.boundary, undefined); + assert.doesNotMatch(resumedSecond.bytes.toString('utf8'), /tail/); + const done = session.advance(); assert.equal(done.done, true); + assert.match(done.bytes.toString('utf8'), /tail/); }); test('updates a committed updatable occurrence', () => { const session = streamingProtocol().streamResponse(streamOptions); const start = session.start({}); const first = boundaryOf(start); - const next = session.resume( + const resumed = session.resume( first.instanceId, { firstLabel: 'alpha' }, 'updatable', ); + assert.equal(resumed.done, false); + assert.equal(resumed.boundary, undefined); const update = session.update(first.instanceId, { firstLabel: 'alpha-2', }); @@ -367,11 +406,35 @@ describe('streamResponse', () => { assert.ok(Buffer.isBuffer(update)); assert.match(update.toString('utf8'), /alpha-2/); + const next = session.advance(); const second = boundaryOf(next); - const done = session.resume(second.instanceId, { secondLabel: 'beta' }); + const resumedSecond = session.resume(second.instanceId, { secondLabel: 'beta' }); + assert.equal(resumedSecond.done, false); + assert.equal(resumedSecond.boundary, undefined); + const done = session.advance(); assert.equal(done.done, true); }); + test('rejects advance before a boundary has been resumed', () => { + const session = streamingProtocol().streamResponse(streamOptions); + assert.throws( + () => session.advance(), + /start must be called before this operation/, + ); + + const start = session.start({}); + assert.throws( + () => session.advance(), + /there is no committed boundary to advance past/, + ); + + const first = boundaryOf(start); + const resumed = session.resume(first.instanceId, { firstLabel: 'alpha' }); + assert.equal(resumed.done, false); + assert.equal(resumed.boundary, undefined); + assert.equal(boundaryOf(session.advance()).name, 'second'); + }); + test('start completes a boundary-free document', () => { const entry = 'index-stream-empty.html'; const session = streamingProtocol(entry).streamResponse({ @@ -434,12 +497,20 @@ describe('streamResponse over node:http', () => { assert.ok(step.boundary); step = session.resume(step.boundary.instanceId, { firstLabel: 'alpha' }); await write(response, step.bytes); + assert.equal(step.done, false); + assert.equal(step.boundary, undefined); // Only reached if the client already has the bytes above. await clientSawFirstBoundary; + step = session.advance(); assert.ok(step.boundary); + await write(response, step.bytes); step = session.resume(step.boundary.instanceId, { secondLabel: 'beta' }); + assert.equal(step.done, false); + assert.equal(step.boundary, undefined); + await write(response, step.bytes); + step = session.advance(); assert.equal(step.done, true); response.end(step.bytes); })().catch((error: unknown) => { @@ -476,6 +547,7 @@ describe('streamResponse over node:http', () => { // The tail arrived only after the client acknowledged the head. assert.equal(sawTailBeforeRelease, false); assert.ok(received.includes('alpha')); + assert.ok(received.includes('between')); assert.ok(received.includes('beta')); assert.ok(received.includes('')); } finally { From d0d3eaf3163bbba8f5a316cde61f45846d62150d Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 21 Aug 2026 13:10:42 -0700 Subject: [PATCH 5/5] fix: harden streaming boundary invariants Preserve literal boundary text, align updatable limits, remove dead span limits, avoid route capture allocations, and centralize activation outcomes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 4 +- crates/webui-handler/README.md | 6 +- crates/webui-handler/src/lib.rs | 3 +- crates/webui-handler/src/streaming/error.rs | 20 +- .../webui-handler/src/streaming/inventory.rs | 245 +++++++++++-- crates/webui-handler/src/streaming/mod.rs | 2 +- crates/webui-handler/src/streaming/owned.rs | 5 + crates/webui-handler/src/streaming/session.rs | 206 ++++++++++- crates/webui-handler/src/streaming/vm.rs | 35 +- crates/webui-parser/src/plugin/webui.rs | 196 ++++++++++- .../webui-framework/src/streaming-deferred.ts | 83 ++++- .../src/streaming-mode.test.ts | 92 ++++- .../webui-framework/src/streaming-mode.ts | 52 ++- .../src/streaming-pipeline.test.ts | 332 ++++++++++++++++-- .../webui-framework/src/streaming-spans.ts | 14 +- .../src/template-element.test.ts | 20 +- .../webui-framework/src/template-element.ts | 27 +- 17 files changed, 1203 insertions(+), 139 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 14bb4e942..6c1d18860 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2539,9 +2539,9 @@ wire correctness. ### Limits, errors, and malformed input - Server limits are 256 continuation frames, 512 runtime boundary occurrences, - 512 keyed occurrences, 128 open generated spans, 32 nested generated spans, + 512 keyed occurrences, 128 updatable occurrences, 32 nested generated spans, and 1,024 frozen top-level state keys. -- Browser limits are 512 queued records, 128 updatable boundaries, 50,000 +- Browser limits are 512 queued records, 128 updatable occurrences, 50,000 retained update roots, 50,000 pending undefined roots, 50,000 pending ancestor-barrier roots, 10,000 elements per checkpoint, 50,000 marker-scan nodes, and an eight-element payload-script lookback. diff --git a/crates/webui-handler/README.md b/crates/webui-handler/README.md index b6c89856c..e940c7aa7 100644 --- a/crates/webui-handler/README.md +++ b/crates/webui-handler/README.md @@ -66,7 +66,11 @@ Commit an occurrence as `BoundaryMode::Updatable` to call `update(instance_id, patch)` later, including between its `resume` and the following `advance`. Updates are projected state records and do not insert markup. Component-local occurrences use generated parent spans so an early child -can hydrate before the parent tail. +can hydrate before the parent tail. One response may commit at most 128 +updatable occurrences, matching what the browser retains; a `resume` past that +is refused before any byte is written, so the same pending occurrence can be +committed as `BoundaryMode::Final` instead. Final occurrences release their +roots at hydration and never count against the cap. A `` may not appear inside a `` repeat body (directly or through a component, ``, route, or outlet): the build rejects it with diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index 2170fbfca..9cb10b4d2 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -46,8 +46,7 @@ use streaming::{ pub use streaming::{ BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions, SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, - MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_OPEN_SPANS, - MAX_SPAN_NESTING, + MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING, }; use thiserror::Error; use webui_expressions::{evaluate_with_resolver, ExpressionError}; diff --git a/crates/webui-handler/src/streaming/error.rs b/crates/webui-handler/src/streaming/error.rs index ec4b4b06a..61e73be69 100644 --- a/crates/webui-handler/src/streaming/error.rs +++ b/crates/webui-handler/src/streaming/error.rs @@ -113,12 +113,11 @@ pub(super) fn boundary_limit_error(limit: usize) -> HandlerError { #[cold] #[inline(never)] -pub(super) fn span_limit_error(limit: usize) -> HandlerError { +pub(super) fn span_id_overflow_error() -> HandlerError { streaming_boundary_error( "component span", - &format!( - "open component span count exceeds {limit}; reduce boundary-bearing component nesting" - ), + "component span IDs exhausted the response-local 32-bit range; split the page into \ + several responses or reduce boundary-bearing component hosts", ) } @@ -131,6 +130,19 @@ pub(super) fn span_nesting_error(limit: usize) -> HandlerError { ) } +#[cold] +#[inline(never)] +pub(super) fn updatable_limit_error(limit: usize) -> HandlerError { + streaming_boundary_error( + "resume", + &format!( + "this response already committed {limit} updatable boundary occurrences, the most the \ + browser retains; resume this occurrence with BoundaryMode::Final, or split the page \ + so fewer occurrences need later updates" + ), + ) +} + #[cold] #[inline(never)] pub(super) fn invalid_boundary_key_error( diff --git a/crates/webui-handler/src/streaming/inventory.rs b/crates/webui-handler/src/streaming/inventory.rs index d53086550..c24cd06e4 100644 --- a/crates/webui-handler/src/streaming/inventory.rs +++ b/crates/webui-handler/src/streaming/inventory.rs @@ -24,38 +24,16 @@ pub(crate) fn record_checkpoint_tag( let Some(&index) = context.component_index.get(fragment_id) else { return; }; - let route_dependent = context.streaming.as_ref().is_some_and(|streaming| { - streaming.component_reachability.is_route_dependent(index) == Some(true) - }); - let route_base = route_dependent.then(|| context.route_base.as_ref().into()); - let Some(streaming) = context.streaming.as_mut() else { + // Borrowed for the whole capture: the base is only ever compared against + // what the checkpoint already holds, so the owned copy is created inside + // the miss path rather than once per rendered tag. + let route_base: &str = context.route_base.as_ref(); + let Some(streaming) = context.streaming.as_deref_mut() else { return; }; - if let Some(route_base) = route_base { - let route_base: Box = route_base; - let already_recorded = streaming.checkpoint_walk_roots.iter().any(|(root, base)| { - *root == index - && base - .as_ref() - .is_some_and(|base| base.as_ref() == route_base.as_ref()) - }); - if !already_recorded { - if streaming.checkpoint_walk_roots.is_empty() { - streaming - .checkpoint_walk_roots - .reserve(streaming.checkpoint_tags.len() + 1); - streaming.checkpoint_walk_roots.extend( - streaming - .checkpoint_tags - .iter() - .copied() - .map(|root| (root, None)), - ); - } - streaming - .checkpoint_walk_roots - .push((index, Some(route_base))); - } + let route_dependent = streaming.component_reachability.is_route_dependent(index) == Some(true); + if route_dependent { + record_route_dependent_root(streaming, index, route_base); } let byte_index = (index / 8) as usize; let bit = 1u8 << (index % 8); @@ -74,6 +52,44 @@ pub(crate) fn record_checkpoint_tag( } } +/// Capture one route-dependent root under the base it rendered against. +/// +/// A route-dependent component reaches a different surface per route base, so +/// the capture keeps `(component, base)` pairs rather than the bare index the +/// bitset holds. Membership is decided against the borrowed base — one slice +/// compare per already-captured entry — so the owned base is built only for a +/// pair the checkpoint has not seen, instead of once per rendered tag. +fn record_route_dependent_root( + streaming: &mut StreamingRenderState<'_>, + index: u32, + route_base: &str, +) { + if streaming + .checkpoint_walk_roots + .iter() + .any(|(root, base)| *root == index && base.as_deref() == Some(route_base)) + { + return; + } + // The first route-dependent root promotes the plain bitset capture into + // base-carrying pairs, so every tag already recorded is carried over. + if streaming.checkpoint_walk_roots.is_empty() { + streaming + .checkpoint_walk_roots + .reserve(streaming.checkpoint_tags.len() + 1); + streaming.checkpoint_walk_roots.extend( + streaming + .checkpoint_tags + .iter() + .copied() + .map(|root| (root, None)), + ); + } + streaming + .checkpoint_walk_roots + .push((index, Some(route_base.into()))); +} + /// Commit the exact rendered tags to the cumulative DOM inventory and encode /// this checkpoint's delta. Template delivery is tracked separately because a /// reachable-but-unrendered descendant needs metadata without claiming live DOM. @@ -302,4 +318,173 @@ mod tests { "checkpoint tag buffer capacity must be reused, not reallocated" ); } + + /// A sink for capture-only tests: recording a tag never writes a byte. + struct NullSink; + + impl crate::ResponseWriter for NullSink { + fn write(&mut self, _content: &str) -> Result<()> { + Ok(()) + } + + fn end(&mut self) -> Result<()> { + Ok(()) + } + } + + /// Component index and heap address of the first captured walk root's + /// owned base, so a repeat can prove the retained base was neither + /// duplicated nor rebuilt. + fn first_captured_base(streaming: &StreamingRenderState<'_>) -> Option<(u32, usize)> { + streaming + .checkpoint_walk_roots + .first() + .and_then(|(root, base)| base.as_ref().map(|base| (*root, base.as_ptr().addr()))) + } + + #[test] + fn repeated_route_dependent_capture_keeps_one_owned_base() -> Result<()> { + // A route-dependent component rendered repeatedly under the same base + // is one capture entry, and that entry keeps the very allocation it was + // first captured with: membership is decided against the borrowed base, + // so a repeat builds no owned base it would immediately drop. A genuine + // new base still captures a second root, because the request-aware walk + // must visit the component once per base it rendered under. + let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([ + ( + "route-shell".to_string(), + FragmentList { + fragments: vec![webui_protocol::WebUIFragment::route( + "details", + "detail-page", + )], + contains_boundary: false, + }, + ), + ( + "detail-page".to_string(), + FragmentList { + fragments: vec![webui_protocol::WebUIFragment::raw("

detail

")], + contains_boundary: false, + }, + ), + ]))); + let component_index = protocol.component_index(); + let Some(&shell) = component_index.get("route-shell") else { + panic!("the route-bearing component must be indexed"); + }; + assert_eq!( + protocol.component_reachability().is_route_dependent(shell), + Some(true), + "a component that hosts a route is route dependent" + ); + + let mut streaming = StreamingRenderState::from_progress( + super::super::state::StreamingProgress::new(component_index.len()), + protocol.component_reachability(), + ); + let state = serde_json::Value::Object(serde_json::Map::new()); + let mut writer = NullSink; + let mut context = WebUIProcessContext { + protocol: protocol.protocol(), + component_asset_style_manifest: protocol.component_asset_style_manifest()?, + component_asset_style_links: protocol.component_asset_style_links(), + state: &state, + writer: &mut writer, + local_vars: HashMap::new(), + component_attrs: HashMap::new(), + request_path: "/account/details", + route_base: std::borrow::Cow::Borrowed("/account"), + rendered_components: std::collections::HashSet::new(), + plugin: None, + route_children: Vec::new(), + entry_id: "index.html", + nonce: None, + component_index, + head_inject: None, + body_inject: None, + state_inject: crate::StateInject::resolve(&state), + head_end_emitted: false, + body_start_emitted: false, + component_asset_styles_emitted: false, + body_end_emitted: false, + route_index: protocol.route_index(), + route_chain_index: 0, + streaming: Some(&mut streaming), + json_scratch: Vec::new(), + scope_pool: Vec::new(), + }; + + record_checkpoint_tag(&mut context, "route-shell"); + let Some(state_after_first) = context.streaming.as_deref() else { + panic!("the capture must retain its streaming state"); + }; + let captured = first_captured_base(state_after_first); + assert_eq!( + state_after_first.checkpoint_walk_roots.len(), + 1, + "the first render of a route-dependent component captures one root" + ); + assert!( + captured.is_some(), + "the root retains the base it rendered at" + ); + let capture_buffer = ( + state_after_first.checkpoint_walk_roots.capacity(), + state_after_first.checkpoint_walk_roots.as_ptr().addr(), + ); + + record_checkpoint_tag(&mut context, "route-shell"); + let Some(state_after_repeat) = context.streaming.as_deref() else { + panic!("the capture must retain its streaming state"); + }; + assert_eq!( + ( + state_after_repeat.checkpoint_walk_roots.capacity(), + state_after_repeat.checkpoint_walk_roots.as_ptr().addr(), + ), + capture_buffer, + "a repeat must touch neither the capture buffer nor its capacity" + ); + assert_eq!( + state_after_repeat.checkpoint_walk_roots.len(), + 1, + "the same component under the same base must not capture a second root" + ); + assert_eq!( + first_captured_base(state_after_repeat), + captured, + "the retained base must be the original allocation, not a rebuilt copy" + ); + assert_eq!( + state_after_repeat.checkpoint_tags, + vec![shell], + "the component-index bitset still records the tag exactly once" + ); + + context.route_base = std::borrow::Cow::Borrowed("/other"); + record_checkpoint_tag(&mut context, "route-shell"); + let Some(state_after_rebase) = context.streaming.as_deref() else { + panic!("the capture must retain its streaming state"); + }; + assert_eq!( + state_after_rebase.checkpoint_walk_roots.len(), + 2, + "a genuinely new base captures the component a second time" + ); + assert_eq!( + first_captured_base(state_after_rebase), + captured, + "capturing a new base must not disturb the base already retained" + ); + assert_eq!( + state_after_rebase.checkpoint_walk_roots[1] + .1 + .as_deref() + .map(str::to_string), + Some("/other".to_string()), + "the second root carries the base it rendered under" + ); + Ok(()) + } } diff --git a/crates/webui-handler/src/streaming/mod.rs b/crates/webui-handler/src/streaming/mod.rs index ab59e16b8..d66d1511d 100644 --- a/crates/webui-handler/src/streaming/mod.rs +++ b/crates/webui-handler/src/streaming/mod.rs @@ -31,7 +31,7 @@ pub(crate) use root::{ pub use session::{ BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, SpanInstanceId, StreamStatus, StreamingResponse, MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, - MAX_KEYED_INSTANCES, MAX_OPEN_SPANS, MAX_SPAN_NESTING, + MAX_KEYED_INSTANCES, MAX_SPAN_NESTING, }; pub(crate) use state::StreamingRenderState; pub(crate) use vm::PreparedContinuationStatePlan; diff --git a/crates/webui-handler/src/streaming/owned.rs b/crates/webui-handler/src/streaming/owned.rs index f6aef6f7e..ac1c76eab 100644 --- a/crates/webui-handler/src/streaming/owned.rs +++ b/crates/webui-handler/src/streaming/owned.rs @@ -135,6 +135,11 @@ impl StreamingSession { /// /// The returned bytes hold that occurrence's record and nothing that /// follows it. Call [`Self::advance`] for the parent bytes. + /// + /// [`BoundaryMode::Updatable`] is refused once the response has committed + /// as many updatable occurrences as the browser retains. The refusal + /// produces no bytes and leaves the occurrence pending, so it can be + /// committed with [`BoundaryMode::Final`] instead. pub fn resume( &mut self, instance_id: BoundaryInstanceId, diff --git a/crates/webui-handler/src/streaming/session.rs b/crates/webui-handler/src/streaming/session.rs index 1bce44a83..4db90fc4c 100644 --- a/crates/webui-handler/src/streaming/session.rs +++ b/crates/webui-handler/src/streaming/session.rs @@ -25,12 +25,17 @@ use crate::{ /// Maximum continuation frames retained by one response. pub const MAX_CONTINUATION_DEPTH: usize = 256; -/// Maximum unfinished component spans retained by one response. -pub const MAX_OPEN_SPANS: usize = 128; /// Maximum nested unfinished component spans. pub const MAX_SPAN_NESTING: usize = 32; /// Maximum runtime boundary occurrences in one response. pub const MAX_BOUNDARY_OCCURRENCES: usize = 512; +/// Maximum runtime boundary occurrences one response may commit as +/// [`BoundaryMode::Updatable`]. +/// +/// Mirrors the browser coordinator's retained-boundary cap: the client refuses +/// to retain a 129th updatable occurrence, so the server refuses to emit one +/// rather than stream a checkpoint the page would fail on. +pub(crate) const MAX_UPDATABLE_OCCURRENCES: usize = 128; /// Maximum keyed runtime occurrences tracked for uniqueness. pub const MAX_KEYED_INSTANCES: usize = 512; /// Maximum top-level state keys retained by a continuation snapshot. @@ -209,6 +214,11 @@ impl StreamingResponse<'_, W> { /// The bytes written by this call are exactly the occurrence's own record — /// no parent or tail bytes follow it — so the host can release the /// occurrence the moment it resolves. Call [`Self::advance`] next. + /// + /// [`BoundaryMode::Updatable`] is refused once the response has committed + /// as many updatable occurrences as the browser retains. The refusal is + /// raised before any byte or state moves, so the same occurrence stays + /// pending and can be committed with [`BoundaryMode::Final`] instead. pub fn resume( &mut self, instance_id: BoundaryInstanceId, @@ -378,6 +388,7 @@ impl SessionCore { ) -> Result { self.require_resumable()?; self.vm.validate_resume(instance_id)?; + self.vm.validate_resume_mode(mode)?; if self.requires_full_state { overlay_full_state(&mut self.frozen_state, state); } else { @@ -411,6 +422,7 @@ impl SessionCore { ) -> Result { self.require_resumable()?; self.vm.validate_resume(instance_id)?; + self.vm.validate_resume_mode(mode)?; self.run_step(call, StepGoal::NextBoundary, Some((instance_id, mode))) } @@ -654,6 +666,28 @@ mod tests { } } + /// A sink whose bytes stay readable while the response holds it, so a test + /// can prove a refused step wrote nothing. + #[derive(Clone, Default)] + struct SharedSink(std::rc::Rc>); + + impl ResponseWriter for SharedSink { + fn write(&mut self, content: &str) -> Result<()> { + self.0.borrow_mut().push_str(content); + Ok(()) + } + + fn end(&mut self) -> Result<()> { + Ok(()) + } + } + + impl FlushWriter for SharedSink { + fn flush(&mut self) -> Result<()> { + Ok(()) + } + } + /// Build a parser-produced entry with `boundaries` runtime occurrences, /// each hosting one island component. fn boundary_protocol(boundaries: usize, hydration_mode: StateProjectionMode) -> Protocol { @@ -998,4 +1032,172 @@ mod tests { ); Ok(()) } + + /// Drive `count` occurrences to completion in `mode`, returning the status + /// the response stopped on. + fn commit_occurrences( + response: &mut StreamingResponse<'_, W>, + status: StreamStatus, + state: &Value, + count: usize, + mode: BoundaryMode, + ) -> Result { + let mut status = status; + for committed in 0..count { + let Some(boundary) = status.boundary.as_ref() else { + panic!("occurrence {committed} must suspend before it can commit"); + }; + let instance_id = boundary.instance_id; + response.resume(instance_id, state, mode)?; + status = response.advance()?; + } + Ok(status) + } + + #[test] + fn updatable_commits_stop_at_the_browser_retention_cap() -> Result<()> { + // The browser retains every updatable occurrence for the life of the + // response and refuses the one past its cap, so the server refuses to + // emit that checkpoint at all. The refusal lands before a byte is + // written and before the caller's state reaches the snapshot, leaving + // the same occurrence pending so the host can commit it as final. + let protocol = boundary_protocol(MAX_UPDATABLE_OCCURRENCES + 1, StateProjectionMode::Keys); + let handler = WebUIHandler::new(); + let state = test_json!({ "count": 1, "title": "retained" }); + let render_options = options(); + let mut sink = SharedSink::default(); + let written = SharedSink::clone(&sink).0; + let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?; + + let status = response.start(&state)?; + let status = commit_occurrences( + &mut response, + status, + &state, + MAX_UPDATABLE_OCCURRENCES, + BoundaryMode::Updatable, + )?; + + let Some(boundary) = status.boundary.as_ref() else { + panic!("the occurrence past the cap must suspend like any other"); + }; + let instance_id = boundary.instance_id; + let bytes_before = written.borrow().len(); + let refused = test_json!({ "count": 2, "title": "refused" }); + let rejected = response.resume(instance_id, &refused, BoundaryMode::Updatable); + match rejected { + Err(HandlerError::StreamingBoundary(error)) => { + assert_eq!(error.signal, "resume"); + assert!( + error.reason.contains("BoundaryMode::Final"), + "the refusal must name the recovery: {}", + error.reason + ); + } + _ => panic!("committing past the cap must fail with a typed boundary error"), + } + assert_eq!( + written.borrow().len(), + bytes_before, + "a refused commit must not write a byte" + ); + assert_eq!( + response.core.frozen_state.get("title"), + Some(&Value::String("retained".to_string())), + "a refused commit must not merge its state into the snapshot" + ); + + // The occurrence is untouched, so the same ID commits as final. + let status = response.resume(instance_id, &state, BoundaryMode::Final)?; + assert!( + status.boundary.is_none() && !status.done, + "the retried commit stops at its own checkpoint" + ); + assert!( + written.borrow().len() > bytes_before, + "the retry writes its checkpoint" + ); + assert!( + response.advance()?.done, + "the response still reaches its terminal" + ); + Ok(()) + } + + #[test] + fn final_commits_do_not_consume_the_updatable_cap() -> Result<()> { + // Only occurrences the browser retains count against the cap: a final + // boundary releases its roots at hydration, so a response may commit + // any number of them and still use its full updatable budget. + let protocol = boundary_protocol(MAX_UPDATABLE_OCCURRENCES + 2, StateProjectionMode::Keys); + let handler = WebUIHandler::new(); + let state = test_json!({ "count": 1, "title": "mixed" }); + let render_options = options(); + let mut sink = TestSink { + output: String::new(), + }; + let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?; + + let status = response.start(&state)?; + let status = commit_occurrences(&mut response, status, &state, 2, BoundaryMode::Final)?; + let status = commit_occurrences( + &mut response, + status, + &state, + MAX_UPDATABLE_OCCURRENCES, + BoundaryMode::Updatable, + )?; + assert!( + status.done && response.is_done(), + "final commits must leave the whole updatable budget available" + ); + Ok(()) + } + + #[test] + fn owned_sessions_enforce_the_same_updatable_cap() -> Result<()> { + // The owned session shares the borrowed session's continuation, so the + // cap, the refusal, and the final-mode retry behave identically. + let protocol = Arc::new(boundary_protocol( + MAX_UPDATABLE_OCCURRENCES + 1, + StateProjectionMode::Keys, + )); + let mut session = crate::streaming::StreamingSession::new( + Arc::new(WebUIHandler::new()), + protocol, + crate::streaming::SessionOptions::new("index.html", "/"), + )?; + let state = test_json!({ "count": 1, "title": "owned" }); + + let mut step = session.start(&state)?; + for committed in 0..MAX_UPDATABLE_OCCURRENCES { + let Some(boundary) = step.boundary.as_ref() else { + panic!("occurrence {committed} must suspend before it can commit"); + }; + let instance_id = boundary.instance_id; + session.resume(instance_id, &state, BoundaryMode::Updatable)?; + step = session.advance()?; + } + + let Some(boundary) = step.boundary.as_ref() else { + panic!("the occurrence past the cap must suspend like any other"); + }; + let instance_id = boundary.instance_id; + assert!( + session + .resume(instance_id, &state, BoundaryMode::Updatable) + .is_err(), + "an owned session refuses the occurrence past the cap" + ); + let step = session.resume(instance_id, &state, BoundaryMode::Final)?; + assert!( + !step.bytes.is_empty(), + "the retried commit still delivers its checkpoint bytes" + ); + assert!( + session.advance()?.done, + "the owned response still reaches its terminal" + ); + Ok(()) + } } diff --git a/crates/webui-handler/src/streaming/vm.rs b/crates/webui-handler/src/streaming/vm.rs index 153062781..d82b21d33 100644 --- a/crates/webui-handler/src/streaming/vm.rs +++ b/crates/webui-handler/src/streaming/vm.rs @@ -18,13 +18,13 @@ use super::checkpoint::RangeRecord; use super::error::{ boundary_in_repeat_error, boundary_limit_error, boundary_order_error, continuation_limit_error, duplicate_boundary_key_error, invalid_boundary_key_error, keyed_instance_limit_error, - malformed_span_signal_error, span_limit_error, span_nesting_error, + malformed_span_signal_error, span_id_overflow_error, span_nesting_error, updatable_limit_error, }; use super::root::ComponentHostOrigin; use super::session::{ BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, SpanInstanceId, StreamStatus, MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, - MAX_OPEN_SPANS, MAX_SPAN_NESTING, + MAX_SPAN_NESTING, MAX_UPDATABLE_OCCURRENCES, }; use super::state::{increment_streaming_record_sequence, protocol_fragment, RecordCapture}; use super::{ @@ -102,6 +102,13 @@ pub(crate) struct ContinuationVm { keyed_instances: HashMap>, keyed_instance_count: usize, committed_modes: Vec, + /// Occurrences already committed as [`BoundaryMode::Updatable`]. + /// + /// The browser retains every updatable occurrence for the life of the + /// response, so the cap is a running total rather than a live count. + /// Keeping it as a counter makes the pre-commit check one integer compare + /// instead of a scan of every mode already committed. + updatable_count: usize, component_count: usize, pending_span_candidate: Option>, /// Repeats currently being walked by this step. @@ -302,6 +309,7 @@ impl ContinuationVm { keyed_instances: HashMap::new(), keyed_instance_count: 0, committed_modes: Vec::new(), + updatable_count: 0, component_count: protocol.component_index().len(), pending_span_candidate: None, open_repeats: 0, @@ -324,6 +332,19 @@ impl ContinuationVm { Ok(()) } + /// Reject an `Updatable` commit the browser could not retain. + /// + /// Checked before the resume writes a byte or takes the pending + /// occurrence, so a rejected attempt leaves the response exactly as it was + /// and the host can commit the same occurrence as + /// [`BoundaryMode::Final`] instead. + pub(crate) fn validate_resume_mode(&self, mode: BoundaryMode) -> Result<()> { + if mode == BoundaryMode::Updatable && self.updatable_count >= MAX_UPDATABLE_OCCURRENCES { + return Err(updatable_limit_error(MAX_UPDATABLE_OCCURRENCES)); + } + Ok(()) + } + pub(crate) fn validate_update(&self, instance_id: BoundaryInstanceId) -> Result { let index = instance_id.index()?; let Some(mode) = self.committed_modes.get(index) else { @@ -986,6 +1007,11 @@ impl ContinuationVm { "committed boundary IDs are not gapless".to_string(), )); } + // Counted here, not at resume: only an occurrence whose checkpoint + // actually reached the client consumes the browser's retention budget. + if active.mode == BoundaryMode::Updatable { + self.updatable_count = self.updatable_count.saturating_add(1); + } self.committed_modes.push(active.mode); Ok(()) } @@ -1012,9 +1038,6 @@ impl ContinuationVm { "component span start is missing its tag", )); } - if self.open_spans.len() >= MAX_OPEN_SPANS { - return Err(span_limit_error(MAX_OPEN_SPANS)); - } if self.open_spans.len() >= MAX_SPAN_NESTING { return Err(span_nesting_error(MAX_SPAN_NESTING)); } @@ -1022,7 +1045,7 @@ impl ContinuationVm { self.next_span_id = self .next_span_id .checked_add(1) - .ok_or_else(|| span_limit_error(MAX_OPEN_SPANS))?; + .ok_or_else(span_id_overflow_error)?; if write_marker { super::write_range_marker(context.writer, "", + "

<boundary> is text

", + "near match", + "case-sensitive near match", + "< boundary>spaced near match", + "", + "", + r#""#, + r#""#, + r#"
child
"#, + "
", + "", + ); + let expected = concat!( + "", + ); + let stripped = WebUIParserPlugin::strip_boundary_directive_tags(source); + + assert_eq!(stripped, expected); + let metadata = generate_compiled_template("my-card", &stripped); + assert_eq!(metadata.matches("").count(), 3); + assert_eq!(metadata.matches("").count(), 3); + assert!(!metadata.contains("ready")); + } + /// Test helper: compile a template and unwrap. The vast majority of tests /// exercise valid templates; tests that assert on authoring errors call /// [`super::generate_compiled_template`] directly and inspect the `Result`. diff --git a/packages/webui-framework/src/streaming-deferred.ts b/packages/webui-framework/src/streaming-deferred.ts index 106f9e03e..adff13b0f 100644 --- a/packages/webui-framework/src/streaming-deferred.ts +++ b/packages/webui-framework/src/streaming-deferred.ts @@ -20,25 +20,34 @@ import { streamingErrorMessage, } from './streaming-dom.js'; import { + ACTIVATION_ACTIVATED, + ACTIVATION_ANCESTOR_BARRIER, + ACTIVATION_MISSING_TEMPLATE, + ACTIVATION_STATIC_HOST_OPT_OUT, PENDING_ROOT_CONNECTED, STREAMED_HOST_ATTR, STREAMING_BOUNDARY_ACTIVATE, } from './streaming-mode.js'; import { applyStateUpdate } from './streaming-state.js'; -const ACTIVATION_ACTIVATED = 1; -const ACTIVATION_STATIC_HOST_OPT_OUT = 2; -export const ACTIVATION_MISSING_TEMPLATE = 3; -const ACTIVATION_ANCESTOR_BARRIER = 4; -export const ELEMENT_IGNORED = 0; -export const ELEMENT_DEFERRED = 4; -export const ELEMENT_LIMIT_FAILURE = 5; -const ELEMENT_ACTIVATED_FROM_PENDING = 6; -const ELEMENT_BARRIER_LIMIT_FAILURE = 7; +// Coordinator-internal walk results, deliberately in a decade disjoint from the +// shared `ACTIVATION_*` outcomes (1..4) declared in `streaming-mode.ts`. Both +// spaces travel through the same `number`, so overlapping them once made an +// ancestor barrier indistinguishable from a definition-deferred element. +export const ELEMENT_IGNORED = 10; +export const ELEMENT_DEFERRED = 11; +export const ELEMENT_LIMIT_FAILURE = 12; +export const ELEMENT_ACTIVATED_FROM_PENDING = 13; +export const ELEMENT_BARRIER_LIMIT_FAILURE = 14; +/** A hook returned something outside the shared activation contract. */ +export const ELEMENT_INVALID_OUTCOME = 15; export const MAX_PENDING_UNDEFINED_ROOTS = 50_000; export const MAX_PENDING_BARRIER_ROOTS = 50_000; type BoundaryActivatable = Element & { + // Typed as `number`, not `ActivationOutcome`: the hook may belong to a + // foreign element that never saw this contract, so the value is validated + // once in `invokeActivationHook` instead of being trusted by the type. [STREAMING_BOUNDARY_ACTIVATE]?: ( state?: Record, bypassAncestor?: Element, @@ -83,6 +92,14 @@ const pendingBarrierRoots = new Set(); let pendingUndefinedRoots = 0; let activationGeneration = 0; let failureHandler: ((reason: string) => void) | null = null; +/** + * The offending value behind the most recent `ELEMENT_INVALID_OUTCOME`. + * + * A scalar rather than a carried payload so the rejection costs no allocation + * on a path the coordinator takes for every marked root. Every caller reads it + * in the same turn it observes the result, before any further hook can run. + */ +let invalidActivationOutcome: unknown; const PENDING_RECORD = Symbol(); @@ -149,6 +166,20 @@ function missingTemplateReason(tag: string): string { return `template metadata missing while activating <${tag}>`; } +/** + * Report a hook that answered outside the shared activation contract. + * + * Kept distinct from `missingTemplateReason` on purpose: folding an + * unrecognized code into "missing template" sends every reader looking for + * absent metadata when the real defect is a hook returning a code this + * coordinator cannot decode. + */ +function invalidOutcomeReason(tag: string): string { + return `<${tag}> returned an unrecognized streaming activation outcome ${ + String(invalidActivationOutcome) + }`; +} + function barrierLimitReason(): string { return `pending ancestor-barrier root count exceeds ${MAX_PENDING_BARRIER_ROOTS}`; } @@ -262,7 +293,8 @@ function activatePendingBarrierRoot(el: Element): number { try { const outcome = resumeRetainedRoot(el, record, updates); if (outcome === ACTIVATION_ANCESTOR_BARRIER) return ELEMENT_DEFERRED; - return outcome === ACTIVATION_MISSING_TEMPLATE + return outcome === ACTIVATION_MISSING_TEMPLATE || + outcome === ELEMENT_INVALID_OUTCOME ? outcome : ELEMENT_ACTIVATED_FROM_PENDING; } finally { @@ -304,6 +336,9 @@ function resumeBarrierRoot(this: Element): void { if (outcome === ACTIVATION_MISSING_TEMPLATE) { abandonDeferredTree(this); fail(missingTemplateReason(tagOf(this))); + } else if (outcome === ELEMENT_INVALID_OUTCOME) { + abandonDeferredTree(this); + fail(invalidOutcomeReason(tagOf(this))); } } catch (error) { abandonDeferredDescendants(this); @@ -377,6 +412,11 @@ function activatePendingRoot( fail(missingTemplateReason(tag)); return; } + if (outcome === ELEMENT_INVALID_OUTCOME) { + abandonDeferredTree(el); + fail(invalidOutcomeReason(tag)); + return; + } if (outcome === ACTIVATION_ANCESTOR_BARRIER) { // Re-retained by `resumeRetainedRoot`; only the budget is enforced here. if (pendingBarrierRoots.size > MAX_PENDING_BARRIER_ROOTS) { @@ -470,6 +510,9 @@ export function activateDeferredTree( if (outcome === ACTIVATION_MISSING_TEMPLATE) { return missingTemplateReason(tagOf(el)); } + if (outcome === ELEMENT_INVALID_OUTCOME) { + return invalidOutcomeReason(tagOf(el)); + } if (outcome === ELEMENT_LIMIT_FAILURE) { return `pending undefined root count exceeds ${MAX_PENDING_UNDEFINED_ROOTS}`; } @@ -596,15 +639,23 @@ function invokeActivationHook( removeStreamingAttributes(el); throw error; } - if (outcome === ACTIVATION_ANCESTOR_BARRIER) return outcome; + // Only a genuinely finished root gives up its markers. A barrier still owns + // its root, and a root that could not hydrate keeps them for fatal cleanup. if ( - outcome !== ACTIVATION_ACTIVATED && - outcome !== ACTIVATION_STATIC_HOST_OPT_OUT + outcome === ACTIVATION_ACTIVATED || + outcome === ACTIVATION_STATIC_HOST_OPT_OUT ) { - return ACTIVATION_MISSING_TEMPLATE; + removeStreamingAttributes(el); + return outcome; } - removeStreamingAttributes(el); - return outcome; + if ( + outcome === ACTIVATION_ANCESTOR_BARRIER || + outcome === ACTIVATION_MISSING_TEMPLATE + ) { + return outcome; + } + invalidActivationOutcome = outcome; + return ELEMENT_INVALID_OUTCOME; } /** Reset retained activation state and invalidate uncancellable waiters. */ diff --git a/packages/webui-framework/src/streaming-mode.test.ts b/packages/webui-framework/src/streaming-mode.test.ts index 5ab8453ee..c64de84ce 100644 --- a/packages/webui-framework/src/streaming-mode.test.ts +++ b/packages/webui-framework/src/streaming-mode.test.ts @@ -4,7 +4,23 @@ import { strict as assert } from 'node:assert'; import { describe, test } from 'node:test'; -import { isStreamingHydrationMode, resetStreamingModeForTests } from './streaming-mode.js'; +import { + ACTIVATION_ACTIVATED, + ACTIVATION_ANCESTOR_BARRIER, + ACTIVATION_MISSING_TEMPLATE, + ACTIVATION_STATIC_HOST_OPT_OUT, + isStreamingHydrationMode, + resetStreamingModeForTests, +} from './streaming-mode.js'; +import type { ActivationOutcome } from './streaming-mode.js'; +import { + ELEMENT_ACTIVATED_FROM_PENDING, + ELEMENT_BARRIER_LIMIT_FAILURE, + ELEMENT_DEFERRED, + ELEMENT_IGNORED, + ELEMENT_INVALID_OUTCOME, + ELEMENT_LIMIT_FAILURE, +} from './streaming-deferred.js'; function withDocument(meta: string | null, run: () => T): T { const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); @@ -67,3 +83,77 @@ describe('streaming-mode detection', () => { }); }); }); + +/** + * The `STREAMING_BOUNDARY_ACTIVATE` outcome contract. + * + * These codes are the wire between the producer (`template-element.ts`) and the + * consumer (`streaming-deferred.ts`). They used to be declared twice, so the + * two copies could drift silently, and the activation space overlapped the + * coordinator's own element-walk results — `ACTIVATION_ANCESTOR_BARRIER` and + * `ELEMENT_DEFERRED` were both `4`. Both spaces still travel through one + * `number`, so the disjointness below is what keeps them decodable. + */ +describe('activation outcome contract', () => { + const ACTIVATION_OUTCOMES: ReadonlyArray = [ + ['ACTIVATION_ACTIVATED', ACTIVATION_ACTIVATED], + ['ACTIVATION_STATIC_HOST_OPT_OUT', ACTIVATION_STATIC_HOST_OPT_OUT], + ['ACTIVATION_MISSING_TEMPLATE', ACTIVATION_MISSING_TEMPLATE], + ['ACTIVATION_ANCESTOR_BARRIER', ACTIVATION_ANCESTOR_BARRIER], + ]; + + const ELEMENT_RESULTS: ReadonlyArray = [ + ['ELEMENT_IGNORED', ELEMENT_IGNORED], + ['ELEMENT_DEFERRED', ELEMENT_DEFERRED], + ['ELEMENT_LIMIT_FAILURE', ELEMENT_LIMIT_FAILURE], + ['ELEMENT_ACTIVATED_FROM_PENDING', ELEMENT_ACTIVATED_FROM_PENDING], + ['ELEMENT_BARRIER_LIMIT_FAILURE', ELEMENT_BARRIER_LIMIT_FAILURE], + ['ELEMENT_INVALID_OUTCOME', ELEMENT_INVALID_OUTCOME], + ]; + + test('every activation outcome is a distinct non-zero integer', () => { + const seen = new Map(); + for (const [name, value] of ACTIVATION_OUTCOMES) { + assert.ok( + Number.isInteger(value) && value > 0, + `${name} must be a non-zero integer so a hook returning nothing is rejected`, + ); + const previous = seen.get(value); + assert.equal(previous, undefined, `${name} duplicates ${previous} (${value})`); + seen.set(value, name); + } + assert.equal(seen.size, ACTIVATION_OUTCOMES.length); + }); + + test('the activation space never collides with an element-walk result', () => { + const activationValues = new Set( + ACTIVATION_OUTCOMES.map(([, value]) => value), + ); + const collisions = ELEMENT_RESULTS + .filter(([, value]) => activationValues.has(value)) + .map(([name]) => name); + assert.deepEqual( + collisions, + [], + 'an element-walk result reusing an activation code makes the two indistinguishable', + ); + }); + + test('every element-walk result is itself distinct', () => { + const seen = new Map(); + for (const [name, value] of ELEMENT_RESULTS) { + const previous = seen.get(value); + assert.equal(previous, undefined, `${name} duplicates ${previous} (${value})`); + seen.set(value, name); + } + assert.equal(seen.size, ELEMENT_RESULTS.length); + }); + + test('an ancestor barrier stays distinguishable from a deferred element', () => { + assert.notEqual( + ACTIVATION_ANCESTOR_BARRIER, + ELEMENT_DEFERRED, + 'the coordinator branches on both from the same value; sharing 4 conflated them', + ); + }); +}); diff --git a/packages/webui-framework/src/streaming-mode.ts b/packages/webui-framework/src/streaming-mode.ts index f4ce86db8..6bd93d89d 100644 --- a/packages/webui-framework/src/streaming-mode.ts +++ b/packages/webui-framework/src/streaming-mode.ts @@ -11,11 +11,13 @@ * `streaming.ts` would close that cycle. * * It carries only what the always-shipped bundle genuinely needs: mode - * detection, the two shared hook symbols, and the single `data-ws` dormancy - * marker. Everything span-shaped — the `data-ws-span` / `data-ws-enclosing` - * attribute names and the open-span registry that resolves them — lives in the - * opt-in streaming graph (`streaming-dom.ts`, `streaming-spans.ts`), so a - * non-streaming app never downloads a byte of it. + * detection, the two shared hook symbols, the single `data-ws` dormancy + * marker, and the numeric activation-outcome codes both sides of the + * `STREAMING_BOUNDARY_ACTIVATE` contract speak. Everything span-shaped — the + * `data-ws-span` / `data-ws-enclosing` attribute names and the open-span + * registry that resolves them — lives in the opt-in streaming graph + * (`streaming-dom.ts`, `streaming-spans.ts`), so a non-streaming app never + * downloads a byte of it. */ let cached: boolean | undefined; @@ -31,6 +33,46 @@ export const PENDING_ROOT_CONNECTED = Symbol.for( /** Compiler-owned marker for an uncommitted streamed host. */ export const STREAMED_HOST_ATTR = 'data-ws'; +// ── Activation outcomes ───────────────────────────────────────── +// +// The producer (`template-element.ts`) and the consumer +// (`streaming-deferred.ts`) both import these, so the contract has exactly one +// definition and `ActivationOutcome` makes TypeScript reject a producer that +// invents a code the consumer cannot decode. +// +// Numeric codes rather than result objects: the coordinator classifies every +// marked root on the boundary walk, and a per-root object there would allocate +// once per streamed component. +// +// The range is `1..4` on purpose. The coordinator's own element-walk results +// (`ELEMENT_*` in `streaming-deferred.ts`) occupy a disjoint decade, so the two +// spaces can flow through one `number` without a code from either side ever +// being mistaken for the other. Codes are also non-zero so a hook that returns +// nothing is rejected rather than decoded. + +/** The root hydrated, or was already live and needed no work. */ +export const ACTIVATION_ACTIVATED = 1; +/** A compiler-owned static host declined activation on purpose. */ +export const ACTIVATION_STATIC_HOST_OPT_OUT = 2; +/** Template metadata was unavailable; the root cannot hydrate. */ +export const ACTIVATION_MISSING_TEMPLATE = 3; +/** An unfinished ancestor owns this root until its barrier lifts. */ +export const ACTIVATION_ANCESTOR_BARRIER = 4; + +/** + * Every code `STREAMING_BOUNDARY_ACTIVATE` is allowed to return. + * + * Typing the hook with this union is what enforces producer/consumer parity at + * compile time. It deliberately does not describe what a *foreign* element may + * hand back at runtime: the coordinator still validates the returned value and + * fails closed on anything outside this set. + */ +export type ActivationOutcome = + | typeof ACTIVATION_ACTIVATED + | typeof ACTIVATION_STATIC_HOST_OPT_OUT + | typeof ACTIVATION_MISSING_TEMPLATE + | typeof ACTIVATION_ANCESTOR_BARRIER; + /** * Whether this document was served in streaming-hydration mode. * diff --git a/packages/webui-framework/src/streaming-pipeline.test.ts b/packages/webui-framework/src/streaming-pipeline.test.ts index c1c7d86b5..9211d38f5 100644 --- a/packages/webui-framework/src/streaming-pipeline.test.ts +++ b/packages/webui-framework/src/streaming-pipeline.test.ts @@ -158,7 +158,7 @@ function element(tagName: string, spec: ElementSpec = {}): FakeElement { const outcome = spec.hook!(state, bypassAncestor); return typeof outcome === 'number' ? outcome - : spec.activationOutcome ?? 1; + : spec.activationOutcome ?? ACTIVATION_ACTIVATED; }; } if (spec.abandon) node[ABANDON] = spec.abandon; @@ -308,7 +308,7 @@ function defineTag( tag: string, supportsDetachedResume = true, hook: (state?: Record) => void = () => {}, - outcome = 1, + outcome: number = ACTIVATION_ACTIVATED, ): void { class DefinedElement {} if (supportsDetachedResume) { @@ -372,6 +372,19 @@ const { pendingBarrierRootCountForTests: __pendingBarrierRootCountForTests, } = await import('./streaming-coordinator.js'); +/** + * The shared activation-outcome contract (`streaming-mode.ts`). + * + * Fake hooks below return these instead of bare numbers so a renumbering + * cannot leave the tests asserting a code the coordinator no longer speaks. + */ +const { + ACTIVATION_ACTIVATED, + ACTIVATION_ANCESTOR_BARRIER, + ACTIVATION_MISSING_TEMPLATE, + ACTIVATION_STATIC_HOST_OPT_OUT, +} = await import('./streaming-mode.js'); + const { beginStreamingGate, __resetLifecycleForTests, @@ -409,6 +422,38 @@ function predefine(...tags: string[]): void { for (const tag of tags) definedTags.set(tag, class {}); } +/** + * Collect the coordinator's fatal-failure log instead of silencing it. + * + * A halt is only correct if it names the right defect, so these tests assert on + * the reason text rather than on the halt flag alone. + */ +function captureErrors(): { + readonly messages: string[]; + restore(): void; +} { + const messages: string[] = []; + const previous = console.error; + console.error = (...args: unknown[]): void => { + messages.push(args.map((arg) => String(arg)).join(' ')); + }; + return { + messages, + restore(): void { + console.error = previous; + }, + }; +} + +function assertLogged(messages: readonly string[], fragment: string): void { + assert.ok( + messages.some((message) => message.includes(fragment)), + `expected a failure mentioning "${fragment}", got: ${ + messages.join(' | ') || '(nothing logged)' + }`, + ); +} + /** Patches queued for late activation accumulate in a null-prototype object * (network-supplied keys must never reach `Object.prototype`), so copy them * into plain objects before strict deep-equality assertions. Keys are defined @@ -1295,7 +1340,7 @@ describe('streaming coordinator pipeline', () => { // and the immediate commit path already writes to it. const staticHost = element('static-panel', { hook() {}, - activationOutcome: 2, + activationOutcome: ACTIVATION_STATIC_HOST_OPT_OUT, setState(state) { updates.push(state); }, @@ -1711,9 +1756,9 @@ describe('streaming coordinator pipeline', () => { let outerActive = false; const inner = element('zero-inner-host', { hook() { - if (!outerActive) return 4; + if (!outerActive) return ACTIVATION_ANCESTOR_BARRIER; order.push('inner'); - return 1; + return ACTIVATION_ACTIVATED; }, }); const outer = element('zero-outer-host', { @@ -1837,9 +1882,9 @@ describe('streaming coordinator pipeline', () => { let child!: FakeElement; child = element('later-span-child', { hook(_state, bypassAncestor) { - if (!parentActive && bypassAncestor !== parent) return 4; + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; order.push('child'); - return 1; + return ACTIVATION_ACTIVATED; }, }); parent = element('later-span-parent', { @@ -1881,10 +1926,10 @@ describe('streaming coordinator pipeline', () => { let child!: FakeElement; child = element('early-child', { hook(state, bypassAncestor) { - if (!parentActive && bypassAncestor !== parent) return 4; + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; order.push('child'); childStates.push(state); - return 1; + return ACTIVATION_ACTIVATED; }, }); parent = element('spanning-parent', { @@ -1935,9 +1980,9 @@ describe('streaming coordinator pipeline', () => { let child!: FakeElement; child = element('mismatch-child', { hook(_state, bypassAncestor) { - if (!parentActive && bypassAncestor !== parent) return 4; + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; order.push('child'); - return 1; + return ACTIVATION_ACTIVATED; }, }); parent = element('mismatch-parent', { @@ -1951,9 +1996,9 @@ describe('streaming coordinator pipeline', () => { }); const unmarked = element('unmarked-child', { hook() { - if (!parentActive) return 4; + if (!parentActive) return ACTIVATION_ANCESTOR_BARRIER; order.push('unmarked'); - return 1; + return ACTIVATION_ACTIVATED; }, }); unmarked.setAttribute('data-ws', ''); @@ -1992,15 +2037,15 @@ describe('streaming coordinator pipeline', () => { const nested = element('barrier-nested-root', { hook() { order.push('nested'); - return 1; + return ACTIVATION_ACTIVATED; }, }); nested.setAttribute('data-ws', ''); const child = element('barrier-outer-root', { hook() { - if (!parentActive) return 4; + if (!parentActive) return ACTIVATION_ANCESTOR_BARRIER; order.push('child'); - return 1; + return ACTIVATION_ACTIVATED; }, children: [nested], }); @@ -2046,9 +2091,9 @@ describe('streaming coordinator pipeline', () => { let child!: FakeElement; child = element('updatable-early-child', { hook(state, bypassAncestor) { - if (!parentActive && bypassAncestor !== parent) return 4; + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; activations.push(state); - return 1; + return ACTIVATION_ACTIVATED; }, setState(state) { updates.push(state); @@ -2095,9 +2140,9 @@ describe('streaming coordinator pipeline', () => { child = element('late-early-child', { hook(state, bypassAncestor) { bypasses.push(bypassAncestor); - if (!parentActive && bypassAncestor !== parent) return 4; + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; childActivations.push(state); - return 1; + return ACTIVATION_ACTIVATED; }, setState(state) { childUpdates.push(state); @@ -2160,9 +2205,9 @@ describe('streaming coordinator pipeline', () => { let child!: FakeElement; child = element('light-span-child', { hook(_state, bypassAncestor) { - if (!parentActive && bypassAncestor !== parent) return 4; + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; order.push('child'); - return 1; + return ACTIVATION_ACTIVATED; }, }); parent = element('light-span-parent', { @@ -2195,17 +2240,17 @@ describe('streaming coordinator pipeline', () => { child = element('nested-span-child', { hook(_state, bypassAncestor) { const bypassesInner = bypassAncestor === inner; - if ((!innerActive && !bypassesInner) || !outerActive) return 4; + if ((!innerActive && !bypassesInner) || !outerActive) return ACTIVATION_ANCESTOR_BARRIER; order.push('child'); - return 1; + return ACTIVATION_ACTIVATED; }, }); inner = element('nested-inner-parent', { hook() { - if (!outerActive) return 4; + if (!outerActive) return ACTIVATION_ANCESTOR_BARRIER; innerActive = true; order.push('inner'); - return 1; + return ACTIVATION_ACTIVATED; }, }); const outer = element('nested-outer-parent', { @@ -2319,9 +2364,9 @@ describe('streaming coordinator pipeline', () => { let child!: FakeElement; child = element('shadow-span-child', { hook(_state, bypassAncestor) { - if (!parentActive && bypassAncestor !== parent) return 4; + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; order.push('child'); - return 1; + return ACTIVATION_ACTIVATED; }, }); parent = element('shadow-span-parent', { @@ -2352,8 +2397,8 @@ describe('streaming coordinator pipeline', () => { let child!: FakeElement; child = element('truncated-span-child', { hook(_state, bypassAncestor) { - if (!parentActive && bypassAncestor !== parent) return 4; - return 1; + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + return ACTIVATION_ACTIVATED; }, }); parent = element('truncated-span-parent', { @@ -3228,7 +3273,7 @@ describe('streaming coordinator pipeline', () => { test('an explicit missing-template activation outcome halts without completion', async () => { const root = element('my-missing-meta', { attrs: { 'data-ws': '' }, - activationOutcome: 3, + activationOutcome: ACTIVATION_MISSING_TEMPLATE, hook() {}, }); const b = buildBoundary(0, 0, [root], { state: {} }); @@ -3284,7 +3329,7 @@ describe('streaming coordinator pipeline', () => { let called = false; const host = element('my-optout', { attrs: { 'data-ws': '' }, - activationOutcome: 2, + activationOutcome: ACTIVATION_STATIC_HOST_OPT_OUT, hook() { called = true; // opts out: records the call but performs no activation }, @@ -3300,4 +3345,227 @@ describe('streaming coordinator pipeline', () => { assert.equal(hasWs(host), false, 'data-ws stripped from a committed opt-out root'); assert.equal(host.parentNode, b.root, 'the opt-out root is retained'); }); + + // ── Shared activation-outcome contract (`streaming-mode.ts`) ───────── + + test('every shared activation outcome drives its own coordinator behavior', async () => { + interface OutcomeCase { + readonly label: string; + readonly outcome: number; + readonly halts: boolean; + readonly keepsMarker: boolean; + readonly retainedBehindBarrier: number; + } + // One table so a new outcome cannot be added to `streaming-mode.ts` with + // only one of the two sides taught how to handle it. + const cases: readonly OutcomeCase[] = [ + { + label: 'activated', + outcome: ACTIVATION_ACTIVATED, + halts: false, + keepsMarker: false, + retainedBehindBarrier: 0, + }, + { + label: 'static-host opt-out', + outcome: ACTIVATION_STATIC_HOST_OPT_OUT, + halts: false, + keepsMarker: false, + retainedBehindBarrier: 0, + }, + { + label: 'missing template', + outcome: ACTIVATION_MISSING_TEMPLATE, + halts: true, + keepsMarker: false, + retainedBehindBarrier: 0, + }, + { + label: 'ancestor barrier', + outcome: ACTIVATION_ANCESTOR_BARRIER, + halts: false, + keepsMarker: true, + retainedBehindBarrier: 1, + }, + ]; + + for (const [index, spec] of cases.entries()) { + // A halting case would reject every later boundary, so each case gets the + // same fresh coordinator `beforeEach` hands the other tests. + __resetStreamingCoordinatorForTests(); + __resetLifecycleForTests(); + beginStreamingGate(); + + const tag = `outcome-case-${index}`; + let invoked = 0; + const root = element(tag, { + attrs: { 'data-ws': '' }, + activationOutcome: spec.outcome, + hook() { + invoked++; + }, + }); + const b = buildBoundary(0, 0, [root], { state: {} }); + predefine(tag); + + const previousError = console.error; + console.error = () => {}; + try { + enqueue(b.sentinel); + await flush(); + } finally { + console.error = previousError; + } + + assert.equal(invoked, 1, `${spec.label}: the hook runs exactly once`); + assert.equal( + __isHaltedForTests(), + spec.halts, + `${spec.label}: only an undecodable root may halt the stream`, + ); + assert.equal( + hasWs(root), + spec.keepsMarker, + `${spec.label}: a root still owned by a barrier keeps its marker`, + ); + assert.equal( + __pendingBarrierRootCountForTests(), + spec.retainedBehindBarrier, + `${spec.label}: only an ancestor barrier retains the root`, + ); + assert.equal( + __pendingTagWaiterCountForTests(), + 0, + `${spec.label}: an already-defined tag never opens a definition waiter`, + ); + } + }); + + test('an ancestor barrier is retained separately from a definition deferral', async () => { + // Both outcomes stop the walk from descending, and both once shared the + // value 4. The two retention sets are what tell them apart. + const barrier = element('shared-code-barrier', { + attrs: { 'data-ws': '' }, + activationOutcome: ACTIVATION_ANCESTOR_BARRIER, + hook() {}, + }); + const undefinedRoot = element('shared-code-undefined', { + attrs: { 'data-ws': '' }, + hook() {}, + }); + const b = buildBoundary(0, 0, [barrier, undefinedRoot], { state: {} }); + predefine('shared-code-barrier'); + + enqueue(b.sentinel); + await flush(); + + assert.equal(__isHaltedForTests(), false); + assert.equal( + __pendingBarrierRootCountForTests(), + 1, + 'the barrier root is held by the barrier set, not by a tag waiter', + ); + assert.equal( + __pendingTagWaiterCountForTests(), + 1, + 'the undefined root is held by a tag waiter, not by the barrier set', + ); + assert.equal(__pendingUndefinedRootCountForTests(), 1); + assert.equal(hasWs(barrier), true, 'a barrier still owns its root'); + assert.equal(hasWs(undefinedRoot), true, 'an undefined root stays dormant'); + }); + + test('an unrecognized activation outcome fails closed with its own reason', async () => { + const root = element('my-bogus-outcome', { + attrs: { 'data-ws': '' }, + activationOutcome: 99, + hook() {}, + }); + const b = buildBoundary(0, 0, [root], { state: {} }); + predefine('my-bogus-outcome'); + + const logged = captureErrors(); + try { + enqueue(b.sentinel); + await flush(); + } finally { + logged.restore(); + } + + assert.equal( + __isHaltedForTests(), + true, + 'an outcome the coordinator cannot decode must not be silently absorbed', + ); + assertLogged(logged.messages, 'unrecognized streaming activation outcome 99'); + assert.equal( + logged.messages.some((m) => m.includes('template metadata missing')), + false, + 'an unknown outcome must not masquerade as missing metadata', + ); + assert.equal(hasWs(root), false, 'fatal cleanup strips the marker'); + assert.equal(__getLifecycleStateForTests().completed, false); + }); + + test('an unrecognized outcome from a late definition resume fails closed', async () => { + const root = element('my-late-bogus', { + activationOutcome: 42, + hook() {}, + }); + const b = buildBoundary(0, 0, [root], { state: {} }); + + enqueue(b.sentinel); + await flush(); + assert.equal(__pendingTagWaiterCountForTests(), 1, 'the undefined tag defers first'); + assert.equal(__isHaltedForTests(), false); + + const logged = captureErrors(); + try { + defineTag('my-late-bogus'); + await flush(); + } finally { + logged.restore(); + } + + assert.equal(__isHaltedForTests(), true); + assertLogged(logged.messages, 'unrecognized streaming activation outcome 42'); + assert.equal(hasWs(root), false, 'the abandoned root loses its marker'); + assert.equal(__getLifecycleStateForTests().completed, false); + }); + + test('an unrecognized outcome from a barrier release fails closed', async () => { + let parentActive = false; + const child = element('barrier-bogus-child', { + hook() { + return parentActive ? 77 : ACTIVATION_ANCESTOR_BARRIER; + }, + }); + const parent = element('barrier-bogus-parent', { + hook() { + parentActive = true; + }, + }); + // `enclosingMarker: 1` mismatches span 0, so the child has no bypass and is + // genuinely retained until the span completes. + const scenario = buildSpanScenario(parent, child, { enclosingMarker: 1 }); + predefine('barrier-bogus-child', 'barrier-bogus-parent'); + + enqueue(scenario.boundarySentinel); + await flush(); + assert.equal(__pendingBarrierRootCountForTests(), 1); + assert.equal(__isHaltedForTests(), false); + + const logged = captureErrors(); + try { + enqueue(scenario.spanSentinel); + await flush(); + } finally { + logged.restore(); + } + + assert.equal(__isHaltedForTests(), true); + assertLogged(logged.messages, 'unrecognized streaming activation outcome 77'); + assert.equal(__pendingBarrierRootCountForTests(), 0, 'the retained root is released'); + assert.equal(hasStreamingAttrs(child), false, 'fatal cleanup strips its markers'); + }); }); diff --git a/packages/webui-framework/src/streaming-spans.ts b/packages/webui-framework/src/streaming-spans.ts index 30a8c4137..c95c71d55 100644 --- a/packages/webui-framework/src/streaming-spans.ts +++ b/packages/webui-framework/src/streaming-spans.ts @@ -10,8 +10,6 @@ import { } from './streaming-dom.js'; import type { HydrationRange } from './streaming-dom.js'; -/** Maximum unfinished component hosts retained by one response. */ -export const MAX_OPEN_SPANS = 128; /** Maximum runtime component ancestry crossed by one early boundary. */ export const MAX_SPAN_NESTING = 32; @@ -25,6 +23,15 @@ interface OpenSpan { openChildren: number; } +/** + * Unfinished component hosts, keyed by SpanInstanceId. + * + * This is the *current* ancestor chain, not a growing pool: a span is only ever + * registered as part of one boundary's spanning ancestry, and the server opens + * and closes spans as a strict stack, so an entry is dropped by `completeSpan` + * before any span outside that chain can register. `MAX_SPAN_NESTING` bounds + * the chain — and therefore this map — on the one walk that fills it. + */ const openSpans = new Map(); // Reused by the single-record pump while it validates one ancestor chain. const hostScratch: Element[] = []; @@ -149,9 +156,6 @@ function registerSpan( if (id !== nextExpectedSpanInstanceId) { return `expected span instance ${nextExpectedSpanInstanceId}, received ${id}`; } - if (openSpans.size >= MAX_OPEN_SPANS) { - return `open component span count exceeds ${MAX_OPEN_SPANS}`; - } const marker = host.previousSibling; if ( marker?.nodeType !== 8 /* COMMENT_NODE */ || diff --git a/packages/webui-framework/src/template-element.test.ts b/packages/webui-framework/src/template-element.test.ts index ce95773e3..5dc45f4eb 100644 --- a/packages/webui-framework/src/template-element.test.ts +++ b/packages/webui-framework/src/template-element.test.ts @@ -77,7 +77,13 @@ Object.defineProperty(globalThis, 'customElements', { const { TemplateElement } = await import('./template-element.js'); const { registerTemplateData } = await import('./template.js'); -const { resetStreamingModeForTests } = await import('./streaming-mode.js'); +const { + ACTIVATION_ACTIVATED, + ACTIVATION_ANCESTOR_BARRIER, + ACTIVATION_MISSING_TEMPLATE, + ACTIVATION_STATIC_HOST_OPT_OUT, + resetStreamingModeForTests, +} = await import('./streaming-mode.js'); const { beginStreamingGate, markBoundaryPending, @@ -231,7 +237,7 @@ describe('TemplateElement.connectedCallback — streamed-host (data-ws) deferral received = { status: 'ready' }; assert.equal( raw[STREAMING_BOUNDARY_ACTIVATE](received), - 1, + ACTIVATION_ACTIVATED, ); raw.removeAttribute('data-ws'); }; @@ -430,7 +436,7 @@ describe('TemplateElement — streamed-host activation ownership', () => { { child: true }, parent as unknown as Element, ), - 1, + ACTIVATION_ACTIVATED, ); assert.equal(childRaw.$deferredSSR, false); }); @@ -475,7 +481,7 @@ describe('TemplateElement — streamed-host activation ownership', () => { { child: true }, unrelated as unknown as Element, ), - 4, + ACTIVATION_ANCESTOR_BARRIER, ); assert.equal(childRaw.$deferredSSR, true); }); @@ -530,7 +536,7 @@ describe('TemplateElement — streamed-host activation ownership', () => { { child: true }, inner as unknown as Element, ), - 4, + ACTIVATION_ANCESTOR_BARRIER, ); assert.equal(childRaw.$deferredSSR, true); }); @@ -555,7 +561,7 @@ describe('TemplateElement — streamed-host activation ownership', () => { raw.setAttribute('data-ws', ''); el.connectedCallback(); - assert.equal(raw[STREAMING_BOUNDARY_ACTIVATE](), 3); + assert.equal(raw[STREAMING_BOUNDARY_ACTIVATE](), ACTIVATION_MISSING_TEMPLATE); assert.equal(raw.$deferredSSR, true); raw[STREAMING_BOUNDARY_ABANDON](); @@ -590,7 +596,7 @@ describe('TemplateElement — streamed-host activation ownership', () => { raw.setAttribute('data-ws', ''); el.connectedCallback(); - assert.equal(raw[STREAMING_BOUNDARY_ACTIVATE](), 2); + assert.equal(raw[STREAMING_BOUNDARY_ACTIVATE](), ACTIVATION_STATIC_HOST_OPT_OUT); assert.ok(raw.$meta, 'boundary commit caches metadata without mounting'); el.setState({ message: 'wake' }); assert.equal(activationMeta, raw.$meta, 'the later state write can activate from cached metadata'); diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index c58cedf47..8a215bb63 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -58,11 +58,16 @@ import type { } from './template.js'; import { hydrationStart, hydrationEnd } from './lifecycle.js'; import { + ACTIVATION_ACTIVATED, + ACTIVATION_ANCESTOR_BARRIER, + ACTIVATION_MISSING_TEMPLATE, + ACTIVATION_STATIC_HOST_OPT_OUT, isStreamingHydrationMode, PENDING_ROOT_CONNECTED, STREAMED_HOST_ATTR, STREAMING_BOUNDARY_ACTIVATE, } from './streaming-mode.js'; +import type { ActivationOutcome } from './streaming-mode.js'; import { createRepeatKeyState, seedHydratedRepeatKeys, @@ -186,10 +191,6 @@ const EMPTY_SET: Set = Object.freeze(new Set()) as Set; const WEBUI_SET_STATE_KEY = Symbol.for('microsoft.webui.setStateKey'); /** Branded fatal-stream cleanup hook, invoked before `data-ws` is removed. */ const STREAMING_BOUNDARY_ABANDON = Symbol.for('microsoft.webui.boundaryAbandon'); -const ACTIVATION_ACTIVATED = 1; -const ACTIVATION_STATIC_HOST_OPT_OUT = 2; -const ACTIVATION_MISSING_TEMPLATE = 3; -const ACTIVATION_ANCESTOR_BARRIER = 4; const templateMetaByCtor = new WeakMap(); const pendingAncestorDescendants = new WeakMap(); @@ -418,12 +419,16 @@ export class TemplateElement extends HTMLElement { /** * Internal hook invoked by the streaming coordinator (`streaming.ts`) once - * a boundary containing this element has committed. Returns a numeric outcome - * so the allocation-sensitive coordinator can distinguish activation, an - * intentional static-host opt-out, and missing metadata without per-root - * result objects. The optional `state` is this element's boundary-local SSR - * state, handed straight through to hydration instead of via the global - * `window.__webui.state` handoff. + * a boundary containing this element has committed. Returns one of the + * shared `ActivationOutcome` codes (`streaming-mode.ts`) so the + * allocation-sensitive coordinator can distinguish activation, an + * intentional static-host opt-out, missing metadata, and an unfinished + * ancestor without per-root result objects. The union is the contract: a + * code this method invents but the coordinator cannot decode is a compile + * error here, and a runtime one on the coordinator side. The optional + * `state` is this element's boundary-local SSR state, handed straight + * through to hydration instead of via the global `window.__webui.state` + * handoff. * * `bypassAncestor`, when supplied, is one already-resolved ancestor element * this root may skip exactly once while looking for its hydration barrier. @@ -435,7 +440,7 @@ export class TemplateElement extends HTMLElement { [STREAMING_BOUNDARY_ACTIVATE]( state?: Record, bypassAncestor?: Element, - ): number { + ): ActivationOutcome { // `customElements.upgrade()` installs this class on detached roots without // invoking connectedCallback(). Preserve the same marker-driven dormant // state those roots would have entered while connected before activation.