Skip to main content

bevy_react/
js_thread.rs

1//! The dedicated JS thread: owns the V8 isolate, runs the React bundle, and
2//! exposes the ops that form the whole Rust<->JS boundary.
3//!
4//! The bundle is split in two (see `examples/.../build.mjs`): a **vendor**
5//! script (react, react-reconciler, the bevy-react runtime) executed once and
6//! never re-run, and an **app** script (the user's components) re-executed on
7//! every edit. On a hot reload the isolate is KEPT ALIVE: we re-`execute_script`
8//! the rebuilt app, which re-registers components and drives a React Fast
9//! Refresh (hook state preserved). Only if that fails do we fall back to tearing
10//! the whole runtime down and rebuilding it.
11
12use std::cell::{Cell, RefCell};
13use std::path::{Path, PathBuf};
14use std::rc::Rc;
15
16use bevy::log::{debug, error, info, warn};
17use crossbeam_channel::Sender;
18use deno_core::{Extension, JsRuntime, OpDecl, OpState, RuntimeOptions, op2};
19use tokio::sync::Mutex;
20use tokio::sync::Notify;
21use tokio::sync::mpsc::UnboundedReceiver;
22
23use crate::animations::AnimationCommand;
24
25use crate::message::ReactMessage;
26use crate::protocol::{Op, Outbound};
27use crate::request::RawRequest;
28
29/// Sender half stored in `OpState` so `op_flush` can hand op batches to Bevy.
30struct OpSender(Sender<Vec<Op>>);
31
32/// Sender half stored in `OpState` so `op_emit` can hand app messages to Bevy.
33struct EmitSender(Sender<ReactMessage>);
34
35/// Sender half stored in `OpState` so `op_request` can hand requests to Bevy.
36struct RequestSender(Sender<RawRequest>);
37
38/// Sender half stored in `OpState` so `op_animate` can hand animation commands to
39/// the animations plugin. Always present; if animations are disabled the receiver
40/// is dropped and sends are silently discarded.
41struct AnimSender(Sender<AnimationCommand>);
42
43/// Receivers shared (by `Rc`) into each runtime's `OpState`. The async op clones
44/// the `Rc` out and awaits without holding the `OpState` borrow.
45struct OutboundReceiver(Rc<Mutex<UnboundedReceiver<Outbound>>>);
46struct ReloadReceiver(Rc<Mutex<UnboundedReceiver<()>>>);
47
48/// Set true when a reload was requested, so the outer loop rebuilds rather than
49/// exits. One per runtime instance.
50struct ReloadFlag(Rc<Cell<bool>>);
51
52/// Woken by `op_next_event` when it hands the JS loop the reload sentinel, so
53/// `pump` can break out of `run_event_loop` even when perpetual timers (e.g. a
54/// `setInterval` clock) keep the event loop from ever going idle on its own.
55struct ReloadNotify(Rc<Notify>);
56
57/// JS -> Bevy: ship one commit's worth of mutation ops. Synchronous.
58#[op2]
59fn op_flush(state: &mut OpState, #[serde] ops: Vec<Op>) {
60    let sender = state.borrow::<OpSender>();
61    let _ = sender.0.send(ops);
62}
63
64/// JS -> Bevy: emit a named app message (e.g. "count") for ECS systems to read.
65// TODO(review): the app-message path (emit/request/event) double-converts v8 →
66// `serde_json::Value` → the typed `T` (here, and again in message::dispatch /
67// request::dispatch; outbound mirrors it in event::send), two extra allocations per
68// message — unlike the `op_flush` hot path, which deserializes straight into `protocol::Op`.
69// Routing-by-name needs the type erased, but high-frequency events still pay for it.
70#[op2]
71fn op_emit(state: &mut OpState, #[string] name: String, #[serde] value: serde_json::Value) {
72    let sender = state.borrow::<EmitSender>();
73    let _ = sender.0.send(ReactMessage { name, value });
74}
75
76/// JS -> Bevy: surface a `console.*` call in the Bevy log. `level` is one of
77/// "error" | "warn" | "info" | "debug" (mapped from the console method in the
78/// prelude shim). The `target: "bevy_react::js"` marks the line as coming from the React
79/// app, and the tracing level keeps `console.log` and `console.error` visually
80/// distinct (INFO vs the red ERROR).
81#[op2(fast)]
82fn op_log(#[string] level: String, #[string] msg: String) {
83    match level.as_str() {
84        "error" => error!(target: "bevy_react::js", "{msg}"),
85        "warn" => warn!(target: "bevy_react::js", "{msg}"),
86        "debug" => debug!(target: "bevy_react::js", "{msg}"),
87        _ => info!(target: "bevy_react::js", "{msg}"),
88    }
89}
90
91/// JS -> Bevy: declare/start/stop a shared-value animation. Synchronous,
92/// fire-and-forget (like `op_emit`); the animations plugin drains the channel and
93/// drives the value each frame, so per-frame interpolation never crosses back.
94#[op2]
95fn op_animate(state: &mut OpState, #[serde] cmd: AnimationCommand) {
96    let sender = state.borrow::<AnimSender>();
97    let _ = sender.0.send(cmd);
98}
99
100/// JS -> Bevy: send a correlated request. The reply comes back asynchronously as
101/// an [`Outbound::Response`](crate::protocol::Outbound) with the same `id`, which
102/// the JS event loop matches to the pending promise. `id` is a `BigInt` on the JS
103/// side (well under 2^53 in practice).
104#[op2]
105fn op_request(
106    state: &mut OpState,
107    #[bigint] id: u64,
108    #[string] name: String,
109    #[serde] value: serde_json::Value,
110) {
111    let sender = state.borrow::<RequestSender>();
112    let _ = sender.0.send(RawRequest { id, name, value });
113}
114
115/// Bevy -> JS: resolve with the next outbound message (UI event, app event,
116/// request response), the reload sentinel, or `null` on shutdown (all senders
117/// dropped). Async so the JS loop parks here cheaply.
118#[op2]
119#[serde]
120async fn op_next_event(state: Rc<RefCell<OpState>>) -> Option<Outbound> {
121    let (events, reload, flag, notify) = {
122        let state = state.borrow();
123        (
124            state.borrow::<OutboundReceiver>().0.clone(),
125            state.borrow::<ReloadReceiver>().0.clone(),
126            state.borrow::<ReloadFlag>().0.clone(),
127            state.borrow::<ReloadNotify>().0.clone(),
128        )
129    };
130    let mut events = events.lock().await;
131    let mut reload = reload.lock().await;
132    tokio::select! {
133        ev = events.recv() => ev, // Some(outbound), or None on shutdown
134        r = reload.recv() => match r {
135            Some(()) => {
136                flag.set(true);
137                // Wake `pump`: timers may be keeping `run_event_loop` from
138                // returning, so it can't notice the reload on its own.
139                notify.notify_one();
140                Some(Outbound::Reload)
141            }
142            None => None, // reload sender dropped => shutdown
143        }
144    }
145}
146
147/// JS -> (no Bevy): sleep `ms` milliseconds, then resolve. Backs the real
148/// `setTimeout`/`setInterval` polyfills below; driven by `run_event_loop` (kept
149/// alive by the always-pending `op_next_event`), so timers fire even when the app
150/// is otherwise idle.
151#[op2]
152async fn op_sleep(ms: f64) {
153    let ms = ms.max(0.0) as u64;
154    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
155}
156
157/// JS globals deno_core does not provide on its own. `setTimeout`/`setInterval`
158/// honor their delay via the async `op_sleep`; a `0`ms timeout stays on the
159/// microtask queue so React's scheduler (which yields with `setTimeout(_, 0)`)
160/// stays cheap. Cancellation is observable (a cleared callback never runs), even
161/// though the underlying sleep still completes.
162// The prelude also installs a `console` that forwards to `op_log`, so every
163// `console.*` call (the runtime's own error handlers in bridge.ts/renderer.ts as
164// well as any user component) reaches the Bevy log tagged `target: "bevy_react::js"`, with
165// the tracing level distinguishing `log` from `error`. We define it explicitly
166// rather than relying on deno_core's default so behavior is deterministic.
167const PRELUDE: &str = r#"
168let __nextTimer = 1;
169const __cancelled = new Set();
170globalThis.setTimeout = (cb, ms = 0, ...args) => {
171  const id = __nextTimer++;
172  const delay = Math.max(0, +ms || 0);
173  const run = () => { if (!__cancelled.delete(id)) cb(...args); };
174  if (delay === 0) Promise.resolve().then(run);
175  else Deno.core.ops.op_sleep(delay).then(run);
176  return id;
177};
178globalThis.clearTimeout = (id) => { if (id != null) __cancelled.add(id); };
179globalThis.setInterval = (cb, ms = 0, ...args) => {
180  const id = __nextTimer++;
181  const delay = Math.max(0, +ms || 0);
182  (async () => {
183    while (!__cancelled.has(id)) {
184      await Deno.core.ops.op_sleep(delay);
185      if (__cancelled.has(id)) break;
186      cb(...args);
187    }
188    __cancelled.delete(id);
189  })();
190  return id;
191};
192globalThis.clearInterval = (id) => { if (id != null) __cancelled.add(id); };
193globalThis.queueMicrotask = globalThis.queueMicrotask || ((cb) => { Promise.resolve().then(cb); });
194
195const __fmtArg = (a) => {
196  if (typeof a === "string") return a;
197  if (a instanceof Error) return a.stack || (a.name + ": " + a.message);
198  try { return JSON.stringify(a); } catch { return String(a); }
199};
200const __log = (level) => (...args) =>
201  Deno.core.ops.op_log(level, args.map(__fmtArg).join(" "));
202globalThis.console = {
203  log: __log("info"),
204  info: __log("info"),
205  debug: __log("debug"),
206  trace: __log("debug"),
207  warn: __log("warn"),
208  error: __log("error"),
209  dir: __log("info"),
210  table: __log("info"),
211  // No-op fallbacks so libraries that probe these never throw:
212  group: () => {}, groupCollapsed: () => {}, groupEnd: () => {}, assert: () => {},
213};
214"#;
215
216/// What ended a pump of the JS event loop.
217enum Pumped {
218    /// A reload was signalled; the app bundle should be re-executed.
219    Reload,
220    /// All senders dropped — Bevy is shutting down.
221    Shutdown,
222}
223
224/// The senders the runtime needs; cloned into each (re)build of the isolate.
225#[derive(Clone)]
226struct Senders {
227    ops: Sender<Vec<Op>>,
228    emit: Sender<ReactMessage>,
229    request: Sender<RawRequest>,
230    anim: Sender<AnimationCommand>,
231}
232
233/// Spawn the JS thread. Builds the isolate once and keeps it alive across hot
234/// reloads (re-executing only the app bundle); runs until shutdown.
235#[allow(clippy::too_many_arguments)]
236pub fn spawn_js_thread(
237    vendor_path: PathBuf,
238    app_path: PathBuf,
239    ops_tx: Sender<Vec<Op>>,
240    emit_tx: Sender<ReactMessage>,
241    request_tx: Sender<RawRequest>,
242    anim_tx: Sender<AnimationCommand>,
243    outbound_rx: UnboundedReceiver<Outbound>,
244    reload_rx: UnboundedReceiver<()>,
245) {
246    std::thread::Builder::new()
247        .name("js-runtime".to_string())
248        .spawn(move || {
249            let rt = tokio::runtime::Builder::new_current_thread()
250                .enable_all()
251                .build()
252                .expect("build current-thread tokio runtime");
253
254            rt.block_on(async move {
255                let senders = Senders {
256                    ops: ops_tx,
257                    emit: emit_tx,
258                    request: request_tx,
259                    anim: anim_tx,
260                };
261                // These outlive individual runtimes so events/reload signals
262                // survive across a full-reload rebuild.
263                let outbound_rx = Rc::new(Mutex::new(outbound_rx));
264                let reload_rx = Rc::new(Mutex::new(reload_rx));
265                let reload_flag = Rc::new(Cell::new(false));
266                let reload_notify = Rc::new(Notify::new());
267
268                // The last app bundle that executed WITHOUT throwing. A reload that
269                // throws (syntax error or a runtime error like an undefined
270                // identifier in a component) is rejected and this is re-run instead,
271                // so a broken edit never tears down the working UI — see the reload
272                // arm below.
273                let mut last_good_app = match read_app(&app_path) {
274                    Ok(code) => code,
275                    Err(e) => {
276                        error!(target: "bevy_react::js", "reading app failed: {e:?}");
277                        return;
278                    }
279                };
280
281                let mut runtime = match build_runtime(
282                    &vendor_path,
283                    &last_good_app,
284                    &senders,
285                    outbound_rx.clone(),
286                    reload_rx.clone(),
287                    reload_flag.clone(),
288                    reload_notify.clone(),
289                ) {
290                    Ok(rt) => rt,
291                    Err(e) => {
292                        error!(target: "bevy_react::js", "initial runtime build failed: {e:?}");
293                        return;
294                    }
295                };
296
297                loop {
298                    reload_flag.set(false);
299                    // `pump` drives the JS event loop: the initial/refreshed
300                    // render commits, then it parks on `op_next_event` until a
301                    // reload or shutdown.
302                    match pump(&mut runtime, &reload_flag, &reload_notify).await {
303                        Pumped::Shutdown => break,
304                        Pumped::Reload => {
305                            // Re-execute the rebuilt app in the LIVE isolate. The
306                            // next `pump` drives the resulting Fast Refresh.
307                            let new_code = match read_app(&app_path) {
308                                Ok(code) => code,
309                                Err(e) => {
310                                    warn!(target: "bevy_react::js", "reading rebuilt app failed ({e}); keeping the previous working version");
311                                    continue;
312                                }
313                            };
314                            match runtime.execute_script("[app-update]", new_code.clone()) {
315                                // Applied cleanly — this becomes the new fallback.
316                                Ok(_) => last_good_app = new_code,
317                                Err(e) => {
318                                    // The new bundle threw (a syntax error, or a
319                                    // runtime error like `padding: aa16` referencing
320                                    // an undefined identifier). Don't refresh into
321                                    // broken code: re-run the last working bundle so
322                                    // its `mount()` re-parks the event loop and the
323                                    // UI stays live. The next good edit applies.
324                                    warn!(target: "bevy_react::js", "update rejected ({e}); keeping the previous working version");
325                                    if let Err(e) = runtime
326                                        .execute_script("[app-restore]", last_good_app.clone())
327                                    {
328                                        // The known-good bundle failed to re-run
329                                        // (should not happen — it ran moments ago).
330                                        // Log and keep pumping rather than wedge.
331                                        error!(target: "bevy_react::js", "restoring previous app failed: {e:?}");
332                                    }
333                                }
334                            }
335                        }
336                    }
337                }
338            });
339        })
340        .expect("spawn js-runtime thread");
341}
342
343/// Build a fresh isolate: register ops, run the prelude, then execute the vendor
344/// and app scripts. The app's `mount()` renders the initial tree synchronously
345/// (via `flushSync`) and parks on `op_next_event`; the caller's `pump` drives it.
346fn build_runtime(
347    vendor_path: &Path,
348    app_code: &str,
349    senders: &Senders,
350    outbound_rx: Rc<Mutex<UnboundedReceiver<Outbound>>>,
351    reload_rx: Rc<Mutex<UnboundedReceiver<()>>>,
352    reload_flag: Rc<Cell<bool>>,
353    reload_notify: Rc<Notify>,
354) -> anyhow::Result<JsRuntime> {
355    const FLUSH: OpDecl = op_flush();
356    const EMIT: OpDecl = op_emit();
357    const REQUEST: OpDecl = op_request();
358    const ANIMATE: OpDecl = op_animate();
359    const NEXT: OpDecl = op_next_event();
360    const SLEEP: OpDecl = op_sleep();
361    const LOG: OpDecl = op_log();
362    let ext = Extension {
363        name: "bevy_react_bridge",
364        ops: std::borrow::Cow::Borrowed(&[FLUSH, EMIT, REQUEST, ANIMATE, NEXT, SLEEP, LOG]),
365        ..Default::default()
366    };
367
368    let mut runtime = JsRuntime::new(RuntimeOptions {
369        extensions: vec![ext],
370        ..Default::default()
371    });
372
373    {
374        let op_state = runtime.op_state();
375        let mut op_state = op_state.borrow_mut();
376        op_state.put(OpSender(senders.ops.clone()));
377        op_state.put(EmitSender(senders.emit.clone()));
378        op_state.put(RequestSender(senders.request.clone()));
379        op_state.put(AnimSender(senders.anim.clone()));
380        op_state.put(OutboundReceiver(outbound_rx));
381        op_state.put(ReloadReceiver(reload_rx));
382        op_state.put(ReloadFlag(reload_flag));
383        op_state.put(ReloadNotify(reload_notify));
384    }
385
386    runtime.execute_script("[prelude]", PRELUDE)?;
387
388    let vendor_code = std::fs::read_to_string(vendor_path)
389        .map_err(|e| anyhow::anyhow!("reading vendor {}: {e}", vendor_path.display()))?;
390    runtime.execute_script("[vendor]", vendor_code)?;
391
392    runtime.execute_script("[app]", app_code.to_owned())?;
393
394    Ok(runtime)
395}
396
397/// Drive the JS event loop until it yields control back to Rust: either a reload
398/// was signalled (`op_next_event` returned the reload sentinel, so the JS event
399/// loop returned) or all senders dropped (shutdown).
400async fn pump(
401    runtime: &mut JsRuntime,
402    reload_flag: &Rc<Cell<bool>>,
403    reload_notify: &Notify,
404) -> Pumped {
405    // Race the event loop against the reload signal. `run_event_loop` only
406    // resolves when the loop goes idle, but an app with a perpetual timer
407    // (e.g. a `setInterval` clock) never does — so without this the reload
408    // sentinel `op_next_event` returns would never reach us, the bundle would
409    // never re-execute, and nothing would re-park on `op_next_event` (the UI
410    // would freeze). `biased` polls the event loop first so it fully drains the
411    // pending microtasks (the prior `runEventLoop` returning) before we bail.
412    loop {
413        let loop_result = tokio::select! {
414            biased;
415            res = runtime.run_event_loop(Default::default()) => Some(res),
416            _ = reload_notify.notified() => None,
417        };
418        match loop_result {
419            // Woken by the reload notify (a real reload sets `reload_flag` before
420            // `notify_one`). If the flag is clear this is a *stale* permit: when a
421            // prior reload resolved via the event-loop branch above, `biased`
422            // drained the loop first and dropped this `notified()` future after it
423            // had been notified, which re-arms the permit. Ignore it and keep
424            // driving the loop, or we'd re-execute the app a second time.
425            None => {
426                if reload_flag.get() {
427                    return Pumped::Reload;
428                }
429            }
430            Some(Err(e)) => {
431                // Steady-state errors are caught inside the JS event loop, so this
432                // is rare; treat it like a reload so we rebuild rather than wedge.
433                error!(target: "bevy_react::js", "event loop error: {e}");
434                return Pumped::Reload;
435            }
436            // The loop went idle: a reload with no pending timers, or shutdown.
437            Some(Ok(())) => {
438                return if reload_flag.get() {
439                    Pumped::Reload
440                } else {
441                    Pumped::Shutdown
442                };
443            }
444        }
445    }
446}
447
448/// Read the app bundle from disk. Re-executing it in the live isolate (on a hot
449/// reload) is what drives Fast Refresh: the app IIFE re-registers its components
450/// and calls `mount()`, which — seeing the isolate already mounted — triggers
451/// `performReactRefresh()` and re-parks the event loop on `op_next_event`.
452fn read_app(app_path: &Path) -> anyhow::Result<String> {
453    std::fs::read_to_string(app_path)
454        .map_err(|e| anyhow::anyhow!("reading app {}: {e}", app_path.display()))
455}