Skip to content

Non-deterministic <head> CSS/template emission order for reachable components #520

Description

@janechu

Problem

Route-reachable components are collected into a HashSet<String> in WebUIHandler's ordinary body_end rendering path (crates/webui-handler/src/lib.rs) before emitting:

  • CSS <link> hrefs (Link CSS strategy)
  • CSS module specifiers (Module CSS strategy)
  • Non-split component template emission (HandlerPlugin::emit_templates)
  • Split WebUI template payloads (HandlerPlugin::collect_template_payloads)

Rust's HashSet iteration order depends on a per-process randomized hash seed (RandomState), so the emission order of these items can change between renders and process restarts, even though the underlying reachable-component list is already produced in a deterministic, document/traversal order upstream:

collect_inventoryable_components_from_stack (crates/webui-handler/src/route_handler.rs) walks the protocol's fragment graph with a LIFO stack (reversed so children pop in document order) and explicitly preserves first-discovery order into a Vec<String>, with its own comment stating the intent:

// Preserve first-discovery (document/traversal) order so Link-strategy
// CSS `<link>` tags are emitted in source order, not alphabetically.
// `seen_components` dedups; `component_ids` keeps order (a plain
// `HashSet` would lose it).

That ordered Vec<String> is then discarded into a HashSet<String> one call site later (WebUIHandler's body_end block), which throws away the ordering guarantee before it reaches <head> emission.

Why this matters

If two components apply competing same-specificity CSS (order, position, float, grid placement, etc.) to elements inside a <for> loop or elsewhere on the page, cascade resolution can flip depending on which stylesheet's <link> tag loads last. Since the <for> reconciliation itself (server process_*_for_loop in lib.rs, client element/diff.ts) is already fully array-order deterministic, non-deterministic <head> CSS ordering is the most plausible mechanism for repeated/templated content appearing to land in different visual positions across renders or process restarts, with no change to the markup itself.

Proposed fix

Keep the already-ordered Vec<String> produced by collect_reachable_component_order_for_request instead of collecting it into a HashSet<String> at the body_end call site. Update HandlerPlugin::emit_templates, HandlerPlugin::collect_template_payloads, and BootstrapExtensionContext::components to take &[String] to match. See #519 for a candidate implementation and regression test.

Discussion

Reviewer feedback on #519 raised the concern that the HashSet was intentional — i.e. that ordering here "can't be guaranteed" or that fixing this could add runtime cost. This issue exists to track and resolve that discussion independently of the specific patch, since the underlying question (should reachable-component emission order be a guaranteed, deterministic contract?) affects the API shape of HandlerPlugin regardless of which patch lands.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions