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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
//! Typed app messages emitted by the React app for the Bevy world to consume.
//!
//! This is the complement to the UI bridge: where ops describe UI mutations, an
//! app message carries an app-level signal (e.g. "set the count") from React
//! into the ECS. The JS side calls `emit(name, value)`; the plugin owns a single
//! consumption point that routes each message by name to the typed payload the
//! user registered with [`ReactAppExt::add_react_handler`], deserializing the
//! JSON for them and triggering it for [observers](bevy::ecs::observer) to handle.
//!
//! ```ignore
//! use bevy::prelude::*;
//! use bevy_react_core::{react_message, ReactAppExt};
//!
//! #[react_message]
//! struct Count(usize); // name defaults to "count"
//!
//! app.add_react_handler(|on: On<Count>| {
//!     let n = on.event().0; // typed — no serde_json::Value juggling
//! });
//! ```

use std::any::TypeId;
use std::collections::HashMap;
use std::path::Path;

use bevy::ecs::system::IntoObserverSystem;
use bevy::prelude::*;
use serde::de::DeserializeOwned;
use ts_rs::TS;

use crate::event::{ReactEvent, ReactEventRegistry};
use crate::filters::{FilterRegistry, ReactFilter, ReactMorphFilter};
use crate::registry::{NamedEntry, register_entry};
use crate::request::{ReactRequest, ReactRequestRegistry, RequestEvent};
use crate::ts_codegen::TsCollector;

/// A named, JSON-valued signal sent from the React app to Bevy.
///
/// This is the raw wire form carried across the JS↔Bevy channel. Consumers
/// don't read it directly: register a typed [`ReactPayload`] and observe that
/// instead. The plugin deserializes the [`value`](ReactMessage::value) into the
/// payload type whose [`ReactPayload::NAME`] matches [`name`](ReactMessage::name).
#[derive(Clone, Debug)]
pub struct ReactMessage {
    /// Application-defined message name (the first argument to `emit`).
    pub name: String,
    /// The payload (the second argument to `emit`), as JSON.
    pub value: serde_json::Value,
}

/// A typed payload a React `emit(NAME, value)` call deserializes into.
///
/// Usually you don't implement this by hand — apply [`#[react_message]`](crate::react_message),
/// which derives `Deserialize` and `TS` and implements both `Event` and this trait. The
/// JSON `value` is deserialized straight into `Self`, so the payload's shape must
/// match what JS emits: `emit("count", 5)` needs a payload that deserializes from
/// a number (e.g. `struct Count(usize)`), while `emit("move", { x, y })` needs a struct.
///
/// The [`TS`] bound lets [`ReactAppExt::export_react_typescript`] mirror the payload's
/// shape into a TypeScript type, so the JS `emit` is type-checked against the same struct.
pub trait ReactPayload: Event + DeserializeOwned + TS + Send + Sync + 'static {
    /// The `emit` name this type is routed from.
    const NAME: &'static str;
}

/// Type-erased deserialize-and-trigger closures keyed by `emit` name. Owned by
/// the plugin; the single dispatch system looks up each incoming message here.
/// The [`TypeId`] lets us treat re-registering the *same* payload (e.g. attaching
/// several observers via [`ReactAppExt::add_react_handler`]) as a harmless no-op,
/// while still warning when two *different* types claim one name.
#[derive(Resource, Default)]
pub(crate) struct ReactRegistry {
    pub(crate) handlers: HashMap<&'static str, Registration>,
}

/// What we record per registered payload: the dispatch closure plus the TypeScript
/// metadata [`ReactAppExt::export_react_typescript`] needs to mirror the type.
pub(crate) struct Registration {
    /// Distinguishes re-registering the same type (a no-op) from a name collision.
    type_id: TypeId,
    /// Deserialize-and-trigger for this payload.
    handler: Handler,
    /// The payload's TypeScript reference name (e.g. `Count`), used in the message map.
    pub(crate) ts_name: fn() -> String,
    /// Records this payload's declaration and all its transitive dependencies.
    pub(crate) ts_collect: fn(&mut TsCollector),
}

/// Deserializes a JSON payload and queues a trigger for it, or returns the serde
/// error if the JSON doesn't match the registered payload type.
type Handler =
    Box<dyn Fn(serde_json::Value, &mut Commands) -> Result<(), serde_json::Error> + Send + Sync>;

impl NamedEntry for Registration {
    fn type_id(&self) -> TypeId {
        self.type_id
    }
}

impl ReactRegistry {
    /// Register the deserialize-and-trigger handler for payload `T`. Idempotent
    /// for a given type; warns only if a different type already owns `T::NAME`.
    pub(crate) fn register<T>(&mut self)
    where
        T: ReactPayload,
        for<'a> <T as Event>::Trigger<'a>: Default,
    {
        register_entry(
            &mut self.handlers,
            T::NAME,
            "message",
            Registration {
                type_id: TypeId::of::<T>(),
                handler: Box::new(|value, commands| {
                    // `T` is concrete here, so serde and the trigger are baked in.
                    let payload: T = serde_json::from_value(value)?;
                    commands.trigger(payload);
                    Ok(())
                }),
                // `T` is concrete here too, so its TS shape is baked into these fns.
                ts_name: T::name,
                ts_collect: |c| c.add::<T>(),
            },
        );
    }

    /// Route one message: deserialize into its registered payload and trigger it.
    /// Logs a warning for an unregistered name and an error for malformed JSON.
    pub(crate) fn dispatch(&self, msg: ReactMessage, commands: &mut Commands) {
        match self.handlers.get(msg.name.as_str()) {
            None => tracing::warn!("no handler registered for react message {:?}", msg.name),
            Some(reg) => {
                if let Err(e) = (reg.handler)(msg.value, commands) {
                    tracing::error!("malformed react message {:?}: {e}", msg.name);
                }
            }
        }
    }
}

/// Registers typed React message payloads on a Bevy [`App`].
pub trait ReactAppExt {
    /// Register an element kind — its attributes, writers, and events with
    /// it (see [`crate::element`]). The core registers its own elements
    /// through this same call. Panics on a name already registered or a
    /// malformed declaration.
    fn add_react_element(&mut self, element: &'static crate::element::Element) -> &mut Self;

    /// [`add_react_element`](Self::add_react_element) for several elements.
    fn add_react_elements(&mut self, elements: &[&'static crate::element::Element]) -> &mut Self;

    /// Register a style property: `property.name` in any `style` object
    /// decodes through its codec (see [`crate::style`]). The core registers
    /// its own properties through this same call. Panics on a name already
    /// registered.
    fn add_react_style(
        &mut self,
        property: &'static dyn crate::style::AnyStyleProperty,
    ) -> &mut Self;

    /// [`add_react_style`](Self::add_react_style) for several properties.
    fn add_react_styles(
        &mut self,
        properties: &[&'static dyn crate::style::AnyStyleProperty],
    ) -> &mut Self;

    /// Register a style writer: the code that turns the properties it reads
    /// into the components it writes (see [`crate::style::Writer`]). The core
    /// registers its own writers through this same call. Panics on a writer
    /// registered twice or a component another writer already writes.
    fn add_react_style_writer(&mut self, writer: &'static crate::style::Writer) -> &mut Self;

    /// [`add_react_style_writer`](Self::add_react_style_writer) for several
    /// writers, in apply order.
    fn add_react_style_writers(&mut self, writers: &[&'static crate::style::Writer]) -> &mut Self;

    /// Register a typed React message payload without attaching an observer.
    ///
    /// After this, an `emit(T::NAME, value)` from the React app deserializes
    /// `value` into `T` and triggers it. Prefer [`add_react_handler`](Self::add_react_handler)
    /// unless you want to register the type and observe it separately.
    fn add_react_message<T>(&mut self) -> &mut Self
    where
        T: ReactPayload,
        for<'a> <T as Event>::Trigger<'a>: Default;

    /// Register a payload and attach an observer for it in one call.
    ///
    /// The payload type is inferred from the observer's `On<T>` parameter, so you
    /// never name it twice. Call it again with another observer to add more
    /// handlers for the same message — registration is idempotent.
    ///
    /// ```ignore
    /// app.add_react_handler(|count: On<Count>, mut desired: ResMut<DesiredCubes>| {
    ///     desired.0 = count.event().0;
    /// });
    /// ```
    fn add_react_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
    where
        E: ReactPayload,
        for<'a> <E as Event>::Trigger<'a>: Default,
        B: Bundle,
        S: IntoObserverSystem<E, B, M>;

    /// Register a typed React request without attaching an observer. Prefer
    /// [`add_react_request_handler`](Self::add_react_request_handler).
    fn add_react_request<T>(&mut self) -> &mut Self
    where
        T: ReactRequest;

    /// Register a request and attach its observer in one call.
    ///
    /// The request type is inferred from the observer's `On<Request<T>>` parameter.
    /// The observer answers the request via [`Request::respond`](crate::Request::respond).
    ///
    /// ```ignore
    /// app.add_react_request_handler(|req: On<Request<BoardGet>>, board: Res<Board>| {
    ///     req.respond(board.clone());
    /// });
    /// ```
    fn add_react_request_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
    where
        E: Event + RequestEvent,
        for<'a> <E as Event>::Trigger<'a>: Default,
        B: Bundle,
        S: IntoObserverSystem<E, B, M>;

    /// Register a Bevy → React event type so it appears in the generated
    /// `ReactEvents` map and `bevy.on` typing. Sending an event with
    /// [`ReactEvents`](crate::ReactEvents) does not require this, but then the type
    /// won't be known to the exporter.
    fn add_react_event<E>(&mut self) -> &mut Self
    where
        E: ReactEvent;

    /// Register a custom **regular** filter type (usually a
    /// [`#[react_filter]`](crate::react_filter) struct) so the `filter` /
    /// `backdropFilter` style chains can resolve it by `T::NAME` — and so the
    /// exporter can mirror its params type into the generated TypeScript
    /// (`BevyFilters`). Two-input morph filters are a separate family — see
    /// [`add_react_morph_filter`](Self::add_react_morph_filter).
    ///
    /// The built-in filters (`blur`, `brightness`, …) are registered
    /// automatically by [`ReactUiPlugin`](crate::ReactUiPlugin); call this
    /// only for your own filters. Like events, keep the call in your single
    /// `register_bindings` site so the exporter path sees the same filters
    /// the running app does — a filter registered only at runtime never
    /// appears in the generated typing (see
    /// [`export_react_typescript`](Self::export_react_typescript)).
    ///
    /// A custom filter registered under a built-in's name shadows the
    /// built-in, before or after `ReactUiPlugin` is added (the plugin's
    /// built-ins never displace an existing registration) — the runtime and
    /// the exporter agree on the winner either way.
    fn add_react_filter<T>(&mut self) -> &mut Self
    where
        T: ReactFilter + DeserializeOwned + TS;

    /// Register a custom two-input **morph** filter type (usually a
    /// [`#[react_morph_filter]`](crate::react_morph_filter) struct) so the
    /// `morphFilter` style can resolve it by `T::NAME` — and so it lands in
    /// the generated `BevyMorphFilters` interface. Morph filters and regular
    /// filters are separate families: a morph name in a `filter` /
    /// `backdropFilter` chain (and vice versa) warns and is skipped at
    /// resolve time. Same single-registration-site and shadowing rules as
    /// [`add_react_filter`](Self::add_react_filter).
    fn add_react_morph_filter<T>(&mut self) -> &mut Self
    where
        T: ReactMorphFilter + DeserializeOwned + TS;

    /// Write a self-contained TypeScript module (conventionally `src/bevy.ts`)
    /// mirroring every registered React binding to `path`.
    ///
    /// The generated module covers all four typed surfaces in one pass: a type
    /// declaration per payload (mirrored from the `#[react_message]` /
    /// `#[react_request]` / `#[react_event]` structs and the filter params types
    /// via `ts-rs`), the `ReactMessages`/`ReactRequests`/`ReactEvents` name→type
    /// maps, a `declare module "bevy-react"` block augmenting the `BevyFilters`
    /// and `BevyMorphFilters` interfaces with every registered filter of the
    /// matching family (built-ins included, so the `filter`/`backdropFilter`
    /// and `morphFilter` style fields type each name's params), typed `emit`/`request`/
    /// `on` wrappers, and a structured `bevy` proxy whose nested methods come
    /// from dotted request names (`"board.get"` → `bevy.board.get()`).
    /// App code imports that typed surface from `./bevy` instead of the untyped
    /// functions from `"bevy-react"`, so every call is checked against the same
    /// structs Bevy serializes and deserializes.
    ///
    /// Keep a **single registration site**: put your `add_react_*` calls in a
    /// `register_bindings(app)` function that both the real app (e.g. your
    /// plugin's `build`) and a small exporter entry point call, so a binding can
    /// never exist at runtime without appearing in the generated types. Wire the
    /// exporter to a CLI flag that returns before `app.run()`, commit the output,
    /// and have CI regenerate + `git diff --exit-code` to guarantee the
    /// TypeScript never drifts from Rust. (See `examples/demos/main.rs` and its
    /// `--export-bindings` flag, exposed as `npm run bevy:generate`.)
    ///
    /// ```ignore
    /// if std::env::args().nth(1).as_deref() == Some("--export-bindings") {
    ///     let path = std::env::args().nth(2).expect("output path");
    ///     let mut app = App::new();
    ///     register_bindings(&mut app); // the same fn the real app calls
    ///     app.export_react_typescript(&path)?;
    ///     return;
    /// }
    /// ```
    fn export_react_typescript(&self, path: impl AsRef<Path>) -> std::io::Result<()>;
}

impl ReactAppExt for App {
    fn add_react_element(&mut self, element: &'static crate::element::Element) -> &mut Self {
        self.world_mut()
            .get_resource_or_init::<crate::ext::ExtRegistry>()
            .add_element(element);
        self
    }

    fn add_react_elements(&mut self, elements: &[&'static crate::element::Element]) -> &mut Self {
        let mut registry = self
            .world_mut()
            .get_resource_or_init::<crate::ext::ExtRegistry>();
        for element in elements {
            registry.add_element(element);
        }
        self
    }

    fn add_react_style(
        &mut self,
        property: &'static dyn crate::style::AnyStyleProperty,
    ) -> &mut Self {
        self.world_mut()
            .get_resource_or_init::<crate::ext::ExtRegistry>()
            .add_style(property);
        self
    }

    fn add_react_styles(
        &mut self,
        properties: &[&'static dyn crate::style::AnyStyleProperty],
    ) -> &mut Self {
        let mut registry = self
            .world_mut()
            .get_resource_or_init::<crate::ext::ExtRegistry>();
        for property in properties {
            registry.add_style(*property);
        }
        self
    }

    fn add_react_style_writer(&mut self, writer: &'static crate::style::Writer) -> &mut Self {
        self.world_mut()
            .get_resource_or_init::<crate::ext::ExtRegistry>()
            .add_style_writer(writer);
        self
    }

    fn add_react_style_writers(&mut self, writers: &[&'static crate::style::Writer]) -> &mut Self {
        let mut registry = self
            .world_mut()
            .get_resource_or_init::<crate::ext::ExtRegistry>();
        for writer in writers {
            registry.add_style_writer(writer);
        }
        self
    }

    fn add_react_message<T>(&mut self) -> &mut Self
    where
        T: ReactPayload,
        for<'a> <T as Event>::Trigger<'a>: Default,
    {
        self.world_mut()
            .get_resource_or_init::<ReactRegistry>()
            .register::<T>();
        self
    }

    fn add_react_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
    where
        E: ReactPayload,
        for<'a> <E as Event>::Trigger<'a>: Default,
        B: Bundle,
        S: IntoObserverSystem<E, B, M>,
    {
        self.add_react_message::<E>();
        self.add_observer(observer);
        self
    }

    fn add_react_request<T>(&mut self) -> &mut Self
    where
        T: ReactRequest,
    {
        self.world_mut()
            .get_resource_or_init::<ReactRequestRegistry>()
            .register::<T>();
        self
    }

    fn add_react_request_handler<E, B, M, S>(&mut self, observer: S) -> &mut Self
    where
        E: Event + RequestEvent,
        for<'a> <E as Event>::Trigger<'a>: Default,
        B: Bundle,
        S: IntoObserverSystem<E, B, M>,
    {
        // `E` is `Request<T>`; register the underlying request type `T`.
        self.add_react_request::<E::Req>();
        self.add_observer(observer);
        self
    }

    fn add_react_event<E>(&mut self) -> &mut Self
    where
        E: ReactEvent,
    {
        self.world_mut()
            .get_resource_or_init::<ReactEventRegistry>()
            .register::<E>();
        self
    }

    fn add_react_filter<T>(&mut self) -> &mut Self
    where
        T: ReactFilter + DeserializeOwned + TS,
    {
        self.world_mut()
            .get_resource_or_init::<FilterRegistry>()
            .register::<T>();
        self
    }

    fn add_react_morph_filter<T>(&mut self) -> &mut Self
    where
        T: ReactMorphFilter + DeserializeOwned + TS,
    {
        // One registry serves both families; the entry's `is_morph` bit
        // (from `T::IS_MORPH`) is what separates them at resolve/codegen.
        self.world_mut()
            .get_resource_or_init::<FilterRegistry>()
            .register::<T>();
        self
    }

    fn export_react_typescript(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
        crate::ts_codegen::export(self.world(), path.as_ref())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::react_message;
    use bevy::ecs::world::CommandQueue;

    #[react_message]
    struct Count(usize);

    // Only used to assert their derived `NAME`, so their fields go unread.
    #[react_message(name = "hp")]
    #[allow(dead_code)]
    struct Health(u32);

    #[react_message]
    #[allow(dead_code)]
    struct PlayerScore(i64);

    #[derive(Resource, Default)]
    struct LastCount(usize);

    /// The macro defaults the name to the struct ident, first letter lowered, and
    /// honours an explicit override.
    #[test]
    fn derives_emit_name() {
        assert_eq!(Count::NAME, "count");
        assert_eq!(PlayerScore::NAME, "playerScore");
        assert_eq!(Health::NAME, "hp");
    }

    fn test_app() -> App {
        let mut app = App::new();
        app.init_resource::<LastCount>();
        // Single call registers the deserializer and attaches the observer.
        app.add_react_handler(|on: On<Count>, mut last: ResMut<LastCount>| last.0 = on.event().0);
        app
    }

    /// Run one message through the plugin's dispatch path, applying the trigger
    /// it queues so observers run before we assert.
    fn dispatch(app: &mut App, msg: ReactMessage) {
        app.world_mut()
            .resource_scope(|world, registry: Mut<ReactRegistry>| {
                let mut queue = CommandQueue::default();
                let mut commands = Commands::new(&mut queue, world);
                registry.dispatch(msg, &mut commands);
                queue.apply(world);
            });
    }

    /// A registered payload deserializes and reaches its observer.
    #[test]
    fn dispatches_to_observer() {
        let mut app = test_app();
        dispatch(
            &mut app,
            ReactMessage {
                name: "count".into(),
                value: serde_json::json!(3),
            },
        );
        assert_eq!(app.world().resource::<LastCount>().0, 3);
    }

    /// An unknown name and malformed JSON are tolerated (logged, not panicked).
    #[test]
    fn tolerates_unknown_and_malformed() {
        let mut app = test_app();
        dispatch(
            &mut app,
            ReactMessage {
                name: "nope".into(),
                value: serde_json::json!(1),
            },
        );
        dispatch(
            &mut app,
            ReactMessage {
                name: "count".into(),
                value: serde_json::json!("not a number"),
            },
        );
        // Neither message should have reached the observer.
        assert_eq!(app.world().resource::<LastCount>().0, 0);
    }
}