Skip to main content

bevy_react/
plugin.rs

1//! The public Bevy plugin: wires the JS thread, channels, UI root, and hot
2//! reload into a consumer's `App`.
3
4use std::path::PathBuf;
5
6use crate::animations::{
7    AnimationCommand, AnimationSet, AnimationSettled, ReactUiAnimationsPlugin,
8};
9use bevy::asset::embedded_asset;
10use bevy::prelude::*;
11use bevy::window::CustomCursorImage;
12
13use crate::filter::{FilterMaterial, FilterMaterialCache, init_filter_assets};
14
15use crate::bridge::{JsBridge, OpReceiver, OutboundResource, OutboundSender};
16use crate::event::ReactEventRegistry;
17use crate::host::{self, HostConfig, HostSenders};
18use crate::message::{ReactAppExt, ReactMessage, ReactRegistry};
19use crate::protocol::{Op, Outbound};
20use crate::reconcile::{
21    OpApplyStats, apply_interaction_styles, apply_js_ops, apply_pending_selections,
22    apply_surface_interaction_styles, collect_canvas_resize_events, collect_hover_events,
23    collect_pointer_events, collect_scroll_events, collect_surface_clicks,
24    collect_surface_hover_events, collect_surface_pointer_events, collect_ui_events,
25    on_focus_gained, on_focus_lost, on_text_edit_change, sync_editable_a11y,
26};
27use crate::request::{RawRequest, ReactRequestRegistry, RequestReceiver, dispatch_react_requests};
28
29/// Whether the React UI currently owns the mouse pointer. Refreshed every frame
30/// in [`PointerCaptureSet`]; world-input systems (a 3D camera controller, picking,
31/// …) should consult it and ignore the mouse when it reports captured, so a UI
32/// drag or click doesn't also drive the scene.
33///
34/// Order such a system after the set to read the current frame's state:
35/// ```no_run
36/// # use bevy::prelude::*;
37/// # use bevy_react::PointerCaptureSet;
38/// # fn orbit_camera() {}
39/// # let mut app = App::new();
40/// app.add_systems(Update, orbit_camera.after(PointerCaptureSet));
41/// ```
42#[derive(Resource, Default, Debug, Clone, Copy)]
43pub struct PointerCapture {
44    /// A bevy-react element is being dragged (an `onPointer*` press is in
45    /// progress). Stays true for the whole gesture — even after the cursor leaves
46    /// the element's bounds — until the button is released.
47    pub dragging: bool,
48    /// The pointer is over an interactive UI element (its `Interaction` is
49    /// `Hovered` or `Pressed`).
50    pub over_ui: bool,
51}
52
53impl PointerCapture {
54    /// The UI owns the current pointer input; world systems should ignore the
55    /// mouse. True while dragging a UI element or while over interactive UI.
56    pub fn is_captured(&self) -> bool {
57        self.dragging || self.over_ui
58    }
59}
60
61/// System set in which [`PointerCapture`] is refreshed each frame. Order your
62/// world-input systems `.after(PointerCaptureSet)` so they see this frame's state.
63#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub struct PointerCaptureSet;
65
66/// Adds a React-driven `bevy_ui` layer to a Bevy `App`.
67///
68/// Point it at a built JS bundle (see the `bevy-react` npm package). The plugin
69/// spawns the dedicated JS thread, applies the reconciler's ops to the ECS,
70/// reports interactions back to React, and — unless disabled — hot reloads the
71/// app when the bundle changes on disk. In dev builds it also enables the
72/// devtools inspector (toggled with `F12`; see [`Self::devtools`]).
73pub struct ReactUiPlugin {
74    bundle: PathBuf,
75    hot_reload: bool,
76    animations: bool,
77    default_font: Option<PathBuf>,
78    named_fonts: Vec<(String, PathBuf)>,
79    custom_cursors: Vec<(String, PathBuf, (u16, u16))>,
80    #[cfg(feature = "devtools")]
81    devtools: crate::devtools::DevtoolsConfig,
82}
83
84impl ReactUiPlugin {
85    /// Create the plugin for the given built app bundle (`app.js`). The build
86    /// emits a `vendor.js` beside it (react + the bevy-react runtime, loaded once);
87    /// both must exist. Hot reload (React Fast Refresh — edits preserve component
88    /// state) and the Reanimated-style animations engine are enabled by default.
89    ///
90    /// The plugin does **not** spawn a camera — `bevy_ui` needs one to render, so
91    /// your app must provide it (a `Camera2d`, or any camera that renders UI).
92    pub fn new(bundle: impl Into<PathBuf>) -> Self {
93        Self {
94            bundle: bundle.into(),
95            hot_reload: true,
96            animations: true,
97            default_font: None,
98            named_fonts: Vec::new(),
99            custom_cursors: Vec::new(),
100            #[cfg(feature = "devtools")]
101            devtools: Default::default(),
102        }
103    }
104
105    /// Configure the devtools inspector, on by default in dev builds (release
106    /// builds never run it, even when compiled in). Every
107    /// [`DevtoolsConfig`](crate::DevtoolsConfig) field has a default, so
108    /// override only what you need:
109    ///
110    /// ```no_run
111    /// # use bevy::prelude::*;
112    /// # use bevy_react::{DevtoolsConfig, ReactUiPlugin};
113    /// # let mut app = App::new();
114    /// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
115    ///     toggle_key: KeyCode::F1,
116    ///     settings_path: Some(".config/devtools.json".into()),
117    ///     ..default()
118    /// }));
119    /// // or disable devtools entirely
120    /// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
121    ///     enabled: false,
122    ///     ..default()
123    /// }));
124    /// ```
125    #[cfg(feature = "devtools")]
126    pub fn devtools(mut self, config: crate::devtools::DevtoolsConfig) -> Self {
127        self.devtools = config;
128        self
129    }
130
131    /// Enable/disable watching the bundle and hot reloading on change.
132    pub fn hot_reload(mut self, yes: bool) -> Self {
133        self.hot_reload = yes;
134        self
135    }
136
137    /// Enable/disable the bundled [`ReactUiAnimationsPlugin`] (the `Animated.node`
138    /// / shared-value engine). On by default; disable to drop it entirely — the
139    /// `op_animate` op stays registered but its commands are discarded.
140    pub fn with_animations(mut self, yes: bool) -> Self {
141        self.animations = yes;
142        self
143    }
144
145    /// Set the app-wide default font, loaded via the `AssetServer` (path relative
146    /// to your `AssetPlugin.file_path`, e.g. `"fonts/Roboto.ttf"`). Every `<text>`
147    /// run uses it unless its style names another family via [`Self::font`].
148    pub fn default_font(mut self, path: impl Into<PathBuf>) -> Self {
149        self.default_font = Some(path.into());
150        self
151    }
152
153    /// Register a named font family. React selects it per element with
154    /// `style={{ fontFamily: name }}`; the path is loaded via the `AssetServer`
155    /// (relative to your `AssetPlugin.file_path`).
156    pub fn font(mut self, name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
157        self.named_fonts.push((name.into(), path.into()));
158        self
159    }
160
161    /// Register a named custom **image** cursor. React selects it per element with
162    /// `style={{ cursor: name }}` (any name that isn't a built-in cursor keyword);
163    /// the image at `path` is loaded via the `AssetServer` (relative to your
164    /// `AssetPlugin.file_path`), and `hotspot` is the click-point pixel (top-left
165    /// origin) within it. The cursor analogue of [`Self::font`].
166    pub fn cursor(
167        mut self,
168        name: impl Into<String>,
169        path: impl Into<PathBuf>,
170        hotspot: (u16, u16),
171    ) -> Self {
172        self.custom_cursors
173            .push((name.into(), path.into(), hotspot));
174        self
175    }
176}
177
178impl Plugin for ReactUiPlugin {
179    fn build(&self, app: &mut App) {
180        // The `filter` style's shader, embedded so it ships with the crate (no
181        // `assets/` folder needed by consumers). The `UiMaterialPlugin` registers
182        // the `FilterMaterial` asset + render pipeline; `init_filter_assets`
183        // creates the shared white pixel for solid-color filtered nodes. Gated on
184        // a render pipeline being present (the canonical `DefaultPlugins`-first
185        // setup), since `embedded_asset!`/`UiMaterialPlugin` need the asset + render
186        // infrastructure — a headless `App` with neither (e.g. wiring-only tests)
187        // simply skips it.
188        if app.is_plugin_added::<bevy::render::RenderPlugin>() {
189            embedded_asset!(app, "filter.wgsl");
190            app.add_plugins(UiMaterialPlugin::<FilterMaterial>::default())
191                .init_resource::<FilterMaterialCache>()
192                .add_systems(Startup, init_filter_assets);
193        }
194
195        // Channels: op batches, app messages, requests, and animation commands flow
196        // JS -> Bevy (crossbeam, same on every target). The Bevy -> JS direction (a
197        // single `Outbound` stream plus, on native, reload signals) is owned by the
198        // target's host, which returns its sender below.
199        // TODO(review): all of these are UNBOUNDED — there's no backpressure. A system that
200        // `events.send`s every frame while the JS side consumes slowly (or not at all) grows
201        // the outbound queue without bound. Consider bounded channels with an explicit
202        // drop/coalesce policy before this is "production".
203        let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
204        // Side channel of per-batch send instants (see `FlushStamps`): feeds the
205        // devtools "pre-apply" timing leg. Stays empty on web (no `Instant`).
206        let (flush_stamps_tx, flush_stamps_rx) =
207            crossbeam_channel::unbounded::<std::time::Instant>();
208        // Side channel of per-batch devtools-origin flags (see `FlushFlags`):
209        // lets applies be attributed to the panel vs the app on every target.
210        let (flush_devtools_tx, flush_devtools_rx) = crossbeam_channel::unbounded::<bool>();
211        let (emit_tx, emit_rx) = crossbeam_channel::unbounded::<ReactMessage>();
212        let (request_tx, request_rx) = crossbeam_channel::unbounded::<RawRequest>();
213        let (anim_tx, anim_rx) = crossbeam_channel::unbounded::<AnimationCommand>();
214
215        // Spawn/install the target's JS host: native runs an embedded V8 isolate on
216        // a dedicated thread (fed from disk, with hot reload); web runs React in the
217        // browser's own engine. The host owns the Bevy->JS transport and returns the
218        // sender every outbound producer writes to. It starts before `setup` builds
219        // the root, so the first ops simply queue until then.
220        let outbound_tx = host::spawn(
221            app,
222            HostConfig {
223                bundle: self.bundle.clone(),
224                hot_reload: self.hot_reload,
225            },
226            HostSenders {
227                ops: ops_tx,
228                flush_stamps: flush_stamps_tx,
229                flush_devtools: flush_devtools_tx,
230                emit: emit_tx,
231                request: request_tx,
232                anim: anim_tx,
233            },
234        );
235        app.insert_resource(crate::reconcile::FlushStamps(flush_stamps_rx));
236        app.insert_resource(crate::reconcile::FlushFlags(flush_devtools_rx));
237
238        app.insert_resource(BridgeChannels {
239            ops_rx: Some(ops_rx),
240            outbound_tx: outbound_tx.clone(),
241        })
242        // A standalone outbound handle for the request dispatcher and the
243        // `ReactEvents` param, available before `setup` builds `JsBridge`.
244        .insert_resource(OutboundResource(outbound_tx))
245        .insert_resource(EmitReceiver(emit_rx))
246        .insert_resource(RequestReceiver(request_rx))
247        .insert_resource(ReactUiConfig {
248            default_font: self.default_font.clone(),
249            named_fonts: self.named_fonts.clone(),
250            custom_cursors: self.custom_cursors.clone(),
251        })
252        .init_resource::<ReactRegistry>()
253        .init_resource::<ReactRequestRegistry>()
254        .init_resource::<ReactEventRegistry>()
255        .init_resource::<PointerCapture>()
256        .init_resource::<OpApplyStats>()
257        // Unconditional so wasm compiles; it just stays `None` there.
258        .init_resource::<crate::reconcile::FrameStamp>()
259        .init_resource::<crate::ui_map::AtlasLayoutCache>()
260        .init_resource::<crate::scrollbar::ScrollbarTracks>()
261        .init_resource::<Fonts>()
262        .init_resource::<crate::cursor::CustomCursors>()
263        // The offscreen render-target ("portal") registry and its shared blank
264        // placeholder texture, created before the first portal can mount.
265        .init_resource::<crate::portal::RenderTargets>()
266        .add_systems(Startup, crate::portal::init_portal_placeholder)
267        // The `<surface>` registry (UI subtrees rendered into offscreen textures)
268        // and its single virtual pointer for in-world clicks.
269        .init_resource::<crate::surface::Surfaces>()
270        .add_systems(Startup, crate::surface::init_surface_pointer)
271        .add_systems(Startup, setup)
272        .add_systems(
273            PreUpdate,
274            (dispatch_react_messages, dispatch_react_requests),
275        )
276        // Drive the surface virtual pointer (cursor → mesh UV → image render
277        // target) before `bevy_picking` processes inputs, so the offscreen UI is
278        // hit-tested with this frame's cursor.
279        .add_systems(
280            PreUpdate,
281            crate::surface::drive_surface_pointer
282                .before(bevy::picking::PickingSystems::ProcessInput),
283        )
284        // Re-filter picking hits against the render-grade inherited clip
285        // (`CalculatedClip`) — bevy_ui 0.19's own clip check misses hits on
286        // scrolled/clipped-away nodes whose direct parent doesn't clip (see
287        // `pick_clip` module docs). Between the backends and the hover-map
288        // update, so every downstream consumer sees only visible hits.
289        .add_systems(
290            PreUpdate,
291            crate::pick_clip::filter_clipped_pointer_hits
292                .after(bevy::picking::PickingSystems::Backend)
293                .before(bevy::picking::PickingSystems::Hover),
294        )
295        .add_systems(
296            Update,
297            (
298                apply_js_ops,
299                collect_ui_events,
300                // Forward window-global keystrokes to React as built-in named
301                // events (`onKeyDown`/`onKeyUp`). Node-independent: reads Bevy's
302                // `KeyboardInput` messages directly, no reconciler state needed.
303                crate::keyboard::collect_keyboard_events,
304                // Emit `pointerEnter`/`pointerLeave` from `Interaction` transitions
305                // (same signal as hover styling), for nodes with those handlers.
306                collect_hover_events,
307                collect_pointer_events.in_set(PointerCaptureSet),
308                // Wheel-scroll any `overflow: scroll` node under the cursor, and
309                // deliver raw wheel deltas to any `onWheel` node. Both in the same
310                // set, after `collect_pointer_events`, so their `PointerCapture::over_ui`
311                // claim survives (that system *assigns* `over_ui`) and world systems
312                // (ordered `.after(PointerCaptureSet)`) see it. (A sub-tuple: the outer
313                // tuple is at Bevy's arity limit.)
314                (
315                    crate::scroll::apply_scroll
316                        .in_set(PointerCaptureSet)
317                        .after(collect_pointer_events),
318                    crate::scroll::collect_wheel_events
319                        .in_set(PointerCaptureSet)
320                        .after(collect_pointer_events),
321                    // A scrollbar-thumb drag claims the pointer (so world input
322                    // ignores it) and pins any eased scroll target. In the set,
323                    // after `collect_pointer_events` so its `over_ui` claim survives.
324                    crate::scrollbar::bridge_scrollbar_capture
325                        .in_set(PointerCaptureSet)
326                        .after(collect_pointer_events),
327                ),
328                // Ease `ScrollPosition` toward the target the controlled write
329                // (`apply_js_ops`) and the wheel (`PointerCaptureSet`) set this frame.
330                // Runs after both so it eases toward the freshest target.
331                crate::transition::drive_scroll_transition
332                    .after(apply_js_ops)
333                    .after(PointerCaptureSet),
334                // Report scroll-offset changes (wheel, controlled write, or an eased
335                // frame) to JS for any node with an `onScroll` handler. After the ease
336                // so it sees the moved offset; after the op drain so a controlled write
337                // is already seeded into the dedup map (no echo).
338                collect_scroll_events
339                    .after(crate::transition::drive_scroll_transition)
340                    .after(PointerCaptureSet)
341                    .after(apply_js_ops),
342                // After the op drain so this frame's `StyleVariants` writes are
343                // visible; the ordering forces a command sync point first.
344                apply_interaction_styles.after(apply_js_ops),
345                // Ease `transform`/`opacity`/`backgroundColor` toward the target the
346                // style appliers just wrote. After `apply_interaction_styles` (and
347                // thus the op drain) so the eased value lands last and a coincident
348                // re-render's snap never wins.
349                crate::transition::drive_transitions.after(apply_interaction_styles),
350                // World-anchored overlays reposition after the op drain so they
351                // override this frame's static `left`/`top`.
352                crate::anchor::position_anchored_nodes.after(apply_js_ops),
353                // Repaint `<canvas>` textures after their surfaces/sizes update,
354                // and report layout resizes to JS (the surface just cleared — so
355                // the app / the runtime's declarative replay redraws). Both read
356                // last frame's `ComputedNode`. The cursor driver rides here too —
357                // it also reads last frame's `ComputedNode` after the op drain
358                // (freshly-stamped `NodeCursor`s visible), and this sub-tuple keeps
359                // the outer tuple under Bevy's arity limit.
360                (
361                    crate::canvas::update_canvas_surfaces.after(apply_js_ops),
362                    collect_canvas_resize_events.after(apply_js_ops),
363                    crate::cursor::drive_cursor_icon.after(apply_js_ops),
364                    // Spawn/teardown the Bevy scrollbar widget over each
365                    // `overflow: scroll` container that declared a `scrollbar`
366                    // style, then place its track over the container's edge. Both
367                    // after the op drain (fresh `ScrollbarConfig`s visible); place
368                    // after sync so the tracks exist. Bevy's `ScrollbarPlugin`
369                    // (in `DefaultPlugins`) drives the thumb in `PostUpdate`.
370                    crate::scrollbar::sync_scrollbars.after(apply_js_ops),
371                    crate::scrollbar::position_scrollbars
372                        .after(apply_js_ops)
373                        .after(crate::scrollbar::sync_scrollbars),
374                    // Paint each bar in its hover/pressed/base state (reads Bevy's
375                    // `Hovered` + `ScrollbarDragState`). After sync so the entities exist.
376                    crate::scrollbar::style_scrollbar_states
377                        .after(crate::scrollbar::sync_scrollbars),
378                ),
379                // Bind `<portal>` nodes to their render-target textures after the
380                // op drain (so a freshly-spawned portal binds the same frame), then
381                // drive resolution + the snapshot camera lifecycle.
382                crate::portal::bind_portals.after(apply_js_ops),
383                crate::portal::drive_render_targets.after(crate::portal::bind_portals),
384                // Bind `<surface>` roots to their offscreen UI cameras after the op
385                // drain (so a freshly-mounted surface binds the same frame), then
386                // drive the snapshot camera lifecycle.
387                crate::surface::bind_surfaces.after(apply_js_ops),
388                crate::surface::drive_surfaces.after(crate::surface::bind_surfaces),
389                // Surface interaction: turn the virtual pointer's picking events on
390                // the offscreen subtree into `onClick`/`onPointer*` + hover/press
391                // styling. The picking events are produced in `PreUpdate`, so these
392                // read this frame's events.
393                collect_surface_clicks,
394                collect_surface_pointer_events,
395                collect_surface_hover_events,
396                apply_surface_interaction_styles,
397            ),
398        );
399
400        // The built-in `"resize"` event + `bevy.window.size()` request (see
401        // `crate::window`). A separate `add_systems` call — the Update tuple
402        // above is at Bevy's arity cap. `.after(apply_js_ops)` so the initial
403        // size goes out the same frame the first batch applies.
404        app.add_systems(
405            Update,
406            crate::window::send_resize_events.after(apply_js_ops),
407        );
408        app.add_react_request_handler(crate::window::handle_window_size_request);
409
410        // Frame-start stamp for the devtools frame-wait / pre-apply split
411        // (native only — no usable `Instant::now` on wasm). `First`: before
412        // any work the pre-apply leg should attribute.
413        #[cfg(not(target_arch = "wasm32"))]
414        app.add_systems(bevy::app::First, crate::reconcile::mark_frame_start);
415
416        // `editableText` edits arrive as Bevy's `TextEditChange` trigger; an observer
417        // turns real changes into `"change"` and selection moves into `"select"` UI
418        // events. Two more observers bridge focus gain/loss to `"focus"`/`"blur"`.
419        app.add_observer(on_text_edit_change);
420        app.add_observer(on_focus_gained);
421        app.add_observer(on_focus_lost);
422
423        // Controlled-selection writes and the a11y value sync run after Bevy's
424        // text-edit pass (`EditableTextSystems`) so they see this frame's applied
425        // edits and resolve byte offsets against the current text.
426        app.add_systems(
427            PostUpdate,
428            (apply_pending_selections, sync_editable_a11y).after(bevy::text::EditableTextSystems),
429        );
430
431        // The animations engine is a separate plugin (its crate can't depend on
432        // this one). We add it and, as the only crate that sees both sides, order
433        // its `Apply` set after `apply_js_ops` so per-frame animation writes win
434        // over this frame's static style. Disabled → `anim_rx` drops here and
435        // `op_animate` sends are discarded.
436        if self.animations {
437            app.add_plugins(ReactUiAnimationsPlugin::new(anim_rx))
438                .configure_sets(Update, AnimationSet::Apply.after(apply_js_ops))
439                // Completion callbacks: settlements the engine reports (once per
440                // token-tagged driver, not per frame) go out to JS.
441                .add_systems(Update, forward_animation_settled.after(AnimationSet::Tick));
442        }
443
444        // The devtools inspector rides along by default (see `Self::devtools`).
445        // Registration is the only gate needed here: the plugin itself registers
446        // nothing in `--release` builds.
447        #[cfg(feature = "devtools")]
448        if self.devtools.enabled {
449            app.add_plugins(crate::devtools::DevtoolsPlugin::new(self.devtools.clone()));
450        }
451    }
452}
453
454/// Forward the animation engine's [`AnimationSettled`] messages to JS as
455/// [`Outbound::AnimationFinished`], resolving each to its completion callback.
456/// The engine crate can't depend on this one, so the bridging happens here.
457fn forward_animation_settled(
458    mut settled: MessageReader<AnimationSettled>,
459    outbound: Res<OutboundResource>,
460) {
461    for s in settled.read() {
462        let _ = outbound.0.send(Outbound::AnimationFinished {
463            id: s.id,
464            token: s.token,
465            finished: s.finished,
466        });
467    }
468}
469
470/// Marker for the UI root entity (reconciler node id 0 / `ROOT_ID`).
471/// `pub(crate)`: devtools' reserve-space mode insets this root's margins to
472/// push the app UI aside while the panel is docked.
473#[derive(Component)]
474pub(crate) struct UiRoot;
475
476/// Plugin configuration read by the startup system.
477#[derive(Resource)]
478struct ReactUiConfig {
479    default_font: Option<PathBuf>,
480    named_fonts: Vec<(String, PathBuf)>,
481    custom_cursors: Vec<(String, PathBuf, (u16, u16))>,
482}
483
484/// Fonts loaded from the plugin config, resolved to handles at startup. The
485/// default backs every `<text>` run; named entries are selected per element via
486/// the `fontFamily` style prop. Empty (unconfigured) → Bevy's built-in font.
487#[derive(Resource, Default)]
488pub struct Fonts {
489    pub default: Option<Handle<Font>>,
490    pub named: std::collections::HashMap<String, Handle<Font>>,
491}
492
493/// Carries the Bevy-side channel ends from `build` into `setup`.
494#[derive(Resource)]
495struct BridgeChannels {
496    ops_rx: Option<OpReceiver>,
497    outbound_tx: OutboundSender,
498}
499
500/// Receives app messages emitted by the React app (`emit(name, value)`).
501#[derive(Resource)]
502struct EmitReceiver(crossbeam_channel::Receiver<ReactMessage>);
503
504/// The single consumption point for React-emitted messages. Drains the channel
505/// each frame and routes every message to its registered typed payload via
506/// [`ReactRegistry`], triggering it for observers. Runs in `PreUpdate` so the
507/// triggers land before consumer `Update` systems run the same frame.
508fn dispatch_react_messages(
509    rx: Res<EmitReceiver>,
510    registry: Res<ReactRegistry>,
511    mut commands: Commands,
512) {
513    while let Ok(msg) = rx.0.try_recv() {
514        registry.dispatch(msg, &mut commands);
515    }
516}
517
518fn setup(
519    mut commands: Commands,
520    mut channels: ResMut<BridgeChannels>,
521    config: Res<ReactUiConfig>,
522    assets: Res<AssetServer>,
523) {
524    // Load configured fonts into the `Fonts` resource before the first
525    // `apply_js_ops` (Update) creates any text.
526    commands.insert_resource(Fonts {
527        default: config.default_font.as_ref().map(|p| assets.load(p.clone())),
528        named: config
529            .named_fonts
530            .iter()
531            .map(|(name, path)| (name.clone(), assets.load(path.clone())))
532            .collect(),
533    });
534    // Likewise load configured custom image cursors into the registry `drive_cursor_icon`
535    // resolves a `cursor` name against.
536    commands.insert_resource(crate::cursor::CustomCursors(
537        config
538            .custom_cursors
539            .iter()
540            .map(|(name, path, hotspot)| {
541                (
542                    name.clone(),
543                    CustomCursorImage {
544                        handle: assets.load(path.clone()),
545                        hotspot: *hotspot,
546                        ..default()
547                    },
548                )
549            })
550            .collect(),
551    ));
552
553    // The root container: a full-window flex column the reconciler appends
554    // top-level children into (it is reconciler node id 0). Children stack from
555    // the top, horizontally centered.
556    let root = commands
557        .spawn((
558            Node {
559                width: Val::Percent(100.0),
560                height: Val::Percent(100.0),
561                flex_direction: FlexDirection::Column,
562                justify_content: JustifyContent::FlexStart,
563                align_items: AlignItems::Center,
564                row_gap: Val::Px(16.0),
565                ..default()
566            },
567            // Layout scaffolding only: without an explicit `Pickable` the UI
568            // picking backend treats the full-window root as a blocking hit,
569            // which would make hover queries (`HoverMap`) report "over UI"
570            // everywhere. Element nodes opt in per-`FocusPolicy` instead.
571            bevy::picking::Pickable {
572                should_block_lower: false,
573                ..default()
574            },
575            UiRoot,
576        ))
577        .id();
578
579    // The shared overlay container for world-anchored nodes (`Anchored.node`).
580    // `position_anchored_nodes` reparents every anchored overlay under this so it lives
581    // in its own hierarchy and never inflates an app container's flex layout or
582    // scrollable `content_size`. Zero-size at the window origin (absolute, left/top 0)
583    // with default `Overflow::visible`, so it neither clips its children nor intercepts
584    // pointer input; anchored nodes position themselves relative to its (0,0) corner.
585    // Spawned as the root's first child so the app subtree (appended later via ops)
586    // renders above it — add a `GlobalZIndex` here to lift overlays above app content.
587    commands.spawn((
588        Node {
589            position_type: PositionType::Absolute,
590            left: Val::Px(0.0),
591            top: Val::Px(0.0),
592            width: Val::Px(0.0),
593            height: Val::Px(0.0),
594            ..default()
595        },
596        crate::anchor::AnchorLayer,
597        ChildOf(root),
598    ));
599
600    let ops_rx = channels.ops_rx.take().expect("setup runs once");
601    commands.insert_resource(JsBridge::new(ops_rx, channels.outbound_tx.clone(), root));
602}