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}