Skip to main content

i_slint_compiler/passes/
inject_debug_hooks.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4//! Hooks properties for live updates (and potentially inspection in the future).
5//!
6//! This pass runs once, early in compilation — right after the import passes but before any
7//! lowering or inlining. At that point elements match 1:1 to the code as written (and every
8//! element has exactly one debug entry).
9//!
10//! For each element the pass does two things:
11//!
12//! 1. **Wrap existing bindings** in a non-synthetic `Expression::DebugHook` so the editor can
13//!    read and override the live value.
14//!
15//! 2. **Materialize synthetic hooks** for every *unbound* settable property.
16//!    These are marked `synthetic: true` (and inserted with `priority = 0`).
17//!    The accessors in `object_tree.rs` treat synthetic hooks as "no binding", so later passes
18//!    must opt-in to seeing bindings with a synthetic hook.
19//!    Exception: transform properties are marked non-synthetic, as they must force the transform
20//!    pass to inject a wrapper element for the transform.
21
22use crate::expression_tree::Expression;
23use crate::langtype::PropertyLookupMode;
24use crate::object_tree::forward_inherited_expression::{
25    ForwardedReferenceCache, InheritedExpression, forward_inherited_expression,
26};
27use crate::object_tree::{self, Element, ElementDebugInfo, ElementRc, PropertyVisibility};
28use crate::symbol_counters::SymbolCounters;
29use std::rc::Rc;
30
31pub fn inject_debug_hooks(
32    root_components: &[Rc<object_tree::Component>],
33    random_state: &std::hash::RandomState,
34    symbol_counters: &SymbolCounters,
35    forwarded_references: &mut ForwardedReferenceCache,
36) {
37    for component in root_components {
38        let root = component.root_element.clone();
39        object_tree::recurse_elem(&root, &(), &mut |element, &()| {
40            process_existing_bindings(element, random_state);
41        });
42    }
43
44    // Process the missing bindings after the existing bindings.
45    // Injecting missing bindings will insert new forwarding bindings & properties.
46    // These should not be hooked as "existing", which is why we need to insert them
47    // after all existing bindings are processed.
48    for component in root_components {
49        let root = component.root_element.clone();
50        object_tree::recurse_elem(&root, &(), &mut |element, &()| {
51            let is_root = Rc::ptr_eq(element, &root);
52            process_missing_bindings(element, symbol_counters, forwarded_references, is_root);
53        });
54    }
55}
56
57pub fn property_id(element_id: u64, name: &smol_str::SmolStr) -> smol_str::SmolStr {
58    smol_str::format_smolstr!("?{element_id}-{name}")
59}
60
61/// Guard rail, run near the end of the passes when debug hooks are enabled: every synthetic
62/// hook that survived must sit on a property that actually exists at runtime (native,
63/// declared, or materialized). An orphan would make the interpreter abort at instantiation
64/// with "unknown property ..." — catch it at compile time with a source location instead.
65///
66/// A property still needing materialization at this point (`should_materialize` returns
67/// `Some`) is exactly such an orphan.
68pub fn validate_no_orphan_synthetic_hooks(component: &std::rc::Rc<object_tree::Component>) {
69    if !cfg!(debug_assertions) {
70        return;
71    }
72    object_tree::recurse_elem_including_sub_components_no_borrow(
73        component,
74        &(),
75        &mut |elem, &()| {
76            let elem = elem.borrow();
77            for (name, binding_expression) in elem.bindings_including_synthetic() {
78                if !binding_expression.borrow().expression.is_synthetic_debug_hook() {
79                    continue;
80                }
81                if super::materialize_fake_properties::should_materialize(
82                    &elem.property_declarations,
83                    &elem.base_type,
84                    name,
85                )
86                .is_some()
87                {
88                    panic!(
89                        "Orphan synthetic debug hook: property '{name}' on element '{}' ({}) does \
90                     not exist at runtime — a pass inserted or kept a synthetic hook for a \
91                     property that is neither native, declared, nor materialized",
92                        elem.id,
93                        elem.debug
94                            .first()
95                            .map(|d| format!("{:?}", d.node.source_file.path()))
96                            .unwrap_or_default(),
97                    );
98                }
99            }
100        },
101    );
102}
103
104fn calculate_element_hash(
105    debug_info: &ElementDebugInfo,
106    random_state: &std::hash::RandomState,
107) -> u64 {
108    // At early-injection time (before any inlining) every element has exactly one debug entry.
109    let node = &debug_info.node;
110
111    let elem_path = node.source_file.path();
112    let elem_offset = node
113        .child_token(crate::parser::SyntaxKind::LBrace)
114        .expect("All elements have a opening Brace")
115        .text_range()
116        .start();
117
118    use std::hash::{BuildHasher, Hasher};
119    let mut hasher = random_state.build_hasher();
120    hasher.write(elem_path.as_os_str().as_encoded_bytes());
121    hasher.write_u32(elem_offset.into());
122    hasher.finish()
123}
124
125fn assign_element_hash(element: &ElementRc, random_state: &std::hash::RandomState) -> u64 {
126    let mut elem = element.borrow_mut();
127
128    // Each element in the source has one debug entry.
129    // There may be more if elements have been inlined.
130    // This should not yet have happened so that we can identify which source element this is.
131    debug_assert!(elem.debug.len() == 1);
132    let debug_info = &mut elem.debug[0];
133    if debug_info.element_hash == 0 {
134        let hash = calculate_element_hash(debug_info, random_state);
135        debug_info.element_hash = hash;
136    }
137    debug_info.element_hash
138}
139
140fn hook_existing_bindings(element: &ElementRc, element_hash: u64) {
141    let elem = element.borrow();
142    elem.bindings_including_synthetic().for_each(|(name, be)| {
143        // Only hook properties — callback handlers and functions also live in
144        // `bindings`, but hook ids are a property-only namespace and overriding a code
145        // block with a value makes no sense.
146        if !elem
147            .lookup_property(name, PropertyLookupMode::InternalName)
148            .property_type
149            .is_property_type()
150        {
151            return;
152        }
153        let expr = std::mem::take(&mut be.borrow_mut().expression);
154        be.borrow_mut().expression = {
155            let stripped = expr.ignore_debug_hooks();
156            if matches!(stripped, Expression::Invalid)
157                || matches!(expr, Expression::DebugHook { .. })
158            {
159                expr
160            } else {
161                Expression::DebugHook {
162                    expression: Box::new(expr),
163                    id: property_id(element_hash, name),
164                    synthetic: false,
165                }
166            }
167        };
168    });
169}
170
171fn property_defaults(
172    elem: &Element,
173) -> impl Iterator<Item = (smol_str::SmolStr, crate::expression_tree::Expression, bool)> {
174    // Properties from the base type.
175    let base_props = elem.base_type.property_list();
176
177    // Properties from own declarations.
178    let own_props: Vec<(smol_str::SmolStr, crate::langtype::Type)> = elem
179        .property_declarations
180        .iter()
181        .map(|(name, decl)| (name.clone(), decl.property_type.clone()))
182        .collect();
183
184    base_props
185        .into_iter()
186        .chain(own_props)
187        .filter(|(name, _)| elem.binding_cell_including_synthetic(name.as_str()).is_none())
188        .filter_map(|(name, _ty)| {
189            let name_str = name.clone();
190            let lookup = elem.lookup_property(&name_str, PropertyLookupMode::InternalName);
191            // Skip functions/callbacks exposed as builtin functions.
192            if lookup.builtin_function.is_some() {
193                return None;
194            }
195            // Only settable visibilities.
196            match lookup.property_visibility {
197                PropertyVisibility::Public
198                | PropertyVisibility::InOut
199                | PropertyVisibility::Input
200                | PropertyVisibility::Private => {}
201                PropertyVisibility::Output
202                | PropertyVisibility::Constexpr
203                | PropertyVisibility::Protected
204                | PropertyVisibility::Fake => return None,
205            }
206            let default = Expression::default_value_for_type(&lookup.property_type);
207            if matches!(default, Expression::Invalid) {
208                return None;
209            }
210            Some((name, default, true))
211        })
212}
213
214// Reserved geometry properties (x, y, width, height) are not in property_list()
215// because they are injected by the type system.
216// We exclude "z" to avoid spurious property materialization in materialize_fake_properties.
217// TODO: Add appropriate debug hook.
218fn geometry_properties()
219-> impl Iterator<Item = (smol_str::SmolStr, crate::expression_tree::Expression, bool)> {
220    crate::typeregister::RESERVED_GEOMETRY_PROPERTIES
221        .iter()
222        .filter(|(name, _)| *name != "z")
223        .filter_map(|(prop_name, ty)| {
224            let default = Expression::default_value_for_type(ty);
225            if matches!(default, Expression::Invalid) {
226                return None;
227            }
228            Some((smol_str::SmolStr::new_static(prop_name), default, true))
229        })
230}
231
232// The reserved transform properties are unlike the geometry properties above: no item
233// actually has them. They only exist at runtime when the lower_transform_properties pass
234// finds a binding and wraps the element in a Transform element.
235//
236// Make the binding non-synthetic, so the later pass picks them up.
237fn transform_properties<'a>(
238    element: &'a ElementRc,
239) -> impl Iterator<Item = (smol_str::SmolStr, crate::expression_tree::Expression, bool)> + 'a {
240    // TODO: Wrap other transform properties.
241    const TRANSFORM_PROPS: [&str; 3] =
242        ["transform-rotation", "transform-scale-x", "transform-scale-y"];
243    TRANSFORM_PROPS.into_iter().map(|property_name| {
244        let property_name = smol_str::SmolStr::new_static(property_name);
245        let default_expression =
246            super::lower_property_to_element::transform_property_default_value(
247                element,
248                &property_name,
249            )
250            .unwrap();
251        (property_name.clone(), default_expression, false)
252    })
253}
254
255fn add_hooks_for_non_existent_bindings(
256    element: &ElementRc,
257    element_hash: u64,
258    symbol_counters: &SymbolCounters,
259    forwarded_references: &mut ForwardedReferenceCache,
260    is_root: bool,
261) {
262    let elem = element.borrow();
263    let mut properties: Vec<_> = property_defaults(&elem).collect();
264
265    // Elements that are (or will become) the root of a component are never wrapped by the
266    // property-to-element lowerings, and their geometry is managed specially (runtime-managed
267    // for windows, set after inlining otherwise) — treat them all like the component root below.
268    // A `PopupWindow` is still an ordinary child element at this point, but the lower_popups
269    // pass later turns it into the root of its own component. `builtin_type()` walks through
270    // component bases, so instances of `component MyPopup inherits PopupWindow` are covered.
271    let becomes_root =
272        is_root || element.borrow().builtin_type().is_some_and(|b| b.name == "PopupWindow");
273
274    // Skip root elements because their geometry is either runtime-managed (Window) or set
275    // after inlining into a parent component — either way, no compiler pass will upgrade a
276    // synthetic hook, which would leave root geometry frozen at 0px.
277    if !becomes_root {
278        properties.extend(geometry_properties());
279    };
280
281    // Root elements (including future popup roots) are skipped — the lowering never wraps a
282    // root, so the transform properties are not applicable there — as are elements that don't
283    // support transforms at all (non-item types).
284    //
285    if !becomes_root {
286        properties.extend(transform_properties(element));
287    }
288
289    drop(elem);
290    let unbound_properties = properties.into_iter().filter(|(name, _default, _synthetic)| {
291        let elem = element.borrow();
292        elem.binding_cell_including_synthetic(name).is_none()
293            // Filter invalid reserved properties (e.g. x/y on a Timer, etc.)
294            && elem.lookup_property(name, PropertyLookupMode::InternalName).property_type != crate::langtype::Type::Invalid
295            && !elem.is_property_target_of_two_way_binding(name)
296    });
297
298    for (name, default_expression, synthetic) in unbound_properties {
299        let expression = match forward_inherited_expression(
300            element,
301            &name,
302            symbol_counters,
303            forwarded_references,
304        ) {
305            InheritedExpression::Expression(expression) => expression.ignore_debug_hooks().clone(),
306            InheritedExpression::TwoWayBinding => continue,
307            InheritedExpression::Unbound => default_expression,
308        };
309        let id = property_id(element_hash, &name);
310        let mut binding: crate::expression_tree::BindingExpression =
311            Expression::DebugHook { expression: Box::new(expression), id, synthetic }.into();
312        binding.priority = 0;
313        let mut elem = element.borrow_mut();
314        if elem.binding_cell_including_synthetic(&name).is_none() {
315            elem.set_binding(name, binding);
316        }
317    }
318}
319
320fn is_hookable(element: &ElementRc) -> bool {
321    let element = element.borrow();
322    // Skip the @children placeholder (the generator skips these too).
323    if element.is_component_placeholder {
324        return false;
325    }
326    if element.debug.is_empty() {
327        return false;
328    }
329
330    true
331}
332
333fn process_existing_bindings(element: &ElementRc, random_state: &std::hash::RandomState) {
334    if !is_hookable(element) {
335        return;
336    }
337
338    let element_hash = assign_element_hash(element, random_state);
339
340    hook_existing_bindings(element, element_hash);
341}
342
343fn process_missing_bindings(
344    element: &ElementRc,
345    symbol_counters: &SymbolCounters,
346    forwarded_references: &mut ForwardedReferenceCache,
347    is_root: bool,
348) {
349    if !is_hookable(element) {
350        return;
351    }
352
353    let element_hash = element.borrow().debug[0].element_hash;
354    debug_assert_ne!(element_hash, 0);
355
356    add_hooks_for_non_existent_bindings(
357        element,
358        element_hash,
359        symbol_counters,
360        forwarded_references,
361        is_root,
362    );
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::object_tree::Component;
369    use std::rc::Rc;
370
371    fn compile(source: &str) -> crate::object_tree::Document {
372        let mut config =
373            crate::CompilerConfiguration::new(crate::generator::OutputFormat::Interpreter);
374        config.style = Some("fluent".into());
375        config.debug_hooks = Some(std::hash::RandomState::new());
376        config.inline_all_elements = false;
377        let mut diags = crate::diagnostics::BuildDiagnostics::default();
378        let doc_node = crate::parser::parse(
379            source.into(),
380            Some(std::path::Path::new("test.slint")),
381            &mut diags,
382        );
383        let (doc, diag, _) = spin_on::spin_on(crate::compile_syntax_node(doc_node, diags, config));
384        assert!(!diag.has_errors(), "{:?}", diag.to_string_vec());
385        doc
386    }
387
388    fn component<'a>(doc: &'a crate::object_tree::Document, id: &str) -> Rc<Component> {
389        doc.inner_components.iter().find(|c| c.id == id).expect("component").clone()
390    }
391
392    fn child(root: &ElementRc, id: &str) -> ElementRc {
393        // The unique-id pass suffixes ids with a number (`txt` -> `txt-2`).
394        // Only match that numeric suffix — don't match `txt-Transform-2` for `txt`.
395        fn rec(e: &ElementRc, id: &str) -> Option<ElementRc> {
396            let this_id = e.borrow().id.clone();
397            let matches = this_id == id
398                || this_id
399                    .strip_prefix(&format!("{id}-"))
400                    .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()));
401            if matches {
402                return Some(e.clone());
403            }
404            let children = e.borrow().children.clone();
405            children.iter().find_map(|c| rec(c, id))
406        }
407        rec(root, id).unwrap_or_else(|| panic!("element {id} not found"))
408    }
409
410    /// The inner expression of a property's DebugHook, or None if the binding is not a hook.
411    fn hooked(elem: &ElementRc, name: &str) -> Option<Expression> {
412        let e = elem.borrow();
413        let be = e.binding_cell_including_synthetic(name)?;
414        match be.borrow().expression.clone() {
415            Expression::DebugHook { expression, .. } => Some(*expression),
416            _ => None,
417        }
418    }
419
420    /// Whether the binding is a synthetic debug hook.
421    fn is_synthetic(elem: &ElementRc, name: &str) -> bool {
422        let e = elem.borrow();
423        let Some(be) = e.binding_cell_including_synthetic(name) else { return false };
424        matches!(be.borrow().expression, Expression::DebugHook { synthetic: true, .. })
425    }
426
427    #[test]
428    fn injects_and_wraps_top_level_only() {
429        let doc = compile(
430            r#"
431            component Sub inherits Rectangle {
432                inner-text := Text { }
433            }
434            export component Foo inherits Window {
435                txt := Text { }
436                rect := Rectangle { background: red; }
437                sub := Sub { }
438            }
439            "#,
440        );
441
442        let foo = component(&doc, "Foo");
443        let txt = child(&foo.root_element, "txt");
444        let rect = child(&foo.root_element, "rect");
445
446        // Unbound `text` is now hooked (synthetic), wrapping the empty-string type default.
447        let text_inner = hooked(&txt, "text").expect("txt.text should be a DebugHook");
448        assert!(is_synthetic(&txt, "text"), "txt.text hook should be synthetic (unbound property)");
449        assert!(
450            matches!(text_inner.ignore_debug_hooks(), Expression::StringLiteral(s) if s.is_empty()),
451            "txt.text default should be the empty-string sentinel, got {text_inner:?}"
452        );
453
454        // Unbound `font-size` is hooked (synthetic), wrapping the 0 sentinel.
455        let fs_inner = hooked(&txt, "font-size").expect("txt.font-size should be a DebugHook");
456        assert!(is_synthetic(&txt, "font-size"), "txt.font-size hook should be synthetic");
457        assert!(
458            matches!(fs_inner.ignore_debug_hooks(), Expression::NumberLiteral(v, _) if *v == 0.),
459            "txt.font-size default should be the 0 sentinel, got {fs_inner:?}"
460        );
461
462        // An explicitly-set property is *wrapped* (non-synthetic, value preserved).
463        let bg_inner = hooked(&rect, "background").expect("rect.background should be a DebugHook");
464        assert!(
465            !is_synthetic(&rect, "background"),
466            "rect.background should be non-synthetic (was explicitly set)"
467        );
468        assert!(
469            !matches!(bg_inner.ignore_debug_hooks(), Expression::Invalid),
470            "rect.background should wrap its real value"
471        );
472
473        // Top-level elements carry a non-zero element_hash (used to build the hook ids).
474        assert_ne!(txt.borrow().debug.first().unwrap().element_hash, 0);
475    }
476
477    #[test]
478    fn instance_defaults_forwarded_into_hooks() {
479        let doc = compile(
480            r#"
481            component Item inherits Rectangle {
482                in property <color> tint: blue;
483                background: tint;
484            }
485            export component Win inherits Window {
486                width: 100px; height: 100px;
487                plain := Item { }
488                for _idx in 2: Item { }
489            }
490            "#,
491        );
492        let win = component(&doc, "Win");
493
494        let assert_background_preserved = |element: &ElementRc, what: &str| {
495            let borrowed = element.borrow();
496            let binding_expression = borrowed
497                .binding_cell_including_synthetic("background")
498                .unwrap_or_else(|| panic!("{what}: background must be bound"));
499            let expression = binding_expression.borrow().expression.clone();
500            let Expression::DebugHook { expression: inner, id, synthetic } = expression else {
501                panic!("{what}: background must be a DebugHook, got {expression:?}");
502            };
503            assert!(synthetic, "{what}: inherited hook must remain synthetic");
504            let mut references_tint = false;
505            inner.visit_recursive(&mut |expression| {
506                if let Expression::PropertyReference(named_reference) = expression
507                    && named_reference.name().ends_with("tint")
508                {
509                    references_tint = true;
510                }
511            });
512            assert!(references_tint, "{what}: background must still reference tint, got {inner:?}");
513            assert_eq!(
514                property_id(
515                    borrowed.debug[0].element_hash,
516                    &smol_str::SmolStr::new_static("background")
517                ),
518                id,
519                "{what}: hook id must use the instance element hash"
520            );
521            assert!(
522                matches!(borrowed.base_type, crate::langtype::ElementType::Component(_)),
523                "{what}: component boundary must remain"
524            );
525        };
526
527        let plain = child(&win.root_element, "plain");
528        assert_background_preserved(&plain, "plain instance");
529
530        let repeated = win
531            .root_element
532            .borrow()
533            .children
534            .iter()
535            .find(|c| c.borrow().repeated.is_some())
536            .expect("repeated element")
537            .clone();
538        let repeated_base = repeated.borrow().base_type.as_component().clone();
539        let mut found = None;
540        object_tree::recurse_elem(&repeated_base.root_element, &(), &mut |elem, &()| {
541            if elem.borrow().binding_cell_including_synthetic("background").is_some() {
542                found = Some(elem.clone());
543            }
544        });
545        let repeated_item = found.expect("repeated Item element with background binding");
546        assert_background_preserved(&repeated_item, "repeated instance");
547    }
548
549    #[test]
550    fn reuses_forwarded_references_for_hooks_and_states() {
551        let doc = compile(
552            r#"
553            component Item inherits Rectangle {
554                in-out property <int> property-value: inner.width / 1px;
555                in-out property <int> function-value: inner.compute-value(3);
556                in-out property <int> callback-value: inner.compute-callback(5);
557                inner := Rectangle {
558                    width: 10px;
559                    pure function compute-value(value: int) -> int { value + 10 }
560                    pure callback compute-callback(int) -> int;
561                    compute-callback(value) => value * 2;
562                }
563            }
564            export component Win inherits Window {
565                in property <bool> active;
566                first := Item { }
567                second := Item { }
568                states [
569                    active when root.active: {
570                        first.property-value: 42;
571                        first.function-value: 43;
572                        first.callback-value: 44;
573                    }
574                ]
575            }
576            "#,
577        );
578        let item = component(&doc, "Item");
579        let win = component(&doc, "Win");
580        let first = child(&win.root_element, "first");
581        let second = child(&win.root_element, "second");
582
583        let declarations = item
584            .root_element
585            .borrow()
586            .property_declarations
587            .keys()
588            .filter(|name| name.starts_with("forward_reference_"))
589            .cloned()
590            .collect::<std::collections::HashSet<_>>();
591        assert_eq!(declarations.len(), 3);
592
593        let forwarded_references = |element: &ElementRc| {
594            let element = element.borrow();
595            let mut references = std::collections::HashSet::new();
596            for property_name in ["property-value", "function-value", "callback-value"] {
597                let binding = element
598                    .binding_cell_including_synthetic(property_name)
599                    .unwrap_or_else(|| panic!("{property_name} must be hooked"));
600                binding.borrow().expression.visit_recursive(&mut |expression| match expression {
601                    Expression::PropertyReference(named_reference)
602                    | Expression::FunctionCall {
603                        function:
604                            crate::expression_tree::Callable::Callback(named_reference)
605                            | crate::expression_tree::Callable::Function(named_reference),
606                        ..
607                    } if named_reference.name().starts_with("forward_reference_") => {
608                        references.insert(named_reference.name().clone());
609                    }
610                    _ => {}
611                });
612            }
613            references
614        };
615
616        let first_references = forwarded_references(&first);
617        let second_references = forwarded_references(&second);
618        assert!(!first_references.is_empty());
619        assert!(!second_references.is_empty());
620        assert!(first_references.is_subset(&declarations));
621        assert!(second_references.is_subset(&declarations));
622        assert!(matches!(first.borrow().base_type, crate::langtype::ElementType::Component(_)));
623        assert!(matches!(second.borrow().base_type, crate::langtype::ElementType::Component(_)));
624    }
625
626    #[test]
627    fn inherited_two_way_binding_has_no_hook() {
628        let doc = compile(
629            r#"
630            component Item inherits Rectangle {
631                in-out property <length> linked <=> target;
632                in-out property <length> target: 42px;
633            }
634            export component Win inherits Window {
635                item := Item { }
636            }
637            "#,
638        );
639        let win = component(&doc, "Win");
640        let item = child(&win.root_element, "item");
641
642        assert!(item.borrow().binding_cell_including_synthetic("linked").is_none());
643        assert!(item.borrow().binding_cell_including_synthetic("target").is_none());
644        assert!(matches!(item.borrow().base_type, crate::langtype::ElementType::Component(_)));
645    }
646
647    /// Direct unit tests for the synthetic-hook rules in `BindingExpression::merge_with`
648    /// (used by inlining to merge a definition's bindings into an instance element).
649    #[test]
650    fn merge_with_synthetic_hook_rules() {
651        use crate::expression_tree::BindingExpression;
652
653        let synthetic_hook = || -> BindingExpression {
654            Expression::DebugHook {
655                expression: Box::new(Expression::NumberLiteral(0., Default::default())),
656                id: "?42-prop".into(),
657                synthetic: true,
658            }
659            .into()
660        };
661        let real_binding = |value: f64| -> BindingExpression {
662            let mut binding: BindingExpression =
663                Expression::NumberLiteral(value, Default::default()).into();
664            binding.priority = 3;
665            binding
666        };
667
668        // Synthetic hook + real binding: upgraded in place, wrapper and id survive.
669        let mut binding = synthetic_hook();
670        assert!(binding.merge_with(&real_binding(7.)), "the other expression must be taken");
671        match &binding.expression {
672            Expression::DebugHook { expression, id, synthetic } => {
673                assert!(!synthetic, "upgraded hook must no longer be synthetic");
674                assert_eq!(id, "?42-prop", "the hook id must survive the merge");
675                assert!(
676                    matches!(**expression, Expression::NumberLiteral(v, _) if v == 7.),
677                    "the definition's expression must be taken"
678                );
679            }
680            other => panic!("expected an upgraded DebugHook, got {other:?}"),
681        }
682        assert_eq!(binding.priority, 3, "the other side's priority must be taken");
683
684        // Synthetic hook + synthetic hook: unchanged, still synthetic ("no binding").
685        let mut binding = synthetic_hook();
686        assert!(!binding.merge_with(&synthetic_hook()));
687        assert!(binding.expression.is_synthetic_debug_hook());
688
689        // Synthetic hook + two-way-only binding: the hook is dropped — its default must not
690        // become the two-way's initial value.
691        let mut binding = synthetic_hook();
692        let mut two_way: BindingExpression = Expression::Invalid.into();
693        two_way.two_way_bindings.push(crate::expression_tree::TwoWayBinding::ModelData {
694            repeated_element: std::rc::Weak::default(),
695            field_access: Default::default(),
696        });
697        assert!(binding.merge_with(&two_way));
698        assert!(matches!(binding.expression, Expression::Invalid));
699        assert_eq!(binding.two_way_bindings.len(), 1);
700
701        // Real (non-synthetic hook) binding keeps priority over anything.
702        let mut binding: BindingExpression = Expression::DebugHook {
703            expression: Box::new(Expression::NumberLiteral(1., Default::default())),
704            id: "?42-prop".into(),
705            synthetic: false,
706        }
707        .into();
708        assert!(!binding.merge_with(&real_binding(9.)));
709        assert!(matches!(binding.value_expression(), Expression::NumberLiteral(v, _) if *v == 1.));
710    }
711
712    /// The injected `transform-rotation` hook must cause the Transform wrapper element to be
713    /// reified so the property actually exists at runtime, and the hook (carrying the source
714    /// element's hash id) must survive as a non-synthetic binding. A binding left on a property
715    /// that is never materialized would abort the interpreter at instantiation time.
716    #[test]
717    fn transform_rotation_hook_is_reified() {
718        let doc = compile(
719            r#"
720            export component Foo inherits Window {
721                rect := Rectangle { }
722            }
723            "#,
724        );
725        let foo = component(&doc, "Foo");
726        let rect = child(&foo.root_element, "rect");
727        let rect_hash = rect.borrow().debug.first().unwrap().element_hash;
728        let rotation_hook_id =
729            property_id(rect_hash, &smol_str::SmolStr::new_static("transform-rotation"));
730
731        // A Transform wrapper element must have been injected for the rectangle.
732        let transform_element = child(&foo.root_element, "rect-Transform");
733
734        // The rotation hook ends up driving the Transform element (the two-way binding to the
735        // rectangle's materialized property is collapsed by the alias optimizations); it must
736        // be non-synthetic and wrap the 0 default.
737        let binding_holder = transform_element.borrow();
738        let binding_expression = binding_holder
739            .binding_cell_including_synthetic("transform-rotation")
740            .expect("the Transform element must bind transform-rotation");
741        match &binding_expression.borrow().expression {
742            Expression::DebugHook { id, synthetic, expression } => {
743                assert_eq!(id, &rotation_hook_id, "hook id must be derived from rect's hash");
744                assert!(!synthetic, "the injected rotation hook must be non-synthetic");
745                assert!(
746                    matches!(**expression, Expression::NumberLiteral(v, _) if v == 0.),
747                    "the rotation hook must wrap the 0deg default"
748                );
749            }
750            other => panic!("transform-rotation must be a DebugHook, got {other:?}"),
751        }
752    }
753
754    /// Regression: geometry defaults (width/height) must still be computed even when
755    /// debug hooks are active and inject synthetic hooks for unbound geometry properties.
756    #[test]
757    fn geometry_defaults_still_set_with_debug_hooks() {
758        let doc = compile(
759            r#"
760            export component Foo inherits Window {
761                img := Image { source: @image-url("nonexistent.png"); }
762            }
763            "#,
764        );
765        let foo = component(&doc, "Foo");
766        let img = child(&foo.root_element, "img");
767        let img_hash = img.borrow().debug.first().unwrap().element_hash;
768
769        // The geometry properties are materialized into declarations and the declarations
770        // (with their bindings) are moved to the root by move_declarations — so look the hook
771        // up by its id across the whole component instead of by name on the img element.
772        let find_hook_by_id = |wanted_id: &smol_str::SmolStr| -> Option<Expression> {
773            let mut found = None;
774            object_tree::recurse_elem(&foo.root_element, &(), &mut |elem, &()| {
775                for (_, binding_expression) in elem.borrow().bindings_including_synthetic() {
776                    if let Expression::DebugHook { id, .. } =
777                        &binding_expression.borrow().expression
778                        && id == wanted_id
779                    {
780                        found = Some(binding_expression.borrow().expression.clone());
781                    }
782                }
783            });
784            found
785        };
786
787        for property in ["x", "y", "width", "height"] {
788            // The default_geometry pass must have set width and height on the image.
789            // If synthetic hooks were treated as real bindings, default_geometry would
790            // skip the image, leaving it with no layout binding.  The resulting hook
791            // must therefore be non-synthetic: either upgraded by default_geometry itself or
792            // by materialize_fake_properties' initialization.
793            let hook_id = property_id(img_hash, &smol_str::SmolStr::new_static(property));
794            let expression = find_hook_by_id(&hook_id)
795                .unwrap_or_else(|| panic!("a debug hook for img.{property} must survive"));
796            assert!(
797                matches!(expression, Expression::DebugHook { synthetic: false, .. }),
798                "img.{property} hook should not be synthetic after default_geometry, got {expression:?}"
799            );
800        }
801    }
802}