Skip to content

perf(handler): stop native attributes leaking into component state - #469

Merged
Mohamed Mansour (mohamedmansour) merged 1 commit into
mainfrom
mohamedmansour-fix-component-attribute-leak
Aug 24, 2026
Merged

perf(handler): stop native attributes leaking into component state#469
Mohamed Mansour (mohamedmansour) merged 1 commit into
mainfrom
mohamedmansour-fix-component-attribute-leak

Conversation

@mohamedmansour

@mohamedmansour Mohamed Mansour (mohamedmansour) commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

process_attribute accumulated every non-skipped attribute into context.component_attrs, but only component opening tags carry attr_start. Attributes on native elements were collected too, and the next component fragment adopted them as local variables.

<div title="{{nativeTitle}}"></div>
<my-comp></my-comp>   <!-- {{title}} resolved to the div's value, not state -->

<my-comp> declares no attributes at all, yet rendered native instead of the global title. Native element state silently leaks into the next component's scope.

Fix

A new collecting_component_attrs flag on WebUIProcessContext scopes the accumulation window:

  • attr_start opens the window (only the parser sets it, and only on component elements).
  • Entering a component closes it, so trailing native attributes cannot leak into a sibling.
  • The streaming VM clears it on component enter and exit, so a leak cannot cross a component or a chunk boundary.

Every component_attrs.insert is now gated on the flag. attr_skip handling is unchanged.

Allocation effect

attr_start previously did context.component_attrs = HashMap::new(), discarding the capacity-preserving map that process_component/begin_component had just pulled from the scope pool. The pooling was defeated on the very next attribute. Clearing instead of replacing restores it.

A deterministic counting-allocator harness over 500 component instances, measuring only the render call:

allocations bytes
main 2003 129,815
this PR 1505 8,303
delta -498 (-24.9%) -121,512 (-93.6%)

Exactly one allocation per component instance is avoided.

No timing claim is made. Wall-clock spread on this machine exceeds the effect size: running pristine main against its own saved criterion baseline reproduces the same 3-19% "regressions" on handler_state_depth, so those readings are machine noise, not signal. The deterministic allocation counts above are the claim, and there is no measured regression attributable to this change.

Tests

Two regression tests, both verified failing on pristine main and passing here:

  • native_dynamic_attribute_does_not_leak_into_next_component (direct render). Main produced <my-comp>native</my-comp>, expected global.
  • native_attribute_does_not_leak_into_next_streamed_component (streaming VM path). Main produced <span>native</span> inside the boundary chunk.

Output is byte-identical to main everywhere except the intentionally fixed leak case, and the full workspace suite passes unchanged.

Gate

cargo xtask check: license-headers, fmt, clippy, proto (drift check), deny, test, build, build (wasm), build (examples), bench (validate) all pass.

The docs phase fails with PROJ-C013: Adapter module graph is incomplete or inconsistent. This is pre-existing and unrelated. The identical failure was reproduced on pristine main (2efb60e) before any change here, and is deliberately not addressed in this PR.

Scope

Touches only the handler attribute-collection gate plus its DESIGN.md contract note. No manifest, lockfile, benchmark, or example changes.

`process_attribute` accumulated every non-skipped attribute into
`component_attrs`, but only component opening tags carry `attr_start`.
Attributes on native elements were therefore collected too, and the next
component fragment adopted them as local variables — so
`<div title="{{nativeTitle}}"></div><my-comp>` rendered `my-comp` with
`title` bound to the native element's value instead of falling back to
state.

Gate accumulation behind a new `collecting_component_attrs` flag on
`WebUIProcessContext`. `attr_start` opens the window, entering a component
closes it, and the streaming VM clears it on component enter and exit so a
leak cannot cross a component or a chunk boundary.

Clearing the pooled attribute map on `attr_start` instead of replacing it
with `HashMap::new()` also stops discarding the recycled allocation. A
deterministic counting-allocator harness over 500 component instances
measures 2003 -> 1505 allocations (-498, one per component) and
129,815 -> 8,303 bytes.

Regression tests cover both render paths and fail on pristine main:
`native_dynamic_attribute_does_not_leak_into_next_component` (direct) and
`native_attribute_does_not_leak_into_next_streamed_component` (streaming).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mohamedmansour Mohamed Mansour (mohamedmansour) changed the title fix(handler): stop native attributes leaking into component state perf(handler): stop native attributes leaking into component state Aug 24, 2026
@mohamedmansour
Mohamed Mansour (mohamedmansour) merged commit 8cce082 into main Aug 24, 2026
36 checks passed
@mohamedmansour
Mohamed Mansour (mohamedmansour) deleted the mohamedmansour-fix-component-attribute-leak branch August 24, 2026 19:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a correctness bug in webui-handler where native HTML attributes were being accumulated into component_attrs and could leak into the next component’s local-variable scope, and also restores intended HashMap pooling behavior for fewer allocations during render.

Changes:

  • Add collecting_component_attrs to explicitly scope when component_attrs may be populated (opened by attr_start, closed on component enter/exit and on new streaming contexts).
  • Avoid defeating the scope-map pool by clearing (not replacing) component_attrs on attr_start.
  • Add regression tests covering both direct render and streaming VM paths, and document the contract in DESIGN.md.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
DESIGN.md Documents the attr_start/attr_skip “component attribute collection window” contract and clarifies that native attributes cannot become later component locals.
crates/webui-handler/tests/streaming_v2.rs Adds a streaming regression test to ensure native attributes don’t affect subsequent component state resolution.
crates/webui-handler/src/streaming/vm.rs Clears collecting_component_attrs on component enter/exit to prevent state leakage across component boundaries in the VM path.
crates/webui-handler/src/streaming/session.rs Initializes collecting_component_attrs to false when constructing the per-step streaming render context.
crates/webui-handler/src/streaming/inventory.rs Updates test context construction to include the new collecting_component_attrs field.
crates/webui-handler/src/lib.rs Introduces and gates attribute accumulation behind collecting_component_attrs, clears pooled maps on attr_start, and adds a direct-render regression test.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Mohamed Mansour (mohamedmansour) added a commit that referenced this pull request Aug 26, 2026
## Release

Bumps WebUI to `0.0.26`.

Previous release tag: `v0.0.25`

## Changes since `v0.0.25`

Features:

- Python hosts now have an official typed PyO3 renderer package with
buffered, partial, template, token, and host-driven streaming APIs
(feat: add official Python renderer package #453 by @mohamedmansour).
- Progressive SSR can place transport-flushed streaming boundaries
inside reusable components while preserving component ownership and
continuation state (feat: support component-local streaming boundaries
#460 by @mohamedmansour).
- Components can defer compiler-owned hydration until interaction, with
router preload handoff and a combined lazy-render policy (feat: add
compiler-driven interaction hydration #485 by @mohamedmansour).
- Builds can opt into global Light DOM CSS while preserving explicit
authored Shadow roots and deterministic style closures (feat: make Light
DOM a global CSS opt-in #429 by @mohamedmansour).
- State projection supports application-owned TypeScript 7.0.2 while
retaining the TypeScript 6 migration path (feat: support TypeScript 7
projection compilation #452 by @mohamedmansour).
- WebUI Press supports layout-scoped compile-time named regions with
fallback HTML, state, components, and scripts (feat(press): add
compile-time named regions #484 by @mohamedmansour).
- FAST v2 and v3 gain plugin-owned local and npm component discovery
with validated FAST template transformation (feat: add plugin-owned FAST
component discovery #378 by @janechu).

Fixes:

- Missing condition identifiers are treated as falsy before negation and
logical evaluation, aligning SSR with the browser runtime (fix: negate
missing condition paths correctly #449 by @mohamedmansour).
- FAST route state now scales through escaped scalar kebab-case
attributes shared by FAST v2 and v3 (fix: scale FAST route state with
scalar attributes #450 by @janechu).
- Dynamic Link-mode components wait for native stylesheet readiness
across navigation and component assets, preventing unstyled flashes
(fix: prevent dynamic component stylesheet flashes #454 by
@mohamedmansour).
- Authored component definitions defer whenever compiled template
metadata has not arrived, including ordinary router navigation (fix:
defer authored define() whenever template metadata is missing #461 by
@mohamedmansour).
- Client structural updates preserve authored order and sibling
ownership for shared slots and raw HTML ranges (fix: preserve source
order for shared structural slots #465 by @mohamedmansour, fix: preserve
siblings around raw HTML updates #466 by @mohamedmansour).
- Native-element attributes no longer leak into the local state of a
later component (perf(handler): stop native attributes leaking into
component state #469 by @mohamedmansour).
- Templates-and-state-only SSR bootstrap payloads no longer require
component style metadata (fix: allow SSR bootstrap without component
styles #475 by @mohamedmansour).
- The high-level Rust `serve_request` path now preserves complete render
options, including CSP nonces, while sharing the same entry and request
path with partial rendering (fix: forward CSP nonces through
serve_request #488 by @mohamedmansour).
- Projection compilation bounds source reads, excludes binary and
unsupported-loader inputs from semantic analysis, and preserves
deterministic cleanup under large esbuild graphs (fix: bound projection
adapter source reads #489 by @mohamedmansour).
- Router pending UI remains mounted through pre-commit work and settles
at the synchronous DOM commit, with stale, aborted, and re-entrant
navigation cleanup kept generation-safe (fix: settle router pending UI
during navigation commits #491 by @mohamedmansour).

Docs:

- The documentation site adds accessible responsive navigation, improved
reading layouts, stronger search behavior, and Playground recovery
states (feat: polish WebUI documentation experience #459 by
@mohamedmansour).
- Slot-resolution implementation guidance now documents pre-order
lookup, pending placement ownership, and marker handling (chore: clarify
slot resolution comments #471 by @mohamedmansour).
- Repeated `w-ref` behavior is now explicit: refs are scalar and the
last wired occurrence wins, while stable authored IDs or item components
provide identity-based lookup (chore: clarify repeated w-ref behavior
#490 by @mohamedmansour).

Maintenance:

- Release and package policy metadata now constrains the transitive h2
advisory, uses SPDX NuGet licensing, and classifies publishing jobs
correctly (chore: allow constrained h2 advisory #451 by @janechu, fix:
use modern NuGet license metadata #457 by @janechu, chore: mark
publishing jobs as release jobs #458 by @janechu).
- The development and CI Rust toolchain is updated to 1.98 with the
resulting warnings resolved (Update rust toolchain version and fix
clippy warning #462 by @telecos).
- Release builds use Thin LTO to retain cross-crate optimization with
faster linking (perf: switch release builds to Thin LTO #464 by
@mohamedmansour).
- Handler and expression hot paths reduce attribute vtable calls, render
lookup and scope allocations, and single-term condition overhead
(perf(handler): centralize HTML attribute writing in ResponseWriter #467
by @mohamedmansour, perf(expressions): fast-path single-term conditions
#470 by @mohamedmansour, perf(handler): reduce render lookup and scope
allocations #472 by @mohamedmansour).
- Node rendering can reuse immutable prepared-state snapshots and
bounded per-route output capacity hints (perf(node): reuse prepared
state across renders #477 by @mohamedmansour, perf: reuse Node render
output capacity #478 by @mohamedmansour).
- Framework hydration releases bootstrap data sooner and reduces
allocations for bindings, empty hosts, and visible conditionals (perf:
reduce framework hydration allocations #479 by @mohamedmansour, perf:
release SSR bootstrap memory after hydration #480 by @mohamedmansour,
perf: reduce empty template host overhead #482 by @mohamedmansour, perf:
remove visible conditional anchors #483 by @mohamedmansour).
- Benchmark tooling now renders the full contact workload, runs
Criterion baselines per target, and restores the streaming hydration
fixture (fix(bench): render contacts in contact-book benchmark #468 by
@mohamedmansour, fix(xtask): run Criterion benchmarks per target #473 by
@mohamedmansour, fix: repair streaming hydration benchmark fixture #476
by @mohamedmansour).

## Validation

- `cargo xtask check`

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants