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 86bbd7e61..6c1d18860 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,30 @@ 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 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, +} + /// The fragment oneof variants. pub enum Fragment { Raw(WebUIFragmentRaw), @@ -130,6 +154,7 @@ pub enum Fragment { Plugin(WebUIFragmentPlugin), Route(WebUIFragmentRoute), Outlet(WebUIFragmentOutlet), + Boundary(WebUIFragmentBoundary), } ``` ### Fragment Types @@ -147,6 +172,30 @@ 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. 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 (``, ``, +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 +974,108 @@ 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 advance(&mut self) -> Result; + pub fn update( + &mut self, + instance_id: BoundaryInstanceId, + patch: &Value, + ) -> Result<()>; + pub fn is_done(&self) -> bool; +} + +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 advance(&mut self) -> Result; + pub fn update( + &mut self, + instance_id: BoundaryInstanceId, + patch: &Value, + ) -> Result>; + pub fn is_done(&self) -> bool; +} +``` + +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, 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. 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 For HTML that must be spliced at the structural `` or `` @@ -2028,568 +2179,291 @@ 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 any - `.define(tag)` request whose compiled template metadata has not yet - registered waits until it arrives — not only during streaming. Ordinary - (non-streaming) WebUI Router partial navigation can eagerly import an - authored nested component whose `define(tag)` call runs before the - router registers that route's metadata; deferring there too keeps - `observedAttributes` complete instead of permanently missing - template-only attributes. 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], @@ -679,7 +711,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<'_> { @@ -694,15 +726,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)?; } } @@ -711,11 +743,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)?; } } @@ -790,8 +818,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("{}"); @@ -802,12 +830,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); } @@ -825,8 +855,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)] @@ -850,16 +951,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. @@ -935,6 +1052,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 +1327,7 @@ impl WebUIHandler { Some(Fragment::Outlet(_)) => { self.process_outlet(context)?; } + Some(Fragment::Boundary(_)) => {} None => {} } } @@ -1274,6 +1410,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 +1555,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 +1587,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 +2041,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 +2384,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 +2442,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Hello, WebUI!")], + contains_boundary: false, }, ); @@ -2338,6 +2481,7 @@ mod tests { WebUIFragment::signal("name", false), WebUIFragment::raw("!"), ], + contains_boundary: false, }, ); @@ -2375,6 +2519,7 @@ mod tests { WebUIFragment::raw("People: "), WebUIFragment::for_loop("person", "people", "person-item"), ], + contains_boundary: false, }, ); @@ -2385,6 +2530,7 @@ mod tests { WebUIFragment::signal("person.name", false), WebUIFragment::raw(", "), ], + contains_boundary: false, }, ); @@ -2432,6 +2578,7 @@ mod tests { ), WebUIFragment::raw("End"), ], + contains_boundary: false, }, ); @@ -2439,6 +2586,7 @@ mod tests { "active-content".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Active")], + contains_boundary: false, }, ); @@ -2518,6 +2666,7 @@ mod tests { WebUIFragment::raw("Component: "), WebUIFragment::component("my-component"), ], + contains_boundary: false, }, ); @@ -2525,6 +2674,7 @@ mod tests { "my-component".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
Component Content
")], + contains_boundary: false, }, ); @@ -2562,6 +2712,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::component("missing-component")], + contains_boundary: false, }, ); @@ -2600,6 +2751,7 @@ mod tests { WebUIFragment::signal("missing_field", false), WebUIFragment::raw("!"), ], + contains_boundary: false, }, ); @@ -2639,6 +2791,7 @@ mod tests { ), WebUIFragment::raw(">Click"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2668,6 +2821,7 @@ mod tests { ), WebUIFragment::raw(">Click"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2697,6 +2851,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2730,6 +2885,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2758,6 +2914,7 @@ mod tests { WebUIFragment::attribute("value", "inputValue"), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2784,6 +2941,7 @@ mod tests { WebUIFragment::attribute("handle", "number"), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2815,6 +2973,7 @@ mod tests { WebUIFragment::attribute("href", "value"), WebUIFragment::raw(">demo"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2854,6 +3013,7 @@ mod tests { WebUIFragment::attribute("data-cfg", "cfg"), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2891,6 +3051,7 @@ mod tests { WebUIFragment::attribute_template("value", "attr-1"), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); fragments.insert( @@ -2900,6 +3061,7 @@ mod tests { WebUIFragment::raw("hello "), WebUIFragment::signal("item", false), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2927,6 +3089,7 @@ mod tests { WebUIFragment::signal("html", false), WebUIFragment::signal("html", true), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -2958,6 +3121,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outer"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -2968,12 +3132,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 +3174,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -3018,6 +3185,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -3028,6 +3196,7 @@ mod tests { WebUIFragment::signal("innerItem.name", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3062,6 +3231,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -3073,6 +3243,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -3084,6 +3255,7 @@ mod tests { WebUIFragment::signal("globalInner", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3118,6 +3290,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")], + contains_boundary: false, }, ); fragments.insert( @@ -3127,12 +3300,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 +3330,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")], + contains_boundary: false, }, ); fragments.insert( @@ -3164,12 +3340,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 +3389,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3221,6 +3401,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3261,6 +3442,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3270,6 +3453,7 @@ mod tests { WebUIFragment::raw("hello "), WebUIFragment::signal("item", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -3280,6 +3464,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3320,6 +3505,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3329,6 +3516,7 @@ mod tests { WebUIFragment::raw("prefix "), WebUIFragment::signal("item", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -3339,6 +3527,7 @@ mod tests { WebUIFragment::signal("dataTitle", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3380,6 +3569,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3392,6 +3583,7 @@ mod tests { WebUIFragment::signal("item.bar", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3432,6 +3624,8 @@ mod tests { WebUIFragment::component("parent"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3456,9 +3650,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 +3699,8 @@ mod tests { WebUIFragment::component("my-comp"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3506,12 +3710,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 +3745,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::signal("v", false)], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3656,6 +3863,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3684,6 +3892,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3712,6 +3921,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3741,6 +3951,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3773,6 +3984,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3801,6 +4013,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3829,6 +4042,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3857,6 +4071,7 @@ mod tests { ), WebUIFragment::raw(">"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3887,6 +4102,7 @@ mod tests { ), WebUIFragment::raw(">Click"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3916,6 +4132,7 @@ mod tests { ), WebUIFragment::raw(">Click"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -3955,6 +4172,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3964,6 +4183,7 @@ mod tests { WebUIFragment::raw("Hello "), WebUIFragment::signal("who", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -3985,6 +4205,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -3994,6 +4216,7 @@ mod tests { WebUIFragment::raw("Child of "), WebUIFragment::signal("title", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -4004,6 +4227,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4044,12 +4268,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 +4298,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4082,6 +4311,7 @@ mod tests { WebUIFragment::raw(")-"), WebUIFragment::signal("cExtra", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -4103,6 +4333,8 @@ mod tests { WebUIFragment::component("grandchild-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4113,6 +4345,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4153,6 +4386,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4162,12 +4397,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 +4426,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4200,6 +4439,7 @@ mod tests { WebUIFragment::raw(" / "), WebUIFragment::signal("title", false), ], + contains_boundary: false, }, ); fragments.insert( @@ -4210,6 +4450,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4270,24 +4511,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 +4548,7 @@ mod tests { WebUIFragment::signal("ariaLabel", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4343,6 +4590,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4353,6 +4602,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4378,6 +4628,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "loop")], + contains_boundary: false, }, ); fragments.insert( @@ -4400,6 +4651,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4410,6 +4663,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4461,6 +4715,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4475,12 +4731,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 +4780,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4532,6 +4792,7 @@ mod tests { WebUIFragment::signal("keyHyphen", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4631,6 +4892,8 @@ mod tests { WebUIFragment::component("test-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4651,6 +4914,7 @@ mod tests { WebUIFragment::signal("ariaLabel", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4704,6 +4968,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4727,6 +4993,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4737,6 +5005,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4778,6 +5047,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4800,6 +5071,8 @@ mod tests { WebUIFragment::component("child-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4821,6 +5094,8 @@ mod tests { WebUIFragment::component("grandchild-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4831,6 +5106,7 @@ mod tests { WebUIFragment::signal("title", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4872,6 +5148,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4884,6 +5162,7 @@ mod tests { WebUIFragment::signal("item.bar", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4913,6 +5192,7 @@ mod tests { "list.items", "listTemplate", )], + contains_boundary: false, }, ); fragments.insert( @@ -4932,6 +5212,8 @@ mod tests { }, WebUIFragment::component("item_component"), ], + + contains_boundary: false, }, ); fragments.insert( @@ -4942,6 +5224,7 @@ mod tests { WebUIFragment::signal("item.name", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -4968,6 +5251,7 @@ mod tests { "data.outer", "outerTemplate", )], + contains_boundary: false, }, ); fragments.insert( @@ -4978,6 +5262,7 @@ mod tests { "outer.middle", "middleTemplate", )], + contains_boundary: false, }, ); fragments.insert( @@ -4988,6 +5273,7 @@ mod tests { "middle.inner", "innerTemplate", )], + contains_boundary: false, }, ); fragments.insert( @@ -5032,6 +5318,8 @@ mod tests { WebUIFragment::component("card_component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -5046,6 +5334,7 @@ mod tests { WebUIFragment::signal("inner.label", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5091,6 +5380,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -5106,18 +5397,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 +5452,8 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -5173,18 +5469,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 +5524,8 @@ mod tests { WebUIFragment::component("parent-component"), WebUIFragment::raw(""), ], + + contains_boundary: false, }, ); fragments.insert( @@ -5250,12 +5551,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 +5575,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 +5647,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 +5685,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 +5723,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 +5741,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 +5761,7 @@ mod tests { WebUIFragment::component("custom-button"), WebUIFragment::raw("Ok"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5481,12 +5797,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 +5845,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "template1"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5537,6 +5856,7 @@ mod tests { WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5547,6 +5867,7 @@ mod tests { WebUIFragment::signal("item.label", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5583,6 +5904,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "template1"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5593,6 +5915,7 @@ mod tests { WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5603,6 +5926,7 @@ mod tests { WebUIFragment::signal("item.label", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5637,6 +5961,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "static")], + contains_boundary: false, }, ); fragments.insert( @@ -5653,6 +5978,7 @@ mod tests { WebUIFragment::for_loop("item", "item.children", "static"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5694,6 +6020,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "templateComponent"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5704,6 +6031,7 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5714,6 +6042,7 @@ mod tests { WebUIFragment::signal("name", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5743,6 +6072,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5755,6 +6085,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5768,6 +6099,7 @@ mod tests { WebUIFragment::signal("innerItem.innerLabel", false), WebUIFragment::raw("

"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5803,6 +6135,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "templateComponent"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5813,6 +6146,7 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5825,6 +6159,7 @@ mod tests { WebUIFragment::signal("globalSuffix", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5855,6 +6190,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "templateComponent"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5865,6 +6201,7 @@ mod tests { WebUIFragment::component("my-component"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5877,6 +6214,7 @@ mod tests { WebUIFragment::signal("globalSuffix", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5907,6 +6245,7 @@ mod tests { WebUIFragment::for_loop("item", "items", "template1"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5917,6 +6256,7 @@ mod tests { WebUIFragment::signal("name", false), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -5946,6 +6286,7 @@ mod tests { WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5961,6 +6302,7 @@ mod tests { ), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5971,6 +6313,7 @@ mod tests { WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( @@ -5983,6 +6326,7 @@ mod tests { WebUIFragment::signal("innerItem.innerLabel", false), WebUIFragment::raw("

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

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

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

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

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

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

Dashboard

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

Detail

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

Shell

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

Section

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

Topic content

")], + contains_boundary: false, }, ); @@ -6989,12 +7367,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 +7434,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 +7481,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 +7534,7 @@ mod tests { WebUIFragment::component("my-card"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( @@ -7159,7 +7544,8 @@ mod tests { "" .to_string(), )], - }, + contains_boundary: false, +}, ); let protocol = WebUIProtocol::new(fragments); @@ -7207,12 +7593,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 +7668,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 +7758,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); @@ -7407,6 +7799,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -7448,18 +7841,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 +7923,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 +7989,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 +8047,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 +8124,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); // app-shell contains a cart panel @@ -7731,6 +8135,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 +8147,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 +8155,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 +8290,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 +8326,7 @@ mod tests { structural_fragment("head_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, )]); let mut protocol = WebUIProtocol::new(fragments); @@ -7953,6 +8363,7 @@ mod tests { structural_fragment("head_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, )]); let mut protocol = WebUIProtocol::new(fragments); @@ -8020,12 +8431,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 +8489,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); fragments.insert( @@ -8085,18 +8499,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 +8564,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 +8631,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -8247,12 +8667,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 +8718,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 +8762,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); @@ -8388,7 +8814,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") } } @@ -8419,6 +8845,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let mut protocol = WebUIProtocol::new(fragments); @@ -8456,12 +8883,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 +9075,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 +9212,8 @@ mod tests { keep_alive: false, ..Default::default() })], + + contains_boundary: false, }, ); @@ -8786,18 +9221,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 +9334,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); WebUIProtocol::new(fragments) @@ -8984,6 +9423,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); WebUIProtocol::new(fragments) @@ -9147,12 +9587,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); @@ -9217,12 +9659,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(); @@ -9230,7 +9698,7 @@ mod tests { &mut sink, &mut scratch, &state, - &StateSelection::BorrowedKeys(&keys), + &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }), ) .unwrap(); @@ -9258,6 +9726,7 @@ mod tests { structural_fragment("body_start"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9341,6 +9810,7 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9415,6 +9885,7 @@ mod tests { structural_fragment("body_end"), // duplicate WebUIFragment::raw("".to_string()), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9449,6 +9920,7 @@ mod tests { fragments: vec![WebUIFragment::raw( "hi".to_string(), )], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9650,6 +10122,7 @@ mod tests { WebUIFragment::for_loop("item", "outer", "outer_body"), WebUIFragment::raw("]"), ], + contains_boundary: false, }, ); fragments.insert( @@ -9663,6 +10136,7 @@ mod tests { WebUIFragment::signal("item.tag", false), WebUIFragment::raw(")"), ], + contains_boundary: false, }, ); fragments.insert( @@ -9673,6 +10147,7 @@ mod tests { WebUIFragment::signal("item.tag", false), WebUIFragment::raw("]"), ], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -9705,2060 +10180,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 { + + 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); + } + // 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, - keys: if requires_full_state { - Vec::new() - } else { - state_key_scratch.iter().copied().map(Box::from).collect() - }, + 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); } - 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(plan); } + finish_capture( + context, + CapturedBuffers { + checkpoint_tags, + template_tags: new_template_tags, + state_key_ids, + css_hrefs, + style_specs, + }, + ); + streaming_state(context)?.bootstrap_sent = true; Ok(()) } @@ -280,21 +290,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) } @@ -307,44 +307,145 @@ 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_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) } } + +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 resume_writes_only_the_committed_boundary_and_advance_writes_the_tail() { + let protocol = parsed_protocol( + &document(concat!( + r#"

results

"#, + "
tail
", + )), + &[], + ); + let mut session = new_session(protocol, "/"); + + let start = session.start(&test_json!({})).unwrap(); + let boundary = start.boundary.unwrap(); + assert_eq!(boundary.name.as_ref(), "search"); + + let committed = session + .resume(boundary.instance_id, &test_json!({}), BoundaryMode::Final) + .unwrap(); + assert!(!committed.done && committed.boundary.is_none()); + let bytes = String::from_utf8(committed.bytes).unwrap(); + // Exact segment: the occurrence markers, its body, its record, and nothing + // past the record's hydration sentinel. + assert!(bytes.starts_with("

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 multiple_boundaries_alternate_descriptor_commit_and_advance() { + let protocol = parsed_protocol( + &document(concat!( + r#"

1

"#, + "
", + r#"

2

"#, + "
tail
", + )), + &[], + ); + let mut session = new_session(protocol, "/"); + + let start = session.start(&test_json!({})).unwrap(); + let first = start.boundary.clone().unwrap(); + assert_eq!(first.instance_id.raw(), 0); + + let committed = session + .resume(first.instance_id, &test_json!({}), BoundaryMode::Final) + .unwrap(); + assert!(committed.boundary.is_none() && !committed.done); + + let between = session.advance().unwrap(); + let second = between.boundary.clone().unwrap(); + assert_eq!(second.instance_id.raw(), 1); + let between = String::from_utf8(between.bytes).unwrap(); + assert!(between.starts_with("
"), "{between}"); + assert!(!between.contains("")); + + let committed = session + .resume(second.instance_id, &test_json!({}), BoundaryMode::Final) + .unwrap(); + assert!(committed.boundary.is_none() && !committed.done); + assert!(String::from_utf8(committed.bytes) + .unwrap() + .contains("

2

")); + + let end = session.advance().unwrap(); + assert!(end.done && end.boundary.is_none()); + assert!(String::from_utf8(end.bytes) + .unwrap() + .contains("
tail
")); + assert!(session.is_done()); +} + +#[test] +fn out_of_order_steps_are_rejected_without_poisoning() { + let protocol = parsed_protocol( + &document(concat!( + r#"

1

"#, + r#"

2

"#, + )), + &[], + ); + let mut session = new_session(protocol, "/"); + + // advance before any commit is an ordering error, and writes nothing. + assert!(session + .advance() + .unwrap_err() + .to_string() + .contains("start must be called before this operation")); + + let first = session.start(&test_json!({})).unwrap().boundary.unwrap(); + assert!(session + .advance() + .unwrap_err() + .to_string() + .contains("no committed boundary to advance past")); + + let committed = session + .resume(first.instance_id, &test_json!({}), BoundaryMode::Final) + .unwrap(); + assert!(committed.boundary.is_none()); + + // Resuming again before advancing is rejected, and the response stays + // usable: the very next advance still produces the second descriptor. + assert!(session + .resume(first.instance_id, &test_json!({}), BoundaryMode::Final) + .unwrap_err() + .to_string() + .contains("has not been advanced past")); + let second = session.advance().unwrap().boundary.unwrap(); + assert_eq!(second.instance_id.raw(), 1); + assert!(session + .resume(second.instance_id, &test_json!({}), BoundaryMode::Final) + .unwrap() + .boundary + .is_none()); + assert!(session.advance().unwrap().done); + assert!(session + .advance() + .unwrap_err() + .to_string() + .contains("already completed")); +} + +#[test] +fn boundary_in_repeat_is_rejected_at_build_time() { + let mut parser = HtmlParser::new(); + let error = parser + .parse( + "index.html", + &document( + r#"

{{item.label}}

"#, + ), + ) + .expect_err("a boundary inside a repeat must be rejected before render"); + assert!( + error.to_string().contains(""), + "the diagnostic must name the repeat: {error}" + ); +} + +#[test] +fn boundary_free_repeat_renders_atomically_inside_one_boundary() { + // The whole finite list is one atomic checkpoint: it renders in the commit + // step and leaves no resumable repeat state behind. + let protocol = parsed_protocol( + &document(concat!( + r#"

{{item.label}}

"#, + "
tail
", + )), + &[], + ); + let mut session = new_session(protocol, "/"); + let state = test_json!({ "items": [{ "label": "a" }, { "label": "b" }, { "label": "c" }] }); + + let boundary = session.start(&state).unwrap().boundary.unwrap(); + let committed = session + .resume(boundary.instance_id, &state, BoundaryMode::Final) + .unwrap(); + assert!(committed.boundary.is_none() && !committed.done); + let html = String::from_utf8(committed.bytes).unwrap(); + assert!(html.contains("

a

b

c

"), "{html}"); + assert!(!html.contains("
tail
")); + + let end = session.advance().unwrap(); + assert!(end.done); + assert!(String::from_utf8(end.bytes) + .unwrap() + .contains("
tail
")); +} + +#[test] +fn nested_repeats_and_components_inside_a_boundary_keep_loop_locals_and_capture() { + // Nested repeats exercise the frame-driven walk (no recursion) and a + // component inside a repeat body must still land in the enclosing + // checkpoint's capture, so its template arrives with that record. + let protocol = parsed_protocol( + &document( + r#"
{{row.name}}
"#, + ), + &[("cell-box", "{{owner}}:{{label}}")], + ); + let mut session = new_session(protocol, "/"); + let state = test_json!({ + "rows": [ + { "name": "r1", "cells": ["a", "b"] }, + { "name": "r2", "cells": ["c"] } + ] + }); + + let boundary = session.start(&state).unwrap().boundary.unwrap(); + let html = String::from_utf8( + session + .resume(boundary.instance_id, &state, BoundaryMode::Final) + .unwrap() + .bytes, + ) + .unwrap(); + // The outer loop local stays visible inside the inner loop body, and the + // inner local does not leak between rows. + assert!(html.contains("r1:a"), "{html}"); + assert!(html.contains("r1:b"), "{html}"); + assert!(html.contains("r2:c"), "{html}"); + assert_eq!(html.matches("").count(), 3); + // The repeated component is captured by the enclosing checkpoint, so its + // inventory bit rides that record. + assert!(html.contains(r#""inventory":"01""#), "{html}"); + assert!(session.advance().unwrap().done); +} + +#[test] +fn keyed_component_boundary_returns_gapless_descriptors_and_rejects_duplicates() { + // `` can no longer repeat a declaration, but two static callsites of a + // boundary-bearing component still can, so keys stay live. + let protocol = parsed_protocol( + &document( + r#""#, + ), + &[( + "row-item", + r#"

{{rowId}}

"#, + )], + ); + let mut session = new_session(Arc::clone(&protocol), "/"); + let state = test_json!({ "first": 10, "second": 20 }); + + let first = session.start(&state).unwrap().boundary.unwrap(); + assert_eq!(first.instance_id.raw(), 0); + assert_eq!(first.key, Some(BoundaryKey::Number(10.into()))); + assert!(session + .resume(first.instance_id, &state, BoundaryMode::Final) + .unwrap() + .boundary + .is_none()); + + let second = session.advance().unwrap().boundary.unwrap(); + assert_eq!(second.instance_id.raw(), 1); + assert_eq!(second.key, Some(BoundaryKey::Number(20.into()))); + assert!(session + .resume(second.instance_id, &state, BoundaryMode::Final) + .unwrap() + .boundary + .is_none()); + assert!(session.advance().unwrap().done); + + let mut duplicate = new_session(protocol, "/"); + let duplicate_state = test_json!({ "first": 7, "second": 7 }); + let first = duplicate.start(&duplicate_state).unwrap().boundary.unwrap(); + assert!(duplicate + .resume(first.instance_id, &duplicate_state, BoundaryMode::Final) + .unwrap() + .boundary + .is_none()); + let error = duplicate.advance().unwrap_err(); + assert!(error.to_string().contains("duplicate key")); + assert!(duplicate + .advance() + .unwrap_err() + .to_string() + .contains("poisoned")); +} + +#[test] +fn resume_overlays_boundary_state_on_frozen_parent_and_lexical_locals() { + let protocol = parsed_protocol( + &document(r#""#), + &[( + "row-item", + r#"

{{label}}/{{global}}

"#, + )], + ); + let mut session = new_session(protocol, "/"); + let first = session + .start(&test_json!({ "global": "frozen" })) + .unwrap() + .boundary + .unwrap(); + let committed = session + .resume( + first.instance_id, + &test_json!({ "global": "boundary" }), + BoundaryMode::Final, + ) + .unwrap(); + assert!(String::from_utf8(committed.bytes) + .unwrap() + .contains("

local/boundary

")); + assert!(session.advance().unwrap().done); +} + +#[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#""#, + 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/crates/webui-parser/src/route_parser.rs b/crates/webui-parser/src/route_parser.rs index e9c10504e..77d370561 100644 --- a/crates/webui-parser/src/route_parser.rs +++ b/crates/webui-parser/src/route_parser.rs @@ -218,6 +218,7 @@ pub(crate) fn build_route_fragment( invalidates: attrs.invalidates.clone(), pending_component: attrs.pending_component.clone(), error_component: attrs.error_component.clone(), + content_fragment_id: String::new(), } } diff --git a/crates/webui-protocol/benches/protocol_bench.rs b/crates/webui-protocol/benches/protocol_bench.rs index 5d1c85e46..247c5600e 100644 --- a/crates/webui-protocol/benches/protocol_bench.rs +++ b/crates/webui-protocol/benches/protocol_bench.rs @@ -14,6 +14,7 @@ fn create_test_protocol() -> WebUIProtocol { fragments.insert( "index.html".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("Hello, WebUI!\n"), WebUIFragment::for_loop("person", "people", "for-1"), @@ -26,6 +27,7 @@ fn create_test_protocol() -> WebUIProtocol { fragments.insert( "for-1".to_string(), FragmentList { + contains_boundary: false, fragments: vec![WebUIFragment::signal("person.name", false)], }, ); @@ -33,6 +35,7 @@ fn create_test_protocol() -> WebUIProtocol { fragments.insert( "if-1".to_string(), FragmentList { + contains_boundary: false, fragments: vec![WebUIFragment::component("contact-card")], }, ); @@ -40,6 +43,7 @@ fn create_test_protocol() -> WebUIProtocol { fragments.insert( "contact-card".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("Hello, "), WebUIFragment::signal("name", false), @@ -56,6 +60,7 @@ fn create_simple_protocol() -> WebUIProtocol { fragments.insert( "index.html".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("Hello, WebUI!\n"), WebUIFragment::for_loop("person", "people", "for-1"), @@ -66,6 +71,7 @@ fn create_simple_protocol() -> WebUIProtocol { fragments.insert( "for-1".to_string(), FragmentList { + contains_boundary: false, fragments: vec![WebUIFragment::signal("person.name", false)], }, ); @@ -105,12 +111,14 @@ fn complex_condition_benchmark(c: &mut Criterion) { fragments.insert( "main".to_string(), FragmentList { + contains_boundary: false, fragments: vec![WebUIFragment::if_cond(nested, "then")], }, ); fragments.insert( "then".to_string(), FragmentList { + contains_boundary: false, fragments: vec![WebUIFragment::raw("ok")], }, ); @@ -129,6 +137,7 @@ fn create_medium_protocol() -> WebUIProtocol { fragments.insert( "index.html".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw(""), WebUIFragment::signal("title", false), @@ -143,6 +152,7 @@ fn create_medium_protocol() -> WebUIProtocol { fragments.insert( "app".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("<div class=\"app\"><header><h1>"), WebUIFragment::signal("title", false), @@ -161,6 +171,7 @@ fn create_medium_protocol() -> WebUIProtocol { fragments.insert( "item-frag".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("<li"), WebUIFragment::attribute("data-id", "item.id"), @@ -182,6 +193,7 @@ fn create_medium_protocol() -> WebUIProtocol { fragments.insert( "item-class-tmpl".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("todo-item "), WebUIFragment::signal("item.state", false), @@ -193,6 +205,7 @@ fn create_medium_protocol() -> WebUIProtocol { fragments.insert( "done-badge".to_string(), FragmentList { + contains_boundary: false, fragments: vec![WebUIFragment::raw("<span class=\"badge done\">✓</span>")], }, ); @@ -201,6 +214,7 @@ fn create_medium_protocol() -> WebUIProtocol { fragments.insert( "footer-frag".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("<footer><p>"), WebUIFragment::signal("footerText", false), @@ -233,6 +247,7 @@ fn create_large_protocol(component_count: usize) -> WebUIProtocol { fragments.insert( "index.html".to_string(), FragmentList { + contains_boundary: false, fragments: root_frags, }, ); @@ -241,6 +256,7 @@ fn create_large_protocol(component_count: usize) -> WebUIProtocol { fragments.insert( "nav-link-frag".to_string(), FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("<a"), WebUIFragment::attribute("href", "link.url"), @@ -264,6 +280,7 @@ fn create_large_protocol(component_count: usize) -> WebUIProtocol { fragments.insert( panel_id, FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw(format!("<section class=\"panel\" data-idx=\"{idx}\">")), WebUIFragment::raw("<h3>"), @@ -279,6 +296,7 @@ fn create_large_protocol(component_count: usize) -> WebUIProtocol { fragments.insert( body_id, FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("<div class=\"panel-body\"><p>"), WebUIFragment::signal("description", false), @@ -292,6 +310,7 @@ fn create_large_protocol(component_count: usize) -> WebUIProtocol { fragments.insert( cond_id, FragmentList { + contains_boundary: false, fragments: vec![ WebUIFragment::raw("<details><summary>More</summary><p>"), WebUIFragment::signal("details", false), diff --git a/crates/webui-protocol/proto/webui.proto b/crates/webui-protocol/proto/webui.proto index 49ec2ddbd..946d32ed7 100644 --- a/crates/webui-protocol/proto/webui.proto +++ b/crates/webui-protocol/proto/webui.proto @@ -125,10 +125,6 @@ message WebUIProtocol { // The compiler resolves and sorts these at build time so the handler writes // them verbatim with no per-request work. repeated string module_preloads = 7; - // Free-form <boundary name> values keyed by entry fragment and stored in - // declaration order. Hosts resolve a name once to its zero-based BoundaryId; - // names never reach the rendered HTML response. - map<string, StreamingBoundaryList> streaming_boundaries = 8; // Build-generated document-level rules for component rendering policies. // Empty when no component opts into lazy rendering. string component_render_css = 9; @@ -139,14 +135,13 @@ message WebUIProtocol { repeated ComponentAssetStylePreload component_asset_style_preloads = 10; } -// Ordered compile-time boundary names for one entry fragment. -message StreamingBoundaryList { - repeated string names = 1; -} - // A list of fragments (needed because protobuf maps cannot have repeated values directly). message FragmentList { repeated WebUIFragment fragments = 1; + // True when this record directly or transitively reaches a boundary + // declaration. Computed once at build time so handlers never graph-walk a + // request merely to decide whether rendering can suspend. + bool contains_boundary = 2; } // A single fragment — one of several types. @@ -161,9 +156,51 @@ message WebUIFragment { WebUIFragmentPlugin plugin = 7; WebUIFragmentRoute route = 8; WebUIFragmentOutlet outlet = 9; + WebUIFragmentBoundary boundary = 10; } } +// Which end of an inline boundary tape a fragment marks. +enum BoundaryPhase { + // Opens a boundary body. Carries the full declaration metadata. + BOUNDARY_PHASE_START = 0; + // Closes the body opened by the matching start in the same record. Only + // declaration_id is meaningful. + BOUNDARY_PHASE_END = 1; +} + +// Compile-time streaming boundary declaration, written as an inline tape. +// +// A declaration emits a start marker and an end marker into its owner's +// fragment record, with the body fragments in between. Ordinary rendering +// therefore walks the body without any extra record lookup, and streaming +// suspends and resumes inside the record it is already traversing. +// +// A declaration can produce multiple response-local occurrences through +// components, conditions, loops, and routes. Runtime occurrence IDs are +// therefore assigned later by the handler and are not stored here. +message WebUIFragmentBoundary { + // Stable build-local declaration identity. Shared by the start/end pair. + uint32 declaration_id = 1; + // Entry or reusable component template that authored this declaration. + string owner_fragment_id = 2; + // Free-form authored name, unique only within owner_fragment_id. + string name = 3; + // Optional authored key expression preserved verbatim for handler evaluation. + optional string key = 4; + // Field 5 previously held the separately parsed boundary body record. Bodies + // are now inline between the start and end markers. + reserved 5; + reserved "fragment_id"; + // Conservative graph result: this declaration may produce multiple runtime + // occurrences through repeated component callsites. A `<for>` repeat can no + // longer contribute: the build rejects every boundary a repeat body reaches + // (`boundary-in-repeat`), because a repeat iteration cannot suspend. + bool may_repeat = 6; + // Whether this fragment opens or closes the declaration's body. + BoundaryPhase phase = 7; +} + // Declarative route definition linking a URL path template to a component. // Nested routes are expressed via repeated children. message WebUIFragmentRoute { @@ -191,6 +228,10 @@ message WebUIFragmentRoute { // Component tag name for the error boundary UI (e.g. "error-page"). // Validated at build time — build fails if the component does not exist. string error_component = 10; + // Optional runtime content authored directly inside this route. The record + // contains typed boundary references and is rendered only for this route + // path; nested route declarations remain in children. + string content_fragment_id = 11; } // Outlet placeholder — marks where matched child route content renders. diff --git a/crates/webui-protocol/src/gen_webui.rs b/crates/webui-protocol/src/gen_webui.rs index 1e84389b9..547862d51 100644 --- a/crates/webui-protocol/src/gen_webui.rs +++ b/crates/webui-protocol/src/gen_webui.rs @@ -121,14 +121,6 @@ pub struct WebUiProtocol { /// them verbatim with no per-request work. #[prost(string, repeated, tag = "7")] pub module_preloads: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - /// Free-form <boundary name> values keyed by entry fragment and stored in - /// declaration order. Hosts resolve a name once to its zero-based BoundaryId; - /// names never reach the rendered HTML response. - #[prost(map = "string, message", tag = "8")] - pub streaming_boundaries: ::std::collections::HashMap< - ::prost::alloc::string::String, - StreamingBoundaryList, - >, /// Build-generated document-level rules for component rendering policies. /// Empty when no component opts into lazy rendering. #[prost(string, tag = "9")] @@ -142,25 +134,23 @@ pub struct WebUiProtocol { ComponentAssetStylePreload, >, } -/// Ordered compile-time boundary names for one entry fragment. -#[derive(serde::Serialize, serde::Deserialize)] -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct StreamingBoundaryList { - #[prost(string, repeated, tag = "1")] - pub names: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, -} /// A list of fragments (needed because protobuf maps cannot have repeated values directly). #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct FragmentList { #[prost(message, repeated, tag = "1")] pub fragments: ::prost::alloc::vec::Vec<WebUiFragment>, + /// True when this record directly or transitively reaches a boundary + /// declaration. Computed once at build time so handlers never graph-walk a + /// request merely to decide whether rendering can suspend. + #[prost(bool, tag = "2")] + pub contains_boundary: bool, } /// A single fragment — one of several types. #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct WebUiFragment { - #[prost(oneof = "web_ui_fragment::Fragment", tags = "1, 2, 3, 4, 5, 6, 7, 8, 9")] + #[prost(oneof = "web_ui_fragment::Fragment", tags = "1, 2, 3, 4, 5, 6, 7, 8, 9, 10")] pub fragment: ::core::option::Option<web_ui_fragment::Fragment>, } /// Nested message and enum types in `WebUIFragment`. @@ -186,8 +176,45 @@ pub mod web_ui_fragment { Route(super::WebUiFragmentRoute), #[prost(message, tag = "9")] Outlet(super::WebUiFragmentOutlet), + #[prost(message, tag = "10")] + Boundary(super::WebUiFragmentBoundary), } } +/// Compile-time streaming boundary declaration, written as an inline tape. +/// +/// A declaration emits a start marker and an end marker into its owner's +/// fragment record, with the body fragments in between. Ordinary rendering +/// therefore walks the body without any extra record lookup, and streaming +/// suspends and resumes inside the record it is already traversing. +/// +/// A declaration can produce multiple response-local occurrences through +/// components, conditions, loops, and routes. Runtime occurrence IDs are +/// therefore assigned later by the handler and are not stored here. +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct WebUiFragmentBoundary { + /// Stable build-local declaration identity. Shared by the start/end pair. + #[prost(uint32, tag = "1")] + pub declaration_id: u32, + /// Entry or reusable component template that authored this declaration. + #[prost(string, tag = "2")] + pub owner_fragment_id: ::prost::alloc::string::String, + /// Free-form authored name, unique only within owner_fragment_id. + #[prost(string, tag = "3")] + pub name: ::prost::alloc::string::String, + /// Optional authored key expression preserved verbatim for handler evaluation. + #[prost(string, optional, tag = "4")] + pub key: ::core::option::Option<::prost::alloc::string::String>, + /// Conservative graph result: this declaration may produce multiple runtime + /// occurrences through repeated component callsites. A `<for>` repeat can no + /// longer contribute: the build rejects every boundary a repeat body reaches + /// (`boundary-in-repeat`), because a repeat iteration cannot suspend. + #[prost(bool, tag = "6")] + pub may_repeat: bool, + /// Whether this fragment opens or closes the declaration's body. + #[prost(enumeration = "BoundaryPhase", tag = "7")] + pub phase: i32, +} /// Declarative route definition linking a URL path template to a component. /// Nested routes are expressed via repeated children. #[derive(serde::Serialize, serde::Deserialize)] @@ -227,6 +254,11 @@ pub struct WebUiFragmentRoute { /// Validated at build time — build fails if the component does not exist. #[prost(string, tag = "10")] pub error_component: ::prost::alloc::string::String, + /// Optional runtime content authored directly inside this route. The record + /// contains typed boundary references and is rendered only for this route + /// path; nested route declarations remain in children. + #[prost(string, tag = "11")] + pub content_fragment_id: ::prost::alloc::string::String, } /// Outlet placeholder — marks where matched child route content renders. /// Components use this to indicate where nested route children are rendered. @@ -494,6 +526,37 @@ impl DomStrategy { } } } +/// Which end of an inline boundary tape a fragment marks. +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum BoundaryPhase { + /// Opens a boundary body. Carries the full declaration metadata. + Start = 0, + /// Closes the body opened by the matching start in the same record. Only + /// declaration_id is meaningful. + End = 1, +} +impl BoundaryPhase { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Start => "BOUNDARY_PHASE_START", + Self::End => "BOUNDARY_PHASE_END", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option<Self> { + match value { + "BOUNDARY_PHASE_START" => Some(Self::Start), + "BOUNDARY_PHASE_END" => Some(Self::End), + _ => None, + } + } +} #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] diff --git a/crates/webui-protocol/src/lib.rs b/crates/webui-protocol/src/lib.rs index dc46f173b..d498eebe7 100644 --- a/crates/webui-protocol/src/lib.rs +++ b/crates/webui-protocol/src/lib.rs @@ -9,7 +9,7 @@ //! no conversion layer between domain types and protobuf types. use prost::Message; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::io; use thiserror::Error; @@ -47,8 +47,8 @@ pub type WebUIFragmentAttribute = WebUiFragmentAttribute; pub type WebUIFragmentPlugin = WebUiFragmentPlugin; pub type WebUIFragmentRoute = WebUiFragmentRoute; pub type WebUIFragmentOutlet = WebUiFragmentOutlet; +pub type WebUIFragmentBoundary = WebUiFragmentBoundary; pub type ComponentData = proto::ComponentData; -pub type StreamingBoundaryList = proto::StreamingBoundaryList; /// A mapping of unique fragment identifiers to their corresponding fragment lists. pub type WebUIFragmentRecords = HashMap<String, FragmentList>; @@ -181,6 +181,40 @@ impl WebUiFragment { } } + /// Create the start marker of an inline streaming boundary tape. + /// + /// The body fragments follow this marker in the same record and are closed + /// by [`Self::boundary_end`] carrying the same `declaration_id`. + pub fn boundary( + declaration_id: u32, + owner_fragment_id: impl Into<String>, + name: impl Into<String>, + key: Option<String>, + ) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Boundary(WebUiFragmentBoundary { + declaration_id, + owner_fragment_id: owner_fragment_id.into(), + name: name.into(), + key, + may_repeat: false, + phase: BoundaryPhase::Start as i32, + })), + } + } + + /// Create the end marker that closes an inline streaming boundary tape. + #[must_use] + pub fn boundary_end(declaration_id: u32) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Boundary(WebUiFragmentBoundary { + declaration_id, + phase: BoundaryPhase::End as i32, + ..Default::default() + })), + } + } + /// Create a simple dynamic attribute fragment (value is a single signal name). pub fn attribute(name: impl Into<String>, value: impl Into<String>) -> Self { Self { @@ -332,7 +366,6 @@ impl WebUiProtocol { dom_strategy: 0, initial_state_strategy: InitialStateStrategy::Full as i32, module_preloads: Vec::new(), - streaming_boundaries: HashMap::new(), component_render_css: String::new(), component_asset_style_preloads: Vec::new(), } @@ -348,7 +381,6 @@ impl WebUiProtocol { dom_strategy: 0, initial_state_strategy: InitialStateStrategy::Full as i32, module_preloads: Vec::new(), - streaming_boundaries: HashMap::new(), component_render_css: String::new(), component_asset_style_preloads: Vec::new(), } @@ -399,16 +431,14 @@ impl WebUiProtocol { attr.template ))) } + Some(web_ui_fragment::Fragment::Boundary(boundary)) + if boundary.phase() == BoundaryPhase::Start + && !fragments.contains_key(&boundary.owner_fragment_id) => + { + Some(Self::missing_boundary_owner_error(boundary)) + } Some(web_ui_fragment::Fragment::Route(route)) => { - if !route.fragment_id.is_empty() - && !fragments.contains_key(&route.fragment_id) - { - return Some(ProtocolError::Validation(format!( - "Route references non-existent fragment ID: {}", - route.fragment_id - ))); - } - None + Self::validate_route_references(route, fragments) } _ => None, }) @@ -418,9 +448,141 @@ impl WebUiProtocol { return Err(err); } + let mut declaration_ids = HashSet::new(); + let mut owner_names = HashSet::new(); + for fragment_list in fragments.values() { + let mut open: Option<u32> = None; + for fragment in &fragment_list.fragments { + let Some(web_ui_fragment::Fragment::Boundary(boundary)) = + fragment.fragment.as_ref() + else { + continue; + }; + if boundary.phase() == BoundaryPhase::End { + match open.take() { + Some(declaration_id) if declaration_id == boundary.declaration_id => {} + _ => return Err(Self::unbalanced_boundary_tape_error(boundary)), + } + continue; + } + if open.is_some() { + return Err(Self::unbalanced_boundary_tape_error(boundary)); + } + open = Some(boundary.declaration_id); + if boundary.name.trim().is_empty() { + return Err(Self::empty_boundary_name_error(boundary)); + } + if boundary + .key + .as_ref() + .is_some_and(|key| key.trim().is_empty()) + { + return Err(Self::empty_boundary_key_error(boundary)); + } + if !declaration_ids.insert(boundary.declaration_id) { + return Err(Self::duplicate_boundary_id_error(boundary)); + } + if !owner_names.insert((&boundary.owner_fragment_id, &boundary.name)) { + return Err(Self::duplicate_boundary_name_error(boundary)); + } + } + if let Some(declaration_id) = open { + return Err(Self::unterminated_boundary_tape_error(declaration_id)); + } + } + Ok(protocol) } + fn validate_route_references( + root: &WebUiFragmentRoute, + fragments: &WebUIFragmentRecords, + ) -> Option<ProtocolError> { + let mut pending = vec![root]; + while let Some(route) = pending.pop() { + for (kind, fragment_id) in [ + ("component", route.fragment_id.as_str()), + ("content", route.content_fragment_id.as_str()), + ] { + if !fragment_id.is_empty() && !fragments.contains_key(fragment_id) { + return Some(Self::missing_route_reference_error(kind, fragment_id)); + } + } + pending.extend(route.children.iter()); + } + None + } + + #[cold] + #[inline(never)] + fn unbalanced_boundary_tape_error(boundary: &WebUiFragmentBoundary) -> ProtocolError { + ProtocolError::Validation(format!( + "Boundary declaration {} has an unbalanced inline tape marker", + boundary.declaration_id + )) + } + + #[cold] + #[inline(never)] + fn unterminated_boundary_tape_error(declaration_id: u32) -> ProtocolError { + ProtocolError::Validation(format!( + "Boundary declaration {declaration_id} is missing its end marker" + )) + } + + #[cold] + #[inline(never)] + fn missing_boundary_owner_error(boundary: &WebUiFragmentBoundary) -> ProtocolError { + ProtocolError::Validation(format!( + "Boundary declaration {} references non-existent owner fragment ID: {}", + boundary.declaration_id, boundary.owner_fragment_id + )) + } + + #[cold] + #[inline(never)] + fn empty_boundary_name_error(boundary: &WebUiFragmentBoundary) -> ProtocolError { + ProtocolError::Validation(format!( + "Boundary declaration {} has an empty authored name", + boundary.declaration_id + )) + } + + #[cold] + #[inline(never)] + fn empty_boundary_key_error(boundary: &WebUiFragmentBoundary) -> ProtocolError { + ProtocolError::Validation(format!( + "Boundary declaration {} has an empty key expression", + boundary.declaration_id + )) + } + + #[cold] + #[inline(never)] + fn duplicate_boundary_id_error(boundary: &WebUiFragmentBoundary) -> ProtocolError { + ProtocolError::Validation(format!( + "Duplicate boundary declaration ID: {}", + boundary.declaration_id + )) + } + + #[cold] + #[inline(never)] + fn duplicate_boundary_name_error(boundary: &WebUiFragmentBoundary) -> ProtocolError { + ProtocolError::Validation(format!( + "Duplicate boundary name '{}' in owner fragment '{}'", + boundary.name, boundary.owner_fragment_id + )) + } + + #[cold] + #[inline(never)] + fn missing_route_reference_error(kind: &str, fragment_id: &str) -> ProtocolError { + ProtocolError::Validation(format!( + "Route {kind} references non-existent fragment ID: {fragment_id}" + )) + } + /// Serialize protocol to pretty JSON (for debug/inspect output only). pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> { serde_json::to_string_pretty(self) @@ -471,18 +633,21 @@ mod tests { WebUIFragment::signal("description", true), WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"), ], + contains_boundary: false, }, ); fragments.insert( "for-1".to_string(), FragmentList { fragments: vec![WebUIFragment::signal("person.name", false)], + contains_boundary: false, }, ); fragments.insert( "if-1".to_string(), FragmentList { fragments: vec![WebUIFragment::component("contact-card")], + contains_boundary: false, }, ); fragments.insert( @@ -492,6 +657,7 @@ mod tests { WebUIFragment::raw("Hello, "), WebUIFragment::signal("name", false), ], + contains_boundary: false, }, ); WebUIProtocol::new(fragments) @@ -560,29 +726,90 @@ mod tests { } #[test] - fn test_protobuf_streaming_boundary_names_roundtrip_in_declaration_order() { + fn test_protobuf_boundary_fragment_roundtrip() { let mut protocol = sample_protocol(); - protocol.streaming_boundaries.insert( - "main".to_string(), - StreamingBoundaryList { - names: vec![ - "weather shell".to_string(), - "composer/ready".to_string(), - "feed:batch".to_string(), - ], - }, - ); + let main = protocol + .fragments + .get_mut("index.html") + .expect("sample entry exists"); + main.fragments.push(WebUIFragment::boundary( + 7, + "index.html", + "weather shell", + Some("forecast.id".to_string()), + )); + main.fragments + .push(WebUIFragment::raw("<weather-panel></weather-panel>")); + main.fragments.push(WebUIFragment::boundary_end(7)); + main.contains_boundary = true; let bytes = protocol.to_protobuf().expect("encode failed"); let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed"); - assert_eq!( - decoded.streaming_boundaries["main"].names, - protocol.streaming_boundaries["main"].names - ); + let markers: Vec<&WebUiFragmentBoundary> = decoded.fragments["index.html"] + .fragments + .iter() + .filter_map(|fragment| match fragment.fragment.as_ref() { + Some(web_ui_fragment::Fragment::Boundary(boundary)) => Some(boundary), + _ => None, + }) + .collect(); + assert_eq!(markers.len(), 2, "the tape survives roundtrip as a pair"); + let boundary = markers[0]; + assert_eq!(boundary.phase(), BoundaryPhase::Start); + assert_eq!(boundary.declaration_id, 7); + assert_eq!(boundary.owner_fragment_id, "index.html"); + assert_eq!(boundary.name, "weather shell"); + assert_eq!(boundary.key.as_deref(), Some("forecast.id")); + assert_eq!(markers[1].phase(), BoundaryPhase::End); + assert_eq!(markers[1].declaration_id, 7); + assert!(decoded.fragments["index.html"].contains_boundary); assert_eq!(protocol, decoded); } + #[test] + fn test_protocol_rejects_unbalanced_boundary_tape() { + let tape_cases: [(&str, Vec<WebUIFragment>); 3] = [ + ( + "start without end", + vec![WebUIFragment::boundary(0, "index.html", "ready", None)], + ), + ("end without start", vec![WebUIFragment::boundary_end(0)]), + ( + "nested start", + vec![ + WebUIFragment::boundary(0, "index.html", "outer", None), + WebUIFragment::boundary(1, "index.html", "inner", None), + WebUIFragment::boundary_end(1), + WebUIFragment::boundary_end(0), + ], + ), + ]; + for (label, fragments) in tape_cases { + let protocol = WebUIProtocol::new(HashMap::from([( + "index.html".to_string(), + FragmentList { + fragments, + contains_boundary: true, + }, + )])); + let bytes = protocol.to_protobuf().expect("encode failed"); + let error = + WebUIProtocol::from_protobuf(&bytes).expect_err("unbalanced tape must be rejected"); + assert!( + error.to_string().contains("Boundary"), + "{label}: unexpected error {error}" + ); + } + } + + #[test] + fn test_protocol_has_no_fixed_streaming_boundary_table() { + let json = sample_protocol().to_json_pretty().expect("serialize JSON"); + assert!(!json.contains("streaming_boundaries")); + assert!(!json.contains("StreamingBoundaryList")); + } + #[test] fn test_protobuf_all_fragment_types() { let mut fragments = HashMap::new(); @@ -598,25 +825,38 @@ mod tests { ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"), "cond", ), + WebUIFragment::boundary(0, "main", "ready", None), + WebUIFragment::boundary_end(0), ], + contains_boundary: true, }, ); fragments.insert( "comp".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("c")], + contains_boundary: false, }, ); fragments.insert( "loop".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("l")], + contains_boundary: false, }, ); fragments.insert( "cond".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("i")], + contains_boundary: false, + }, + ); + fragments.insert( + "boundary".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("b")], + contains_boundary: false, }, ); @@ -645,12 +885,14 @@ mod tests { ConditionExpr::predicate("a", *op, "b"), "then", )], + contains_boundary: false, }, ); fragments.insert( "then".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("ok")], + contains_boundary: false, }, ); let p = WebUIProtocol::new(fragments); @@ -677,12 +919,14 @@ mod tests { "main".to_string(), FragmentList { fragments: vec![WebUIFragment::if_cond(nested, "then")], + contains_boundary: false, }, ); fragments.insert( "then".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("ok")], + contains_boundary: false, }, ); let p = WebUIProtocol::new(fragments); @@ -704,12 +948,14 @@ mod tests { "main".to_string(), FragmentList { fragments: vec![WebUIFragment::if_cond(compound, "body")], + contains_boundary: false, }, ); fragments.insert( "body".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("yes")], + contains_boundary: false, }, ); let p = WebUIProtocol::new(fragments); @@ -752,6 +998,7 @@ mod tests { "main".to_string(), FragmentList { fragments: vec![WebUIFragment::component("does-not-exist")], + contains_boundary: false, }, ); @@ -769,6 +1016,7 @@ mod tests { "main".to_string(), FragmentList { fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")], + contains_boundary: false, }, ); @@ -792,6 +1040,7 @@ mod tests { ConditionExpr::identifier("flag"), "missing-if", )], + contains_boundary: false, }, ); @@ -812,6 +1061,7 @@ mod tests { "main".to_string(), FragmentList { fragments: vec![WebUIFragment::signal("name", false)], + contains_boundary: false, }, ); let p = WebUIProtocol::new(fragments); @@ -886,12 +1136,14 @@ mod tests { "main".to_string(), FragmentList { fragments: vec![WebUIFragment::route("/profile/:id", "profile-page")], + contains_boundary: false, }, ); fragments.insert( "profile-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("<h1>Profile</h1>")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -924,18 +1176,21 @@ mod tests { invalidates: vec!["posts".to_string(), "counts".to_string()], pending_component: "loading-skeleton".to_string(), error_component: "error-page".to_string(), + content_fragment_id: String::new(), })), }; fragments.insert( "main".to_string(), FragmentList { fragments: vec![route_frag], + contains_boundary: false, }, ); fragments.insert( "user-posts".into(), FragmentList { fragments: vec![WebUIFragment::raw("posts")], + contains_boundary: false, }, ); @@ -952,6 +1207,7 @@ mod tests { "main".to_string(), FragmentList { fragments: vec![WebUIFragment::route("/test", "missing-fragment")], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -977,6 +1233,7 @@ mod tests { "main".to_string(), FragmentList { fragments: vec![route_frag], + contains_boundary: false, }, ); let protocol = WebUIProtocol::new(fragments); @@ -1001,6 +1258,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("Hello")], + contains_boundary: false, }, ); let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()]; diff --git a/crates/webui-python/README.md b/crates/webui-python/README.md index ee1d57c1a..e4830d45b 100644 --- a/crates/webui-python/README.md +++ b/crates/webui-python/README.md @@ -25,9 +25,42 @@ serialization. Use `render_text()` only when an application specifically needs a Python string. `render_component_templates()` accepts either one component tag as a string or -an iterable of tags. Streaming calls require state explicitly, including -`finish(final_state)`, so the document tail cannot accidentally render against -an empty state. +an iterable of tags. + +Host-driven streaming discovers runtime occurrences instead of resolving +compile-time names: + +```python +from microsoft_webui import BoundaryMode + +session = renderer.stream_response() +step = session.start({"title": "Home", "items": [{"id": 7}]}) +while not step.done: + boundary = step.boundary + send(step.bytes) + if boundary is not None: + step = session.resume( + boundary.instance_id, + {"title": "Home"}, + mode=BoundaryMode.FINAL, + ) + else: + step = session.advance() +send(step.bytes) +``` + +`start()`, `resume()`, and `advance()` return immutable `StreamStep` values +containing the bytes produced by that call, a `done` flag, and an optional +`BoundaryDescriptor`. A descriptor means call `resume()`; no descriptor with +`done == False` means call `advance()`; `done == True` means complete. +`resume()` returns only the pending occurrence through its checkpoint, while +`advance()` returns following parent or tail bytes. No sibling boundary +workaround is required. + +Optional boundary keys are Python strings, integers, or floats. They are +required only when one component-owned declaration is reached from multiple +static callsites in one entry traversal. `update(instance_id, patch)` returns +the update record as `bytes` and is valid between `resume()` and `advance()`. The first release targets regular CPython 3.11+ builds on Windows, macOS, and manylinux, on x64 and ARM64. PyPy, free-threaded CPython, and Alpine/musllinux diff --git a/crates/webui-python/benchmarks/benchmark_renderer.py b/crates/webui-python/benchmarks/benchmark_renderer.py index f2a7a90d4..ee1f41a95 100644 --- a/crates/webui-python/benchmarks/benchmark_renderer.py +++ b/crates/webui-python/benchmarks/benchmark_renderer.py @@ -19,8 +19,18 @@ FIXTURES = Path(__file__).parents[1] / "tests" / "fixtures" PROTOCOL_PATH = FIXTURES / "protocol.bin" PROTOCOL_BYTES = PROTOCOL_PATH.read_bytes() +STREAMING_PROTOCOL_BYTES = (FIXTURES / "streaming_protocol.bin").read_bytes() STATE = {"title": "Benchmark", "name": "Ada", "status": "ready"} +STREAMING_STATE = { + **STATE, + "show": True, + "integerKey": 1, + "floatKey": 2.5, + "stringKey": "last", + "summary": "All ready", +} STATE_BYTES = json.dumps(STATE, separators=(",", ":")).encode() +STREAMING_STATE_BYTES = json.dumps(STREAMING_STATE, separators=(",", ":")).encode() @pytest.fixture(scope="module") @@ -28,6 +38,11 @@ def renderer() -> Renderer: return Renderer(PROTOCOL_BYTES, plugin=Plugin.WEBUI) +@pytest.fixture(scope="module") +def streaming_renderer() -> Renderer: + return Renderer(STREAMING_PROTOCOL_BYTES, plugin=Plugin.WEBUI) + + @pytest.fixture(scope="module") def ffi_renderer() -> Generator[CtypesRenderer, None, None]: path = os.environ.get("WEBUI_FFI_LIBRARY") @@ -80,26 +95,31 @@ def test_render_component_templates(benchmark: Any, renderer: Renderer) -> None: def _stream_once(renderer: Renderer) -> bytes: session = renderer.stream_response() - greeting = session.boundary("greeting") - status = session.boundary("status") - return b"".join( - ( - session.write_shell(STATE_BYTES), - session.write_boundary( - greeting, - STATE_BYTES, - mode=BoundaryMode.UPDATABLE, - ), - session.update(greeting, STATE_BYTES), - session.write_boundary(status, STATE_BYTES), - session.finish(STATE_BYTES), + chunks: list[bytes] = [] + step = session.start(STREAMING_STATE_BYTES) + first = True + while not step.done: + chunks.append(step.bytes) + boundary = step.boundary + assert boundary is not None + mode = BoundaryMode.UPDATABLE if first else BoundaryMode.FINAL + committed = session.resume( + boundary.instance_id, + STREAMING_STATE_BYTES, + mode=mode, ) - ) + chunks.append(committed.bytes) + if first: + chunks.append(session.update(boundary.instance_id, STREAMING_STATE_BYTES)) + first = False + step = session.advance() + chunks.append(step.bytes) + return b"".join(chunks) -def test_streaming_session(benchmark: Any, renderer: Renderer) -> None: +def test_streaming_session(benchmark: Any, streaming_renderer: Renderer) -> None: benchmark.group = "streaming" - benchmark(_stream_once, renderer) + benchmark(_stream_once, streaming_renderer) @pytest.mark.parametrize("workers", [1, 2, 4]) diff --git a/crates/webui-python/python/microsoft_webui/__init__.py b/crates/webui-python/python/microsoft_webui/__init__.py index 3ba37f970..70d42ac5f 100644 --- a/crates/webui-python/python/microsoft_webui/__init__.py +++ b/crates/webui-python/python/microsoft_webui/__init__.py @@ -3,7 +3,15 @@ """High-performance Python renderer for compiled WebUI applications.""" -from ._api import BoundaryMode, Plugin, Renderer, StateInput, StreamingSession +from ._api import ( + BoundaryDescriptor, + BoundaryMode, + Plugin, + Renderer, + StateInput, + StreamingSession, + StreamStep, +) from ._native import ( ProtocolError, RenderError, @@ -14,6 +22,7 @@ ) __all__ = [ + "BoundaryDescriptor", "BoundaryMode", "Plugin", "ProtocolError", @@ -21,6 +30,7 @@ "Renderer", "StateError", "StateInput", + "StreamStep", "StreamingError", "StreamingSession", "WebUIError", diff --git a/crates/webui-python/python/microsoft_webui/_api.py b/crates/webui-python/python/microsoft_webui/_api.py index c5c8c049c..d9e4cfbe6 100644 --- a/crates/webui-python/python/microsoft_webui/_api.py +++ b/crates/webui-python/python/microsoft_webui/_api.py @@ -8,6 +8,7 @@ import json import os from collections.abc import Iterable, Mapping +from dataclasses import dataclass from enum import StrEnum from typing import Any, TypeAlias @@ -33,6 +34,26 @@ class BoundaryMode(StrEnum): UPDATABLE = "updatable" +@dataclass(frozen=True, slots=True) +class BoundaryDescriptor: + """One runtime boundary occurrence discovered during streaming.""" + + instance_id: int + declaration_id: int + owner: str + name: str + key: str | int | float | None + + +@dataclass(frozen=True, slots=True) +class StreamStep: + """Immutable bytes and continuation state produced by one streaming call.""" + + bytes: bytes + done: bool + boundary: BoundaryDescriptor | None = None + + def _plugin_value(plugin: Plugin | str | None) -> str | None: if plugin is None: return None @@ -182,50 +203,61 @@ def stream_response( class StreamingSession: - """A mutable, host-driven progressive HTML response.""" + """A mutable, single-driver progressive HTML response. + + Each call returns immutable output while the session retains its continuation. + """ __slots__ = ("_inner",) def __init__(self, inner: _native._StreamingSession) -> None: self._inner = inner - def boundary(self, name: str) -> int: - """Resolve an authored boundary name once and reuse its integer ID.""" - return self._inner.boundary(name) - - @property - def boundary_count(self) -> int: - """Number of compile-time boundaries in the entry.""" - return self._inner.boundary_count + def start(self, state: StateInput) -> StreamStep: + """Render until the first runtime boundary occurrence or completion.""" + return _stream_step(self._inner.start(_state_json(state))) - @property - def finished(self) -> bool: - """Whether the terminal record has been emitted.""" - return self._inner.finished - - def write_shell(self, state: StateInput) -> bytes: - """Render everything before the first boundary.""" - return self._inner.write_shell(_state_json(state)) - - def write_boundary( + def resume( self, - boundary: int, + instance_id: int, state: StateInput, *, mode: BoundaryMode | str = BoundaryMode.FINAL, - ) -> bytes: - """Render and commit the next boundary in declaration order.""" + ) -> StreamStep: + """Commit only the pending occurrence through its checkpoint.""" parsed_mode = BoundaryMode(mode) - return self._inner.write_boundary( - _state_json(state), - boundary, - parsed_mode is BoundaryMode.UPDATABLE, + return _stream_step( + self._inner.resume( + _state_json(state), + instance_id, + parsed_mode is BoundaryMode.UPDATABLE, + ) ) - def update(self, boundary: int, state: StateInput) -> bytes: - """Push a projected state patch to an updatable boundary.""" - return self._inner.update(_state_json(state), boundary) - - def finish(self, state: StateInput) -> bytes: - """Render the document tail and terminal record from the final state.""" - return self._inner.finish(_state_json(state)) + def advance(self) -> StreamStep: + """Render parent bytes until the next occurrence or completion.""" + return _stream_step(self._inner.advance()) + + def update(self, instance_id: int, patch: StateInput) -> bytes: + """Push a projected state patch to an updatable occurrence.""" + return self._inner.update(_state_json(patch), instance_id) + + +def _stream_step(value: _native._StreamStep) -> StreamStep: + boundary = value["boundary"] + descriptor = ( + None + if boundary is None + else BoundaryDescriptor( + instance_id=boundary["instance_id"], + declaration_id=boundary["declaration_id"], + owner=boundary["owner"], + name=boundary["name"], + key=boundary["key"], + ) + ) + return StreamStep( + bytes=value["bytes"], + done=value["done"], + boundary=descriptor, + ) diff --git a/crates/webui-python/python/microsoft_webui/_native.pyi b/crates/webui-python/python/microsoft_webui/_native.pyi index c4418b2c8..8d6dd397b 100644 --- a/crates/webui-python/python/microsoft_webui/_native.pyi +++ b/crates/webui-python/python/microsoft_webui/_native.pyi @@ -2,7 +2,7 @@ # Licensed under the MIT license. from os import PathLike -from typing import Self, TypeAlias, final +from typing import Self, TypeAlias, TypedDict, final _JsonInput: TypeAlias = str | bytes | bytearray _RenderOptions: TypeAlias = tuple[ @@ -19,6 +19,18 @@ class StateError(WebUIError): ... class RenderError(WebUIError): ... class StreamingError(WebUIError): ... +class _BoundaryDescriptor(TypedDict): + instance_id: int + declaration_id: int + owner: str + name: str + key: str | int | float | None + +class _StreamStep(TypedDict): + bytes: bytes + done: bool + boundary: _BoundaryDescriptor | None + @final class _Renderer: def __new__( @@ -49,20 +61,15 @@ class _Renderer: @final class _StreamingSession: - def boundary(self, name: str) -> int: ... - @property - def boundary_count(self) -> int: ... - @property - def finished(self) -> bool: ... - def write_shell(self, state_json: _JsonInput) -> bytes: ... - def write_boundary( + def start(self, state_json: _JsonInput) -> _StreamStep: ... + def resume( self, state_json: _JsonInput, - boundary: int, + instance_id: int, updatable: bool, - ) -> bytes: ... - def update(self, state_json: _JsonInput, boundary: int) -> bytes: ... - def finish(self, state_json: _JsonInput) -> bytes: ... + ) -> _StreamStep: ... + def advance(self) -> _StreamStep: ... + def update(self, state_json: _JsonInput, instance_id: int) -> bytes: ... __version__: str __all__ = [ diff --git a/crates/webui-python/src/lib.rs b/crates/webui-python/src/lib.rs index 2e2910260..28c681bb8 100644 --- a/crates/webui-python/src/lib.rs +++ b/crates/webui-python/src/lib.rs @@ -8,14 +8,15 @@ use std::sync::{Arc, Mutex, MutexGuard}; use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; use pyo3::pybacked::{PyBackedBytes, PyBackedStr}; -use pyo3::types::{PyAny, PyByteArray, PyBytes, PyModule, PyString, PyType}; +use pyo3::types::{PyAny, PyByteArray, PyBytes, PyDict, PyModule, PyString, PyType}; use serde_json::Value; use webui_handler::plugin::fast_v2::FastV2HydrationPlugin; use webui_handler::plugin::fast_v3::FastV3HydrationPlugin; use webui_handler::plugin::webui::WebUIHydrationPlugin; use webui_handler::{ - BoundaryId, BoundaryMode, HandlerError, Protocol, RenderOptions, ResponseWriter, - SessionOptions, StreamingSession as HandlerStreamingSession, WebUIHandler, + BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, HandlerError, Protocol, + RenderOptions, ResponseWriter, SessionOptions, StreamStep as HandlerStreamStep, + StreamingSession as HandlerStreamingSession, WebUIHandler, }; // PyO3's exception macro uses `Result::expect` inside its one-time type initializer. @@ -352,57 +353,32 @@ struct NativeStreamingSession { #[pymethods] impl NativeStreamingSession { - fn boundary(&self, py: Python<'_>, name: &str) -> PyResult<u32> { - py.detach(|| { - self.session_binding()? - .boundary(name) - .map(BoundaryId::raw) - .map_err(streaming_binding_error) - }) - .map_err(BindingError::into_py_error) - } - - #[getter] - fn boundary_count(&self, py: Python<'_>) -> PyResult<usize> { - py.detach(|| { - self.session_binding() - .map(|session| session.boundary_count()) - }) - .map_err(BindingError::into_py_error) - } - - #[getter] - fn finished(&self, py: Python<'_>) -> PyResult<bool> { - py.detach(|| self.session_binding().map(|session| session.is_finished())) - .map_err(BindingError::into_py_error) - } - - fn write_shell<'py>( + fn start<'py>( &self, py: Python<'py>, state_json: &Bound<'py, PyAny>, - ) -> PyResult<Bound<'py, PyBytes>> { + ) -> PyResult<Bound<'py, PyDict>> { let input = JsonInput::extract(state_json)?; - let result = py + let step = py .detach(|| { let state = parse_state(&input)?; self.session_binding()? - .write_shell(&state) + .start(&state) .map_err(streaming_binding_error) }) .map_err(BindingError::into_py_error)?; - Ok(PyBytes::new(py, &result)) + stream_step_dict(py, step) } - fn write_boundary<'py>( + fn resume<'py>( &self, py: Python<'py>, state_json: &Bound<'py, PyAny>, - boundary: u32, + instance_id: u32, updatable: bool, - ) -> PyResult<Bound<'py, PyBytes>> { + ) -> PyResult<Bound<'py, PyDict>> { let input = JsonInput::extract(state_json)?; - let result = py + let step = py .detach(|| { let state = parse_state(&input)?; let mode = if updatable { @@ -411,42 +387,36 @@ impl NativeStreamingSession { BoundaryMode::Final }; self.session_binding()? - .write_boundary(BoundaryId::from_raw(boundary), &state, mode) + .resume(BoundaryInstanceId::from_raw(instance_id), &state, mode) .map_err(streaming_binding_error) }) .map_err(BindingError::into_py_error)?; - Ok(PyBytes::new(py, &result)) + stream_step_dict(py, step) } - fn update<'py>( - &self, - py: Python<'py>, - state_json: &Bound<'py, PyAny>, - boundary: u32, - ) -> PyResult<Bound<'py, PyBytes>> { - let input = JsonInput::extract(state_json)?; - let result = py + fn advance<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> { + let step = py .detach(|| { - let state = parse_state(&input)?; self.session_binding()? - .update(BoundaryId::from_raw(boundary), &state) + .advance() .map_err(streaming_binding_error) }) .map_err(BindingError::into_py_error)?; - Ok(PyBytes::new(py, &result)) + stream_step_dict(py, step) } - fn finish<'py>( + fn update<'py>( &self, py: Python<'py>, state_json: &Bound<'py, PyAny>, + instance_id: u32, ) -> PyResult<Bound<'py, PyBytes>> { let input = JsonInput::extract(state_json)?; let result = py .detach(|| { let state = parse_state(&input)?; self.session_binding()? - .finish(&state) + .update(BoundaryInstanceId::from_raw(instance_id), &state) .map_err(streaming_binding_error) }) .map_err(BindingError::into_py_error)?; @@ -464,6 +434,62 @@ impl NativeStreamingSession { } } +fn stream_step_dict(py: Python<'_>, step: HandlerStreamStep) -> PyResult<Bound<'_, PyDict>> { + let result = PyDict::new(py); + result.set_item("bytes", PyBytes::new(py, &step.bytes))?; + result.set_item("done", step.done)?; + match step.boundary { + Some(boundary) => result.set_item("boundary", boundary_descriptor_dict(py, boundary)?)?, + None => result.set_item("boundary", py.None())?, + } + Ok(result) +} + +fn boundary_descriptor_dict( + py: Python<'_>, + descriptor: BoundaryDescriptor, +) -> PyResult<Bound<'_, PyDict>> { + let result = PyDict::new(py); + result.set_item("instance_id", descriptor.instance_id.raw())?; + result.set_item("declaration_id", descriptor.declaration_id)?; + result.set_item("owner", &*descriptor.owner)?; + result.set_item("name", &*descriptor.name)?; + match descriptor.key { + Some(BoundaryKey::String(key)) => result.set_item("key", key)?, + Some(BoundaryKey::Number(key)) if key.is_i64() => { + let value = key + .as_i64() + .ok_or_else(|| impossible_boundary_key_error(&key))?; + result.set_item("key", value)?; + } + Some(BoundaryKey::Number(key)) if key.is_u64() => { + let value = key + .as_u64() + .ok_or_else(|| impossible_boundary_key_error(&key))?; + result.set_item("key", value)?; + } + Some(BoundaryKey::Number(key)) if key.is_f64() => { + let value = key + .as_f64() + .filter(|value| value.is_finite()) + .ok_or_else(|| impossible_boundary_key_error(&key))?; + result.set_item("key", value)?; + } + Some(BoundaryKey::Number(key)) => return Err(impossible_boundary_key_error(&key)), + None => result.set_item("key", py.None())?, + } + Ok(result) +} + +#[cold] +#[inline(never)] +fn impossible_boundary_key_error(key: &serde_json::Number) -> PyErr { + BindingError::streaming(format!( + "WebUI returned an unsupported boundary key number `{key}`" + )) + .into_py_error() +} + fn parse_state(input: &JsonInput) -> Result<Value, BindingError> { serde_json::from_slice(input.as_bytes()) .map_err(|error| BindingError::state(format!("failed to parse state JSON: {error}"))) diff --git a/crates/webui-python/tests/compare_fixture.py b/crates/webui-python/tests/compare_fixture.py index 57ab925dc..6033f2196 100644 --- a/crates/webui-python/tests/compare_fixture.py +++ b/crates/webui-python/tests/compare_fixture.py @@ -1,11 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Compare two `webui inspect` JSON dumps semantically. +"""Compare two ``webui inspect`` JSON dumps semantically. -The committed `protocol.bin` fixture is a build output, so CI rebuilds it and -checks for drift. Bytes cannot be compared directly because protobuf map field -ordering is not stable, so compare the decoded JSON instead. +Committed protocol fixtures are build outputs, so CI rebuilds them and checks +for drift. Bytes cannot be compared directly because protobuf map field ordering +is not stable, so compare the decoded JSON instead. """ from __future__ import annotations @@ -15,11 +15,20 @@ import sys from pathlib import Path +DEFAULT_FIXTURE = Path("crates/webui-python/tests/fixtures/protocol.bin") + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("committed", type=Path) parser.add_argument("generated", type=Path) + parser.add_argument( + "fixture", + nargs="?", + type=Path, + default=DEFAULT_FIXTURE, + help="committed binary to name when the semantic comparison fails", + ) args = parser.parse_args() try: @@ -31,8 +40,7 @@ def main() -> int: if committed != generated: print( - "error: the Python protocol fixture is stale; regenerate " - "crates/webui-python/tests/fixtures/protocol.bin", + f"error: the Python protocol fixture is stale; regenerate {args.fixture}", file=sys.stderr, ) return 1 diff --git a/crates/webui-python/tests/conftest.py b/crates/webui-python/tests/conftest.py index aca8c5c06..4e946e247 100644 --- a/crates/webui-python/tests/conftest.py +++ b/crates/webui-python/tests/conftest.py @@ -8,6 +8,7 @@ FIXTURES = Path(__file__).parent / "fixtures" PROTOCOL_PATH = FIXTURES / "protocol.bin" +STREAMING_PROTOCOL_PATH = FIXTURES / "streaming_protocol.bin" STATE = { "title": "Python renderer", @@ -15,6 +16,15 @@ "status": "ready", } +STREAMING_STATE = { + **STATE, + "show": True, + "integerKey": 10, + "floatKey": 2.5, + "stringKey": "last", + "summary": "All ready", +} + @pytest.fixture(scope="session") def protocol_bytes() -> bytes: @@ -24,3 +34,8 @@ def protocol_bytes() -> bytes: @pytest.fixture def renderer(protocol_bytes: bytes) -> Renderer: return Renderer(protocol_bytes, plugin=Plugin.WEBUI) + + +@pytest.fixture +def streaming_renderer() -> Renderer: + return Renderer(STREAMING_PROTOCOL_PATH.read_bytes(), plugin=Plugin.WEBUI) diff --git a/crates/webui-python/tests/fixtures/protocol.bin b/crates/webui-python/tests/fixtures/protocol.bin index d99e86194..1b21ea408 100644 Binary files a/crates/webui-python/tests/fixtures/protocol.bin and b/crates/webui-python/tests/fixtures/protocol.bin differ diff --git a/crates/webui-python/tests/fixtures/streaming-app/index.html b/crates/webui-python/tests/fixtures/streaming-app/index.html new file mode 100644 index 000000000..78ac0101b --- /dev/null +++ b/crates/webui-python/tests/fixtures/streaming-app/index.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <title>{{title}} + + + + +

Hello, {{name}}!

+
+

Greeting committed.

+ +

{{status}}

+
+

Status committed.

+ +

{{summary}}

+
+
Streaming complete.
+
+ + diff --git a/crates/webui-python/tests/fixtures/streaming_protocol.bin b/crates/webui-python/tests/fixtures/streaming_protocol.bin new file mode 100644 index 000000000..1340fea79 --- /dev/null +++ b/crates/webui-python/tests/fixtures/streaming_protocol.bin @@ -0,0 +1,78 @@ + + +if-1 +(R& +index.htmlgreeting"{{integerKey}} + + +

Hello, +" +name + + +!

+R8 +1 +/ +-

Greeting committed.

+&R$ +index.htmlstatus" {{floatKey}} + + +

+ +" +status + + +

+R8 +- ++ +)

Status committed.

+(R& +index.htmlsummary" {{stringKey}} + + +

+ " +summary + + +

+R8 +( +& +$
Streaming complete.
 + + +index.html +) +' +% +" +}}}webui:head_start +" + + + " +title + + + + +" +}}}webui:head_end + + + +" +}}}webui:body_start +* +" +showif-1 +" +}}}webui:body_end + + + \ No newline at end of file diff --git a/crates/webui-python/tests/test_compare_fixture.py b/crates/webui-python/tests/test_compare_fixture.py new file mode 100644 index 000000000..8d3adca05 --- /dev/null +++ b/crates/webui-python/tests/test_compare_fixture.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from __future__ import annotations + +import sys +from pathlib import Path + +import compare_fixture +import pytest + + +def test_reports_requested_stale_fixture( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + committed = tmp_path / "committed.json" + generated = tmp_path / "generated.json" + fixture = Path("fixtures/streaming_protocol.bin") + committed.write_text('{"value": 1}', encoding="utf-8") + generated.write_text('{"value": 2}', encoding="utf-8") + monkeypatch.setattr( + sys, + "argv", + ["compare_fixture.py", str(committed), str(generated), str(fixture)], + ) + + assert compare_fixture.main() == 1 + assert str(fixture) in capsys.readouterr().err + + +def test_default_fixture_path_remains_compatible( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + committed = tmp_path / "committed.json" + generated = tmp_path / "generated.json" + committed.write_text('{"value": 1}', encoding="utf-8") + generated.write_text('{"value": 2}', encoding="utf-8") + monkeypatch.setattr( + sys, + "argv", + ["compare_fixture.py", str(committed), str(generated)], + ) + + assert compare_fixture.main() == 1 + assert str(compare_fixture.DEFAULT_FIXTURE) in capsys.readouterr().err diff --git a/crates/webui-python/tests/test_package.py b/crates/webui-python/tests/test_package.py index 4da50e17d..744dc3540 100644 --- a/crates/webui-python/tests/test_package.py +++ b/crates/webui-python/tests/test_package.py @@ -16,6 +16,7 @@ import pytest import validate_release_targets from microsoft_webui import ( + BoundaryDescriptor, BoundaryMode, Plugin, ProtocolError, @@ -24,6 +25,7 @@ StateError, StreamingError, StreamingSession, + StreamStep, WebUIError, ) @@ -35,6 +37,7 @@ def _raise_state_error() -> NoReturn: def test_public_surface_and_version() -> None: assert microsoft_webui.__version__ == importlib.metadata.version("microsoft-webui") assert set(microsoft_webui.__all__) == { + "BoundaryDescriptor", "BoundaryMode", "Plugin", "ProtocolError", @@ -42,6 +45,7 @@ def test_public_surface_and_version() -> None: "Renderer", "StateError", "StateInput", + "StreamStep", "StreamingError", "StreamingSession", "WebUIError", @@ -52,6 +56,8 @@ def test_public_surface_and_version() -> None: assert issubclass(RenderError, WebUIError) assert issubclass(StreamingError, WebUIError) assert Renderer is not None + assert BoundaryDescriptor is not None + assert StreamStep is not None assert StreamingSession is not None diff --git a/crates/webui-python/tests/test_streaming.py b/crates/webui-python/tests/test_streaming.py index 45a139ad5..02796b9d4 100644 --- a/crates/webui-python/tests/test_streaming.py +++ b/crates/webui-python/tests/test_streaming.py @@ -4,138 +4,228 @@ from __future__ import annotations import gc +from dataclasses import FrozenInstanceError import pytest -from conftest import STATE -from microsoft_webui import BoundaryMode, Plugin, Renderer, StateError, StreamingError - - -def test_host_driven_streaming_lifecycle(renderer: Renderer) -> None: - session = renderer.stream_response(nonce="stream-nonce") - greeting = session.boundary("greeting") - status = session.boundary("status") - - assert session.boundary_count == 2 - assert greeting == 0 - assert status == 1 - assert not session.finished - +from conftest import STREAMING_STATE +from microsoft_webui import ( + BoundaryDescriptor, + BoundaryMode, + Renderer, + StateError, + StreamingError, + StreamingSession, + StreamStep, +) + + +def require_boundary(step: StreamStep) -> BoundaryDescriptor: + assert not step.done + assert step.boundary is not None + return step.boundary + + +def finish_stream(session: StreamingSession, step: StreamStep) -> StreamStep: + while not step.done: + boundary = require_boundary(step) + committed = session.resume(boundary.instance_id, STREAMING_STATE) + assert not committed.done + assert committed.boundary is None + step = session.advance() + return step + + +def test_legacy_streaming_surface_is_removed(streaming_renderer: Renderer) -> None: + session = streaming_renderer.stream_response() + + for name in ( + "boundary", + "boundary_count", + "finished", + "write_shell", + "write_boundary", + "finish", + ): + assert not hasattr(session, name) + + +def test_discovers_resumes_updates_and_completes(streaming_renderer: Renderer) -> None: + session = streaming_renderer.stream_response(nonce="stream-nonce") + + start = session.start(STREAMING_STATE) + greeting = require_boundary(start) + assert isinstance(start, StreamStep) + assert isinstance(start.bytes, bytes) + assert greeting == BoundaryDescriptor( + instance_id=0, + declaration_id=0, + owner="index.html", + name="greeting", + key=10, + ) + + greeting_commit = session.resume( + greeting.instance_id, + STREAMING_STATE, + mode=BoundaryMode.UPDATABLE, + ) + assert not greeting_commit.done + assert greeting_commit.boundary is None + assert b"Hello, Ada!" in greeting_commit.bytes + assert greeting_commit.bytes.endswith(b"") + assert b"greeting-tail" not in greeting_commit.bytes + assert b'id="status"' not in greeting_commit.bytes + + update = session.update(greeting.instance_id, {"name": "Grace"}) + assert isinstance(update, bytes) + assert b"Grace" in update + + status_prefix = session.advance() + assert b"greeting-tail" in status_prefix.bytes + assert b'id="status"' not in status_prefix.bytes + status = require_boundary(status_prefix) + assert status.name == "status" + + status_commit = session.resume(status.instance_id, STREAMING_STATE) + assert status_commit.boundary is None + assert b'id="status">ready

' in status_commit.bytes + assert b"status-tail" not in status_commit.bytes + assert b'id="summary"' not in status_commit.bytes + + summary_prefix = session.advance() + assert b"status-tail" in summary_prefix.bytes + summary = require_boundary(summary_prefix) + assert summary.name == "summary" + + summary_commit = session.resume(summary.instance_id, STREAMING_STATE) + assert summary_commit.boundary is None + assert b'id="summary">All ready

' in summary_commit.bytes + assert b"Streaming complete." not in summary_commit.bytes + + step = session.advance() chunks = [ - session.write_shell(STATE), - session.write_boundary(greeting, STATE, mode=BoundaryMode.UPDATABLE), - session.update(greeting, {**STATE, "name": "Grace"}), - session.write_boundary(status, STATE), - session.finish(STATE), + start.bytes, + greeting_commit.bytes, + update, + status_prefix.bytes, + status_commit.bytes, + summary_prefix.bytes, + summary_commit.bytes, + step.bytes, ] - assert all(isinstance(chunk, bytes) for chunk in chunks) - assert all(chunks) - assert b"Hello, Ada!" in chunks[1] - assert b"Grace" in chunks[2] - assert b"ready" in chunks[3] - assert b"" in chunks[4] + assert step.done + assert step.boundary is None + assert b"" in step.bytes + assert b"Streaming complete." in step.bytes assert b'nonce="stream-nonce"' in b"".join(chunks) - assert session.finished -def test_unknown_boundary_is_actionable(renderer: Renderer) -> None: - session = renderer.stream_response() - with pytest.raises(StreamingError, match="missing"): - session.boundary("missing") +def test_static_boundary_keys_preserve_python_types(streaming_renderer: Renderer) -> None: + session = streaming_renderer.stream_response() + step = session.start(STREAMING_STATE) + keys: list[str | int | float] = [] + while not step.done: + boundary = require_boundary(step) + assert boundary.key is not None + keys.append(boundary.key) + committed = session.resume(boundary.instance_id, STREAMING_STATE) + assert committed.boundary is None + step = session.advance() -def test_ordering_error_is_recoverable(renderer: Renderer) -> None: - session = renderer.stream_response() - greeting = session.boundary("greeting") - status = session.boundary("status") + assert keys == [10, 2.5, "last"] + assert type(keys[0]) is int + assert type(keys[1]) is float + assert type(keys[2]) is str - with pytest.raises(StreamingError, match="shell"): - session.write_boundary(greeting, STATE) - assert session.write_shell(STATE) - with pytest.raises(StreamingError, match="declaration order"): - session.write_boundary(status, STATE) +def test_boundary_free_start_completes(streaming_renderer: Renderer) -> None: + session = streaming_renderer.stream_response() + step = session.start({**STREAMING_STATE, "show": False}) - assert session.write_boundary(greeting, STATE) - assert session.write_boundary(status, STATE) - assert session.finish(STATE) + assert step.done + assert step.boundary is None + assert b"" in step.bytes -def test_finish_error_preserves_open_session(renderer: Renderer) -> None: - session = renderer.stream_response() - greeting = session.boundary("greeting") - status = session.boundary("status") +def test_stream_values_are_immutable(streaming_renderer: Renderer) -> None: + step = streaming_renderer.stream_response().start(STREAMING_STATE) + boundary = require_boundary(step) - session.write_shell(STATE) - with pytest.raises(StreamingError, match="every boundary must be committed"): - session.finish(STATE) + with pytest.raises(FrozenInstanceError): + step.done = True # type: ignore[misc] + with pytest.raises(FrozenInstanceError): + boundary.name = "changed" # type: ignore[misc] - session.write_boundary(greeting, STATE) - session.write_boundary(status, STATE) - assert session.finish(STATE) +def test_invalid_state_does_not_advance_session(streaming_renderer: Renderer) -> None: + session = streaming_renderer.stream_response() + + with pytest.raises(StateError, match="parse state JSON"): + session.start(b"{not-json") + + assert require_boundary(session.start(STREAMING_STATE)).name == "greeting" -def test_updates_require_committed_updatable_boundary(renderer: Renderer) -> None: - session = renderer.stream_response() - greeting = session.boundary("greeting") - status = session.boundary("status") - session.write_shell(STATE) + +def test_updates_require_committed_updatable_occurrence( + streaming_renderer: Renderer, +) -> None: + session = streaming_renderer.stream_response() + greeting = require_boundary(session.start(STREAMING_STATE)) with pytest.raises(StreamingError, match=r"not.*committed"): - session.update(greeting, STATE) + session.update(greeting.instance_id, {"name": "Grace"}) - session.write_boundary(greeting, STATE, mode=BoundaryMode.FINAL) + committed = session.resume(greeting.instance_id, STREAMING_STATE) with pytest.raises(StreamingError, match="final"): - session.update(greeting, STATE) + session.update(greeting.instance_id, {"name": "Grace"}) - session.write_boundary(status, STATE) - session.finish(STATE) + assert committed.boundary is None + finish_stream(session, session.advance()) -def test_invalid_state_does_not_advance_session(renderer: Renderer) -> None: - session = renderer.stream_response() - greeting = session.boundary("greeting") +def test_advance_requires_a_committed_boundary(streaming_renderer: Renderer) -> None: + session = streaming_renderer.stream_response() + greeting = require_boundary(session.start(STREAMING_STATE)) - with pytest.raises(StateError, match="parse state JSON"): - session.write_shell(b"{not-json") + with pytest.raises(StreamingError, match="pending"): + session.advance() - assert session.write_shell(STATE) - assert session.write_boundary(greeting, STATE) + committed = session.resume(greeting.instance_id, STREAMING_STATE) + assert committed.boundary is None + assert require_boundary(session.advance()).name == "status" -def test_session_outlives_renderer_reference(protocol_bytes: bytes) -> None: - renderer = Renderer(protocol_bytes, plugin=Plugin.WEBUI) - session = renderer.stream_response() - del renderer - gc.collect() +def test_resume_rejects_wrong_instance_without_advancing( + streaming_renderer: Renderer, +) -> None: + session = streaming_renderer.stream_response() + greeting = require_boundary(session.start(STREAMING_STATE)) + + with pytest.raises(StreamingError, match="pending"): + session.resume(greeting.instance_id + 1, STREAMING_STATE) - greeting = session.boundary("greeting") - status = session.boundary("status") - assert session.write_shell(STATE) - assert session.write_boundary(greeting, STATE) - assert session.write_boundary(status, STATE) - assert session.finish(STATE) + committed = session.resume(greeting.instance_id, STREAMING_STATE) + assert committed.boundary is None + assert require_boundary(session.advance()).name == "status" -def test_finished_session_rejects_further_work(renderer: Renderer) -> None: +def test_session_outlives_renderer_reference(streaming_renderer: Renderer) -> None: + renderer = streaming_renderer session = renderer.stream_response() - session.write_shell(STATE) - session.write_boundary(session.boundary("greeting"), STATE) - session.write_boundary(session.boundary("status"), STATE) - session.finish(STATE) + del renderer + gc.collect() - with pytest.raises(StreamingError, match="already finished"): - session.finish(STATE) + step = finish_stream(session, session.start(STREAMING_STATE)) + assert b"" in step.bytes -def test_finish_requires_final_state(renderer: Renderer) -> None: - session = renderer.stream_response() - session.write_shell(STATE) - session.write_boundary(session.boundary("greeting"), STATE) - session.write_boundary(session.boundary("status"), STATE) - with pytest.raises(TypeError, match=r"required positional argument.*state"): - session.finish() # type: ignore[call-arg] +def test_completed_session_rejects_further_work(streaming_renderer: Renderer) -> None: + session = streaming_renderer.stream_response() + step = session.start({**STREAMING_STATE, "show": False}) + assert step.done - assert not session.finished - assert session.finish(STATE) + with pytest.raises(StreamingError, match="already started"): + session.start(STREAMING_STATE) diff --git a/crates/webui-test-utils/src/lib.rs b/crates/webui-test-utils/src/lib.rs index 208b1289c..4bca396c3 100644 --- a/crates/webui-test-utils/src/lib.rs +++ b/crates/webui-test-utils/src/lib.rs @@ -38,6 +38,7 @@ macro_rules! test_json { /// - `component("id")` — Component fragment /// - `for_loop("item", "collection", "template")` — For loop /// - `if_cond("template")` — If condition +/// - `boundary("name", "template")` — Streaming boundary declaration #[macro_export] macro_rules! assert_fragments { ($fragments:expr, [ $($matcher:expr),* $(,)? ]) => {{ @@ -77,6 +78,13 @@ pub enum FragmentMatcher { IfCond { template: String, }, + Boundary { + name: String, + declaration: u32, + }, + BoundaryEnd { + declaration: u32, + }, } /// Describes expected attribute properties. @@ -278,6 +286,20 @@ pub fn if_cond(template: &str) -> FragmentMatcher { } } +/// Match the start marker of an inline streaming boundary tape. +pub fn boundary(name: &str, declaration: u32) -> FragmentMatcher { + FragmentMatcher::Boundary { + name: name.to_string(), + declaration, + } +} + +/// Match the end marker that closes an inline streaming boundary tape. +#[must_use] +pub fn boundary_end(declaration: u32) -> FragmentMatcher { + FragmentMatcher::BoundaryEnd { declaration } +} + // ── Assertion implementation ──────────────────────────────────────── /// Assert that a fragment list matches the expected matchers. @@ -439,6 +461,40 @@ pub fn assert_fragment_list( i ); } + ( + Some(Fragment::Boundary(boundary)), + FragmentMatcher::Boundary { name, declaration }, + ) => { + assert_eq!( + boundary.phase(), + webui_protocol::BoundaryPhase::Start, + "Fragment[{}]: expected a boundary start marker", + i + ); + assert_eq!( + boundary.name, *name, + "Fragment[{}]: boundary name mismatch", + i + ); + assert_eq!( + boundary.declaration_id, *declaration, + "Fragment[{}]: boundary declaration mismatch", + i + ); + } + (Some(Fragment::Boundary(boundary)), FragmentMatcher::BoundaryEnd { declaration }) => { + assert_eq!( + boundary.phase(), + webui_protocol::BoundaryPhase::End, + "Fragment[{}]: expected a boundary end marker", + i + ); + assert_eq!( + boundary.declaration_id, *declaration, + "Fragment[{}]: boundary declaration mismatch", + i + ); + } (_actual, expected) => { panic!( "Fragment[{}]: type mismatch\n expected: {:?}\n actual: {}", @@ -471,6 +527,12 @@ fn format_fragment(frag: &webui_protocol::WebUIFragment) -> String { format!("route(path={:?}, fragment={:?})", r.path, r.fragment_id) } Some(Fragment::Outlet(_)) => "outlet".to_string(), + Some(Fragment::Boundary(boundary)) => format!( + "boundary(name={:?}, phase={:?}, declaration={})", + boundary.name, + boundary.phase(), + boundary.declaration_id + ), None => "None".to_string(), } } diff --git a/crates/webui-wasm/README.md b/crates/webui-wasm/README.md index 19c5508bf..b9089619c 100644 --- a/crates/webui-wasm/README.md +++ b/crates/webui-wasm/README.md @@ -15,10 +15,46 @@ WebAssembly bindings for the [WebUI](https://github.com/microsoft/webui) framewo The default feature is `all`, which powers the online playground. Consumers that only need to render prebuilt protobuf protocol bytes should use the handler bundle to avoid shipping parser code. Construct `Protocol` once from protocol bytes. It exposes `render`, -`renderStream`, `renderPartial`, `renderComponentTemplates`, and `tokens`. +`renderStream`, `renderPartial`, `renderComponentTemplates`, `tokens`, and +`streamResponse`. Streaming callbacks are coalesced around a 16 KiB target before crossing into JavaScript. +`Protocol.streamResponse(entry, requestPath, options)` returns a host-driven +`StreamingSession`. State and patch arguments are JSON strings. `start`, +`resume`, and `advance` return: + +```js +{ + bytes: Uint8Array, + done: boolean, + boundary?: { instanceId, declarationId, owner, name, key } +} +``` + +| Method | Result | +|---|---| +| `start(stateJson)` | Shell bytes through the first descriptor or terminal | +| `resume(instanceId, stateJson, mode)` | Only the pending occurrence's bytes through its checkpoint | +| `advance()` | Following parent bytes through the next descriptor or terminal | +| `update(instanceId, patchJson)` | Projected state bytes for an updatable occurrence | + +A descriptor means call `resume`; no descriptor with `done: false` means call +`advance`; `done: true` means complete. Boundary-only `resume` lets the host +enqueue the checkpoint immediately. `advance` renders following parent or tail +bytes, so no sibling boundary workaround is needed. + +`mode` is `"final"` (the default) or `"updatable"`. A committed updatable +occurrence accepts `update(instanceId, patch)`, including between its `resume` +and `advance`, and returns a `Uint8Array`. Optional boundary keys preserve JSON +identity: string keys are JavaScript strings and finite numeric keys are +JavaScript numbers. A key is required when one component-owned declaration is +reached from multiple static callsites in one entry traversal. Boundaries are +discovered through entries, reusable components, runtime branches, and selected +routes. A boundary-bearing subtree under `` fails with +`boundary-in-repeat`; one boundary may wrap the whole ``. The step with +`done: true` already contains terminal and document-tail bytes. + ## Building ```bash diff --git a/crates/webui-wasm/src/handler.rs b/crates/webui-wasm/src/handler.rs index 85ca5848c..56e09f7e8 100644 --- a/crates/webui-wasm/src/handler.rs +++ b/crates/webui-wasm/src/handler.rs @@ -4,7 +4,7 @@ //! Handler-only WASM exports. use crate::error::WasmError; -use js_sys::{Function, Object, Reflect}; +use js_sys::{Function, Object, Reflect, Uint8Array}; use serde_json::Value; use std::sync::Arc; use wasm_bindgen::prelude::*; @@ -12,8 +12,9 @@ use webui_handler::plugin::fast_v2::FastV2HydrationPlugin; use webui_handler::plugin::fast_v3::FastV3HydrationPlugin; use webui_handler::plugin::webui::WebUIHydrationPlugin; use webui_handler::{ - BoundaryId, BoundaryMode, HandlerError, Protocol as HandlerProtocol, RenderOptions, - ResponseWriter, SessionOptions, StreamingSession as HandlerStreamingSession, WebUIHandler, + BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, HandlerError, + Protocol as HandlerProtocol, RenderOptions, ResponseWriter, SessionOptions, + StreamStep as HandlerStreamStep, StreamingSession as HandlerStreamingSession, WebUIHandler, }; #[cfg(test)] use webui_protocol::WebUIProtocol; @@ -236,19 +237,26 @@ impl Protocol { } } -/// A progressive HTML response driven one chunk at a time from JavaScript. +/// A progressive HTML response driven one semantic step at a time from JavaScript. /// -/// Every method returns the UTF-8 bytes it produced. Write them to the -/// response and apply the host's own backpressure; the session holds no -/// transport and never blocks on one. +/// `start()`, `resume()`, and `advance()` return +/// `{ bytes, done, boundary? }`, where `bytes` is a `Uint8Array` and a boundary is +/// `{ instanceId, declarationId, owner, name, key }`. Boundary keys retain +/// their authored JSON type: strings are JavaScript strings and finite numbers +/// are JavaScript numbers. /// /// ```js /// const session = protocol.streamResponse('index.html', '/'); -/// const weather = session.boundary('weather-shell'); -/// controller.enqueue(session.writeShell(shellState)); -/// controller.enqueue(session.writeBoundary(weather, weatherState, 'updatable')); -/// controller.enqueue(session.update(weather, forecast)); -/// controller.enqueue(session.finish(tailState)); +/// let step = session.start(JSON.stringify(shellState)); +/// controller.enqueue(step.bytes); +/// while (!step.done) { +/// const { instanceId, name, key } = step.boundary; +/// const state = await loadBoundary(name, key); +/// step = session.resume(instanceId, JSON.stringify(state), 'updatable'); +/// controller.enqueue(step.bytes); +/// step = session.advance(); +/// controller.enqueue(step.bytes); +/// } /// ``` #[wasm_bindgen] pub struct StreamingSession { @@ -257,74 +265,105 @@ pub struct StreamingSession { #[wasm_bindgen] impl StreamingSession { - /// Resolve an authored boundary name to a stable integer handle. - /// - /// Resolve once outside the write loop; the handle costs nothing to reuse. - #[wasm_bindgen(js_name = boundary)] - pub fn boundary(&self, name: &str) -> Result { - self.inner - .boundary(name) - .map(BoundaryId::raw) - .map_err(streaming_error) - } - - /// Number of compile-time boundaries declared by this entry. - #[wasm_bindgen(getter, js_name = boundaryCount)] - pub fn boundary_count(&self) -> u32 { - // Boundary counts are bounded by the compiled entry, so this cannot - // exceed u32 in any protocol the build can produce. - u32::try_from(self.inner.boundary_count()).unwrap_or(u32::MAX) - } - - /// Whether the terminal record has been written. - #[wasm_bindgen(getter, js_name = finished)] - pub fn finished(&self) -> bool { - self.inner.is_finished() - } - - /// Render everything before the first boundary. - #[wasm_bindgen(js_name = writeShell)] - pub fn write_shell(&mut self, state_json: &str) -> Result, JsValue> { + /// Render until the first runtime boundary occurrence or terminal. + #[wasm_bindgen(js_name = start)] + pub fn start(&mut self, state_json: &str) -> Result { let state = session_state(state_json)?; - - self.inner.write_shell(&state).map_err(streaming_error) + let step = self.inner.start(&state).map_err(streaming_error)?; + stream_step_object(step) } - /// Render and commit the next boundary in declaration order. + /// Commit the pending occurrence through its checkpoint, then stop. /// /// `mode` is `"final"` (default) or `"updatable"`. Only updatable /// boundaries accept later `update()` calls. - #[wasm_bindgen(js_name = writeBoundary)] - pub fn write_boundary( + #[wasm_bindgen(js_name = resume)] + pub fn resume( &mut self, - boundary: u32, + instance_id: u32, state_json: &str, mode: Option, - ) -> Result, JsValue> { + ) -> Result { let state = session_state(state_json)?; - let mode = parse_boundary_mode(mode.as_deref())?; - self.inner - .write_boundary(BoundaryId::from_raw(boundary), &state, mode) - .map_err(streaming_error) + let step = self + .inner + .resume(BoundaryInstanceId::from_raw(instance_id), &state, mode) + .map_err(streaming_error)?; + stream_step_object(step) + } + + /// Write the parent bytes after the committed occurrence. + /// + /// Valid only after `resume()`. Returns the next boundary occurrence or + /// completes the document tail. + #[wasm_bindgen(js_name = advance)] + pub fn advance(&mut self) -> Result { + let step = self.inner.advance().map_err(streaming_error)?; + stream_step_object(step) } /// Push a projected state patch to a committed updatable boundary. #[wasm_bindgen(js_name = update)] - pub fn update(&mut self, boundary: u32, state_json: &str) -> Result, JsValue> { - let state = session_state(state_json)?; + pub fn update(&mut self, instance_id: u32, patch_json: &str) -> Result, JsValue> { + let patch = session_state(patch_json)?; self.inner - .update(BoundaryId::from_raw(boundary), &state) + .update(BoundaryInstanceId::from_raw(instance_id), &patch) .map_err(streaming_error) } +} - /// Render the document tail and emit the terminal record. - #[wasm_bindgen(js_name = finish)] - pub fn finish(&mut self, state_json: &str) -> Result, JsValue> { - let state = session_state(state_json)?; +fn stream_step_object(step: HandlerStreamStep) -> Result { + let result = Object::new(); + let bytes = Uint8Array::from(step.bytes.as_slice()); + set_object_property(&result, "bytes", bytes.as_ref())?; + set_object_property(&result, "done", &JsValue::from_bool(step.done))?; + if let Some(boundary) = step.boundary { + let boundary = boundary_object(boundary)?; + set_object_property(&result, "boundary", boundary.as_ref())?; + } + Ok(result) +} + +fn boundary_object(boundary: BoundaryDescriptor) -> Result { + let result = Object::new(); + set_object_property( + &result, + "instanceId", + &JsValue::from_f64(f64::from(boundary.instance_id.raw())), + )?; + set_object_property( + &result, + "declarationId", + &JsValue::from_f64(f64::from(boundary.declaration_id)), + )?; + set_object_property(&result, "owner", &JsValue::from_str(&boundary.owner))?; + set_object_property(&result, "name", &JsValue::from_str(&boundary.name))?; + if let Some(key) = boundary.key { + set_object_property(&result, "key", &boundary_key_value(key)?)?; + } + Ok(result) +} + +fn boundary_key_value(key: BoundaryKey) -> Result { + match key { + BoundaryKey::String(value) => Ok(JsValue::from_str(&value)), + BoundaryKey::Number(value) => value.as_f64().map(JsValue::from_f64).ok_or_else(|| { + JsValue::from_str("boundary key cannot be represented as a JavaScript number") + }), + } +} - self.inner.finish(&state).map_err(streaming_error) +fn set_object_property(object: &Object, key: &str, value: &JsValue) -> Result<(), JsValue> { + let written = Reflect::set(object.as_ref(), &JsValue::from_str(key), value) + .map_err(|_| JsValue::from_str(&format!("failed to set StreamStep '{key}' property")))?; + if written { + Ok(()) + } else { + Err(JsValue::from_str(&format!( + "failed to set StreamStep '{key}' property" + ))) } } @@ -512,6 +551,7 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::signal("name".to_string(), true)], + contains_boundary: false, }, ); let bytes = WebUIProtocol::new(fragments) @@ -549,12 +589,14 @@ mod tests { structural_fragment("body_end"), WebUIFragment::raw(""), ], + contains_boundary: false, }, ); fragments.insert( "client-card".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

client

")], + contains_boundary: false, }, ); let mut protocol = WebUIProtocol::new(fragments); @@ -592,4 +634,183 @@ mod tests { "server-only key name leaked into render:\n{rendered}" ); } + + #[cfg(feature = "parser")] + mod streaming_tests { + use std::collections::HashMap; + + use super::*; + + fn session(html: &str) -> HandlerStreamingSession { + let files = HashMap::from([("index.html".to_string(), html.to_string())]); + let protocol = crate::parser::parse_to_protocol(&files, "index.html", &[]) + .expect("protocol should parse"); + HandlerStreamingSession::new( + Arc::new(WebUIHandler::new()), + Arc::new(HandlerProtocol::new(protocol)), + SessionOptions::new("index.html", "/"), + ) + .expect("session should open") + } + + fn state(json: &str) -> Value { + parse_state_json(json).expect("state should parse") + } + + #[test] + fn streaming_steps_preserve_key_types_and_checkpoint_segments() { + let mut session = session(concat!( + "", + r#"

{{firstLabel}}

"#, + "between", + r#"

{{secondLabel}}

"#, + "
tail
", + "", + )); + let state = + state(r#"{"firstId":"alpha","firstLabel":"a","secondId":20,"secondLabel":"b"}"#); + + let first = session + .start(&state) + .expect("start should discover first boundary"); + assert!(!first.done); + assert!(!first.bytes.is_empty()); + let first_boundary = first.boundary.expect("first boundary should be returned"); + assert_eq!(first_boundary.instance_id.raw(), 0); + assert_eq!(first_boundary.declaration_id, 0); + assert_eq!(first_boundary.owner.as_ref(), "index.html"); + assert_eq!(first_boundary.name.as_ref(), "first"); + assert_eq!( + first_boundary.key, + Some(BoundaryKey::String("alpha".to_string())) + ); + + let resumed = session + .resume(first_boundary.instance_id, &state, BoundaryMode::Final) + .expect("resume should commit first boundary"); + assert!(!resumed.done); + assert!(resumed.boundary.is_none()); + let resumed_text = + std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8"); + assert!(resumed_text.contains(">a<")); + assert!(!resumed_text.contains("between")); + + let next = session + .advance() + .expect("advance should discover second boundary"); + assert!(!next.done); + let next_text = + std::str::from_utf8(&next.bytes).expect("advance output should be UTF-8"); + assert!(next_text.contains("between")); + assert!(!next_text.contains(">b<")); + let second_boundary = next.boundary.expect("second boundary should be returned"); + assert_eq!(second_boundary.instance_id.raw(), 1); + assert_eq!(second_boundary.declaration_id, 1); + assert_eq!(second_boundary.name.as_ref(), "second"); + assert_eq!(second_boundary.key, Some(BoundaryKey::Number(20.into()))); + + let resumed = session + .resume(second_boundary.instance_id, &state, BoundaryMode::Final) + .expect("resume should commit second boundary"); + assert!(!resumed.done); + assert!(resumed.boundary.is_none()); + let resumed_text = + std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8"); + assert!(resumed_text.contains(">b<")); + assert!(!resumed_text.contains("tail")); + + let done = session.advance().expect("final advance should complete"); + assert!(done.done); + assert!(done.boundary.is_none()); + assert!(std::str::from_utf8(&done.bytes) + .expect("advance output should be UTF-8") + .contains("tail")); + } + + #[test] + fn streaming_update_returns_bytes_for_updatable_occurrence() { + let mut session = session(concat!( + "", + r#"

{{count}}

"#, + r#"

done

"#, + "", + )); + let initial = state(r#"{"count":1}"#); + let first = session + .start(&initial) + .expect("start should discover first boundary") + .boundary + .expect("first boundary should be returned"); + let resumed = session + .resume(first.instance_id, &initial, BoundaryMode::Updatable) + .expect("resume should commit updatable boundary"); + assert!(!resumed.done); + assert!(resumed.boundary.is_none()); + + let update = session + .update(first.instance_id, &state(r#"{"count":2}"#)) + .expect("update should render"); + assert!(!update.is_empty()); + assert!(std::str::from_utf8(&update) + .expect("update should be UTF-8") + .contains(r#""count":2"#)); + + let second = session + .advance() + .expect("advance should discover second boundary") + .boundary + .expect("second boundary should be returned"); + let resumed = session + .resume(second.instance_id, &state("{}"), BoundaryMode::Final) + .expect("second resume should commit boundary"); + assert!(!resumed.done); + assert!(resumed.boundary.is_none()); + let done = session.advance().expect("final advance should complete"); + assert!(done.done); + } + + #[test] + fn streaming_advance_rejects_out_of_order_calls() { + let mut session = session(concat!( + "", + r#"

first

"#, + "", + )); + + let before_start = session + .advance() + .expect_err("advance before start should fail"); + assert!(before_start + .to_string() + .contains("start must be called before this operation")); + + let start = session.start(&state("{}")).expect("start should succeed"); + let before_resume = session + .advance() + .expect_err("advance before resume should fail"); + assert!(before_resume + .to_string() + .contains("there is no committed boundary to advance past")); + + let boundary = start.boundary.expect("first boundary should be returned"); + session + .resume(boundary.instance_id, &state("{}"), BoundaryMode::Final) + .expect("resume should still succeed after rejected advance"); + assert!(session.advance().expect("advance should complete").done); + } + + #[test] + fn streaming_start_returns_done_for_boundary_free_document() { + let mut session = session("

done

"); + + let step = session + .start(&state("{}")) + .expect("boundary-free start should complete"); + assert!(step.done); + assert!(step.boundary.is_none()); + assert!(std::str::from_utf8(&step.bytes) + .expect("output should be UTF-8") + .contains("

done

")); + } + } } diff --git a/crates/webui/README.md b/crates/webui/README.md index 8e083840f..c4b89b007 100644 --- a/crates/webui/README.md +++ b/crates/webui/README.md @@ -114,6 +114,43 @@ use webui_handler::plugin::webui::WebUIHydrationPlugin; let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new())); ``` +### Progressive Responses + +For host-paced `` rendering, drive the four-state session directly: + +```rust +use webui::{BoundaryMode, RenderOptions}; + +let options = RenderOptions::new("index.html", "/"); +let mut response = + handler.stream_response(&protocol, &options, &mut writer)?; +let mut step = response.start(&initial_state)?; + +while !step.done { + step = match step.boundary.as_ref() { + Some(boundary) => { + let state = + load_state(&boundary.owner, &boundary.name, boundary.key.as_ref())?; + response.resume(boundary.instance_id, &state, BoundaryMode::Final)? + } + None => response.advance()?, + }; +} +``` + +| Method | Result | +|---|---| +| `start(state)` | Shell bytes through the first descriptor or terminal | +| `resume(instance_id, state, mode)` | Only the pending occurrence through its checkpoint | +| `advance()` | Following parent bytes through the next descriptor or terminal | +| `update(instance_id, patch)` | Projected state for an updatable occurrence | + +A descriptor means call `resume`; no descriptor with `done == false` means call +`advance`; `done == true` means complete. Boundary-only `resume` makes a +checkpoint independently flushable. `advance` writes following parent or tail +bytes, so no sibling boundary is needed. An update is valid between `resume` and +`advance`. + ### Inspect ```rust diff --git a/crates/webui/benches/component_assets_bench.rs b/crates/webui/benches/component_assets_bench.rs index 8f31d91e2..51f10fc24 100644 --- a/crates/webui/benches/component_assets_bench.rs +++ b/crates/webui/benches/component_assets_bench.rs @@ -51,12 +51,14 @@ fn setup() -> Fixture { root.clone(), FragmentList { fragments: root_fragments, + contains_boundary: false, }, ); fragments.insert( unique, FragmentList { fragments: vec![WebUIFragment::raw("

unique

")], + contains_boundary: false, }, ); roots.push(root); @@ -67,6 +69,7 @@ fn setup() -> Fixture { component_tag("shared-child", shared_index), FragmentList { fragments: vec![WebUIFragment::raw("

shared

")], + contains_boundary: false, }, ); } @@ -76,6 +79,7 @@ fn setup() -> Fixture { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("
")], + contains_boundary: false, }, ); let tags: Vec = protocol.fragments.keys().cloned().collect(); diff --git a/crates/webui/src/lib.rs b/crates/webui/src/lib.rs index e7740464c..e411e6fc0 100644 --- a/crates/webui/src/lib.rs +++ b/crates/webui/src/lib.rs @@ -34,8 +34,9 @@ pub use error::WebUIError; pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory}; pub use webui_handler::Result as HandlerResult; pub use webui_handler::{ - plugin::HandlerPlugin, BoundaryId, BoundaryMode, FlushWriter, HandlerError, Protocol, - RenderOptions, ResponseWriter, StreamingResponse, WebUIHandler, + plugin::HandlerPlugin, BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, + FlushWriter, HandlerError, Protocol, RenderOptions, ResponseWriter, SessionOptions, + SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, WebUIHandler, }; pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface}; pub use webui_parser::CssStrategy; @@ -588,13 +589,8 @@ fn build_protocol_inner(options: &BuildOptions) -> Result Result + { + Some(boundary.name.clone()) + } + _ => None, + }) + .collect::>() + }; + assert_eq!(boundary_names(&result.protocol), ["a", "b"]); let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap(); - assert_eq!(decoded.streaming_boundaries["index.html"].names, ["a", "b"]); + assert_eq!(boundary_names(&decoded), ["a", "b"]); let warning = result .warnings .iter() diff --git a/crates/webui/src/server.rs b/crates/webui/src/server.rs index 2d6d118d2..a1a058e77 100644 --- a/crates/webui/src/server.rs +++ b/crates/webui/src/server.rs @@ -154,12 +154,14 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::component("my-page")], + contains_boundary: false, }, ); fragments.insert( "my-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

page

")], + contains_boundary: false, }, ); @@ -207,12 +209,14 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::component("my-page")], + contains_boundary: false, }, ); fragments.insert( "my-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

page

")], + contains_boundary: false, }, ); @@ -257,12 +261,14 @@ mod tests { "index.html".to_string(), FragmentList { fragments: vec![WebUIFragment::component("my-page")], + contains_boundary: false, }, ); fragments.insert( "my-page".to_string(), FragmentList { fragments: vec![WebUIFragment::raw("

page

")], + contains_boundary: false, }, ); diff --git a/docs/.webui-press/config.json b/docs/.webui-press/config.json index 9b54bfc77..f56d055db 100644 --- a/docs/.webui-press/config.json +++ b/docs/.webui-press/config.json @@ -166,7 +166,11 @@ "link": "/guide/integrations/electron" }, { - "text": "C / C# / FFI", + "text": ".NET", + "link": "/guide/integrations/dotnet" + }, + { + "text": "C / FFI", "link": "/guide/integrations/ffi" }, { diff --git a/docs/ai/SKILL.md b/docs/ai/SKILL.md index ab38bf423..14aeff7fc 100644 --- a/docs/ai/SKILL.md +++ b/docs/ai/SKILL.md @@ -720,54 +720,73 @@ removal, or reordering shifts compiled element indices. ### Progressive streaming hydration -Use `` only when the Rust server calls -`WebUIHandler::render_streaming` / `WebUIHandler::stream_response` with a -`FlushWriter`, or when an API backend returns the versioned -`application/x-webui-stream` control format to `webui serve --api-port`. The -directive is removed at compile time and emits no application DOM wrapper. +`` is a compile-time checkpoint directive for progressive sessions. +It is valid in entries and reusable components, including runtime conditions, +outlets, and selected route content. ```html + - - - + + +``` - - +```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, and + selected route content. Authored boundaries may not contain another authored + boundary directly or transitively. +- A boundary-bearing subtree reached from a `` body fails with + `boundary-in-repeat`, including declarations reached through a component, + condition, route, or outlet. A `` may be wholly inside one boundary, and + boundaries before or after a `` are valid. +- A component-owned declaration reached from multiple static callsites in one + entry traversal requires `key`; it must resolve to a unique live string or + finite number. Independent entries that each reach it once do not. - 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 +- `start(state)`, `resume(instanceId, state, mode)`, and `advance()` return a + step with bytes, optional runtime descriptor + `{ instanceId, declarationId, owner, name, key }`, and `done`. +- Drive the step state exactly: descriptor present means `resume`; no descriptor + with `done == false` means `advance`; `done == true` means complete. +- `resume` writes only the pending occurrence through its checkpoint. + `advance` writes the following parent or shell bytes through the next + occurrence or terminal. No sibling boundary is needed to split an early + component child from its parent tail. +- `update(instanceId, patch)` accepts only a committed updatable occurrence. + It is valid between that occurrence's `resume` and `advance`, and calls + `setState()` without inserting markup, rerunning hydration, or rerunning `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`. +- 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 `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 +798,11 @@ 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-in-repeat`, +`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 +1222,43 @@ 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 { + step = match step.boundary.as_ref() { + Some(boundary) => { + let state = + load_state(&boundary.owner, &boundary.name, boundary.key.as_ref())?; + page.resume(boundary.instance_id, &state, BoundaryMode::Final)? + } + None => page.advance()?, + }; +} ``` 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 `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. A resume control commits boundary-only bytes; +the CLI then calls `advance` internally for the following parent bytes. The +control stream needs no advance record. After the backend sends the resume for +the final descriptor and closes its body, the CLI's final `advance` 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..a02f39fb6 100644 --- a/docs/guide/cli/index.md +++ b/docs/guide/cli/index.md @@ -299,21 +299,40 @@ 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 control stream has no `advance` record because the CLI drives that core +operation: + +| Core step state | CLI action | +|---|---| +| descriptor present | Wait for the matching `resume` control and call core `resume` | +| no descriptor and not done | Call core `advance` | +| done | Complete the browser response | + +Core `resume` emits only the pending occurrence through its checkpoint. Core +`advance` emits the following parent or tail bytes through the next descriptor +or terminal. After the backend sends the resume for the final descriptor and +closes its NDJSON body, the CLI's final `advance` emits the terminal. 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 +342,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..c2f8d4dd0 100644 --- a/docs/guide/concepts/directives/boundary.md +++ b/docs/guide/concepts/directives/boundary.md @@ -1,171 +1,209 @@ # 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 ``, and do not add a sibling boundary merely to separate the +checkpoint from the parent tail. `resume` returns after the checkpoint; +`advance` renders the following parent bytes. + +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. - -## 2. Choose how the server drives the response - -| 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 | +The application entry must load early with `async`, or an equivalent +non-blocking strategy, in ``. -All paths produce the same ordered browser protocol. +## Runtime occurrences -## 3. Drive a host-controlled response +A declaration becomes an occurrence only when rendering reaches it. Boundaries +are allowed in: -Resolve authored names once to integer boundary handles. Then use these four -operations: +- entry templates +- reusable component templates +- true `` branches +- selected route content and outlets -| 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 | +False conditions and unselected routes produce no occurrence. A boundary is +not allowed in a `` body, directly or through a component, condition, +route, or outlet reached from that body. The build fails with +`boundary-in-repeat`. A `` may be wholly inside one boundary, and +boundaries before or after a `` are valid. -The required order is: +The host receives the next occurrence as: ```text -write_shell -> write_boundary* -> finish +{ instanceId, declarationId, owner, name, key } ``` -`update` may run between boundary writes, but only after its target has -committed as updatable. +- `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` distinguishes multiple static occurrences of one component-owned + declaration when required. -```rust -use webui::{BoundaryMode, RenderOptions, WebUIHandler}; +Use `owner`, `name`, and `key` to decide what state to load. Pass `instanceId` +back to the session. -let options = RenderOptions::new("index.html", "/"); -let mut response = - handler.stream_response(&protocol, &options, &mut writer)?; +### Keys for multiple static callsites -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)?; +```html + + + + + + + + ``` -Boundary HTML always commits once in declaration order. Backend work may run -concurrently, but a later boundary cannot overtake an earlier one. +When one entry traversal reaches a boundary-bearing component from more than one +static callsite, that component's declaration must have a key. Independent +entries that each call the component once do not trigger this rule. At runtime +the key must resolve to a string or finite JSON number, and simultaneously live +occurrences of that declaration must have unique keys. `` is not a source +of multiple boundary occurrences because boundary-bearing subtrees under its +body are rejected. -### Final or updatable? +## Drive the response -| 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` | +Every host binding exposes the same four operations: -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()`. +| Operation | Result | +|---|---| +| `start(state)` | Bytes through the first occurrence, or a completed step | +| `resume(instanceId, state, mode)` | Bytes for only the pending occurrence through its checkpoint | +| `advance()` | Following parent bytes through the next occurrence or completion | +| `update(instanceId, patch)` | State-only bytes for a committed updatable occurrence | -## 4. Drive streaming through `webui serve` +`start`, `resume`, and `advance` return bytes, `done`, and an optional +descriptor. Interpret each step in this order: -With `webui serve --api-port`, the API backend can return newline-delimited -control records: +| Step state | Required action | +|---|---| +| descriptor present | Call `resume` with that descriptor's `instanceId` | +| no descriptor and `done` is false | Call `advance` | +| `done` is true | The response is complete | + +`resume` is boundary-only so the host can write and flush a resolved occurrence +without waiting for its parent or document tail. Its step contains the +occurrence markers, body, checkpoint record, and sentinel, but no bytes that +follow the occurrence. `advance` renders those following parent or shell bytes +until discovery pauses again or the terminal completes. This split handles a +boundary inside an unfinished component directly, so no sibling boundary +workaround is required. A completed step includes the parent tail, terminal +record, and document close. -```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"} +```rust +let mut response = + handler.stream_response(&protocol, &options, &mut writer)?; +let mut step = response.start(&initial_state)?; + +while !step.done { + step = match step.boundary.as_ref() { + Some(boundary) => { + let state = + load_state(&boundary.owner, &boundary.name, boundary.key.as_ref()); + response.resume( + boundary.instance_id, + &state, + BoundaryMode::Final, + )? + } + None => response.advance()?, + }; +} ``` -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. +`update` is also valid after a boundary-only `resume` and before its matching +`advance`. This lets the host flush a checkpoint, emit a state-only patch, and +then continue the parent. + +### Final or updatable + +| Mode | Use when | +|---|---| +| `Final` | No later server state is needed | +| `Updatable` | Complete HTML should hydrate now and accept state later | + +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()`. + +## State at a suspension + +WebUI freezes only the projected parent keys needed to continue, plus lexical +locals such as component attributes and selected route context. Resume state +overlays that frozen parent state. Resolution order remains: + +1. lexical locals +2. state supplied to `resume` +3. frozen parent state ## 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. `resume` commits only +`` through its checkpoint, so it can become interactive +immediately. `advance` then renders the remaining parent section, generated +span completion, and later shell bytes. This boundary-only resume means a +sibling boundary is not needed to separate the early child from the parent +tail. + +Boundaries can also occur in true conditions and selected route content. A +boundary-bearing subtree reached from a `` body fails the build with +`boundary-in-repeat`. A complete `` may instead sit inside one boundary, +and boundaries before or after a `` are valid. ### 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 +247,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 +266,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 +278,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 +301,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 +323,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..7642ec7bd 100644 --- a/docs/guide/concepts/performance.md +++ b/docs/guide/concepts/performance.md @@ -194,30 +194,34 @@ 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, and route path. It is iterative and keeps bounded + frames, projected parent keys, lexical locals, static-occurrence keys, and + generated component spans instead of cloning full state for every boundary or + prebuilding a request plan. A repeat body cannot reach a boundary, so each + `` finishes inside its current step and no repeat iterator survives a + host call. 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. + 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`, + `advance`, or `update`, so Node, WASM, Python, C, and .NET hosts write through + their native backpressure APIs. A boundary-only `resume` can flush immediately; + `advance` carries the following parent and tail bytes. `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. @@ -229,6 +233,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 discovers runtime occurrences, preserves continuation +state, and completes generated component spans. A fixed entry-boundary +benchmark provides a lower-feature baseline for these interleaved release-mode +measurements: + +| 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/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..f170c9509 --- /dev/null +++ b/docs/guide/integrations/dotnet.md @@ -0,0 +1,108 @@ +# .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`, `Resume`, and +`Advance` 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; + + if (step.Boundary is BoundaryDescriptor boundary) + { + string state = await LoadBoundaryStateAsync( + boundary.Owner, + boundary.Name, + boundary.Key); + step = session.Resume( + boundary.InstanceId, + state, + BoundaryMode.Final); + } + else + { + step = session.Advance(); + } +} +``` + +| Member | Result | +|---|---| +| `Start(stateJson)` | Shell bytes through the first descriptor or terminal | +| `Resume(instanceId, stateJson, mode)` | Only the pending occurrence's bytes through its checkpoint | +| `Advance()` | Following parent bytes through the next descriptor or terminal | +| `Update(instanceId, patchJson)` | Projected state bytes for an updatable occurrence | + +A descriptor requires `Resume`; no descriptor with `Done == false` requires +`Advance`; `Done == true` means complete. `Resume` is boundary-only so the host +can flush that checkpoint before parent or tail bytes. `Advance` renders those +following bytes, so no sibling boundary workaround is required. + +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, and are valid between an occurrence's `Resume` and `Advance`. +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..1b079ffc9 100644 --- a/docs/guide/integrations/ffi.md +++ b/docs/guide/integrations/ffi.md @@ -206,30 +206,51 @@ 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, resume, and advance 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; + } + + if (webui_streaming_step_has_boundary(step)) { + /* 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); + } else { + webui_streaming_step_destroy(step); + step = webui_streaming_session_advance(session); + } + 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 +258,31 @@ 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)` | Owned step containing only the pending occurrence through its checkpoint | +| `webui_streaming_session_advance(session)` | Owned step containing following parent bytes through the next occurrence or terminal | +| `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`. + +If a step has a descriptor, call `resume`. If it has neither a descriptor nor +`done`, call `advance`. If `done` is true, the response is complete. `resume` +is boundary-only so the host can send that checkpoint immediately; `advance` +renders the following parent or document-tail bytes. No sibling boundary is +needed. An update may be emitted between `resume` and `advance`. 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 +316,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 +492,32 @@ 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; + + if (step.Boundary is BoundaryDescriptor boundary) + { + string state = await LoadStateAsync( + boundary.Owner, + boundary.Name, + boundary.Key); + step = session.Resume(boundary.InstanceId, state, BoundaryMode.Final); + } + else + { + step = session.Advance(); + } +} ``` 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..899550141 100644 --- a/docs/guide/integrations/node.md +++ b/docs/guide/integrations/node.md @@ -219,30 +219,37 @@ 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())); - -// 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) { + const state = await loadBoundaryState( + boundary.owner, + boundary.name, + boundary.key, + ); + step = session.resume(boundary.instanceId, state, 'final'); + } else { + step = session.advance(); + } + await write(res, step.bytes); +} +res.end(); async function write(res, chunk) { if (res.write(chunk)) return; @@ -263,29 +270,38 @@ 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, and +the selected route. A boundary-bearing subtree under `` fails the build +with `boundary-in-repeat`; a whole `` may sit inside one boundary. ### 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?)` | Return only the pending occurrence's bytes through its checkpoint | +| `advance()` | Return following parent bytes through the next occurrence or terminal | +| `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`. A descriptor means call `resume`; no descriptor +with `done: false` means call `advance`; `done: true` means complete. + +`resume` is boundary-only so its bytes can be written and flushed without +waiting for following parent content. `advance` carries that parent content and +the document tail. No sibling boundary workaround is needed. 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); +``` + +An update may be written between the occurrence's `resume` and `advance`. +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..3b5ed9d53 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,26 @@ 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 not None: + state = load_boundary_state( + boundary.owner, + boundary.name, + boundary.key, + ) + step = session.resume( + boundary.instance_id, + state, + mode="final", + ) + else: + step = session.advance() + yield step.bytes start_response("200 OK", [ ("Content-Type", "text/html; charset=utf-8"), @@ -162,25 +175,46 @@ 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 not None: + 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", + ), + ) + else: + step = await anyio.to_thread.run_sync(session.advance) + 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()`, `resume()`, and `advance()` return a `StreamStep` with `bytes`, +`done`, and an optional descriptor. The descriptor provides `instance_id`, +`declaration_id`, `owner`, `name`, and `key`. A descriptor means call +`resume()`; no descriptor with `done == False` means call `advance()`; a true +`done` value means complete. + +`resume()` returns only the pending occurrence through its checkpoint. +`advance()` returns the following parent or tail bytes through the next +descriptor or terminal. This separation lets the host yield the checkpoint +immediately, with no sibling boundary workaround. `update()` is valid between +the occurrence's `resume()` and `advance()`. 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 +234,10 @@ 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` | Bytes for only the pending occurrence through its checkpoint | +| `advance() -> StreamStep` | Following parent bytes through the next occurrence or terminal | +| `update(instance_id, patch) -> bytes` | Projected state for a committed updatable occurrence | ### `Plugin` and `BoundaryMode` @@ -217,8 +248,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..2c112fc51 100644 --- a/docs/guide/integrations/rust.md +++ b/docs/guide/integrations/rust.md @@ -217,33 +217,72 @@ 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}; 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 { + step = match step.boundary.as_ref() { + Some(boundary) => { + let state = load_state( + &boundary.owner, + &boundary.name, + boundary.key.as_ref(), + )?; + response.resume( + boundary.instance_id, + &state, + BoundaryMode::Final, + )? + } + None => response.advance()?, + }; +} +``` + +`start` writes the shell prefix through the first descriptor, or completes +immediately when the selected path has none. `resume` must use the currently +pending `BoundaryInstanceId` and writes only that occurrence through its +checkpoint. Its result normally has no descriptor and `done == false`. +`advance` writes the following parent or shell bytes until the next descriptor +or terminal. This boundary-only resume lets the host flush an early component +child independently; no sibling boundary is needed to separate it from the +parent tail. + +The status states are exact: a descriptor requires `resume`; no descriptor with +`done == false` requires `advance`; `done == true` completes the response. The +completed status has 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 component-owned declaration reached from +multiple static callsites in one entry traversal requires a key, and +simultaneously live keys must be unique. A boundary-bearing subtree under +`` is rejected with `boundary-in-repeat`; a complete `` may instead be +inside one boundary. + +To send later state, resume the occurrence as `BoundaryMode::Updatable`, retain +its instance ID, then call: + +```rust +response.update(search_instance, &json!({ "query": "webui" }))?; ``` -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. +`update` accepts an object patch, emits a projected markerless state record, and +flushes immediately. It is valid between the occurrence's `resume` and +`advance`, 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 +298,11 @@ 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. `resume` flushes immediately after the +occurrence's hydration checkpoint. The matching `advance` separately flushes +the following parent bytes through the next descriptor or terminal. 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 ` - - - - - - + + + + +

between-boundaries

+ + + +
+

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..2011bd1da 100644 --- a/examples/app/service-worker/README.md +++ b/examples/app/service-worker/README.md @@ -11,9 +11,11 @@ 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()`, +and `advance()` step into a `ReadableStream`. API fetches run concurrently, +while each resume uses the runtime descriptor returned by the previous +descriptor-bearing `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 +79,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 +90,15 @@ 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 calls `resume()` for a descriptor, `advance()` for a step with + neither a descriptor nor `done`, and stops when `done` is true. + +`resume()` enqueues only the resolved occurrence through its checkpoint. +`advance()` enqueues following parent or tail bytes through the next descriptor +or terminal. The final `advance()` emits the terminal, and no sibling boundary +is needed to split a checkpoint from its parent tail. ## 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..3875a74c6 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,62 @@ 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) { + if (!step.boundary) { + step = session.advance() as WasmStreamStep; + html += decoder.decode(step.bytes); + continue; } - 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) { + if (!step.boundary) { + step = session.advance(); + controller.enqueue(step.bytes); + continue; + } + 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 +142,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 +192,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..3bfecb290 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,34 @@ 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; + advance(): 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..88a836dbf 100644 --- a/examples/app/streaming/README.md +++ b/examples/app/streaming/README.md @@ -11,7 +11,11 @@ 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. Each declaration is static; no boundary executes from a +`` body. ```bash # Install JS dependencies @@ -44,7 +48,10 @@ 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 resume control for +the final descriptor, because the CLI follows that boundary-only resume with +the final `advance`, which completes the session: ```bash pnpm start:api -- --feed-delay-min-ms 200 --feed-delay-max-ms 400 @@ -52,22 +59,32 @@ 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. +Core `resume` writes only that occurrence through its checkpoint. The CLI then +calls core `advance` to write following parent bytes through the next +descriptor or terminal. The backend sends no `advance` control. After it sends +the resume for the final descriptor, it closes its NDJSON body; the CLI's final +`advance` writes the tail and terminal. A capacity-one command channel and +Node's `response.write()` / `drain` contract propagate backpressure across the +loopback bridge. + +This split is why the component-local composer needs no synthetic sibling +boundary. Its resume step can flush the child checkpoint, and advance later +writes the unfinished parent tail and span completion. **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 +101,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,9 +201,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 +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 resume control for the final descriptor. The CLI then performs the +final advance. The API caps concurrent admitted streams before sending a 200 response. Inside `webui serve`, one blocking worker owns the real @@ -196,7 +214,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..d9f6f95f0 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 boundary control closes 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..0ab941deb 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,35 @@ 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 }; +/** + * Backend controls intentionally omit `advance`: the CLI advances its Rust + * session after intervening updates, before the next resume or at stream EOF. + */ 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 +83,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..75cd5bd78 100644 --- a/examples/integration/node/README.md +++ b/examples/integration/node/README.md @@ -62,14 +62,27 @@ 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')); -// ... later, on the same response: -await write(res, session.update(status, { jobState: 'succeeded' })); -res.end(session.finish({})); +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); +// The checkpoint is committed, but parent bytes have not advanced yet. +await write(res, session.update(status.instanceId, { jobState: 'succeeded' })); + +while (!step.done) { + const boundary = step.boundary; + if (boundary) { + step = session.resume(boundary.instanceId, await stateFor(boundary)); + } else { + step = session.advance(); + } + await write(res, step.bytes); +} +res.end(); async function write(res, chunk) { if (res.write(chunk)) return; @@ -99,19 +112,31 @@ 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 +start ships and job-status is discovered +resume commit job-status only, as updatable +update patch job-status before advancing its parent +advance write parent bytes and discover log batch 1 +resume / advance commit log batch 1, then discover log batch 2 +resume / advance commit log batch 2, then discover log batch 3 +resume / advance commit log batch 3, then write the tail and terminal ``` -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 routes, conditions, or component templates. A +boundary-bearing subtree under `` fails with `boundary-in-repeat`; a whole +`` may sit inside one boundary. + +The state machine has no ambiguous unfinished step: a descriptor means +`resume`, neither a descriptor nor `done` means `advance`, and `done` means +complete. Boundary-only `resume` makes each checkpoint independently writable. +`advance` carries the following parent or tail bytes, so no sibling boundary is +needed. + ### Scope This example covers the **response** half of streaming: chunking, ordering, @@ -119,6 +144,6 @@ updates, and backpressure. Its components are scriptless, so hydration comes from the framework's streaming entry loaded straight from `@microsoft/webui-framework`, with no bundler step. -For the full picture — interactive islands, boundary-carried module loading, -`modulepreload` scheduling, and the measured performance story — see +For the full picture - interactive islands, boundary-carried module loading, +`modulepreload` scheduling, and the measured performance story - see [`examples/app/streaming`](../../app/streaming/README.md). diff --git a/examples/integration/node/streaming-server.js b/examples/integration/node/streaming-server.js index 63c03b2eb..da4213582 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. `resume()` commits only + * that occurrence, and `advance()` writes the following parent bytes while + * discovering the next descriptor, so runtime paths need no 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,38 @@ 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; + } + step = session.advance(); + await write(response, step.bytes); + 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)); + step = session.advance(); + await write(response, step.bytes); + if (!step.done) { + throw new Error("streaming session did not complete after advancing 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 +242,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..317082ae5 100644 --- a/examples/integration/rust/README.md +++ b/examples/integration/rust/README.md @@ -17,3 +17,19 @@ 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 follows the step state +exactly: a descriptor requires `resume`, no descriptor with `done == false` +requires `advance`, and `done == true` completes the response. `resume` emits +only that boundary through its checkpoint. `advance` emits the following parent +or tail bytes through the next descriptor or terminal, so no sibling boundary +is needed. The final `advance` emits the terminal; there is no separate finish +call. Real servers can commit an occurrence as `BoundaryMode::Updatable` and +call `update(instance_id, patch)` between its `resume` and `advance`. diff --git a/examples/integration/rust/src/main.rs b/examples/integration/rust/src/main.rs index 4597a98a8..932f21555 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,41 @@ 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 { + step = match step.boundary.as_ref() { + Some(boundary) => { + let instance_id = boundary.instance_id; + let owner = boundary.owner.clone(); + let name = boundary.name.clone(); + session + .resume(instance_id, state, BoundaryMode::Final) + .with_context(|| format!("Failed to resume boundary {owner}/{name}"))? + } + None => session.advance().context("Failed to advance stream")?, + }; + } + Ok(()) +} diff --git a/examples/integration/streaming-browser-bench/README.md b/examples/integration/streaming-browser-bench/README.md index c78287d31..53be6d64c 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 @@ -136,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 10.625 KiB minified / 3.75 KiB -gzip. Esbuild output is deterministic and the cap retains under 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 74a932684..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,19 +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. - * 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; +/** + * 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; @@ -260,7 +281,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; @@ -282,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 10.625KiB 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 3.75KiB cap', + `streaming coordinator incremental gzip bytes stay within ${STREAMING_INCREMENTAL_GZIP_CAP_BYTES}`, ).toBeLessThanOrEqual(STREAMING_INCREMENTAL_GZIP_CAP_BYTES); const bundle: BundleSizes = { @@ -309,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/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..39875f8d3 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -199,12 +199,35 @@ 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, outlets, and selected routes. A boundary-bearing subtree +reached from a `` body fails the build with `boundary-in-repeat`. A whole +`` may sit inside one boundary, and boundaries before or after a `` +are valid. 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. The server's boundary-only `resume` emits that checkpoint first; +`advance` emits the following parent tail, with no sibling boundary workaround. +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 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 +576,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 +931,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/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 910c3fe39..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,6 +23,7 @@ export function activateRootsBetween( endMarker: Comment, state: Record | undefined, updates?: PendingBoundaryUpdates, + bypass?: SpanBypass, ): void { const root = startMarker.parentNode; if (!root) { @@ -35,7 +37,9 @@ export function activateRootsBetween( root, endMarker, state, - updates ? { updates, countRetention: true } : undefined, + updates || bypass + ? { updates, bypass, countRetention: updates !== undefined } + : 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..d35c5fbd3 100644 --- a/packages/webui-framework/src/streaming-cleanup.ts +++ b/packages/webui-framework/src/streaming-cleanup.ts @@ -2,10 +2,18 @@ // Licensed under the MIT license. import { + BOUNDARY_SCRIPT_ATTR, + BOUNDARY_START_PREFIX, firstNodeWithin, + isRangeEndMarker, MAX_MARKER_SCAN_NODES, + nextAfterSubtreeWithin, nextWithinRoot, safeRemoveAttribute, + safeRemove, + SPAN_START_PREFIX, + STREAMING_ENCLOSING_SPAN_ATTR, + STREAMING_SPAN_HOST_ATTR, streamingErrorMessage, } from './streaming-dom.js'; import { STREAMED_HOST_ATTR } from './streaming-mode.js'; @@ -14,16 +22,30 @@ 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; }; /** 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,7 +60,21 @@ export function abandonDeferredElement(el: Element): void { }>: ${streamingErrorMessage(error)}`, ); } finally { - safeRemoveAttribute(el, STREAMED_HOST_ATTR); + removeStreamingAttributes(el); + } +} + +function hasStreamingAttribute(el: Element): boolean { + for (let i = 0; i < STREAMING_ROOT_ATTRS.length; i++) { + if (el.hasAttribute(STREAMING_ROOT_ATTRS[i])) return true; + } + return false; +} + +/** 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]); } } @@ -74,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' || @@ -83,24 +126,55 @@ 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 ( - 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); + } +} + +/** 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(SPAN_START_PREFIX) || + isRangeEndMarker(data); +} diff --git a/packages/webui-framework/src/streaming-coordinator.ts b/packages/webui-framework/src/streaming-coordinator.ts index 83e76962c..cc4d0e803 100644 --- a/packages/webui-framework/src/streaming-coordinator.ts +++ b/packages/webui-framework/src/streaming-coordinator.ts @@ -26,40 +26,55 @@ import { abandonPendingWaiters, configureStreamingFailureHandler, elementHasPendingStateForTests, + pendingBarrierRootCountForTests, pendingTagWaiterCountForTests, pendingUndefinedRootCountForTests, resetDeferredActivationForTests, } from './streaming-deferred.js'; -import type { PendingBoundaryUpdates } from './streaming-deferred.js'; +import type { PendingBoundaryUpdates, SpanBypass } from './streaming-deferred.js'; import { findBoundaryScript, - findEndMarkerByPrefix, - findStartMarkerByPrefix, + findRangeEndMarkerByPrefix, + findRangeStartMarkerByPrefix, + markerlessRecordViolation, removeBoundaryScaffolding, resolveBoundaryRange, + 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, + prepareSpanCompletion, + registerEnclosingSpans, + spanHostFor, +} 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); @@ -268,7 +284,28 @@ function processSentinel(sentinel: Element): void { return; } - if (kind === RECORD_KIND_STATE_UPDATE) { + 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); if (!boundary) { failBoundary( @@ -289,46 +326,51 @@ function processSentinel(sentinel: Element): void { return; } - if (kind === RECORD_KIND_TERMINAL) { - const resolved = resolveBoundaryRange(scriptEl, 0, true); + if (kind === RECORD_KIND_SPAN_COMPLETION) { + const resolved = resolveSpanRange(scriptEl, target); if (!resolved.ok) { - failBoundary(sentinel, resolved.reason); + failRangeResolution(sentinel, scriptEl, resolved); return; } nextExpectedRecordSequence++; - commitTerminal(sequence, sentinel, scriptEl); + commitRecord( + true, + payload as SpanCompletionPayload, + resolved.range, + sequence, + target, + false, + sentinel, + scriptEl, + ); + return; + } + + if ( + kind !== RECORD_KIND_FINAL_CHECKPOINT && + kind !== RECORD_KIND_UPDATABLE_CHECKPOINT + ) { + failBoundary(sentinel, `unsupported streaming record kind ${kind}`); return; } - if (target !== nextExpectedBoundaryId) { + 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++; - commitCheckpoint( + nextExpectedBoundaryInstanceId++; + commitRecord( + false, payload as BoundaryBootstrap, resolved.range, sequence, @@ -339,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, @@ -351,31 +404,23 @@ function commitCheckpoint( markBoundaryPending(); let committed = false; try { - applyBoundaryBootstrap(bootstrap); - if (range.start && range.end) { - const boundary: UpdatableBoundary | undefined = updatable - ? { roots: [], retained: 0, pendingRoots: 0 } - : undefined; - activateRootsBetween( - range.start, - range.end, - bootstrap.state, - boundary, + 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: [], - 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( @@ -385,12 +430,90 @@ 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 hydrateCheckpoint( + bootstrap: BoundaryBootstrap, + range: HydrationRange, + target: number, + updatable: boolean, +): void { + 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); + // 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, + bootstrap.state, + boundary, + bypass, + ); + 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, + 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 +589,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 +700,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 +710,7 @@ export function resetStreamingCoordinatorStateForTests(): void { slicedDrainActive = false; halted = false; nextExpectedRecordSequence = 0; - nextExpectedBoundaryId = 0; + nextExpectedBoundaryInstanceId = 0; terminalCommitted = false; pendingTerminalSequence = null; terminalValidationScheduled = false; @@ -595,6 +726,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..adff13b0f 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,43 +16,92 @@ import { MAX_MARKER_SCAN_NODES, nextAfterSubtreeWithin, nextWithinRoot, - safeRemoveAttribute, + STREAMING_ENCLOSING_SPAN_ATTR, 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; -export const ELEMENT_IGNORED = 0; -export const ELEMENT_DEFERRED = 4; -export const ELEMENT_LIMIT_FAILURE = 5; +// 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, ) => 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; +/** + * 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_BOUNDARY_STATE = Symbol(); -const PENDING_BOUNDARY_UPDATES = Symbol(); -const NO_BOUNDARY_STATE: unique symbol = Symbol(); +const PENDING_RECORD = Symbol(); /** One boundary-owned shallow patch shared by every deferred root. */ export interface PendingBoundaryUpdates { @@ -62,6 +112,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 +130,8 @@ export interface PendingBoundaryUpdates { export interface DeferredActivationOptions { updates?: PendingBoundaryUpdates; + /** Span barrier this boundary's compiler-marked early roots may bypass. */ + 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 @@ -100,6 +158,32 @@ 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}>`; +} + +/** + * 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}`; +} + /** * Deliver a replayed patch, halting when the target cannot accept one. * @@ -115,9 +199,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); } /** @@ -131,19 +228,33 @@ function activateMarkedElement( el: Element, state: Record | undefined, updates?: PendingBoundaryUpdates, + bypass?: SpanBypass, ): number { - const tag = el.tagName.toLowerCase(); + const tag = tagOf(el); if (tag.indexOf('-') === -1) return ELEMENT_IGNORED; - if (customElements.get(tag)) return invokeActivationHook(el, state); - if ( - !hasPendingState(el) && - pendingUndefinedRoots >= MAX_PENDING_UNDEFINED_ROOTS - ) { - return ELEMENT_LIMIT_FAILURE; + 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 (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; + } + deferBehindBarrier(el, state, updates, bypass); + return ELEMENT_DEFERRED; } - stashPendingState(el, state, updates); + 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) { waiter = { generation: activationGeneration, roots: new Set() }; @@ -162,6 +273,79 @@ 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]; + const record = takePendingRecord(el); + const updates = releaseUpdates(record); + try { + const outcome = resumeRetainedRoot(el, record, updates); + if (outcome === ACTIVATION_ANCESTOR_BARRIER) return ELEMENT_DEFERRED; + return outcome === ACTIVATION_MISSING_TEMPLATE || + outcome === ELEMENT_INVALID_OUTCOME + ? outcome + : ELEMENT_ACTIVATED_FROM_PENDING; + } finally { + if (updates?.pendingRoots === 0) updates.patch = undefined; + } +} + +/** + * 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) { + abandonDeferredTree(this); + fail(missingTemplateReason(tagOf(this))); + } else if (outcome === ELEMENT_INVALID_OUTCOME) { + abandonDeferredTree(this); + fail(invalidOutcomeReason(tagOf(this))); + } + } catch (error) { + abandonDeferredDescendants(this); + reportActivationFailure(tagOf(this), error); + } +} + function onTagDefined(tag: string, generation: number): void { if (generation !== activationGeneration) return; const waiter = pendingTagWaiters.get(tag); @@ -196,7 +380,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 || @@ -217,30 +401,36 @@ 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 outcome = invokeActivationHook(el, state); + 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 === ELEMENT_INVALID_OUTCOME) { + abandonDeferredTree(el); + fail(invalidOutcomeReason(tag)); 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.patch) requireStateUpdate(el, updates.patch); + if (outcome === ACTIVATION_ANCESTOR_BARRIER) { + // Re-retained by `resumeRetainedRoot`; only the budget is enforced here. + if (pendingBarrierRoots.size > MAX_PENDING_BARRIER_ROOTS) { + abandonDeferredTree(el); + fail(barrierLimitReason()); + } + return; } const failure = activateDeferredTree( firstNodeWithin(el), el, null, state, - updates ? { updates } : undefined, + updates || bypass ? { updates, bypass } : undefined, ); if (failure) fail(failure); } catch (error) { @@ -274,6 +464,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 bypass = options?.bypass; const countRetention = options?.countRetention === true && updates !== undefined; let node = first; @@ -315,15 +506,19 @@ export function activateDeferredTree( if (marked) { const el = node as Element; try { - const outcome = activateMarkedElement(el, state, updates); + 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_INVALID_OUTCOME) { + return invalidOutcomeReason(tagOf(el)); } if (outcome === ELEMENT_LIMIT_FAILURE) { return `pending undefined root count exceeds ${MAX_PENDING_UNDEFINED_ROOTS}`; } + if (outcome === ELEMENT_BARRIER_LIMIT_FAILURE) { + return barrierLimitReason(); + } if (outcome === ELEMENT_DEFERRED) { resumeAfterDeferred = nextAfterSubtreeWithin(node, root); skippingDeferredDescendants = true; @@ -332,19 +527,10 @@ export function activateDeferredTree( (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); - // 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); } } } @@ -363,91 +549,113 @@ 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 (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); - 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, + updates: PendingBoundaryUpdates | undefined, + bypass: SpanBypass | undefined, ): void { - const store = el as unknown as Record; - store[PENDING_BOUNDARY_STATE] = - state === undefined ? NO_BOUNDARY_STATE : state; - 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 takePendingRecord(el: Element): PendingRootRecord | undefined { + const store = el as unknown as Record; + const record = store[PENDING_RECORD]; + delete store[PENDING_RECORD]; + return record; } -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; +/** + * 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; } function invokeActivationHook( el: Element, state: Record | undefined, + 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); + outcome = hook.call(el, state, bypassAncestor); } catch (error) { - safeRemoveAttribute(el, STREAMED_HOST_ATTR); + removeStreamingAttributes(el); throw error; } + // 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; } - safeRemoveAttribute(el, STREAMED_HOST_ATTR); - 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. */ @@ -464,6 +672,10 @@ export function pendingUndefinedRootCountForTests(): number { return pendingUndefinedRoots; } +export function pendingBarrierRootCountForTests(): number { + return pendingBarrierRoots.size; +} + 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 053ef975b..b1d5a27c7 100644 --- a/packages/webui-framework/src/streaming-dom.ts +++ b/packages/webui-framework/src/streaming-dom.ts @@ -6,13 +6,49 @@ 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); } -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:'; + +/** + * 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 { @@ -30,42 +66,74 @@ 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 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 { + return resolveMarkerRange( + scriptEl, + instanceId, + BOUNDARY_START_PREFIX, + BOUNDARY_END_PREFIX, + MARKER_KIND_BOUNDARY, + ); +} + +/** Resolve the root-local marker range for one completed component span. */ +export function resolveSpanRange( + scriptEl: Element, + instanceId: number, ): RangeResolution { - const end = findEndMarker(scriptEl, sequence); + return resolveMarkerRange( + scriptEl, + instanceId, + SPAN_START_PREFIX, + SPAN_END_PREFIX, + MARKER_KIND_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 markerlessRecordViolation( + scriptEl: Element, + kind: string, +): string | null { + return previousRangeEndMarker(scriptEl) + ? `${kind} record must be markerless` + : null; +} + export function findBoundaryScript(sentinel: Element): Element | null { let node: Element | null = sentinel.previousElementSibling; for ( @@ -86,42 +154,49 @@ 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 previousRangeEndMarker(scriptEl); } -/** Find the start marker paired with a structurally discovered end marker. */ -export function findStartMarkerByPrefix( +/** + * 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 { - return findCommentBefore( - endMarker, - `${BOUNDARY_START_PREFIX}${endMarker.data.slice( - BOUNDARY_END_PREFIX.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.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 16fffe09f..6bd93d89d 100644 --- a/packages/webui-framework/src/streaming-mode.ts +++ b/packages/webui-framework/src/streaming-mode.ts @@ -9,6 +9,15 @@ * (`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, 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; @@ -17,13 +26,53 @@ 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'; +// ── 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 87af2d898..9211d38f5 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, + bypassAncestor?: FakeElement, + ) => 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, + bypassAncestor?: FakeElement, + ) => 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, bypassAncestor?: FakeElement) => { + const outcome = spec.hook!(state, bypassAncestor); + return typeof outcome === 'number' + ? outcome + : spec.activationOutcome ?? ACTIVATION_ACTIVATED; }; } if (spec.abandon) node[ABANDON] = spec.abandon; @@ -294,7 +308,7 @@ function defineTag( tag: string, supportsDetachedResume = true, hook: (state?: Record) => void = () => {}, - outcome = 1, + outcome: number = ACTIVATION_ACTIVATED, ): void { class DefinedElement {} if (supportsDetachedResume) { @@ -353,6 +367,24 @@ const { __streamingRetentionStateForTests, } = await import('./streaming.js'); +const { + openSpanCountForTests: __openSpanCountForTests, + 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, @@ -374,6 +406,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; @@ -384,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 @@ -447,11 +517,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 +536,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 +563,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 +584,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 +592,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 +634,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 +790,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 +1026,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[] = []; @@ -1022,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); }, @@ -1153,7 +1471,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 +1627,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 +1657,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 +1724,710 @@ 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 ACTIVATION_ANCESTOR_BARRIER; + order.push('inner'); + return ACTIVATION_ACTIVATED; + }, + }); + 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 parent!: FakeElement; + let child!: FakeElement; + child = element('later-span-child', { + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + order.push('child'); + return ACTIVATION_ACTIVATED; + }, + }); + 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 parent!: FakeElement; + let child!: FakeElement; + child = element('early-child', { + hook(state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + order.push('child'); + childStates.push(state); + return ACTIVATION_ACTIVATED; + }, + }); + 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 parent!: FakeElement; + let child!: FakeElement; + child = element('mismatch-child', { + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + order.push('child'); + return ACTIVATION_ACTIVATED; + }, + }); + 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 ACTIVATION_ANCESTOR_BARRIER; + order.push('unmarked'); + return ACTIVATION_ACTIVATED; + }, + }); + 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('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 ACTIVATION_ACTIVATED; + }, + }); + nested.setAttribute('data-ws', ''); + const child = element('barrier-outer-root', { + hook() { + if (!parentActive) return ACTIVATION_ANCESTOR_BARRIER; + order.push('child'); + return ACTIVATION_ACTIVATED; + }, + 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, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + activations.push(state); + return ACTIVATION_ACTIVATED; + }, + setState(state) { + updates.push(state); + }, + }); + 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 parent!: FakeElement; + let child!: FakeElement; + child = element('late-early-child', { + hook(state, bypassAncestor) { + bypasses.push(bypassAncestor); + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + childActivations.push(state); + return ACTIVATION_ACTIVATED; + }, + setState(state) { + childUpdates.push(state); + }, + }); + 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, [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'); + + 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 parent!: FakeElement; + let child!: FakeElement; + child = element('light-span-child', { + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + order.push('child'); + return ACTIVATION_ACTIVATED; + }, + }); + 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 inner!: FakeElement; + let child!: FakeElement; + child = element('nested-span-child', { + hook(_state, bypassAncestor) { + const bypassesInner = bypassAncestor === inner; + if ((!innerActive && !bypassesInner) || !outerActive) return ACTIVATION_ANCESTOR_BARRIER; + order.push('child'); + return ACTIVATION_ACTIVATED; + }, + }); + inner = element('nested-inner-parent', { + hook() { + if (!outerActive) return ACTIVATION_ANCESTOR_BARRIER; + innerActive = true; + order.push('inner'); + return ACTIVATION_ACTIVATED; + }, + }); + 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 parent!: FakeElement; + let child!: FakeElement; + child = element('shadow-span-child', { + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + order.push('child'); + return ACTIVATION_ACTIVATED; + }, + }); + 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 parent!: FakeElement; + let child!: FakeElement; + child = element('truncated-span-child', { + hook(_state, bypassAncestor) { + if (!parentActive && bypassAncestor !== parent) return ACTIVATION_ANCESTOR_BARRIER; + return ACTIVATION_ACTIVATED; + }, + }); + 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 +2760,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 +2797,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 +2949,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 +3191,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 +3222,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(); @@ -2244,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: {} }); @@ -2300,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 }, @@ -2316,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-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..c95c71d55 --- /dev/null +++ b/packages/webui-framework/src/streaming-spans.ts @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + MAX_MARKER_SCAN_NODES, + safeRemove, + safeRemoveAttribute, + SPAN_START_PREFIX, + STREAMING_SPAN_HOST_ATTR, +} from './streaming-dom.js'; +import type { HydrationRange } from './streaming-dom.js'; + +/** 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; + readonly parentId: number | undefined; + 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[] = []; +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. + * + * 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 = 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}`; + } + if (id === INVALID_SPAN_ID) { + clearScratch(); + 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 as Element); + idScratch.push(id); + } + current = ascendRenderRoots(current); + } + + 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}`; + } + 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; +} + +/** + * Resolve and validate one span completion before it mutates or hydrates. + * + * 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 prepareSpanCompletion( + id: number, + range: HydrationRange, +): string | null { + const start = range.start; + const end = range.end; + if (!start || !end) return `span ${id} completion is markerless`; + + let host: Element | undefined; + let node: Node | null = start.nextSibling; + let hops = 0; + 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 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}`; + } + host = node as Element; + break; + } + } + node = node.nextSibling; + } + if (!host) { + return `span completion targets span ${id}, but no spanning host was found inside its markers`; + } + + 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`; + } + return span.host === host && span.start === start && + host.parentNode === start.parentNode + ? null + : `span completion markers do not match the open span ${id}`; +} + +/** 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; +} + +/** + * 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; + } + if (node.nodeType === 1 /* ELEMENT_NODE */) { + const slot = (node as Element).assignedSlot; + if (slot) return slot; + } + return node.parentNode; +} + +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 d0f6968d3..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, @@ -89,6 +95,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 { @@ -199,6 +206,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), + ACTIVATION_ACTIVATED, + ); + 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(); @@ -343,6 +397,150 @@ describe('TemplateElement — streamed-host activation ownership', () => { assert.deepEqual(received, { detached: true }); }); + test('a coordinator-resolved bypass ancestor is skipped exactly once', () => { + 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; + }; + parentRaw.tagName = parentTag; + parentRaw.parentElement = null; + parentRaw.$deferredSSR = true; + + 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, + bypassAncestor?: Element, + ): number; + }; + childRaw.tagName = childTag; + childRaw.parentElement = parent as unknown as Element; + childRaw.setAttribute('data-ws', ''); + child.connectedCallback(); + childRaw.$hydrated = true; + + assert.equal( + childRaw[STREAMING_BOUNDARY_ACTIVATE]( + { child: true }, + parent as unknown as Element, + ), + ACTIVATION_ACTIVATED, + ); + assert.equal(childRaw.$deferredSSR, false); + }); + + test('an unrelated bypass ancestor 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; + }; + parentRaw.tagName = parentTag; + parentRaw.parentElement = null; + parentRaw.$deferredSSR = true; + + const unrelated = new TemplateElement(); + (unrelated as unknown as { tagName: string }).tagName = parentTag; + + 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 = parent as unknown as Element; + childRaw.setAttribute('data-ws', ''); + child.connectedCallback(); + + assert.equal( + childRaw[STREAMING_BOUNDARY_ACTIVATE]( + { child: true }, + unrelated as unknown as Element, + ), + ACTIVATION_ANCESTOR_BARRIER, + ); + 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, + ), + ACTIVATION_ANCESTOR_BARRIER, + ); + 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; @@ -363,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](); @@ -398,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 deb1c4918..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,9 +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 templateMetaByCtor = new WeakMap(); const pendingAncestorDescendants = new WeakMap(); @@ -417,14 +419,28 @@ 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. + * 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): number { + [STREAMING_BOUNDARY_ACTIVATE]( + state?: Record, + bypassAncestor?: Element, + ): 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. @@ -441,14 +457,17 @@ 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(bypassAncestor); if (ancestor) { this.$deferredByAncestor = true; this.$ancestorBoundaryState = state; this.$hasAncestorBoundaryState = true; this.$registerWithHydrationBarrier(ancestor); - return ACTIVATION_ACTIVATED; + return ACTIVATION_ANCESTOR_BARRIER; } + // 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); @@ -460,18 +479,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. */ @@ -552,8 +600,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') resume.call(this); + if (this.$resumeRetainedRoot()) return; this.$didDeferSSRHydration(); return; } @@ -808,12 +855,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; @@ -941,7 +985,19 @@ export class TemplateElement extends HTMLElement { return this.$meta ?? this.$templateMeta(); } - private $nearestHydrationBarrier(): Element | undefined { + /** + * 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( + bypassAncestor?: Element, + ): Element | undefined { + let bypass = bypassAncestor; let current: Element = this; while (true) { let parent: Element | null = @@ -959,6 +1015,11 @@ export class TemplateElement extends HTMLElement { : null; } if (!parent) return undefined; + if (parent === bypass) { + bypass = undefined; + current = parent; + continue; + } if (parent instanceof TemplateElement) { const parentMeta = parent.$meta ?? parent.$templateMeta(); if (parentMeta?.th) { @@ -1079,6 +1140,7 @@ export class TemplateElement extends HTMLElement { const boundaryState = this.$ancestorBoundaryState; this.$hasAncestorBoundaryState = undefined; this.$ancestorBoundaryState = undefined; + 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( diff --git a/packages/webui/README.md b/packages/webui/README.md index 8f95759df..608ea7169 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -165,23 +165,34 @@ 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) { + const state = await loadBoundaryState( + boundary.owner, + boundary.name, + boundary.key, + ); + step = session.resume(boundary.instanceId, state, 'final'); + } else { + step = session.advance(); + } + await write(res, step.bytes); +} +res.end(); async function write(res, chunk) { if (res.write(chunk)) return; @@ -204,17 +215,19 @@ 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` | Only the pending occurrence's bytes through its checkpoint (`'final'` \| `'updatable'`) | +| `advance()` | `StreamStep` | Following parent bytes through the next occurrence or terminal | +| `update(instanceId, patch)` | `Buffer` | Projected state for a committed updatable occurrence | + +`StreamStep` contains `bytes`, `done`, and optional +`{ instanceId, declarationId, owner, name, key }`. A descriptor requires +`resume`; no descriptor with `done: false` requires `advance`; `done: true` +means complete. `resume` is boundary-only, while `advance` carries following +parent or tail bytes. No sibling boundary workaround is required. The completed +step already contains tail and terminal bytes. Updates are valid between +`resume` and `advance`, 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..945b49415 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 boundary key, preserving its authored JSON type. */ + key?: string | number; +} + +/** Bytes and continuation state produced by a streaming session step. */ +export interface StreamStep { + /** Complete bytes produced by this semantic step. */ + bytes: Buffer; + /** Whether the document tail and terminal record have been emitted. */ + done: boolean; + /** Runtime occurrence waiting for `resume()`, present only at a boundary. */ + 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,24 @@ 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; + advance(): 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 +394,25 @@ 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 + * only the pending occurrence. An optional `update()` may follow for an + * `updatable` occurrence, then `advance()` discovers the next occurrence or + * returns the document tail and terminal record. * * ```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); + * step = session.advance(); + * res.write(step.bytes); * } - * - * res.end(session.finish({})); + * res.end(); * ``` */ export class StreamingSession { @@ -387,49 +423,56 @@ export class StreamingSession { this.#native = native; } - /** Number of compile-time boundaries declared by this entry. */ - get boundaryCount(): number { - return this.#native.boundaryCount; + /** Render until the first runtime boundary occurrence or terminal. */ + start(state: object | string): StreamStep { + return toStreamStep(this.#native.start(toStateJson(state))); } - /** Whether the terminal record has been written. */ - get finished(): boolean { - return this.#native.finished; - } - - /** - * 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); + /** Commit the pending occurrence through its checkpoint, then stop. */ + resume( + instanceId: number, + state: object | string, + mode: BoundaryMode = "final", + ): StreamStep { + return toStreamStep(this.#native.resume(instanceId, toStateJson(state), mode)); } - /** Render everything before the first boundary. */ - writeShell(state: object | string): Buffer { - return this.#native.writeShell(toStateJson(state)); + /** Write following parent bytes and discover the next boundary or terminal. */ + advance(): StreamStep { + return toStreamStep(this.#native.advance()); } - /** Render and commit the next boundary in declaration order. */ - writeBoundary( - boundary: number, - state: object | string, - mode: BoundaryMode = "final", - ): Buffer { - return this.#native.writeBoundary(boundary, toStateJson(state), mode); + /** Push a projected state patch to a committed `updatable` occurrence. */ + update(instanceId: number, patch: object | string): Buffer { + return this.#native.update(instanceId, toStateJson(patch)); } +} - /** Push a projected state patch to a committed `updatable` boundary. */ - update(boundary: number, state: object | string): Buffer { - return this.#native.update(boundary, toStateJson(state)); +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..69f68382f 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'; @@ -72,11 +76,35 @@ before(() => { +

between

+`); + writeFileSync(join(appDir, 'index-stream-keys.html'), ` + + + + + + + +

between

+ + + +
tail
+ + +`); + writeFileSync(join(appDir, 'index-stream-empty.html'), ` + + + +

boundary-free

+ `); }); @@ -250,134 +278,187 @@ 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 stops at the checkpoint and advance discovers the next boundary', () => { + const session = streamingProtocol().streamResponse(streamOptions); + const start = session.start({}); + const first = boundaryOf(start); + 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 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, + resumedFirst.bytes, + next.bytes, + resumedSecond.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 keys on static sibling occurrences', () => { + const entry = 'index-stream-keys.html'; + const session = streamingProtocol(entry).streamResponse({ + entry, + requestPath: '/', + }); + const state = { + stringId: 'alpha', + firstLabel: 'first', + numberId: 20, + secondLabel: 'second', + }; + + const start = session.start(state); + const first = boundaryOf(start); + assert.equal(first.key, 'alpha'); + + 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.notEqual(second.declarationId, first.declarationId); + assert.equal(second.key, 20); + assert.match(next.bytes.toString('utf8'), /between/); + + 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('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 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', + }); - 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 next = session.advance(); + const second = boundaryOf(next); + 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 an unknown boundary mode', () => { + test('rejects advance before a boundary has been resumed', () => { const session = streamingProtocol().streamResponse(streamOptions); - session.writeShell({}); assert.throws( - () => - session.writeBoundary( - session.boundary('first'), - { firstLabel: 'alpha' }, - 'sometimes' as 'final', - ), - /unknown boundary mode/, + () => session.advance(), + /start must be called before this operation/, ); - }); - - 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/); - }); - - test('an out-of-order finish leaves the session usable', () => { - 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); + const start = session.start({}); + assert.throws( + () => session.advance(), + /there is no committed boundary to advance past/, + ); - session.writeBoundary(session.boundary('second'), { secondLabel: 'beta' }); - assert.ok(session.finish({}).length > 0); - assert.equal(session.finished, true); + 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('keeps concurrent sessions independent', () => { - const protocol = streamingProtocol(); - const a = protocol.streamResponse(streamOptions); - const b = protocol.streamResponse(streamOptions); + test('start completes a boundary-free document', () => { + const entry = 'index-stream-empty.html'; + const session = streamingProtocol(entry).streamResponse({ + entry, + requestPath: '/', + }); + const done = session.start({}); - 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(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('')); + }); - assert.ok(fromA.includes('from-a')); - assert.ok(!fromA.includes('from-b')); - assert.ok(fromB.includes('from-b')); - assert.ok(!fromB.includes('from-a')); + test('does not expose legacy wrapper members', () => { + const session = streamingProtocol().streamResponse(streamOptions); + 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 +490,29 @@ 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); + assert.equal(step.done, false); + assert.equal(step.boundary, undefined); // Only reached if the client already has the bytes above. await clientSawFirstBoundary; - await write(response, session.writeBoundary(second, { secondLabel: 'beta' })); - response.end(session.finish({})); + 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) => { serverError = error; response.destroy(); @@ -455,8 +547,9 @@ 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.trimEnd().endsWith('')); + assert.ok(received.includes('')); } finally { server.close(); await once(server, 'close');