bevy_react_core 0.7.0

The core bridge of bevy-react (drive bevy_ui from React over an embedded V8 runtime). Apps depend on the `bevy-react` crate.
//! Web JS host: React runs in the browser's own JS engine, so there is no V8
//! isolate, no thread, and no filesystem. We instead expose the op surface on
//! `globalThis.__bevyHost` (the same methods `bridge.ts` calls), back the JS→Bevy
//! ops with the shared crossbeam channels, and push Bevy→JS events from a per-frame
//! Bevy system.
//!
//! Boot order on web: the wasm module's entry point builds the `App` (this runs in
//! [`spawn`], installing `__bevyHost`) and starts Bevy; the HTML page then loads
//! `vendor.js` + `app.js`, which read `__bevyHost` already present on `globalThis`.

use std::cell::RefCell;
use std::collections::VecDeque;

use bevy::prelude::*;
use crossbeam_channel::{Receiver, Sender};
use js_sys::{Function, Object, Promise, Reflect};
use serde::Serialize;
use wasm_bindgen::prelude::*;

use crate::animations::AnimationCommand;

use crate::bridge::OutboundSender;
use crate::message::ReactMessage;
use crate::protocol::{op::Op, op::OpBatch, outbound::Outbound};
use crate::request::RawRequest;

use super::{FlushInfo, HostConfig, HostSenders};

/// Singleton host state. wasm is single-threaded, so a thread-local is the natural
/// home: the op closures (called from JS) and the drain system (a Bevy system on
/// the same thread) both reach it here without `Send` plumbing.
struct WebHost {
    // JS → Bevy: the same crossbeam senders every target uses.
    ops: Sender<Vec<Op>>,
    flush: Sender<FlushInfo>,
    emit: Sender<ReactMessage>,
    request: Sender<RawRequest>,
    anim: Sender<AnimationCommand>,
    // The feature-registry handoff (`crate::ext`): installed on this (the
    // only) thread before the first decode.
    ext: crate::ext::ExtRegistrySlot,
    // `op_flush` batches (raw JSON + devtools flag) that arrived before the
    // registry did. On wasm Bevy defers `Plugin::finish` (which fills the
    // slot) until the renderer's async setup lands, while the page may start
    // React right after `init()` — decoding those batches without the
    // registry would drop every feature-owned prop key for good. They are
    // held here, in order, and decoded once the slot is filled.
    held_flushes: VecDeque<(String, bool)>,
    // Bevy → JS: events waiting for a JS `op_next_event` caller, and the `resolve`
    // callbacks of callers parked on `op_next_event`. At most one side is ever
    // non-empty — an arriving event resolves a waiter immediately, else it queues.
    pending_events: VecDeque<JsValue>,
    waiters: VecDeque<Function>,
}

thread_local! {
    static HOST: RefCell<Option<WebHost>> = const { RefCell::new(None) };
}

/// Wire the web host into `app` and return the sender outbound producers use.
pub(crate) fn spawn(app: &mut App, config: HostConfig, senders: HostSenders) -> OutboundSender {
    console_error_panic_hook::set_once();

    // Bevy → JS over a crossbeam channel drained each frame (no async needed — the
    // page's engine runs React on the same thread).
    let (outbound_tx, outbound_rx) = crossbeam_channel::unbounded::<Outbound>();

    HOST.with(|h| {
        *h.borrow_mut() = Some(WebHost {
            ops: senders.ops,
            flush: senders.flush,
            emit: senders.emit,
            request: senders.request,
            anim: senders.anim,
            ext: config.ext,
            held_flushes: VecDeque::new(),
            pending_events: VecDeque::new(),
            waiters: VecDeque::new(),
        });
    });

    let host_obj = install_host_object();
    let global: JsValue = js_sys::global().into();
    Reflect::set(&global, &JsValue::from_str("__bevyHost"), &host_obj)
        .expect("install globalThis.__bevyHost");

    app.insert_resource(OutboundDrain(outbound_rx))
        .add_systems(First, release_held_flushes)
        .add_systems(Last, drain_outbound);

    outbound_tx
}

/// Build the `__bevyHost` object: the same five methods `bridge.ts` calls, each
/// backed by a Rust closure. The closures are leaked (`forget`) — the host lives
/// for the whole program, so there is nothing to free.
fn install_host_object() -> Object {
    let host = Object::new();

    // op_flush(json, devtools): JS → Bevy, a commit's worth of mutation ops as
    // ONE `JSON.stringify`ed batch (same wire as the native host — see
    // `js_thread::op_flush`), flagged with its origin (the panel's own container
    // vs the app). The flag is sent first so the aligned FIFOs never desync
    // (see `HostSenders::flush`). A structurally invalid batch throws into the JS call
    // (native semantics: `bridge.ts` isolates hand-built devtools edits on it);
    // value-level fallbacks only warn (see `crate::diag`).
    let flush = Closure::<dyn Fn(JsValue, JsValue)>::new(|json: JsValue, devtools: JsValue| {
        let Some(json) = json.as_string() else {
            wasm_bindgen::throw_str("op_flush: expected the op batch as a JSON string");
        };
        let devtools = devtools.as_bool().unwrap_or(false);
        // Hold the batch until the feature registry is installed (see
        // `WebHost::held_flushes`); a batch behind held ones waits too, so
        // decode order is commit order.
        if !registry_ready() || with_host(|h| !h.held_flushes.is_empty()) {
            with_host(|h| h.held_flushes.push_back((json, devtools)));
            release_held_flushes();
            return;
        }
        if let Err(e) = decode_and_send(&json, devtools) {
            wasm_bindgen::throw_str(&e);
        }
    });
    set_method(&host, "op_flush", flush.as_ref());
    flush.forget();

    // op_take_decode_warnings(): drain the invalid-value warnings collected while
    // decoding the most recent op_flush batch (same thread — wasm is
    // single-threaded). Called by the dev-only devtools bridge tap.
    let take_warnings = Closure::<dyn Fn() -> JsValue>::new(|| {
        serde_wasm_bindgen::to_value(&crate::diag::take_decode_warnings()).unwrap_or(JsValue::NULL)
    });
    set_method(&host, "op_take_decode_warnings", take_warnings.as_ref());
    take_warnings.forget();

    // op_emit(name, value): JS → Bevy, a named app message.
    let emit = Closure::<dyn Fn(JsValue, JsValue)>::new(|name: JsValue, value: JsValue| {
        let name = name.as_string().unwrap_or_default();
        let value = serde_wasm_bindgen::from_value(value).unwrap_or(serde_json::Value::Null);
        with_host(|h| {
            let _ = h.emit.send(ReactMessage { name, value });
        });
    });
    set_method(&host, "op_emit", emit.as_ref());
    emit.forget();

    // op_request(id, name, value): JS → Bevy, a correlated request. `id` is a BigInt.
    let request =
        Closure::<dyn Fn(JsValue, JsValue, JsValue)>::new(|id: JsValue, name: JsValue, value| {
            let id = bigint_to_u64(&id);
            let name = name.as_string().unwrap_or_default();
            let value = serde_wasm_bindgen::from_value(value).unwrap_or(serde_json::Value::Null);
            with_host(|h| {
                let _ = h.request.send(RawRequest { id, name, value });
            });
        });
    set_method(&host, "op_request", request.as_ref());
    request.forget();

    // op_animate(cmd): JS → Bevy, an animation command.
    let animate = Closure::<dyn Fn(JsValue)>::new(|cmd: JsValue| {
        match serde_wasm_bindgen::from_value::<AnimationCommand>(cmd) {
            Ok(cmd) => with_host(|h| {
                let _ = h.anim.send(cmd);
            }),
            Err(e) => error(&format!("op_animate decode: {e}")),
        }
    });
    set_method(&host, "op_animate", animate.as_ref());
    animate.forget();

    // op_next_event() -> Promise<Outbound | null>: Bevy → JS pull. Resolves from the
    // queue if an event is waiting, else parks until the drain system delivers one.
    let next = Closure::<dyn Fn() -> Promise>::new(next_event_promise);
    set_method(&host, "op_next_event", next.as_ref());
    next.forget();

    host
}

/// Install the feature registry on this thread once the plugin's slot is
/// filled; whether it is installed.
fn registry_ready() -> bool {
    if crate::ext::with_thread_registry(|r| r.is_some()) {
        return true;
    }
    match with_host(|h| h.ext.get()) {
        Some(registry) => {
            crate::ext::set_thread_registry(registry);
            true
        }
        None => false,
    }
}

/// Decode one `op_flush` batch and hand it to Bevy. A structurally invalid
/// batch is an `Err` (the live call throws it into JS; a held batch, whose
/// caller is gone, logs it).
fn decode_and_send(json: &str, devtools: bool) -> Result<(), String> {
    // `OpBatch` decodes exactly like `Vec<Op>` but stamps decode-fallback
    // warnings with their op's node id (see `crate::diag`).
    let batch =
        serde_json::from_str::<OpBatch>(json).map_err(|e| format!("op_flush decode: {e}"))?;
    with_host(|h| {
        let _ = h.flush.send(FlushInfo {
            sent: None,
            devtools,
        });
        let _ = h.ops.send(batch.0);
    });
    Ok(())
}

/// Decode the batches held while the registry was missing, in order, once it
/// is installed. Runs from `op_flush` and in `First` (so held batches land
/// even if React never flushes again), ahead of the op drain in `Update`.
fn release_held_flushes() {
    if !registry_ready() {
        return;
    }
    while let Some((json, devtools)) = with_host(|h| h.held_flushes.pop_front()) {
        if let Err(e) = decode_and_send(&json, devtools) {
            error(&e);
        }
    }
}

/// `op_next_event`: hand back the next queued event, or a Promise that the drain
/// system resolves when one arrives. The `Promise::new` executor runs synchronously,
/// so the `resolve` handle is captured before we return.
fn next_event_promise() -> Promise {
    HOST.with(|h| {
        let mut guard = h.borrow_mut();
        let host = guard.as_mut().expect("host installed before op_next_event");
        if let Some(ev) = host.pending_events.pop_front() {
            Promise::resolve(&ev)
        } else {
            let mut resolve_slot: Option<Function> = None;
            let promise = Promise::new(&mut |resolve, _reject| resolve_slot = Some(resolve));
            host.waiters
                .push_back(resolve_slot.expect("Promise executor runs synchronously"));
            promise
        }
    })
}

/// The Bevy → JS receiver, drained each frame in `Last`.
#[derive(Resource)]
struct OutboundDrain(Receiver<Outbound>);

/// Push every pending Bevy event to the JS side: resolve a parked `op_next_event`
/// caller if one exists, else queue the event for the next call. `resolve()` only
/// schedules a microtask, so React handlers run after this system returns (and the
/// resulting `op_flush` lands next frame) — no re-entrancy into the host borrow.
fn drain_outbound(drain: Res<OutboundDrain>) {
    while let Ok(ev) = drain.0.try_recv() {
        // serde_json `Value`s inside `Outbound` must become plain JS objects, not JS
        // `Map`s, or the bridge can't read them; u64 ids stay JS numbers (default).
        let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
        let js = match ev.serialize(&serializer) {
            Ok(v) => v,
            Err(e) => {
                error(&format!("outbound encode: {e}"));
                continue;
            }
        };
        // Pop a waiter (or queue) inside the borrow; call `resolve` after releasing it.
        let resolve = with_host(|h| match h.waiters.pop_front() {
            Some(resolve) => Some(resolve),
            None => {
                h.pending_events.push_back(js.clone());
                None
            }
        });
        if let Some(resolve) = resolve {
            let _ = resolve.call1(&JsValue::NULL, &js);
        }
    }
}

/// Run `f` with the installed host. The host is set in [`spawn`] before any op can
/// fire, so absence is a bug; we no-op rather than panic across the JS boundary.
fn with_host<R: Default>(f: impl FnOnce(&mut WebHost) -> R) -> R {
    HOST.with(|h| match h.borrow_mut().as_mut() {
        Some(host) => f(host),
        None => R::default(),
    })
}

/// Convert a JS `BigInt` (how `bridge.ts` passes request ids) to `u64` via its
/// base-10 string — ids stay well under 2^53 in practice.
fn bigint_to_u64(v: &JsValue) -> u64 {
    js_sys::BigInt::new(v)
        .ok()
        .and_then(|b| b.to_string(10).ok())
        .map(String::from)
        .and_then(|s| s.parse().ok())
        .unwrap_or(0)
}

fn set_method(host: &Object, name: &str, f: &JsValue) {
    Reflect::set(host.as_ref(), &JsValue::from_str(name), f).expect("set host method");
}

fn error(msg: &str) {
    web_sys::console::error_1(&JsValue::from_str(msg));
}