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