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