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