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::Op, op::OpBatch, outbound::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    // Mirror into the devtools console ring (dev builds only; no-op stub
125    // otherwise) — this single op is the funnel for ALL JS console output,
126    // including the runtime's own error handlers.
127    crate::console_log::push(
128        crate::console_log::Source::Js,
129        crate::console_log::Level::from_js(&level),
130        &msg,
131    );
132}
133
134/// JS -> Bevy: declare/start/stop a shared-value animation. Synchronous,
135/// fire-and-forget (like `op_emit`); the animations plugin drains the channel and
136/// drives the value each frame, so per-frame interpolation never crosses back.
137#[op2]
138fn op_animate(state: &mut OpState, #[serde] cmd: AnimationCommand) {
139    let sender = state.borrow::<AnimSender>();
140    let _ = sender.0.send(cmd);
141}
142
143/// JS -> Bevy: send a correlated request. The reply comes back asynchronously as
144/// an [`Outbound::Response`](crate::protocol::outbound::Outbound) with the same `id`, which
145/// the JS event loop matches to the pending promise. `id` is a `BigInt` on the JS
146/// side (well under 2^53 in practice).
147#[op2]
148fn op_request(
149    state: &mut OpState,
150    #[bigint] id: u64,
151    #[string] name: String,
152    #[serde] value: serde_json::Value,
153) {
154    let sender = state.borrow::<RequestSender>();
155    let _ = sender.0.send(RawRequest { id, name, value });
156}
157
158/// Bevy -> JS: resolve with the next outbound message (UI event, app event,
159/// request response), the reload sentinel, or `null` on shutdown (all senders
160/// dropped). Async so the JS loop parks here cheaply.
161#[op2]
162#[serde]
163async fn op_next_event(state: Rc<RefCell<OpState>>) -> Option<Outbound> {
164    let (events, reload, flag, notify) = {
165        let state = state.borrow();
166        (
167            state.borrow::<OutboundReceiver>().0.clone(),
168            state.borrow::<ReloadReceiver>().0.clone(),
169            state.borrow::<ReloadFlag>().0.clone(),
170            state.borrow::<ReloadNotify>().0.clone(),
171        )
172    };
173    let mut events = events.lock().await;
174    let mut reload = reload.lock().await;
175    tokio::select! {
176        ev = events.recv() => ev, // Some(outbound), or None on shutdown
177        r = reload.recv() => match r {
178            Some(()) => {
179                flag.set(true);
180                // Wake `pump`: timers may be keeping `run_event_loop` from
181                // returning, so it can't notice the reload on its own.
182                notify.notify_one();
183                Some(Outbound::Reload)
184            }
185            None => None, // reload sender dropped => shutdown
186        }
187    }
188}
189
190/// JS -> (no Bevy): sleep `ms` milliseconds, then resolve. Backs the real
191/// `setTimeout`/`setInterval` polyfills below; driven by `run_event_loop` (kept
192/// alive by the always-pending `op_next_event`), so timers fire even when the app
193/// is otherwise idle.
194#[op2]
195async fn op_sleep(ms: f64) {
196    let ms = ms.max(0.0) as u64;
197    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
198}
199
200/// JS globals deno_core does not provide on its own. `setTimeout`/`setInterval`
201/// honor their delay via the async `op_sleep`; a `0`ms timeout stays on the
202/// microtask queue so React's scheduler (which yields with `setTimeout(_, 0)`)
203/// stays cheap. Cancellation is observable (a cleared callback never runs), even
204/// though the underlying sleep still completes.
205// The prelude also installs a `console` that forwards to `op_log`, so every
206// `console.*` call (the runtime's own error handlers in bridge.ts/renderer.ts as
207// well as any user component) reaches the Bevy log tagged `target: "bevy_react::js"`, with
208// the tracing level distinguishing `log` from `error`. We define it explicitly
209// rather than relying on deno_core's default so behavior is deterministic.
210const PRELUDE: &str = r#"
211let __nextTimer = 1;
212const __cancelled = new Set();
213globalThis.setTimeout = (cb, ms = 0, ...args) => {
214  const id = __nextTimer++;
215  const delay = Math.max(0, +ms || 0);
216  const run = () => { if (!__cancelled.delete(id)) cb(...args); };
217  if (delay === 0) Promise.resolve().then(run);
218  else Deno.core.ops.op_sleep(delay).then(run);
219  return id;
220};
221globalThis.clearTimeout = (id) => { if (id != null) __cancelled.add(id); };
222globalThis.setInterval = (cb, ms = 0, ...args) => {
223  const id = __nextTimer++;
224  const delay = Math.max(0, +ms || 0);
225  (async () => {
226    while (!__cancelled.has(id)) {
227      await Deno.core.ops.op_sleep(delay);
228      if (__cancelled.has(id)) break;
229      cb(...args);
230    }
231    __cancelled.delete(id);
232  })();
233  return id;
234};
235globalThis.clearInterval = (id) => { if (id != null) __cancelled.add(id); };
236globalThis.queueMicrotask = globalThis.queueMicrotask || ((cb) => { Promise.resolve().then(cb); });
237
238const __fmtArg = (a) => {
239  if (typeof a === "string") return a;
240  if (a instanceof Error) return a.stack || (a.name + ": " + a.message);
241  try { return JSON.stringify(a); } catch { return String(a); }
242};
243const __log = (level) => (...args) =>
244  Deno.core.ops.op_log(level, args.map(__fmtArg).join(" "));
245globalThis.console = {
246  log: __log("info"),
247  info: __log("info"),
248  debug: __log("debug"),
249  trace: __log("debug"),
250  warn: __log("warn"),
251  error: __log("error"),
252  dir: __log("info"),
253  table: __log("info"),
254  // No-op fallbacks so libraries that probe these never throw:
255  group: () => {}, groupCollapsed: () => {}, groupEnd: () => {}, assert: () => {},
256};
257// Unhandled promise rejections: log (→ op_log → the devtools console) and
258// swallow. Returning true suppresses op_dispatch_exception — a deliberate
259// behavior change: previously a rejection errored the event loop, which
260// `pump` treats as a reload and re-executes the whole app bundle. A logged
261// rejection with a live app beats a silent restart.
262Deno.core.setUnhandledPromiseRejectionHandler((_promise, reason) => {
263  console.error("[js] unhandled promise rejection:", __fmtArg(reason));
264  return true;
265});
266"#;
267
268/// What ended a pump of the JS event loop.
269enum Pumped {
270    /// A reload was signalled; the app bundle should be re-executed.
271    Reload,
272    /// All senders dropped — Bevy is shutting down.
273    Shutdown,
274}
275
276/// The senders the runtime needs; cloned into each (re)build of the isolate.
277#[derive(Clone)]
278struct Senders {
279    ops: Sender<Vec<Op>>,
280    flush_stamps: Sender<std::time::Instant>,
281    flush_devtools: Sender<bool>,
282    emit: Sender<ReactMessage>,
283    request: Sender<RawRequest>,
284    anim: Sender<AnimationCommand>,
285}
286
287/// Spawn the JS thread. Builds the isolate once and keeps it alive across hot
288/// reloads (re-executing only the app bundle); runs until shutdown.
289#[allow(clippy::too_many_arguments)]
290pub fn spawn_js_thread(
291    vendor_path: PathBuf,
292    app_path: PathBuf,
293    ops_tx: Sender<Vec<Op>>,
294    flush_stamps_tx: Sender<std::time::Instant>,
295    flush_devtools_tx: Sender<bool>,
296    emit_tx: Sender<ReactMessage>,
297    request_tx: Sender<RawRequest>,
298    anim_tx: Sender<AnimationCommand>,
299    outbound_rx: UnboundedReceiver<Outbound>,
300    reload_rx: UnboundedReceiver<()>,
301) {
302    std::thread::Builder::new()
303        .name("js-runtime".to_string())
304        .spawn(move || {
305            let rt = tokio::runtime::Builder::new_current_thread()
306                .enable_all()
307                .build()
308                .expect("build current-thread tokio runtime");
309
310            rt.block_on(async move {
311                let senders = Senders {
312                    ops: ops_tx,
313                    flush_stamps: flush_stamps_tx,
314                    flush_devtools: flush_devtools_tx,
315                    emit: emit_tx,
316                    request: request_tx,
317                    anim: anim_tx,
318                };
319                // These outlive individual runtimes so events/reload signals
320                // survive across a full-reload rebuild.
321                let outbound_rx = Rc::new(Mutex::new(outbound_rx));
322                let reload_rx = Rc::new(Mutex::new(reload_rx));
323                let reload_flag = Rc::new(Cell::new(false));
324                let reload_notify = Rc::new(Notify::new());
325
326                // The last app bundle that executed WITHOUT throwing. A reload that
327                // throws (syntax error or a runtime error like an undefined
328                // identifier in a component) is rejected and this is re-run instead,
329                // so a broken edit never tears down the working UI — see the reload
330                // arm below.
331                let mut last_good_app = match read_app(&app_path) {
332                    Ok(code) => code,
333                    Err(e) => {
334                        error!(target: "bevy_react::js", "reading app failed: {e:?}");
335                        return;
336                    }
337                };
338
339                let mut runtime = match build_runtime(
340                    &vendor_path,
341                    &last_good_app,
342                    &senders,
343                    outbound_rx.clone(),
344                    reload_rx.clone(),
345                    reload_flag.clone(),
346                    reload_notify.clone(),
347                ) {
348                    Ok(rt) => rt,
349                    Err(e) => {
350                        error!(target: "bevy_react::js", "initial runtime build failed: {e:?}");
351                        return;
352                    }
353                };
354
355                loop {
356                    reload_flag.set(false);
357                    // `pump` drives the JS event loop: the initial/refreshed
358                    // render commits, then it parks on `op_next_event` until a
359                    // reload or shutdown.
360                    match pump(&mut runtime, &reload_flag, &reload_notify).await {
361                        Pumped::Shutdown => break,
362                        Pumped::Reload => {
363                            // Re-execute the rebuilt app in the LIVE isolate. The
364                            // next `pump` drives the resulting Fast Refresh.
365                            let new_code = match read_app(&app_path) {
366                                Ok(code) => code,
367                                Err(e) => {
368                                    warn!(target: "bevy_react::js", "reading rebuilt app failed ({e}); keeping the previous working version");
369                                    continue;
370                                }
371                            };
372                            match runtime.execute_script("[app-update]", new_code.clone()) {
373                                // Applied cleanly — this becomes the new fallback.
374                                Ok(_) => last_good_app = new_code,
375                                Err(e) => {
376                                    // The new bundle threw (a syntax error, or a
377                                    // runtime error like `padding: aa16` referencing
378                                    // an undefined identifier). Don't refresh into
379                                    // broken code: re-run the last working bundle so
380                                    // its `mount()` re-parks the event loop and the
381                                    // UI stays live. The next good edit applies.
382                                    warn!(target: "bevy_react::js", "update rejected ({e}); keeping the previous working version");
383                                    crate::console_log::push(
384                                        crate::console_log::Source::Rust,
385                                        crate::console_log::Level::Error,
386                                        &format!("hot reload rejected ({e}); keeping the previous working version"),
387                                    );
388                                    if let Err(e) = runtime
389                                        .execute_script("[app-restore]", last_good_app.clone())
390                                    {
391                                        // The known-good bundle failed to re-run
392                                        // (should not happen — it ran moments ago).
393                                        // Log and keep pumping rather than wedge.
394                                        error!(target: "bevy_react::js", "restoring previous app failed: {e:?}");
395                                        crate::console_log::push(
396                                            crate::console_log::Source::Rust,
397                                            crate::console_log::Level::Error,
398                                            &format!("restoring previous app failed: {e:?}"),
399                                        );
400                                    }
401                                }
402                            }
403                        }
404                    }
405                }
406            });
407        })
408        .expect("spawn js-runtime thread");
409}
410
411/// Build a fresh isolate: register ops, run the prelude, then execute the vendor
412/// and app scripts. The app's `mount()` renders the initial tree synchronously
413/// (via `flushSync`) and parks on `op_next_event`; the caller's `pump` drives it.
414fn build_runtime(
415    vendor_path: &Path,
416    app_code: &str,
417    senders: &Senders,
418    outbound_rx: Rc<Mutex<UnboundedReceiver<Outbound>>>,
419    reload_rx: Rc<Mutex<UnboundedReceiver<()>>>,
420    reload_flag: Rc<Cell<bool>>,
421    reload_notify: Rc<Notify>,
422) -> anyhow::Result<JsRuntime> {
423    const FLUSH: OpDecl = op_flush();
424    const TAKE_WARNINGS: OpDecl = op_take_decode_warnings();
425    const EMIT: OpDecl = op_emit();
426    const REQUEST: OpDecl = op_request();
427    const ANIMATE: OpDecl = op_animate();
428    const NEXT: OpDecl = op_next_event();
429    const SLEEP: OpDecl = op_sleep();
430    const LOG: OpDecl = op_log();
431    let ext = Extension {
432        name: "bevy_react_bridge",
433        ops: std::borrow::Cow::Borrowed(&[
434            FLUSH,
435            TAKE_WARNINGS,
436            EMIT,
437            REQUEST,
438            ANIMATE,
439            NEXT,
440            SLEEP,
441            LOG,
442        ]),
443        ..Default::default()
444    };
445
446    let mut runtime = JsRuntime::new(RuntimeOptions {
447        extensions: vec![ext],
448        ..Default::default()
449    });
450
451    {
452        let op_state = runtime.op_state();
453        let mut op_state = op_state.borrow_mut();
454        op_state.put(OpSender(senders.ops.clone()));
455        op_state.put(FlushStampSender(senders.flush_stamps.clone()));
456        op_state.put(FlushDevtoolsSender(senders.flush_devtools.clone()));
457        op_state.put(EmitSender(senders.emit.clone()));
458        op_state.put(RequestSender(senders.request.clone()));
459        op_state.put(AnimSender(senders.anim.clone()));
460        op_state.put(OutboundReceiver(outbound_rx));
461        op_state.put(ReloadReceiver(reload_rx));
462        op_state.put(ReloadFlag(reload_flag));
463        op_state.put(ReloadNotify(reload_notify));
464    }
465
466    runtime.execute_script("[prelude]", PRELUDE)?;
467
468    let vendor_code = std::fs::read_to_string(vendor_path)
469        .map_err(|e| anyhow::anyhow!("reading vendor {}: {e}", vendor_path.display()))?;
470    runtime.execute_script("[vendor]", vendor_code)?;
471
472    runtime.execute_script("[app]", app_code.to_owned())?;
473
474    Ok(runtime)
475}
476
477/// Drive the JS event loop until it yields control back to Rust: either a reload
478/// was signalled (`op_next_event` returned the reload sentinel, so the JS event
479/// loop returned) or all senders dropped (shutdown).
480async fn pump(
481    runtime: &mut JsRuntime,
482    reload_flag: &Rc<Cell<bool>>,
483    reload_notify: &Notify,
484) -> Pumped {
485    // Race the event loop against the reload signal. `run_event_loop` only
486    // resolves when the loop goes idle, but an app with a perpetual timer
487    // (e.g. a `setInterval` clock) never does — so without this the reload
488    // sentinel `op_next_event` returns would never reach us, the bundle would
489    // never re-execute, and nothing would re-park on `op_next_event` (the UI
490    // would freeze). `biased` polls the event loop first so it fully drains the
491    // pending microtasks (the prior `runEventLoop` returning) before we bail.
492    loop {
493        let loop_result = tokio::select! {
494            biased;
495            res = runtime.run_event_loop(Default::default()) => Some(res),
496            _ = reload_notify.notified() => None,
497        };
498        match loop_result {
499            // Woken by the reload notify (a real reload sets `reload_flag` before
500            // `notify_one`). If the flag is clear this is a *stale* permit: when a
501            // prior reload resolved via the event-loop branch above, `biased`
502            // drained the loop first and dropped this `notified()` future after it
503            // had been notified, which re-arms the permit. Ignore it and keep
504            // driving the loop, or we'd re-execute the app a second time.
505            None => {
506                if reload_flag.get() {
507                    return Pumped::Reload;
508                }
509            }
510            Some(Err(e)) => {
511                // Steady-state errors are caught inside the JS event loop, so this
512                // is rare; treat it like a reload so we rebuild rather than wedge.
513                // (Unhandled promise rejections used to land here too — the
514                // prelude's rejection handler now logs them instead.)
515                error!(target: "bevy_react::js", "event loop error: {e}");
516                crate::console_log::push(
517                    crate::console_log::Source::Rust,
518                    crate::console_log::Level::Error,
519                    &format!("JS event loop error: {e}"),
520                );
521                return Pumped::Reload;
522            }
523            // The loop went idle: a reload with no pending timers, or shutdown.
524            Some(Ok(())) => {
525                return if reload_flag.get() {
526                    Pumped::Reload
527                } else {
528                    Pumped::Shutdown
529                };
530            }
531        }
532    }
533}
534
535/// Read the app bundle from disk. Re-executing it in the live isolate (on a hot
536/// reload) is what drives Fast Refresh: the app IIFE re-registers its components
537/// and calls `mount()`, which — seeing the isolate already mounted — triggers
538/// `performReactRefresh()` and re-parks the event loop on `op_next_event`.
539fn read_app(app_path: &Path) -> anyhow::Result<String> {
540    std::fs::read_to_string(app_path)
541        .map_err(|e| anyhow::anyhow!("reading app {}: {e}", app_path.display()))
542}