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