Skip to main content

bevy_react/protocol/
merge.rs

1//! The delta-merge engine: [`Props::merge_delta`] folds an update op into the
2//! cached props and reports the dirty groups; [`Props::split_events`] strips
3//! the act-now event fields.
4
5use super::props::{Props, PropsDirty, UpdateEvents};
6use super::style::{Style, StyleDirty};
7
8impl Props {
9    /// Iterate every present style slot: the base [`Self::style`] plus the
10    /// hover/press/focus variants, in that order. THE definition of "all
11    /// style slots" for presence-based unions (layer promotion's
12    /// opacity/filter reasons, the create-time layer-dirty seed) — a new
13    /// variant slot extends this once, not each call site.
14    pub fn all_styles(&self) -> impl Iterator<Item = &Style> {
15        [
16            &self.style,
17            &self.hover_style,
18            &self.press_style,
19            &self.focus_style,
20        ]
21        .into_iter()
22        .flatten()
23    }
24
25    /// Split the event-like fields (see [`UpdateEvents`]) out of `self`,
26    /// leaving the retained state. Used to seed the per-node props cache from
27    /// a create.
28    pub fn split_events(mut self) -> (Props, UpdateEvents) {
29        let events = UpdateEvents {
30            value: self.value.take(),
31            selection_start: self.selection_start.take(),
32            selection_end: self.selection_end.take(),
33            scroll_top: self.scroll_top.take(),
34            scroll_left: self.scroll_left.take(),
35            draw: self.draw.take(),
36        };
37        (self, events)
38    }
39
40    /// Merge an [`super::op::Op::Update`] delta (`props` + `unset` + `style_unset`) into
41    /// `self` (the retained last-applied props), returning what the delta
42    /// touched and the event-like fields to act on. See the semantics on
43    /// [`super::op::Op::Update`].
44    pub fn merge_delta(
45        &mut self,
46        delta: Props,
47        unset: &[String],
48        style_unset: &[String],
49    ) -> (PropsDirty, UpdateEvents) {
50        let mut dirty = PropsDirty::default();
51        let (delta, events) = delta.split_events();
52
53        // --- set: fields present in the delta ---
54        if let Some(style_delta) = &delta.style {
55            let groups = self
56                .style
57                .get_or_insert_default()
58                .overlay_delta(style_delta);
59            dirty.style.0 |= groups;
60        }
61        if delta.hover_style.is_some() {
62            self.hover_style = delta.hover_style;
63            dirty.hover_style = true;
64        }
65        if delta.press_style.is_some() {
66            self.press_style = delta.press_style;
67            dirty.press_style = true;
68        }
69        if delta.focus_style.is_some() {
70            self.focus_style = delta.focus_style;
71            dirty.focus_style = true;
72        }
73        // `shape` replaces ATOMICALLY (the variant-style precedent above),
74        // deliberately not field-wise like `style`: a shape change has a
75        // single Rust-side consequence — a full re-raster of the enclosing
76        // `<svg>` surface, with no per-field dirty groups to save — the
77        // object is small, and atomic replace handles JSX attr *removal*
78        // correctly by construction (JS sends the complete folded object
79        // whenever anything changed, so an attr absent from the new value is
80        // an attr removed, no `unset` bookkeeping needed). Compare-before-set
81        // keeps an idempotent re-send silent, like the rest of the delta.
82        if let Some(shape) = delta.shape
83            && self.shape.as_ref() != Some(&shape)
84        {
85            self.shape = Some(shape);
86            dirty.shape = true;
87        }
88        if let Some(view_box) = delta.view_box
89            && self.view_box != Some(view_box)
90        {
91            self.view_box = Some(view_box);
92            dirty.view_box = true;
93        }
94        // Handler/flag booleans: the delta only ever carries `true` (a handler
95        // appeared / a flag turned on); turning one off rides `unset`.
96        macro_rules! merge_bool {
97            ($($f:ident => $flag:ident),* $(,)?) => {
98                $(
99                    if delta.$f {
100                        self.$f = true;
101                        dirty.$flag = true;
102                    }
103                )*
104            };
105        }
106        merge_bool!(
107            on_click => pointer,
108            on_pointer_down => pointer,
109            on_pointer_move => pointer,
110            on_pointer_up => pointer,
111            on_pointer_enter => pointer,
112            on_pointer_leave => pointer,
113            on_scroll => scroll_listener,
114            on_wheel => wheel,
115            on_change => editable_handlers,
116            on_select => editable_handlers,
117            on_focus => editable_handlers,
118            on_blur => editable_handlers,
119            flip_x => image,
120            flip_y => image,
121        );
122        // `multiline`/`autofocus` are create-time only; keep the cache true to
123        // the props but no apply work keys off them.
124        if delta.multiline {
125            self.multiline = true;
126        }
127        if delta.autofocus {
128            self.autofocus = true;
129        }
130        // `onResize` gates nothing Rust-side (resize events are unconditional);
131        // cached only so the delta stays truthful.
132        if delta.on_resize {
133            self.on_resize = true;
134        }
135        macro_rules! merge_option {
136            ($($f:ident => $($flag:ident)?),* $(,)?) => {
137                $(
138                    if delta.$f.is_some() {
139                        self.$f = delta.$f;
140                        $( dirty.$flag = true; )?
141                    }
142                )*
143            };
144        }
145        merge_option!(
146            scroll_step => scroll_step,
147            anchor => anchor,
148            src => image,
149            tint => image,
150            image_mode => image,
151            source_rect => image,
152            atlas => image,
153            visual_box => image,
154            target => target,
155            aria_label => aria_label,
156            max_length => , // create-time only, cached for completeness
157        );
158
159        // --- unset: wire names reset to their defaults ---
160        for name in unset {
161            match name.as_str() {
162                "style" => {
163                    self.style = None;
164                    dirty.style = StyleDirty::ALL;
165                }
166                "hoverStyle" => {
167                    self.hover_style = None;
168                    dirty.hover_style = true;
169                }
170                "pressStyle" => {
171                    self.press_style = None;
172                    dirty.press_style = true;
173                }
174                "focusStyle" => {
175                    self.focus_style = None;
176                    dirty.focus_style = true;
177                }
178                "onClick" => {
179                    self.on_click = false;
180                    dirty.pointer = true;
181                }
182                "onPointerDown" => {
183                    self.on_pointer_down = false;
184                    dirty.pointer = true;
185                }
186                "onPointerMove" => {
187                    self.on_pointer_move = false;
188                    dirty.pointer = true;
189                }
190                "onPointerUp" => {
191                    self.on_pointer_up = false;
192                    dirty.pointer = true;
193                }
194                "onPointerEnter" => {
195                    self.on_pointer_enter = false;
196                    dirty.pointer = true;
197                }
198                "onPointerLeave" => {
199                    self.on_pointer_leave = false;
200                    dirty.pointer = true;
201                }
202                "onScroll" => {
203                    self.on_scroll = false;
204                    dirty.scroll_listener = true;
205                }
206                "onWheel" => {
207                    self.on_wheel = false;
208                    dirty.wheel = true;
209                }
210                "onChange" => {
211                    self.on_change = false;
212                    dirty.editable_handlers = true;
213                }
214                "onSelect" => {
215                    self.on_select = false;
216                    dirty.editable_handlers = true;
217                }
218                "onFocus" => {
219                    self.on_focus = false;
220                    dirty.editable_handlers = true;
221                }
222                "onBlur" => {
223                    self.on_blur = false;
224                    dirty.editable_handlers = true;
225                }
226                "flipX" => {
227                    self.flip_x = false;
228                    dirty.image = true;
229                }
230                "flipY" => {
231                    self.flip_y = false;
232                    dirty.image = true;
233                }
234                "multiline" => self.multiline = false,
235                "autofocus" => self.autofocus = false,
236                "onResize" => self.on_resize = false,
237                "scrollStep" => {
238                    self.scroll_step = None;
239                    dirty.scroll_step = true;
240                }
241                "anchor" => {
242                    self.anchor = None;
243                    dirty.anchor = true;
244                }
245                "src" => {
246                    self.src = None;
247                    dirty.image = true;
248                }
249                "tint" => {
250                    self.tint = None;
251                    dirty.image = true;
252                }
253                "imageMode" => {
254                    self.image_mode = None;
255                    dirty.image = true;
256                }
257                "sourceRect" => {
258                    self.source_rect = None;
259                    dirty.image = true;
260                }
261                "atlas" => {
262                    self.atlas = None;
263                    dirty.image = true;
264                }
265                "visualBox" => {
266                    self.visual_box = None;
267                    dirty.image = true;
268                }
269                "target" => {
270                    self.target = None;
271                    dirty.target = true;
272                }
273                "shape" => {
274                    self.shape = None;
275                    dirty.shape = true;
276                }
277                "viewBox" => {
278                    self.view_box = None;
279                    dirty.view_box = true;
280                }
281                "ariaLabel" => {
282                    self.aria_label = None;
283                    dirty.aria_label = true;
284                }
285                "maxLength" => self.max_length = None,
286                // Event-like props have no retained state to unset; dropping
287                // the prop simply stops producing events.
288                "value" | "selectionStart" | "selectionEnd" | "scrollTop" | "scrollLeft"
289                | "draw" => {
290                    tracing::warn!(
291                        target: "bevy_react",
292                        "event-like prop {name:?} in unset; nothing to reset"
293                    );
294                }
295                other => {
296                    tracing::warn!(
297                        target: "bevy_react",
298                        "unknown prop {other:?} in unset; ignoring"
299                    );
300                }
301            }
302        }
303
304        // --- style_unset: after the overlay, so a (never-emitted) set+unset of
305        // the same field resolves to unset ---
306        if !style_unset.is_empty() {
307            let style = self.style.get_or_insert_default();
308            for name in style_unset {
309                if let Some(groups) = style.unset_field(name) {
310                    dirty.style.0 |= groups;
311                }
312            }
313        }
314
315        (dirty, events)
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::protocol::animatable::AnimatableField;
323    use crate::protocol::props::{Props, props_from_json as props};
324    use crate::protocol::style::style_groups;
325    use crate::protocol::units::Length;
326    use crate::svg::ViewBox;
327
328    /// A delta sets exactly the supplied fields; everything else is preserved.
329    #[test]
330    fn merge_delta_sets_and_preserves() {
331        let mut cached = props(serde_json::json!({
332            "style": { "backgroundColor": "red", "outline": { "color": "white" } },
333            "hoverStyle": { "backgroundColor": "blue" },
334            "onClick": true,
335            "src": "a.png",
336        }));
337        let (dirty, ev) = cached.merge_delta(
338            props(serde_json::json!({ "style": { "width": 100 } })),
339            &[],
340            &[],
341        );
342
343        let style = cached.style.as_ref().unwrap();
344        assert_eq!(style.width.static_val(), Some(Length::Px(100.0)));
345        assert_eq!(
346            style.background_color.static_ref().map(String::as_str),
347            Some("red")
348        );
349        assert!(style.outline.is_some(), "untouched style fields preserved");
350        assert!(cached.hover_style.is_some(), "untouched props preserved");
351        assert!(cached.on_click);
352        assert_eq!(cached.src.as_deref(), Some("a.png"));
353
354        assert!(dirty.style.intersects(style_groups::LAYOUT));
355        assert!(
356            !dirty
357                .style
358                .intersects(style_groups::BACKGROUND | style_groups::OUTLINE),
359            "untouched groups must stay clean"
360        );
361        assert!(!dirty.hover_style && !dirty.pointer && !dirty.image);
362        // `width` is a transitioned channel, so the transition group re-arms.
363        assert!(dirty.style.intersects(style_groups::TRANSITION));
364        assert!(ev.value.is_none() && ev.draw.is_none());
365    }
366
367    /// `unset` resets props (bools to false, options to None); `style_unset`
368    /// clears style fields — even when the delta carries no `style` object.
369    #[test]
370    fn merge_delta_unsets() {
371        let mut cached = props(serde_json::json!({
372            "style": { "backgroundColor": "red", "width": 50 },
373            "hoverStyle": { "backgroundColor": "blue" },
374            "onClick": true,
375        }));
376        let (dirty, _) = cached.merge_delta(
377            Props::default(),
378            &["hoverStyle".into(), "onClick".into()],
379            &["backgroundColor".into()],
380        );
381
382        let style = cached.style.as_ref().unwrap();
383        assert_eq!(style.background_color, None);
384        assert_eq!(
385            style.width.static_val(),
386            Some(Length::Px(50.0)),
387            "other style fields kept"
388        );
389        assert!(cached.hover_style.is_none());
390        assert!(!cached.on_click);
391        assert!(dirty.style.intersects(style_groups::BACKGROUND));
392        assert!(!dirty.style.intersects(style_groups::LAYOUT));
393        assert!(dirty.hover_style && dirty.pointer);
394        assert!(dirty.any_style_variant());
395    }
396
397    /// The bool-flag contract the JS diff relies on (bridge.ts
398    /// `BOOL_PROP_KEYS`): a plain-`bool` field can't distinguish an explicit
399    /// `false` from absent on the wire, so a `false` in the delta is a no-op —
400    /// turning a flag off must ride `unset`, which resets it and dirties its
401    /// group.
402    #[test]
403    fn merge_delta_bool_false_is_noop_off_rides_unset() {
404        let mut cached = props(serde_json::json!({ "flipX": true, "flipY": true }));
405
406        // `{"flipX": false}` decodes identically to an absent field: no-op.
407        let (dirty, _) = cached.merge_delta(props(serde_json::json!({ "flipX": false })), &[], &[]);
408        assert!(cached.flip_x, "explicit false in a delta must not clear");
409        assert!(!dirty.image);
410
411        // The off path: `unset` resets the flag and dirties the image group.
412        let (dirty, _) = cached.merge_delta(Props::default(), &["flipX".into()], &[]);
413        assert!(!cached.flip_x);
414        assert!(cached.flip_y, "sibling flag untouched");
415        assert!(dirty.image);
416    }
417
418    /// `"style"` in `unset` drops the whole style and dirties every group.
419    #[test]
420    fn merge_delta_unsets_style_wholesale() {
421        let mut cached = props(serde_json::json!({
422            "style": { "backgroundColor": "red", "width": 50 },
423        }));
424        let (dirty, _) = cached.merge_delta(Props::default(), &["style".into()], &[]);
425        assert!(cached.style.is_none());
426        assert_eq!(dirty.style, StyleDirty::ALL);
427    }
428
429    /// Event-like fields ride out through `UpdateEvents` and are never retained.
430    #[test]
431    fn merge_delta_events_not_cached() {
432        let mut cached = Props::default();
433        let (dirty, ev) = cached.merge_delta(
434            props(serde_json::json!({
435                "value": "hi", "selectionStart": 1, "selectionEnd": 3,
436                "scrollTop": 40.0, "scrollLeft": 2.0,
437            })),
438            &[],
439            &[],
440        );
441        assert_eq!(ev.value.as_deref(), Some("hi"));
442        assert_eq!((ev.selection_start, ev.selection_end), (Some(1), Some(3)));
443        assert_eq!((ev.scroll_top, ev.scroll_left), (Some(40.0), Some(2.0)));
444        assert!(cached.value.is_none() && cached.scroll_top.is_none());
445        assert!(cached.selection_start.is_none());
446        // Event fields alone dirty nothing.
447        assert!(!dirty.style.any() && !dirty.image && !dirty.anchor);
448    }
449
450    /// Variant styles replace atomically: a delta `hoverStyle` is the whole new
451    /// value, not a merge into the previous one.
452    #[test]
453    fn merge_delta_replaces_variants_atomically() {
454        let mut cached = props(serde_json::json!({
455            "hoverStyle": { "backgroundColor": "blue", "width": 10 },
456        }));
457        let (dirty, _) = cached.merge_delta(
458            props(serde_json::json!({ "hoverStyle": { "outline": { "color": "white" } } })),
459            &[],
460            &[],
461        );
462        let hover = cached.hover_style.as_ref().unwrap();
463        assert!(hover.outline.is_some());
464        assert_eq!(hover.background_color, None, "atomic replace, not a merge");
465        assert_eq!(hover.width, None);
466        assert!(dirty.hover_style);
467    }
468
469    /// `shape` replaces atomically — the delta value is the whole new object,
470    /// so an attr absent from it is an attr removed (the amended-C1 semantics:
471    /// NOT a field-wise merge like `style`) — while an identical re-send stays
472    /// silent, and `"shape"` in `unset` clears it.
473    #[test]
474    fn merge_delta_replaces_shape_atomically() {
475        let mut cached = props(serde_json::json!({ "shape": { "cx": 5, "r": 2 } }));
476        let (dirty, _) =
477            cached.merge_delta(props(serde_json::json!({ "shape": { "cx": 9 } })), &[], &[]);
478        let shape = cached.shape.as_ref().unwrap();
479        assert_eq!(shape.cx.static_val(), Some(9.0));
480        assert_eq!(shape.r, None, "atomic replace: the absent attr is removed");
481        assert!(dirty.shape);
482
483        // Idempotent re-send: compare-before-set keeps the delta silent.
484        let (dirty, _) =
485            cached.merge_delta(props(serde_json::json!({ "shape": { "cx": 9 } })), &[], &[]);
486        assert!(!dirty.shape, "an identical shape re-send must not dirty");
487
488        let (dirty, _) = cached.merge_delta(Props::default(), &["shape".into()], &[]);
489        assert!(cached.shape.is_none());
490        assert!(dirty.shape);
491    }
492
493    /// The `viewBox` wire name (camelCase of `view_box`, pinned here) decodes
494    /// into `Props::view_box`; merge dirties on change only, and `"viewBox"`
495    /// in `unset` clears it.
496    #[test]
497    fn merge_delta_view_box_wire_name_set_and_unset() {
498        let mut cached = Props::default();
499        let (dirty, _) = cached.merge_delta(
500            props(serde_json::json!({ "viewBox": "0 0 100 50" })),
501            &[],
502            &[],
503        );
504        assert_eq!(
505            cached.view_box,
506            Some(ViewBox {
507                min: bevy::math::Vec2::ZERO,
508                size: bevy::math::Vec2::new(100.0, 50.0),
509            }),
510            "the camelCase `viewBox` wire name must land in `view_box`"
511        );
512        assert!(dirty.view_box);
513
514        let (dirty, _) = cached.merge_delta(
515            props(serde_json::json!({ "viewBox": "0 0 100 50" })),
516            &[],
517            &[],
518        );
519        assert!(
520            !dirty.view_box,
521            "an identical viewBox re-send must not dirty"
522        );
523
524        let (dirty, _) = cached.merge_delta(Props::default(), &["viewBox".into()], &[]);
525        assert!(cached.view_box.is_none());
526        assert!(dirty.view_box);
527    }
528
529    /// `shape`/`viewBox` are retained state, not act-now events: they survive
530    /// `split_events` untouched.
531    #[test]
532    fn shape_and_view_box_are_retained_not_events() {
533        let p = props(serde_json::json!({
534            "shape": { "cx": 1 },
535            "viewBox": "0 0 10 10",
536        }));
537        let (retained, ev) = p.split_events();
538        assert!(retained.shape.is_some() && retained.view_box.is_some());
539        assert!(ev.value.is_none() && ev.draw.is_none());
540    }
541
542    /// Unknown names in `unset`/`style_unset` warn and are ignored — a delta
543    /// from a newer/older bundle must never panic the op drain.
544    #[test]
545    fn merge_delta_ignores_unknown_names() {
546        let mut cached = props(serde_json::json!({ "style": { "width": 10 } }));
547        let (dirty, _) = cached.merge_delta(
548            Props::default(),
549            &["nope".into(), "value".into()],
550            &["alsoNope".into()],
551        );
552        assert_eq!(
553            cached.style.as_ref().unwrap().width.static_val(),
554            Some(Length::Px(10.0))
555        );
556        assert!(!dirty.style.any());
557    }
558
559    /// Two sequential deltas converge to the same state as one combined delta.
560    #[test]
561    fn merge_delta_converges() {
562        let base = serde_json::json!({
563            "style": { "backgroundColor": "red", "width": 10 }, "onClick": true,
564        });
565        let mut two_steps = props(base.clone());
566        two_steps.merge_delta(
567            props(serde_json::json!({ "style": { "width": 20 } })),
568            &[],
569            &[],
570        );
571        two_steps.merge_delta(
572            props(serde_json::json!({ "style": { "height": 5 } })),
573            &[],
574            &["backgroundColor".into()],
575        );
576
577        let mut one_step = props(base);
578        one_step.merge_delta(
579            props(serde_json::json!({ "style": { "width": 20, "height": 5 } })),
580            &[],
581            &["backgroundColor".into()],
582        );
583
584        let a = two_steps.style.as_ref().unwrap();
585        let b = one_step.style.as_ref().unwrap();
586        assert_eq!(a.width, b.width);
587        assert_eq!(a.height, b.height);
588        assert_eq!(a.background_color, b.background_color);
589        assert!(two_steps.on_click && one_step.on_click);
590    }
591
592    /// `split_events` strips exactly the event-like fields, leaving state.
593    #[test]
594    fn split_events_strips_event_fields() {
595        let full = props(serde_json::json!({
596            "style": { "width": 10 }, "onClick": true, "value": "v",
597            "selectionStart": 0, "selectionEnd": 1, "scrollTop": 5.0,
598        }));
599        let (state, ev) = full.split_events();
600        assert!(state.style.is_some() && state.on_click);
601        assert!(state.value.is_none() && state.selection_start.is_none());
602        assert!(state.scroll_top.is_none());
603        assert_eq!(ev.value.as_deref(), Some("v"));
604        assert_eq!(ev.scroll_top, Some(5.0));
605    }
606
607    /// `onResize` decodes, merges into the cache, and unsets without warning —
608    /// it gates nothing Rust-side, so it dirties nothing.
609    #[test]
610    fn merge_delta_on_resize_flag() {
611        let mut cached = Props::default();
612        let (dirty, _) =
613            cached.merge_delta(props(serde_json::json!({ "onResize": true })), &[], &[]);
614        assert!(cached.on_resize);
615        assert!(!dirty.pointer && !dirty.scroll_listener);
616        cached.merge_delta(Props::default(), &["onResize".into()], &[]);
617        assert!(!cached.on_resize);
618    }
619
620    /// `onWheel` sets the `wheel` dirty flag on appearance and clears it on `unset`,
621    /// independent of the scroll flags.
622    #[test]
623    fn merge_delta_wheel_flag() {
624        let mut cached = Props::default();
625        let (dirty, _) =
626            cached.merge_delta(props(serde_json::json!({ "onWheel": true })), &[], &[]);
627        assert!(cached.on_wheel);
628        assert!(dirty.wheel);
629        assert!(!dirty.pointer && !dirty.scroll_listener);
630
631        let (dirty, _) = cached.merge_delta(Props::default(), &["onWheel".into()], &[]);
632        assert!(!cached.on_wheel);
633        assert!(dirty.wheel);
634    }
635
636    /// A `cursor` delta sets the `CURSOR` dirty group; a `style` unset of it clears
637    /// the field and re-arms the group.
638    #[test]
639    fn merge_delta_cursor_group() {
640        let mut cached = Props::default();
641        let (dirty, _) = cached.merge_delta(
642            props(serde_json::json!({ "style": { "cursor": "pointer" } })),
643            &[],
644            &[],
645        );
646        assert_eq!(
647            cached.style.as_ref().unwrap().cursor.as_deref(),
648            Some("pointer")
649        );
650        assert!(dirty.style.intersects(style_groups::CURSOR));
651        assert!(!dirty.style.intersects(style_groups::LAYOUT));
652
653        let (dirty, _) = cached.merge_delta(Props::default(), &[], &["cursor".into()]);
654        assert_eq!(cached.style.as_ref().unwrap().cursor, None);
655        assert!(dirty.style.intersects(style_groups::CURSOR));
656    }
657
658    /// The delta merge marks the `BG_IMAGE` group; `styleUnset` clears the
659    /// field and returns the same bit.
660    #[test]
661    fn merge_delta_background_image_group() {
662        let mut cached = Props::default();
663        let (dirty, _) = cached.merge_delta(
664            props(serde_json::json!({
665                "style": { "backgroundImage": { "src": "bg.png", "mode": "repeat" } }
666            })),
667            &[],
668            &[],
669        );
670        assert!(cached.style.as_ref().unwrap().background_image.is_some());
671        assert!(dirty.style.intersects(style_groups::BG_IMAGE));
672        assert!(!dirty.style.intersects(style_groups::LAYOUT));
673
674        let (dirty, _) = cached.merge_delta(Props::default(), &[], &["backgroundImage".into()]);
675        assert!(cached.style.as_ref().unwrap().background_image.is_none());
676        assert!(dirty.style.intersects(style_groups::BG_IMAGE));
677    }
678}