Skip to main content

presolve_compiler/
ordinary_html_codegen.rs

1//! Compiler-owned J1-P materialization of ordinary template instance markers.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::template_graph::{AttributeValue, ElementNode, FragmentNode, TemplateAttribute};
6use crate::{
7    build_ordinary_template_instance_registry, build_resume_anchor_plan, ApplicationSemanticModel,
8    ComponentInstanceId, ComponentInstanceStatus, ResumeAnchorPlacement, SerializableValue,
9    SlotBindingStatus, TemplateChild, TemplateNode,
10};
11
12#[derive(Debug, Default)]
13struct ResumeHtmlMarkers {
14    element_anchors: BTreeMap<String, String>,
15    text_anchors: BTreeMap<String, String>,
16    structural_starts: BTreeMap<String, String>,
17    structural_ends: BTreeMap<String, String>,
18    events: BTreeMap<String, String>,
19}
20
21#[derive(Debug, Clone)]
22enum SlotProjection<'a> {
23    Authored {
24        outlet_entity: crate::SemanticId,
25        caller_instance: ComponentInstanceId,
26        caller_template: &'a TemplateNode,
27        invocation_element: &'a ElementNode,
28        invocation_path: String,
29        content_entities: BTreeSet<crate::SemanticId>,
30        caller_slot_projections: &'a [SlotProjection<'a>],
31    },
32    DirectLayoutChild {
33        outlet_entity: crate::SemanticId,
34        child_instance: ComponentInstanceId,
35    },
36}
37
38/// Render the planned initial component topology with compiler-precomputed
39/// ordinary instance and J10 resume markers.
40#[must_use]
41pub fn generate_ordinary_instance_html(model: &ApplicationSemanticModel) -> String {
42    generate_ordinary_instance_html_for_roots(model, None)
43}
44
45/// Renders only the planned ordinary instance tree rooted at one compiler
46/// selected component. Application publication uses this entry-scoped form so
47/// unrelated top-level components cannot leak into an entry page.
48#[must_use]
49pub fn generate_ordinary_instance_html_for_component(
50    model: &ApplicationSemanticModel,
51    root_component: &crate::SemanticId,
52) -> String {
53    generate_ordinary_instance_html_for_roots(model, Some(root_component))
54}
55
56fn generate_ordinary_instance_html_for_roots(
57    model: &ApplicationSemanticModel,
58    root_component: Option<&crate::SemanticId>,
59) -> String {
60    let registry = build_ordinary_template_instance_registry(model);
61    let resume = build_resume_anchor_plan(model);
62    let mut resume_markers = ResumeHtmlMarkers::default();
63    for anchor in &resume.anchors {
64        let target = anchor.marker_target_id.to_string();
65        let index = match anchor.placement {
66            ResumeAnchorPlacement::ElementAttribute => &mut resume_markers.element_anchors,
67            ResumeAnchorPlacement::TextTemplate => &mut resume_markers.text_anchors,
68            ResumeAnchorPlacement::StructuralStartComment => &mut resume_markers.structural_starts,
69            ResumeAnchorPlacement::StructuralEndComment => &mut resume_markers.structural_ends,
70        };
71        index.insert(target, anchor.anchor_id.to_string());
72    }
73    for event in &resume.events {
74        resume_markers.events.insert(
75            event.target_id.to_string(),
76            event.resume_event_id.to_string(),
77        );
78    }
79    let targets = registry
80        .targets
81        .iter()
82        .map(|record| {
83            (
84                (
85                    record.component_instance_id.clone(),
86                    record.template_entity_id.clone(),
87                ),
88                record.target_id.to_string(),
89            )
90        })
91        .collect::<BTreeMap<_, _>>();
92    let bindings = registry
93        .bindings
94        .iter()
95        .map(|record| {
96            (
97                (
98                    record.component_instance_id.clone(),
99                    record.declaration_binding_id.clone(),
100                ),
101                record.instance_binding_id.to_string(),
102            )
103        })
104        .collect::<BTreeMap<_, _>>();
105    let children = model
106        .component_instance_plan
107        .instances
108        .values()
109        .filter_map(|instance| {
110            instance.parent_instance.as_ref().and_then(|parent| {
111                instance
112                    .invocation
113                    .as_ref()
114                    .map(|invocation| ((parent.clone(), invocation.clone()), instance))
115            })
116        })
117        .collect::<BTreeMap<_, _>>();
118    let templates = model
119        .templates
120        .iter()
121        .map(|template| (template.id.clone(), template))
122        .collect::<BTreeMap<_, _>>();
123    model
124        .component_instance_plan
125        .instances
126        .values()
127        .filter(|instance| {
128            instance.parent_instance.is_none()
129                && instance.status == ComponentInstanceStatus::Planned
130                && root_component.is_none_or(|component| instance.component == *component)
131        })
132        .map(|instance| {
133            render_instance(
134                model,
135                &templates,
136                &children,
137                &targets,
138                &bindings,
139                &resume_markers,
140                &instance.id,
141                &instance.component,
142                &[],
143            )
144        })
145        .collect::<Vec<_>>()
146        .join("\n")
147}
148
149#[allow(clippy::too_many_arguments)]
150fn render_instance(
151    model: &ApplicationSemanticModel,
152    templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
153    children: &BTreeMap<
154        (ComponentInstanceId, crate::ComponentInvocationId),
155        &crate::ComponentInstance,
156    >,
157    targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
158    bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
159    resume_markers: &ResumeHtmlMarkers,
160    instance: &ComponentInstanceId,
161    component: &crate::SemanticId,
162    slot_projections: &[SlotProjection<'_>],
163) -> String {
164    let mut instance_slot_projections = slot_projections.to_vec();
165    instance_slot_projections.extend(direct_layout_slot_projections(model, instance));
166    let Some(template) = templates.get(&component.template()) else {
167        return String::new();
168    };
169    if let Some(root) = &template.root {
170        return render_element(
171            model,
172            templates,
173            children,
174            targets,
175            bindings,
176            resume_markers,
177            instance,
178            template,
179            root,
180            "root",
181            &instance_slot_projections,
182        );
183    }
184    template
185        .root_fragment
186        .as_ref()
187        .map_or_else(String::new, |fragment| {
188            render_fragment(
189                model,
190                templates,
191                children,
192                targets,
193                bindings,
194                resume_markers,
195                instance,
196                template,
197                fragment,
198                "root",
199                &instance_slot_projections,
200            )
201        })
202}
203
204#[allow(clippy::too_many_arguments)]
205fn render_fragment(
206    model: &ApplicationSemanticModel,
207    templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
208    children: &BTreeMap<
209        (ComponentInstanceId, crate::ComponentInvocationId),
210        &crate::ComponentInstance,
211    >,
212    targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
213    bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
214    resume_markers: &ResumeHtmlMarkers,
215    instance: &ComponentInstanceId,
216    template: &TemplateNode,
217    fragment: &FragmentNode,
218    path: &str,
219    slot_projections: &[SlotProjection<'_>],
220) -> String {
221    render_children(
222        model,
223        templates,
224        children,
225        targets,
226        bindings,
227        resume_markers,
228        instance,
229        template,
230        &fragment.children,
231        path,
232        slot_projections,
233    )
234}
235
236#[allow(clippy::too_many_arguments)]
237fn render_children(
238    model: &ApplicationSemanticModel,
239    templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
240    children: &BTreeMap<
241        (ComponentInstanceId, crate::ComponentInvocationId),
242        &crate::ComponentInstance,
243    >,
244    targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
245    bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
246    resume_markers: &ResumeHtmlMarkers,
247    instance: &ComponentInstanceId,
248    template: &TemplateNode,
249    nodes: &[TemplateChild],
250    parent_path: &str,
251    slot_projections: &[SlotProjection<'_>],
252) -> String {
253    nodes
254        .iter()
255        .enumerate()
256        .map(|(index, node)| {
257            render_child(
258                model,
259                templates,
260                children,
261                targets,
262                bindings,
263                resume_markers,
264                instance,
265                template,
266                node,
267                &format!("{parent_path}.{index}"),
268                slot_projections,
269            )
270        })
271        .collect()
272}
273
274#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
275fn render_child(
276    model: &ApplicationSemanticModel,
277    templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
278    children: &BTreeMap<
279        (ComponentInstanceId, crate::ComponentInvocationId),
280        &crate::ComponentInstance,
281    >,
282    targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
283    bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
284    resume_markers: &ResumeHtmlMarkers,
285    instance: &ComponentInstanceId,
286    template: &TemplateNode,
287    node: &TemplateChild,
288    path: &str,
289    slot_projections: &[SlotProjection<'_>],
290) -> String {
291    match node {
292        TemplateChild::Text { value, .. } => escape_text(value),
293        TemplateChild::Binding {
294            expression,
295            initial_value,
296            ..
297        } => {
298            let entity = template.id.template_entity("binding", path);
299            let value = initial_value
300                .as_ref()
301                .map_or_else(String::new, SerializableValue::render_text);
302            let marker_target = targets.get(&(instance.clone(), entity.clone()));
303            let resume_marker = marker_target
304                .and_then(|target| resume_markers.text_anchors.get(target))
305                .map_or_else(String::new, |anchor| {
306                    format!(
307                        "<template data-presolve-r=\"{}\"></template>",
308                        escape_attr(anchor)
309                    )
310                });
311            bindings.get(&(instance.clone(), entity)).map_or_else(
312                || format!("{resume_marker}<!-- presolve-binding:{expression} -->{value}"),
313                |id| format!("{resume_marker}<!--presolve-ti-binding-start:{id}-->{value}<!--presolve-ti-binding-end:{id}-->"),
314            )
315        }
316        TemplateChild::Element(element) => render_element(
317            model,
318            templates,
319            children,
320            targets,
321            bindings,
322            resume_markers,
323            instance,
324            template,
325            element,
326            path,
327            slot_projections,
328        ),
329        TemplateChild::Fragment(fragment) => render_fragment(
330            model,
331            templates,
332            children,
333            targets,
334            bindings,
335            resume_markers,
336            instance,
337            template,
338            fragment,
339            path,
340            slot_projections,
341        ),
342        TemplateChild::Conditional(conditional) => {
343            let entity = template.id.template_entity("conditional", path);
344            let marker = targets
345                .get(&(instance.clone(), entity))
346                .map_or("", String::as_str);
347            let resume_start = resume_markers
348                .structural_starts
349                .get(marker)
350                .map_or("", String::as_str);
351            let resume_end = resume_markers
352                .structural_ends
353                .get(marker)
354                .map_or("", String::as_str);
355            let selected = if matches!(
356                conditional.initial_value,
357                Some(SerializableValue::Boolean(true))
358            ) {
359                &conditional.when_true
360            } else {
361                &conditional.when_false
362            };
363            format!(
364                "<!--presolve-r-start:{}--><!--presolve-conditional-start:{}:ti:{}-->{}<!--presolve-conditional-end:{}:ti:{}--><!--presolve-r-end:{}-->",
365                escape_comment(resume_start),
366                conditional.start_id.0,
367                marker,
368                render_children(
369                    model,
370                    templates,
371                    children,
372                    targets,
373                    bindings,
374                    resume_markers,
375                    instance,
376                    template,
377                    selected,
378                    path,
379                    slot_projections,
380                ),
381                conditional.end_id.0,
382                marker,
383                escape_comment(resume_end)
384            )
385        }
386        TemplateChild::List(list) => {
387            let entity = template.id.template_entity("list", path);
388            let marker = targets
389                .get(&(instance.clone(), entity))
390                .map_or("", String::as_str);
391            let resume_start = resume_markers
392                .structural_starts
393                .get(marker)
394                .map_or("", String::as_str);
395            let resume_end = resume_markers
396                .structural_ends
397                .get(marker)
398                .map_or("", String::as_str);
399            format!(
400                "<!--presolve-r-start:{}--><!--presolve-ti-target-start:{marker}-->{}<!--presolve-ti-target-end:{marker}--><!--presolve-r-end:{}-->",
401                escape_comment(resume_start),
402                crate::html_codegen::generate_list_html(list),
403                escape_comment(resume_end),
404            )
405        }
406    }
407}
408
409#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
410fn render_element(
411    model: &ApplicationSemanticModel,
412    templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
413    children: &BTreeMap<
414        (ComponentInstanceId, crate::ComponentInvocationId),
415        &crate::ComponentInstance,
416    >,
417    targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
418    bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
419    resume_markers: &ResumeHtmlMarkers,
420    instance: &ComponentInstanceId,
421    template: &TemplateNode,
422    element: &ElementNode,
423    path: &str,
424    slot_projections: &[SlotProjection<'_>],
425) -> String {
426    let entity = template.id.template_entity("element", path);
427    if element.tag_name == "slot" {
428        if let Some(projection) = slot_projections
429            .iter()
430            .find(|projection| projection.outlet_entity() == &entity)
431        {
432            return render_slot_projection(
433                model,
434                templates,
435                children,
436                targets,
437                bindings,
438                resume_markers,
439                projection,
440            );
441        }
442        if let Some(outlet) = model
443            .slot_outlets
444            .values()
445            .find(|outlet| outlet.template_entity == entity)
446        {
447            let empty = model
448                .slot_bindings
449                .for_callee(instance)
450                .into_iter()
451                .any(|binding| {
452                    binding.outlet.as_ref() == Some(&outlet.id)
453                        && binding.status == SlotBindingStatus::Empty
454                });
455            if empty {
456                return String::new();
457            }
458        }
459    }
460    if let Some(invocation) = model
461        .component_invocations
462        .values()
463        .find(|candidate| candidate.template_entity == entity)
464    {
465        if let Some(child) = children.get(&(instance.clone(), invocation.id.clone())) {
466            if child.status == ComponentInstanceStatus::Planned {
467                let projections = slot_projections_for_invocation(
468                    model,
469                    &child.id,
470                    instance,
471                    template,
472                    element,
473                    path,
474                    slot_projections,
475                );
476                return render_instance(
477                    model,
478                    templates,
479                    children,
480                    targets,
481                    bindings,
482                    resume_markers,
483                    &child.id,
484                    &child.component,
485                    &projections,
486                );
487            }
488        }
489    }
490    let mut html = format!(
491        "<{} data-presolve-node=\"{}\"",
492        element.tag_name,
493        escape_attr(&element.id.0)
494    );
495    if let Some(target) = targets.get(&(instance.clone(), entity)) {
496        html.push_str(" data-presolve-ti=\"");
497        html.push_str(&escape_attr(target));
498        html.push('"');
499        if let Some(anchor) = resume_markers.element_anchors.get(target) {
500            html.push_str(" data-presolve-r=\"");
501            html.push_str(&escape_attr(anchor));
502            html.push('"');
503        }
504        if let Some(event) = resume_markers.events.get(target) {
505            html.push_str(" data-presolve-e=\"");
506            html.push_str(&escape_attr(event));
507            html.push('"');
508        }
509    }
510    for attribute in &element.attributes {
511        html.push(' ');
512        html.push_str(&attribute_html(attribute));
513    }
514    html.push('>');
515    html.push_str(&render_children(
516        model,
517        templates,
518        children,
519        targets,
520        bindings,
521        resume_markers,
522        instance,
523        template,
524        &element.children,
525        path,
526        slot_projections,
527    ));
528    html.push_str("</");
529    html.push_str(&element.tag_name);
530    html.push('>');
531    html
532}
533
534#[allow(clippy::similar_names, clippy::too_many_arguments)]
535fn slot_projections_for_invocation<'a>(
536    model: &'a ApplicationSemanticModel,
537    callee_instance: &ComponentInstanceId,
538    caller_instance: &ComponentInstanceId,
539    caller_template: &'a TemplateNode,
540    invocation_element: &'a ElementNode,
541    invocation_path: &str,
542    caller_slot_projections: &'a [SlotProjection<'a>],
543) -> Vec<SlotProjection<'a>> {
544    model
545        .slot_bindings
546        .for_callee(callee_instance)
547        .into_iter()
548        .filter(|binding| binding.status == SlotBindingStatus::Bound)
549        .filter_map(|binding| {
550            let outlet = model.slot_outlets.get(binding.outlet.as_ref()?)?;
551            let fragment = model
552                .slot_content_fragments
553                .get(binding.content_fragment.as_ref()?)?;
554            Some(SlotProjection::Authored {
555                outlet_entity: outlet.template_entity.clone(),
556                caller_instance: caller_instance.clone(),
557                caller_template,
558                invocation_element,
559                invocation_path: invocation_path.to_string(),
560                content_entities: fragment.content_template_entities.iter().cloned().collect(),
561                caller_slot_projections,
562            })
563        })
564        .collect()
565}
566
567impl SlotProjection<'_> {
568    fn outlet_entity(&self) -> &crate::SemanticId {
569        match self {
570            Self::Authored { outlet_entity, .. }
571            | Self::DirectLayoutChild { outlet_entity, .. } => outlet_entity,
572        }
573    }
574}
575
576fn direct_layout_slot_projections(
577    model: &ApplicationSemanticModel,
578    instance: &ComponentInstanceId,
579) -> Vec<SlotProjection<'static>> {
580    model
581        .slot_bindings
582        .for_callee(instance)
583        .into_iter()
584        .filter(|binding| binding.status == SlotBindingStatus::Bound)
585        .filter_map(|binding| {
586            let child_instance = binding.direct_child_instance.clone()?;
587            let outlet = model.slot_outlets.get(binding.outlet.as_ref()?)?;
588            Some(SlotProjection::DirectLayoutChild {
589                outlet_entity: outlet.template_entity.clone(),
590                child_instance,
591            })
592        })
593        .collect()
594}
595
596#[allow(clippy::too_many_arguments)]
597fn render_slot_projection(
598    model: &ApplicationSemanticModel,
599    templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
600    children: &BTreeMap<
601        (ComponentInstanceId, crate::ComponentInvocationId),
602        &crate::ComponentInstance,
603    >,
604    targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
605    bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
606    resume_markers: &ResumeHtmlMarkers,
607    projection: &SlotProjection<'_>,
608) -> String {
609    if let SlotProjection::DirectLayoutChild { child_instance, .. } = projection {
610        let Some(child) = model.component_instance_plan.instances.get(child_instance) else {
611            return String::new();
612        };
613        return render_instance(
614            model,
615            templates,
616            children,
617            targets,
618            bindings,
619            resume_markers,
620            &child.id,
621            &child.component,
622            &[],
623        );
624    }
625    let SlotProjection::Authored {
626        caller_instance,
627        caller_template,
628        invocation_element,
629        invocation_path,
630        content_entities,
631        caller_slot_projections,
632        ..
633    } = projection
634    else {
635        unreachable!("direct layout Slot projection returns above");
636    };
637    let mut html = String::new();
638    for (index, child) in invocation_element.children.iter().enumerate() {
639        let path = format!("{invocation_path}.{index}");
640        let entity = template_child_entity(&caller_template.id, child, &path);
641        if content_entities.contains(&entity) {
642            html.push_str(&render_child(
643                model,
644                templates,
645                children,
646                targets,
647                bindings,
648                resume_markers,
649                caller_instance,
650                caller_template,
651                child,
652                &path,
653                caller_slot_projections,
654            ));
655            continue;
656        }
657        let TemplateChild::Element(wrapper) = child else {
658            continue;
659        };
660        if wrapper.tag_name != "template" {
661            continue;
662        }
663        for (child_index, wrapped) in wrapper.children.iter().enumerate() {
664            let wrapped_path = format!("{path}.{child_index}");
665            let wrapped_entity = template_child_entity(&caller_template.id, wrapped, &wrapped_path);
666            if content_entities.contains(&wrapped_entity) {
667                html.push_str(&render_child(
668                    model,
669                    templates,
670                    children,
671                    targets,
672                    bindings,
673                    resume_markers,
674                    caller_instance,
675                    caller_template,
676                    wrapped,
677                    &wrapped_path,
678                    caller_slot_projections,
679                ));
680            }
681        }
682    }
683    html
684}
685
686fn template_child_entity(
687    template: &crate::SemanticId,
688    child: &TemplateChild,
689    path: &str,
690) -> crate::SemanticId {
691    template.template_entity(
692        match child {
693            TemplateChild::Text { .. } => "text",
694            TemplateChild::Binding { .. } => "binding",
695            TemplateChild::Element(_) => "element",
696            TemplateChild::Fragment(_) => "fragment",
697            TemplateChild::Conditional(_) => "conditional",
698            TemplateChild::List(_) => "list",
699        },
700        path,
701    )
702}
703
704fn attribute_html(attribute: &TemplateAttribute) -> String {
705    let value = match &attribute.value {
706        AttributeValue::Boolean => return attribute.name.clone(),
707        AttributeValue::Static(value) => value.clone(),
708        AttributeValue::Binding { initial_value, .. } => initial_value
709            .as_ref()
710            .map_or_else(String::new, SerializableValue::render_text),
711        AttributeValue::EventHandler { handler, .. } => handler.clone(),
712        AttributeValue::BindingList(values) => values.join(","),
713    };
714    format!("{}=\"{}\"", attribute.name, escape_attr(&value))
715}
716
717fn escape_attr(value: &str) -> String {
718    value
719        .replace('&', "&amp;")
720        .replace('"', "&quot;")
721        .replace('<', "&lt;")
722        .replace('>', "&gt;")
723}
724
725fn escape_comment(value: &str) -> String {
726    value.replace("--", "&#45;&#45;")
727}
728fn escape_text(value: &str) -> String {
729    value
730        .replace('&', "&amp;")
731        .replace('<', "&lt;")
732        .replace('>', "&gt;")
733}
734
735#[cfg(test)]
736mod tests {
737    use super::{generate_ordinary_instance_html, generate_ordinary_instance_html_for_component};
738    use crate::{
739        build_application_semantic_model, build_resume_anchor_plan, validate_resume_marker_html,
740    };
741
742    #[test]
743    fn emits_precomputed_repeated_instance_and_exact_resume_markers() {
744        let model = build_application_semantic_model(&presolve_parser::parse_file(
745            "src/Markers.tsx",
746            r#"
747@component("x-child") class Child {
748  count = state(0);
749  @action() increment() { this.count++; }
750  render() { return <button onClick={() => this.increment()}>{this.count}</button>; }
751}
752@component("x-parent") class Parent { render() { return <><Child /><Child /></>; } }
753"#,
754        ));
755        let html = generate_ordinary_instance_html(&model);
756        assert_eq!(html.matches("data-presolve-ti=").count(), 2);
757        assert_eq!(html.matches("presolve-ti-binding-start:").count(), 2);
758        assert_eq!(html.matches("data-presolve-r=").count(), 4);
759        assert_eq!(html.matches("data-presolve-e=").count(), 2);
760        assert_eq!(html, generate_ordinary_instance_html(&model));
761    }
762
763    #[test]
764    fn places_caller_owned_slot_content_at_the_exact_compiler_outlet() {
765        let model = build_application_semantic_model(&presolve_parser::parse_file(
766            "src/Slots.tsx",
767            r#"
768@component("x-leaf") class Leaf {
769  count = state(0);
770  @action() increment() { this.count++; }
771  render() { return <button onClick={() => this.increment()}>{this.count}</button>; }
772}
773@component("x-card") class Card {
774  @slot() children!: SlotContent;
775  render() { return <article><slot /><Leaf /></article>; }
776}
777@component("x-page") class Page { render() { return <main><Card><Leaf /></Card></main>; } }
778"#,
779        ));
780        let html = generate_ordinary_instance_html(&model);
781        let resume = build_resume_anchor_plan(&model);
782
783        assert_eq!(html.matches("<button").count(), 2);
784        assert!(!html.contains("<slot"));
785        assert_eq!(html.matches("presolve-ti-binding-start:").count(), 2);
786        assert!(
787            validate_resume_marker_html(&resume, &html).is_empty(),
788            "caller-owned Slot content must carry every required resume marker"
789        );
790    }
791
792    #[test]
793    fn renders_only_the_selected_entry_component_tree() {
794        let model = build_application_semantic_model(&presolve_parser::parse_file(
795            "src/Entries.tsx",
796            r#"
797@component("x-home") class Home { render() { return <main>Home</main>; } }
798@component("x-about") class About { render() { return <main>About</main>; } }
799"#,
800        ));
801        let home = model
802            .components
803            .iter()
804            .find(|component| component.class_name == "Home")
805            .unwrap();
806
807        let html = generate_ordinary_instance_html_for_component(&model, &home.id);
808
809        assert!(html.contains("Home"));
810        assert!(!html.contains("About"));
811    }
812}