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}