Skip to main content

bevy_react/
reconcile.rs

1//! The two Bevy systems that drive the boundary each frame:
2//! - [`apply_js_ops`] drains reconciler op batches and mutates the UI tree.
3//! - [`collect_ui_events`] reports interactions back to the JS thread.
4
5use crate::animations::AnimatedNode;
6use crate::canvas::{CanvasSurface, blank_canvas_image, clamp_physical_size};
7use crate::portal::{RPortal, blank_portal_image};
8use crate::surface::{RSurface, SurfaceVirtualPointer};
9use accesskit::Role;
10use bevy::a11y::AccessibilityNode;
11use bevy::ecs::system::SystemParam;
12use bevy::image::Image;
13use bevy::input_focus::tab_navigation::TabIndex;
14use bevy::input_focus::{AutoFocus, FocusGained, FocusLost};
15use bevy::picking::events::{Click, Drag, Enter, Leave, Pointer, Press, Release};
16use bevy::picking::pointer::{PointerButton, PointerId};
17use bevy::platform::collections::HashSet;
18use bevy::prelude::*;
19use bevy::text::{EditableText, FontCx, LayoutCx, TextCursorStyle, TextEdit, TextEditChange};
20use bevy::ui::FocusPolicy;
21use bevy::ui::RelativeCursorPosition;
22use bevy::ui::widget::NodeImageMode;
23use bevy::ui::{ComputedNode, ScrollPosition, UiGlobalTransform};
24
25use crate::anchor::{AnchorScaling, Anchored};
26use crate::bridge::{
27    CanvasSizeTracker, FocusState, HoverState, JsBridge, PointerHandlers, RNode, ScrollListener,
28    ScrollStep, SpanKind, StyleVariants, WheelListener,
29};
30use crate::filter::{FilterAssets, FilterMaterial, FilterMaterialCache, filter_material};
31use crate::plugin::Fonts;
32use crate::protocol::{NodeId, Op, Outbound, Props, ROOT_ID, Style, UiEvent};
33use crate::transition::{ScrollTransitionState, apply_scroll_transition};
34use crate::ui_map::{
35    AtlasLayoutCache, apply_atlas, apply_opacity, apply_style, apply_style_masked,
36    apply_text_style, image_node, overlay_style, parse_color, resolved_text_style, text_layout,
37};
38
39/// Live instrumentation of the [`apply_js_ops`] hot path. Updated once per frame
40/// that applies at least one reconciler op (empty frames leave it untouched), so
41/// a benchmark driver — or any consumer — can poll `applied_count` to detect
42/// "my flushed batch has landed" and read the timing of the most recent batch.
43///
44/// Note `last_translate` measures only the op→command *queuing* in
45/// [`apply_js_ops`]; the queued `Commands` (entity spawn / component insert /
46/// hierarchy) execute later at a sync point, and `bevy_ui` layout later still —
47/// neither is included here. `last_apply_end` is exposed so a downstream timer
48/// can bracket those phases (e.g. up to `UiSystems::Layout`).
49///
50/// Timings are wall-clock, measured on native only; on web they stay zero/`None`
51/// (`std::time::Instant` is unavailable on wasm).
52#[derive(Resource, Default, Debug, Clone, Copy)]
53pub struct OpApplyStats {
54    /// Count of non-empty op batches applied since startup (one increment per
55    /// frame that applied at least one op).
56    pub applied_count: u64,
57    /// Count of [`Op::Reset`]s applied (a cold hot-reload tears the tree down).
58    /// Devtools uses it to clear its warning-dedup state, so a reloaded app's
59    /// re-decoded invalid values flag again (the JS mirror was also reset).
60    pub reset_count: u64,
61    /// Like `applied_count`, but only counting applies that included at least
62    /// one APP flush (per-batch origin flags — see [`FlushFlags`]). The
63    /// devtools panel's own repaints bump only `applied_count`; batch-stats
64    /// emission keys off this so the panel never reports (and re-triggers
65    /// itself with) its own commits. With no flags channel wired (headless
66    /// tests), every apply counts as app.
67    pub app_applied_count: u64,
68    /// Number of ops in the most recently applied batch.
69    pub last_ops: usize,
70    /// How long the most recently applied ops idled in the channel across the
71    /// frame boundary: the OLDEST coalesced batch's [`FlushStamps`] stamp →
72    /// this frame's [`FrameStamp`]. Structural queue wait, typically ~one
73    /// vsync period (a Bevy-triggered commit always lands just after that
74    /// frame's drain); can exceed one frame when batches coalesce. Zero when
75    /// the stamp channel or frame stamp is missing (headless tests) and on web.
76    pub last_frame_wait: std::time::Duration,
77    /// The in-frame leg of the same span: max(batch stamp, frame start) →
78    /// the start of [`apply_js_ops`] — time eaten by schedules/systems that
79    /// ran before the drain this frame. With no [`FrameStamp`] present the
80    /// whole send→apply span lands here. Zero when no stamp channel is wired
81    /// (headless tests) and on web.
82    pub last_pre_apply: std::time::Duration,
83    /// Time spent translating the most recent batch into ECS commands — the
84    /// [`apply_js_ops`] body only. Excludes command execution and layout.
85    pub last_translate: std::time::Duration,
86    /// The instant [`apply_js_ops`] finished queuing the most recent batch
87    /// (native only). A later system can subtract this from a post-layout instant
88    /// to time command execution + layout.
89    pub last_apply_end: Option<std::time::Instant>,
90}
91
92/// Receiver of per-batch send instants, stamped by the JS host's `op_flush`
93/// right before each batch enters the ops channel (see `js_thread.rs`). Both
94/// FIFOs are aligned (stamp sent first), so draining one stamp per received
95/// batch keeps them in lockstep. Feeds [`OpApplyStats::last_frame_wait`] and
96/// [`OpApplyStats::last_pre_apply`].
97#[derive(Resource)]
98pub struct FlushStamps(pub(crate) crossbeam_channel::Receiver<std::time::Instant>);
99
100/// The instant Bevy's `First` schedule ran this frame (native only; stays
101/// `None` on web and in headless tests that never add [`mark_frame_start`]).
102/// The frame boundary that splits [`OpApplyStats::last_frame_wait`] from
103/// `last_pre_apply`.
104#[derive(Resource, Default, Debug, Clone, Copy)]
105pub struct FrameStamp(pub Option<std::time::Instant>);
106
107/// Stamp the frame's start. Registered in `First` (native only).
108#[cfg(not(target_arch = "wasm32"))]
109pub(crate) fn mark_frame_start(mut stamp: ResMut<FrameStamp>) {
110    stamp.0 = Some(std::time::Instant::now());
111}
112
113/// Receiver of per-batch devtools-origin flags (`true` = the devtools panel's
114/// own React container flushed the batch), sent by the JS host's `op_flush`
115/// with the same aligned-FIFO discipline as [`FlushStamps`]. Feeds
116/// [`OpApplyStats::app_applied_count`].
117#[derive(Resource)]
118pub struct FlushFlags(pub(crate) crossbeam_channel::Receiver<bool>);
119
120/// The per-batch side channels (`Option`: absent in headless unit tests),
121/// bundled as one `SystemParam` so [`apply_js_ops`] stays within Bevy's
122/// 16-parameter limit.
123#[derive(SystemParam)]
124pub struct FlushMeta<'w> {
125    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
126    stamps: Option<Res<'w, FlushStamps>>,
127    flags: Option<Res<'w, FlushFlags>>,
128    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
129    frame: Option<Res<'w, FrameStamp>>,
130}
131
132/// The asset stores + caches the op-apply path builds components from: the
133/// `<image atlas>` `TextureAtlasLayout`s and the `filter` style's
134/// [`FilterMaterial`]s (plus the shared white pixel). Bundled as one `SystemParam`
135/// so [`apply_js_ops`] stays under Bevy's per-system parameter limit.
136#[derive(SystemParam)]
137pub struct UiAssets<'w> {
138    layouts: ResMut<'w, Assets<TextureAtlasLayout>>,
139    atlas_cache: ResMut<'w, AtlasLayoutCache>,
140    filter_materials: ResMut<'w, Assets<FilterMaterial>>,
141    filter_cache: ResMut<'w, FilterMaterialCache>,
142    filter_assets: Res<'w, FilterAssets>,
143}
144
145/// Apply every queued reconciler op to the ECS. Runs in `Update`; ops simply
146/// queue in the channel until this drains them, so startup ordering is a
147/// non-issue.
148#[allow(clippy::too_many_arguments)]
149pub fn apply_js_ops(
150    mut commands: Commands,
151    mut bridge: ResMut<JsBridge>,
152    assets: Res<AssetServer>,
153    fonts: Res<Fonts>,
154    mut images: ResMut<Assets<Image>>,
155    // Sprite-sheet grids for `<image atlas>`, plus the cache that keeps repeated
156    // commits from leaking a `TextureAtlasLayout` per frame (see `AtlasLayoutCache`).
157    // Asset stores + caches for `<image atlas>` and the `filter` material, bundled
158    // into one `SystemParam` so `apply_js_ops` stays within Bevy's 16-param limit.
159    mut ui_assets: UiAssets,
160    children: Query<&Children>,
161    rnodes: Query<&RNode>,
162    // On re-render the entity's kind isn't on the op, so we detect a `<button>` by
163    // its marker to keep re-asserting its `FocusPolicy::Block` default (see
164    // `apply_button_focus_default`) that the per-commit `apply_style` resets to `Pass`.
165    buttons: Query<(), With<Button>>,
166    // The persistent world-anchor overlay layer (a child of the root). It is
167    // infrastructure, not a reconciler node, so `Op::Reset` must preserve it and
168    // the end-of-batch hierarchy rebuild must keep it in the root's children.
169    anchor_layer: Query<Entity, With<crate::anchor::AnchorLayer>>,
170    mut editables: Query<&mut EditableText>,
171    // Controlled `scrollTop`/`scrollLeft`: every `Node` has a `ScrollPosition`
172    // (it's a required component), so `get_mut(e)` succeeds for any node — we only
173    // write the axis React controls, and only when it diverges from the live value.
174    // `ComputedNode` lets us clamp the write to the scrollable range, like the
175    // wheel handler does, so a controlled offset can't overscroll. With a scroll
176    // transition the offset is eased: the controlled value sets the target rather
177    // than `ScrollPosition` directly.
178    mut scroll_query: Query<(
179        &mut ScrollPosition,
180        &ComputedNode,
181        Option<&mut ScrollTransitionState>,
182    )>,
183    mut a11y_nodes: Query<&mut AccessibilityNode>,
184    // A `<text>` *root* carries a layout `Node`; a span (nested `<text>` or a
185    // bare string) does not. Used on update to re-apply layout/visual/transform
186    // style to roots only — spans must never get a `Node`.
187    text_roots: Query<(), With<Node>>,
188    mut stats: ResMut<OpApplyStats>,
189    // The stamp + origin-flag side channels; absent in headless unit tests
190    // (stamps also stay empty on web). See [`FlushMeta`].
191    #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] meta: FlushMeta,
192) {
193    // Drain all pending batches first so we don't hold an immutable borrow of
194    // `bridge` while mutating `bridge.nodes` below.
195    let mut ops: Vec<Op> = Vec::new();
196    #[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
197    let mut batches = 0usize;
198    while let Ok(batch) = bridge.ops_rx.try_recv() {
199        ops.extend(batch);
200        batches += 1;
201    }
202    if ops.is_empty() {
203        return;
204    }
205    let op_count = ops.len();
206    #[cfg(not(target_arch = "wasm32"))]
207    let started = std::time::Instant::now();
208    // One stamp per received batch (aligned FIFOs — see `FlushStamps`); the
209    // OLDEST is when the earliest coalesced batch entered the channel.
210    #[cfg(not(target_arch = "wasm32"))]
211    let first_stamp = meta.stamps.as_ref().and_then(|stamps| {
212        let mut first = None;
213        for _ in 0..batches {
214            if let Ok(stamp) = stamps.0.try_recv() {
215                first.get_or_insert(stamp);
216            }
217        }
218        first
219    });
220    // One origin flag per received batch (aligned FIFOs — see [`FlushFlags`]);
221    // any non-devtools flush makes this an APP apply. A missing channel
222    // (headless tests) or a missing flag counts as app.
223    let any_app = match &meta.flags {
224        Some(flags) => {
225            let mut any_app = false;
226            for _ in 0..batches {
227                match flags.0.try_recv() {
228                    Ok(devtools) => any_app |= !devtools,
229                    Err(_) => any_app = true,
230                }
231            }
232            any_app
233        }
234        None => true,
235    };
236    debug!("applying {op_count} reconciler op(s)");
237
238    // Parents whose child ORDER diverged from the ECS this batch (same-parent
239    // re-appends and every `Insert`); they get one `replace_children` after the
240    // loop instead of a per-op O(siblings) splice — mass reorders are O(ops) +
241    // one O(children) rebuild, not quadratic. First-time attaches still queue an
242    // O(1) `add_child` per op (a same-batch ancestor removal must reach the child
243    // recursively), and removals don't dirty their parent at all: despawn's
244    // relationship cleanup drops the child from `Children` preserving the order
245    // of the rest.
246    let mut dirty: HashSet<NodeId> = HashSet::new();
247
248    for op in ops {
249        match op {
250            Op::Reset => {
251                stats.reset_count += 1;
252                // Despawn the whole tree under the root (recursive), then reset
253                // the id map to just the root. Stale ops referencing despawned
254                // ids resolve to None afterwards and are skipped harmlessly.
255                if let Some(&root) = bridge.nodes.get(&ROOT_ID)
256                    && let Ok(kids) = children.get(root)
257                {
258                    for child in kids.iter() {
259                        // The anchor layer is persistent infrastructure: keep it,
260                        // but despawn the reconciler overlays reparented under it
261                        // so a reload doesn't leave stale duplicate overlays.
262                        if anchor_layer.contains(child) {
263                            if let Ok(overlays) = children.get(child) {
264                                for overlay in overlays.iter() {
265                                    commands.entity(overlay).despawn();
266                                }
267                            }
268                        } else {
269                            commands.entity(child).despawn();
270                        }
271                    }
272                }
273                // Detached roots (`<surface>`/`<root>`) aren't under `root`, so the
274                // child-despawn above misses them. On a cold reload the old React
275                // tree is discarded without unmount lifecycle (no
276                // `detachDeletedInstance`), so despawn them here too — otherwise a
277                // stale surface subtree keeps rendering into its texture, and a
278                // stale `<root>` stays on screen.
279                for id in bridge.surfaces.iter().chain(bridge.roots.iter()) {
280                    if let Some(&e) = bridge.nodes.get(id) {
281                        commands.entity(e).despawn();
282                    }
283                }
284                bridge.nodes.retain(|&id, _| id == ROOT_ID);
285                bridge.props_cache.clear();
286                bridge.text_styles.clear();
287                bridge.spans.clear();
288                bridge.editable_inputs.clear();
289                bridge.surfaces.clear();
290                bridge.roots.clear();
291                bridge.editable_values.clear();
292                bridge.editable_selections.clear();
293                bridge.editable_select_handlers.clear();
294                bridge.editable_focus_handlers.clear();
295                bridge.editable_pending_selection.clear();
296                bridge.scroll_positions.clear();
297                // The root persists but its children were just despawned; the shadow
298                // tree is fully rebuilt by the ops that follow. Drop any pre-reset
299                // dirty parents too — the reloaded app re-uses node ids, and its own
300                // ops re-dirty whatever it rebuilds.
301                bridge.siblings.clear();
302                bridge.child_list.clear();
303                bridge.parent_of.clear();
304                bridge.surface_parent.clear();
305                bridge.child_surfaces.clear();
306                dirty.clear();
307            }
308            Op::Create {
309                id,
310                kind,
311                props,
312                text,
313            } => {
314                // Attribute apply-time parse warnings (colors, fonts, …) fired
315                // while building this node to its id (see `crate::diag`).
316                let _diag = crate::diag::node_scope(id);
317                let entity = match kind.as_str() {
318                    // A `<text>` root: a UI node carrying the text block + style.
319                    // A single-string child rides inline as `text` (no child span).
320                    "text" => {
321                        let mut ec = commands.spawn(RNode(id));
322                        apply_style(&mut ec, &props.style);
323                        ec.insert(Text::new(text.clone().unwrap_or_default()));
324                        apply_text_style(&mut ec, &props.style, &fonts);
325                        if let Some(layout) = text_layout(&props.style) {
326                            ec.insert(layout);
327                        }
328                        apply_anchor(&mut ec, &props);
329                        ec.id()
330                    }
331                    // A nested `<text>`: a styled span (no layout box of its own).
332                    // A single-string child rides inline as `text`.
333                    "textSpan" => {
334                        let mut ec =
335                            commands.spawn((RNode(id), TextSpan(text.clone().unwrap_or_default())));
336                        apply_text_style(&mut ec, &props.style, &fonts);
337                        ec.id()
338                    }
339                    // A `<canvas>`: a styled node carrying an `ImageNode` whose
340                    // texture the canvas system paints from the display list. The
341                    // image stretches to fill the node's laid-out box.
342                    "canvas" => {
343                        let handle = images.add(blank_canvas_image());
344                        let mut node_img = ImageNode::new(handle);
345                        node_img.image_mode = NodeImageMode::Stretch;
346                        let mut ec = commands.spawn(RNode(id));
347                        apply_style(&mut ec, &props.style);
348                        ec.insert((
349                            node_img,
350                            CanvasSurface::new(props.draw.clone().unwrap_or_default()),
351                            CanvasSizeTracker::default(),
352                        ));
353                        apply_style_variants(&mut ec, &props);
354                        apply_pointer_handlers(&mut ec, &props);
355                        apply_animated(&mut ec, &props);
356                        apply_anchor(&mut ec, &props);
357                        ec.id()
358                    }
359                    // A `<portal>`: a styled node carrying an `ImageNode` whose
360                    // texture is an offscreen render target the [`crate::portal`]
361                    // registry owns. Starts on a blank placeholder; `bind_portals`
362                    // swaps in the real target texture for `target` once it exists.
363                    "portal" => {
364                        let handle = images.add(blank_portal_image());
365                        let mut node_img = ImageNode::new(handle);
366                        node_img.image_mode = NodeImageMode::Stretch;
367                        let mut ec = commands.spawn(RNode(id));
368                        apply_style(&mut ec, &props.style);
369                        ec.insert((node_img, RPortal(props.target.clone().unwrap_or_default())));
370                        apply_style_variants(&mut ec, &props);
371                        apply_pointer_handlers(&mut ec, &props);
372                        apply_animated(&mut ec, &props);
373                        apply_anchor(&mut ec, &props);
374                        ec.id()
375                    }
376                    // A `<surface>`: a styled container whose subtree renders into
377                    // an offscreen image instead of the on-screen UI. It is a
378                    // **detached UI root** — `crate::surface::bind_surfaces`
379                    // points its `UiTargetCamera` at the surface's offscreen UI
380                    // camera, and the child-attach ops below keep it out of the
381                    // on-screen Bevy hierarchy. The root fills the texture by
382                    // default (user `style` overrides). Pointer/click events on it
383                    // arrive via the surface picking path (`collect_surface_events`),
384                    // not the legacy `Interaction` focus path.
385                    "surface" => {
386                        let style = overlay_style(&surface_root_base(), &props.style);
387                        let mut ec = commands.spawn(RNode(id));
388                        apply_style(&mut ec, &style);
389                        ec.insert(RSurface(props.target.clone().unwrap_or_default()));
390                        apply_anchor(&mut ec, &props);
391                        ec.id()
392                    }
393                    // A `<root>`: the screen-space twin of `<surface>` — a styled
394                    // container that is a **detached UI root** on the default UI
395                    // camera (no `UiTargetCamera`), for overlays that must float
396                    // above and stay out of the app's own tree (the devtools
397                    // panel). The child-attach ops keep it out of the Bevy
398                    // hierarchy like a surface. It fills the window as a column
399                    // and sits just above the window tree by default — both from
400                    // `root_base()`, overlaid by the user's `style`; baking
401                    // `globalZIndex` into the style (instead of inserting a raw
402                    // `GlobalZIndex`) means masked re-applies on re-render keep
403                    // re-asserting it. The root itself never blocks or hovers
404                    // picking; its children are ordinary pickable nodes.
405                    "root" => {
406                        let style = overlay_style(&root_base(), &props.style);
407                        let mut ec = commands.spawn(RNode(id));
408                        apply_style(&mut ec, &style);
409                        ec.insert((crate::bridge::RRoot, Pickable::IGNORE));
410                        apply_anchor(&mut ec, &props);
411                        ec.id()
412                    }
413                    // An `<editableText>`: a focusable native text input. Bevy's
414                    // `EditableTextInputPlugin` (registered by `DefaultPlugins`)
415                    // drives keyboard/focus/cursor/selection/clipboard; we just
416                    // spawn the widget and observe `TextEditChange` for `onChange`.
417                    "editableText" => {
418                        let mut ec = commands.spawn(RNode(id));
419                        apply_style(&mut ec, &props.style);
420                        let mut editable =
421                            EditableText::new(props.value.as_deref().unwrap_or_default());
422                        editable.max_characters = props.max_length;
423                        editable.allow_newlines = props.multiline;
424                        let (text_color, font, line_height, letter_spacing) =
425                            resolved_text_style(&props.style, &fonts);
426                        ec.insert((
427                            editable,
428                            text_color,
429                            font,
430                            line_height,
431                            letter_spacing,
432                            TextLayout {
433                                linebreak: if props.multiline {
434                                    LineBreak::WordBoundary
435                                } else {
436                                    LineBreak::NoWrap
437                                },
438                                ..default()
439                            },
440                            // Caret follows the text color so it stays visible on
441                            // any themed background (the default is a dark slate).
442                            TextCursorStyle {
443                                color: text_color.0,
444                                ..default()
445                            },
446                            // Focusable via click (the widget's picking observers)
447                            // and Tab navigation.
448                            TabIndex(0),
449                            // Announce as a text field to assistive tech; the live
450                            // value is kept in sync by `sync_editable_a11y`.
451                            AccessibilityNode(editable_a11y_node(&props)),
452                        ));
453                        // `AutoFocus`'s `on_add` hook focuses the entity once mounted.
454                        if props.autofocus {
455                            ec.insert(AutoFocus);
456                        }
457                        // `focusStyle` (and any hover/press) — applied Bevy-side as
458                        // the field's focus/interaction state changes.
459                        apply_style_variants(&mut ec, &props);
460                        apply_anchor(&mut ec, &props);
461                        ec.id()
462                    }
463                    _ => spawn_element(
464                        &mut commands,
465                        id,
466                        &kind,
467                        &props,
468                        &assets,
469                        &mut ui_assets.layouts,
470                        &mut ui_assets.atlas_cache,
471                        &mut FilterCtx {
472                            materials: &mut ui_assets.filter_materials,
473                            cache: &mut ui_assets.filter_cache,
474                            white: &ui_assets.filter_assets.white,
475                        },
476                    ),
477                };
478                if matches!(kind.as_str(), "text" | "textSpan") {
479                    bridge
480                        .text_styles
481                        .insert(id, resolved_text_style(&props.style, &fonts));
482                }
483                // A `textSpan` carries its text in a `TextSpan` component, so a later
484                // `Op::UpdateText` must update that (not insert a stray `Text`). It is
485                // `InlineStyled`: nested `<text>` spans keep their own style.
486                if kind == "textSpan" {
487                    bridge.spans.insert(id, SpanKind::InlineStyled);
488                }
489                if kind == "editableText" {
490                    bridge.editable_inputs.insert(id);
491                    bridge
492                        .editable_values
493                        .insert(id, props.value.clone().unwrap_or_default());
494                    register_editable_handlers(&mut bridge, id, &props);
495                    queue_pending_selection(
496                        &mut bridge,
497                        id,
498                        props.selection_start,
499                        props.selection_end,
500                    );
501                }
502                if kind == "surface" {
503                    bridge.surfaces.insert(id);
504                }
505                if kind == "root" {
506                    bridge.roots.insert(id);
507                }
508                // Controlled scroll + the `onScroll` listener apply to any node
509                // (anything with `overflow: scroll`). A `textSpan` has no `Node`
510                // and so never matches the read-back query — harmless there.
511                {
512                    let mut ec = commands.entity(entity);
513                    apply_scroll_listener(&mut ec, &props);
514                    apply_wheel_listener(&mut ec, &props);
515                    apply_scroll_step(&mut ec, &props);
516                    apply_scroll_transition(&mut ec, &props.style);
517                    create_controlled_scroll(&mut bridge, &mut ec, id, &props);
518                }
519                bridge.nodes.insert(id, entity);
520                // Seed the retained props a later update's delta merges into.
521                // Event-like fields were consumed by the create itself and are
522                // never part of the retained state.
523                let (state, _) = props.split_events();
524                bridge.props_cache.insert(id, Box::new(state));
525            }
526            Op::CreateText { id, text } => {
527                let entity = commands
528                    .spawn((Text::new(text), TextColor(Color::WHITE), RNode(id)))
529                    .id();
530                bridge.nodes.insert(id, entity);
531            }
532            Op::CreateTextSpan { id, text } => {
533                // A bare-string run inside a `<text>`. Style is inherited from its
534                // parent on append (see below); until then it keeps span defaults.
535                let entity = commands.spawn((TextSpan(text), RNode(id))).id();
536                bridge.nodes.insert(id, entity);
537                bridge.spans.insert(id, SpanKind::RawInherited);
538            }
539            Op::Append { parent, child } => {
540                // A `<surface>`/`<root>` is a detached UI root: never parent it into
541                // the on-screen hierarchy (a surface renders to its own offscreen
542                // camera; a `<root>` is an independent screen-space tree). Its own
543                // children attach to it normally via their own Append ops. Record
544                // its React parent so removing an ancestor can despawn this detached
545                // root (Bevy's recursive despawn never reaches it).
546                if bridge.is_detached_root(child) {
547                    bridge.attach_surface(child, parent);
548                    continue;
549                }
550                if let (Some(p), Some(c)) = (resolve(&bridge, parent), resolve(&bridge, child)) {
551                    let same_parent = bridge.parent_of.get(&child) == Some(&parent);
552                    bridge.append_child(parent, child);
553                    if same_parent {
554                        // Re-append = move to the end: an O(1) shadow reorder, synced
555                        // to the ECS by the end-of-batch rebuild.
556                        dirty.insert(parent);
557                    } else {
558                        // Fresh node (or cross-parent move): attach in the ECS NOW —
559                        // a same-batch removal of an ancestor must be able to despawn
560                        // it recursively; deferring the attach would leak it as an
561                        // orphaned window-UI root. `add_child` appends, matching the
562                        // shadow tail (so no rebuild is needed), and a cross-parent
563                        // `add_child` also detaches from the old ECS parent via the
564                        // relationship hooks.
565                        commands.entity(p).add_child(c);
566                    }
567                    inherit_text_style(&mut commands, &bridge, parent, child, c);
568                }
569            }
570            Op::Insert {
571                parent,
572                child,
573                before,
574            } => {
575                // A detached root (`<surface>`/`<root>`) is never parented (see
576                // `Op::Append`), but still record its React parent for
577                // ancestor-removal cleanup.
578                if bridge.is_detached_root(child) {
579                    bridge.attach_surface(child, parent);
580                    continue;
581                }
582                // Ordered insertion: place `child` at `before`'s position. The live
583                // `Children` can't be read here (commands queued earlier in this same
584                // batch haven't applied), so the shadow tree is the ordering truth and
585                // the ECS position is fixed up by the end-of-batch rebuild of the
586                // (always dirty) parent. A missing `before` falls back to appending.
587                if let (Some(p), Some(c)) = (resolve(&bridge, parent), resolve(&bridge, child)) {
588                    let same_parent = bridge.parent_of.get(&child) == Some(&parent);
589                    bridge.insert_before(parent, child, before);
590                    if !same_parent {
591                        // Fresh/cross-parent: attach NOW (at the end — the rebuild
592                        // moves it into place); see `Op::Append` for why deferring
593                        // the attach itself would leak on same-batch removal.
594                        commands.entity(p).add_child(c);
595                    }
596                    dirty.insert(parent);
597                    inherit_text_style(&mut commands, &bridge, parent, child, c);
598                }
599            }
600            Op::Remove { parent: _, child } => {
601                // React emits `Remove` only for the subtree's top node, and Bevy
602                // despawns that node recursively — but a `<surface>`/`<root>` nested
603                // under it is a detached root (no `ChildOf`), so neither reaches it.
604                // Despawn every detached root at/under `child` (incl. `child` itself
605                // if it is one) before the recursive despawn below; otherwise the
606                // orphan keeps rendering (a surface into its often-shared texture, a
607                // `<root>` straight onto the screen).
608                let mut surfaces = bridge.surfaces_under(child);
609                if bridge.is_detached_root(child) {
610                    bridge.detach_surface(child);
611                    surfaces.push(child);
612                }
613                for s in surfaces {
614                    if let Some(se) = resolve(&bridge, s) {
615                        commands.entity(se).despawn();
616                    }
617                    // `forget_subtree` prunes `s` *and* the content rendered inside it
618                    // (its `child_order` subtree) from every per-node side-table.
619                    bridge.detach(s);
620                    bridge.forget_subtree(s);
621                }
622
623                if let Some(c) = resolve(&bridge, child) {
624                    commands.entity(c).despawn();
625                    // Unlink from the parent's ordered list, then drop the whole subtree
626                    // from the shadow tree — `forget_subtree` prunes `child` and every
627                    // despawned descendant from all per-node side-tables, so no stale
628                    // `NodeId → Entity` handles linger until the next `Reset`.
629                    bridge.detach(child);
630                    bridge.forget_subtree(child);
631                }
632            }
633            Op::Update {
634                id,
635                props,
636                unset,
637                style_unset,
638            } => {
639                let Some(e) = resolve(&bridge, id) else {
640                    continue;
641                };
642                // Attribute apply-time parse warnings to this node (see
643                // `crate::diag`); the guard restores the outer scope on any
644                // exit from this arm.
645                let _diag = crate::diag::node_scope(id);
646                // Merge the delta into the retained per-node props, yielding the
647                // merged full props, what the delta touched, and the event-like
648                // fields to act on.
649                //
650                // The cache entry is taken OUT of the map for the duration of the
651                // arm and re-inserted at the end — the branches below borrow it
652                // as `props` while also borrowing `bridge` mutably, and this way
653                // no per-update `Props` clone is needed (it measurably showed up
654                // in the update benchmarks).
655                let mut cached = bridge.props_cache.remove(&id).unwrap_or_else(|| {
656                    // Only reachable through a bug (create always seeds the
657                    // cache); merging onto defaults degrades to "delta = the
658                    // whole truth" rather than crashing.
659                    warn!("delta update for uncached node {id}; merging onto defaults");
660                    Box::default()
661                });
662                let (dirty, ev) = cached.merge_delta(props, &unset, &style_unset);
663                let props = cached;
664                use crate::protocol::style_groups as g;
665                if bridge.text_styles.contains_key(&id) {
666                    // A `<text>` element: refresh its resolved style — but only
667                    // when a text-style field actually changed (resolution does
668                    // color parsing + a font lookup, and the raw-span
669                    // re-propagation below is O(children)).
670                    let resolved = dirty.style.intersects(g::TEXT).then(|| {
671                        let style = resolved_text_style(&props.style, &fonts);
672                        bridge.text_styles.insert(id, style.clone());
673                        style
674                    });
675                    let mut ec = commands.entity(e);
676                    if let Some(style) = &resolved {
677                        ec.insert(style.clone());
678                    }
679                    // A text *root* (has a `Node`) also gets the layout/visual/
680                    // transform style + transition, mirroring its create path —
681                    // otherwise a `transform`/`transition` on a `<text>` would only
682                    // apply on mount and never animate. Spans have no `Node` and are
683                    // skipped so they never gain a layout box.
684                    if text_roots.contains(e) {
685                        apply_style_masked(&mut ec, &props.style, dirty.style);
686                    }
687                    // Parity quirk preserved: a stale `TextLayout` is never removed
688                    // when both its fields go absent, only overwritten.
689                    if dirty.style.intersects(g::TEXT_LAYOUT)
690                        && let Some(layout) = text_layout(&props.style)
691                    {
692                        ec.insert(layout);
693                    }
694                    if dirty.anchor {
695                        apply_anchor(&mut ec, &props);
696                    }
697                    // Re-propagate the resolved style to any bare-string children
698                    // that inherit it (after the last `ec` use — the loop needs
699                    // `commands` back).
700                    if let Some(style) = resolved
701                        && let Ok(kids) = children.get(e)
702                    {
703                        for child in kids.iter() {
704                            if let Ok(rnode) = rnodes.get(child)
705                                && bridge.spans.get(&rnode.0) == Some(&SpanKind::RawInherited)
706                            {
707                                commands.entity(child).insert(style.clone());
708                            }
709                        }
710                    }
711                } else if bridge.editable_inputs.contains(&id) {
712                    // Controlled `editableText`: push `value` into the live buffer
713                    // only when it diverges from what the widget already holds, so
714                    // a re-render echoing the user's own keystrokes is a no-op and
715                    // never resets the cursor. Re-applying baseline keeps the
716                    // `onChange` dedup from echoing this programmatic set back.
717                    if let Some(new_val) = &ev.value {
718                        if let Ok(mut editable) = editables.get_mut(e)
719                            && editable.value().to_string() != *new_val
720                        {
721                            editable.editor_mut().set_text(new_val);
722                            editable.queue_edit(TextEdit::TextEnd(false));
723                        }
724                        bridge.editable_values.insert(id, new_val.clone());
725                    }
726                    // Handler presence and the controlled selection can change on a
727                    // re-render; refresh them. The accessible label is kept live too.
728                    if dirty.editable_handlers {
729                        register_editable_handlers(&mut bridge, id, &props);
730                    }
731                    queue_pending_selection(&mut bridge, id, ev.selection_start, ev.selection_end);
732                    if dirty.aria_label
733                        && let Ok(mut node) = a11y_nodes.get_mut(e)
734                    {
735                        match &props.aria_label {
736                            Some(label) => node.set_label(label.clone()),
737                            None => node.clear_label(),
738                        }
739                    }
740                    let mut ec = commands.entity(e);
741                    apply_style_masked(&mut ec, &props.style, dirty.style);
742                    if dirty.any_style_variant() {
743                        apply_style_variants(&mut ec, &props);
744                    }
745                } else if bridge.surfaces.contains(&id) {
746                    // A `<surface>` re-render: re-apply the (full-size-defaulted)
747                    // style and rebind its name. It shares the `target` wire field
748                    // with `<portal>`, so it must branch before the general path
749                    // below (which would wrongly stamp an `RPortal`).
750                    let mut ec = commands.entity(e);
751                    if dirty.style.any() {
752                        let style = overlay_style(&surface_root_base(), &props.style);
753                        apply_style_masked(&mut ec, &style, dirty.style);
754                    }
755                    if dirty.target
756                        && let Some(name) = &props.target
757                    {
758                        ec.insert(RSurface(name.clone()));
759                    }
760                    if dirty.anchor {
761                        apply_anchor(&mut ec, &props);
762                    }
763                } else if bridge.roots.contains(&id) {
764                    // A `<root>` re-render: re-overlay the screen-filling,
765                    // top-of-stack base (see `root_base`) so a masked re-apply
766                    // keeps the baked `globalZIndex` instead of stripping it.
767                    let mut ec = commands.entity(e);
768                    if dirty.style.any() {
769                        let style = overlay_style(&root_base(), &props.style);
770                        apply_style_masked(&mut ec, &style, dirty.style);
771                    }
772                    if dirty.anchor {
773                        apply_anchor(&mut ec, &props);
774                    }
775                } else {
776                    let mut ec = commands.entity(e);
777                    apply_style_masked(&mut ec, &props.style, dirty.style);
778                    // Image attributes only ever appear on `image` elements, so
779                    // their presence is enough to re-apply the texture/tint. A
780                    // removed `filter` also lands here: its material made the
781                    // `ImageNode` transparent, so the normal image must be rebuilt.
782                    if (dirty.image || dirty.style.intersects(g::FILTER)) && is_image(&props) {
783                        let mut img = image_node(&props, &assets);
784                        apply_atlas(
785                            &mut img,
786                            &props,
787                            &mut ui_assets.layouts,
788                            &mut ui_assets.atlas_cache,
789                        );
790                        ec.insert(img);
791                    }
792                    // A `filter` swaps the node's draw for a `MaterialNode`; run
793                    // after the style/image above so it can drop the components it
794                    // replaces. Absent → it removes any prior filter material. Its
795                    // material bakes tint/src (image attrs) plus filter, opacity and
796                    // background color, so any of those dirties re-runs it.
797                    if dirty.image || dirty.style.intersects(g::FILTER | g::BACKGROUND) {
798                        apply_filter(
799                            &mut ec,
800                            &props,
801                            &assets,
802                            &mut FilterCtx {
803                                materials: &mut ui_assets.filter_materials,
804                                cache: &mut ui_assets.filter_cache,
805                                white: &ui_assets.filter_assets.white,
806                            },
807                        );
808                    }
809                    // A `<canvas>`'s new declarative display list: clear + replay
810                    // on the retained surface. Queued (not re-inserted) so the
811                    // surface's retained pixmap and pending imperative commands
812                    // aren't thrown away with the component.
813                    if let Some(cmds) = ev.draw {
814                        ec.queue(move |mut entity: EntityWorldMut| {
815                            if let Some(mut surface) = entity.get_mut::<CanvasSurface>() {
816                                surface.set_display_list(cmds);
817                            }
818                        });
819                    }
820                    // A `<portal>`'s new target name: rebind it (the binding system
821                    // points its `ImageNode` at the new target next frame).
822                    if dirty.target
823                        && let Some(target) = &props.target
824                    {
825                        ec.insert(RPortal(target.clone()));
826                    }
827                    // When `apply_style_masked` reset this entity's `FocusPolicy` to
828                    // the `Pass` default, re-assert a button's `Block` (no-op /
829                    // `Pass` for plain nodes). Skipped when the mask skipped the
830                    // `FocusPolicy` insert — nothing reset it.
831                    if dirty.style.intersects(g::FOCUS_POLICY) && buttons.get(e).is_ok() {
832                        apply_button_focus_default(&mut ec, &props.style);
833                    }
834                    // `StyleVariants.base` mirrors the (merged) base style, so any
835                    // style change rebuilds it. Skipping when untouched also avoids
836                    // a spurious `Changed<StyleVariants>` → full restyle merge from
837                    // `apply_interaction_styles` on every unrelated update.
838                    if dirty.any_style_variant() {
839                        apply_style_variants(&mut ec, &props);
840                    }
841                    if dirty.pointer {
842                        apply_pointer_handlers(&mut ec, &props);
843                    }
844                    if dirty.scroll_listener {
845                        apply_scroll_listener(&mut ec, &props);
846                    }
847                    if dirty.wheel {
848                        apply_wheel_listener(&mut ec, &props);
849                    }
850                    if dirty.scroll_step {
851                        apply_scroll_step(&mut ec, &props);
852                    }
853                    if dirty.style.intersects(g::SCROLL_TRANSITION) {
854                        apply_scroll_transition(&mut ec, &props.style);
855                    }
856                    if dirty.animated {
857                        apply_animated(&mut ec, &props);
858                    }
859                    if dirty.anchor {
860                        apply_anchor(&mut ec, &props);
861                    }
862                    update_controlled_scroll(
863                        &mut bridge,
864                        &mut scroll_query,
865                        e,
866                        id,
867                        ev.scroll_left,
868                        ev.scroll_top,
869                    );
870                }
871                // Retain the merged props for the next delta (see above).
872                bridge.props_cache.insert(id, props);
873            }
874            Op::UpdateText { id, text } => {
875                if let Some(e) = resolve(&bridge, id) {
876                    // A run is either a standalone `Text` or, inside a `<text>`, a
877                    // `TextSpan` — update whichever this entity is.
878                    if bridge.spans.contains_key(&id) {
879                        commands.entity(e).insert(TextSpan(text));
880                    } else {
881                        commands.entity(e).insert(Text::new(text));
882                    }
883                }
884            }
885            Op::Draw { id, cmds } => {
886                // Imperative canvas drawing (a handle's microtask flush) or the
887                // runtime's declarative replay after a resize: append to the
888                // retained surface. A missing node (already unmounted, stale
889                // handle) is skipped silently, like every other op. Queued so a
890                // same-batch `Create`'s deferred `CanvasSurface` insert lands
891                // first.
892                if let Some(e) = resolve(&bridge, id) {
893                    commands.entity(e).queue(move |mut entity: EntityWorldMut| {
894                        if let Some(mut surface) = entity.get_mut::<CanvasSurface>() {
895                            surface.enqueue(cmds);
896                        }
897                    });
898                }
899            }
900        }
901    }
902
903    // Sync the ECS hierarchy: one `replace_children` per parent whose child list
904    // changed this batch (Bevy diffs — kept children get no `ChildOf` rewrite, the
905    // order becomes exactly the slice's). Skipping unresolvable parents guards the
906    // despawned-entity panic: anything removed (or wiped by `Reset`) mid-batch was
907    // pruned from `bridge.nodes` by `forget_subtree`.
908    for parent in dirty {
909        let Some(p) = resolve(&bridge, parent) else {
910            continue;
911        };
912        let mut list: Vec<Entity> = Vec::new();
913        // The AnchorLayer is a Rust-side child of the root, invisible to the shadow
914        // tree — keep it as the first child (its spawn-time position; overlays are
915        // lifted by `GlobalZIndex`, not sibling order). Without this, the root's
916        // rebuild would strip its `ChildOf`.
917        if parent == ROOT_ID
918            && let Ok(layer) = anchor_layer.single()
919        {
920            list.push(layer);
921        }
922        list.extend(
923            bridge
924                .children_of(parent)
925                .filter_map(|id| resolve(&bridge, id)),
926        );
927        // Note: an anchored overlay under `parent` gets `ChildOf(parent)` re-asserted
928        // here (its live parent is the AnchorLayer) — same as the old per-op
929        // `insert_child` path; the anchor system self-heals it next frame.
930        commands.entity(p).replace_children(&list);
931    }
932
933    // Record this batch for live instrumentation (see [`OpApplyStats`]).
934    stats.applied_count = stats.applied_count.wrapping_add(1);
935    if any_app {
936        stats.app_applied_count = stats.app_applied_count.wrapping_add(1);
937    }
938    stats.last_ops = op_count;
939    #[cfg(not(target_arch = "wasm32"))]
940    {
941        let end = std::time::Instant::now();
942        let (wait, pre) = first_stamp
943            .map(|stamp| split_pre_apply(stamp, meta.frame.as_ref().and_then(|f| f.0), started))
944            .unwrap_or_default();
945        stats.last_frame_wait = wait;
946        stats.last_pre_apply = pre;
947        stats.last_translate = end.duration_since(started);
948        stats.last_apply_end = Some(end);
949    }
950}
951
952/// Split "op_flush send → apply start" into the cross-frame queue wait and the
953/// in-frame leg at the frame-start boundary. Saturating: a stamp landing
954/// mid-frame (after frame start, e.g. a JS-timer commit) clamps the wait to
955/// zero; jitter never panics. A `None` frame start puts the whole span in the
956/// in-frame leg.
957#[cfg(not(target_arch = "wasm32"))]
958fn split_pre_apply(
959    stamp: std::time::Instant,
960    frame_start: Option<std::time::Instant>,
961    apply_start: std::time::Instant,
962) -> (std::time::Duration, std::time::Duration) {
963    let boundary = frame_start.map_or(stamp, |fs| fs.max(stamp));
964    (
965        boundary.saturating_duration_since(stamp),
966        apply_start.saturating_duration_since(boundary),
967    )
968}
969
970/// When a bare-string run is appended into a `<text>`, copy the parent's text
971/// style onto it (Bevy has no text-style inheritance, and the parent's freshly
972/// queued components aren't yet visible to an ECS query this frame).
973// TODO(review): this hand-rolled CSS-style text inheritance (here + the O(children)
974// re-propagation loop in the `<text>` `Op::Update` branch) is a complexity hotspot. It's
975// likely unavoidable until Bevy grows real text-style inheritance, but worth watching as the
976// text model grows.
977fn inherit_text_style(
978    commands: &mut Commands,
979    bridge: &JsBridge,
980    parent: NodeId,
981    child: NodeId,
982    child_entity: Entity,
983) {
984    if bridge.spans.get(&child) != Some(&SpanKind::RawInherited) {
985        return;
986    }
987    if let Some(style) = bridge.text_styles.get(&parent).cloned() {
988        commands.entity(child_entity).insert(style);
989    }
990}
991
992/// The default style a `<surface>` root gets before the user's `style` is overlaid:
993/// it fills the offscreen texture (the camera's logical viewport) so the subtree
994/// has a definite box to lay out in. The user can override `width`/`height` (or any
995/// other field) via the element's `style` prop.
996fn surface_root_base() -> Option<Style> {
997    Some(Style {
998        width: Some(crate::protocol::Length::Percent(100.0)),
999        height: Some(crate::protocol::Length::Percent(100.0)),
1000        ..Default::default()
1001    })
1002}
1003
1004/// The default style a `<root>` gets before the user's `style` is overlaid: a
1005/// window-filling overlay just above the window tree. `globalZIndex: 1` (not a
1006/// magic max — see below) because bevy_ui sorts root nodes by `(GlobalZIndex,
1007/// ZIndex)` with NO tiebreak: equal keys fall back to query iteration order,
1008/// which is unspecified — a bare `<root>` at the window tree's implicit 0 could
1009/// land above OR below it. `1` is deterministically above, while leaving the
1010/// whole range open for the user's own layering (`style.globalZIndex` overrides
1011/// in either direction; the devtools panel claims `i32::MAX` explicitly).
1012/// Baked into the *style* rather than inserted as a raw `GlobalZIndex`
1013/// component so masked style re-applies on re-render re-assert it (a raw
1014/// insert would be stripped the first time the Z_INDEX dirty group executes
1015/// with no style value).
1016fn root_base() -> Option<Style> {
1017    Some(Style {
1018        width: Some(crate::protocol::Length::Percent(100.0)),
1019        height: Some(crate::protocol::Length::Percent(100.0)),
1020        // Default to a column, like the main UI root (plugin.rs). Bevy's own
1021        // default is `row`, but a row container mis-measures a single
1022        // content-sized child that has `maxWidth` + wrapping text: the text is
1023        // sized at max-content (one line) during the row's main-axis pass, then
1024        // clamped to `maxWidth` and wrapped on render — so the child's height is
1025        // committed one line short while its siblings sit at the wrapped
1026        // positions. A `<root>` is a top-level app container like the main root,
1027        // so `column` is both the least-surprising default and the one that
1028        // sidesteps that quirk. Overridable via `style.flexDirection`.
1029        flex_direction: Some(FlexDirection::Column),
1030        global_z_index: Some(1),
1031        ..Default::default()
1032    })
1033}
1034
1035/// The resources [`apply_filter`] needs to build/cache a `FilterMaterial` and bind
1036/// the shared white pixel — bundled so the call sites don't thread three params.
1037struct FilterCtx<'a> {
1038    materials: &'a mut Assets<FilterMaterial>,
1039    cache: &'a mut FilterMaterialCache,
1040    white: &'a Handle<Image>,
1041}
1042
1043/// Apply (or clear) a `filter` style on an element. Present → build a
1044/// [`FilterMaterial`] (source = the `<image>`'s texture, else the shared white
1045/// pixel tinted by `base_color`) and insert a `MaterialNode<FilterMaterial>`,
1046/// dropping the standard `ImageNode` / `BackgroundColor` so the node isn't drawn
1047/// twice. Absent → remove any prior filter material so the node reverts to its
1048/// normal draw. Must run *after* `apply_style` / the image insert (it removes the
1049/// components those add). See [`crate::filter`] for the scope (own surface only).
1050fn apply_filter(ec: &mut EntityCommands, props: &Props, assets: &AssetServer, ctx: &mut FilterCtx) {
1051    let Some(spec) = props.style.as_ref().and_then(|s| s.filter.as_ref()) else {
1052        ec.remove::<MaterialNode<FilterMaterial>>();
1053        return;
1054    };
1055    // Base color: the image tint, else the background color, else white. Opacity is
1056    // folded into alpha just like the standard background/image paths.
1057    let opacity = props.style.as_ref().and_then(|s| s.opacity);
1058    let base = props
1059        .tint
1060        .as_deref()
1061        .or_else(|| {
1062            props
1063                .style
1064                .as_ref()
1065                .and_then(|s| s.background_color.as_deref())
1066        })
1067        .map(parse_color)
1068        .unwrap_or(Color::WHITE);
1069    let texture = match &props.src {
1070        Some(path) => assets.load(path),
1071        None => ctx.white.clone(),
1072    };
1073    let mat = filter_material(spec, texture, apply_opacity(base, opacity));
1074    let handle = ctx.cache.handle(ctx.materials, mat);
1075
1076    // The material replaces the node's own draw (so a filtered node never carries a
1077    // visible `BackgroundColor` — that's already dropped in `apply_style`).
1078    if props.src.is_some() {
1079        // A `MaterialNode` has no content measure, so a filtered `<image>` with only
1080        // a `width` would collapse to zero height. Keep the `ImageNode` (it measures
1081        // the texture's intrinsic size) but make it transparent so only the filter
1082        // material paints — no double draw.
1083        let mut img = image_node(props, assets);
1084        img.color = img.color.with_alpha(0.0);
1085        ec.insert(img);
1086    } else {
1087        // A solid-colored node: the material paints the (filtered) color; drop any
1088        // `ImageNode` a prior render left behind.
1089        ec.remove::<ImageNode>();
1090    }
1091    ec.remove::<BackgroundColor>();
1092    ec.insert(MaterialNode(handle));
1093}
1094
1095/// Spawn a `node`, `button`, or `image` host element with its style.
1096#[allow(clippy::too_many_arguments)]
1097fn spawn_element(
1098    commands: &mut Commands,
1099    id: NodeId,
1100    kind: &str,
1101    props: &Props,
1102    assets: &AssetServer,
1103    layouts: &mut Assets<TextureAtlasLayout>,
1104    atlas_cache: &mut AtlasLayoutCache,
1105    filter: &mut FilterCtx,
1106) -> Entity {
1107    let mut ec = commands.spawn(RNode(id));
1108    apply_style(&mut ec, &props.style);
1109    match kind {
1110        // `Button` requires `Interaction`, which is added automatically.
1111        "button" => {
1112            ec.insert(Button);
1113            // Buttons capture the pointer by default; `apply_style` already
1114            // defaulted this entity to `Pass`, so override unless the prop is set.
1115            apply_button_focus_default(&mut ec, &props.style);
1116        }
1117        "image" => {
1118            let mut img = image_node(props, assets);
1119            apply_atlas(&mut img, props, layouts, atlas_cache);
1120            ec.insert(img);
1121        }
1122        _ => {}
1123    }
1124    // A `filter` swaps the node's image/background draw for a filter material.
1125    apply_filter(&mut ec, props, assets, filter);
1126    apply_style_variants(&mut ec, props);
1127    apply_pointer_handlers(&mut ec, props);
1128    apply_animated(&mut ec, props);
1129    apply_anchor(&mut ec, props);
1130    ec.id()
1131}
1132
1133/// Stamp (or clear) the [`AnimatedNode`] bindings on a host element. Present →
1134/// the animations plugin drives the listed props each frame (no-op if animations
1135/// are disabled — nothing reads the component).
1136fn apply_animated(ec: &mut EntityCommands, props: &Props) {
1137    match &props.animated {
1138        Some(bindings) => {
1139            ec.insert(AnimatedNode(bindings.clone()));
1140        }
1141        None => {
1142            ec.remove::<AnimatedNode>();
1143        }
1144    }
1145}
1146
1147/// Stamp (or clear) the [`Anchored`] binding on a host element. Present → the
1148/// positioning system projects the target entity's world position to the screen
1149/// each frame and writes this node's `left`/`top`. A malformed/dead entity id is
1150/// ignored (the binding is simply not applied).
1151fn apply_anchor(ec: &mut EntityCommands, props: &Props) {
1152    match &props.anchor {
1153        Some(anchor) => match Entity::try_from_bits(anchor.entity as u64) {
1154            Some(target) => {
1155                let offset = anchor.offset.map(Vec3::from).unwrap_or(Vec3::ZERO);
1156                ec.insert(Anchored {
1157                    target,
1158                    offset,
1159                    // Sanitized once here so the per-frame scale math can't panic
1160                    // on JS-supplied NaN/reversed bounds.
1161                    scale: anchor.scale.and_then(AnchorScaling::sanitized),
1162                });
1163            }
1164            None => {
1165                ec.remove::<Anchored>();
1166            }
1167        },
1168        None => {
1169            ec.remove::<Anchored>();
1170        }
1171    }
1172}
1173
1174/// Stamp (or clear) the hover/press [`StyleVariants`] on a host element. When
1175/// either variant is present the element also gets an `Interaction` so the focus
1176/// system tracks hover/press for it; `insert_if_new` leaves any existing
1177/// `Interaction` untouched (a `button`'s, or a node already mid-hover) so we
1178/// never reset its state on a re-render.
1179fn apply_style_variants(ec: &mut EntityCommands, props: &Props) {
1180    if props.hover_style.is_some() || props.press_style.is_some() || props.focus_style.is_some() {
1181        ec.insert(StyleVariants {
1182            base: props.style.clone(),
1183            hover: props.hover_style.clone(),
1184            press: props.press_style.clone(),
1185            focus: props.focus_style.clone(),
1186        });
1187        // Hover/press are driven by `Interaction`; focus by `FocusState` (toggled
1188        // by the focus observers). Add each only for the variants present.
1189        if props.hover_style.is_some() || props.press_style.is_some() {
1190            ec.insert_if_new(Interaction::default());
1191        }
1192        if props.focus_style.is_some() {
1193            ec.insert_if_new(FocusState::default());
1194        } else {
1195            ec.remove::<FocusState>();
1196        }
1197    } else {
1198        ec.remove::<StyleVariants>();
1199        ec.remove::<FocusState>();
1200    }
1201}
1202
1203/// Stamp (or clear) the [`PointerHandlers`] marker plus the components the
1204/// drag-capture system needs. When any `onPointer*` handler is declared the
1205/// element also gets a [`RelativeCursorPosition`] (so we can read the cursor's
1206/// normalized position within it).
1207///
1208/// Both `onClick` and the `onPointer*` handlers need an `Interaction`: it is the
1209/// click-*ownership* marker ([`collect_ui_events`] climbs a picked leaf to the
1210/// nearest `Interaction`-bearing node), the drag begin/over test in
1211/// [`collect_pointer_events`], and the hover/press-style + [`crate::PointerCapture`]
1212/// source. Without it a plain `<node onClick>` — no hover/press style, not a
1213/// `<button>` — would never be reported as clicked. `insert_if_new` leaves an
1214/// existing `Interaction` (a `button`'s, or a hover/press variant's) untouched.
1215fn apply_pointer_handlers(ec: &mut EntityCommands, props: &Props) {
1216    let any_pointer = props.on_pointer_down
1217        || props.on_pointer_move
1218        || props.on_pointer_up
1219        || props.on_pointer_enter
1220        || props.on_pointer_leave;
1221    if any_pointer {
1222        ec.insert(PointerHandlers {
1223            down: props.on_pointer_down,
1224            moved: props.on_pointer_move,
1225            up: props.on_pointer_up,
1226            enter: props.on_pointer_enter,
1227            leave: props.on_pointer_leave,
1228        });
1229        // `RelativeCursorPosition` supplies the `x`/`y` carried by drag and hover
1230        // events; the drag-capture and hover systems both read it.
1231        ec.insert_if_new(RelativeCursorPosition::default());
1232    } else {
1233        ec.remove::<PointerHandlers>();
1234        ec.remove::<RelativeCursorPosition>();
1235    }
1236    // `pointerEnter`/`pointerLeave` are derived from `Interaction` transitions, so
1237    // the node tracks its "inside" state in `HoverState`; add/remove it in step.
1238    if props.on_pointer_enter || props.on_pointer_leave {
1239        ec.insert_if_new(HoverState::default());
1240    } else {
1241        ec.remove::<HoverState>();
1242    }
1243    if props.on_click || any_pointer {
1244        ec.insert_if_new(Interaction::default());
1245    }
1246}
1247
1248/// Toggle the [`ScrollListener`] marker so [`collect_scroll_events`] reports this
1249/// node's `ScrollPosition` changes only while an `onScroll` handler is declared.
1250fn apply_scroll_listener(ec: &mut EntityCommands, props: &Props) {
1251    if props.on_scroll {
1252        ec.insert_if_new(ScrollListener);
1253    } else {
1254        ec.remove::<ScrollListener>();
1255    }
1256}
1257
1258/// Toggle the [`WheelListener`] marker so [`crate::scroll::collect_wheel_events`]
1259/// reports raw wheel deltas over this node only while an `onWheel` handler is
1260/// declared. Independent of `overflow: scroll` — any node can receive the wheel.
1261fn apply_wheel_listener(ec: &mut EntityCommands, props: &Props) {
1262    if props.on_wheel {
1263        ec.insert_if_new(WheelListener);
1264    } else {
1265        ec.remove::<WheelListener>();
1266    }
1267}
1268
1269/// Stamp (or clear) the per-node [`ScrollStep`] wheel step from `scrollStep`.
1270fn apply_scroll_step(ec: &mut EntityCommands, props: &Props) {
1271    match props.scroll_step {
1272        Some(step) => {
1273            ec.insert(ScrollStep(step));
1274        }
1275        None => {
1276            ec.remove::<ScrollStep>();
1277        }
1278    }
1279}
1280
1281/// Apply a controlled `scrollTop`/`scrollLeft` on **create**: insert the offset
1282/// (defaulting the uncontrolled axis to 0) and seed [`JsBridge::scroll_positions`]
1283/// so neither the programmatic write nor the node's mount-frame
1284/// `Changed<ScrollPosition>` echoes back as an `onScroll`. A listener with no
1285/// controlled offset is seeded at the default `ZERO` for the same reason.
1286fn create_controlled_scroll(
1287    bridge: &mut JsBridge,
1288    ec: &mut EntityCommands,
1289    id: NodeId,
1290    props: &Props,
1291) {
1292    if props.scroll_top.is_some() || props.scroll_left.is_some() {
1293        let pos = Vec2::new(
1294            props.scroll_left.unwrap_or(0.0),
1295            props.scroll_top.unwrap_or(0.0),
1296        );
1297        // Overrides the `ZERO` that `Node`'s required `ScrollPosition` defaults to.
1298        ec.insert(ScrollPosition(pos));
1299        bridge.scroll_positions.insert(id, pos);
1300    } else if props.on_scroll {
1301        bridge.scroll_positions.insert(id, Vec2::ZERO);
1302    }
1303}
1304
1305/// Push a controlled `scrollTop`/`scrollLeft` into a live node on **update**:
1306/// write only the axis React controls, clamped to the scrollable range, and only
1307/// when it diverges from the live offset (so a re-render echoing the user's own
1308/// wheel scroll is a no-op and never snaps the view). Mirrors the controlled
1309/// `value` diff for `editableText`.
1310///
1311/// Records the **requested** (pre-clamp) value in [`JsBridge::scroll_positions`].
1312/// When the request was in range this equals the written offset, so the read-back
1313/// dedups it (no echo). When the request overshot, the clamped component value
1314/// diverges from the recorded request, so the read-back fires one `"scroll"` with
1315/// the real offset — letting a controlled `scrollTop={BIG}` settle to the true max.
1316///
1317/// With a scroll transition ([`ScrollTransitionState`] present) the clamped value
1318/// becomes the eased **target** instead of being written to `ScrollPosition` — the
1319/// `drive_scroll_transition` system moves the offset toward it. The uncontrolled
1320/// axis keeps the current target (not the mid-ease position) so it doesn't snap.
1321fn update_controlled_scroll(
1322    bridge: &mut JsBridge,
1323    scroll_query: &mut Query<(
1324        &mut ScrollPosition,
1325        &ComputedNode,
1326        Option<&mut ScrollTransitionState>,
1327    )>,
1328    e: Entity,
1329    id: NodeId,
1330    scroll_left: Option<f32>,
1331    scroll_top: Option<f32>,
1332) {
1333    if scroll_top.is_none() && scroll_left.is_none() {
1334        return;
1335    }
1336    if let Ok((mut pos, computed, scroll_state)) = scroll_query.get_mut(e) {
1337        // Base on the eased target if a transition owns the offset, else the live one.
1338        let mut requested = scroll_state.as_ref().map_or(pos.0, |s| s.target);
1339        if let Some(x) = scroll_left {
1340            requested.x = x;
1341        }
1342        if let Some(y) = scroll_top {
1343            requested.y = y;
1344        }
1345        // Same range as the wheel handler (`scroll::apply_scroll`): `ComputedNode`
1346        // sizes are physical, the component is logical, so scale with `inverse_scale_factor`.
1347        let max = (computed.content_size - computed.size + computed.scrollbar_size).max(Vec2::ZERO)
1348            * computed.inverse_scale_factor;
1349        let clamped = requested.clamp(Vec2::ZERO, max);
1350        match scroll_state {
1351            // Eased: set the target; `drive_scroll_transition` moves `ScrollPosition`.
1352            Some(mut state) => state.target = clamped,
1353            // Snap: write the offset directly, only when it diverges.
1354            None => {
1355                if pos.0 != clamped {
1356                    pos.0 = clamped;
1357                }
1358            }
1359        }
1360        bridge.scroll_positions.insert(id, requested);
1361    }
1362}
1363
1364/// `<button>` captures the pointer by default — bevy_ui's native `Button` sets
1365/// `FocusPolicy::Block`, and we mirror that so a button doesn't leak its click to a
1366/// sibling, an ancestor, or the 3D scene/portal behind it. [`apply_style`] defaults
1367/// every element to `Pass`, so for a button with no explicit `focusPolicy` we
1368/// re-assert `Block` here. A bare `<node>` keeps `Pass`, so containers/labels stay
1369/// click-through and don't swallow clicks meant for what's behind or around them.
1370/// An explicit `focusPolicy` prop (handled in `apply_style`) always wins.
1371fn apply_button_focus_default(ec: &mut EntityCommands, style: &Option<Style>) {
1372    let has_explicit = style.as_ref().is_some_and(|s| s.focus_policy.is_some());
1373    if !has_explicit {
1374        ec.insert(FocusPolicy::Block);
1375        // Mirror into the picking backend's blocking flag, exactly as
1376        // `apply_style` does for the `Pass` default (see its `FOCUS_POLICY` doc).
1377        ec.insert(bevy::picking::Pickable {
1378            should_block_lower: true,
1379            is_hoverable: true,
1380        });
1381    }
1382}
1383
1384/// Whether these props carry any `image` element attribute.
1385fn is_image(props: &Props) -> bool {
1386    props.src.is_some()
1387        || props.tint.is_some()
1388        || props.image_mode.is_some()
1389        || props.flip_x
1390        || props.flip_y
1391        || props.source_rect.is_some()
1392        || props.atlas.is_some()
1393        || props.visual_box.is_some()
1394}
1395
1396fn resolve(bridge: &JsBridge, id: NodeId) -> Option<Entity> {
1397    bridge.nodes.get(&id).copied()
1398}
1399
1400/// Report clicks on reconciler-owned nodes to the JS thread. Rides bevy_picking's
1401/// `Pointer<Click>`, which fires on *release over the same node the press landed
1402/// on* — DOM click semantics, so press → drag off → release never clicks. Like
1403/// DOM `click`, only the primary (left) button clicks; right/middle interactions
1404/// are the `onPointer*` events' job (which carry the button). The surface
1405/// virtual pointer is excluded: its clicks are [`collect_surface_clicks`]' job.
1406pub fn collect_ui_events(
1407    bridge: Res<JsBridge>,
1408    surface_pointer: Option<Res<SurfaceVirtualPointer>>,
1409    mut clicks: MessageReader<Pointer<Click>>,
1410    // Only `Interaction`-bearing nodes own a click (a `<button>` gets one via
1411    // `Button`; a `<text>` child does not) — the same attribution rule as the
1412    // legacy `ui_focus_system` path and `collect_surface_clicks`.
1413    targets: Query<&RNode, With<Interaction>>,
1414    child_of: Query<&ChildOf>,
1415) {
1416    // One gesture fans out to every entity in the pointer's hover map (a button
1417    // AND its pass-through label); climbing resolves them to the same owner, so
1418    // dedupe per (pointer, owner) within the frame.
1419    let mut seen: HashSet<(PointerId, Entity)> = HashSet::new();
1420    for ev in clicks.read() {
1421        if ev.button != PointerButton::Primary {
1422            continue;
1423        }
1424        if surface_pointer
1425            .as_ref()
1426            .is_some_and(|p| ev.pointer_id == p.id)
1427        {
1428            continue;
1429        }
1430        // Resolve the picked leaf (often a text span) to the nearest interactive
1431        // ancestor, so a click on a button's label still fires the button.
1432        if let Some(target) = climb(ev.entity, &child_of, |e| targets.contains(e))
1433            && seen.insert((ev.pointer_id, target))
1434            && let Ok(rnode) = targets.get(target)
1435        {
1436            debug!("click -> reconciler node {}", rnode.0);
1437            send_ui_event(&bridge, rnode.0, "click", None, None, None);
1438        }
1439    }
1440}
1441
1442/// Report `ScrollPosition` changes back to JS as `"scroll"` events. Scoped to
1443/// nodes carrying a [`ScrollListener`] (i.e. those with an `onScroll` handler) so
1444/// the `Changed<ScrollPosition>` query stays cheap — `ScrollPosition` is a
1445/// required component of every `Node`, so an unscoped query would fire for every
1446/// node on its mount frame. A controlled write-back is deduped against
1447/// [`JsBridge::scroll_positions`], breaking the controlled-component echo loop.
1448#[allow(clippy::type_complexity)]
1449pub fn collect_scroll_events(
1450    mut bridge: ResMut<JsBridge>,
1451    query: Query<(&ScrollPosition, &RNode), (With<ScrollListener>, Changed<ScrollPosition>)>,
1452) {
1453    for (scroll, rnode) in &query {
1454        let id = rnode.0;
1455        if bridge.scroll_positions.get(&id) == Some(&scroll.0) {
1456            // Our own controlled write (or an unchanged value) — don't echo it.
1457            continue;
1458        }
1459        bridge.scroll_positions.insert(id, scroll.0);
1460        debug!("scroll -> reconciler node {id}");
1461        let _ = bridge.outbound_tx.send(Outbound::UiEvent {
1462            event: UiEvent {
1463                id,
1464                kind: "scroll".to_string(),
1465                scroll_top: Some(scroll.0.y),
1466                scroll_left: Some(scroll.0.x),
1467                ..default()
1468            },
1469        });
1470    }
1471}
1472
1473/// Emit a `"resize"` UI event (new logical size) for every `<canvas>` whose
1474/// laid-out **physical** size changed — including its first layout (0 → W×H)
1475/// and a DPR change at constant logical size, both of which cleared the
1476/// retained surface. Not gated on a handler flag: the JS runtime consumes
1477/// resizes unconditionally (to replay a declarative painter and keep the
1478/// canvas handle's size fresh); a user `onResize` is dispatched if registered.
1479/// The per-entity [`CanvasSizeTracker`] filters the non-size `ComputedNode`
1480/// rewrites layout does every pass. Sizes clamp exactly like the rasterizer's,
1481/// so the reported size always matches the actual buffer.
1482#[allow(clippy::type_complexity)]
1483pub fn collect_canvas_resize_events(
1484    bridge: Res<JsBridge>,
1485    mut query: Query<
1486        (&RNode, &ComputedNode, &mut CanvasSizeTracker),
1487        (With<CanvasSurface>, Changed<ComputedNode>),
1488    >,
1489) {
1490    for (rnode, node, mut tracker) in &mut query {
1491        let (w, h) = clamp_physical_size(node.size);
1492        if w == 0 || h == 0 || tracker.0 == (w, h) {
1493            continue;
1494        }
1495        tracker.0 = (w, h);
1496        let scale = if node.inverse_scale_factor > 0.0 {
1497            node.inverse_scale_factor
1498        } else {
1499            1.0
1500        };
1501        debug!("canvas resize -> reconciler node {}", rnode.0);
1502        let _ = bridge.outbound_tx.send(Outbound::UiEvent {
1503            event: UiEvent {
1504                id: rnode.0,
1505                kind: "resize".to_string(),
1506                width: Some(w as f32 * scale),
1507                height: Some(h as f32 * scale),
1508                ..default()
1509            },
1510        });
1511    }
1512}
1513
1514/// Build the accesskit node for an `editableText` from its props (role + label +
1515/// initial value). The live value is kept current by [`sync_editable_a11y`].
1516fn editable_a11y_node(props: &Props) -> accesskit::Node {
1517    let role = if props.multiline {
1518        Role::MultilineTextInput
1519    } else {
1520        Role::TextInput
1521    };
1522    let mut node = accesskit::Node::new(role);
1523    if let Some(label) = &props.aria_label {
1524        node.set_label(label.clone());
1525    }
1526    node.set_value(props.value.clone().unwrap_or_default());
1527    node
1528}
1529
1530/// Add or remove `id` from `set` to mirror a boolean prop.
1531fn set_membership(set: &mut HashSet<NodeId>, id: NodeId, present: bool) {
1532    if present {
1533        set.insert(id);
1534    } else {
1535        set.remove(&id);
1536    }
1537}
1538
1539/// Record which optional `editableText` handlers are registered in JS, so the
1540/// high-frequency `"select"`/`"focus"`/`"blur"` events are only emitted when
1541/// something is listening. Called on create and on every controlled update.
1542fn register_editable_handlers(bridge: &mut JsBridge, id: NodeId, props: &Props) {
1543    set_membership(&mut bridge.editable_select_handlers, id, props.on_select);
1544    set_membership(
1545        &mut bridge.editable_focus_handlers,
1546        id,
1547        props.on_focus || props.on_blur,
1548    );
1549}
1550
1551/// Queue a controlled selection (byte offsets) for [`apply_pending_selections`],
1552/// when both `selectionStart` and `selectionEnd` are supplied. (The JS delta
1553/// builder keeps the pair coupled: when either changes, both current values are
1554/// sent, so a delta update never sees half a selection.)
1555fn queue_pending_selection(
1556    bridge: &mut JsBridge,
1557    id: NodeId,
1558    start: Option<usize>,
1559    end: Option<usize>,
1560) {
1561    if let (Some(start), Some(end)) = (start, end) {
1562        bridge.editable_pending_selection.insert(id, (start, end));
1563    }
1564}
1565
1566/// Report `editableText` edits back to JS. Bevy triggers [`TextEditChange`] after
1567/// applying edits — but also on cursor/selection moves — so this single observer
1568/// emits a `"change"` (deduped against the last value) when the text changed, and
1569/// a `"select"` (deduped against the last selection, and only for nodes with an
1570/// `onSelect` handler, since caret moves are frequent) when the selection moved.
1571/// Each is routed by node id + kind in the JS event-loop router.
1572pub fn on_text_edit_change(
1573    change: On<TextEditChange>,
1574    mut bridge: ResMut<JsBridge>,
1575    editables: Query<(&EditableText, &RNode)>,
1576) {
1577    let Ok((editable, rnode)) = editables.get(change.event_target()) else {
1578        return;
1579    };
1580    let id = rnode.0;
1581    let composing = editable.is_composing();
1582
1583    let value = editable.value().to_string();
1584    if bridge.editable_values.get(&id) != Some(&value) {
1585        bridge.editable_values.insert(id, value.clone());
1586        debug!("change -> reconciler node {id}");
1587        let _ = bridge.outbound_tx.send(Outbound::UiEvent {
1588            event: UiEvent {
1589                id,
1590                kind: "change".to_string(),
1591                value: Some(value),
1592                composing: Some(composing),
1593                ..default()
1594            },
1595        });
1596    }
1597
1598    if bridge.editable_select_handlers.contains(&id) {
1599        let sel = editable.editor().raw_selection();
1600        let anchor = sel.anchor().index();
1601        let focus = sel.focus().index();
1602        if bridge.editable_selections.get(&id) != Some(&(anchor, focus)) {
1603            // Pre-seeded by a programmatic select; this dedup suppresses that echo.
1604            bridge.editable_selections.insert(id, (anchor, focus));
1605            let direction = if anchor == focus {
1606                "none"
1607            } else if anchor < focus {
1608                "forward"
1609            } else {
1610                "backward"
1611            };
1612            let _ = bridge.outbound_tx.send(Outbound::UiEvent {
1613                event: UiEvent {
1614                    id,
1615                    kind: "select".to_string(),
1616                    selection_start: Some(anchor.min(focus)),
1617                    selection_end: Some(anchor.max(focus)),
1618                    selection_direction: Some(direction.to_string()),
1619                    composing: Some(composing),
1620                    ..default()
1621                },
1622            });
1623        }
1624    }
1625}
1626
1627/// Emit an `editableText`'s `"focus"` / `"blur"` events, and toggle the node's
1628/// [`FocusState`] so a `focusStyle` is (un)applied by [`apply_interaction_styles`].
1629/// `FocusGained`/`FocusLost` are `auto_propagate` (they bubble to parents), so we
1630/// act on the originally focused entity (`ev.entity`). Event emission is gated to
1631/// editables with an `onFocus`/`onBlur` handler; `FocusState` is general (no-op for
1632/// nodes without it).
1633pub fn on_focus_gained(
1634    ev: On<FocusGained>,
1635    bridge: ResMut<JsBridge>,
1636    editables: Query<&RNode, With<EditableText>>,
1637    mut focus_states: Query<&mut FocusState>,
1638) {
1639    set_focus_state(&mut focus_states, ev.entity, true);
1640    emit_focus_event(&bridge, &editables, ev.entity, "focus");
1641}
1642
1643/// See [`on_focus_gained`]; the blur counterpart.
1644pub fn on_focus_lost(
1645    ev: On<FocusLost>,
1646    bridge: ResMut<JsBridge>,
1647    editables: Query<&RNode, With<EditableText>>,
1648    mut focus_states: Query<&mut FocusState>,
1649) {
1650    set_focus_state(&mut focus_states, ev.entity, false);
1651    emit_focus_event(&bridge, &editables, ev.entity, "blur");
1652}
1653
1654/// Set a node's [`FocusState`] (if it has one), nudging change-detection only when
1655/// the value actually flips so `apply_interaction_styles` re-merges just on change.
1656fn set_focus_state(focus_states: &mut Query<&mut FocusState>, entity: Entity, focused: bool) {
1657    if let Ok(mut state) = focus_states.get_mut(entity)
1658        && state.0 != focused
1659    {
1660        state.0 = focused;
1661    }
1662}
1663
1664fn emit_focus_event(
1665    bridge: &JsBridge,
1666    editables: &Query<&RNode, With<EditableText>>,
1667    entity: Entity,
1668    kind: &str,
1669) {
1670    let Ok(rnode) = editables.get(entity) else {
1671        return;
1672    };
1673    if !bridge.editable_focus_handlers.contains(&rnode.0) {
1674        return;
1675    }
1676    let _ = bridge.outbound_tx.send(Outbound::UiEvent {
1677        event: UiEvent {
1678            id: rnode.0,
1679            kind: kind.to_string(),
1680            ..default()
1681        },
1682    });
1683}
1684
1685/// Apply controlled selections queued by [`queue_pending_selection`] to the live
1686/// `EditableText`. Runs after Bevy's text-edit pass so offsets resolve against the
1687/// text applied this frame. Pre-writes the last-emitted selection so the
1688/// `TextEditChange` this triggers doesn't echo back to JS as a `"select"`.
1689pub fn apply_pending_selections(
1690    mut bridge: ResMut<JsBridge>,
1691    mut editables: Query<&mut EditableText>,
1692    mut font_cx: ResMut<FontCx>,
1693    mut layout_cx: ResMut<LayoutCx>,
1694) {
1695    if bridge.editable_pending_selection.is_empty() {
1696        return;
1697    }
1698    let pending: Vec<(NodeId, (usize, usize))> =
1699        bridge.editable_pending_selection.drain().collect();
1700    for (id, (start, end)) in pending {
1701        let Some(&entity) = bridge.nodes.get(&id) else {
1702            continue;
1703        };
1704        let Ok(mut editable) = editables.get_mut(entity) else {
1705            continue;
1706        };
1707        // Suppress the echoed `"select"` (anchor=start, focus=end after the write).
1708        bridge.editable_selections.insert(id, (start, end));
1709        editable
1710            .editor_mut()
1711            .driver(&mut font_cx.context, &mut layout_cx.0)
1712            .select_byte_range(start, end);
1713    }
1714}
1715
1716/// Keep each `editableText`'s accessibility node's value in step with its text, so
1717/// screen readers announce the current content. Label/role are set on spawn (and
1718/// the label refreshed on update) in [`apply_js_ops`].
1719pub fn sync_editable_a11y(
1720    mut q: Query<(&EditableText, &mut AccessibilityNode), Changed<EditableText>>,
1721) {
1722    for (editable, mut node) in &mut q {
1723        node.set_value(editable.value().to_string());
1724    }
1725}
1726
1727/// The mouse buttons the pointer pipeline reports, paired with their DOM
1728/// `MouseEvent.button` numbers (`0`/`1`/`2` = left/middle/right — the same set
1729/// bevy_picking forwards; Back/Forward/Other stay ignored).
1730const POINTER_BUTTONS: [(MouseButton, u8); 3] = [
1731    (MouseButton::Left, 0),
1732    (MouseButton::Middle, 1),
1733    (MouseButton::Right, 2),
1734];
1735
1736/// The node currently being dragged (an `onPointer*` element pressed with any
1737/// mouse button), plus the last cursor positions we read for it — used as a
1738/// fallback when the cursor leaves the window mid-drag. `button`/`dom_button`
1739/// are the button that began the drag: move/up track and report it, and any
1740/// other button pressed mid-drag is ignored (one active drag at a time).
1741/// `last_pos` is the node-relative `0..1` position; `last_abs` is the absolute
1742/// window position.
1743pub struct ActiveDrag {
1744    entity: Option<Entity>,
1745    button: MouseButton,
1746    dom_button: u8,
1747    last_pos: Vec2,
1748    last_abs: Vec2,
1749}
1750
1751impl Default for ActiveDrag {
1752    fn default() -> Self {
1753        Self {
1754            entity: None,
1755            button: MouseButton::Left,
1756            dom_button: 0,
1757            last_pos: Vec2::ZERO,
1758            last_abs: Vec2::ZERO,
1759        }
1760    }
1761}
1762
1763/// Drive native pointer/drag events for elements that declared `onPointer*`
1764/// handlers. Unlike the discrete click path, this follows the cursor across
1765/// frames so a dragged control (e.g. a slider) keeps updating even when the
1766/// pointer leaves its bounds — `RelativeCursorPosition` keeps reporting while the
1767/// cursor is anywhere in the window, and we clamp to `0..1`. `pointerMove` is
1768/// emitted only when the window cursor actually moved (DOM semantics), not once
1769/// per held frame. Any mouse button starts a drag and is reported on its events
1770/// ([`ActiveDrag::button`] — one drag at a time, keyed to the button that began
1771/// it).
1772///
1773/// `RelativeCursorPosition::normalized` is centered (`-0.5` = left/top edge,
1774/// `0.5` = right/bottom); we shift it to a `0..1` top-left origin to match the
1775/// CSS-like coordinates the JS handlers expect.
1776pub fn collect_pointer_events(
1777    bridge: Res<JsBridge>,
1778    buttons: Res<ButtonInput<MouseButton>>,
1779    windows: Query<&Window>,
1780    nodes: Query<(
1781        Entity,
1782        &RNode,
1783        &Interaction,
1784        &RelativeCursorPosition,
1785        &PointerHandlers,
1786    )>,
1787    interactions: Query<&Interaction>,
1788    mut capture: ResMut<crate::PointerCapture>,
1789    mut drag: Local<ActiveDrag>,
1790) {
1791    let emit = |rnode: &RNode, kind: &str, pos: Vec2, abs: Vec2, button: u8| {
1792        let _ = bridge.outbound_tx.send(Outbound::UiEvent {
1793            event: UiEvent {
1794                id: rnode.0,
1795                kind: kind.to_string(),
1796                x: Some(pos.x),
1797                y: Some(pos.y),
1798                client_x: Some(abs.x),
1799                client_y: Some(abs.y),
1800                button: Some(button),
1801                ..default()
1802            },
1803        });
1804    };
1805
1806    // Absolute cursor position in window logical pixels; `None` when the cursor
1807    // is outside the window (mid-drag), where we fall back to the last reading.
1808    let cursor_abs = windows.iter().next().and_then(|w| w.cursor_position());
1809
1810    // Begin a drag on the frame any button goes down over a handler node.
1811    if drag.entity.is_none() {
1812        'begin: for (mb, dom) in POINTER_BUTTONS {
1813            if !buttons.just_pressed(mb) {
1814                continue;
1815            }
1816            for (entity, rnode, interaction, rel, handlers) in &nodes {
1817                let over = if mb == MouseButton::Left {
1818                    // `ui_focus_system` attributes left presses for us (it
1819                    // honors `FocusPolicy` blocking).
1820                    *interaction == Interaction::Pressed
1821                } else {
1822                    // Other buttons never set `Pressed`: use this frame's hover
1823                    // attribution (same blocking rules) plus the geometric
1824                    // over-test, which rejects a stale sticky `Pressed` left
1825                    // behind by a left-drag that exited the node.
1826                    *interaction != Interaction::None && rel.cursor_over()
1827                };
1828                if over {
1829                    let pos = normalized_01(rel).unwrap_or(drag.last_pos);
1830                    let abs = cursor_abs.unwrap_or(drag.last_abs);
1831                    drag.entity = Some(entity);
1832                    drag.button = mb;
1833                    drag.dom_button = dom;
1834                    drag.last_pos = pos;
1835                    drag.last_abs = abs;
1836                    if handlers.down {
1837                        emit(rnode, "pointerDown", pos, abs, dom);
1838                    }
1839                    break 'begin;
1840                }
1841            }
1842        }
1843    }
1844
1845    // While the initiating button is held, follow the cursor and emit move
1846    // events (a drag). Only an actual cursor displacement emits: a stationary
1847    // held pointer stays silent (DOM `pointermove` semantics) instead of
1848    // flooding the bridge with one identical event per frame.
1849    if buttons.pressed(drag.button)
1850        && let Some(entity) = drag.entity
1851        && let Ok((_, rnode, _, rel, handlers)) = nodes.get(entity)
1852    {
1853        let pos = normalized_01(rel).unwrap_or(drag.last_pos);
1854        let abs = cursor_abs.unwrap_or(drag.last_abs);
1855        let cursor_moved = abs != drag.last_abs;
1856        drag.last_pos = pos;
1857        drag.last_abs = abs;
1858        if cursor_moved && handlers.moved {
1859            emit(rnode, "pointerMove", pos, abs, drag.dom_button);
1860        }
1861    }
1862
1863    // End the drag when the initiating button is released.
1864    if buttons.just_released(drag.button)
1865        && let Some(entity) = drag.entity.take()
1866        && let Ok((_, rnode, _, rel, handlers)) = nodes.get(entity)
1867    {
1868        let pos = normalized_01(rel).unwrap_or(drag.last_pos);
1869        let abs = cursor_abs.unwrap_or(drag.last_abs);
1870        if handlers.up {
1871            emit(rnode, "pointerUp", pos, abs, drag.dom_button);
1872        }
1873    }
1874
1875    // Publish whether the UI owns the pointer so world systems (e.g. a camera
1876    // controller) can ignore the mouse. `dragging` spans the whole gesture even
1877    // once the cursor leaves the element; `over_ui` covers hover/press on any
1878    // interactive node (so e.g. wheel-zoom over UI can be trapped too).
1879    capture.dragging = drag.entity.is_some();
1880    capture.over_ui = interactions.iter().any(|i| *i != Interaction::None);
1881}
1882
1883/// Emit `pointerEnter` / `pointerLeave` for main-window nodes that declared those
1884/// handlers. Hover in/out is the `Interaction` `None`↔(`Hovered`|`Pressed`) boundary
1885/// — the same signal that drives hover *styling* ([`apply_interaction_styles`]) — so
1886/// this lands on the right node via `FocusPolicy` (a `<button>`, not its child text)
1887/// with no ancestor climbing. Per-node [`HoverState`] remembers whether the pointer
1888/// was inside, so a click's `Hovered`↔`Pressed` transition never re-fires enter/leave.
1889#[allow(clippy::type_complexity)]
1890pub fn collect_hover_events(
1891    bridge: Res<JsBridge>,
1892    windows: Query<&Window>,
1893    mut nodes: Query<
1894        (
1895            &Interaction,
1896            &mut HoverState,
1897            &PointerHandlers,
1898            &RNode,
1899            Option<&RelativeCursorPosition>,
1900        ),
1901        Changed<Interaction>,
1902    >,
1903) {
1904    let cursor_abs = windows.iter().next().and_then(|w| w.cursor_position());
1905    for (interaction, mut hover, handlers, rnode, rel) in &mut nodes {
1906        let inside = *interaction != Interaction::None;
1907        if inside == hover.0 {
1908            continue; // A `Hovered`↔`Pressed` change, not a boundary crossing.
1909        }
1910        hover.0 = inside;
1911        let kind = if inside {
1912            "pointerEnter"
1913        } else {
1914            "pointerLeave"
1915        };
1916        if (inside && handlers.enter) || (!inside && handlers.leave) {
1917            let pos = rel.and_then(normalized_01).unwrap_or(Vec2::ZERO);
1918            let abs = cursor_abs.unwrap_or(Vec2::ZERO);
1919            send_ui_event(&bridge, rnode.0, kind, Some(pos), Some(abs), None);
1920        }
1921    }
1922}
1923
1924/// Shift `RelativeCursorPosition`'s centered, unclamped position to a clamped
1925/// `0..1` top-left-origin coordinate. `None` when the cursor position is unknown.
1926fn normalized_01(rel: &RelativeCursorPosition) -> Option<Vec2> {
1927    rel.normalized
1928        .map(|n| Vec2::new((n.x + 0.5).clamp(0.0, 1.0), (n.y + 0.5).clamp(0.0, 1.0)))
1929}
1930
1931/// Re-apply the merged style for any element with [`StyleVariants`] whose
1932/// `Interaction` or `FocusState` changed (hover/press/focus in or out) — or whose
1933/// variants changed from a React re-render. The interaction axis: `None` → base,
1934/// `Hovered` → base+hover, `Pressed` → base+hover+press; then `focus` overlays last
1935/// (so an explicit `focusStyle` wins on conflicting fields). Both `Interaction` and
1936/// `FocusState` are optional — a focus-only `editableText` has no `Interaction`, and
1937/// a hover-only node has no `FocusState`. Runs entirely on the Bevy side: no
1938/// round-trip to JS, no React re-render on mouse move or focus change.
1939#[allow(clippy::type_complexity)]
1940pub fn apply_interaction_styles(
1941    mut commands: Commands,
1942    query: Query<
1943        (
1944            Entity,
1945            Option<&Interaction>,
1946            Option<&FocusState>,
1947            &StyleVariants,
1948        ),
1949        Or<(
1950            Changed<Interaction>,
1951            Changed<FocusState>,
1952            Changed<StyleVariants>,
1953        )>,
1954    >,
1955    rnodes: Query<&RNode>,
1956) {
1957    for (entity, interaction, focus, variants) in &query {
1958        let mut style = match interaction {
1959            Some(Interaction::Pressed) => overlay_style(
1960                &overlay_style(&variants.base, &variants.hover),
1961                &variants.press,
1962            ),
1963            Some(Interaction::Hovered) => overlay_style(&variants.base, &variants.hover),
1964            _ => variants.base.clone(),
1965        };
1966        if focus.is_some_and(|f| f.0) {
1967            style = overlay_style(&style, &variants.focus);
1968        }
1969        // Attribute re-parse warnings (e.g. a bad hoverStyle color) to the node.
1970        let _diag = rnodes
1971            .get(entity)
1972            .ok()
1973            .map(|r| crate::diag::node_scope(r.0));
1974        apply_style(&mut commands.entity(entity), &style);
1975    }
1976}
1977
1978/// Send one [`Outbound::UiEvent`] to the JS thread for a reconciler node.
1979fn send_ui_event(
1980    bridge: &JsBridge,
1981    id: NodeId,
1982    kind: &str,
1983    pos: Option<Vec2>,
1984    abs: Option<Vec2>,
1985    button: Option<u8>,
1986) {
1987    let _ = bridge.outbound_tx.send(Outbound::UiEvent {
1988        event: UiEvent {
1989            id,
1990            kind: kind.to_string(),
1991            x: pos.map(|p| p.x),
1992            y: pos.map(|p| p.y),
1993            client_x: abs.map(|a| a.x),
1994            client_y: abs.map(|a| a.y),
1995            button,
1996            ..default()
1997        },
1998    });
1999}
2000
2001/// DOM `MouseEvent.button` number for a picking button (`0`/`1`/`2` =
2002/// left/middle/right — bevy_picking never forwards Back/Forward/Other).
2003fn dom_button(button: PointerButton) -> u8 {
2004    match button {
2005        PointerButton::Primary => 0,
2006        PointerButton::Middle => 1,
2007        PointerButton::Secondary => 2,
2008    }
2009}
2010
2011/// Node-relative `0..1` position (top-left origin) of a surface-space pixel
2012/// `position` within a node, plus that absolute surface pixel as the client coord.
2013/// `None` when the point can't be normalized (degenerate node).
2014fn surface_relative(
2015    node: &ComputedNode,
2016    transform: &UiGlobalTransform,
2017    position: Vec2,
2018) -> Option<(Vec2, Vec2)> {
2019    node.normalize_point(*transform, position).map(|n| {
2020        (
2021            Vec2::new((n.x + 0.5).clamp(0.0, 1.0), (n.y + 0.5).clamp(0.0, 1.0)),
2022            position,
2023        )
2024    })
2025}
2026
2027/// Walk up the `ChildOf` chain from `entity` (inclusive) to the nearest entity that
2028/// satisfies `is_target`. Surface picking hits the topmost leaf node (e.g. a `<text>`
2029/// inside a `<button>`); this resolves it to the node that actually owns the
2030/// interaction — mirroring how the legacy focus system attributes to the nearest
2031/// `Interaction` node. Stops at the (detached) surface root when nothing matches.
2032pub(crate) fn climb(
2033    mut entity: Entity,
2034    child_of: &Query<&ChildOf>,
2035    is_target: impl Fn(Entity) -> bool,
2036) -> Option<Entity> {
2037    loop {
2038        if is_target(entity) {
2039            return Some(entity);
2040        }
2041        entity = child_of.get(entity).ok()?.parent();
2042    }
2043}
2044
2045/// Report `<surface>` clicks to JS. The in-world picking path drives a virtual
2046/// pointer ([`SurfaceVirtualPointer`]) over the offscreen subtree, so a click on a
2047/// surface node arrives as a `Pointer<Click>` for that pointer — the analogue of
2048/// [`collect_ui_events`] for surfaces (whose nodes never get a legacy `Interaction`
2049/// press, since they don't render to a window), primary-button-only like it too.
2050/// Scoped to the surface pointer id so it never double-fires for main-window UI.
2051pub fn collect_surface_clicks(
2052    bridge: Res<JsBridge>,
2053    pointer: Option<Res<SurfaceVirtualPointer>>,
2054    mut clicks: MessageReader<Pointer<Click>>,
2055    // Only `Interaction`-bearing nodes own a click (a `<button>` gets one via `Button`;
2056    // a `<text>` child does not) — matching the legacy `collect_ui_events` attribution.
2057    targets: Query<&RNode, With<Interaction>>,
2058    child_of: Query<&ChildOf>,
2059) {
2060    let Some(pointer) = pointer else { return };
2061    // A pass-through node stacked over the target makes one gesture fan out to
2062    // every entity in the hover map; climbing can resolve them to the same
2063    // owner, so dedupe per owner within the frame.
2064    let mut seen: HashSet<Entity> = HashSet::new();
2065    for ev in clicks.read() {
2066        // Like DOM `click` (and `collect_ui_events`), only the primary button
2067        // clicks; right/middle ride the `onPointer*` events.
2068        if ev.pointer_id != pointer.id || ev.button != PointerButton::Primary {
2069            continue;
2070        }
2071        // Resolve the picked leaf to the nearest interactive ancestor (the button),
2072        // so a click on its label text still fires the button's handler.
2073        if let Some(target) = climb(ev.entity, &child_of, |e| targets.contains(e))
2074            && seen.insert(target)
2075            && let Ok(rnode) = targets.get(target)
2076        {
2077            debug!("surface click -> reconciler node {}", rnode.0);
2078            send_ui_event(&bridge, rnode.0, "click", None, None, None);
2079        }
2080    }
2081}
2082
2083/// Report `onPointer*` drag events for `<surface>` nodes, mirroring
2084/// [`collect_pointer_events`] for the in-world picking path. Press → `pointerDown`,
2085/// drag → `pointerMove`, release → `pointerUp`, each gated on the node's declared
2086/// [`PointerHandlers`], carrying the cursor's node-relative `0..1` position
2087/// (the surface-space pixel as `client_x/y`) and the mouse button (a `Drag`'s
2088/// button is the one doing the dragging).
2089#[allow(clippy::too_many_arguments)]
2090pub fn collect_surface_pointer_events(
2091    bridge: Res<JsBridge>,
2092    pointer: Option<Res<SurfaceVirtualPointer>>,
2093    mut presses: MessageReader<Pointer<Press>>,
2094    mut releases: MessageReader<Pointer<Release>>,
2095    mut drags: MessageReader<Pointer<Drag>>,
2096    nodes: Query<(&RNode, &PointerHandlers, &ComputedNode, &UiGlobalTransform)>,
2097    child_of: Query<&ChildOf>,
2098) {
2099    let Some(pointer) = pointer else { return };
2100    // Per-kind (owner, button) dedupe: a pass-through node stacked over the
2101    // target fans each gesture out to every hovered entity, and climbing can
2102    // resolve them to the same owner. (Moves see at most one `Drag` per button
2103    // per frame — `drive_surface_pointer` emits at most one `Move` per frame —
2104    // so the set never suppresses a genuine repeat.)
2105    let mut seen: HashSet<(Entity, PointerButton)> = HashSet::new();
2106    let emit = |entity: Entity,
2107                want: fn(&PointerHandlers) -> bool,
2108                kind: &str,
2109                at: Vec2,
2110                button: PointerButton,
2111                seen: &mut HashSet<(Entity, PointerButton)>| {
2112        // Resolve the picked leaf to the nearest ancestor that declared `onPointer*`.
2113        if let Some(target) = climb(entity, &child_of, |e| nodes.contains(e))
2114            && seen.insert((target, button))
2115            && let Ok((rnode, handlers, node, transform)) = nodes.get(target)
2116            && want(handlers)
2117            && let Some((pos, abs)) = surface_relative(node, transform, at)
2118        {
2119            send_ui_event(
2120                &bridge,
2121                rnode.0,
2122                kind,
2123                Some(pos),
2124                Some(abs),
2125                Some(dom_button(button)),
2126            );
2127        }
2128    };
2129    for ev in presses.read() {
2130        if ev.pointer_id == pointer.id {
2131            emit(
2132                ev.entity,
2133                |h| h.down,
2134                "pointerDown",
2135                ev.pointer_location.position,
2136                ev.button,
2137                &mut seen,
2138            );
2139        }
2140    }
2141    seen.clear();
2142    for ev in drags.read() {
2143        if ev.pointer_id == pointer.id {
2144            emit(
2145                ev.entity,
2146                |h| h.moved,
2147                "pointerMove",
2148                ev.pointer_location.position,
2149                ev.button,
2150                &mut seen,
2151            );
2152        }
2153    }
2154    seen.clear();
2155    for ev in releases.read() {
2156        if ev.pointer_id == pointer.id {
2157            emit(
2158                ev.entity,
2159                |h| h.up,
2160                "pointerUp",
2161                ev.pointer_location.position,
2162                ev.button,
2163                &mut seen,
2164            );
2165        }
2166    }
2167}
2168
2169/// Report `pointerEnter` / `pointerLeave` for `<surface>` nodes, mirroring
2170/// [`collect_surface_pointer_events`] for the hover boundary. Surface nodes get no
2171/// legacy `Interaction`, so this reads the virtual pointer's `Pointer<Enter>` /
2172/// `Pointer<Leave>` picking events. Those already implement DOM
2173/// `mouseenter`/`mouseleave` semantics — they fire for the hovered entity *and*
2174/// its ancestors, only on true boundary crossings — so no climb (and no dedupe)
2175/// is needed, and crossing between a button's label and its padding never
2176/// re-fires the button's boundary. Hover events carry no button.
2177pub fn collect_surface_hover_events(
2178    bridge: Res<JsBridge>,
2179    pointer: Option<Res<SurfaceVirtualPointer>>,
2180    mut enters: MessageReader<Pointer<Enter>>,
2181    mut leaves: MessageReader<Pointer<Leave>>,
2182    nodes: Query<(&RNode, &PointerHandlers, &ComputedNode, &UiGlobalTransform)>,
2183) {
2184    let Some(pointer) = pointer else { return };
2185    let emit = |entity: Entity, want: fn(&PointerHandlers) -> bool, kind: &str, at: Vec2| {
2186        if let Ok((rnode, handlers, node, transform)) = nodes.get(entity)
2187            && want(handlers)
2188            && let Some((pos, abs)) = surface_relative(node, transform, at)
2189        {
2190            send_ui_event(&bridge, rnode.0, kind, Some(pos), Some(abs), None);
2191        }
2192    };
2193    for ev in enters.read() {
2194        if ev.pointer_id == pointer.id {
2195            emit(
2196                ev.entity,
2197                |h| h.enter,
2198                "pointerEnter",
2199                ev.pointer_location.position,
2200            );
2201        }
2202    }
2203    for ev in leaves.read() {
2204        if ev.pointer_id == pointer.id {
2205            emit(
2206                ev.entity,
2207                |h| h.leave,
2208                "pointerLeave",
2209                ev.pointer_location.position,
2210            );
2211        }
2212    }
2213}
2214
2215/// Apply hover/press [`StyleVariants`] to `<surface>` nodes from the in-world
2216/// picking path — the surface-side analogue of [`apply_interaction_styles`], which
2217/// can't help here because surface nodes never receive a legacy `Interaction`
2218/// (their offscreen camera makes `ui_focus_system` skip them). Enter →
2219/// base+hover, press → base+hover+press, leave/release → base/hover. The hover
2220/// axis rides `Pointer<Enter>`/`Pointer<Leave>` (boundary-only, ancestor-aware —
2221/// see [`collect_surface_hover_events`]); the press axis keeps `Press`/`Release`
2222/// with the climb, filtered to the primary button so a right/middle press
2223/// doesn't trigger `pressStyle` (DOM `:active` parity with the main window's
2224/// `Interaction::Pressed`).
2225#[allow(clippy::too_many_arguments)]
2226pub fn apply_surface_interaction_styles(
2227    mut commands: Commands,
2228    pointer: Option<Res<SurfaceVirtualPointer>>,
2229    mut enters: MessageReader<Pointer<Enter>>,
2230    mut leaves: MessageReader<Pointer<Leave>>,
2231    mut presses: MessageReader<Pointer<Press>>,
2232    mut releases: MessageReader<Pointer<Release>>,
2233    variants: Query<&StyleVariants>,
2234    child_of: Query<&ChildOf>,
2235    rnodes: Query<&RNode>,
2236) {
2237    let Some(pointer) = pointer else { return };
2238    let mut restyle = |entity: Entity, style: Option<Style>| {
2239        // Attribute re-parse warnings (e.g. a bad hoverStyle color) to the node.
2240        let _diag = rnodes
2241            .get(entity)
2242            .ok()
2243            .map(|r| crate::diag::node_scope(r.0));
2244        apply_style(&mut commands.entity(entity), &style);
2245    };
2246    // Resolve a picked leaf to the nearest ancestor with hover/press variants (the
2247    // button), so its label text highlights the button rather than nothing.
2248    let target = |entity: Entity| climb(entity, &child_of, |e| variants.contains(e));
2249    for ev in leaves.read() {
2250        if ev.pointer_id == pointer.id
2251            && let Ok(v) = variants.get(ev.entity)
2252        {
2253            restyle(ev.entity, v.base.clone());
2254        }
2255    }
2256    for ev in enters.read() {
2257        if ev.pointer_id == pointer.id
2258            && let Ok(v) = variants.get(ev.entity)
2259        {
2260            restyle(ev.entity, overlay_style(&v.base, &v.hover));
2261        }
2262    }
2263    for ev in releases.read() {
2264        if ev.pointer_id == pointer.id
2265            && ev.button == PointerButton::Primary
2266            && let Some(t) = target(ev.entity)
2267            && let Ok(v) = variants.get(t)
2268        {
2269            restyle(t, overlay_style(&v.base, &v.hover));
2270        }
2271    }
2272    for ev in presses.read() {
2273        if ev.pointer_id == pointer.id
2274            && ev.button == PointerButton::Primary
2275            && let Some(t) = target(ev.entity)
2276            && let Ok(v) = variants.get(t)
2277        {
2278            let pressed = overlay_style(&overlay_style(&v.base, &v.hover), &v.press);
2279            restyle(t, pressed);
2280        }
2281    }
2282}
2283
2284#[cfg(test)]
2285mod tests {
2286    use super::*;
2287    use crate::bridge::JsBridge;
2288    use crate::transition::TransitionInput;
2289    use std::f32::consts::PI;
2290
2291    // Pass rotate as an explicit `rad` string so the asserted radian value is
2292    // carried verbatim (a bare number would be read as degrees).
2293    fn text_props(rotate: f32) -> Props {
2294        serde_json::from_value(serde_json::json!({
2295            "style": {
2296                "transform": { "rotate": format!("{rotate}rad") },
2297                "transition": { "transform": { "duration": 0.3 } },
2298            }
2299        }))
2300        .expect("valid text props")
2301    }
2302
2303    /// A delta update: only the supplied fields are touched.
2304    fn update_delta(id: NodeId, props: Props, unset: &[&str], style_unset: &[&str]) -> Op {
2305        Op::Update {
2306            id,
2307            props,
2308            unset: unset.iter().map(|s| s.to_string()).collect(),
2309            style_unset: style_unset.iter().map(|s| s.to_string()).collect(),
2310        }
2311    }
2312
2313    /// Spin up a minimal app wired to `apply_js_ops`, returning the app and the
2314    /// op sender (the outbound receiver is leaked to keep the sender open).
2315    fn op_app() -> (App, crossbeam_channel::Sender<Vec<Op>>) {
2316        let mut app = App::new();
2317        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
2318        app.init_asset::<Image>();
2319        app.init_asset::<TextureAtlasLayout>();
2320        app.init_resource::<Fonts>();
2321        app.init_resource::<OpApplyStats>();
2322        app.init_resource::<AtlasLayoutCache>();
2323        // `apply_js_ops` reads the `filter` material assets/cache + white pixel.
2324        app.init_asset::<FilterMaterial>();
2325        app.init_resource::<FilterMaterialCache>();
2326        app.add_systems(Startup, crate::filter::init_filter_assets);
2327
2328        let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
2329        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
2330        std::mem::forget(out_rx); // keep the channel open for the test's lifetime
2331        let root = app.world_mut().spawn_empty().id();
2332        app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
2333        app.add_systems(Update, apply_js_ops);
2334        (app, ops_tx)
2335    }
2336
2337    /// The per-batch origin flags attribute applies: a devtools-flagged batch
2338    /// bumps `applied_count` but not `app_applied_count`, so devtools batch
2339    /// stats (keyed off the app counter) skip the panel's own repaints —
2340    /// otherwise stats → panel repaint → new batch → stats… self-observes at
2341    /// frame rate.
2342    #[test]
2343    fn devtools_flagged_batches_skip_app_applied_count() {
2344        let (mut app, ops_tx) = op_app();
2345        let (flags_tx, flags_rx) = crossbeam_channel::unbounded::<bool>();
2346        app.insert_resource(FlushFlags(flags_rx));
2347        let create = |id: NodeId| Op::Create {
2348            id,
2349            kind: "node".into(),
2350            props: Props::default(),
2351            text: None,
2352        };
2353
2354        // A devtools-flagged batch (the panel's own commit): applied, but not
2355        // an APP apply.
2356        flags_tx.send(true).unwrap();
2357        ops_tx.send(vec![create(1)]).unwrap();
2358        app.update();
2359        let stats = *app.world().resource::<OpApplyStats>();
2360        assert_eq!((stats.applied_count, stats.app_applied_count), (1, 0));
2361
2362        // An app batch bumps both — even when a devtools batch coalesces into
2363        // the same apply.
2364        flags_tx.send(false).unwrap();
2365        ops_tx.send(vec![create(2)]).unwrap();
2366        flags_tx.send(true).unwrap();
2367        ops_tx.send(vec![create(3)]).unwrap();
2368        app.update();
2369        let stats = *app.world().resource::<OpApplyStats>();
2370        assert_eq!((stats.applied_count, stats.app_applied_count), (2, 1));
2371    }
2372
2373    #[test]
2374    fn split_pre_apply_splits_wait_and_in_frame() {
2375        use std::time::Duration;
2376        let t0 = std::time::Instant::now();
2377        let t1 = t0 + Duration::from_millis(12);
2378        let t2 = t1 + Duration::from_millis(3);
2379        assert_eq!(
2380            split_pre_apply(t0, Some(t1), t2),
2381            (Duration::from_millis(12), Duration::from_millis(3))
2382        );
2383        // A stamp landing mid-frame (after frame start, e.g. a JS-timer
2384        // commit) clamps the wait to zero — the whole span is in-frame.
2385        assert_eq!(split_pre_apply(t1, Some(t0), t2), (Duration::ZERO, t2 - t1));
2386        // No frame stamp (headless): the whole span is the in-frame leg.
2387        assert_eq!(split_pre_apply(t0, None, t2), (Duration::ZERO, t2 - t0));
2388    }
2389
2390    /// The send→apply span splits at the frame boundary: the cross-frame queue
2391    /// wait lands in `last_frame_wait`, the in-frame remainder in
2392    /// `last_pre_apply`.
2393    #[test]
2394    fn flush_stamp_splits_frame_wait_from_pre_apply() {
2395        use std::time::{Duration, Instant};
2396        let (mut app, ops_tx) = op_app();
2397        let (stamps_tx, stamps_rx) = crossbeam_channel::unbounded::<Instant>();
2398        app.insert_resource(FlushStamps(stamps_rx));
2399        // Both boundaries in the past so ordering is stamp < frame start <
2400        // apply start (a future-dated frame stamp would saturate the in-frame
2401        // leg to zero instead).
2402        let now = Instant::now();
2403        let stamp = now - Duration::from_millis(30);
2404        let frame_start = now - Duration::from_millis(10);
2405        app.insert_resource(FrameStamp(Some(frame_start)));
2406
2407        stamps_tx.send(stamp).unwrap();
2408        ops_tx
2409            .send(vec![Op::Create {
2410                id: 1,
2411                kind: "node".into(),
2412                props: Props::default(),
2413                text: None,
2414            }])
2415            .unwrap();
2416        app.update();
2417
2418        let stats = *app.world().resource::<OpApplyStats>();
2419        // Both endpoints are fixed instants, so the wait is exact.
2420        assert_eq!(stats.last_frame_wait, Duration::from_millis(20));
2421        // The in-frame leg runs to the real apply start — at least the fixed
2422        // 10ms between the frame stamp and `now`.
2423        assert!(stats.last_pre_apply >= Duration::from_millis(10));
2424    }
2425
2426    /// A plain `<node onClick>` — no hover/press style, not a `<button>` — must get
2427    /// an `Interaction` so [`collect_ui_events`] can report its clicks. Regression:
2428    /// `onClick` crossed the wire as a bool but nothing attached an `Interaction`,
2429    /// so such a node was silently unclickable (only a `<button>`, or a node that
2430    /// also had a hover/press style or an `onPointer*` handler, worked).
2431    #[test]
2432    fn node_onclick_attaches_interaction() {
2433        let (mut app, ops_tx) = op_app();
2434
2435        ops_tx
2436            .send(vec![
2437                // 1: a bare onClick node — the case that was broken.
2438                Op::Create {
2439                    id: 1,
2440                    kind: "node".into(),
2441                    props: serde_json::from_value(serde_json::json!({ "onClick": true })).unwrap(),
2442                    text: None,
2443                },
2444                // 2: a node with no interaction props at all — must stay inert.
2445                Op::Create {
2446                    id: 2,
2447                    kind: "node".into(),
2448                    props: Props::default(),
2449                    text: None,
2450                },
2451            ])
2452            .unwrap();
2453        app.update();
2454
2455        let nodes = &app.world().resource::<JsBridge>().nodes;
2456        let (clickable, inert) = (nodes[&1], nodes[&2]);
2457        assert!(
2458            app.world().entity(clickable).get::<Interaction>().is_some(),
2459            "`onClick` alone must make a <node> clickable"
2460        );
2461        assert!(
2462            app.world().entity(inert).get::<Interaction>().is_none(),
2463            "a node with no handlers/hover/press must not gain an Interaction"
2464        );
2465    }
2466
2467    /// A node with `onPointerEnter`/`onPointerLeave` gets an `Interaction` + a
2468    /// [`HoverState`], and the reconciler stamps the handler flags.
2469    #[test]
2470    fn pointer_enter_leave_stamps_hover_state() {
2471        let (mut app, ops_tx) = op_app();
2472        ops_tx
2473            .send(vec![Op::Create {
2474                id: 1,
2475                kind: "node".into(),
2476                props: serde_json::from_value(
2477                    serde_json::json!({ "onPointerEnter": true, "onPointerLeave": true }),
2478                )
2479                .unwrap(),
2480                text: None,
2481            }])
2482            .unwrap();
2483        app.update();
2484
2485        let e = app.world().resource::<JsBridge>().nodes[&1];
2486        let entity = app.world().entity(e);
2487        assert!(
2488            entity.get::<Interaction>().is_some(),
2489            "hover handlers must make the node interactive"
2490        );
2491        assert!(
2492            entity.get::<HoverState>().is_some(),
2493            "hover handlers must stamp a HoverState"
2494        );
2495        let handlers = entity.get::<PointerHandlers>().expect("PointerHandlers");
2496        assert!(handlers.enter && handlers.leave);
2497    }
2498
2499    /// [`collect_hover_events`] emits `pointerEnter` on the first non-`None`
2500    /// interaction and `pointerLeave` on the return to `None`, and must NOT re-fire
2501    /// on the `Hovered`↔`Pressed` transition of a click (guarded by [`HoverState`]).
2502    #[test]
2503    fn hover_events_fire_on_boundary_only() {
2504        let mut app = App::new();
2505        app.add_plugins(MinimalPlugins);
2506        let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
2507        let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
2508        let root = app.world_mut().spawn_empty().id();
2509        app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
2510        app.add_systems(Update, collect_hover_events);
2511
2512        let e = app
2513            .world_mut()
2514            .spawn((
2515                Interaction::None,
2516                HoverState(false),
2517                PointerHandlers {
2518                    enter: true,
2519                    leave: true,
2520                    ..default()
2521                },
2522                RNode(1),
2523            ))
2524            .id();
2525
2526        let set = |app: &mut App, i: Interaction| {
2527            *app.world_mut()
2528                .entity_mut(e)
2529                .get_mut::<Interaction>()
2530                .unwrap() = i;
2531            app.update();
2532        };
2533
2534        app.update(); // Mount frame: still "outside" (None) → no event.
2535        set(&mut app, Interaction::Hovered); // None → Hovered: enter.
2536        set(&mut app, Interaction::Pressed); // Hovered → Pressed: no re-enter.
2537        set(&mut app, Interaction::None); // Pressed → None: leave.
2538
2539        let kinds: Vec<String> = std::iter::from_fn(|| out_rx.try_recv().ok())
2540            .map(|o| match o {
2541                Outbound::UiEvent { event } => {
2542                    assert_eq!(event.id, 1);
2543                    event.kind
2544                }
2545                other => panic!("expected a UiEvent, got {other:?}"),
2546            })
2547            .collect();
2548        assert_eq!(kinds, vec!["pointerEnter", "pointerLeave"]);
2549    }
2550
2551    /// `FocusPolicy` defaults differ by element kind: a `<button>` captures the
2552    /// pointer (`Block`, mirroring bevy_ui's native `Button`), while a `<node>`
2553    /// passes it through (`Pass`), so a container/label never swallows clicks meant
2554    /// for what's behind it. An explicit `focusPolicy` prop overrides either, and
2555    /// re-rendering a button keeps its `Block` (the per-commit `apply_style` resets
2556    /// it to `Pass` first).
2557    #[test]
2558    fn focus_policy_defaults_block_button_pass_node() {
2559        let (mut app, ops_tx) = op_app();
2560
2561        let node_props =
2562            |json: serde_json::Value| -> Props { serde_json::from_value(json).unwrap() };
2563        ops_tx
2564            .send(vec![
2565                // 1: bare button → Block default.
2566                Op::Create {
2567                    id: 1,
2568                    kind: "button".into(),
2569                    props: Props::default(),
2570                    text: None,
2571                },
2572                // 2: bare node → Pass default.
2573                Op::Create {
2574                    id: 2,
2575                    kind: "node".into(),
2576                    props: Props::default(),
2577                    text: None,
2578                },
2579                // 3: button with explicit focusPolicy "pass" → overrides the default.
2580                Op::Create {
2581                    id: 3,
2582                    kind: "button".into(),
2583                    props: node_props(serde_json::json!({ "style": { "focusPolicy": "pass" } })),
2584                    text: None,
2585                },
2586            ])
2587            .unwrap();
2588        app.update();
2589
2590        let fp = |app: &App, id: u32| -> Option<FocusPolicy> {
2591            let e = app.world().resource::<JsBridge>().nodes[&id];
2592            app.world().entity(e).get::<FocusPolicy>().copied()
2593        };
2594        // The picking mirror: `Pickable.should_block_lower` must track the policy,
2595        // because the picking backend (which clicks and all `<surface>` interaction
2596        // ride) ignores `FocusPolicy` and blocks when `Pickable` is absent.
2597        let blocks = |app: &App, id: u32| -> Option<bool> {
2598            let e = app.world().resource::<JsBridge>().nodes[&id];
2599            app.world()
2600                .entity(e)
2601                .get::<bevy::picking::Pickable>()
2602                .map(|p| p.should_block_lower)
2603        };
2604        assert_eq!(
2605            fp(&app, 1),
2606            Some(FocusPolicy::Block),
2607            "button defaults to Block"
2608        );
2609        assert_eq!(blocks(&app, 1), Some(true), "button blocks picking too");
2610        assert_eq!(
2611            fp(&app, 2),
2612            Some(FocusPolicy::Pass),
2613            "node defaults to Pass"
2614        );
2615        assert_eq!(blocks(&app, 2), Some(false), "node passes picking too");
2616        assert_eq!(
2617            fp(&app, 3),
2618            Some(FocusPolicy::Pass),
2619            "explicit focusPolicy overrides the button default"
2620        );
2621        assert_eq!(
2622            blocks(&app, 3),
2623            Some(false),
2624            "explicit pass unblocks picking on a button"
2625        );
2626
2627        // A delta that dirties the FOCUS_POLICY group (unsetting the — already
2628        // absent — `focusPolicy` field) makes `apply_style` reset the bare
2629        // button to `Pass`; the button default must be re-asserted so it stays
2630        // `Block`. (A delta touching nothing wouldn't run the group at all.)
2631        ops_tx
2632            .send(vec![update_delta(
2633                1,
2634                Props::default(),
2635                &[],
2636                &["focusPolicy"],
2637            )])
2638            .unwrap();
2639        app.update();
2640        assert_eq!(
2641            fp(&app, 1),
2642            Some(FocusPolicy::Block),
2643            "a re-rendered button keeps its Block default"
2644        );
2645        assert_eq!(
2646            blocks(&app, 1),
2647            Some(true),
2648            "a re-rendered button keeps blocking picking"
2649        );
2650    }
2651
2652    /// A synthetic picking `Pointer<Click>` location: the render target is
2653    /// irrelevant to the collectors, so a default image handle stands in.
2654    fn click_location() -> bevy::picking::pointer::Location {
2655        bevy::picking::pointer::Location {
2656            target: bevy::camera::NormalizedRenderTarget::Image(Handle::<Image>::default().into()),
2657            position: Vec2::ZERO,
2658        }
2659    }
2660
2661    /// A minimal app wired for the picking-based click collectors: a `JsBridge`
2662    /// (with its outbound receiver kept alive) + `Pointer<Click>` messages.
2663    fn click_app() -> (App, tokio::sync::mpsc::UnboundedReceiver<Outbound>) {
2664        let mut app = App::new();
2665        app.add_plugins(MinimalPlugins);
2666        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
2667        let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
2668        std::mem::forget(_ops_tx); // Keep the ops channel open for the app's lifetime.
2669        let root = app.world_mut().spawn_empty().id();
2670        app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
2671        app.add_message::<Pointer<Click>>();
2672        (app, out_rx)
2673    }
2674
2675    fn drain_clicks(out_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Outbound>) -> Vec<UiEvent> {
2676        std::iter::from_fn(|| out_rx.try_recv().ok())
2677            .map(|o| match o {
2678                Outbound::UiEvent { event } => event,
2679                other => panic!("expected a UiEvent, got {other:?}"),
2680            })
2681            .collect()
2682    }
2683
2684    /// [`collect_ui_events`] rides `Pointer<Click>`: only the primary button
2685    /// clicks (right/middle are the `onPointer*` events' job), a click on a
2686    /// node's leaf (label) climbs to the `Interaction`-bearing owner, and the
2687    /// multi-pick fan-out (leaf + owner both hovered) dedupes to ONE event.
2688    #[test]
2689    fn picking_click_fires_once_primary_only() {
2690        let (mut app, mut out_rx) = click_app();
2691        app.add_systems(Update, collect_ui_events);
2692
2693        let owner = app.world_mut().spawn((RNode(1), Interaction::None)).id();
2694        let leaf = app.world_mut().spawn(ChildOf(owner)).id();
2695
2696        let click = |entity, button| {
2697            Pointer::new(
2698                PointerId::Mouse,
2699                click_location(),
2700                Click {
2701                    button,
2702                    hit: bevy::picking::backend::HitData::new(Entity::PLACEHOLDER, 0.0, None, None),
2703                    duration: std::time::Duration::ZERO,
2704                    count: 1,
2705                },
2706                entity,
2707            )
2708        };
2709        // A right click must be ignored entirely…
2710        app.world_mut()
2711            .write_message(click(leaf, PointerButton::Secondary));
2712        // …while a primary gesture fans out to every hovered entity (leaf +
2713        // owner) and must dedupe to one click.
2714        app.world_mut()
2715            .write_message(click(leaf, PointerButton::Primary));
2716        app.world_mut()
2717            .write_message(click(owner, PointerButton::Primary));
2718        app.update();
2719
2720        let events = drain_clicks(&mut out_rx);
2721        assert_eq!(
2722            events.len(),
2723            1,
2724            "secondary filtered out; leaf + owner primary picks dedupe to one click"
2725        );
2726        assert_eq!(events[0].id, 1);
2727        assert_eq!(events[0].kind, "click");
2728        assert_eq!(
2729            events[0].button, None,
2730            "clicks carry no button (primary implied)"
2731        );
2732    }
2733
2734    /// [`collect_pointer_events`] emits `pointerMove` only when the window cursor
2735    /// actually moved: a stationary held button is silent (the regression was one
2736    /// identical event per frame), and the down frame doesn't duplicate
2737    /// `pointerDown` as a zero-length move.
2738    #[test]
2739    fn pointer_move_only_fires_on_cursor_movement() {
2740        let (mut app, mut out_rx) = click_app();
2741        app.init_resource::<ButtonInput<MouseButton>>();
2742        app.init_resource::<crate::PointerCapture>();
2743        app.add_systems(Update, collect_pointer_events);
2744
2745        let mut window = Window::default();
2746        window.set_physical_cursor_position(Some(bevy::math::DVec2::new(100.0, 100.0)));
2747        let win = app.world_mut().spawn(window).id();
2748
2749        let node = app
2750            .world_mut()
2751            .spawn((
2752                RNode(1),
2753                Interaction::Pressed,
2754                RelativeCursorPosition {
2755                    cursor_over: true,
2756                    normalized: Some(Vec2::ZERO),
2757                },
2758                PointerHandlers {
2759                    down: true,
2760                    moved: true,
2761                    up: true,
2762                    ..default()
2763                },
2764            ))
2765            .id();
2766
2767        let kinds = |rx: &mut tokio::sync::mpsc::UnboundedReceiver<Outbound>| {
2768            drain_clicks(rx)
2769                .into_iter()
2770                .map(|e| e.kind)
2771                .collect::<Vec<_>>()
2772        };
2773
2774        // Press frame: a pointerDown, and no same-position pointerMove.
2775        app.world_mut()
2776            .resource_mut::<ButtonInput<MouseButton>>()
2777            .press(MouseButton::Left);
2778        app.update();
2779        assert_eq!(kinds(&mut out_rx), ["pointerDown"]);
2780
2781        // Held but stationary: silence.
2782        app.world_mut()
2783            .resource_mut::<ButtonInput<MouseButton>>()
2784            .clear();
2785        app.update();
2786        assert_eq!(kinds(&mut out_rx), Vec::<String>::new());
2787
2788        // The cursor moves: exactly one pointerMove.
2789        app.world_mut()
2790            .get_mut::<Window>(win)
2791            .unwrap()
2792            .set_physical_cursor_position(Some(bevy::math::DVec2::new(110.0, 100.0)));
2793        app.world_mut()
2794            .get_mut::<RelativeCursorPosition>(node)
2795            .unwrap()
2796            .normalized = Some(Vec2::new(0.05, 0.0));
2797        app.update();
2798        assert_eq!(kinds(&mut out_rx), ["pointerMove"]);
2799
2800        // Release: a pointerUp, no trailing move.
2801        app.world_mut()
2802            .resource_mut::<ButtonInput<MouseButton>>()
2803            .release(MouseButton::Left);
2804        app.update();
2805        assert_eq!(kinds(&mut out_rx), ["pointerUp"]);
2806    }
2807
2808    /// The surface virtual pointer's clicks belong to [`collect_surface_clicks`]
2809    /// alone: [`collect_ui_events`] must skip them (no double-fire), and the
2810    /// surface collector reports exactly one click.
2811    #[test]
2812    fn surface_pointer_clicks_are_not_main_clicks() {
2813        let (mut app, mut out_rx) = click_app();
2814        app.add_systems(Startup, crate::surface::init_surface_pointer);
2815        app.add_systems(Update, (collect_ui_events, collect_surface_clicks));
2816        app.update(); // Run Startup so the pointer resource exists.
2817
2818        let owner = app.world_mut().spawn((RNode(7), Interaction::None)).id();
2819        let surface_id = app.world().resource::<SurfaceVirtualPointer>().id;
2820        app.world_mut().write_message(Pointer::new(
2821            surface_id,
2822            click_location(),
2823            Click {
2824                button: PointerButton::Primary,
2825                hit: bevy::picking::backend::HitData::new(Entity::PLACEHOLDER, 0.0, None, None),
2826                duration: std::time::Duration::ZERO,
2827                count: 1,
2828            },
2829            owner,
2830        ));
2831        app.update();
2832
2833        let events = drain_clicks(&mut out_rx);
2834        assert_eq!(
2835            events.len(),
2836            1,
2837            "exactly one click: surface-collected, not double-fired by collect_ui_events"
2838        );
2839        assert_eq!(events[0].id, 7);
2840        assert_eq!(events[0].button, None, "clicks carry no button");
2841    }
2842
2843    /// A `<text>` root's `transform`/`transition` must update on re-render — not
2844    /// just at mount. Regression: the text-update branch skipped `apply_style`, so
2845    /// a rotating chevron's target never changed and the animation never ran.
2846    #[test]
2847    fn text_update_reapplies_transform_target() {
2848        let mut app = App::new();
2849        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
2850        app.init_asset::<Image>();
2851        app.init_asset::<TextureAtlasLayout>();
2852        app.init_resource::<Fonts>();
2853        app.init_resource::<OpApplyStats>();
2854        app.init_resource::<AtlasLayoutCache>();
2855        // `apply_js_ops` reads the `filter` material assets/cache + white pixel.
2856        app.init_asset::<FilterMaterial>();
2857        app.init_resource::<FilterMaterialCache>();
2858        app.add_systems(Startup, crate::filter::init_filter_assets);
2859
2860        let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
2861        // Keep the outbound receiver alive so the sender stays open.
2862        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
2863        let root = app.world_mut().spawn_empty().id();
2864        app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
2865        app.add_systems(Update, apply_js_ops);
2866
2867        // Mount a `<text>` with rotate 0.
2868        ops_tx
2869            .send(vec![Op::Create {
2870                id: 1,
2871                kind: "text".into(),
2872                props: text_props(0.0),
2873                text: None,
2874            }])
2875            .unwrap();
2876        app.update();
2877        let e = app.world().resource::<JsBridge>().nodes[&1];
2878        assert_eq!(
2879            app.world()
2880                .entity(e)
2881                .get::<TransitionInput>()
2882                .unwrap()
2883                .rotate,
2884            Some(0.0),
2885            "create stamps the initial transform target"
2886        );
2887
2888        // Re-render with rotate π — the transition target must follow.
2889        ops_tx
2890            .send(vec![update_delta(1, text_props(PI), &[], &[])])
2891            .unwrap();
2892        app.update();
2893        assert_eq!(
2894            app.world()
2895                .entity(e)
2896                .get::<TransitionInput>()
2897                .unwrap()
2898                .rotate,
2899            Some(PI),
2900            "a text re-render must refresh the transform target so it animates"
2901        );
2902    }
2903
2904    /// Regression: an inline-text nested `<text>` (a `textSpan` carrying its text
2905    /// on the create op) must keep updating its `TextSpan` on `Op::UpdateText` — it
2906    /// must never gain a stray `Text` component (which renders a duplicate, leaving
2907    /// the old value visible alongside the new one).
2908    #[test]
2909    fn update_text_on_inline_span_keeps_textspan() {
2910        let (mut app, ops_tx, _root) = ordering_app();
2911
2912        ops_tx
2913            .send(vec![
2914                // A `<text>` root with a nested inline `<text>{0}</text>` span.
2915                Op::Create {
2916                    id: 1,
2917                    kind: "text".into(),
2918                    props: Props::default(),
2919                    text: None,
2920                },
2921                Op::Create {
2922                    id: 2,
2923                    kind: "textSpan".into(),
2924                    props: Props::default(),
2925                    text: Some("0".into()),
2926                },
2927                Op::Append {
2928                    parent: 1,
2929                    child: 2,
2930                },
2931            ])
2932            .unwrap();
2933        app.update();
2934
2935        ops_tx
2936            .send(vec![Op::UpdateText {
2937                id: 2,
2938                text: "1".into(),
2939            }])
2940            .unwrap();
2941        app.update();
2942
2943        let span = ent(&app, 2);
2944        assert_eq!(
2945            app.world().entity(span).get::<TextSpan>().map(|s| &*s.0),
2946            Some("1"),
2947            "the span's TextSpan must hold the updated text"
2948        );
2949        assert!(
2950            app.world().entity(span).get::<Text>().is_none(),
2951            "a span must never gain a Text component (that renders a duplicate)"
2952        );
2953    }
2954
2955    // --- ordered insertion (`Op::Insert` honoring `before`) --------------------
2956
2957    /// Build a minimal app with `apply_js_ops` wired up and a spawned UI root, plus
2958    /// the ops sender. Mirrors `text_update_reapplies_transform_target`'s harness.
2959    fn ordering_app() -> (App, crossbeam_channel::Sender<Vec<Op>>, Entity) {
2960        let mut app = App::new();
2961        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
2962        app.init_asset::<Image>();
2963        app.init_asset::<TextureAtlasLayout>();
2964        app.init_resource::<Fonts>();
2965        app.init_resource::<OpApplyStats>();
2966        app.init_resource::<AtlasLayoutCache>();
2967        // `apply_js_ops` reads the `filter` material assets/cache + white pixel.
2968        app.init_asset::<FilterMaterial>();
2969        app.init_resource::<FilterMaterialCache>();
2970        app.add_systems(Startup, crate::filter::init_filter_assets);
2971        let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
2972        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
2973        let root = app.world_mut().spawn_empty().id();
2974        app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
2975        app.add_systems(Update, apply_js_ops);
2976        (app, ops_tx, root)
2977    }
2978
2979    fn create_node(id: NodeId) -> Op {
2980        Op::Create {
2981            id,
2982            kind: "node".into(),
2983            props: Props::default(),
2984            text: None,
2985        }
2986    }
2987
2988    /// The entity a node id resolved to.
2989    fn ent(app: &App, id: NodeId) -> Entity {
2990        app.world().resource::<JsBridge>().nodes[&id]
2991    }
2992
2993    /// The parent's children, in order.
2994    fn children_of(app: &App, parent: Entity) -> Vec<Entity> {
2995        app.world()
2996            .entity(parent)
2997            .get::<Children>()
2998            .map(|c| c.iter().collect())
2999            .unwrap_or_default()
3000    }
3001
3002    /// Append-only construction yields the appended order — and does so within a
3003    /// single batch, where the live `Children` is not yet readable.
3004    #[test]
3005    fn append_builds_child_order() {
3006        let (mut app, tx, _root) = ordering_app();
3007        tx.send(vec![
3008            create_node(1), // parent
3009            create_node(2),
3010            create_node(3),
3011            create_node(4),
3012            Op::Append {
3013                parent: ROOT_ID,
3014                child: 1,
3015            },
3016            Op::Append {
3017                parent: 1,
3018                child: 2,
3019            },
3020            Op::Append {
3021                parent: 1,
3022                child: 3,
3023            },
3024            Op::Append {
3025                parent: 1,
3026                child: 4,
3027            },
3028        ])
3029        .unwrap();
3030        app.update();
3031
3032        let parent = ent(&app, 1);
3033        assert_eq!(
3034            children_of(&app, parent),
3035            vec![ent(&app, 2), ent(&app, 3), ent(&app, 4)],
3036        );
3037    }
3038
3039    /// Moving an existing child with `Insert` reorders it (React emits `insertBefore`
3040    /// with the same id, no preceding remove): `[A,B,C]` + move C before A → `[C,A,B]`.
3041    #[test]
3042    fn insert_reorders_existing_child() {
3043        let (mut app, tx, _root) = ordering_app();
3044        tx.send(vec![
3045            create_node(1),
3046            create_node(2),
3047            create_node(3),
3048            create_node(4),
3049            Op::Append {
3050                parent: ROOT_ID,
3051                child: 1,
3052            },
3053            Op::Append {
3054                parent: 1,
3055                child: 2,
3056            },
3057            Op::Append {
3058                parent: 1,
3059                child: 3,
3060            },
3061            Op::Append {
3062                parent: 1,
3063                child: 4,
3064            },
3065        ])
3066        .unwrap();
3067        app.update();
3068
3069        // Move C (4) before A (2).
3070        tx.send(vec![Op::Insert {
3071            parent: 1,
3072            child: 4,
3073            before: 2,
3074        }])
3075        .unwrap();
3076        app.update();
3077
3078        let parent = ent(&app, 1);
3079        assert_eq!(
3080            children_of(&app, parent),
3081            vec![ent(&app, 4), ent(&app, 2), ent(&app, 3)],
3082            "C should move to the front: [C, A, B]"
3083        );
3084    }
3085
3086    /// Inserting a brand-new child mid-list lands it at `before`'s position:
3087    /// `[A,B,C]` + insert D before B → `[A,D,B,C]`.
3088    #[test]
3089    fn insert_new_child_in_the_middle() {
3090        let (mut app, tx, _root) = ordering_app();
3091        tx.send(vec![
3092            create_node(1),
3093            create_node(2),
3094            create_node(3),
3095            create_node(4),
3096            Op::Append {
3097                parent: ROOT_ID,
3098                child: 1,
3099            },
3100            Op::Append {
3101                parent: 1,
3102                child: 2,
3103            },
3104            Op::Append {
3105                parent: 1,
3106                child: 3,
3107            },
3108            Op::Append {
3109                parent: 1,
3110                child: 4,
3111            },
3112        ])
3113        .unwrap();
3114        app.update();
3115
3116        // New node D (5) inserted before B (3).
3117        tx.send(vec![
3118            create_node(5),
3119            Op::Insert {
3120                parent: 1,
3121                child: 5,
3122                before: 3,
3123            },
3124        ])
3125        .unwrap();
3126        app.update();
3127
3128        let parent = ent(&app, 1);
3129        assert_eq!(
3130            children_of(&app, parent),
3131            vec![ent(&app, 2), ent(&app, 5), ent(&app, 3), ent(&app, 4)],
3132            "D should land before B: [A, D, B, C]"
3133        );
3134    }
3135
3136    /// The regression that motivates the shadow tree: an `Insert` whose `before` was
3137    /// appended earlier in the SAME batch. The live `Children` can't be read mid-batch
3138    /// (deferred commands), so the index must come from the shadow order — `[X, Y]`.
3139    #[test]
3140    fn insert_orders_within_a_single_batch() {
3141        let (mut app, tx, _root) = ordering_app();
3142        tx.send(vec![
3143            create_node(10), // parent
3144            create_node(11), // X
3145            create_node(12), // Y
3146            Op::Append {
3147                parent: ROOT_ID,
3148                child: 10,
3149            },
3150            Op::Append {
3151                parent: 10,
3152                child: 12,
3153            }, // Y appended first
3154            Op::Insert {
3155                parent: 10,
3156                child: 11,
3157                before: 12,
3158            }, // X inserted before Y, same batch
3159        ])
3160        .unwrap();
3161        app.update();
3162
3163        let parent = ent(&app, 10);
3164        assert_eq!(
3165            children_of(&app, parent),
3166            vec![ent(&app, 11), ent(&app, 12)],
3167            "X must precede Y even though Children was unreadable mid-batch"
3168        );
3169    }
3170
3171    /// One batch mixing all three structural ops on the same parent: append a new
3172    /// child, move an existing one, remove another. The end-of-batch rebuild must
3173    /// produce the final order in one `replace_children`, with the removed child's
3174    /// despawn applied first.
3175    #[test]
3176    fn mixed_batch_orders_correctly() {
3177        let (mut app, tx, _root) = ordering_app();
3178        tx.send(vec![
3179            create_node(1),
3180            create_node(2),
3181            create_node(3),
3182            create_node(4),
3183            Op::Append {
3184                parent: ROOT_ID,
3185                child: 1,
3186            },
3187            Op::Append {
3188                parent: 1,
3189                child: 2,
3190            },
3191            Op::Append {
3192                parent: 1,
3193                child: 3,
3194            },
3195            Op::Append {
3196                parent: 1,
3197                child: 4,
3198            },
3199        ])
3200        .unwrap();
3201        app.update();
3202
3203        // [2,3,4] → append 5 → move 4 before 2 → remove 3 ⇒ [4,2,5].
3204        tx.send(vec![
3205            create_node(5),
3206            Op::Append {
3207                parent: 1,
3208                child: 5,
3209            },
3210            Op::Insert {
3211                parent: 1,
3212                child: 4,
3213                before: 2,
3214            },
3215            Op::Remove {
3216                parent: 1,
3217                child: 3,
3218            },
3219        ])
3220        .unwrap();
3221        app.update();
3222
3223        let parent = ent(&app, 1);
3224        assert_eq!(
3225            children_of(&app, parent),
3226            vec![ent(&app, 4), ent(&app, 2), ent(&app, 5)],
3227            "append + move + remove in one batch must land as [4, 2, 5]"
3228        );
3229    }
3230
3231    /// Moving a child to a DIFFERENT parent in one batch: the old `ChildOf` must be
3232    /// dropped eagerly (the rebuild's `replace_children` skips relationship hooks for
3233    /// the entities it adds), or the child would linger in the old parent's
3234    /// `Children`.
3235    #[test]
3236    fn move_between_parents_in_one_batch() {
3237        let (mut app, tx, _root) = ordering_app();
3238        tx.send(vec![
3239            create_node(1), // parent A
3240            create_node(2), // parent B
3241            create_node(3),
3242            create_node(4),
3243            create_node(5),
3244            Op::Append {
3245                parent: ROOT_ID,
3246                child: 1,
3247            },
3248            Op::Append {
3249                parent: ROOT_ID,
3250                child: 2,
3251            },
3252            Op::Append {
3253                parent: 1,
3254                child: 3,
3255            },
3256            Op::Append {
3257                parent: 1,
3258                child: 4,
3259            },
3260            Op::Append {
3261                parent: 2,
3262                child: 5,
3263            },
3264        ])
3265        .unwrap();
3266        app.update();
3267
3268        // Move 3 from A to B (append at B's end).
3269        tx.send(vec![Op::Append {
3270            parent: 2,
3271            child: 3,
3272        }])
3273        .unwrap();
3274        app.update();
3275
3276        let (a, b) = (ent(&app, 1), ent(&app, 2));
3277        assert_eq!(
3278            children_of(&app, a),
3279            vec![ent(&app, 4)],
3280            "the moved child must leave the old parent's Children"
3281        );
3282        assert_eq!(children_of(&app, b), vec![ent(&app, 5), ent(&app, 3)]);
3283        assert_eq!(
3284            app.world()
3285                .entity(ent(&app, 3))
3286                .get::<ChildOf>()
3287                .map(|c| c.parent()),
3288            Some(b),
3289            "the moved child's ChildOf must point at the new parent"
3290        );
3291    }
3292
3293    /// The `AnchorLayer` is a Rust-side child of the root, invisible to the shadow
3294    /// tree — a root rebuild must keep it as the first child instead of stripping
3295    /// its `ChildOf`.
3296    #[test]
3297    fn root_rebuild_preserves_anchor_layer() {
3298        let (mut app, tx, root) = ordering_app();
3299        let layer = app
3300            .world_mut()
3301            .spawn((crate::anchor::AnchorLayer, ChildOf(root)))
3302            .id();
3303
3304        tx.send(vec![
3305            create_node(1),
3306            create_node(2),
3307            Op::Append {
3308                parent: ROOT_ID,
3309                child: 1,
3310            },
3311            Op::Append {
3312                parent: ROOT_ID,
3313                child: 2,
3314            },
3315        ])
3316        .unwrap();
3317        app.update();
3318        assert_eq!(
3319            children_of(&app, root),
3320            vec![layer, ent(&app, 1), ent(&app, 2)]
3321        );
3322
3323        // Reorder the root's reconciler children; the layer must stay first.
3324        tx.send(vec![Op::Insert {
3325            parent: ROOT_ID,
3326            child: 2,
3327            before: 1,
3328        }])
3329        .unwrap();
3330        app.update();
3331        assert_eq!(
3332            children_of(&app, root),
3333            vec![layer, ent(&app, 2), ent(&app, 1)],
3334            "the AnchorLayer must survive root rebuilds as the first child"
3335        );
3336    }
3337
3338    /// The leak regression the demos app exposed: a child created and appended in
3339    /// the SAME batch that removes its (pre-existing) parent. The attach must be
3340    /// queued per op — if it were deferred to the end-of-batch rebuild (which skips
3341    /// removed parents), the recursive despawn couldn't reach the child and it would
3342    /// survive as an orphaned window-UI root.
3343    #[test]
3344    fn same_batch_create_under_removed_parent_despawns() {
3345        let (mut app, tx, _root) = ordering_app();
3346        tx.send(vec![
3347            create_node(1),
3348            Op::Append {
3349                parent: ROOT_ID,
3350                child: 1,
3351            },
3352        ])
3353        .unwrap();
3354        app.update();
3355
3356        // One batch: grow the subtree, then remove its root.
3357        tx.send(vec![
3358            create_node(2),
3359            Op::Append {
3360                parent: 1,
3361                child: 2,
3362            },
3363            Op::Remove {
3364                parent: ROOT_ID,
3365                child: 1,
3366            },
3367        ])
3368        .unwrap();
3369        app.update();
3370
3371        let survivors = app.world_mut().query::<&RNode>().iter(app.world()).count();
3372        assert_eq!(
3373            survivors, 0,
3374            "the same-batch child must be despawned with its removed parent, not \
3375             leaked as an orphaned root"
3376        );
3377    }
3378
3379    /// Remove + reorder on the same parent in one batch: the dirty rebuild runs with
3380    /// a despawned ex-child mid-queue and must not resurrect or panic on it.
3381    #[test]
3382    fn remove_then_reorder_same_parent() {
3383        let (mut app, tx, _root) = ordering_app();
3384        tx.send(vec![
3385            create_node(1),
3386            create_node(2),
3387            create_node(3),
3388            create_node(4),
3389            Op::Append {
3390                parent: ROOT_ID,
3391                child: 1,
3392            },
3393            Op::Append {
3394                parent: 1,
3395                child: 2,
3396            },
3397            Op::Append {
3398                parent: 1,
3399                child: 3,
3400            },
3401            Op::Append {
3402                parent: 1,
3403                child: 4,
3404            },
3405        ])
3406        .unwrap();
3407        app.update();
3408
3409        // [2,3,4] → remove 3, then move 4 before 2 ⇒ [4,2].
3410        tx.send(vec![
3411            Op::Remove {
3412                parent: 1,
3413                child: 3,
3414            },
3415            Op::Insert {
3416                parent: 1,
3417                child: 4,
3418                before: 2,
3419            },
3420        ])
3421        .unwrap();
3422        app.update();
3423
3424        let parent = ent(&app, 1);
3425        assert_eq!(children_of(&app, parent), vec![ent(&app, 4), ent(&app, 2)]);
3426    }
3427
3428    /// A `<portal>` mounts to an `ImageNode` carrying an `RPortal` with its target
3429    /// name; an update rebinds the name.
3430    #[test]
3431    fn portal_mounts_with_target_and_rebinds() {
3432        use crate::portal::RPortal;
3433        use bevy::ui::widget::ImageNode;
3434        let (mut app, tx, _root) = ordering_app();
3435        tx.send(vec![Op::Create {
3436            id: 1,
3437            kind: "portal".into(),
3438            props: serde_json::from_value(serde_json::json!({ "target": "follow" }))
3439                .expect("valid portal props"),
3440            text: None,
3441        }])
3442        .unwrap();
3443        app.update();
3444
3445        let e = ent(&app, 1);
3446        assert_eq!(
3447            app.world().entity(e).get::<RPortal>().map(|p| p.0.clone()),
3448            Some("follow".to_string()),
3449            "a portal carries its target name"
3450        );
3451        assert!(
3452            app.world().entity(e).get::<ImageNode>().is_some(),
3453            "a portal is backed by an ImageNode"
3454        );
3455
3456        tx.send(vec![update_delta(
3457            1,
3458            serde_json::from_value(serde_json::json!({ "target": "minimap" }))
3459                .expect("valid portal props"),
3460            &[],
3461            &[],
3462        )])
3463        .unwrap();
3464        app.update();
3465        assert_eq!(
3466            app.world().entity(e).get::<RPortal>().map(|p| p.0.clone()),
3467            Some("minimap".to_string()),
3468            "an update rebinds the portal's target name"
3469        );
3470    }
3471
3472    /// A `<surface>` mounts carrying its name in an `RSurface`, and stays a detached
3473    /// UI root: appending it under a parent must NOT add it to that parent's Bevy
3474    /// `Children` (it renders to its own offscreen camera instead).
3475    #[test]
3476    fn surface_mounts_detached_with_name() {
3477        use crate::surface::RSurface;
3478        let (mut app, tx, _root) = ordering_app();
3479        tx.send(vec![
3480            create_node(1), // a normal parent under the root
3481            Op::Create {
3482                id: 2,
3483                kind: "surface".into(),
3484                props: serde_json::from_value(serde_json::json!({ "target": "monitor" }))
3485                    .expect("valid surface props"),
3486                text: None,
3487            },
3488            Op::Append {
3489                parent: ROOT_ID,
3490                child: 1,
3491            },
3492            // React appends the surface under node 1; the reconciler must keep it
3493            // detached (no Bevy parent) so it is an independent layout root.
3494            Op::Append {
3495                parent: 1,
3496                child: 2,
3497            },
3498        ])
3499        .unwrap();
3500        app.update();
3501
3502        let surface = ent(&app, 2);
3503        assert_eq!(
3504            app.world()
3505                .entity(surface)
3506                .get::<RSurface>()
3507                .map(|s| s.0.clone()),
3508            Some("monitor".to_string()),
3509            "a surface carries its name in RSurface"
3510        );
3511        assert!(
3512            app.world().entity(surface).get::<ChildOf>().is_none(),
3513            "a surface is a detached root — never parented into the on-screen tree"
3514        );
3515        assert!(
3516            children_of(&app, ent(&app, 1)).is_empty(),
3517            "the surface's React parent has no Bevy children"
3518        );
3519
3520        // An update rebinds the surface name (and never stamps an RPortal).
3521        tx.send(vec![update_delta(
3522            2,
3523            serde_json::from_value(serde_json::json!({ "target": "panel" }))
3524                .expect("valid surface props"),
3525            &[],
3526            &[],
3527        )])
3528        .unwrap();
3529        app.update();
3530        assert_eq!(
3531            app.world()
3532                .entity(surface)
3533                .get::<RSurface>()
3534                .map(|s| s.0.clone()),
3535            Some("panel".to_string()),
3536            "an update rebinds the surface name"
3537        );
3538        assert!(
3539            app.world()
3540                .entity(surface)
3541                .get::<crate::portal::RPortal>()
3542                .is_none(),
3543            "a surface update must not stamp an RPortal (shared `target` field)"
3544        );
3545    }
3546
3547    /// A `<root>` mounts as a detached, screen-space top-level tree: never parented
3548    /// into the Bevy hierarchy, floating just above the window tree (the
3549    /// `globalZIndex` is baked into its style so re-renders re-assert it), ignoring
3550    /// picking itself — while its own children attach to it normally — and it
3551    /// despawns when its React ancestor unmounts (Bevy's recursive despawn can't
3552    /// reach a node with no `ChildOf`).
3553    #[test]
3554    fn root_mounts_detached_screen_space() {
3555        use crate::bridge::RRoot;
3556        let (mut app, tx, _ui_root) = ordering_app();
3557        tx.send(vec![
3558            create_node(1), // a normal parent under the UI root
3559            Op::Create {
3560                id: 2,
3561                kind: "root".into(),
3562                props: Props::default(),
3563                text: None,
3564            },
3565            create_node(3), // panel content inside the <root>
3566            Op::Append {
3567                parent: ROOT_ID,
3568                child: 1,
3569            },
3570            // React appends the <root> under node 1; the reconciler must keep it
3571            // detached so it is an independent screen-space layout root.
3572            Op::Append {
3573                parent: 1,
3574                child: 2,
3575            },
3576            Op::Append {
3577                parent: 2,
3578                child: 3,
3579            },
3580        ])
3581        .unwrap();
3582        app.update();
3583
3584        let root_e = ent(&app, 2);
3585        assert!(
3586            app.world().entity(root_e).get::<RRoot>().is_some(),
3587            "a <root> carries the RRoot marker"
3588        );
3589        assert!(
3590            app.world().entity(root_e).get::<ChildOf>().is_none(),
3591            "a <root> is a detached root — never parented into the on-screen tree"
3592        );
3593        assert!(
3594            children_of(&app, ent(&app, 1)).is_empty(),
3595            "the <root>'s React parent has no Bevy children"
3596        );
3597        assert_eq!(
3598            app.world()
3599                .entity(root_e)
3600                .get::<GlobalZIndex>()
3601                .map(|z| z.0),
3602            Some(1),
3603            "a <root> floats just above the window tree by default"
3604        );
3605        assert_eq!(
3606            app.world().entity(root_e).get::<Pickable>(),
3607            Some(&Pickable::IGNORE),
3608            "the <root> itself must not block or hover picking"
3609        );
3610        assert_eq!(
3611            children_of(&app, root_e),
3612            vec![ent(&app, 3)],
3613            "the <root>'s own children attach to it normally"
3614        );
3615        assert_eq!(
3616            app.world()
3617                .entity(root_e)
3618                .get::<Node>()
3619                .map(|n| n.flex_direction),
3620            Some(FlexDirection::Column),
3621            "a <root> defaults to a column, like the main UI root (not Bevy's row)"
3622        );
3623
3624        // A style-only re-render must keep the baked default z-index.
3625        tx.send(vec![update_delta(
3626            2,
3627            serde_json::from_value(serde_json::json!({ "style": { "padding": 4 } }))
3628                .expect("valid root props"),
3629            &[],
3630            &[],
3631        )])
3632        .unwrap();
3633        app.update();
3634        assert_eq!(
3635            app.world()
3636                .entity(root_e)
3637                .get::<GlobalZIndex>()
3638                .map(|z| z.0),
3639            Some(1),
3640            "a re-render must re-assert the baked globalZIndex, not strip it"
3641        );
3642
3643        // Removing the React ancestor must despawn the detached <root> (and its
3644        // subtree) even though no ChildOf links them.
3645        tx.send(vec![Op::Remove {
3646            parent: ROOT_ID,
3647            child: 1,
3648        }])
3649        .unwrap();
3650        app.update();
3651        assert!(
3652            !app.world().entities().contains(root_e),
3653            "removing a React ancestor must despawn the detached <root>"
3654        );
3655        let bridge = app.world().resource::<JsBridge>();
3656        assert!(
3657            bridge.roots.is_empty() && !bridge.nodes.contains_key(&2),
3658            "the <root>'s bookkeeping must be pruned on removal"
3659        );
3660    }
3661
3662    /// `Op::Reset` must despawn detached `<root>`s: they aren't children of the UI
3663    /// root, so the root-children despawn misses them; a cold reload would otherwise
3664    /// leave the stale overlay on screen.
3665    #[test]
3666    fn reset_despawns_detached_roots() {
3667        let (mut app, tx, _ui_root) = ordering_app();
3668        tx.send(vec![
3669            Op::Create {
3670                id: 1,
3671                kind: "root".into(),
3672                props: Props::default(),
3673                text: None,
3674            },
3675            Op::Append {
3676                parent: ROOT_ID,
3677                child: 1,
3678            },
3679        ])
3680        .unwrap();
3681        app.update();
3682        let root_e = ent(&app, 1);
3683
3684        tx.send(vec![Op::Reset]).unwrap();
3685        app.update();
3686        assert!(
3687            !app.world().entities().contains(root_e),
3688            "Op::Reset must despawn detached <root>s"
3689        );
3690        assert!(
3691            app.world().resource::<JsBridge>().roots.is_empty(),
3692            "Op::Reset must clear the roots set"
3693        );
3694    }
3695
3696    /// `Op::Reset` must keep the persistent anchor layer alive (it is spawned once at
3697    /// startup) while still clearing the reconciler overlays reparented under it.
3698    #[test]
3699    fn reset_preserves_anchor_layer_but_clears_its_overlays() {
3700        use crate::anchor::AnchorLayer;
3701        let (mut app, tx, root) = ordering_app();
3702
3703        // The anchor layer is a child of the root; an overlay (a reconciler node) has
3704        // been reparented under it, exactly as `position_anchored_nodes` would do.
3705        let layer = app.world_mut().spawn((AnchorLayer, ChildOf(root))).id();
3706        let overlay = app.world_mut().spawn((RNode(99), ChildOf(layer))).id();
3707
3708        tx.send(vec![Op::Reset]).unwrap();
3709        app.update();
3710
3711        assert!(
3712            app.world().entities().contains(layer),
3713            "Op::Reset must preserve the persistent anchor layer"
3714        );
3715        assert!(
3716            !app.world().entities().contains(overlay),
3717            "Op::Reset must despawn overlays reparented under the anchor layer"
3718        );
3719    }
3720
3721    /// `Op::Reset` must despawn detached `<surface>` roots. They aren't children of the
3722    /// UI root (a surface renders to its own offscreen camera), so the root-children
3723    /// despawn misses them; a cold reload would otherwise leak a stale surface subtree
3724    /// that keeps rendering into the texture.
3725    #[test]
3726    fn reset_despawns_detached_surfaces() {
3727        let (mut app, tx, _root) = ordering_app();
3728
3729        // Mount a `<surface>` under the root (it stays a detached root in Bevy).
3730        tx.send(vec![
3731            Op::Create {
3732                id: 1,
3733                kind: "surface".into(),
3734                props: serde_json::from_value(serde_json::json!({ "target": "monitor" }))
3735                    .expect("valid surface props"),
3736                text: None,
3737            },
3738            Op::Append {
3739                parent: ROOT_ID,
3740                child: 1,
3741            },
3742        ])
3743        .unwrap();
3744        app.update();
3745        let surface = ent(&app, 1);
3746        assert!(app.world().entities().contains(surface));
3747
3748        tx.send(vec![Op::Reset]).unwrap();
3749        app.update();
3750
3751        assert!(
3752            !app.world().entities().contains(surface),
3753            "Op::Reset must despawn the detached surface root"
3754        );
3755        assert!(
3756            app.world().resource::<JsBridge>().surfaces.is_empty(),
3757            "Op::Reset must clear surface bookkeeping"
3758        );
3759    }
3760
3761    /// Removing an ancestor whose subtree *contains* a detached `<surface>` must despawn
3762    /// the surface too. React emits `Remove` only for the subtree's top node, and the
3763    /// surface is a detached root (no `ChildOf`), so neither React's op nor Bevy's
3764    /// recursive despawn of the ancestor reaches it — `apply_js_ops` must find it via the
3765    /// tracked React parentage. Regression: navigating away from the Home demo left its
3766    /// `<surface name="monitor">` rendering into the shared monitor texture under the
3767    /// `<surface>` demo. This reproduces the exact op stream React emits (verified: only
3768    /// the wrapper gets a `Remove`, never the nested surface).
3769    #[test]
3770    fn remove_ancestor_despawns_nested_surface() {
3771        let (mut app, tx, _root) = ordering_app();
3772        // Mirror Home's shape: a wrapper `<node>` under the root, a `<surface>` nested
3773        // inside it, and a normal node rendered inside the surface.
3774        tx.send(vec![
3775            create_node(1), // wrapper (Home's container)
3776            Op::Create {
3777                id: 2,
3778                kind: "surface".into(),
3779                props: serde_json::from_value(serde_json::json!({ "target": "monitor" }))
3780                    .expect("valid surface props"),
3781                text: None,
3782            },
3783            create_node(3), // content rendered inside the surface
3784            Op::Append {
3785                parent: ROOT_ID,
3786                child: 1,
3787            },
3788            Op::Append {
3789                parent: 1,
3790                child: 2,
3791            }, // surface nested under the wrapper
3792            Op::Append {
3793                parent: 2,
3794                child: 3,
3795            }, // content inside the surface
3796        ])
3797        .unwrap();
3798        app.update();
3799        let wrapper = ent(&app, 1);
3800        let surface = ent(&app, 2);
3801        let inner = ent(&app, 3);
3802        assert!(app.world().entities().contains(surface));
3803
3804        // React unmounts the wrapper: a single `Remove` for the top node only.
3805        tx.send(vec![Op::Remove {
3806            parent: ROOT_ID,
3807            child: 1,
3808        }])
3809        .unwrap();
3810        app.update();
3811
3812        assert!(
3813            !app.world().entities().contains(wrapper),
3814            "the removed wrapper is despawned"
3815        );
3816        assert!(
3817            !app.world().entities().contains(surface),
3818            "the detached <surface> nested under the removed wrapper must be despawned"
3819        );
3820        assert!(
3821            !app.world().entities().contains(inner),
3822            "the surface's own subtree is despawned with it"
3823        );
3824        let bridge = app.world().resource::<JsBridge>();
3825        assert!(bridge.surfaces.is_empty(), "surface bookkeeping is cleared");
3826        assert!(
3827            !bridge.nodes.contains_key(&2),
3828            "the surface node id is forgotten"
3829        );
3830        assert!(
3831            bridge.child_surfaces.is_empty() && bridge.surface_parent.is_empty(),
3832            "surface parentage maps are cleared"
3833        );
3834    }
3835
3836    /// Removing a subtree must forget its *descendants'* per-node bookkeeping, not just
3837    /// the removed root's. React emits `Remove` only for the top node, and Bevy despawns
3838    /// the whole subtree recursively — so the bridge's `NodeId`-keyed side-tables would
3839    /// otherwise keep stale entries for every descendant until the next `Op::Reset`.
3840    #[test]
3841    fn remove_subtree_forgets_descendant_node_data() {
3842        let (mut app, tx, _root) = ordering_app();
3843        // A plain nested subtree wrapper(1) → mid(2) → leaf(3); `leaf` is an
3844        // `editableText` so a set-typed side-table (`editable_inputs`) is exercised too.
3845        tx.send(vec![
3846            create_node(1),
3847            create_node(2),
3848            Op::Create {
3849                id: 3,
3850                kind: "editableText".into(),
3851                props: Props::default(),
3852                text: None,
3853            },
3854            Op::Append {
3855                parent: ROOT_ID,
3856                child: 1,
3857            },
3858            Op::Append {
3859                parent: 1,
3860                child: 2,
3861            },
3862            Op::Append {
3863                parent: 2,
3864                child: 3,
3865            },
3866        ])
3867        .unwrap();
3868        app.update();
3869        let mid = ent(&app, 2);
3870        let leaf = ent(&app, 3);
3871        assert!(
3872            app.world()
3873                .resource::<JsBridge>()
3874                .editable_inputs
3875                .contains(&3),
3876            "the editableText descendant is tracked before removal"
3877        );
3878
3879        // React unmounts the wrapper: a single `Remove` for the top node only.
3880        tx.send(vec![Op::Remove {
3881            parent: ROOT_ID,
3882            child: 1,
3883        }])
3884        .unwrap();
3885        app.update();
3886
3887        assert!(
3888            !app.world().entities().contains(mid),
3889            "the descendant mid node is despawned with the subtree"
3890        );
3891        assert!(
3892            !app.world().entities().contains(leaf),
3893            "the descendant leaf node is despawned with the subtree"
3894        );
3895        let bridge = app.world().resource::<JsBridge>();
3896        assert!(
3897            !bridge.nodes.contains_key(&1),
3898            "the removed root is forgotten"
3899        );
3900        assert!(
3901            !bridge.nodes.contains_key(&2),
3902            "the descendant mid node id is forgotten (no stale entity handle)"
3903        );
3904        assert!(
3905            !bridge.nodes.contains_key(&3),
3906            "the descendant leaf node id is forgotten (no stale entity handle)"
3907        );
3908        assert!(
3909            !bridge.editable_inputs.contains(&3),
3910            "the descendant editableText is dropped from the editable_inputs set"
3911        );
3912    }
3913
3914    /// A node created with a controlled `scrollTop` gets that `ScrollPosition`; an
3915    /// `onScroll` node gets a `ScrollListener` and is seeded in the dedup map (at
3916    /// `ZERO` when uncontrolled) so its mount-frame change doesn't echo back.
3917    #[test]
3918    fn controlled_scroll_create_sets_position_and_listener() {
3919        let (mut app, ops_tx) = op_app();
3920        ops_tx
3921            .send(vec![
3922                // controlled offset + an onScroll handler.
3923                Op::Create {
3924                    id: 1,
3925                    kind: "node".into(),
3926                    props: serde_json::from_value(serde_json::json!({
3927                        "scrollTop": 50.0, "onScroll": true,
3928                        "style": { "overflowY": "scroll" }
3929                    }))
3930                    .unwrap(),
3931                    text: None,
3932                },
3933                // listener only (read-only scroll): seeded at ZERO.
3934                Op::Create {
3935                    id: 2,
3936                    kind: "node".into(),
3937                    props: serde_json::from_value(serde_json::json!({ "onScroll": true })).unwrap(),
3938                    text: None,
3939                },
3940                // controlled only, no handler → no marker.
3941                Op::Create {
3942                    id: 3,
3943                    kind: "node".into(),
3944                    props: serde_json::from_value(serde_json::json!({ "scrollTop": 30.0 }))
3945                        .unwrap(),
3946                    text: None,
3947                },
3948            ])
3949            .unwrap();
3950        app.update();
3951
3952        let nodes = app.world().resource::<JsBridge>().nodes.clone();
3953        let (e1, e2, e3) = (nodes[&1], nodes[&2], nodes[&3]);
3954
3955        assert_eq!(
3956            app.world().entity(e1).get::<ScrollPosition>().unwrap().0,
3957            Vec2::new(0.0, 50.0)
3958        );
3959        assert!(app.world().entity(e1).get::<ScrollListener>().is_some());
3960        assert!(app.world().entity(e2).get::<ScrollListener>().is_some());
3961        assert!(
3962            app.world().entity(e3).get::<ScrollListener>().is_none(),
3963            "a controlled node with no onScroll must not be marked"
3964        );
3965
3966        let bridge = app.world().resource::<JsBridge>();
3967        assert_eq!(bridge.scroll_positions.get(&1), Some(&Vec2::new(0.0, 50.0)));
3968        assert_eq!(bridge.scroll_positions.get(&2), Some(&Vec2::ZERO));
3969        assert_eq!(bridge.scroll_positions.get(&3), Some(&Vec2::new(0.0, 30.0)));
3970    }
3971
3972    /// A controlled `scrollTop` past the scrollable range clamps the written
3973    /// `ScrollPosition` to the max, while recording the *requested* value so the
3974    /// read-back can correct React's controlled state down to the real max.
3975    #[test]
3976    fn controlled_scroll_update_clamps_to_range() {
3977        let (mut app, ops_tx) = op_app();
3978        ops_tx
3979            .send(vec![Op::Create {
3980                id: 1,
3981                kind: "node".into(),
3982                props: serde_json::from_value(serde_json::json!({
3983                    "onScroll": true, "style": { "overflowY": "scroll" }
3984                }))
3985                .unwrap(),
3986                text: None,
3987            }])
3988            .unwrap();
3989        app.update();
3990
3991        let e1 = app.world().resource::<JsBridge>().nodes[&1];
3992        // A laid-out size with real range: content 300, view 100 → max scroll 200.
3993        app.world_mut().entity_mut(e1).insert(ComputedNode {
3994            size: Vec2::new(200.0, 100.0),
3995            content_size: Vec2::new(200.0, 300.0),
3996            inverse_scale_factor: 1.0,
3997            ..default()
3998        });
3999
4000        ops_tx
4001            .send(vec![update_delta(
4002                1,
4003                serde_json::from_value(serde_json::json!({
4004                    "onScroll": true, "scrollTop": 10000.0,
4005                    "style": { "overflowY": "scroll" }
4006                }))
4007                .unwrap(),
4008                &[],
4009                &[],
4010            )])
4011            .unwrap();
4012        app.update();
4013
4014        assert_eq!(
4015            app.world().entity(e1).get::<ScrollPosition>().unwrap().0,
4016            Vec2::new(0.0, 200.0),
4017            "the written offset is clamped to the scrollable range"
4018        );
4019        assert_eq!(
4020            app.world().resource::<JsBridge>().scroll_positions.get(&1),
4021            Some(&Vec2::new(0.0, 10000.0)),
4022            "the requested (pre-clamp) value is recorded so the read-back can correct React"
4023        );
4024    }
4025
4026    /// [`collect_scroll_events`] reports a `"scroll"` for a `ScrollListener` node
4027    /// whose offset diverges from the recorded one, ignores non-listener nodes, and
4028    /// records the emitted value.
4029    #[test]
4030    fn collect_scroll_events_emits_for_listener_only() {
4031        use bevy::ecs::system::RunSystemOnce;
4032
4033        let mut world = World::new();
4034        let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
4035        let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
4036        let root = world.spawn_empty().id();
4037        world.insert_resource(JsBridge::new(ops_rx, out_tx, root));
4038
4039        world.spawn((
4040            ScrollPosition(Vec2::new(0.0, 50.0)),
4041            RNode(1),
4042            ScrollListener,
4043        ));
4044        // No marker → must be ignored even though its ScrollPosition is "changed".
4045        world.spawn((ScrollPosition(Vec2::new(0.0, 70.0)), RNode(2)));
4046
4047        world.run_system_once(collect_scroll_events).unwrap();
4048
4049        match out_rx.try_recv().expect("a scroll event for the listener") {
4050            Outbound::UiEvent { event } => {
4051                assert_eq!(event.id, 1);
4052                assert_eq!(event.kind, "scroll");
4053                assert_eq!(event.scroll_top, Some(50.0));
4054                assert_eq!(event.scroll_left, Some(0.0));
4055            }
4056            other => panic!("expected a UiEvent, got {other:?}"),
4057        }
4058        assert!(
4059            out_rx.try_recv().is_err(),
4060            "the non-listener node must not emit"
4061        );
4062        assert_eq!(
4063            world.resource::<JsBridge>().scroll_positions.get(&1),
4064            Some(&Vec2::new(0.0, 50.0))
4065        );
4066    }
4067
4068    /// A `ScrollPosition` equal to the recorded value (a controlled write-back, or
4069    /// an unchanged offset) is NOT echoed — this is what breaks the controlled
4070    /// component's feedback loop.
4071    #[test]
4072    fn collect_scroll_events_dedups_controlled_writeback() {
4073        use bevy::ecs::system::RunSystemOnce;
4074
4075        let mut world = World::new();
4076        let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
4077        let (_ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
4078        let root = world.spawn_empty().id();
4079        world.insert_resource(JsBridge::new(ops_rx, out_tx, root));
4080
4081        // The controlled write already recorded this exact offset.
4082        world
4083            .resource_mut::<JsBridge>()
4084            .scroll_positions
4085            .insert(1, Vec2::new(0.0, 50.0));
4086        world.spawn((
4087            ScrollPosition(Vec2::new(0.0, 50.0)),
4088            RNode(1),
4089            ScrollListener,
4090        ));
4091
4092        world.run_system_once(collect_scroll_events).unwrap();
4093
4094        assert!(
4095            out_rx.try_recv().is_err(),
4096            "a write-back equal to the recorded value must not echo back to React"
4097        );
4098    }
4099
4100    /// With a `transition: { scroll }`, a controlled `scrollTop` change sets the eased
4101    /// `ScrollTransitionState` target instead of snapping `ScrollPosition` — the drive
4102    /// system (not exercised here) moves the offset toward it.
4103    #[test]
4104    fn controlled_scroll_with_transition_sets_target_not_position() {
4105        let (mut app, ops_tx) = op_app();
4106        let style = serde_json::json!({
4107            "overflowY": "scroll", "transition": { "scroll": { "duration": 300 } }
4108        });
4109        ops_tx
4110            .send(vec![Op::Create {
4111                id: 1,
4112                kind: "node".into(),
4113                props: serde_json::from_value(serde_json::json!({ "style": style })).unwrap(),
4114                text: None,
4115            }])
4116            .unwrap();
4117        app.update();
4118
4119        let e1 = app.world().resource::<JsBridge>().nodes[&1];
4120        // A real scroll range so the target isn't clamped away (content 300, view 100).
4121        app.world_mut().entity_mut(e1).insert(ComputedNode {
4122            size: Vec2::new(200.0, 100.0),
4123            content_size: Vec2::new(200.0, 300.0),
4124            inverse_scale_factor: 1.0,
4125            ..default()
4126        });
4127
4128        ops_tx
4129            .send(vec![update_delta(
4130                1,
4131                serde_json::from_value(serde_json::json!({ "scrollTop": 80.0, "style": style }))
4132                    .unwrap(),
4133                &[],
4134                &[],
4135            )])
4136            .unwrap();
4137        app.update();
4138
4139        assert_eq!(
4140            app.world().entity(e1).get::<ScrollPosition>().unwrap().0,
4141            Vec2::ZERO,
4142            "a controlled change with a scroll transition must not snap the offset"
4143        );
4144        assert_eq!(
4145            app.world()
4146                .entity(e1)
4147                .get::<ScrollTransitionState>()
4148                .unwrap()
4149                .target,
4150            Vec2::new(0.0, 80.0),
4151            "it sets the eased target instead"
4152        );
4153    }
4154    /// A delta update touching only `width` must leave every other derived
4155    /// component untouched — not merely re-inserted-equal, but with its change
4156    /// tick intact (re-insertion would re-extract paint and re-run the
4157    /// interaction restyle via `Changed<StyleVariants>`).
4158    #[test]
4159    fn delta_update_skips_untouched_groups() {
4160        let (mut app, ops_tx) = op_app();
4161        ops_tx
4162            .send(vec![Op::Create {
4163                id: 1,
4164                kind: "node".into(),
4165                props: serde_json::from_value(serde_json::json!({
4166                    "style": {
4167                        "backgroundColor": "red",
4168                        "width": 10,
4169                        "outline": { "color": "white" },
4170                    },
4171                    "hoverStyle": { "backgroundColor": "blue" },
4172                    "onClick": true,
4173                }))
4174                .unwrap(),
4175                text: None,
4176            }])
4177            .unwrap();
4178        app.update();
4179
4180        let e = app.world().resource::<JsBridge>().nodes[&1];
4181        let paint_ticks = |app: &App| {
4182            let entity = app.world().entity(e);
4183            (
4184                entity
4185                    .get_change_ticks::<BackgroundColor>()
4186                    .unwrap()
4187                    .changed,
4188                entity.get_change_ticks::<Outline>().unwrap().changed,
4189            )
4190        };
4191        let variants_tick = |app: &App| {
4192            app.world()
4193                .entity(e)
4194                .get_change_ticks::<StyleVariants>()
4195                .unwrap()
4196                .changed
4197        };
4198        let ticks_before = paint_ticks(&app);
4199
4200        ops_tx
4201            .send(vec![update_delta(
4202                1,
4203                serde_json::from_value(serde_json::json!({ "style": { "width": 100 } })).unwrap(),
4204                &[],
4205                &[],
4206            )])
4207            .unwrap();
4208        app.update();
4209
4210        {
4211            let entity = app.world().entity(e);
4212            assert_eq!(
4213                entity.get::<Node>().unwrap().width,
4214                Val::Px(100.0),
4215                "the delta's own field must apply"
4216            );
4217            assert_eq!(
4218                entity.get::<BackgroundColor>().unwrap().0,
4219                crate::ui_map::parse_color("red"),
4220                "untouched background survives a width-only delta"
4221            );
4222            assert!(
4223                entity.get::<StyleVariants>().is_some(),
4224                "variants survive (base mirrors the style, so it was rebuilt)"
4225            );
4226            assert!(
4227                entity.get::<Interaction>().is_some(),
4228                "the onClick Interaction survives"
4229            );
4230        }
4231        assert_eq!(
4232            ticks_before,
4233            paint_ticks(&app),
4234            "untouched paint groups must not even be marked changed"
4235        );
4236
4237        // A non-style delta (a handler toggle) must not touch `StyleVariants`
4238        // at all — re-inserting it would trigger a full interaction restyle
4239        // via `Changed<StyleVariants>` on every unrelated update.
4240        let tick_before = variants_tick(&app);
4241        ops_tx
4242            .send(vec![update_delta(
4243                1,
4244                serde_json::from_value(serde_json::json!({ "onPointerDown": true })).unwrap(),
4245                &[],
4246                &[],
4247            )])
4248            .unwrap();
4249        app.update();
4250        assert_eq!(
4251            tick_before,
4252            variants_tick(&app),
4253            "a handler-only delta must not re-insert StyleVariants"
4254        );
4255    }
4256
4257    /// `styleUnset` removes exactly the named field's component; the rest of
4258    /// the merged style (and unrelated props) stay.
4259    #[test]
4260    fn delta_style_unset_removes_component() {
4261        let (mut app, ops_tx) = op_app();
4262        ops_tx
4263            .send(vec![Op::Create {
4264                id: 1,
4265                kind: "node".into(),
4266                props: serde_json::from_value(serde_json::json!({
4267                    "style": { "backgroundColor": "red", "width": 10 },
4268                }))
4269                .unwrap(),
4270                text: None,
4271            }])
4272            .unwrap();
4273        app.update();
4274        let e = app.world().resource::<JsBridge>().nodes[&1];
4275        assert!(app.world().entity(e).get::<BackgroundColor>().is_some());
4276
4277        ops_tx
4278            .send(vec![update_delta(
4279                1,
4280                Props::default(),
4281                &[],
4282                &["backgroundColor"],
4283            )])
4284            .unwrap();
4285        app.update();
4286
4287        let entity = app.world().entity(e);
4288        assert!(
4289            entity.get::<BackgroundColor>().is_none(),
4290            "an unset style field removes its component"
4291        );
4292        assert_eq!(
4293            entity.get::<Node>().unwrap().width,
4294            Val::Px(10.0),
4295            "the retained width survives the unset"
4296        );
4297    }
4298
4299    /// Explicit unsets are the delta's "reset" mechanism: `styleUnset` drops
4300    /// the style field's component, `unset` drops a whole prop (here the last
4301    /// variant style, which must remove `StyleVariants` from the entity).
4302    #[test]
4303    fn delta_unsets_reset_absent_fields() {
4304        let (mut app, ops_tx) = op_app();
4305        ops_tx
4306            .send(vec![Op::Create {
4307                id: 1,
4308                kind: "node".into(),
4309                props: serde_json::from_value(serde_json::json!({
4310                    "style": { "backgroundColor": "red" },
4311                    "hoverStyle": { "backgroundColor": "blue" },
4312                }))
4313                .unwrap(),
4314                text: None,
4315            }])
4316            .unwrap();
4317        app.update();
4318        let e = app.world().resource::<JsBridge>().nodes[&1];
4319        assert!(app.world().entity(e).get::<StyleVariants>().is_some());
4320
4321        ops_tx
4322            .send(vec![update_delta(
4323                1,
4324                serde_json::from_value(serde_json::json!({ "style": { "width": 5 } })).unwrap(),
4325                &["hoverStyle"],
4326                &["backgroundColor"],
4327            )])
4328            .unwrap();
4329        app.update();
4330
4331        let entity = app.world().entity(e);
4332        assert!(
4333            entity.get::<BackgroundColor>().is_none(),
4334            "styleUnset resets the background"
4335        );
4336        assert!(
4337            entity.get::<StyleVariants>().is_none(),
4338            "unsetting the last variant style removes StyleVariants"
4339        );
4340        assert_eq!(
4341            entity.get::<Node>().unwrap().width,
4342            Val::Px(5.0),
4343            "the delta's own field still applies"
4344        );
4345    }
4346
4347    /// An unrelated delta on a controlled-scroll node must not touch the
4348    /// scroll offset (event-like props are never replayed from the cache).
4349    #[test]
4350    fn delta_update_does_not_replay_controlled_scroll() {
4351        let (mut app, ops_tx) = op_app();
4352        ops_tx
4353            .send(vec![Op::Create {
4354                id: 1,
4355                kind: "node".into(),
4356                props: serde_json::from_value(serde_json::json!({
4357                    "scrollTop": 40.0,
4358                    "style": { "overflowY": "scroll" },
4359                }))
4360                .unwrap(),
4361                text: None,
4362            }])
4363            .unwrap();
4364        app.update();
4365        let e = app.world().resource::<JsBridge>().nodes[&1];
4366        // Simulate the user scrolling away from the controlled value.
4367        app.world_mut()
4368            .entity_mut(e)
4369            .get_mut::<ScrollPosition>()
4370            .unwrap()
4371            .0 = Vec2::new(0.0, 7.0);
4372
4373        ops_tx
4374            .send(vec![update_delta(
4375                1,
4376                serde_json::from_value(serde_json::json!({ "style": { "width": 50 } })).unwrap(),
4377                &[],
4378                &[],
4379            )])
4380            .unwrap();
4381        app.update();
4382
4383        assert_eq!(
4384            app.world().entity(e).get::<ScrollPosition>().unwrap().0,
4385            Vec2::new(0.0, 7.0),
4386            "a width-only delta must not re-push the cached scrollTop"
4387        );
4388    }
4389
4390    /// On a `<text>` with inheriting bare-string spans, a transform-only delta
4391    /// must skip the O(children) span re-propagation (their tick stays), while
4392    /// a `color` delta re-propagates.
4393    #[test]
4394    fn text_delta_gates_span_repropagation() {
4395        let (mut app, ops_tx) = op_app();
4396        ops_tx
4397            .send(vec![
4398                Op::Create {
4399                    id: 1,
4400                    kind: "text".into(),
4401                    props: serde_json::from_value(serde_json::json!({
4402                        "style": { "color": "red" },
4403                    }))
4404                    .unwrap(),
4405                    text: None,
4406                },
4407                Op::CreateTextSpan {
4408                    id: 2,
4409                    text: "run".into(),
4410                },
4411                Op::Append {
4412                    parent: 1,
4413                    child: 2,
4414                },
4415            ])
4416            .unwrap();
4417        app.update();
4418        let bridge = app.world().resource::<JsBridge>();
4419        let (root, span) = (bridge.nodes[&1], bridge.nodes[&2]);
4420        let span_tick = app
4421            .world()
4422            .entity(span)
4423            .get_change_ticks::<TextColor>()
4424            .unwrap()
4425            .changed;
4426
4427        // Transform-only delta: no text-style group dirty → span untouched.
4428        ops_tx
4429            .send(vec![update_delta(
4430                1,
4431                serde_json::from_value(
4432                    serde_json::json!({ "style": { "transform": { "scale": 2.0 } } }),
4433                )
4434                .unwrap(),
4435                &[],
4436                &[],
4437            )])
4438            .unwrap();
4439        app.update();
4440        assert_eq!(
4441            app.world()
4442                .entity(span)
4443                .get_change_ticks::<TextColor>()
4444                .unwrap()
4445                .changed,
4446            span_tick,
4447            "a transform-only text delta must not re-propagate to spans"
4448        );
4449
4450        // Color delta: text group dirty → span restyled.
4451        ops_tx
4452            .send(vec![update_delta(
4453                1,
4454                serde_json::from_value(serde_json::json!({ "style": { "color": "blue" } }))
4455                    .unwrap(),
4456                &[],
4457                &[],
4458            )])
4459            .unwrap();
4460        app.update();
4461        let world = app.world();
4462        assert_eq!(
4463            world.entity(span).get::<TextColor>().unwrap().0,
4464            crate::ui_map::parse_color("blue"),
4465            "a color delta re-propagates to inheriting spans"
4466        );
4467        assert_eq!(
4468            world.entity(root).get::<TextColor>().unwrap().0,
4469            crate::ui_map::parse_color("blue")
4470        );
4471    }
4472
4473    /// A handler toggled off via `unset` clears its marker; the merged (not
4474    /// delta-only) props drive the rebuild, so the other handler survives.
4475    #[test]
4476    fn delta_toggles_pointer_handlers() {
4477        let (mut app, ops_tx) = op_app();
4478        ops_tx
4479            .send(vec![Op::Create {
4480                id: 1,
4481                kind: "node".into(),
4482                props: serde_json::from_value(
4483                    serde_json::json!({ "onPointerDown": true, "onPointerUp": true }),
4484                )
4485                .unwrap(),
4486                text: None,
4487            }])
4488            .unwrap();
4489        app.update();
4490        let e = app.world().resource::<JsBridge>().nodes[&1];
4491
4492        // Unset one of the two: the marker must keep the other (merged props).
4493        ops_tx
4494            .send(vec![update_delta(
4495                1,
4496                Props::default(),
4497                &["onPointerUp"],
4498                &[],
4499            )])
4500            .unwrap();
4501        app.update();
4502        let handlers = app
4503            .world()
4504            .entity(e)
4505            .get::<PointerHandlers>()
4506            .expect("one handler remains");
4507        assert!(handlers.down && !handlers.up);
4508
4509        ops_tx
4510            .send(vec![update_delta(
4511                1,
4512                Props::default(),
4513                &["onPointerDown"],
4514                &[],
4515            )])
4516            .unwrap();
4517        app.update();
4518        assert!(
4519            app.world().entity(e).get::<PointerHandlers>().is_none(),
4520            "unsetting the last handler clears the marker"
4521        );
4522    }
4523}