Skip to main content

noxid_codegen_js/
lib.rs

1use noxid_dom_ir::{DomAttribute, DomNode};
2use noxid_ir::*;
3use noxid_source::{html_escape, js_escape};
4use std::collections::BTreeSet;
5
6const LANGUAGE_VALUE_EQUALITY_FUNCTION: &str = r#"function $noxEqual($noxLeft, $noxRight) {
7  if ($noxLeft === $noxRight) return true;
8  if (Array.isArray($noxLeft) || Array.isArray($noxRight)) return Array.isArray($noxLeft) && Array.isArray($noxRight) && $noxLeft.length === $noxRight.length && $noxLeft.every(($noxValue, $noxIndex) => $noxEqual($noxValue, $noxRight[$noxIndex]));
9  if ($noxLeft === null || $noxRight === null || typeof $noxLeft !== "object" || typeof $noxRight !== "object") return false;
10  const $noxLeftKeys = Object.keys($noxLeft).sort();
11  const $noxRightKeys = Object.keys($noxRight).sort();
12  return $noxLeftKeys.length === $noxRightKeys.length && $noxLeftKeys.every(($noxKey, $noxIndex) => $noxKey === $noxRightKeys[$noxIndex] && $noxEqual($noxLeft[$noxKey], $noxRight[$noxKey]));
13}
14"#;
15
16#[derive(Clone, Debug)]
17pub struct GeneratedComponent {
18    pub javascript: String,
19    pub css: String,
20    /// Runtime exports referenced by this generated module. The CLI uses this
21    /// compiler-derived manifest to emit a feature-pruned runtime.
22    pub runtime_imports: BTreeSet<String>,
23    /// Independently loadable component chunks. Populated when a source file
24    /// declares more than one component.
25    pub modules: Vec<GeneratedModule>,
26}
27
28#[derive(Clone, Debug)]
29pub struct GeneratedModule {
30    pub component: String,
31    pub javascript: String,
32    pub runtime_imports: BTreeSet<String>,
33}
34
35#[derive(Clone, Debug)]
36struct SlotChildren {
37    template_name: String,
38    operations: Vec<Operation>,
39}
40
41#[derive(Clone, Debug)]
42enum Operation {
43    Text {
44        id: SemanticId,
45        marker: usize,
46        expression: SemanticExpr,
47        dependencies: Vec<SemanticId>,
48    },
49    Attribute {
50        id: SemanticId,
51        marker: usize,
52        name: String,
53        expression: SemanticExpr,
54        dependencies: Vec<SemanticId>,
55    },
56    TwoWayBinding {
57        id: SemanticId,
58        marker: usize,
59        name: String,
60        target: SemanticId,
61    },
62    Event {
63        id: SemanticId,
64        marker: usize,
65        event: String,
66        action: SemanticId,
67        arguments: Option<Vec<SemanticExpr>>,
68    },
69    RoutePrefetch {
70        marker: usize,
71        trigger: PrefetchTrigger,
72    },
73    Attachment {
74        attachment: ElementAttachment,
75        marker: usize,
76    },
77    Component {
78        id: SemanticId,
79        marker: usize,
80        target: SemanticId,
81        props: Vec<PropArgument>,
82        handlers: Vec<ComponentEventHandler>,
83        prefetch: Option<PrefetchTrigger>,
84        children: Option<SlotChildren>,
85    },
86    Slot {
87        id: SemanticId,
88        marker: usize,
89    },
90    Conditional {
91        id: SemanticId,
92        marker: usize,
93        condition: SemanticExpr,
94        dependencies: Vec<SemanticId>,
95        transition: Option<ConditionalTransition>,
96        template_name: String,
97        operations: Vec<Operation>,
98    },
99    Match {
100        id: SemanticId,
101        marker: usize,
102        expression: SemanticExpr,
103        dependencies: Vec<SemanticId>,
104        cases: Vec<MatchOperationCase>,
105    },
106    Stream {
107        id: SemanticId,
108        marker: usize,
109        expression: SemanticExpr,
110        dependencies: Vec<SemanticId>,
111        cases: Vec<MatchOperationCase>,
112    },
113    For {
114        id: SemanticId,
115        marker: usize,
116        collection: SemanticExpr,
117        dependencies: Vec<SemanticId>,
118        binding: ForBinding,
119        key: Box<SemanticExpr>,
120        template_name: String,
121        operations: Vec<Operation>,
122    },
123}
124
125#[derive(Clone, Debug)]
126struct MatchOperationCase {
127    variant: String,
128    binding: Option<MatchBinding>,
129    template_name: String,
130    operations: Vec<Operation>,
131}
132
133#[derive(Clone, Debug, Default)]
134struct Template {
135    html: String,
136    operations: Vec<Operation>,
137}
138
139struct TemplateBuilder<'a> {
140    scope: &'a str,
141    component: &'a str,
142    marker: usize,
143    conditional: usize,
144    match_block: usize,
145    stream_block: usize,
146    for_block: usize,
147    slot_children: usize,
148    declarations: &'a mut Vec<(String, String)>,
149}
150
151pub fn generate(component: &ComponentDefinition) -> Result<GeneratedComponent, String> {
152    generate_program(&SemanticProgram {
153        imports: vec![],
154        functions: vec![],
155        external_modules: vec![],
156        contexts: vec![],
157        types: vec![],
158        distinct_types: vec![],
159        resources: vec![],
160        streams: vec![],
161        agents: vec![],
162        endpoints: vec![],
163        tasks: vec![],
164        queues: vec![],
165        models: vec![],
166        components: vec![component.clone()],
167    })
168}
169
170pub fn generate_program(program: &SemanticProgram) -> Result<GeneratedComponent, String> {
171    generate_program_with_resource_module(program, None)
172}
173
174pub fn generate_program_with_resource_module(
175    program: &SemanticProgram,
176    resource_module: Option<&str>,
177) -> Result<GeneratedComponent, String> {
178    generate_program_with_modules(program, None, resource_module, None, None)
179}
180
181pub fn generate_program_with_modules(
182    program: &SemanticProgram,
183    validator_module: Option<&str>,
184    resource_module: Option<&str>,
185    stream_module: Option<&str>,
186    agent_module: Option<&str>,
187) -> Result<GeneratedComponent, String> {
188    let mut generated = generate_program_inner(
189        program,
190        validator_module,
191        resource_module,
192        stream_module,
193        agent_module,
194    )?;
195    prepend_external_component_imports(&mut generated.javascript, program);
196    if program.components.len() > 1 {
197        generated.modules = program
198            .components
199            .iter()
200            .map(|component| {
201                let single = SemanticProgram {
202                    imports: program.imports.clone(),
203                    functions: program.functions.clone(),
204                    external_modules: program.external_modules.clone(),
205                    contexts: program.contexts.clone(),
206                    types: program.types.clone(),
207                    distinct_types: program.distinct_types.clone(),
208                    resources: program.resources.clone(),
209                    streams: program.streams.clone(),
210                    agents: program.agents.clone(),
211                    endpoints: vec![],
212                    tasks: vec![],
213                    queues: vec![],
214                    models: vec![],
215                    components: vec![component.clone()],
216                };
217                let mut output = generate_program_inner(
218                    &single,
219                    validator_module,
220                    resource_module,
221                    stream_module,
222                    agent_module,
223                )?;
224                prepend_external_component_imports(&mut output.javascript, &single);
225                let dependencies = invoked_components(&component.view);
226                let prefetched = prefetched_components(&component.view);
227                if !dependencies.is_empty() {
228                    // Each invocation is imported from its own chunk. Multiple
229                    // imports are explicit so native ESM never evaluates an
230                    // unrelated sibling component.
231                    let prefix = dependencies
232                        .iter()
233                        .map(|name| {
234                            component_import(
235                                name,
236                                supports_hydration(component),
237                                prefetched.contains(name),
238                            )
239                        })
240                        .collect::<String>();
241                    output.javascript.insert_str(0, &prefix);
242                }
243                Ok(GeneratedModule {
244                    component: component.name.clone(),
245                    javascript: output.javascript,
246                    runtime_imports: output.runtime_imports,
247                })
248            })
249            .collect::<Result<Vec<_>, String>>()?;
250    }
251    Ok(generated)
252}
253
254fn prepend_external_component_imports(javascript: &mut String, program: &SemanticProgram) {
255    let local = program
256        .components
257        .iter()
258        .map(|component| component.name.as_str())
259        .collect::<BTreeSet<_>>();
260    let imported = program
261        .imports
262        .iter()
263        .map(|import| import.name.as_str())
264        .collect::<BTreeSet<_>>();
265    let invoked = program
266        .components
267        .iter()
268        .flat_map(|component| invoked_components(&component.view))
269        .filter(|name| imported.contains(name.as_str()) && !local.contains(name.as_str()))
270        .collect::<BTreeSet<_>>();
271    let prefetched = program
272        .components
273        .iter()
274        .flat_map(|component| prefetched_components(&component.view))
275        .collect::<BTreeSet<_>>();
276    if invoked.is_empty() {
277        return;
278    }
279    let prefix = invoked
280        .iter()
281        .map(|name| {
282            component_import(
283                name,
284                program.components.iter().any(supports_hydration),
285                prefetched.contains(name),
286            )
287        })
288        .collect::<String>();
289    javascript.insert_str(0, &prefix);
290}
291
292fn supports_hydration(component: &ComponentDefinition) -> bool {
293    component.render.mode == ComponentRenderMode::Universal
294}
295
296fn component_import(name: &str, hydration: bool, prefetch: bool) -> String {
297    let hydrate = if hydration {
298        format!(", hydrate{name}")
299    } else {
300        String::new()
301    };
302    let prefetch = if prefetch {
303        format!(", __noxidPrefetch{name}")
304    } else {
305        String::new()
306    };
307    format!("import {{ mount{name}{hydrate}{prefetch} }} from \"./{name}.js\";\n")
308}
309
310fn generate_program_inner(
311    program: &SemanticProgram,
312    validator_module: Option<&str>,
313    resource_module: Option<&str>,
314    stream_module: Option<&str>,
315    agent_module: Option<&str>,
316) -> Result<GeneratedComponent, String> {
317    let mut declarations = Vec::new();
318    let mut compiled = Vec::new();
319    for component in &program.components {
320        let scope = noxid_css_ir::scope_id(component.id.as_str());
321        let dom = noxid_dom_ir::lower(component);
322        let mut builder = TemplateBuilder {
323            scope: &scope,
324            component: &component.name,
325            marker: 0,
326            conditional: 0,
327            match_block: 0,
328            stream_block: 0,
329            for_block: 0,
330            slot_children: 0,
331            declarations: &mut declarations,
332        };
333        let template = build_template(&dom.nodes, &mut builder);
334        let template_name = format!("{}Template", component.name);
335        declarations.push((template_name.clone(), template.html));
336        compiled.push((component, template_name, template.operations));
337    }
338
339    let mut runtime_imports = BTreeSet::from([
340        "componentEmitter".to_string(),
341        "createOwner".to_string(),
342        "disposeOwner".to_string(),
343    ]);
344    for (component, _, operations) in &compiled {
345        collect_component_runtime_imports(
346            component,
347            operations,
348            supports_hydration(component),
349            &mut runtime_imports,
350        );
351    }
352    if !program.functions.is_empty() {
353        runtime_imports.insert("toSource".into());
354    }
355    let mut js = format!(
356        "import {{ {} }} from \"./noxid-runtime.js\";\n",
357        runtime_imports
358            .iter()
359            .cloned()
360            .collect::<Vec<_>>()
361            .join(", ")
362    );
363    for module in &program.external_modules {
364        if !module.functions.is_empty() {
365            js.push_str(&format!(
366                "import {{ {} }} from \"{}\";\n",
367                module
368                    .functions
369                    .iter()
370                    .map(|function| function.name.as_str())
371                    .collect::<Vec<_>>()
372                    .join(", "),
373                js_escape(&module.runtime_source)
374            ));
375        }
376    }
377    if !program.external_modules.is_empty() {
378        js.push_str(&external_validation_prelude(program));
379    }
380    if program
381        .components
382        .iter()
383        .any(|component| noxid_ir::component_uses_date_helpers(component, &program.functions))
384    {
385        js.push_str(noxid_ir::DATE_HELPERS_JS);
386        js.push('\n');
387    }
388    emit_function_definitions(&mut js, &program.functions)?;
389    let mut state_validators = program
390        .components
391        .iter()
392        .flat_map(|component| {
393            component
394                .states
395                .iter()
396                .filter_map(|state| state_validator_id(program, component, &state.ty))
397        })
398        .collect::<BTreeSet<_>>();
399    state_validators.extend(
400        program
401            .components
402            .iter()
403            .filter_map(|component| component.presence.as_ref())
404            .map(|presence| presence.record_type.to_string()),
405    );
406    if !state_validators.is_empty() {
407        let module = validator_module.expect("named state requires a generated validator module");
408        js.push_str(&format!(
409            "import {{ typeValidators as __noxidTypeValidators }} from \"./{}\";\n",
410            js_escape(module)
411        ));
412    }
413    let mut resource_names = program
414        .components
415        .iter()
416        .flat_map(|component| component.resources.iter())
417        .map(|resource| resource.resource_name.clone())
418        .collect::<std::collections::BTreeSet<_>>();
419    resource_names.extend(
420        program
421            .components
422            .iter()
423            .flat_map(|component| component.actions.iter())
424            .flat_map(|action| action.invalidation.resources.iter())
425            .map(|resource| resource_definition_name(program, resource)),
426    );
427    if !resource_names.is_empty() {
428        let module = resource_module.expect("resource acquisitions require a generated module");
429        js.push_str(&format!(
430            "import {{ {}, queryClient as __noxidQueryClient }} from \"./{}\";\n",
431            resource_names.into_iter().collect::<Vec<_>>().join(", "),
432            js_escape(module)
433        ));
434    }
435    let stream_names = program
436        .components
437        .iter()
438        .flat_map(|component| component.streams.iter())
439        .map(|stream| stream.stream_name.clone())
440        .collect::<std::collections::BTreeSet<_>>();
441    if !stream_names.is_empty() {
442        let module = stream_module.expect("stream acquisitions require a generated module");
443        js.push_str(&format!(
444            "import {{ {} }} from \"./{}\";\n",
445            stream_names.into_iter().collect::<Vec<_>>().join(", "),
446            js_escape(module)
447        ));
448    }
449    let agent_names = program
450        .components
451        .iter()
452        .flat_map(|component| component.agents.iter())
453        .map(|agent| agent.agent_name.clone())
454        .collect::<std::collections::BTreeSet<_>>();
455    if !agent_names.is_empty() {
456        let module = agent_module.expect("agent acquisitions require a generated module");
457        js.push_str(&format!(
458            "import {{ {} }} from \"./{}\";\n",
459            agent_names.into_iter().collect::<Vec<_>>().join(", "),
460            js_escape(module)
461        ));
462    }
463    js.push('\n');
464    for (name, html) in declarations {
465        js.push_str(&format!(
466            "const {name} = document.createElement(\"template\");\n{name}.innerHTML = \"{}\";\n\n",
467            js_escape(&html)
468        ));
469    }
470    for (component, template_name, operations) in compiled {
471        emit_component(&mut js, program, component, &template_name, &operations)?;
472    }
473    if js.contains("$noxEqual(") {
474        js.push('\n');
475        js.push_str(LANGUAGE_VALUE_EQUALITY_FUNCTION);
476    }
477    Ok(GeneratedComponent {
478        javascript: js,
479        css: String::new(),
480        runtime_imports,
481        modules: vec![],
482    })
483}
484
485fn state_validator_id(
486    program: &SemanticProgram,
487    component: &ComponentDefinition,
488    ty: &noxid_types::Type,
489) -> Option<String> {
490    let noxid_types::Type::Named(name) = ty else {
491        return None;
492    };
493    component
494        .types
495        .iter()
496        .find(|definition| definition.name == *name)
497        .map(|definition| definition.id.to_string())
498        .or_else(|| {
499            component
500                .machines
501                .iter()
502                .find(|machine| machine.name == *name)
503                .map(|machine| machine.id.to_string())
504        })
505        .or_else(|| {
506            program
507                .types
508                .iter()
509                .find(|definition| definition.name == *name)
510                .map(|definition| definition.id.to_string())
511        })
512}
513
514fn resource_definition_name(program: &SemanticProgram, id: &SemanticId) -> String {
515    program
516        .resources
517        .iter()
518        .find(|resource| resource.id == *id)
519        .map(|resource| resource.name.clone())
520        .unwrap_or_else(|| {
521            panic!("validated action invalidation references missing resource `{id}`")
522        })
523}
524
525fn invoked_components(nodes: &[SemanticViewNode]) -> BTreeSet<String> {
526    fn visit(nodes: &[SemanticViewNode], output: &mut BTreeSet<String>) {
527        for node in nodes {
528            match node {
529                SemanticViewNode::ComponentInvocation {
530                    component,
531                    children,
532                    ..
533                } => {
534                    if let Some(name) = component.as_str().strip_prefix("component:") {
535                        output.insert(name.to_string());
536                    }
537                    visit(children, output);
538                }
539                SemanticViewNode::Slot { .. } => {}
540                SemanticViewNode::Element { children, .. }
541                | SemanticViewNode::Conditional { children, .. }
542                | SemanticViewNode::For { children, .. } => visit(children, output),
543                SemanticViewNode::Match { cases, .. } | SemanticViewNode::Stream { cases, .. } => {
544                    for case in cases {
545                        visit(&case.children, output);
546                    }
547                }
548                SemanticViewNode::Text { .. } | SemanticViewNode::Binding { .. } => {}
549            }
550        }
551    }
552    let mut output = BTreeSet::new();
553    visit(nodes, &mut output);
554    output
555}
556
557fn prefetched_components(nodes: &[SemanticViewNode]) -> BTreeSet<String> {
558    fn visit(nodes: &[SemanticViewNode], output: &mut BTreeSet<String>) {
559        for node in nodes {
560            match node {
561                SemanticViewNode::ComponentInvocation {
562                    component,
563                    prefetch,
564                    children,
565                    ..
566                } => {
567                    if prefetch.is_some()
568                        && let Some(name) = component.as_str().strip_prefix("component:")
569                    {
570                        output.insert(name.to_string());
571                    }
572                    visit(children, output);
573                }
574                SemanticViewNode::Element { children, .. }
575                | SemanticViewNode::Conditional { children, .. }
576                | SemanticViewNode::For { children, .. } => visit(children, output),
577                SemanticViewNode::Match { cases, .. } | SemanticViewNode::Stream { cases, .. } => {
578                    for case in cases {
579                        visit(&case.children, output);
580                    }
581                }
582                SemanticViewNode::Slot { .. }
583                | SemanticViewNode::Text { .. }
584                | SemanticViewNode::Binding { .. } => {}
585            }
586        }
587    }
588    let mut output = BTreeSet::new();
589    visit(nodes, &mut output);
590    output
591}
592
593fn collect_component_runtime_imports(
594    component: &ComponentDefinition,
595    operations: &[Operation],
596    hydration: bool,
597    imports: &mut BTreeSet<String>,
598) {
599    if !component.middleware.is_empty() || !component.capabilities.is_empty() {
600        imports.insert("authorizeComponent".into());
601    }
602    if !component.props.is_empty() {
603        imports.insert("propSource".into());
604    }
605    if component
606        .states
607        .iter()
608        .any(|state| matches!(state.ty, noxid_types::Type::Array(_)))
609    {
610        imports.insert("collectionSignal".into());
611    }
612    if component
613        .states
614        .iter()
615        .any(|state| !matches!(state.ty, noxid_types::Type::Array(_)))
616    {
617        imports.insert("signal".into());
618    }
619    if !component.computed.is_empty() {
620        imports.insert("computed".into());
621    }
622    if !component.resources.is_empty() {
623        imports.insert("acquireResource".into());
624    }
625    if component
626        .resources
627        .iter()
628        .any(|resource| !resource.refresh.is_empty())
629    {
630        imports.insert("attachResourceRefreshTriggers".into());
631    }
632    if component
633        .actions
634        .iter()
635        .any(|action| !action.invalidation.resources.is_empty())
636    {
637        imports.insert("invalidateCompiledResources".into());
638    }
639    if component.presence.is_some() {
640        imports.insert("acquirePresence".into());
641    }
642    if component.streams.iter().any(|stream| {
643        component
644            .presence
645            .as_ref()
646            .is_none_or(|presence| presence.stream != stream.stream)
647    }) {
648        imports.insert("acquireStream".into());
649    }
650    if !component.agents.is_empty() {
651        imports.insert("acquireAgent".into());
652    }
653    if !component.context_providers.is_empty() {
654        imports.insert("provideContext".into());
655        if component
656            .context_providers
657            .iter()
658            .flat_map(|provider| &provider.fields)
659            .any(|field| source_requires_computed(&field.value, &field.dependencies))
660        {
661            imports.insert("computed".into());
662        }
663    }
664    if !component.context_uses.is_empty() {
665        imports.insert("useContext".into());
666    }
667    if !component.actions.is_empty() {
668        imports.insert("runAction".into());
669    }
670    if component.lifecycle.is_some() {
671        imports.insert("registerLifecycle".into());
672    }
673    if !component.behaviors.is_empty() {
674        imports.insert("applyBehavior".into());
675    }
676    if !component.regions.is_empty() {
677        imports.insert("validateComponentRegion".into());
678    }
679    if component.actions.iter().any(|action| {
680        !action.parameters.is_empty() || statements_declare_locals(&action.statements)
681    }) || component
682        .effects
683        .iter()
684        .any(|effect| statements_declare_locals(&effect.statements))
685    {
686        imports.insert("toSource".into());
687    }
688    if component
689        .effects
690        .iter()
691        .any(|item| matches!(item.kind, ReactiveEffectKind::Effect))
692    {
693        imports.insert("effect".into());
694        imports.insert("batch".into());
695    }
696    if component
697        .effects
698        .iter()
699        .any(|item| matches!(item.kind, ReactiveEffectKind::Watch { .. }))
700    {
701        imports.insert("watch".into());
702        imports.insert("batch".into());
703        imports.insert("toSource".into());
704    }
705    if component.actions.iter().any(|action| {
706        noxid_ir::flatten_statements(&action.statements)
707            .iter()
708            .any(|statement| matches!(statement, SemanticStatement::Transition { .. }))
709    }) || component.effects.iter().any(|effect| {
710        noxid_ir::flatten_statements(&effect.statements)
711            .iter()
712            .any(|statement| matches!(statement, SemanticStatement::Transition { .. }))
713    }) {
714        imports.insert("transitionMachine".into());
715    }
716    collect_operation_runtime_imports(operations, hydration, imports);
717}
718
719fn collect_operation_runtime_imports(
720    operations: &[Operation],
721    hydration: bool,
722    imports: &mut BTreeSet<String>,
723) {
724    for operation in operations {
725        match operation {
726            Operation::Text { .. } => {
727                imports.insert("bindText".into());
728                if hydration {
729                    imports.insert("hydrateText".into());
730                }
731                imports.insert("findMarker".into());
732            }
733            Operation::Attribute { .. } => {
734                imports.insert("bindAttribute".into());
735            }
736            Operation::TwoWayBinding { .. } => {
737                imports.insert("bindProperty".into());
738            }
739            Operation::Event { .. } => {
740                imports.insert("listen".into());
741            }
742            Operation::RoutePrefetch { .. } => {
743                imports.insert("attachCompiledPrefetch".into());
744            }
745            Operation::Attachment { .. } => {
746                imports.insert("installAnimateAttachment".into());
747            }
748            Operation::Slot { .. } => {
749                imports.insert("mountSlot".into());
750                if hydration {
751                    imports.insert("hydrateSlot".into());
752                }
753                imports.insert("findMarker".into());
754            }
755            Operation::Component {
756                props,
757                prefetch,
758                children,
759                ..
760            } => {
761                imports.insert("mountComponent".into());
762                if hydration {
763                    imports.insert("hydrateComponent".into());
764                }
765                imports.insert("findMarker".into());
766                if prefetch.is_some() {
767                    imports.insert("attachCompiledPrefetch".into());
768                }
769                if let Some(children) = children {
770                    collect_operation_runtime_imports(&children.operations, hydration, imports);
771                }
772                if props
773                    .iter()
774                    .any(|prop| source_requires_computed(&prop.expression, &prop.dependencies))
775                {
776                    imports.insert("computed".into());
777                }
778            }
779            Operation::Conditional {
780                condition,
781                dependencies,
782                transition,
783                operations,
784                ..
785            } => {
786                imports.insert("mountIf".into());
787                if hydration {
788                    imports.insert("hydrateIf".into());
789                }
790                imports.insert("findMarker".into());
791                if transition.is_some() {
792                    imports.insert("createPresence".into());
793                }
794                if source_requires_computed(condition, dependencies) {
795                    imports.insert("computed".into());
796                }
797                collect_operation_runtime_imports(operations, hydration, imports);
798            }
799            Operation::Match {
800                expression,
801                dependencies,
802                cases,
803                ..
804            } => {
805                imports.insert("mountMatch".into());
806                if hydration {
807                    imports.insert("hydrateMatch".into());
808                }
809                imports.insert("findMarker".into());
810                if match_source_requires_computed(expression, dependencies) {
811                    imports.insert("computed".into());
812                }
813                if cases.iter().any(|case| case.binding.is_some()) {
814                    imports.insert("toSource".into());
815                }
816                for case in cases {
817                    collect_operation_runtime_imports(&case.operations, hydration, imports);
818                }
819            }
820            Operation::Stream {
821                expression,
822                dependencies,
823                cases,
824                ..
825            } => {
826                imports.insert("mountStream".into());
827                if hydration {
828                    imports.insert("hydrateStream".into());
829                }
830                imports.insert("findMarker".into());
831                if source_requires_computed(expression, dependencies) {
832                    imports.insert("computed".into());
833                }
834                if cases.iter().any(|case| case.binding.is_some()) {
835                    imports.insert("toSource".into());
836                }
837                for case in cases {
838                    collect_operation_runtime_imports(&case.operations, hydration, imports);
839                }
840            }
841            Operation::For {
842                collection,
843                dependencies,
844                operations,
845                ..
846            } => {
847                imports.insert("mountFor".into());
848                if hydration {
849                    imports.insert("hydrateFor".into());
850                }
851                imports.insert("findMarker".into());
852                imports.insert("toSource".into());
853                if source_requires_computed(collection, dependencies) {
854                    imports.insert("computed".into());
855                }
856                collect_operation_runtime_imports(operations, hydration, imports);
857            }
858        }
859    }
860}
861
862fn source_requires_computed(expression: &SemanticExpr, dependencies: &[SemanticId]) -> bool {
863    !dependencies.is_empty() && !matches!(expression.kind, SemanticExprKind::Reference(_))
864}
865
866fn match_source_requires_computed(expression: &SemanticExpr, dependencies: &[SemanticId]) -> bool {
867    !dependencies.is_empty()
868        && (matches!(expression.ty, noxid_types::Type::Optional(_))
869            || source_requires_computed(expression, dependencies))
870}
871
872fn build_template(nodes: &[DomNode], builder: &mut TemplateBuilder<'_>) -> Template {
873    let mut output = Template::default();
874    for node in nodes {
875        match node {
876            DomNode::StaticText(value) => output.html.push_str(&html_escape(value)),
877            DomNode::DynamicText {
878                id,
879                expression,
880                dependencies,
881            } => {
882                builder.marker += 1;
883                output
884                    .html
885                    .push_str(&format!("<!--noxid-text-{}-->", builder.marker));
886                output.operations.push(Operation::Text {
887                    id: id.clone(),
888                    marker: builder.marker,
889                    expression: expression.clone(),
890                    dependencies: dependencies.clone(),
891                });
892            }
893            DomNode::Element {
894                tag,
895                attributes,
896                attachments,
897                prefetch,
898                children,
899            } => {
900                output.html.push('<');
901                output.html.push_str(tag);
902                output
903                    .html
904                    .push_str(&format!(" data-noxid-scope=\"{}\"", builder.scope));
905                for attribute in attributes {
906                    match attribute {
907                        DomAttribute::Static { name, value } => output
908                            .html
909                            .push_str(&format!(" {name}=\"{}\"", html_escape(value))),
910                        DomAttribute::Dynamic {
911                            id,
912                            name,
913                            expression,
914                            dependencies,
915                        } => {
916                            builder.marker += 1;
917                            output
918                                .html
919                                .push_str(&format!(" data-noxid-bind-{}=\"\"", builder.marker));
920                            output.operations.push(Operation::Attribute {
921                                id: id.clone(),
922                                marker: builder.marker,
923                                name: name.clone(),
924                                expression: expression.clone(),
925                                dependencies: dependencies.clone(),
926                            });
927                        }
928                        DomAttribute::Event {
929                            id,
930                            name,
931                            action,
932                            arguments,
933                        } => {
934                            builder.marker += 1;
935                            output
936                                .html
937                                .push_str(&format!(" data-noxid-event-{}=\"\"", builder.marker));
938                            output.operations.push(Operation::Event {
939                                id: id.clone(),
940                                marker: builder.marker,
941                                event: name.clone(),
942                                action: action.clone(),
943                                arguments: arguments.clone(),
944                            });
945                        }
946                        DomAttribute::TwoWayBinding { id, name, target } => {
947                            builder.marker += 1;
948                            output
949                                .html
950                                .push_str(&format!(" data-noxid-two-way-{}=\"\"", builder.marker));
951                            output.operations.push(Operation::TwoWayBinding {
952                                id: id.clone(),
953                                marker: builder.marker,
954                                name: name.clone(),
955                                target: target.clone(),
956                            });
957                        }
958                    }
959                }
960                for attachment in attachments {
961                    builder.marker += 1;
962                    output
963                        .html
964                        .push_str(&format!(" data-noxid-attach-{}=\"\"", builder.marker));
965                    output.operations.push(Operation::Attachment {
966                        attachment: attachment.clone(),
967                        marker: builder.marker,
968                    });
969                }
970                if let Some(trigger) = prefetch {
971                    builder.marker += 1;
972                    output
973                        .html
974                        .push_str(&format!(" data-noxid-prefetch-{}=\"\"", builder.marker));
975                    output.operations.push(Operation::RoutePrefetch {
976                        marker: builder.marker,
977                        trigger: *trigger,
978                    });
979                }
980                output.html.push('>');
981                let child = build_template(children, builder);
982                output.html.push_str(&child.html);
983                output.operations.extend(child.operations);
984                output.html.push_str("</");
985                output.html.push_str(tag);
986                output.html.push('>');
987            }
988            DomNode::Component {
989                id,
990                target,
991                props,
992                handlers,
993                prefetch,
994                children,
995            } => {
996                builder.marker += 1;
997                let component_marker = builder.marker;
998                let slot_children = if children.is_empty() {
999                    None
1000                } else {
1001                    builder.slot_children += 1;
1002                    let block_name = format!(
1003                        "{}SlotChildren{}Template",
1004                        builder.component, builder.slot_children
1005                    );
1006                    let child = build_template(children, builder);
1007                    builder.declarations.push((block_name.clone(), child.html));
1008                    Some(SlotChildren {
1009                        template_name: block_name,
1010                        operations: child.operations,
1011                    })
1012                };
1013                output
1014                    .html
1015                    .push_str(&format!("<!--noxid-component-{component_marker}-->"));
1016                output.operations.push(Operation::Component {
1017                    id: id.clone(),
1018                    marker: component_marker,
1019                    target: target.clone(),
1020                    props: props.clone(),
1021                    handlers: handlers.clone(),
1022                    prefetch: *prefetch,
1023                    children: slot_children,
1024                });
1025            }
1026            DomNode::Slot { id } => {
1027                builder.marker += 1;
1028                output
1029                    .html
1030                    .push_str(&format!("<!--noxid-slot-{}-->", builder.marker));
1031                output.operations.push(Operation::Slot {
1032                    id: id.clone(),
1033                    marker: builder.marker,
1034                });
1035            }
1036            DomNode::Conditional {
1037                id,
1038                condition,
1039                dependencies,
1040                transition,
1041                children,
1042            } => {
1043                builder.marker += 1;
1044                let conditional_marker = builder.marker;
1045                builder.conditional += 1;
1046                let block_name = format!("{}If{}Template", builder.component, builder.conditional);
1047                let child = build_template(children, builder);
1048                builder.declarations.push((block_name.clone(), child.html));
1049                output
1050                    .html
1051                    .push_str(&format!("<!--noxid-if-{conditional_marker}-->"));
1052                output.operations.push(Operation::Conditional {
1053                    id: id.clone(),
1054                    marker: conditional_marker,
1055                    condition: condition.clone(),
1056                    dependencies: dependencies.clone(),
1057                    transition: transition.clone(),
1058                    template_name: block_name,
1059                    operations: child.operations,
1060                });
1061            }
1062            DomNode::Match {
1063                id,
1064                expression,
1065                dependencies,
1066                cases,
1067            } => {
1068                builder.marker += 1;
1069                let match_marker = builder.marker;
1070                builder.match_block += 1;
1071                let match_ordinal = builder.match_block;
1072                let mut compiled_cases = Vec::new();
1073                for case in cases {
1074                    let variant = symbol_name(&case.variant).to_string();
1075                    let template_name =
1076                        format!("{}Match{match_ordinal}{variant}Template", builder.component);
1077                    let child = build_template(&case.children, builder);
1078                    builder
1079                        .declarations
1080                        .push((template_name.clone(), child.html));
1081                    compiled_cases.push(MatchOperationCase {
1082                        variant,
1083                        binding: case.binding.clone(),
1084                        template_name,
1085                        operations: child.operations,
1086                    });
1087                }
1088                output
1089                    .html
1090                    .push_str(&format!("<!--noxid-match-{match_marker}-->"));
1091                output.operations.push(Operation::Match {
1092                    id: id.clone(),
1093                    marker: match_marker,
1094                    expression: expression.clone(),
1095                    dependencies: dependencies.clone(),
1096                    cases: compiled_cases,
1097                });
1098            }
1099            DomNode::Stream {
1100                id,
1101                expression,
1102                dependencies,
1103                cases,
1104            } => {
1105                builder.marker += 1;
1106                let stream_marker = builder.marker;
1107                builder.stream_block += 1;
1108                let stream_ordinal = builder.stream_block;
1109                let mut compiled_cases = Vec::new();
1110                for case in cases {
1111                    let variant = symbol_name(&case.variant).to_string();
1112                    let template_name = format!(
1113                        "{}Stream{stream_ordinal}{variant}Template",
1114                        builder.component
1115                    );
1116                    let child = build_template(&case.children, builder);
1117                    builder
1118                        .declarations
1119                        .push((template_name.clone(), child.html));
1120                    compiled_cases.push(MatchOperationCase {
1121                        variant,
1122                        binding: case.binding.clone(),
1123                        template_name,
1124                        operations: child.operations,
1125                    });
1126                }
1127                output
1128                    .html
1129                    .push_str(&format!("<!--noxid-stream-{stream_marker}-->"));
1130                output.operations.push(Operation::Stream {
1131                    id: id.clone(),
1132                    marker: stream_marker,
1133                    expression: expression.clone(),
1134                    dependencies: dependencies.clone(),
1135                    cases: compiled_cases,
1136                });
1137            }
1138            DomNode::For {
1139                id,
1140                binding,
1141                collection,
1142                dependencies,
1143                key,
1144                children,
1145            } => {
1146                builder.marker += 1;
1147                let for_marker = builder.marker;
1148                builder.for_block += 1;
1149                let template_name =
1150                    format!("{}For{}Template", builder.component, builder.for_block);
1151                let child = build_template(children, builder);
1152                builder
1153                    .declarations
1154                    .push((template_name.clone(), child.html));
1155                output
1156                    .html
1157                    .push_str(&format!("<!--noxid-for-{for_marker}-->"));
1158                output.operations.push(Operation::For {
1159                    id: id.clone(),
1160                    marker: for_marker,
1161                    collection: collection.clone(),
1162                    dependencies: dependencies.clone(),
1163                    binding: binding.clone(),
1164                    key: key.clone(),
1165                    template_name,
1166                    operations: child.operations,
1167                });
1168            }
1169        }
1170    }
1171    output
1172}
1173
1174fn emit_component(
1175    js: &mut String,
1176    program: &SemanticProgram,
1177    component: &ComponentDefinition,
1178    template_name: &str,
1179    operations: &[Operation],
1180) -> Result<(), String> {
1181    let hydration = supports_hydration(component);
1182    emit_hmr_action_factory(js, program, component)?;
1183    let guarded = !component.middleware.is_empty() || !component.capabilities.is_empty();
1184    let query_client = if component.resources.is_empty() {
1185        ""
1186    } else {
1187        "\n  if (!resourceOptions.queryClient) resourceOptions = { ...resourceOptions, queryClient: __noxidQueryClient };"
1188    };
1189    let resource_options_binding = if component.resources.is_empty() {
1190        "const"
1191    } else {
1192        "let"
1193    };
1194    let hydrating_parameter = if hydration {
1195        ", $noxHydrating = false"
1196    } else {
1197        ""
1198    };
1199    js.push_str(&format!("export function mount{}($noxRoot, $noxProps = {{}}, $noxEventHandlers = {{}}, $noxParentOwner = null, $noxRuntimeOptions = null{hydrating_parameter}) {{\n  const $noxOwner = createOwner($noxParentOwner, {{ semanticId: \"{}\", label: \"{}\" }});\n  {} $noxResourceOptions = $noxRuntimeOptions ?? $noxParentOwner?.resourceOptions ?? {{}};{}\n  $noxOwner.resourceOptions = $noxResourceOptions;\n  const $noxEmit = componentEmitter($noxEventHandlers, \"{}\");\n", component.name, js_escape(component.id.as_str()), js_escape(&component.name), resource_options_binding, query_client.replace("resourceOptions", "$noxResourceOptions"), js_escape(&component.name)));
1200    if component
1201        .actions
1202        .iter()
1203        .any(|action| action.execution.is_remote())
1204    {
1205        js.push_str("  const $noxExecuteBoundary = $noxResourceOptions.executeBoundary;\n");
1206    }
1207    if guarded {
1208        let middleware = component
1209            .middleware
1210            .iter()
1211            .map(|usage| format!("\"{}\"", js_escape(&usage.name)))
1212            .collect::<Vec<_>>()
1213            .join(", ");
1214        let capabilities = component
1215            .capabilities
1216            .iter()
1217            .map(|capability| format!("\"{}\"", js_escape(capability.id.as_str())))
1218            .collect::<Vec<_>>()
1219            .join(", ");
1220        js.push_str(&format!(
1221            "  try {{ authorizeComponent($noxResourceOptions, \"{}\", [{}], [{}]); }} catch ($noxError) {{ disposeOwner($noxOwner); throw $noxError; }}\n",
1222            js_escape(component.id.as_str()),
1223            capabilities,
1224            middleware,
1225        ));
1226    }
1227    for prop in &component.props {
1228        js.push_str(&format!(
1229            "  const {} = propSource($noxProps, \"{}\", \"{}\");\n",
1230            prop.name,
1231            js_escape(&prop.name),
1232            js_escape(&component.name)
1233        ));
1234    }
1235    for context_use in &component.context_uses {
1236        js.push_str(&format!(
1237            "  const $noxContext_{} = useContext($noxOwner, \"{}\", \"{}\");\n",
1238            context_use.name,
1239            js_escape(context_use.context.as_str()),
1240            js_escape(&component.name)
1241        ));
1242        for field in &context_use.fields {
1243            js.push_str(&format!(
1244                "  const {} = $noxContext_{}[\"{}\"];\n",
1245                field.name,
1246                context_use.name,
1247                js_escape(&field.name)
1248            ));
1249        }
1250    }
1251    for state in &component.states {
1252        let constructor = if matches!(state.ty, noxid_types::Type::Array(_)) {
1253            "collectionSignal"
1254        } else {
1255            "signal"
1256        };
1257        let validator = state_validator_id(program, component, &state.ty)
1258            .map(|id| {
1259                format!(
1260                    ", validatorId: \"{}\", validator: __noxidTypeValidators[\"{}\"]",
1261                    js_escape(&id),
1262                    js_escape(&id)
1263                )
1264            })
1265            .unwrap_or_default();
1266        js.push_str(&format!(
1267            "  const $noxInitial_{} = {};\n  const {} = {constructor}(globalThis.__NOXID_HMR__ ? globalThis.__NOXID_HMR__.initialState(\"{}\", $noxInitial_{}) : $noxInitial_{}, {{ semanticId: \"{}\", type: \"{}\", owner: $noxOwner{validator} }});\n",
1268            state.name,
1269            emit_expr(&state.initializer)?,
1270            state.name,
1271            js_escape(state.id.as_str()),
1272            state.name,
1273            state.name,
1274            js_escape(state.id.as_str()),
1275            js_escape(&state.ty.to_string())
1276        ));
1277    }
1278    for computed in &component.computed {
1279        js.push_str(&format!(
1280            "  const {} = computed(() => {}, $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
1281            computed.name,
1282            emit_expr(&computed.expression)?,
1283            emit_sources(&computed.dependencies),
1284            js_escape(computed.id.as_str())
1285        ));
1286    }
1287    for resource in &component.resources {
1288        let arguments = resource
1289            .arguments
1290            .iter()
1291            .map(|argument| {
1292                Ok(format!(
1293                    "[\"{}\"]: {}",
1294                    js_escape(&argument.name),
1295                    emit_expr(&argument.value)?
1296                ))
1297            })
1298            .collect::<Result<Vec<_>, String>>()?
1299            .join(", ");
1300        let mut dependencies = resource
1301            .arguments
1302            .iter()
1303            .flat_map(|argument| argument.dependencies.clone())
1304            .collect::<Vec<_>>();
1305        dependencies.sort();
1306        dependencies.dedup();
1307        js.push_str(&format!(
1308            "  const $noxResourceHandle_{} = acquireResource({}, () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n  const {} = $noxResourceHandle_{}.state;\n",
1309            resource.name,
1310            resource.resource_name,
1311            arguments,
1312            emit_sources(&dependencies),
1313            js_escape(resource.id.as_str()),
1314            resource.name,
1315            resource.name,
1316        ));
1317        if !resource.refresh.is_empty() {
1318            js.push_str(&format!(
1319                "  attachResourceRefreshTriggers($noxResourceHandle_{}, Object.freeze([{}]), $noxOwner, $noxResourceOptions);\n",
1320                resource.name,
1321                emit_resource_refresh_triggers(&resource.refresh),
1322            ));
1323        }
1324    }
1325    for stream in &component.streams {
1326        let arguments = stream
1327            .arguments
1328            .iter()
1329            .map(|argument| {
1330                Ok(format!(
1331                    "[\"{}\"]: {}",
1332                    js_escape(&argument.name),
1333                    emit_expr(&argument.value)?
1334                ))
1335            })
1336            .collect::<Result<Vec<_>, String>>()?
1337            .join(", ");
1338        let mut dependencies = stream
1339            .arguments
1340            .iter()
1341            .flat_map(|argument| argument.dependencies.clone())
1342            .collect::<Vec<_>>();
1343        dependencies.sort();
1344        dependencies.dedup();
1345        if let Some(presence) = component
1346            .presence
1347            .as_ref()
1348            .filter(|presence| presence.stream == stream.stream)
1349        {
1350            js.push_str(&format!(
1351                "  const $noxStreamHandle_presence = acquirePresence({}, Object.freeze({{ id: \"{}\", stream: \"{}\", recordType: \"{}\", memberType: \"{}\", snapshotType: \"{}\", ttlMilliseconds: {}, heartbeatMilliseconds: {}, validateRecord: __noxidTypeValidators[\"{}\"] }}), () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n  const presence = $noxStreamHandle_presence.events;\n",
1352                stream.stream_name,
1353                js_escape(presence.id.as_str()),
1354                js_escape(presence.stream.as_str()),
1355                js_escape(presence.record_type.as_str()),
1356                js_escape(presence.member_type.as_str()),
1357                js_escape(presence.snapshot_type.as_str()),
1358                presence.ttl_milliseconds,
1359                presence.heartbeat_milliseconds,
1360                js_escape(presence.record_type.as_str()),
1361                arguments,
1362                emit_sources(&dependencies),
1363                js_escape(stream.id.as_str()),
1364            ));
1365            continue;
1366        }
1367        js.push_str(&format!(
1368            "  const $noxStreamHandle_{} = acquireStream({}, () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n  const {} = $noxStreamHandle_{}.events;\n",
1369            stream.name,
1370            stream.stream_name,
1371            arguments,
1372            emit_sources(&dependencies),
1373            js_escape(stream.id.as_str()),
1374            stream.name,
1375            stream.name,
1376        ));
1377    }
1378    for agent in &component.agents {
1379        js.push_str(&format!(
1380            "  const $noxAgentHandle_{} = acquireAgent({}, () => ({}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n  const {} = $noxAgentHandle_{}.events;\n",
1381            agent.name,
1382            agent.agent_name,
1383            emit_expr(&agent.input)?,
1384            emit_sources(&agent.dependencies),
1385            js_escape(agent.id.as_str()),
1386            agent.name,
1387            agent.name,
1388        ));
1389    }
1390    for provider in &component.context_providers {
1391        let fields = provider
1392            .fields
1393            .iter()
1394            .map(|field| {
1395                Ok(format!(
1396                    "\"{}\": {}",
1397                    js_escape(&field.name),
1398                    emit_source(&field.value, &field.dependencies, "$noxOwner")?
1399                ))
1400            })
1401            .collect::<Result<Vec<_>, String>>()?
1402            .join(", ");
1403        js.push_str(&format!(
1404            "  provideContext($noxOwner, \"{}\", {{ {fields} }});\n",
1405            js_escape(provider.context.as_str())
1406        ));
1407    }
1408    let scope_names = hmr_scope_names(component);
1409    let resource_options_scope = if component
1410        .actions
1411        .iter()
1412        .any(|action| !action.invalidation.resources.is_empty())
1413    {
1414        ", $noxResourceOptions"
1415    } else {
1416        ""
1417    };
1418    js.push_str(&format!(
1419        "  const $noxActionScope = {{ $noxOwner, $noxEmit{resource_options_scope}{}{} }};\n  let $noxActions = __noxidCreate{}Actions($noxActionScope);\n",
1420        scope_names
1421            .iter()
1422            .map(|name| format!(", {name}"))
1423            .collect::<String>(),
1424        if component.actions.iter().any(|action| action.execution.is_remote()) {
1425            ", $noxExecuteBoundary"
1426        } else {
1427            ""
1428        },
1429        component.name,
1430    ));
1431    for action in &component.actions {
1432        js.push_str(&format!(
1433            "  function {}(...$noxInputs) {{ return $noxActions.{}(...$noxInputs); }}\n",
1434            action.name, action.name,
1435        ));
1436    }
1437    for reactive in &component.effects {
1438        match &reactive.kind {
1439            ReactiveEffectKind::Effect => {
1440                js.push_str("  effect(() => batch(() => {\n");
1441                emit_statements(js, &reactive.statements, 2)?;
1442                js.push_str(&format!(
1443                    "  }}), $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
1444                    emit_sources(&reactive.dependencies),
1445                    js_escape(reactive.id.as_str())
1446                ));
1447            }
1448            ReactiveEffectKind::Watch {
1449                expression,
1450                current,
1451                previous,
1452            } => {
1453                js.push_str(&format!(
1454                    "  watch(() => {}, ($noxWatchCurrentInput, $noxWatchPreviousInput) => {{\n",
1455                    emit_expr(expression)?,
1456                ));
1457                for (parameter, input) in [
1458                    (current, "$noxWatchCurrentInput"),
1459                    (previous, "$noxWatchPreviousInput"),
1460                ] {
1461                    js.push_str(&format!(
1462                        "    const {} = toSource({input});\n",
1463                        parameter.name
1464                    ));
1465                }
1466                js.push_str("    return batch(() => {\n");
1467                emit_statements(js, &reactive.statements, 3)?;
1468                js.push_str(&format!(
1469                    "    }});\n  }}, $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
1470                    emit_sources(&reactive.dependencies),
1471                    js_escape(reactive.id.as_str())
1472                ));
1473            }
1474        }
1475    }
1476    if hydration {
1477        js.push_str("  if ($noxHydrating) {\n");
1478        emit_hydration_operations(js, operations, "$noxRoot", "$noxOwner", 2)?;
1479        js.push_str("  } else {\n");
1480        js.push_str(&format!(
1481            "    const $noxFragment = {template_name}.content.cloneNode(true);\n"
1482        ));
1483        emit_operations(js, operations, "$noxFragment", "$noxOwner", 2)?;
1484        js.push_str("    $noxRoot.replaceChildren($noxFragment);\n  }\n");
1485    } else {
1486        js.push_str(&format!(
1487            "  const $noxFragment = {template_name}.content.cloneNode(true);\n"
1488        ));
1489        emit_operations(js, operations, "$noxFragment", "$noxOwner", 1)?;
1490        js.push_str("  $noxRoot.replaceChildren($noxFragment);\n");
1491    }
1492    for behavior in &component.behaviors {
1493        let legal = behavior
1494            .legal_elements
1495            .iter()
1496            .map(|value| format!("\"{}\"", js_escape(value)))
1497            .collect::<Vec<_>>()
1498            .join(", ");
1499        let keyboard = behavior
1500            .keyboard
1501            .iter()
1502            .map(|value| format!("\"{}\"", js_escape(value)))
1503            .collect::<Vec<_>>()
1504            .join(", ");
1505        let effects = behavior
1506            .effects
1507            .iter()
1508            .map(|value| format!("\"{}\"", js_escape(value)))
1509            .collect::<Vec<_>>()
1510            .join(", ");
1511        let events = behavior.events.iter().map(|event| {
1512            if event.event == "keydown" && !behavior.keyboard.is_empty() {
1513                format!("\"keydown\": ($noxEvent) => {{ if ([{keyboard}].includes($noxEvent.key)) return $noxActions.{}(); }}", event.action_name)
1514            } else {
1515                format!("\"{}\": () => $noxActions.{}()", js_escape(&event.event), event.action_name)
1516            }
1517        }).collect::<Vec<_>>().join(", ");
1518        js.push_str(&format!(
1519            "  for (const $noxElement of $noxRoot.querySelectorAll('[behavior=\"{}\"]')) applyBehavior($noxElement, $noxOwner, Object.freeze({{ legalElements: Object.freeze([{legal}]), keyboard: Object.freeze([{keyboard}]), effects: Object.freeze([{effects}]), events: Object.freeze({{ {events} }}), ssr: \"{}\" }}), Object.freeze({{}}), Object.freeze({{ semanticId: \"{}\" }}));\n",
1520            js_escape(&behavior.name),
1521            js_escape(&behavior.ssr),
1522            js_escape(behavior.id.as_str()),
1523        ));
1524    }
1525    for region in &component.regions {
1526        let role = region
1527            .semantic_role
1528            .as_ref()
1529            .map(|value| format!("\"{}\"", js_escape(value)))
1530            .unwrap_or_else(|| "null".into());
1531        js.push_str(&format!(
1532            "  validateComponentRegion([ ...$noxRoot.querySelectorAll('[region=\"{}\"]')], Object.freeze({{ cardinality: \"{}\", contentType: \"{}\", semanticRole: {role} }}), $noxOwner, Object.freeze({{ semanticId: \"{}\" }}));\n",
1533            js_escape(&region.name),
1534            js_escape(&region.cardinality),
1535            js_escape(&region.content_type.to_string()),
1536            js_escape(region.id.as_str()),
1537        ));
1538    }
1539    if let Some(lifecycle) = &component.lifecycle {
1540        let mount = lifecycle
1541            .mount
1542            .as_ref()
1543            .map(|id| symbol_name(id).to_string())
1544            .unwrap_or_else(|| "null".into());
1545        let cleanup = lifecycle
1546            .cleanup
1547            .as_ref()
1548            .map(|id| symbol_name(id).to_string())
1549            .unwrap_or_else(|| "null".into());
1550        js.push_str(&format!(
1551            "  try {{ registerLifecycle($noxOwner, \"{}\", {mount}, {cleanup}); }} catch ($noxError) {{ disposeOwner($noxOwner); $noxRoot.replaceChildren(); throw $noxError; }}\n",
1552            js_escape(lifecycle.id.as_str())
1553        ));
1554    }
1555    let state_handles = component
1556        .states
1557        .iter()
1558        .map(|state| format!("\"{}\": {}", js_escape(state.id.as_str()), state.name))
1559        .collect::<Vec<_>>()
1560        .join(", ");
1561    js.push_str(&format!(
1562        "  globalThis.__NOXID_HMR__?.registerInstance({{ component: \"{}\", owner: $noxOwner, state: {{ {state_handles} }}, patchActions($noxNextModule) {{ if (typeof $noxNextModule.__noxidCreate{}Actions === \"function\") $noxActions = $noxNextModule.__noxidCreate{}Actions($noxActionScope); }} }});\n",
1563        js_escape(&component.name),
1564        component.name,
1565        component.name,
1566    ));
1567    if component.resources.is_empty() && component.streams.is_empty() && component.agents.is_empty()
1568    {
1569        js.push_str("  return { owner: $noxOwner, dispose() { disposeOwner($noxOwner); $noxRoot.replaceChildren(); } };\n}\n\n");
1570    } else {
1571        let resource_handles = component
1572            .resources
1573            .iter()
1574            .map(|resource| format!("{}: $noxResourceHandle_{}", resource.name, resource.name))
1575            .collect::<Vec<_>>()
1576            .join(", ");
1577        let stream_handles = component
1578            .streams
1579            .iter()
1580            .map(|stream| format!("{}: $noxStreamHandle_{}", stream.name, stream.name))
1581            .collect::<Vec<_>>()
1582            .join(", ");
1583        let agent_handles = component
1584            .agents
1585            .iter()
1586            .map(|agent| format!("{}: $noxAgentHandle_{}", agent.name, agent.name))
1587            .collect::<Vec<_>>()
1588            .join(", ");
1589        js.push_str(&format!("  return {{ owner: $noxOwner, resources: {{ {resource_handles} }}, streams: {{ {stream_handles} }}, agents: {{ {agent_handles} }}, dispose() {{ disposeOwner($noxOwner); $noxRoot.replaceChildren(); }} }};\n}}\n\n"));
1590    }
1591    if !component.resources.is_empty() {
1592        emit_component_prefetch(js, component)?;
1593    }
1594    if hydration {
1595        js.push_str(&format!(
1596            "export function hydrate{}($noxRoot, $noxProps = {{}}, $noxEventHandlers = {{}}, $noxParentOwner = null, $noxRuntimeOptions = null) {{\n  return mount{}($noxRoot, $noxProps, $noxEventHandlers, $noxParentOwner, $noxRuntimeOptions, true);\n}}\n\n",
1597            component.name,
1598            component.name,
1599        ));
1600    }
1601    Ok(())
1602}
1603
1604fn emit_component_prefetch(js: &mut String, component: &ComponentDefinition) -> Result<(), String> {
1605    let prop_ids = component
1606        .props
1607        .iter()
1608        .map(|prop| prop.id.clone())
1609        .collect::<BTreeSet<_>>();
1610    let available = component.resources.iter().all(|resource| {
1611        resource
1612            .arguments
1613            .iter()
1614            .flat_map(|argument| &argument.dependencies)
1615            .all(|dependency| prop_ids.contains(dependency))
1616    });
1617    js.push_str(&format!(
1618        "export async function __noxidPrefetch{}($noxProps = {{}}, $noxRuntimeOptions = null) {{\n",
1619        component.name
1620    ));
1621    if !available {
1622        js.push_str(&format!(
1623            "  const $noxError = new Error(\"Component {} cannot prefetch because a resource argument depends on component state\");\n  $noxError.code = \"PREFETCH_RESOURCE_ARGUMENT_UNAVAILABLE\";\n  throw $noxError;\n}}\n\n",
1624            js_escape(&component.name)
1625        ));
1626        return Ok(());
1627    }
1628    js.push_str(&format!(
1629        "  const $noxOwner = createOwner(null, {{ semanticId: \"{}\", label: \"component-prefetch\" }});\n  let $noxResourceOptions = $noxRuntimeOptions ?? {{}};\n  if (!$noxResourceOptions.queryClient) $noxResourceOptions = {{ ...$noxResourceOptions, queryClient: __noxidQueryClient }};\n",
1630        js_escape(component.id.as_str())
1631    ));
1632    for prop in &component.props {
1633        js.push_str(&format!(
1634            "  const {} = propSource($noxProps, \"{}\", \"{}\");\n",
1635            prop.name,
1636            js_escape(&prop.name),
1637            js_escape(&component.name)
1638        ));
1639    }
1640    let requests = component
1641        .resources
1642        .iter()
1643        .map(|resource| {
1644            let arguments = resource
1645                .arguments
1646                .iter()
1647                .map(|argument| {
1648                    Ok(format!(
1649                        "[\"{}\"]: {}",
1650                        js_escape(&argument.name),
1651                        emit_expr(&argument.value)?
1652                    ))
1653                })
1654                .collect::<Result<Vec<_>, String>>()?
1655                .join(", ");
1656            Ok(format!(
1657                "{}.prefetch({{ {arguments} }}, {{ ...$noxResourceOptions, owner: $noxOwner }})",
1658                resource.resource_name
1659            ))
1660        })
1661        .collect::<Result<Vec<_>, String>>()?
1662        .join(", ");
1663    js.push_str(&format!(
1664        "  try {{\n    await Promise.all([{requests}]);\n  }} finally {{\n    disposeOwner($noxOwner);\n  }}\n}}\n\n"
1665    ));
1666    Ok(())
1667}
1668
1669fn emit_hmr_action_factory(
1670    js: &mut String,
1671    program: &SemanticProgram,
1672    component: &ComponentDefinition,
1673) -> Result<(), String> {
1674    let scope_names = hmr_scope_names(component);
1675    let boundary_scope = if component
1676        .actions
1677        .iter()
1678        .any(|action| action.execution.is_remote())
1679    {
1680        "\n  const $noxExecuteBoundary = $noxScope.$noxExecuteBoundary ?? $noxScope.executeBoundary;"
1681    } else {
1682        ""
1683    };
1684    let resource_options_scope = if component
1685        .actions
1686        .iter()
1687        .any(|action| !action.invalidation.resources.is_empty())
1688    {
1689        "\n  const $noxResourceOptions = $noxScope.$noxResourceOptions ?? $noxScope.resourceOptions ?? {};"
1690    } else {
1691        ""
1692    };
1693    js.push_str(&format!(
1694        "export function __noxidCreate{}Actions($noxScope) {{\n  const $noxOwner = $noxScope.$noxOwner ?? $noxScope.owner;\n  const $noxEmit = $noxScope.$noxEmit ?? $noxScope.emit;{resource_options_scope}\n  const {{ {} }} = $noxScope;{boundary_scope}\n",
1695        component.name,
1696        scope_names.join(", "),
1697    ));
1698    for action in &component.actions {
1699        let inputs = action
1700            .parameters
1701            .iter()
1702            .map(|parameter| format!("$noxInput_{}", parameter.name))
1703            .collect::<Vec<_>>()
1704            .join(", ");
1705        let remote_await = action
1706            .statements
1707            .iter()
1708            .position(|statement| matches!(statement, SemanticStatement::RemoteAwait { .. }));
1709        let async_keyword = if remote_await.is_some() { "async " } else { "" };
1710        js.push_str(&format!(
1711            "  {async_keyword}function {}({inputs}) {{\n",
1712            action.name
1713        ));
1714        if action.execution.is_remote() {
1715            let arguments = action
1716                .parameters
1717                .iter()
1718                .map(|parameter| (parameter, format!("$noxInput_{}", parameter.name)))
1719                .collect::<Vec<_>>();
1720            let descriptor = emit_remote_action_descriptor(component, action, &arguments);
1721            let invalidation = emit_action_invalidation(program, action);
1722            js.push_str(&format!(
1723                "      return Promise.resolve(runAction(\"{}\", $noxOwner, () => {{\n        if (typeof $noxExecuteBoundary !== \"function\") {{ const $noxError = new Error(\"No host executor is configured for {} action {}\"); $noxError.code = \"EXECUTION_BOUNDARY_UNAVAILABLE\"; $noxError.semanticId = \"{}\"; $noxError.execution = \"{}\"; throw $noxError; }}\n        return $noxExecuteBoundary({descriptor});\n      }})).then(($noxValue) => {{{invalidation} return $noxValue; }});\n  }}\n",
1724                js_escape(action.id.as_str()),
1725                action.execution.as_str(),
1726                js_escape(&action.name),
1727                js_escape(action.id.as_str()),
1728                action.execution.as_str(),
1729            ));
1730        } else {
1731            for parameter in &action.parameters {
1732                js.push_str(&format!(
1733                    "      const {} = toSource($noxInput_{});\n",
1734                    parameter.name, parameter.name
1735                ));
1736            }
1737            if let Some(remote_await) = remote_await {
1738                emit_async_action(js, program, component, action, remote_await)?;
1739            } else {
1740                js.push_str(&format!(
1741                    "      return runAction(\"{}\", $noxOwner, () => {{\n",
1742                    js_escape(action.id.as_str())
1743                ));
1744                emit_statements(js, &action.statements, 4)?;
1745                js.push_str("      });\n  }\n");
1746            }
1747        }
1748    }
1749    js.push_str(&format!(
1750        "  return {{ {} }};\n}}\n\n",
1751        component
1752            .actions
1753            .iter()
1754            .map(|action| action.name.as_str())
1755            .collect::<Vec<_>>()
1756            .join(", ")
1757    ));
1758    Ok(())
1759}
1760
1761fn emit_async_action(
1762    js: &mut String,
1763    program: &SemanticProgram,
1764    component: &ComponentDefinition,
1765    action: &Action,
1766    remote_await_index: usize,
1767) -> Result<(), String> {
1768    let SemanticStatement::RemoteAwait {
1769        action: remote_action_id,
1770        arguments,
1771        ok_arm,
1772        err_arm,
1773        ..
1774    } = &action.statements[remote_await_index]
1775    else {
1776        unreachable!("remote await index must identify a RemoteAwait statement")
1777    };
1778    let remote_action = component
1779        .actions
1780        .iter()
1781        .find(|candidate| candidate.id == *remote_action_id)
1782        .unwrap_or_else(|| {
1783            panic!(
1784                "remote await references missing sibling action `{}`",
1785                remote_action_id
1786            )
1787        });
1788    let descriptor_arguments = arguments
1789        .iter()
1790        .map(|argument| {
1791            let parameter = remote_action
1792                .parameters
1793                .iter()
1794                .find(|parameter| parameter.id == argument.parameter)
1795                .unwrap_or_else(|| {
1796                    panic!(
1797                        "remote await argument `{}` references missing parameter `{}`",
1798                        argument.name, argument.parameter
1799                    )
1800                });
1801            Ok((parameter, emit_expr(&argument.value)?))
1802        })
1803        .collect::<Result<Vec<_>, String>>()?;
1804    let descriptor = emit_remote_action_descriptor(component, remote_action, &descriptor_arguments);
1805    let remote_invalidation = emit_action_invalidation(program, remote_action);
1806    let action_id = js_escape(action.id.as_str());
1807    let before_await = &action.statements[..remote_await_index];
1808
1809    // Locals initialized before the boundary may be read by its named
1810    // arguments. Declare them in the async action's lexical scope, but keep
1811    // their initialization inside the first runAction transaction so the
1812    // transaction still closes before the Promise is awaited.
1813    for statement in before_await {
1814        if let SemanticStatement::Local { name, .. } = statement {
1815            js.push_str(&format!("      let {name};\n"));
1816        }
1817    }
1818
1819    js.push_str(&format!(
1820        "      runAction(\"{action_id}\", $noxOwner, () => {{\n"
1821    ));
1822    emit_pre_await_statements(js, before_await, 4)?;
1823    js.push_str("      });\n");
1824    js.push_str("      const $noxRemoteOutcome = await (async () => {\n");
1825    js.push_str(&format!(
1826        "        try {{\n          if (typeof $noxExecuteBoundary !== \"function\") {{ const $noxError = new Error(\"No host executor is configured for {} action {}\"); $noxError.code = \"EXECUTION_BOUNDARY_UNAVAILABLE\"; $noxError.semanticId = \"{}\"; $noxError.execution = \"{}\"; throw $noxError; }}\n          const $noxValue = await $noxExecuteBoundary({descriptor});\n          {remote_invalidation}return Object.freeze({{ tag: \"Ok\", value: $noxValue }});\n        }} catch ($noxThrown) {{\n          const $noxRemoteError = Object.freeze({{ code: typeof $noxThrown?.code === \"string\" ? $noxThrown.code : \"REMOTE_ACTION_FAILED\", message: typeof $noxThrown?.message === \"string\" ? $noxThrown.message : String($noxThrown) }});\n          return Object.freeze({{ tag: \"Err\", value: $noxRemoteError }});\n        }}\n",
1827        remote_action.execution.as_str(),
1828        js_escape(&remote_action.name),
1829        js_escape(remote_action.id.as_str()),
1830        remote_action.execution.as_str(),
1831    ));
1832    js.push_str("      })();\n");
1833    js.push_str(&format!(
1834        "      return runAction(\"{action_id}\", $noxOwner, () => {{\n"
1835    ));
1836    emit_remote_await_arm(js, ok_arm, 4)?;
1837    emit_remote_await_arm(js, err_arm, 4)?;
1838    emit_statements(js, &action.statements[remote_await_index + 1..], 4)?;
1839    js.push_str("      });\n  }\n");
1840    Ok(())
1841}
1842
1843fn emit_pre_await_statements(
1844    js: &mut String,
1845    statements: &[SemanticStatement],
1846    depth: usize,
1847) -> Result<(), String> {
1848    let indent = "  ".repeat(depth);
1849    for statement in statements {
1850        if let SemanticStatement::Local { name, value, .. } = statement {
1851            js.push_str(&format!(
1852                "{indent}{name} = toSource({});\n",
1853                emit_expr(value)?
1854            ));
1855        } else {
1856            emit_statements(js, std::slice::from_ref(statement), depth)?;
1857        }
1858    }
1859    Ok(())
1860}
1861
1862fn emit_remote_await_arm(
1863    js: &mut String,
1864    arm: &RemoteAwaitArm,
1865    depth: usize,
1866) -> Result<(), String> {
1867    let indent = "  ".repeat(depth);
1868    let branch = if arm.name == "Ok" { "if" } else { "else if" };
1869    js.push_str(&format!(
1870        "{indent}{branch} ($noxRemoteOutcome.tag === \"{}\") {{\n",
1871        js_escape(&arm.name)
1872    ));
1873    if let Some(binding) = &arm.binding {
1874        js.push_str(&format!(
1875            "{indent}  const {} = toSource($noxRemoteOutcome.value);\n",
1876            binding.name
1877        ));
1878    }
1879    emit_statements(js, &arm.statements, depth + 1)?;
1880    js.push_str(&format!("{indent}}}\n"));
1881    Ok(())
1882}
1883
1884fn emit_remote_action_descriptor(
1885    component: &ComponentDefinition,
1886    action: &Action,
1887    arguments: &[(&ActionParameter, String)],
1888) -> String {
1889    let arguments = arguments
1890        .iter()
1891        .map(|(parameter, value)| {
1892            let type_id = parameter
1893                .type_id
1894                .as_ref()
1895                .map(|id| format!("\"{}\"", js_escape(id.as_str())))
1896                .unwrap_or_else(|| "null".into());
1897            format!(
1898                "{{ name: \"{}\", type: \"{}\", typeId: {type_id}, value: {value} }}",
1899                js_escape(&parameter.name),
1900                parameter.ty,
1901            )
1902        })
1903        .collect::<Vec<_>>()
1904        .join(", ");
1905    let result_type_id = action
1906        .result
1907        .type_id
1908        .as_ref()
1909        .map(|id| format!("\"{}\"", js_escape(id.as_str())))
1910        .unwrap_or_else(|| "null".into());
1911    let capabilities = action
1912        .capabilities
1913        .iter()
1914        .map(|capability| format!("\"{}\"", js_escape(&capability.name)))
1915        .collect::<Vec<_>>()
1916        .join(", ");
1917    format!(
1918        "Object.freeze({{ id: \"{}\", component: \"{}\", action: \"{}\", execution: \"{}\", arguments: Object.freeze([{arguments}]), result: Object.freeze({{ id: \"{}\", type: \"{}\", typeId: {result_type_id} }}), capabilities: Object.freeze([{capabilities}]) }})",
1919        js_escape(action.id.as_str()),
1920        js_escape(&component.name),
1921        js_escape(&action.name),
1922        action.execution.as_str(),
1923        js_escape(action.result.id.as_str()),
1924        action.result.ty,
1925    )
1926}
1927
1928fn emit_action_invalidation(program: &SemanticProgram, action: &Action) -> String {
1929    if action.invalidation.resources.is_empty() {
1930        return String::new();
1931    }
1932    let definitions = action
1933        .invalidation
1934        .resources
1935        .iter()
1936        .map(|resource| resource_definition_name(program, resource))
1937        .collect::<Vec<_>>()
1938        .join(", ");
1939    format!(
1940        " invalidateCompiledResources($noxResourceOptions.queryClient ?? __noxidQueryClient, [{definitions}], $noxResourceOptions, \"{}\");",
1941        js_escape(action.id.as_str())
1942    )
1943}
1944
1945fn hmr_scope_names(component: &ComponentDefinition) -> Vec<String> {
1946    let mut names = std::collections::BTreeSet::new();
1947    names.extend(component.props.iter().map(|value| value.name.clone()));
1948    names.extend(
1949        component
1950            .context_uses
1951            .iter()
1952            .flat_map(|context| context.fields.iter().map(|field| field.name.clone())),
1953    );
1954    names.extend(component.states.iter().map(|value| value.name.clone()));
1955    names.extend(component.computed.iter().map(|value| value.name.clone()));
1956    names.extend(component.resources.iter().map(|value| value.name.clone()));
1957    names.extend(component.streams.iter().map(|value| value.name.clone()));
1958    names.extend(component.agents.iter().map(|value| value.name.clone()));
1959    names.into_iter().collect()
1960}
1961
1962fn emit_statements(
1963    js: &mut String,
1964    statements: &[SemanticStatement],
1965    depth: usize,
1966) -> Result<(), String> {
1967    let indent = "  ".repeat(depth);
1968    for statement in statements {
1969        match statement {
1970            SemanticStatement::Return { value, .. } => {
1971                js.push_str(&format!("{indent}return {};\n", emit_expr(value)?,))
1972            }
1973            SemanticStatement::Assignment { target, value, .. } => js.push_str(&format!(
1974                "{indent}{}.set({});\n",
1975                symbol_name(target),
1976                emit_expr(value)?
1977            )),
1978            SemanticStatement::Transition {
1979                target,
1980                value,
1981                machine,
1982                event,
1983                allowed,
1984                ..
1985            } => {
1986                let allowed = allowed
1987                    .iter()
1988                    .map(|(from, to)| format!("[\"{}\", \"{}\"]", js_escape(from), js_escape(to)))
1989                    .collect::<Vec<_>>()
1990                    .join(", ");
1991                js.push_str(&format!(
1992                    "{indent}transitionMachine({}, {}, \"{}\", [{}], \"{}\");\n",
1993                    symbol_name(target),
1994                    emit_expr(value)?,
1995                    js_escape(event),
1996                    allowed,
1997                    js_escape(symbol_name(machine))
1998                ));
1999            }
2000            SemanticStatement::CollectionMutation {
2001                target,
2002                operation,
2003                arguments,
2004                ..
2005            } => js.push_str(&format!(
2006                "{indent}{}.{}({});\n",
2007                symbol_name(target),
2008                operation.as_str(),
2009                arguments
2010                    .iter()
2011                    .map(emit_expr)
2012                    .collect::<Result<Vec<_>, String>>()?
2013                    .join(", ")
2014            )),
2015            SemanticStatement::Emit { name, payload, .. } => js.push_str(&format!(
2016                "{indent}$noxEmit(\"{}\", {});\n",
2017                js_escape(name),
2018                emit_expr(payload)?
2019            )),
2020            SemanticStatement::FieldAssignment {
2021                target,
2022                path,
2023                value,
2024                ..
2025            } => {
2026                let base = format!("{}.get()", symbol_name(target));
2027                js.push_str(&format!(
2028                    "{indent}{}.set({});\n",
2029                    symbol_name(target),
2030                    field_assignment_value(&base, path, &emit_expr(value)?)
2031                ));
2032            }
2033            // Locals are wrapped with toSource like action parameters, so
2034            // references read them through the same .get() convention.
2035            SemanticStatement::Local { name, value, .. } => js.push_str(&format!(
2036                "{indent}let {} = toSource({});\n",
2037                name,
2038                emit_expr(value)?
2039            )),
2040            SemanticStatement::LocalAssignment { name, value, .. } => js.push_str(&format!(
2041                "{indent}{} = toSource({});\n",
2042                name,
2043                emit_expr(value)?
2044            )),
2045            SemanticStatement::RemoteAwait { .. } => {
2046                panic!("RemoteAwait must be lowered by the async client-action emitter")
2047            }
2048            // ADR 0137 rule 3: client code never observes a principal. The
2049            // value exists only inside the generated server boundary, so
2050            // reaching here means a semantic guard was lost — fail the build
2051            // rather than emit a read of a `context` the browser has no way
2052            // to hold.
2053            SemanticStatement::PrincipalMatch { .. } => {
2054                return Err(noxid_ir::emitter_rejection(
2055                    "PRINCIPAL_CONSTRUCTION_RESERVED",
2056                    "`#match context.principal` has no client lowering; a principal exists only inside the generated server boundary, so keep the branch in a compiler-owned endpoint, task, or queue handler",
2057                ));
2058            }
2059            SemanticStatement::If {
2060                condition,
2061                then_statements,
2062                else_statements,
2063                ..
2064            } => {
2065                js.push_str(&format!("{indent}if ({}) {{\n", emit_expr(condition)?));
2066                emit_statements(js, then_statements, depth + 1)?;
2067                if else_statements.is_empty() {
2068                    js.push_str(&format!("{indent}}}\n"));
2069                } else {
2070                    js.push_str(&format!("{indent}}} else {{\n"));
2071                    emit_statements(js, else_statements, depth + 1)?;
2072                    js.push_str(&format!("{indent}}}\n"));
2073                }
2074            }
2075            SemanticStatement::ActionCall {
2076                name, arguments, ..
2077            } => js.push_str(&format!(
2078                "{indent}{}({});\n",
2079                name,
2080                arguments
2081                    .iter()
2082                    .map(emit_expr)
2083                    .collect::<Result<Vec<_>, String>>()?
2084                    .join(", ")
2085            )),
2086        }
2087    }
2088    Ok(())
2089}
2090
2091fn statements_declare_locals(statements: &[SemanticStatement]) -> bool {
2092    statements.iter().any(|statement| match statement {
2093        SemanticStatement::Local { .. } => true,
2094        SemanticStatement::RemoteAwait {
2095            ok_arm, err_arm, ..
2096        } => {
2097            ok_arm.binding.is_some()
2098                || err_arm.binding.is_some()
2099                || statements_declare_locals(&ok_arm.statements)
2100                || statements_declare_locals(&err_arm.statements)
2101        }
2102        SemanticStatement::If {
2103            then_statements,
2104            else_statements,
2105            ..
2106        } => {
2107            statements_declare_locals(then_statements) || statements_declare_locals(else_statements)
2108        }
2109        SemanticStatement::PrincipalMatch { arms, .. } => arms
2110            .iter()
2111            .any(|arm| arm.binding.is_some() || statements_declare_locals(&arm.statements)),
2112        SemanticStatement::Return { .. }
2113        | SemanticStatement::Assignment { .. }
2114        | SemanticStatement::FieldAssignment { .. }
2115        | SemanticStatement::LocalAssignment { .. }
2116        | SemanticStatement::ActionCall { .. }
2117        | SemanticStatement::Transition { .. }
2118        | SemanticStatement::CollectionMutation { .. }
2119        | SemanticStatement::Emit { .. } => false,
2120    })
2121}
2122
2123fn field_assignment_value(
2124    base: &str,
2125    path: &[noxid_ir::FieldPathSegment],
2126    value_js: &str,
2127) -> String {
2128    match path.split_first() {
2129        None => value_js.to_string(),
2130        Some((segment, rest)) => {
2131            let inner_base = format!("{base}[\"{}\"]", js_escape(&segment.name));
2132            format!(
2133                "{{ ...{base}, \"{}\": {} }}",
2134                js_escape(&segment.name),
2135                field_assignment_value(&inner_base, rest, value_js)
2136            )
2137        }
2138    }
2139}
2140
2141fn emit_operations(
2142    js: &mut String,
2143    operations: &[Operation],
2144    fragment: &str,
2145    owner: &str,
2146    depth: usize,
2147) -> Result<(), String> {
2148    let indent = "  ".repeat(depth);
2149    for operation in operations {
2150        match operation {
2151            Operation::Text { id, marker, expression, dependencies } => js.push_str(&format!("{indent}bindText(findMarker({fragment}, \"noxid-text-{marker}\"), () => {}, {owner}, {}, \"{}\");\n", emit_expr(expression)?, emit_sources(dependencies), js_escape(id.as_str()))),
2152            Operation::Attribute { id, marker, name, expression, dependencies } => js.push_str(&format!("{indent}bindAttribute({fragment}.querySelector(\"[data-noxid-bind-{marker}]\"), \"{}\", () => {}, {owner}, {}, \"{}\");\n", js_escape(name), emit_expr(expression)?, emit_sources(dependencies), js_escape(id.as_str()))),
2153            Operation::TwoWayBinding { id, marker, name, target } => js.push_str(&format!("{indent}bindProperty({fragment}.querySelector(\"[data-noxid-two-way-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n", js_escape(name), symbol_name(target), js_escape(id.as_str()))),
2154            Operation::Event { id, marker, event, action, arguments } => js.push_str(&format!("{indent}listen({fragment}.querySelector(\"[data-noxid-event-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n", js_escape(event), event_handler_javascript(action, arguments)?, js_escape(id.as_str()))),
2155            Operation::RoutePrefetch { marker, trigger } => {
2156                let trigger = trigger.as_str();
2157                js.push_str(&format!("{indent}const $noxPrefetchTarget{marker} = {fragment}.querySelector(\"[data-noxid-prefetch-{marker}]\");\n"));
2158                js.push_str(&format!("{indent}attachCompiledPrefetch($noxPrefetchTarget{marker}, \"{trigger}\", () => $noxResourceOptions.prefetchRoute($noxPrefetchTarget{marker}.getAttribute(\"href\")), {owner}, $noxResourceOptions);\n"));
2159            }
2160            Operation::Attachment { attachment, marker } => js.push_str(&format!(
2161                "{indent}installAnimateAttachment({fragment}.querySelector(\"[data-noxid-attach-{marker}]\"), {}, {owner}, \"{}\");\n",
2162                emit_attachment_config(&attachment.config),
2163                js_escape(attachment.id.as_str()),
2164            )),
2165            Operation::Component { id, marker, target, props, handlers, prefetch, children } => {
2166              match children {
2167                None => js.push_str(&format!("{indent}mountComponent(findMarker({fragment}, \"noxid-component-{marker}\"), mount{}, {}, {}, {owner}, \"{}\");\n", target_name(target), emit_props(props, owner)?, emit_handlers(handlers), js_escape(id.as_str()))),
2168                Some(slot) => {
2169                    // Children render in the parent's scope: the closure
2170                    // captures the parent's signals and mounts under the
2171                    // child's slot owner.
2172                    js.push_str(&format!("{indent}mountComponent(findMarker({fragment}, \"noxid-component-{marker}\"), mount{}, Object.assign({}, {{ __noxidChildren: ($noxTarget, $noxBlockOwner) => {{\n", target_name(target), emit_props(props, owner)?));
2173                    let slot_fragment = format!("$noxSlotFragment{marker}");
2174                    js.push_str(&format!("{indent}  const {slot_fragment} = {}.content.cloneNode(true);\n", slot.template_name));
2175                    emit_operations(js, &slot.operations, &slot_fragment, "$noxBlockOwner", depth + 1)?;
2176                    js.push_str(&format!("{indent}  $noxTarget.appendChild({slot_fragment});\n"));
2177                    js.push_str(&format!("{indent}}} }}), {}, {owner}, \"{}\");\n", emit_handlers(handlers), js_escape(id.as_str())));
2178                }
2179              }
2180              if let Some(trigger) = prefetch {
2181                  js.push_str(&format!("{indent}attachCompiledPrefetch(findMarker({fragment}, \"noxid-component-{marker}\"), \"{}\", () => __noxidPrefetch{}({}, $noxResourceOptions), {owner}, $noxResourceOptions);\n", trigger.as_str(), target_name(target), emit_props(props, owner)?));
2182              }
2183            }
2184            Operation::Slot { id, marker } => js.push_str(&format!("{indent}mountSlot(findMarker({fragment}, \"noxid-slot-{marker}\"), $noxProps.__noxidChildren, {owner}, \"{}\");\n", js_escape(id.as_str()))),
2185            Operation::Conditional { id, marker, condition, dependencies, transition, template_name, operations } => {
2186                js.push_str(&format!("{indent}mountIf(findMarker({fragment}, \"noxid-if-{marker}\"), {}, {owner}, ($noxTarget, $noxBlockOwner) => {{\n", emit_source(condition, dependencies, owner)?));
2187                let nested_fragment = format!("$noxFragment{marker}");
2188                js.push_str(&format!("{indent}  const {nested_fragment} = {template_name}.content.cloneNode(true);\n"));
2189                emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 1)?;
2190                js.push_str(&format!("{indent}  $noxTarget.replaceChildren({nested_fragment});\n{indent}}}, \"{}\", {});\n", js_escape(id.as_str()), emit_transition(transition)));
2191            }
2192            Operation::Match {
2193                id,
2194                marker,
2195                expression,
2196                dependencies,
2197                cases,
2198            } => {
2199                js.push_str(&format!(
2200                    "{indent}mountMatch(findMarker({fragment}, \"noxid-match-{marker}\"), {}, {owner}, {{\n",
2201                    emit_match_source(expression, dependencies, owner)?
2202                ));
2203                for case in cases {
2204                    js.push_str(&format!(
2205                        "{indent}  \"{}\": ($noxTarget, $noxBlockOwner, $noxMatchValue) => {{\n",
2206                        js_escape(&case.variant)
2207                    ));
2208                    if let Some(binding) = &case.binding {
2209                        js.push_str(&format!(
2210                            "{indent}    const {} = toSource($noxMatchValue.value);\n",
2211                            binding.name
2212                        ));
2213                    }
2214                    let nested_fragment = format!("$noxMatchFragment{marker}{}", case.variant);
2215                    js.push_str(&format!(
2216                        "{indent}    const {nested_fragment} = {}.content.cloneNode(true);\n",
2217                        case.template_name
2218                    ));
2219                    emit_operations(
2220                        js,
2221                        &case.operations,
2222                        &nested_fragment,
2223                        "$noxBlockOwner",
2224                        depth + 2,
2225                    )?;
2226                    js.push_str(&format!(
2227                        "{indent}    $noxTarget.replaceChildren({nested_fragment});\n{indent}  }},\n"
2228                    ));
2229                }
2230                js.push_str(&format!("{indent}}}, \"{}\");\n", js_escape(id.as_str())));
2231            }
2232            Operation::Stream {
2233                id,
2234                marker,
2235                expression,
2236                dependencies,
2237                cases,
2238            } => {
2239                js.push_str(&format!(
2240                    "{indent}mountStream(findMarker({fragment}, \"noxid-stream-{marker}\"), {}, {owner}, {{\n",
2241                    emit_source(expression, dependencies, owner)?
2242                ));
2243                for case in cases {
2244                    js.push_str(&format!(
2245                        "{indent}  \"{}\": ($noxTarget, $noxBlockOwner, $noxStreamEvent) => {{\n",
2246                        js_escape(&case.variant)
2247                    ));
2248                    if let Some(binding) = &case.binding {
2249                        js.push_str(&format!(
2250                            "{indent}    const {} = toSource($noxStreamEvent.value);\n",
2251                            binding.name
2252                        ));
2253                    }
2254                    let nested_fragment = format!("$noxStreamFragment{marker}{}", case.variant);
2255                    js.push_str(&format!(
2256                        "{indent}    const {nested_fragment} = {}.content.cloneNode(true);\n",
2257                        case.template_name
2258                    ));
2259                    emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
2260                    js.push_str(&format!(
2261                        "{indent}    $noxTarget.replaceChildren({nested_fragment});\n{indent}  }},\n"
2262                    ));
2263                }
2264                js.push_str(&format!("{indent}}}, \"{}\");\n", js_escape(id.as_str())));
2265            }
2266            Operation::For {
2267                id,
2268                marker,
2269                collection,
2270                dependencies,
2271                binding,
2272                key,
2273                template_name,
2274                operations,
2275            } => {
2276                js.push_str(&format!(
2277                    "{indent}mountFor(findMarker({fragment}, \"noxid-for-{marker}\"), {}, {owner}, ({}) => {}, ($noxTarget, $noxBlockOwner, $noxItemSource) => {{\n",
2278                    emit_source(collection, dependencies, owner)?,
2279                    binding.name,
2280                    emit_expr(key)?,
2281                ));
2282                js.push_str(&format!(
2283                    "{indent}  const {} = $noxItemSource;\n",
2284                    binding.name
2285                ));
2286                let nested_fragment = format!("$noxForFragment{marker}");
2287                js.push_str(&format!(
2288                    "{indent}  const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
2289                ));
2290                emit_operations(
2291                    js,
2292                    operations,
2293                    &nested_fragment,
2294                    "$noxBlockOwner",
2295                    depth + 1,
2296                )?;
2297                js.push_str(&format!(
2298                    "{indent}  $noxTarget.replaceChildren({nested_fragment});\n{indent}}}, \"{}\");\n",
2299                    js_escape(id.as_str())
2300                ));
2301            }
2302        }
2303    }
2304    Ok(())
2305}
2306
2307fn emit_hydration_operations(
2308    js: &mut String,
2309    operations: &[Operation],
2310    root: &str,
2311    owner: &str,
2312    depth: usize,
2313) -> Result<(), String> {
2314    let indent = "  ".repeat(depth);
2315    for operation in operations {
2316        match operation {
2317            Operation::Text {
2318                id,
2319                marker,
2320                expression,
2321                dependencies,
2322            } => js.push_str(&format!(
2323                "{indent}hydrateText(findMarker({root}, \"noxid-text-{marker}\"), () => {}, {owner}, {}, \"{}\");\n",
2324                emit_expr(expression)?,
2325                emit_sources(dependencies),
2326                js_escape(id.as_str()),
2327            )),
2328            Operation::Attribute {
2329                id,
2330                marker,
2331                name,
2332                expression,
2333                dependencies,
2334            } => js.push_str(&format!(
2335                "{indent}bindAttribute({root}.querySelector(\"[data-noxid-bind-{marker}]\"), \"{}\", () => {}, {owner}, {}, \"{}\");\n",
2336                js_escape(name),
2337                emit_expr(expression)?,
2338                emit_sources(dependencies),
2339                js_escape(id.as_str()),
2340            )),
2341            Operation::TwoWayBinding {
2342                id,
2343                marker,
2344                name,
2345                target,
2346            } => js.push_str(&format!(
2347                "{indent}bindProperty({root}.querySelector(\"[data-noxid-two-way-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n",
2348                js_escape(name),
2349                symbol_name(target),
2350                js_escape(id.as_str()),
2351            )),
2352            Operation::Event {
2353                id,
2354                marker,
2355                event,
2356                action,
2357                arguments,
2358            } => js.push_str(&format!(
2359                "{indent}listen({root}.querySelector(\"[data-noxid-event-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n",
2360                js_escape(event),
2361                event_handler_javascript(action, arguments)?,
2362                js_escape(id.as_str()),
2363            )),
2364            Operation::RoutePrefetch { marker, trigger } => {
2365                let trigger = trigger.as_str();
2366                js.push_str(&format!("{indent}const $noxPrefetchTarget{marker} = {root}.querySelector(\"[data-noxid-prefetch-{marker}]\");\n"));
2367                js.push_str(&format!("{indent}attachCompiledPrefetch($noxPrefetchTarget{marker}, \"{trigger}\", () => $noxResourceOptions.prefetchRoute($noxPrefetchTarget{marker}.getAttribute(\"href\")), {owner}, $noxResourceOptions);\n"));
2368            }
2369            Operation::Attachment { attachment, marker } => js.push_str(&format!(
2370                "{indent}installAnimateAttachment({root}.querySelector(\"[data-noxid-attach-{marker}]\"), {}, {owner}, \"{}\");\n",
2371                emit_attachment_config(&attachment.config),
2372                js_escape(attachment.id.as_str()),
2373            )),
2374            Operation::Component {
2375                id,
2376                marker,
2377                target,
2378                props,
2379                handlers,
2380                prefetch,
2381                children,
2382            } => {
2383                let props_javascript = match children {
2384                    None => emit_props(props, owner)?,
2385                    Some(slot) => {
2386                        let mut closure = String::new();
2387                        let slot_fragment = format!("$noxSlotFragment{marker}");
2388                        closure.push_str(&format!(
2389                            "Object.assign({}, {{ __noxidChildren: ($noxTarget, $noxBlockOwner, $noxHydratingSlot = false) => {{\n{indent}  if ($noxHydratingSlot) {{\n",
2390                            emit_props(props, owner)?
2391                        ));
2392                        emit_hydration_operations(
2393                            &mut closure,
2394                            &slot.operations,
2395                            "$noxTarget",
2396                            "$noxBlockOwner",
2397                            depth + 2,
2398                        )?;
2399                        closure.push_str(&format!(
2400                            "{indent}  }} else {{\n{indent}    const {slot_fragment} = {}.content.cloneNode(true);\n",
2401                            slot.template_name
2402                        ));
2403                        emit_operations(
2404                            &mut closure,
2405                            &slot.operations,
2406                            &slot_fragment,
2407                            "$noxBlockOwner",
2408                            depth + 2,
2409                        )?;
2410                        closure.push_str(&format!(
2411                            "{indent}    $noxTarget.appendChild({slot_fragment});\n{indent}  }}\n{indent}}} }})"
2412                        ));
2413                        closure
2414                    }
2415                };
2416                js.push_str(&format!(
2417                    "{indent}hydrateComponent(findMarker({root}, \"noxid-component-{marker}\"), \"noxid-component-end-{marker}\", hydrate{}, mount{}, {}, {}, {owner}, \"{}\", $noxResourceOptions);\n",
2418                    target_name(target),
2419                    target_name(target),
2420                    props_javascript,
2421                    emit_handlers(handlers),
2422                    js_escape(id.as_str()),
2423                ));
2424                if let Some(trigger) = prefetch {
2425                    js.push_str(&format!("{indent}attachCompiledPrefetch(findMarker({root}, \"noxid-component-{marker}\"), \"{}\", () => __noxidPrefetch{}({}, $noxResourceOptions), {owner}, $noxResourceOptions);\n", trigger.as_str(), target_name(target), emit_props(props, owner)?));
2426                }
2427            }
2428            Operation::Slot { id, marker } => js.push_str(&format!(
2429                "{indent}hydrateSlot(findMarker({root}, \"noxid-slot-{marker}\"), \"noxid-slot-end-{marker}\", $noxProps.__noxidChildren, {owner}, \"{}\");\n",
2430                js_escape(id.as_str()),
2431            )),
2432            Operation::Conditional {
2433                id,
2434                marker,
2435                condition,
2436                dependencies,
2437                transition,
2438                template_name,
2439                operations,
2440            } => {
2441                js.push_str(&format!(
2442                    "{indent}hydrateIf(findMarker({root}, \"noxid-if-{marker}\"), \"noxid-if-end-{marker}\", {}, {owner}, ($noxTarget, $noxBlockOwner, $noxHydratingBlock) => {{\n{indent}  if ($noxHydratingBlock) {{\n",
2443                    emit_source(condition, dependencies, owner)?,
2444                ));
2445                emit_hydration_operations(js, operations, "$noxTarget", "$noxBlockOwner", depth + 2)?;
2446                js.push_str(&format!("{indent}  }} else {{\n"));
2447                let nested_fragment = format!("$noxHydratedIfFragment{marker}");
2448                js.push_str(&format!(
2449                    "{indent}    const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
2450                ));
2451                emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
2452                js.push_str(&format!(
2453                    "{indent}    $noxTarget.replaceChildren({nested_fragment});\n{indent}  }}\n{indent}}}, \"{}\", {});\n",
2454                    js_escape(id.as_str()),
2455                    emit_transition(transition),
2456                ));
2457            }
2458            Operation::Match {
2459                id,
2460                marker,
2461                expression,
2462                dependencies,
2463                cases,
2464            } => {
2465                js.push_str(&format!(
2466                    "{indent}hydrateMatch(findMarker({root}, \"noxid-match-{marker}\"), \"noxid-match-end-{marker}\", {}, {owner}, {{\n",
2467                    emit_match_source(expression, dependencies, owner)?,
2468                ));
2469                for case in cases {
2470                    js.push_str(&format!(
2471                        "{indent}  \"{}\": ($noxTarget, $noxBlockOwner, $noxMatchValue, $noxHydratingBlock) => {{\n",
2472                        js_escape(&case.variant),
2473                    ));
2474                    if let Some(binding) = &case.binding {
2475                        js.push_str(&format!(
2476                            "{indent}    const {} = toSource($noxMatchValue.value);\n",
2477                            binding.name,
2478                        ));
2479                    }
2480                    js.push_str(&format!("{indent}    if ($noxHydratingBlock) {{\n"));
2481                    emit_hydration_operations(js, &case.operations, "$noxTarget", "$noxBlockOwner", depth + 3)?;
2482                    js.push_str(&format!("{indent}    }} else {{\n"));
2483                    let nested_fragment = format!("$noxHydratedMatchFragment{marker}{}", case.variant);
2484                    js.push_str(&format!(
2485                        "{indent}      const {nested_fragment} = {}.content.cloneNode(true);\n",
2486                        case.template_name,
2487                    ));
2488                    emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 3)?;
2489                    js.push_str(&format!(
2490                        "{indent}      $noxTarget.replaceChildren({nested_fragment});\n{indent}    }}\n{indent}  }},\n"
2491                    ));
2492                }
2493                js.push_str(&format!(
2494                    "{indent}}}, \"{}\");\n",
2495                    js_escape(id.as_str()),
2496                ));
2497            }
2498            Operation::For {
2499                id,
2500                marker,
2501                collection,
2502                dependencies,
2503                binding,
2504                key,
2505                template_name,
2506                operations,
2507            } => {
2508                js.push_str(&format!(
2509                    "{indent}hydrateFor(findMarker({root}, \"noxid-for-{marker}\"), \"noxid-for-end-{marker}\", {}, {owner}, ({}) => {}, ($noxTarget, $noxBlockOwner, $noxItemSource, $noxHydratingBlock) => {{\n",
2510                    emit_source(collection, dependencies, owner)?,
2511                    binding.name,
2512                    emit_expr(key)?,
2513                ));
2514                js.push_str(&format!(
2515                    "{indent}  const {} = $noxItemSource;\n{indent}  if ($noxHydratingBlock) {{\n",
2516                    binding.name,
2517                ));
2518                emit_hydration_operations(js, operations, "$noxTarget", "$noxBlockOwner", depth + 2)?;
2519                js.push_str(&format!("{indent}  }} else {{\n"));
2520                let nested_fragment = format!("$noxHydratedForFragment{marker}");
2521                js.push_str(&format!(
2522                    "{indent}    const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
2523                ));
2524                emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
2525                js.push_str(&format!(
2526                    "{indent}    $noxTarget.replaceChildren({nested_fragment});\n{indent}  }}\n{indent}}}, \"{}\");\n",
2527                    js_escape(id.as_str()),
2528                ));
2529            }
2530            Operation::Stream {
2531                id,
2532                marker,
2533                expression,
2534                dependencies,
2535                cases,
2536            } => {
2537                js.push_str(&format!(
2538                    "{indent}hydrateStream(findMarker({root}, \"noxid-stream-{marker}\"), \"noxid-stream-end-{marker}\", {}, {owner}, {{\n",
2539                    emit_source(expression, dependencies, owner)?,
2540                ));
2541                for case in cases {
2542                    js.push_str(&format!(
2543                        "{indent}  \"{}\": ($noxTarget, $noxBlockOwner, $noxStreamEvent, $noxHydratingBlock) => {{\n",
2544                        js_escape(&case.variant),
2545                    ));
2546                    if let Some(binding) = &case.binding {
2547                        js.push_str(&format!(
2548                            "{indent}    const {} = toSource($noxStreamEvent.value);\n",
2549                            binding.name,
2550                        ));
2551                    }
2552                    js.push_str(&format!("{indent}    if ($noxHydratingBlock) {{\n"));
2553                    emit_hydration_operations(js, &case.operations, "$noxTarget", "$noxBlockOwner", depth + 3)?;
2554                    js.push_str(&format!("{indent}    }} else {{\n"));
2555                    let nested_fragment = format!("$noxHydratedStreamFragment{marker}{}", case.variant);
2556                    js.push_str(&format!(
2557                        "{indent}      const {nested_fragment} = {}.content.cloneNode(true);\n",
2558                        case.template_name,
2559                    ));
2560                    emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 3)?;
2561                    js.push_str(&format!(
2562                        "{indent}      $noxTarget.replaceChildren({nested_fragment});\n{indent}    }}\n{indent}  }},\n"
2563                    ));
2564                }
2565                js.push_str(&format!(
2566                    "{indent}}}, \"{}\");\n",
2567                    js_escape(id.as_str()),
2568                ));
2569            }
2570        }
2571    }
2572    Ok(())
2573}
2574
2575fn emit_props(props: &[PropArgument], owner: &str) -> Result<String, String> {
2576    let values = props
2577        .iter()
2578        .map(|prop| {
2579            Ok(format!(
2580                "{}: {}",
2581                prop.name,
2582                emit_source(&prop.expression, &prop.dependencies, owner)?
2583            ))
2584        })
2585        .collect::<Result<Vec<_>, String>>()?
2586        .join(", ");
2587    Ok(format!("{{ {values} }}"))
2588}
2589
2590fn event_handler_javascript(
2591    action: &SemanticId,
2592    arguments: &Option<Vec<SemanticExpr>>,
2593) -> Result<String, String> {
2594    Ok(match arguments {
2595        // Bare form: the action itself is the listener and receives the event.
2596        None => symbol_name(action).to_string(),
2597        // Call form: arguments are evaluated when the event fires, so a
2598        // handler inside a keyed block reads the block's current values.
2599        Some(arguments) => format!(
2600            "() => {}({})",
2601            symbol_name(action),
2602            arguments
2603                .iter()
2604                .map(emit_expr)
2605                .collect::<Result<Vec<_>, String>>()?
2606                .join(", ")
2607        ),
2608    })
2609}
2610
2611fn emit_handlers(handlers: &[ComponentEventHandler]) -> String {
2612    let values = handlers
2613        .iter()
2614        .map(|handler| {
2615            format!(
2616                "\"{}\": {}",
2617                js_escape(&handler.name),
2618                symbol_name(&handler.action)
2619            )
2620        })
2621        .collect::<Vec<_>>()
2622        .join(", ");
2623    format!("{{ {values} }}")
2624}
2625
2626fn emit_resource_refresh_triggers(triggers: &[ResourceRefreshTrigger]) -> String {
2627    triggers
2628        .iter()
2629        .map(|trigger| match trigger {
2630            ResourceRefreshTrigger::Focus { .. } | ResourceRefreshTrigger::Reconnect { .. } => {
2631                format!(
2632                    "Object.freeze({{ kind: \"{}\", milliseconds: null }})",
2633                    trigger.kind()
2634                )
2635            }
2636            ResourceRefreshTrigger::Every { milliseconds, .. } => {
2637                format!("Object.freeze({{ kind: \"every\", milliseconds: {milliseconds} }})")
2638            }
2639        })
2640        .collect::<Vec<_>>()
2641        .join(", ")
2642}
2643
2644fn emit_source(
2645    expression: &SemanticExpr,
2646    dependencies: &[SemanticId],
2647    owner: &str,
2648) -> Result<String, String> {
2649    if let SemanticExprKind::Reference(id) = &expression.kind {
2650        return Ok(symbol_name(id).into());
2651    }
2652    if dependencies.is_empty() {
2653        return emit_expr(expression);
2654    }
2655    Ok(format!(
2656        "computed(() => {}, {owner}, {})",
2657        emit_expr(expression)?,
2658        emit_sources(dependencies)
2659    ))
2660}
2661
2662fn emit_transition(transition: &Option<ConditionalTransition>) -> String {
2663    transition.as_ref().map_or_else(
2664        || "null".into(),
2665        |transition| {
2666            format!(
2667                "{{ semanticId: \"{}\", durationMilliseconds: {} }}",
2668                js_escape(transition.id.as_str()),
2669                transition.milliseconds,
2670            )
2671        },
2672    )
2673}
2674
2675fn emit_attachment_config(config: &AttachmentConfig) -> String {
2676    match config {
2677        AttachmentConfig::Animate { duration } => {
2678            format!("{{ durationMilliseconds: {} }}", duration.milliseconds)
2679        }
2680    }
2681}
2682
2683fn emit_match_source(
2684    expression: &SemanticExpr,
2685    dependencies: &[SemanticId],
2686    owner: &str,
2687) -> Result<String, String> {
2688    if !matches!(expression.ty, noxid_types::Type::Optional(_)) {
2689        return emit_source(expression, dependencies, owner);
2690    }
2691    let optional = format!(
2692        "(($noxOptional) => ($noxOptional == null ? {{ tag: \"None\" }} : {{ tag: \"Some\", value: $noxOptional }}))({})",
2693        emit_expr(expression)?,
2694    );
2695    Ok(if dependencies.is_empty() {
2696        optional
2697    } else {
2698        format!(
2699            "computed(() => {optional}, {owner}, {})",
2700            emit_sources(dependencies)
2701        )
2702    })
2703}
2704
2705/// Emit an already type-checked expression with the client runtime calling
2706/// convention. Scenario harnesses use this narrow API so tests execute the
2707/// same expression lowering as the shipped component module.
2708pub fn emit_scenario_expression(expr: &SemanticExpr) -> Result<String, String> {
2709    let emitted = emit_expr(expr)?;
2710    Ok(if emitted.contains("$noxEqual(") {
2711        format!(
2712            "(($noxEqual) => ({}))({})",
2713            emitted,
2714            LANGUAGE_VALUE_EQUALITY_FUNCTION
2715                .trim()
2716                .strip_prefix("function $noxEqual")
2717                .map(|body| format!("function $noxEqual{body}"))
2718                .expect("equality helper is a named function")
2719        )
2720    } else {
2721        emitted
2722    })
2723}
2724
2725fn has_language_value_equality(ty: &noxid_types::Type) -> bool {
2726    use noxid_types::Type;
2727    match ty {
2728        Type::Array(_)
2729        | Type::Map(_, _)
2730        | Type::MapEntry(_, _)
2731        | Type::Result(_, _)
2732        | Type::Named(_) => true,
2733        Type::Optional(inner)
2734        | Type::Static(inner)
2735        | Type::Reactive(inner)
2736        | Type::Binding(inner) => has_language_value_equality(inner),
2737        // `File` never reaches client code: an upload body field carries the
2738        // compiler-owned `FileRef` shape by the time codegen sees it.
2739        Type::File
2740        | Type::Int
2741        | Type::String
2742        | Type::Boolean
2743        | Type::Number
2744        | Type::Float
2745        | Type::Date
2746        | Type::Function(_, _)
2747        | Type::Unknown => false,
2748    }
2749}
2750
2751/// Lower one expression with the client runtime calling convention.
2752///
2753/// Fail-closed: a builtin the shared lowering table cannot serve returns a
2754/// stable `error[CODE]` instead of panicking, and never falls through to the
2755/// user-function form where `len(a, b)` would name a function the emitted
2756/// module never defines.
2757fn emit_expr(expr: &SemanticExpr) -> Result<String, String> {
2758    Ok(match &expr.kind {
2759        SemanticExprKind::Int(value) => value.to_string(),
2760        SemanticExprKind::Float(value) => value.to_string(),
2761        SemanticExprKind::String(value) => format!("\"{}\"", js_escape(value)),
2762        SemanticExprKind::Boolean(value) => value.to_string(),
2763        SemanticExprKind::Array(values) => format!(
2764            "[{}]",
2765            values
2766                .iter()
2767                .map(emit_expr)
2768                .collect::<Result<Vec<_>, _>>()?
2769                .join(", ")
2770        ),
2771        SemanticExprKind::Struct { fields, .. } => format!(
2772            "{{ {} }}",
2773            fields
2774                .iter()
2775                .map(|field| {
2776                    Ok(format!(
2777                        "\"{}\": {}",
2778                        js_escape(&field.name),
2779                        emit_expr(&field.value)?
2780                    ))
2781                })
2782                .collect::<Result<Vec<_>, String>>()?
2783                .join(", ")
2784        ),
2785        SemanticExprKind::FieldAccess { base, name, .. } => {
2786            format!("{}[\"{}\"]", emit_expr(base)?, js_escape(name))
2787        }
2788        SemanticExprKind::CollectionQuery {
2789            base,
2790            kind,
2791            field,
2792            value,
2793        } => {
2794            let value = value.as_deref().map(emit_expr).transpose()?;
2795            noxid_ir::collection_query_javascript(
2796                *kind,
2797                &emit_expr(base)?,
2798                field.as_ref().map(|segment| segment.name.as_str()),
2799                value.as_deref(),
2800                match &base.ty {
2801                    noxid_types::Type::Map(key, _) => Some(key.as_ref()),
2802                    _ => None,
2803                },
2804            )
2805        }
2806        SemanticExprKind::Call {
2807            function,
2808            name,
2809            arguments,
2810        } => format!(
2811            "__noxidValidateExternalResult(\"{}\", {}, {}({}))",
2812            js_escape(function.as_str()),
2813            external_type_descriptor(&expr.ty),
2814            name,
2815            arguments
2816                .iter()
2817                .map(emit_expr)
2818                .collect::<Result<Vec<_>, _>>()?
2819                .join(", ")
2820        ),
2821        SemanticExprKind::Reference(id)
2822            if id.as_str().starts_with("stream-use:")
2823                || id.as_str().starts_with("presence-use:") =>
2824        {
2825            format!(
2826                "{}.get().map(($noxEnvelope) => $noxEnvelope.event)",
2827                symbol_name(id)
2828            )
2829        }
2830        SemanticExprKind::Reference(id) => format!("{}.get()", symbol_name(id)),
2831        SemanticExprKind::Variant {
2832            variant, payload, ..
2833        } => {
2834            let tag = symbol_name(variant);
2835            match payload {
2836                Some(payload) => format!(
2837                    "{{ tag: \"{}\", value: {} }}",
2838                    js_escape(tag),
2839                    emit_expr(payload)?
2840                ),
2841                None => format!("{{ tag: \"{}\" }}", js_escape(tag)),
2842            }
2843        }
2844        SemanticExprKind::Binary { left, op, right } => {
2845            if matches!(op, SemanticBinaryOp::Equal | SemanticBinaryOp::NotEqual)
2846                && has_language_value_equality(&left.ty)
2847            {
2848                let equality = format!("$noxEqual({}, {})", emit_expr(left)?, emit_expr(right)?);
2849                return Ok(if matches!(op, SemanticBinaryOp::NotEqual) {
2850                    format!("(!{equality})")
2851                } else {
2852                    equality
2853                });
2854            }
2855            let operator = match op {
2856                SemanticBinaryOp::Equal => "===",
2857                SemanticBinaryOp::NotEqual => "!==",
2858                SemanticBinaryOp::Coalesce => "??",
2859                other => other.as_str(),
2860            };
2861            format!("({} {operator} {})", emit_expr(left)?, emit_expr(right)?)
2862        }
2863        SemanticExprKind::Unary { op, operand } => {
2864            format!("({}{})", op.as_str(), emit_expr(operand)?)
2865        }
2866        SemanticExprKind::StringTemplate(parts) => {
2867            let mut pieces = vec!["\"\"".to_string()];
2868            for part in parts {
2869                pieces.push(match part {
2870                    SemanticTemplatePart::Literal(value) => format!("\"{}\"", js_escape(value)),
2871                    SemanticTemplatePart::Expression(expression) => {
2872                        format!("({})", emit_expr(expression)?)
2873                    }
2874                });
2875            }
2876            format!("({})", pieces.join(" + "))
2877        }
2878        SemanticExprKind::FunctionCall {
2879            function,
2880            name,
2881            arguments,
2882        } => match emit_distinct_identity_call(function, arguments)? {
2883            Some(javascript) => javascript,
2884            None => match emit_builtin_call(function, arguments)? {
2885                Some(javascript) => javascript,
2886                // Only a genuine file-scoped `function` reaches the plain
2887                // call form; the client emits those as real JS functions.
2888                None => format!(
2889                    "{}({})",
2890                    name,
2891                    arguments
2892                        .iter()
2893                        .map(emit_expr)
2894                        .collect::<Result<Vec<_>, _>>()?
2895                        .join(", ")
2896                ),
2897            },
2898        },
2899    })
2900}
2901
2902fn emit_distinct_identity_call(
2903    function: &SemanticId,
2904    arguments: &[SemanticExpr],
2905) -> Result<Option<String>, String> {
2906    if !function.is_distinct_call() {
2907        return Ok(None);
2908    }
2909    match arguments.first() {
2910        Some(argument) => emit_expr(argument).map(Some),
2911        None => Ok(Some("undefined".into())),
2912    }
2913}
2914
2915/// `Ok(None)` means "not a builtin at all"; `Err` means the id claims to be a
2916/// builtin the shared lowering table cannot emit. Semantics rejects every such
2917/// call with BUILTIN_OVERLOAD_MISMATCH first, so this is a defensive backstop
2918/// — but it must fail closed rather than panic or emit a bare call.
2919fn emit_builtin_call(
2920    function: &SemanticId,
2921    arguments: &[SemanticExpr],
2922) -> Result<Option<String>, String> {
2923    let Some(name) = function.as_str().strip_prefix("fn:@builtin.") else {
2924        return Ok(None);
2925    };
2926    let emitted = arguments
2927        .iter()
2928        .map(emit_expr)
2929        .collect::<Result<Vec<_>, _>>()?;
2930    noxid_ir::builtin_javascript(name, &emitted)
2931        .map(Some)
2932        .ok_or_else(|| noxid_ir::rejected_builtin_call(name, emitted.len()))
2933}
2934
2935// File-scoped pure functions share the action calling convention: inputs are
2936// wrapped with toSource so the shared statement emitter's .get() reads work.
2937fn emit_function_definitions(
2938    js: &mut String,
2939    functions: &[FunctionDefinition],
2940) -> Result<(), String> {
2941    for function in functions {
2942        js.push_str(&format!(
2943            "function {}({}) {{\n",
2944            function.name,
2945            function
2946                .parameters
2947                .iter()
2948                .map(|parameter| format!("{}Input", parameter.name))
2949                .collect::<Vec<_>>()
2950                .join(", ")
2951        ));
2952        for parameter in &function.parameters {
2953            js.push_str(&format!(
2954                "  const {} = toSource({}Input);\n",
2955                parameter.name, parameter.name
2956            ));
2957        }
2958        emit_statements(js, &function.statements, 1)?;
2959        js.push_str("}\n\n");
2960    }
2961    Ok(())
2962}
2963
2964fn external_validation_prelude(program: &SemanticProgram) -> String {
2965    let schemas = program
2966        .types
2967        .iter()
2968        .chain(
2969            program
2970                .components
2971                .iter()
2972                .flat_map(|component| component.types.iter()),
2973        )
2974        .map(|definition| {
2975            format!(
2976                "\"{}\":{{{}}}",
2977                js_escape(&definition.name),
2978                definition
2979                    .fields
2980                    .iter()
2981                    .map(|field| format!(
2982                        "\"{}\":{}",
2983                        js_escape(&field.name),
2984                        external_type_descriptor(&field.ty)
2985                    ))
2986                    .collect::<Vec<_>>()
2987                    .join(",")
2988            )
2989        })
2990        .collect::<Vec<_>>()
2991        .join(",");
2992    format!(
2993        r#"const __noxidExternalSchemas = Object.freeze({{{schemas}}});
2994function __noxidExternalFailure(code, symbol, expected, path, value) {{
2995  const error = new TypeError(`${{code}}: ${{symbol}} returned invalid ${{expected}} at ${{path}}`);
2996  error.code = code;
2997  error.symbol = symbol;
2998  error.expected = expected;
2999  error.path = path;
3000  error.received = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
3001  throw error;
3002}}
3003function __noxidValidateExternalValue(symbol, schema, value, path) {{
3004  switch (schema.kind) {{
3005    case "string": if (typeof value === "string") return value; break;
3006    case "boolean": if (typeof value === "boolean") return value; break;
3007    case "number": if (typeof value === "number" && Number.isFinite(value)) return value; break;
3008    case "int": if (Number.isSafeInteger(value)) return value; break;
3009    case "date": if (value instanceof Date && Number.isFinite(value.getTime())) return value; break;
3010    case "void": if (value === undefined) return value; break;
3011    case "optional": if (value === undefined || value === null) return value; return __noxidValidateExternalValue(symbol, schema.value, value, path);
3012    case "array": if (Array.isArray(value)) return Object.freeze(value.map((item, index) => __noxidValidateExternalValue(symbol, schema.item, item, `${{path}}[${{index}}]`))); break;
3013    case "map": if (value && typeof value === "object" && !Array.isArray(value)) {{ const normalized = Object.create(null); for (const [key, item] of Object.entries(value)) {{ const trustedKey = __noxidValidateExternalValue(symbol, schema.key, key, `${{path}}.<key>`); normalized[trustedKey] = __noxidValidateExternalValue(symbol, schema.value, item, `${{path}}.${{key}}`); }} return Object.freeze(normalized); }} break;
3014    case "named": {{ const fields = __noxidExternalSchemas[schema.name]; if (!fields || !value || typeof value !== "object" || Array.isArray(value)) break; const normalized = Object.create(null); for (const [name, fieldSchema] of Object.entries(fields)) normalized[name] = __noxidValidateExternalValue(symbol, fieldSchema, value[name], `${{path}}.${{name}}`); return Object.freeze(normalized); }}
3015  }}
3016  return __noxidExternalFailure("EXTERNAL_RESULT_VALIDATION_FAILED", symbol, schema.name ?? schema.kind, path, value);
3017}}
3018function __noxidValidateExternalResult(symbol, schema, value) {{
3019  if (value && typeof value.then === "function") return __noxidExternalFailure("EXTERNAL_ASYNC_RESULT_UNSUPPORTED", symbol, schema.name ?? schema.kind, "$", value);
3020  return __noxidValidateExternalValue(symbol, schema, value, "$");
3021}}
3022"#
3023    )
3024}
3025
3026fn external_type_descriptor(ty: &noxid_types::Type) -> String {
3027    use noxid_types::Type;
3028    match ty {
3029        Type::String => "{kind:\"string\"}".into(),
3030        Type::Boolean => "{kind:\"boolean\"}".into(),
3031        Type::Number | Type::Float => "{kind:\"number\"}".into(),
3032        Type::Int => "{kind:\"int\"}".into(),
3033        Type::Date => "{kind:\"date\"}".into(),
3034        Type::Array(item) => format!("{{kind:\"array\",item:{}}}", external_type_descriptor(item)),
3035        Type::Map(key, value) => format!(
3036            "{{kind:\"map\",key:{},value:{}}}",
3037            external_type_descriptor(key),
3038            external_type_descriptor(value)
3039        ),
3040        Type::Optional(value) => format!(
3041            "{{kind:\"optional\",value:{}}}",
3042            external_type_descriptor(value)
3043        ),
3044        Type::Static(value) | Type::Reactive(value) | Type::Binding(value) => {
3045            external_type_descriptor(value)
3046        }
3047        Type::Named(name) if matches!(name.as_str(), "Void" | "Undefined") => {
3048            "{kind:\"void\"}".into()
3049        }
3050        Type::Named(name) => format!("{{kind:\"named\",name:\"{}\"}}", js_escape(name)),
3051        Type::File
3052        | Type::MapEntry(_, _)
3053        | Type::Result(_, _)
3054        | Type::Function(_, _)
3055        | Type::Unknown => "{kind:\"unsupported\"}".into(),
3056    }
3057}
3058
3059fn target_name(id: &SemanticId) -> &str {
3060    id.as_str()
3061        .strip_prefix("component:")
3062        .unwrap_or(id.as_str())
3063}
3064fn symbol_name(id: &SemanticId) -> &str {
3065    if id.as_str().starts_with("presence-use:") {
3066        return "presence";
3067    }
3068    id.as_str()
3069        .rsplit('.')
3070        .next()
3071        .unwrap_or_else(|| target_name(id))
3072}
3073fn emit_sources(sources: &[SemanticId]) -> String {
3074    format!(
3075        "[{}]",
3076        sources
3077            .iter()
3078            .map(symbol_name)
3079            .collect::<Vec<_>>()
3080            .join(", ")
3081    )
3082}
3083
3084#[cfg(test)]
3085mod tests {
3086    use super::*;
3087    use noxid_ir::{StructDefinition, StructFieldDefinition};
3088    use noxid_source::Span;
3089    use noxid_types::Type;
3090    use std::process::Command;
3091
3092    // Every expression and component these tests build is lowerable; the
3093    // fail-closed path has its own test that calls `super::emit_expr` directly.
3094    fn emit_expr(expr: &SemanticExpr) -> String {
3095        super::emit_expr(expr).expect("expression has a client lowering")
3096    }
3097
3098    // WO-45 phase 2 parity: the same two expressions lower to the same two
3099    // strings in codegen-ssr-js and codegen-server-js, whose sibling tests
3100    // assert them literally. A distinct type is erased at every boundary.
3101    #[test]
3102    fn distinct_construct_and_unwrap_erase_to_the_same_plain_client_value() {
3103        let literal = SemanticExpr {
3104            kind: SemanticExprKind::String("u-1".into()),
3105            ty: Type::String,
3106            span: Span::new(0, 1),
3107        };
3108        let constructed = SemanticExpr {
3109            kind: SemanticExprKind::FunctionCall {
3110                function: SemanticId::distinct_construct("UserId"),
3111                name: "UserId".into(),
3112                arguments: vec![literal],
3113            },
3114            ty: Type::Named("UserId".into()),
3115            span: Span::new(0, 1),
3116        };
3117        let unwrapped = SemanticExpr {
3118            kind: SemanticExprKind::FunctionCall {
3119                function: SemanticId::distinct_unwrap("UserId"),
3120                name: "UserId.base".into(),
3121                arguments: vec![constructed.clone()],
3122            },
3123            ty: Type::String,
3124            span: Span::new(0, 1),
3125        };
3126        assert_eq!(emit_expr(&constructed), "\"u-1\"");
3127        assert_eq!(emit_expr(&unwrapped), "\"u-1\"");
3128    }
3129
3130    fn emit_scenario_expression(expr: &SemanticExpr) -> String {
3131        super::emit_scenario_expression(expr).expect("expression has a client lowering")
3132    }
3133
3134    /// Semantics refuses a mis-arity builtin with BUILTIN_OVERLOAD_MISMATCH
3135    /// before emission, so this is a defensive backstop. The client emitter
3136    /// used to `panic!` here; it now fails closed with the same message the
3137    /// SSR and server emitters produce.
3138    #[test]
3139    fn a_builtin_with_no_lowering_fails_closed_in_the_client_emitter() {
3140        let call = client_builtin(
3141            "len",
3142            vec![
3143                SemanticExpr {
3144                    kind: SemanticExprKind::String("a".into()),
3145                    ty: Type::String,
3146                    span: Span::new(0, 1),
3147                },
3148                SemanticExpr {
3149                    kind: SemanticExprKind::String("b".into()),
3150                    ty: Type::String,
3151                    span: Span::new(0, 1),
3152                },
3153            ],
3154            Type::Int,
3155        );
3156        let error =
3157            super::emit_expr(&call).expect_err("a two-argument `len` has no client lowering");
3158        assert!(
3159            error.starts_with("error[BUILTIN_OVERLOAD_MISMATCH]: builtin `len`"),
3160            "{error}"
3161        );
3162    }
3163
3164    fn expression(kind: SemanticExprKind, ty: Type) -> SemanticExpr {
3165        SemanticExpr {
3166            kind,
3167            ty,
3168            span: Span::new(0, 1),
3169        }
3170    }
3171
3172    fn remote_await_component() -> ComponentDefinition {
3173        let request_parameter = ActionParameter {
3174            id: SemanticId::action_parameter("RemoteForm", "save", "request"),
3175            name: "request".into(),
3176            ty: Type::Int,
3177            type_id: None,
3178            span: Span::new(0, 1),
3179        };
3180        let remote_action = Action {
3181            id: SemanticId::action("RemoteForm", "save"),
3182            name: "save".into(),
3183            execution: ExecutionTarget::Server,
3184            parameters: vec![request_parameter.clone()],
3185            result: ActionResult {
3186                id: SemanticId::action_result("RemoteForm", "save"),
3187                ty: Type::Int,
3188                type_id: None,
3189                span: Span::new(0, 1),
3190            },
3191            capabilities: vec![],
3192            invalidation: ActionInvalidation {
3193                mode: ActionInvalidationMode::None,
3194                resources: vec![],
3195            },
3196            statements: vec![],
3197            reads: vec![],
3198            writes: vec![],
3199            calls: vec![],
3200            span: Span::new(0, 1),
3201        };
3202        let success = MatchBinding {
3203            id: SemanticId::local("RemoteForm", "submit", "saved"),
3204            name: "saved".into(),
3205            ty: Type::Int,
3206        };
3207        let failure = MatchBinding {
3208            id: SemanticId::local("RemoteForm", "submit", "error"),
3209            name: "error".into(),
3210            ty: Type::Named("RemoteError".into()),
3211        };
3212        let client_action = Action {
3213            id: SemanticId::action("RemoteForm", "submit"),
3214            name: "submit".into(),
3215            execution: ExecutionTarget::Client,
3216            parameters: vec![],
3217            result: ActionResult {
3218                id: SemanticId::action_result("RemoteForm", "submit"),
3219                ty: Type::Unknown,
3220                type_id: None,
3221                span: Span::new(0, 1),
3222            },
3223            capabilities: vec![],
3224            invalidation: ActionInvalidation {
3225                mode: ActionInvalidationMode::None,
3226                resources: vec![],
3227            },
3228            statements: vec![
3229                SemanticStatement::Local {
3230                    id: SemanticId::local("RemoteForm", "submit", "request"),
3231                    name: "request".into(),
3232                    value: expression(SemanticExprKind::Int(3), Type::Int),
3233                    span: Span::new(0, 1),
3234                },
3235                SemanticStatement::Assignment {
3236                    target: SemanticId::state("RemoteForm", "savedId"),
3237                    value: expression(SemanticExprKind::Int(-1), Type::Int),
3238                    span: Span::new(0, 1),
3239                },
3240                SemanticStatement::RemoteAwait {
3241                    binding: MatchBinding {
3242                        id: SemanticId::local("RemoteForm", "submit", "outcome"),
3243                        name: "outcome".into(),
3244                        ty: Type::Result(
3245                            Box::new(Type::Int),
3246                            Box::new(Type::Named("RemoteError".into())),
3247                        ),
3248                    },
3249                    action: remote_action.id.clone(),
3250                    name: remote_action.name.clone(),
3251                    arguments: vec![RemoteActionArgument {
3252                        parameter: request_parameter.id.clone(),
3253                        name: request_parameter.name.clone(),
3254                        value: expression(
3255                            SemanticExprKind::Reference(SemanticId::local(
3256                                "RemoteForm",
3257                                "submit",
3258                                "request",
3259                            )),
3260                            Type::Int,
3261                        ),
3262                        span: Span::new(0, 1),
3263                    }],
3264                    ok_arm: Box::new(RemoteAwaitArm {
3265                        variant: SemanticId::remote_result_variant("Ok"),
3266                        name: "Ok".into(),
3267                        binding: Some(success.clone()),
3268                        statements: vec![SemanticStatement::Assignment {
3269                            target: SemanticId::state("RemoteForm", "savedId"),
3270                            value: expression(SemanticExprKind::Reference(success.id), Type::Int),
3271                            span: Span::new(0, 1),
3272                        }],
3273                        span: Span::new(0, 1),
3274                    }),
3275                    err_arm: Box::new(RemoteAwaitArm {
3276                        variant: SemanticId::remote_result_variant("Err"),
3277                        name: "Err".into(),
3278                        binding: Some(failure.clone()),
3279                        statements: vec![SemanticStatement::Assignment {
3280                            target: SemanticId::state("RemoteForm", "errorMessage"),
3281                            value: expression(
3282                                SemanticExprKind::FieldAccess {
3283                                    base: Box::new(expression(
3284                                        SemanticExprKind::Reference(failure.id),
3285                                        Type::Named("RemoteError".into()),
3286                                    )),
3287                                    field: SemanticId::external_field("RemoteError", "message"),
3288                                    name: "message".into(),
3289                                },
3290                                Type::String,
3291                            ),
3292                            span: Span::new(0, 1),
3293                        }],
3294                        span: Span::new(0, 1),
3295                    }),
3296                    span: Span::new(0, 1),
3297                },
3298                SemanticStatement::Assignment {
3299                    target: SemanticId::state("RemoteForm", "finished"),
3300                    value: expression(SemanticExprKind::Boolean(true), Type::Boolean),
3301                    span: Span::new(0, 1),
3302                },
3303            ],
3304            reads: vec![],
3305            writes: vec![],
3306            calls: vec![remote_action.id.clone()],
3307            span: Span::new(0, 1),
3308        };
3309        ComponentDefinition {
3310            id: SemanticId::component("RemoteForm"),
3311            name: "RemoteForm".into(),
3312            route_metadata: None,
3313            route_render: None,
3314            render: ComponentRenderPolicy {
3315                id: SemanticId::component_render("RemoteForm"),
3316                mode: ComponentRenderMode::Universal,
3317                hydration: HydrationMode::Eager,
3318                span: Span::new(0, 1),
3319            },
3320            route_query: vec![],
3321            middleware: vec![],
3322            capabilities: vec![],
3323            props: vec![],
3324            events: vec![],
3325            context_uses: vec![],
3326            context_providers: vec![],
3327            types: vec![],
3328            distinct_types: vec![],
3329            machines: vec![],
3330            states: vec![],
3331            computed: vec![],
3332            loaders: vec![],
3333            resources: vec![],
3334            presence: None,
3335            streams: vec![],
3336            agents: vec![],
3337            actions: vec![remote_action, client_action],
3338            lifecycle: None,
3339            effects: vec![],
3340            behaviors: vec![],
3341            regions: vec![],
3342            intent: None,
3343            invariants: vec![],
3344            requirements: vec![],
3345            scenarios: vec![],
3346            view: vec![],
3347            style: None,
3348            span: Span::new(0, 1),
3349        }
3350    }
3351
3352    #[test]
3353    fn remote_await_commits_optimistic_and_continuation_transactions_around_boundary() {
3354        let mut generated = String::new();
3355        emit_hmr_action_factory(
3356            &mut generated,
3357            &SemanticProgram {
3358                imports: vec![],
3359                functions: vec![],
3360                external_modules: vec![],
3361                contexts: vec![],
3362                types: vec![],
3363                distinct_types: vec![],
3364                resources: vec![],
3365                streams: vec![],
3366                agents: vec![],
3367                endpoints: vec![],
3368                tasks: vec![],
3369                queues: vec![],
3370                models: vec![],
3371                components: vec![],
3372            },
3373            &remote_await_component(),
3374        )
3375        .expect("component has a client lowering");
3376        let generated = generated.replace("export ", "");
3377        let script = format!(
3378            r#"const transactions = [];
3379function runAction(id, _owner, callback) {{ transactions.push(id); return callback(); }}
3380function toSource(value) {{ return {{ get() {{ return value; }} }}; }}
3381function source(initial) {{ let value = initial; return {{ get() {{ return value; }}, set(next) {{ value = next; }} }}; }}
3382{generated}
3383let resolveBoundary;
3384const savedId = source(0);
3385const errorMessage = source("");
3386const finished = source(false);
3387const pending = new Promise((resolve) => {{ resolveBoundary = resolve; }});
3388const actions = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished, $noxExecuteBoundary(descriptor) {{
3389  if (descriptor.id !== "action:RemoteForm.save" || descriptor.arguments[0].value !== 3) throw new Error("invalid descriptor");
3390  return pending;
3391}} }});
3392const submission = actions.submit();
3393if (savedId.get() !== -1 || finished.get() || transactions.length !== 1) throw new Error("optimistic transaction did not commit before await");
3394resolveBoundary(7);
3395await submission;
3396if (savedId.get() !== 7 || !finished.get() || transactions.length !== 2) throw new Error("Ok continuation did not run in exactly one fresh transaction");
3397
3398transactions.length = 0;
3399savedId.set(0); errorMessage.set(""); finished.set(false);
3400const rejected = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished, async $noxExecuteBoundary() {{ const error = new Error("already exists"); error.code = "CONFLICT"; throw error; }} }});
3401await rejected.submit();
3402if (savedId.get() !== -1 || errorMessage.get() !== "already exists" || !finished.get()) throw new Error("Err continuation did not consume the closed RemoteError value");
3403if (transactions.length !== 2) throw new Error("Err path did not preserve the two-transaction contract");
3404
3405transactions.length = 0;
3406savedId.set(0); errorMessage.set(""); finished.set(false);
3407const unavailable = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished }});
3408await unavailable.submit();
3409if (savedId.get() !== -1 || !errorMessage.get().includes("No host executor is configured") || !finished.get()) throw new Error("missing boundary executor escaped the typed Err channel");
3410if (transactions.length !== 2) throw new Error("unavailable boundary did not preserve the two-transaction contract");
3411"#,
3412        );
3413        let output = Command::new("node")
3414            .args(["--input-type=module", "-e", &script])
3415            .output()
3416            .expect("node must execute generated remote-await actions");
3417        assert!(
3418            output.status.success(),
3419            "generated:\n{generated}\nstderr:\n{}",
3420            String::from_utf8_lossy(&output.stderr)
3421        );
3422    }
3423
3424    #[test]
3425    fn remote_await_arm_transitions_keep_the_runtime_helper_import() {
3426        let mut component = remote_await_component();
3427        let submit = component
3428            .actions
3429            .iter_mut()
3430            .find(|action| action.name == "submit")
3431            .unwrap();
3432        let remote_await = submit
3433            .statements
3434            .iter_mut()
3435            .find_map(|statement| match statement {
3436                SemanticStatement::RemoteAwait { ok_arm, .. } => Some(ok_arm),
3437                SemanticStatement::Return { .. }
3438                | SemanticStatement::Assignment { .. }
3439                | SemanticStatement::FieldAssignment { .. }
3440                | SemanticStatement::Local { .. }
3441                | SemanticStatement::LocalAssignment { .. }
3442                | SemanticStatement::If { .. }
3443                | SemanticStatement::ActionCall { .. }
3444                | SemanticStatement::Transition { .. }
3445                | SemanticStatement::CollectionMutation { .. }
3446                | SemanticStatement::PrincipalMatch { .. }
3447                | SemanticStatement::Emit { .. } => None,
3448            })
3449            .unwrap();
3450        remote_await.statements = vec![SemanticStatement::Transition {
3451            target: SemanticId::state("RemoteForm", "phase"),
3452            value: expression(
3453                SemanticExprKind::Variant {
3454                    machine: SemanticId::machine("RemoteForm", "Phase"),
3455                    variant: SemanticId::variant("RemoteForm", "Phase", "Ready"),
3456                    payload: None,
3457                },
3458                Type::Named("Phase".into()),
3459            ),
3460            machine: SemanticId::machine("RemoteForm", "Phase"),
3461            event: "submit".into(),
3462            allowed: vec![("Idle".into(), "Ready".into())],
3463            span: Span::new(0, 1),
3464        }];
3465
3466        let mut imports = BTreeSet::new();
3467        collect_component_runtime_imports(&component, &[], false, &mut imports);
3468        assert!(imports.contains("transitionMachine"));
3469    }
3470
3471    fn client_builtin(name: &str, arguments: Vec<SemanticExpr>, ty: Type) -> SemanticExpr {
3472        SemanticExpr {
3473            kind: SemanticExprKind::FunctionCall {
3474                function: SemanticId::function(&format!("@builtin.{name}")),
3475                name: name.into(),
3476                arguments,
3477            },
3478            ty,
3479            span: Span::new(0, 1),
3480        }
3481    }
3482
3483    #[test]
3484    fn emits_every_scalar_builtin_with_reactive_client_arguments() {
3485        let string = || SemanticExpr {
3486            kind: SemanticExprKind::Reference(SemanticId::state("C", "name")),
3487            ty: Type::String,
3488            span: Span::new(0, 1),
3489        };
3490        let int = |value| SemanticExpr {
3491            kind: SemanticExprKind::Int(value),
3492            ty: Type::Int,
3493            span: Span::new(0, 1),
3494        };
3495        let float = |value| SemanticExpr {
3496            kind: SemanticExprKind::Float(value),
3497            ty: Type::Float,
3498            span: Span::new(0, 1),
3499        };
3500        let cases = [
3501            (
3502                client_builtin("len", vec![string()], Type::Int),
3503                "name.get().length",
3504            ),
3505            (
3506                client_builtin(
3507                    "contains",
3508                    vec![
3509                        string(),
3510                        SemanticExpr {
3511                            kind: SemanticExprKind::String("a".into()),
3512                            ty: Type::String,
3513                            span: Span::new(0, 1),
3514                        },
3515                    ],
3516                    Type::Boolean,
3517                ),
3518                "name.get().includes(\"a\")",
3519            ),
3520            (
3521                client_builtin(
3522                    "startsWith",
3523                    vec![
3524                        string(),
3525                        SemanticExpr {
3526                            kind: SemanticExprKind::String("A".into()),
3527                            ty: Type::String,
3528                            span: Span::new(0, 1),
3529                        },
3530                    ],
3531                    Type::Boolean,
3532                ),
3533                "name.get().startsWith(\"A\")",
3534            ),
3535            (
3536                client_builtin("trim", vec![string()], Type::String),
3537                "name.get().trim()",
3538            ),
3539            (
3540                client_builtin("lower", vec![string()], Type::String),
3541                "name.get().toLowerCase()",
3542            ),
3543            (
3544                client_builtin("upper", vec![string()], Type::String),
3545                "name.get().toUpperCase()",
3546            ),
3547            (
3548                client_builtin("min", vec![int(2), int(3)], Type::Int),
3549                "Math.min(2, 3)",
3550            ),
3551            (
3552                client_builtin("max", vec![float(2.5), float(3.5)], Type::Float),
3553                "Math.max(2.5, 3.5)",
3554            ),
3555            (
3556                client_builtin("abs", vec![int(-2)], Type::Int),
3557                "Math.abs(-2)",
3558            ),
3559            (
3560                client_builtin("round", vec![float(2.5)], Type::Int),
3561                "Math.round(2.5)",
3562            ),
3563            (
3564                client_builtin("floor", vec![float(2.5)], Type::Int),
3565                "Math.floor(2.5)",
3566            ),
3567            (
3568                client_builtin("ceil", vec![float(2.5)], Type::Int),
3569                "Math.ceil(2.5)",
3570            ),
3571            (client_builtin("toFloat", vec![int(2)], Type::Float), "(2)"),
3572            (
3573                client_builtin("toInt", vec![float(2.5)], Type::Int),
3574                "Math.trunc(2.5)",
3575            ),
3576            (
3577                client_builtin("toString", vec![int(2)], Type::String),
3578                "String(2)",
3579            ),
3580        ];
3581        for (expression, expected) in cases {
3582            assert_eq!(emit_expr(&expression), expected);
3583        }
3584
3585        let shadowing_user_function = SemanticExpr {
3586            kind: SemanticExprKind::FunctionCall {
3587                function: SemanticId::function("len"),
3588                name: "len".into(),
3589                arguments: vec![string()],
3590            },
3591            ty: Type::Int,
3592            span: Span::new(0, 1),
3593        };
3594        assert_eq!(emit_expr(&shadowing_user_function), "len(name.get())");
3595    }
3596
3597    fn empty_program() -> SemanticProgram {
3598        SemanticProgram {
3599            imports: vec![],
3600            functions: vec![],
3601            external_modules: vec![],
3602            contexts: vec![],
3603            types: vec![StructDefinition {
3604                id: SemanticId::global_type("User"),
3605                name: "User".into(),
3606                fields: vec![
3607                    StructFieldDefinition {
3608                        id: SemanticId::global_type_field("User", "name"),
3609                        name: "name".into(),
3610                        ty: Type::String,
3611                        span: Span::new(0, 0),
3612                    },
3613                    StructFieldDefinition {
3614                        id: SemanticId::global_type_field("User", "scores"),
3615                        name: "scores".into(),
3616                        ty: Type::Array(Box::new(Type::Int)),
3617                        span: Span::new(0, 0),
3618                    },
3619                ],
3620                span: Span::new(0, 0),
3621            }],
3622            distinct_types: vec![],
3623            resources: vec![],
3624            streams: vec![],
3625            agents: vec![],
3626            endpoints: vec![],
3627            tasks: vec![],
3628            queues: vec![],
3629            models: vec![],
3630            components: vec![],
3631        }
3632    }
3633
3634    #[test]
3635    fn optional_matches_emit_reactive_tagged_sources_without_changing_machine_sources() {
3636        let dependency = SemanticId::state("C", "page");
3637        let optional = SemanticExpr {
3638            kind: SemanticExprKind::Reference(dependency.clone()),
3639            ty: Type::Optional(Box::new(Type::Int)),
3640            span: Span::new(0, 0),
3641        };
3642        assert_eq!(
3643            emit_match_source(&optional, std::slice::from_ref(&dependency), "$noxOwner")
3644                .expect("optional match source emits"),
3645            "computed(() => (($noxOptional) => ($noxOptional == null ? { tag: \"None\" } : { tag: \"Some\", value: $noxOptional }))(page.get()), $noxOwner, [page])"
3646        );
3647        assert!(match_source_requires_computed(
3648            &optional,
3649            std::slice::from_ref(&dependency)
3650        ));
3651
3652        let machine = SemanticExpr {
3653            kind: SemanticExprKind::Reference(dependency.clone()),
3654            ty: Type::Named("PageState".into()),
3655            span: Span::new(0, 0),
3656        };
3657        assert_eq!(
3658            emit_match_source(&machine, std::slice::from_ref(&dependency), "$noxOwner")
3659                .expect("machine match source emits"),
3660            "page"
3661        );
3662        assert!(!match_source_requires_computed(&machine, &[dependency]));
3663    }
3664
3665    #[test]
3666    fn imported_results_are_validated_normalized_and_async_safe() {
3667        let mut script = external_validation_prelude(&empty_program());
3668        script.push_str(
3669            r#"
3670const user = __noxidValidateExternalResult("external-function:legacy.load", {kind:"named",name:"User"}, {name:"Ada",scores:[1,2],ignored:true});
3671if (user.name !== "Ada" || user.ignored !== undefined || !Object.isFrozen(user) || !Object.isFrozen(user.scores)) process.exit(2);
3672try { __noxidValidateExternalResult("external-function:legacy.bad", {kind:"int"}, "1"); process.exit(3); }
3673catch (error) { if (error.code !== "EXTERNAL_RESULT_VALIDATION_FAILED" || error.symbol !== "external-function:legacy.bad") process.exit(4); }
3674try { __noxidValidateExternalResult("external-function:legacy.async", {kind:"string"}, Promise.resolve("value")); process.exit(5); }
3675catch (error) { if (error.code !== "EXTERNAL_ASYNC_RESULT_UNSUPPORTED") process.exit(6); }
3676"#,
3677        );
3678        let output = Command::new("node")
3679            .args(["--input-type=module", "-e", &script])
3680            .output()
3681            .expect("node must execute generated validation");
3682        assert!(
3683            output.status.success(),
3684            "{}",
3685            String::from_utf8_lossy(&output.stderr)
3686        );
3687    }
3688
3689    #[test]
3690    fn client_compound_equality_executes_recursive_value_semantics() {
3691        fn row(id: i64) -> SemanticExpr {
3692            SemanticExpr {
3693                kind: SemanticExprKind::Struct {
3694                    definition: SemanticId::global_type("Row"),
3695                    fields: vec![StructFieldValue {
3696                        field: SemanticId::global_type_field("Row", "id"),
3697                        name: "id".into(),
3698                        value: SemanticExpr {
3699                            kind: SemanticExprKind::Int(id),
3700                            ty: Type::Int,
3701                            span: Span::new(0, 0),
3702                        },
3703                        span: Span::new(0, 0),
3704                    }],
3705                },
3706                ty: Type::Named("Row".into()),
3707                span: Span::new(0, 0),
3708            }
3709        }
3710        fn rows(values: &[i64]) -> SemanticExpr {
3711            SemanticExpr {
3712                kind: SemanticExprKind::Array(values.iter().copied().map(row).collect()),
3713                ty: Type::Array(Box::new(Type::Named("Row".into()))),
3714                span: Span::new(0, 0),
3715            }
3716        }
3717        let same = SemanticExpr {
3718            kind: SemanticExprKind::Binary {
3719                left: Box::new(rows(&[1, 2])),
3720                op: SemanticBinaryOp::Equal,
3721                right: Box::new(rows(&[1, 2])),
3722            },
3723            ty: Type::Boolean,
3724            span: Span::new(0, 0),
3725        };
3726        let different = SemanticExpr {
3727            kind: SemanticExprKind::Binary {
3728                left: Box::new(rows(&[1, 2])),
3729                op: SemanticBinaryOp::NotEqual,
3730                right: Box::new(rows(&[1, 3])),
3731            },
3732            ty: Type::Boolean,
3733            span: Span::new(0, 0),
3734        };
3735        let primitive = SemanticExpr {
3736            kind: SemanticExprKind::Binary {
3737                left: Box::new(SemanticExpr {
3738                    kind: SemanticExprKind::Int(1),
3739                    ty: Type::Int,
3740                    span: Span::new(0, 0),
3741                }),
3742                op: SemanticBinaryOp::Equal,
3743                right: Box::new(SemanticExpr {
3744                    kind: SemanticExprKind::Int(1),
3745                    ty: Type::Int,
3746                    span: Span::new(0, 0),
3747                }),
3748            },
3749            ty: Type::Boolean,
3750            span: Span::new(0, 0),
3751        };
3752        assert_eq!(emit_expr(&primitive), "(1 === 1)");
3753        let script = format!(
3754            "if (!({}) || !({})) process.exit(1);",
3755            emit_scenario_expression(&same),
3756            emit_scenario_expression(&different)
3757        );
3758        let output = Command::new("node")
3759            .args(["--input-type=module", "-e", &script])
3760            .output()
3761            .expect("node must execute generated compound equality");
3762        assert!(
3763            output.status.success(),
3764            "{}",
3765            String::from_utf8_lossy(&output.stderr)
3766        );
3767    }
3768}