use noxid_dom_ir::{DomAttribute, DomNode};
use noxid_ir::*;
use noxid_source::{html_escape, js_escape};
use std::collections::BTreeSet;
const LANGUAGE_VALUE_EQUALITY_FUNCTION: &str = r#"function $noxEqual($noxLeft, $noxRight) {
if ($noxLeft === $noxRight) return true;
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]));
if ($noxLeft === null || $noxRight === null || typeof $noxLeft !== "object" || typeof $noxRight !== "object") return false;
const $noxLeftKeys = Object.keys($noxLeft).sort();
const $noxRightKeys = Object.keys($noxRight).sort();
return $noxLeftKeys.length === $noxRightKeys.length && $noxLeftKeys.every(($noxKey, $noxIndex) => $noxKey === $noxRightKeys[$noxIndex] && $noxEqual($noxLeft[$noxKey], $noxRight[$noxKey]));
}
"#;
#[derive(Clone, Debug)]
pub struct GeneratedComponent {
pub javascript: String,
pub css: String,
/// Runtime exports referenced by this generated module. The CLI uses this
/// compiler-derived manifest to emit a feature-pruned runtime.
pub runtime_imports: BTreeSet<String>,
/// Independently loadable component chunks. Populated when a source file
/// declares more than one component.
pub modules: Vec<GeneratedModule>,
}
#[derive(Clone, Debug)]
pub struct GeneratedModule {
pub component: String,
pub javascript: String,
pub runtime_imports: BTreeSet<String>,
}
#[derive(Clone, Debug)]
struct SlotChildren {
template_name: String,
operations: Vec<Operation>,
}
#[derive(Clone, Debug)]
enum Operation {
Text {
id: SemanticId,
marker: usize,
expression: SemanticExpr,
dependencies: Vec<SemanticId>,
},
Attribute {
id: SemanticId,
marker: usize,
name: String,
expression: SemanticExpr,
dependencies: Vec<SemanticId>,
},
TwoWayBinding {
id: SemanticId,
marker: usize,
name: String,
target: SemanticId,
},
Event {
id: SemanticId,
marker: usize,
event: String,
action: SemanticId,
arguments: Option<Vec<SemanticExpr>>,
},
RoutePrefetch {
marker: usize,
trigger: PrefetchTrigger,
},
Attachment {
attachment: ElementAttachment,
marker: usize,
},
Component {
id: SemanticId,
marker: usize,
target: SemanticId,
props: Vec<PropArgument>,
handlers: Vec<ComponentEventHandler>,
prefetch: Option<PrefetchTrigger>,
children: Option<SlotChildren>,
},
Slot {
id: SemanticId,
marker: usize,
},
Conditional {
id: SemanticId,
marker: usize,
condition: SemanticExpr,
dependencies: Vec<SemanticId>,
transition: Option<ConditionalTransition>,
template_name: String,
operations: Vec<Operation>,
},
Match {
id: SemanticId,
marker: usize,
expression: SemanticExpr,
dependencies: Vec<SemanticId>,
cases: Vec<MatchOperationCase>,
},
Stream {
id: SemanticId,
marker: usize,
expression: SemanticExpr,
dependencies: Vec<SemanticId>,
cases: Vec<MatchOperationCase>,
},
For {
id: SemanticId,
marker: usize,
collection: SemanticExpr,
dependencies: Vec<SemanticId>,
binding: ForBinding,
key: Box<SemanticExpr>,
template_name: String,
operations: Vec<Operation>,
},
}
#[derive(Clone, Debug)]
struct MatchOperationCase {
variant: String,
binding: Option<MatchBinding>,
template_name: String,
operations: Vec<Operation>,
}
#[derive(Clone, Debug, Default)]
struct Template {
html: String,
operations: Vec<Operation>,
}
struct TemplateBuilder<'a> {
scope: &'a str,
component: &'a str,
marker: usize,
conditional: usize,
match_block: usize,
stream_block: usize,
for_block: usize,
slot_children: usize,
declarations: &'a mut Vec<(String, String)>,
}
pub fn generate(component: &ComponentDefinition) -> Result<GeneratedComponent, String> {
generate_program(&SemanticProgram {
imports: vec![],
functions: vec![],
external_modules: vec![],
contexts: vec![],
types: vec![],
distinct_types: vec![],
resources: vec![],
streams: vec![],
agents: vec![],
endpoints: vec![],
tasks: vec![],
queues: vec![],
models: vec![],
components: vec![component.clone()],
})
}
pub fn generate_program(program: &SemanticProgram) -> Result<GeneratedComponent, String> {
generate_program_with_resource_module(program, None)
}
pub fn generate_program_with_resource_module(
program: &SemanticProgram,
resource_module: Option<&str>,
) -> Result<GeneratedComponent, String> {
generate_program_with_modules(program, None, resource_module, None, None)
}
pub fn generate_program_with_modules(
program: &SemanticProgram,
validator_module: Option<&str>,
resource_module: Option<&str>,
stream_module: Option<&str>,
agent_module: Option<&str>,
) -> Result<GeneratedComponent, String> {
let mut generated = generate_program_inner(
program,
validator_module,
resource_module,
stream_module,
agent_module,
)?;
prepend_external_component_imports(&mut generated.javascript, program);
if program.components.len() > 1 {
generated.modules = program
.components
.iter()
.map(|component| {
let single = SemanticProgram {
imports: program.imports.clone(),
functions: program.functions.clone(),
external_modules: program.external_modules.clone(),
contexts: program.contexts.clone(),
types: program.types.clone(),
distinct_types: program.distinct_types.clone(),
resources: program.resources.clone(),
streams: program.streams.clone(),
agents: program.agents.clone(),
endpoints: vec![],
tasks: vec![],
queues: vec![],
models: vec![],
components: vec![component.clone()],
};
let mut output = generate_program_inner(
&single,
validator_module,
resource_module,
stream_module,
agent_module,
)?;
prepend_external_component_imports(&mut output.javascript, &single);
let dependencies = invoked_components(&component.view);
let prefetched = prefetched_components(&component.view);
if !dependencies.is_empty() {
// Each invocation is imported from its own chunk. Multiple
// imports are explicit so native ESM never evaluates an
// unrelated sibling component.
let prefix = dependencies
.iter()
.map(|name| {
component_import(
name,
supports_hydration(component),
prefetched.contains(name),
)
})
.collect::<String>();
output.javascript.insert_str(0, &prefix);
}
Ok(GeneratedModule {
component: component.name.clone(),
javascript: output.javascript,
runtime_imports: output.runtime_imports,
})
})
.collect::<Result<Vec<_>, String>>()?;
}
Ok(generated)
}
fn prepend_external_component_imports(javascript: &mut String, program: &SemanticProgram) {
let local = program
.components
.iter()
.map(|component| component.name.as_str())
.collect::<BTreeSet<_>>();
let imported = program
.imports
.iter()
.map(|import| import.name.as_str())
.collect::<BTreeSet<_>>();
let invoked = program
.components
.iter()
.flat_map(|component| invoked_components(&component.view))
.filter(|name| imported.contains(name.as_str()) && !local.contains(name.as_str()))
.collect::<BTreeSet<_>>();
let prefetched = program
.components
.iter()
.flat_map(|component| prefetched_components(&component.view))
.collect::<BTreeSet<_>>();
if invoked.is_empty() {
return;
}
let prefix = invoked
.iter()
.map(|name| {
component_import(
name,
program.components.iter().any(supports_hydration),
prefetched.contains(name),
)
})
.collect::<String>();
javascript.insert_str(0, &prefix);
}
fn supports_hydration(component: &ComponentDefinition) -> bool {
component.render.mode == ComponentRenderMode::Universal
}
fn component_import(name: &str, hydration: bool, prefetch: bool) -> String {
let hydrate = if hydration {
format!(", hydrate{name}")
} else {
String::new()
};
let prefetch = if prefetch {
format!(", __noxidPrefetch{name}")
} else {
String::new()
};
format!("import {{ mount{name}{hydrate}{prefetch} }} from \"./{name}.js\";\n")
}
fn generate_program_inner(
program: &SemanticProgram,
validator_module: Option<&str>,
resource_module: Option<&str>,
stream_module: Option<&str>,
agent_module: Option<&str>,
) -> Result<GeneratedComponent, String> {
let mut declarations = Vec::new();
let mut compiled = Vec::new();
for component in &program.components {
let scope = noxid_css_ir::scope_id(component.id.as_str());
let dom = noxid_dom_ir::lower(component);
let mut builder = TemplateBuilder {
scope: &scope,
component: &component.name,
marker: 0,
conditional: 0,
match_block: 0,
stream_block: 0,
for_block: 0,
slot_children: 0,
declarations: &mut declarations,
};
let template = build_template(&dom.nodes, &mut builder);
let template_name = format!("{}Template", component.name);
declarations.push((template_name.clone(), template.html));
compiled.push((component, template_name, template.operations));
}
let mut runtime_imports = BTreeSet::from([
"componentEmitter".to_string(),
"createOwner".to_string(),
"disposeOwner".to_string(),
]);
for (component, _, operations) in &compiled {
collect_component_runtime_imports(
component,
operations,
supports_hydration(component),
&mut runtime_imports,
);
}
if !program.functions.is_empty() {
runtime_imports.insert("toSource".into());
}
let mut js = format!(
"import {{ {} }} from \"./noxid-runtime.js\";\n",
runtime_imports
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
for module in &program.external_modules {
if !module.functions.is_empty() {
js.push_str(&format!(
"import {{ {} }} from \"{}\";\n",
module
.functions
.iter()
.map(|function| function.name.as_str())
.collect::<Vec<_>>()
.join(", "),
js_escape(&module.runtime_source)
));
}
}
if !program.external_modules.is_empty() {
js.push_str(&external_validation_prelude(program));
}
if program
.components
.iter()
.any(|component| noxid_ir::component_uses_date_helpers(component, &program.functions))
{
js.push_str(noxid_ir::DATE_HELPERS_JS);
js.push('\n');
}
emit_function_definitions(&mut js, &program.functions)?;
let mut state_validators = program
.components
.iter()
.flat_map(|component| {
component
.states
.iter()
.filter_map(|state| state_validator_id(program, component, &state.ty))
})
.collect::<BTreeSet<_>>();
state_validators.extend(
program
.components
.iter()
.filter_map(|component| component.presence.as_ref())
.map(|presence| presence.record_type.to_string()),
);
if !state_validators.is_empty() {
let module = validator_module.expect("named state requires a generated validator module");
js.push_str(&format!(
"import {{ typeValidators as __noxidTypeValidators }} from \"./{}\";\n",
js_escape(module)
));
}
let mut resource_names = program
.components
.iter()
.flat_map(|component| component.resources.iter())
.map(|resource| resource.resource_name.clone())
.collect::<std::collections::BTreeSet<_>>();
resource_names.extend(
program
.components
.iter()
.flat_map(|component| component.actions.iter())
.flat_map(|action| action.invalidation.resources.iter())
.map(|resource| resource_definition_name(program, resource)),
);
if !resource_names.is_empty() {
let module = resource_module.expect("resource acquisitions require a generated module");
js.push_str(&format!(
"import {{ {}, queryClient as __noxidQueryClient }} from \"./{}\";\n",
resource_names.into_iter().collect::<Vec<_>>().join(", "),
js_escape(module)
));
}
let stream_names = program
.components
.iter()
.flat_map(|component| component.streams.iter())
.map(|stream| stream.stream_name.clone())
.collect::<std::collections::BTreeSet<_>>();
if !stream_names.is_empty() {
let module = stream_module.expect("stream acquisitions require a generated module");
js.push_str(&format!(
"import {{ {} }} from \"./{}\";\n",
stream_names.into_iter().collect::<Vec<_>>().join(", "),
js_escape(module)
));
}
let agent_names = program
.components
.iter()
.flat_map(|component| component.agents.iter())
.map(|agent| agent.agent_name.clone())
.collect::<std::collections::BTreeSet<_>>();
if !agent_names.is_empty() {
let module = agent_module.expect("agent acquisitions require a generated module");
js.push_str(&format!(
"import {{ {} }} from \"./{}\";\n",
agent_names.into_iter().collect::<Vec<_>>().join(", "),
js_escape(module)
));
}
js.push('\n');
for (name, html) in declarations {
js.push_str(&format!(
"const {name} = document.createElement(\"template\");\n{name}.innerHTML = \"{}\";\n\n",
js_escape(&html)
));
}
for (component, template_name, operations) in compiled {
emit_component(&mut js, program, component, &template_name, &operations)?;
}
if js.contains("$noxEqual(") {
js.push('\n');
js.push_str(LANGUAGE_VALUE_EQUALITY_FUNCTION);
}
Ok(GeneratedComponent {
javascript: js,
css: String::new(),
runtime_imports,
modules: vec![],
})
}
fn state_validator_id(
program: &SemanticProgram,
component: &ComponentDefinition,
ty: &noxid_types::Type,
) -> Option<String> {
let noxid_types::Type::Named(name) = ty else {
return None;
};
component
.types
.iter()
.find(|definition| definition.name == *name)
.map(|definition| definition.id.to_string())
.or_else(|| {
component
.machines
.iter()
.find(|machine| machine.name == *name)
.map(|machine| machine.id.to_string())
})
.or_else(|| {
program
.types
.iter()
.find(|definition| definition.name == *name)
.map(|definition| definition.id.to_string())
})
}
fn resource_definition_name(program: &SemanticProgram, id: &SemanticId) -> String {
program
.resources
.iter()
.find(|resource| resource.id == *id)
.map(|resource| resource.name.clone())
.unwrap_or_else(|| {
panic!("validated action invalidation references missing resource `{id}`")
})
}
fn invoked_components(nodes: &[SemanticViewNode]) -> BTreeSet<String> {
fn visit(nodes: &[SemanticViewNode], output: &mut BTreeSet<String>) {
for node in nodes {
match node {
SemanticViewNode::ComponentInvocation {
component,
children,
..
} => {
if let Some(name) = component.as_str().strip_prefix("component:") {
output.insert(name.to_string());
}
visit(children, output);
}
SemanticViewNode::Slot { .. } => {}
SemanticViewNode::Element { children, .. }
| SemanticViewNode::Conditional { children, .. }
| SemanticViewNode::For { children, .. } => visit(children, output),
SemanticViewNode::Match { cases, .. } | SemanticViewNode::Stream { cases, .. } => {
for case in cases {
visit(&case.children, output);
}
}
SemanticViewNode::Text { .. } | SemanticViewNode::Binding { .. } => {}
}
}
}
let mut output = BTreeSet::new();
visit(nodes, &mut output);
output
}
fn prefetched_components(nodes: &[SemanticViewNode]) -> BTreeSet<String> {
fn visit(nodes: &[SemanticViewNode], output: &mut BTreeSet<String>) {
for node in nodes {
match node {
SemanticViewNode::ComponentInvocation {
component,
prefetch,
children,
..
} => {
if prefetch.is_some()
&& let Some(name) = component.as_str().strip_prefix("component:")
{
output.insert(name.to_string());
}
visit(children, output);
}
SemanticViewNode::Element { children, .. }
| SemanticViewNode::Conditional { children, .. }
| SemanticViewNode::For { children, .. } => visit(children, output),
SemanticViewNode::Match { cases, .. } | SemanticViewNode::Stream { cases, .. } => {
for case in cases {
visit(&case.children, output);
}
}
SemanticViewNode::Slot { .. }
| SemanticViewNode::Text { .. }
| SemanticViewNode::Binding { .. } => {}
}
}
}
let mut output = BTreeSet::new();
visit(nodes, &mut output);
output
}
fn collect_component_runtime_imports(
component: &ComponentDefinition,
operations: &[Operation],
hydration: bool,
imports: &mut BTreeSet<String>,
) {
if !component.middleware.is_empty() || !component.capabilities.is_empty() {
imports.insert("authorizeComponent".into());
}
if !component.props.is_empty() {
imports.insert("propSource".into());
}
if component
.states
.iter()
.any(|state| matches!(state.ty, noxid_types::Type::Array(_)))
{
imports.insert("collectionSignal".into());
}
if component
.states
.iter()
.any(|state| !matches!(state.ty, noxid_types::Type::Array(_)))
{
imports.insert("signal".into());
}
if !component.computed.is_empty() {
imports.insert("computed".into());
}
if !component.resources.is_empty() {
imports.insert("acquireResource".into());
}
if component
.resources
.iter()
.any(|resource| !resource.refresh.is_empty())
{
imports.insert("attachResourceRefreshTriggers".into());
}
if component
.actions
.iter()
.any(|action| !action.invalidation.resources.is_empty())
{
imports.insert("invalidateCompiledResources".into());
}
if component.presence.is_some() {
imports.insert("acquirePresence".into());
}
if component.streams.iter().any(|stream| {
component
.presence
.as_ref()
.is_none_or(|presence| presence.stream != stream.stream)
}) {
imports.insert("acquireStream".into());
}
if !component.agents.is_empty() {
imports.insert("acquireAgent".into());
}
if !component.context_providers.is_empty() {
imports.insert("provideContext".into());
if component
.context_providers
.iter()
.flat_map(|provider| &provider.fields)
.any(|field| source_requires_computed(&field.value, &field.dependencies))
{
imports.insert("computed".into());
}
}
if !component.context_uses.is_empty() {
imports.insert("useContext".into());
}
if !component.actions.is_empty() {
imports.insert("runAction".into());
}
if component.lifecycle.is_some() {
imports.insert("registerLifecycle".into());
}
if !component.behaviors.is_empty() {
imports.insert("applyBehavior".into());
}
if !component.regions.is_empty() {
imports.insert("validateComponentRegion".into());
}
if component.actions.iter().any(|action| {
!action.parameters.is_empty() || statements_declare_locals(&action.statements)
}) || component
.effects
.iter()
.any(|effect| statements_declare_locals(&effect.statements))
{
imports.insert("toSource".into());
}
if component
.effects
.iter()
.any(|item| matches!(item.kind, ReactiveEffectKind::Effect))
{
imports.insert("effect".into());
imports.insert("batch".into());
}
if component
.effects
.iter()
.any(|item| matches!(item.kind, ReactiveEffectKind::Watch { .. }))
{
imports.insert("watch".into());
imports.insert("batch".into());
imports.insert("toSource".into());
}
if component.actions.iter().any(|action| {
noxid_ir::flatten_statements(&action.statements)
.iter()
.any(|statement| matches!(statement, SemanticStatement::Transition { .. }))
}) || component.effects.iter().any(|effect| {
noxid_ir::flatten_statements(&effect.statements)
.iter()
.any(|statement| matches!(statement, SemanticStatement::Transition { .. }))
}) {
imports.insert("transitionMachine".into());
}
collect_operation_runtime_imports(operations, hydration, imports);
}
fn collect_operation_runtime_imports(
operations: &[Operation],
hydration: bool,
imports: &mut BTreeSet<String>,
) {
for operation in operations {
match operation {
Operation::Text { .. } => {
imports.insert("bindText".into());
if hydration {
imports.insert("hydrateText".into());
}
imports.insert("findMarker".into());
}
Operation::Attribute { .. } => {
imports.insert("bindAttribute".into());
}
Operation::TwoWayBinding { .. } => {
imports.insert("bindProperty".into());
}
Operation::Event { .. } => {
imports.insert("listen".into());
}
Operation::RoutePrefetch { .. } => {
imports.insert("attachCompiledPrefetch".into());
}
Operation::Attachment { .. } => {
imports.insert("installAnimateAttachment".into());
}
Operation::Slot { .. } => {
imports.insert("mountSlot".into());
if hydration {
imports.insert("hydrateSlot".into());
}
imports.insert("findMarker".into());
}
Operation::Component {
props,
prefetch,
children,
..
} => {
imports.insert("mountComponent".into());
if hydration {
imports.insert("hydrateComponent".into());
}
imports.insert("findMarker".into());
if prefetch.is_some() {
imports.insert("attachCompiledPrefetch".into());
}
if let Some(children) = children {
collect_operation_runtime_imports(&children.operations, hydration, imports);
}
if props
.iter()
.any(|prop| source_requires_computed(&prop.expression, &prop.dependencies))
{
imports.insert("computed".into());
}
}
Operation::Conditional {
condition,
dependencies,
transition,
operations,
..
} => {
imports.insert("mountIf".into());
if hydration {
imports.insert("hydrateIf".into());
}
imports.insert("findMarker".into());
if transition.is_some() {
imports.insert("createPresence".into());
}
if source_requires_computed(condition, dependencies) {
imports.insert("computed".into());
}
collect_operation_runtime_imports(operations, hydration, imports);
}
Operation::Match {
expression,
dependencies,
cases,
..
} => {
imports.insert("mountMatch".into());
if hydration {
imports.insert("hydrateMatch".into());
}
imports.insert("findMarker".into());
if match_source_requires_computed(expression, dependencies) {
imports.insert("computed".into());
}
if cases.iter().any(|case| case.binding.is_some()) {
imports.insert("toSource".into());
}
for case in cases {
collect_operation_runtime_imports(&case.operations, hydration, imports);
}
}
Operation::Stream {
expression,
dependencies,
cases,
..
} => {
imports.insert("mountStream".into());
if hydration {
imports.insert("hydrateStream".into());
}
imports.insert("findMarker".into());
if source_requires_computed(expression, dependencies) {
imports.insert("computed".into());
}
if cases.iter().any(|case| case.binding.is_some()) {
imports.insert("toSource".into());
}
for case in cases {
collect_operation_runtime_imports(&case.operations, hydration, imports);
}
}
Operation::For {
collection,
dependencies,
operations,
..
} => {
imports.insert("mountFor".into());
if hydration {
imports.insert("hydrateFor".into());
}
imports.insert("findMarker".into());
imports.insert("toSource".into());
if source_requires_computed(collection, dependencies) {
imports.insert("computed".into());
}
collect_operation_runtime_imports(operations, hydration, imports);
}
}
}
}
fn source_requires_computed(expression: &SemanticExpr, dependencies: &[SemanticId]) -> bool {
!dependencies.is_empty() && !matches!(expression.kind, SemanticExprKind::Reference(_))
}
fn match_source_requires_computed(expression: &SemanticExpr, dependencies: &[SemanticId]) -> bool {
!dependencies.is_empty()
&& (matches!(expression.ty, noxid_types::Type::Optional(_))
|| source_requires_computed(expression, dependencies))
}
fn build_template(nodes: &[DomNode], builder: &mut TemplateBuilder<'_>) -> Template {
let mut output = Template::default();
for node in nodes {
match node {
DomNode::StaticText(value) => output.html.push_str(&html_escape(value)),
DomNode::DynamicText {
id,
expression,
dependencies,
} => {
builder.marker += 1;
output
.html
.push_str(&format!("<!--noxid-text-{}-->", builder.marker));
output.operations.push(Operation::Text {
id: id.clone(),
marker: builder.marker,
expression: expression.clone(),
dependencies: dependencies.clone(),
});
}
DomNode::Element {
tag,
attributes,
attachments,
prefetch,
children,
} => {
output.html.push('<');
output.html.push_str(tag);
output
.html
.push_str(&format!(" data-noxid-scope=\"{}\"", builder.scope));
for attribute in attributes {
match attribute {
DomAttribute::Static { name, value } => output
.html
.push_str(&format!(" {name}=\"{}\"", html_escape(value))),
DomAttribute::Dynamic {
id,
name,
expression,
dependencies,
} => {
builder.marker += 1;
output
.html
.push_str(&format!(" data-noxid-bind-{}=\"\"", builder.marker));
output.operations.push(Operation::Attribute {
id: id.clone(),
marker: builder.marker,
name: name.clone(),
expression: expression.clone(),
dependencies: dependencies.clone(),
});
}
DomAttribute::Event {
id,
name,
action,
arguments,
} => {
builder.marker += 1;
output
.html
.push_str(&format!(" data-noxid-event-{}=\"\"", builder.marker));
output.operations.push(Operation::Event {
id: id.clone(),
marker: builder.marker,
event: name.clone(),
action: action.clone(),
arguments: arguments.clone(),
});
}
DomAttribute::TwoWayBinding { id, name, target } => {
builder.marker += 1;
output
.html
.push_str(&format!(" data-noxid-two-way-{}=\"\"", builder.marker));
output.operations.push(Operation::TwoWayBinding {
id: id.clone(),
marker: builder.marker,
name: name.clone(),
target: target.clone(),
});
}
}
}
for attachment in attachments {
builder.marker += 1;
output
.html
.push_str(&format!(" data-noxid-attach-{}=\"\"", builder.marker));
output.operations.push(Operation::Attachment {
attachment: attachment.clone(),
marker: builder.marker,
});
}
if let Some(trigger) = prefetch {
builder.marker += 1;
output
.html
.push_str(&format!(" data-noxid-prefetch-{}=\"\"", builder.marker));
output.operations.push(Operation::RoutePrefetch {
marker: builder.marker,
trigger: *trigger,
});
}
output.html.push('>');
let child = build_template(children, builder);
output.html.push_str(&child.html);
output.operations.extend(child.operations);
output.html.push_str("</");
output.html.push_str(tag);
output.html.push('>');
}
DomNode::Component {
id,
target,
props,
handlers,
prefetch,
children,
} => {
builder.marker += 1;
let component_marker = builder.marker;
let slot_children = if children.is_empty() {
None
} else {
builder.slot_children += 1;
let block_name = format!(
"{}SlotChildren{}Template",
builder.component, builder.slot_children
);
let child = build_template(children, builder);
builder.declarations.push((block_name.clone(), child.html));
Some(SlotChildren {
template_name: block_name,
operations: child.operations,
})
};
output
.html
.push_str(&format!("<!--noxid-component-{component_marker}-->"));
output.operations.push(Operation::Component {
id: id.clone(),
marker: component_marker,
target: target.clone(),
props: props.clone(),
handlers: handlers.clone(),
prefetch: *prefetch,
children: slot_children,
});
}
DomNode::Slot { id } => {
builder.marker += 1;
output
.html
.push_str(&format!("<!--noxid-slot-{}-->", builder.marker));
output.operations.push(Operation::Slot {
id: id.clone(),
marker: builder.marker,
});
}
DomNode::Conditional {
id,
condition,
dependencies,
transition,
children,
} => {
builder.marker += 1;
let conditional_marker = builder.marker;
builder.conditional += 1;
let block_name = format!("{}If{}Template", builder.component, builder.conditional);
let child = build_template(children, builder);
builder.declarations.push((block_name.clone(), child.html));
output
.html
.push_str(&format!("<!--noxid-if-{conditional_marker}-->"));
output.operations.push(Operation::Conditional {
id: id.clone(),
marker: conditional_marker,
condition: condition.clone(),
dependencies: dependencies.clone(),
transition: transition.clone(),
template_name: block_name,
operations: child.operations,
});
}
DomNode::Match {
id,
expression,
dependencies,
cases,
} => {
builder.marker += 1;
let match_marker = builder.marker;
builder.match_block += 1;
let match_ordinal = builder.match_block;
let mut compiled_cases = Vec::new();
for case in cases {
let variant = symbol_name(&case.variant).to_string();
let template_name =
format!("{}Match{match_ordinal}{variant}Template", builder.component);
let child = build_template(&case.children, builder);
builder
.declarations
.push((template_name.clone(), child.html));
compiled_cases.push(MatchOperationCase {
variant,
binding: case.binding.clone(),
template_name,
operations: child.operations,
});
}
output
.html
.push_str(&format!("<!--noxid-match-{match_marker}-->"));
output.operations.push(Operation::Match {
id: id.clone(),
marker: match_marker,
expression: expression.clone(),
dependencies: dependencies.clone(),
cases: compiled_cases,
});
}
DomNode::Stream {
id,
expression,
dependencies,
cases,
} => {
builder.marker += 1;
let stream_marker = builder.marker;
builder.stream_block += 1;
let stream_ordinal = builder.stream_block;
let mut compiled_cases = Vec::new();
for case in cases {
let variant = symbol_name(&case.variant).to_string();
let template_name = format!(
"{}Stream{stream_ordinal}{variant}Template",
builder.component
);
let child = build_template(&case.children, builder);
builder
.declarations
.push((template_name.clone(), child.html));
compiled_cases.push(MatchOperationCase {
variant,
binding: case.binding.clone(),
template_name,
operations: child.operations,
});
}
output
.html
.push_str(&format!("<!--noxid-stream-{stream_marker}-->"));
output.operations.push(Operation::Stream {
id: id.clone(),
marker: stream_marker,
expression: expression.clone(),
dependencies: dependencies.clone(),
cases: compiled_cases,
});
}
DomNode::For {
id,
binding,
collection,
dependencies,
key,
children,
} => {
builder.marker += 1;
let for_marker = builder.marker;
builder.for_block += 1;
let template_name =
format!("{}For{}Template", builder.component, builder.for_block);
let child = build_template(children, builder);
builder
.declarations
.push((template_name.clone(), child.html));
output
.html
.push_str(&format!("<!--noxid-for-{for_marker}-->"));
output.operations.push(Operation::For {
id: id.clone(),
marker: for_marker,
collection: collection.clone(),
dependencies: dependencies.clone(),
binding: binding.clone(),
key: key.clone(),
template_name,
operations: child.operations,
});
}
}
}
output
}
fn emit_component(
js: &mut String,
program: &SemanticProgram,
component: &ComponentDefinition,
template_name: &str,
operations: &[Operation],
) -> Result<(), String> {
let hydration = supports_hydration(component);
emit_hmr_action_factory(js, program, component)?;
let guarded = !component.middleware.is_empty() || !component.capabilities.is_empty();
let query_client = if component.resources.is_empty() {
""
} else {
"\n if (!resourceOptions.queryClient) resourceOptions = { ...resourceOptions, queryClient: __noxidQueryClient };"
};
let resource_options_binding = if component.resources.is_empty() {
"const"
} else {
"let"
};
let hydrating_parameter = if hydration {
", $noxHydrating = false"
} else {
""
};
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)));
if component
.actions
.iter()
.any(|action| action.execution.is_remote())
{
js.push_str(" const $noxExecuteBoundary = $noxResourceOptions.executeBoundary;\n");
}
if guarded {
let middleware = component
.middleware
.iter()
.map(|usage| format!("\"{}\"", js_escape(&usage.name)))
.collect::<Vec<_>>()
.join(", ");
let capabilities = component
.capabilities
.iter()
.map(|capability| format!("\"{}\"", js_escape(capability.id.as_str())))
.collect::<Vec<_>>()
.join(", ");
js.push_str(&format!(
" try {{ authorizeComponent($noxResourceOptions, \"{}\", [{}], [{}]); }} catch ($noxError) {{ disposeOwner($noxOwner); throw $noxError; }}\n",
js_escape(component.id.as_str()),
capabilities,
middleware,
));
}
for prop in &component.props {
js.push_str(&format!(
" const {} = propSource($noxProps, \"{}\", \"{}\");\n",
prop.name,
js_escape(&prop.name),
js_escape(&component.name)
));
}
for context_use in &component.context_uses {
js.push_str(&format!(
" const $noxContext_{} = useContext($noxOwner, \"{}\", \"{}\");\n",
context_use.name,
js_escape(context_use.context.as_str()),
js_escape(&component.name)
));
for field in &context_use.fields {
js.push_str(&format!(
" const {} = $noxContext_{}[\"{}\"];\n",
field.name,
context_use.name,
js_escape(&field.name)
));
}
}
for state in &component.states {
let constructor = if matches!(state.ty, noxid_types::Type::Array(_)) {
"collectionSignal"
} else {
"signal"
};
let validator = state_validator_id(program, component, &state.ty)
.map(|id| {
format!(
", validatorId: \"{}\", validator: __noxidTypeValidators[\"{}\"]",
js_escape(&id),
js_escape(&id)
)
})
.unwrap_or_default();
js.push_str(&format!(
" const $noxInitial_{} = {};\n const {} = {constructor}(globalThis.__NOXID_HMR__ ? globalThis.__NOXID_HMR__.initialState(\"{}\", $noxInitial_{}) : $noxInitial_{}, {{ semanticId: \"{}\", type: \"{}\", owner: $noxOwner{validator} }});\n",
state.name,
emit_expr(&state.initializer)?,
state.name,
js_escape(state.id.as_str()),
state.name,
state.name,
js_escape(state.id.as_str()),
js_escape(&state.ty.to_string())
));
}
for computed in &component.computed {
js.push_str(&format!(
" const {} = computed(() => {}, $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
computed.name,
emit_expr(&computed.expression)?,
emit_sources(&computed.dependencies),
js_escape(computed.id.as_str())
));
}
for resource in &component.resources {
let arguments = resource
.arguments
.iter()
.map(|argument| {
Ok(format!(
"[\"{}\"]: {}",
js_escape(&argument.name),
emit_expr(&argument.value)?
))
})
.collect::<Result<Vec<_>, String>>()?
.join(", ");
let mut dependencies = resource
.arguments
.iter()
.flat_map(|argument| argument.dependencies.clone())
.collect::<Vec<_>>();
dependencies.sort();
dependencies.dedup();
js.push_str(&format!(
" const $noxResourceHandle_{} = acquireResource({}, () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n const {} = $noxResourceHandle_{}.state;\n",
resource.name,
resource.resource_name,
arguments,
emit_sources(&dependencies),
js_escape(resource.id.as_str()),
resource.name,
resource.name,
));
if !resource.refresh.is_empty() {
js.push_str(&format!(
" attachResourceRefreshTriggers($noxResourceHandle_{}, Object.freeze([{}]), $noxOwner, $noxResourceOptions);\n",
resource.name,
emit_resource_refresh_triggers(&resource.refresh),
));
}
}
for stream in &component.streams {
let arguments = stream
.arguments
.iter()
.map(|argument| {
Ok(format!(
"[\"{}\"]: {}",
js_escape(&argument.name),
emit_expr(&argument.value)?
))
})
.collect::<Result<Vec<_>, String>>()?
.join(", ");
let mut dependencies = stream
.arguments
.iter()
.flat_map(|argument| argument.dependencies.clone())
.collect::<Vec<_>>();
dependencies.sort();
dependencies.dedup();
if let Some(presence) = component
.presence
.as_ref()
.filter(|presence| presence.stream == stream.stream)
{
js.push_str(&format!(
" const $noxStreamHandle_presence = acquirePresence({}, Object.freeze({{ id: \"{}\", stream: \"{}\", recordType: \"{}\", memberType: \"{}\", snapshotType: \"{}\", ttlMilliseconds: {}, heartbeatMilliseconds: {}, validateRecord: __noxidTypeValidators[\"{}\"] }}), () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n const presence = $noxStreamHandle_presence.events;\n",
stream.stream_name,
js_escape(presence.id.as_str()),
js_escape(presence.stream.as_str()),
js_escape(presence.record_type.as_str()),
js_escape(presence.member_type.as_str()),
js_escape(presence.snapshot_type.as_str()),
presence.ttl_milliseconds,
presence.heartbeat_milliseconds,
js_escape(presence.record_type.as_str()),
arguments,
emit_sources(&dependencies),
js_escape(stream.id.as_str()),
));
continue;
}
js.push_str(&format!(
" const $noxStreamHandle_{} = acquireStream({}, () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n const {} = $noxStreamHandle_{}.events;\n",
stream.name,
stream.stream_name,
arguments,
emit_sources(&dependencies),
js_escape(stream.id.as_str()),
stream.name,
stream.name,
));
}
for agent in &component.agents {
js.push_str(&format!(
" const $noxAgentHandle_{} = acquireAgent({}, () => ({}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n const {} = $noxAgentHandle_{}.events;\n",
agent.name,
agent.agent_name,
emit_expr(&agent.input)?,
emit_sources(&agent.dependencies),
js_escape(agent.id.as_str()),
agent.name,
agent.name,
));
}
for provider in &component.context_providers {
let fields = provider
.fields
.iter()
.map(|field| {
Ok(format!(
"\"{}\": {}",
js_escape(&field.name),
emit_source(&field.value, &field.dependencies, "$noxOwner")?
))
})
.collect::<Result<Vec<_>, String>>()?
.join(", ");
js.push_str(&format!(
" provideContext($noxOwner, \"{}\", {{ {fields} }});\n",
js_escape(provider.context.as_str())
));
}
let scope_names = hmr_scope_names(component);
let resource_options_scope = if component
.actions
.iter()
.any(|action| !action.invalidation.resources.is_empty())
{
", $noxResourceOptions"
} else {
""
};
js.push_str(&format!(
" const $noxActionScope = {{ $noxOwner, $noxEmit{resource_options_scope}{}{} }};\n let $noxActions = __noxidCreate{}Actions($noxActionScope);\n",
scope_names
.iter()
.map(|name| format!(", {name}"))
.collect::<String>(),
if component.actions.iter().any(|action| action.execution.is_remote()) {
", $noxExecuteBoundary"
} else {
""
},
component.name,
));
for action in &component.actions {
js.push_str(&format!(
" function {}(...$noxInputs) {{ return $noxActions.{}(...$noxInputs); }}\n",
action.name, action.name,
));
}
for reactive in &component.effects {
match &reactive.kind {
ReactiveEffectKind::Effect => {
js.push_str(" effect(() => batch(() => {\n");
emit_statements(js, &reactive.statements, 2)?;
js.push_str(&format!(
" }}), $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
emit_sources(&reactive.dependencies),
js_escape(reactive.id.as_str())
));
}
ReactiveEffectKind::Watch {
expression,
current,
previous,
} => {
js.push_str(&format!(
" watch(() => {}, ($noxWatchCurrentInput, $noxWatchPreviousInput) => {{\n",
emit_expr(expression)?,
));
for (parameter, input) in [
(current, "$noxWatchCurrentInput"),
(previous, "$noxWatchPreviousInput"),
] {
js.push_str(&format!(
" const {} = toSource({input});\n",
parameter.name
));
}
js.push_str(" return batch(() => {\n");
emit_statements(js, &reactive.statements, 3)?;
js.push_str(&format!(
" }});\n }}, $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
emit_sources(&reactive.dependencies),
js_escape(reactive.id.as_str())
));
}
}
}
if hydration {
js.push_str(" if ($noxHydrating) {\n");
emit_hydration_operations(js, operations, "$noxRoot", "$noxOwner", 2)?;
js.push_str(" } else {\n");
js.push_str(&format!(
" const $noxFragment = {template_name}.content.cloneNode(true);\n"
));
emit_operations(js, operations, "$noxFragment", "$noxOwner", 2)?;
js.push_str(" $noxRoot.replaceChildren($noxFragment);\n }\n");
} else {
js.push_str(&format!(
" const $noxFragment = {template_name}.content.cloneNode(true);\n"
));
emit_operations(js, operations, "$noxFragment", "$noxOwner", 1)?;
js.push_str(" $noxRoot.replaceChildren($noxFragment);\n");
}
for behavior in &component.behaviors {
let legal = behavior
.legal_elements
.iter()
.map(|value| format!("\"{}\"", js_escape(value)))
.collect::<Vec<_>>()
.join(", ");
let keyboard = behavior
.keyboard
.iter()
.map(|value| format!("\"{}\"", js_escape(value)))
.collect::<Vec<_>>()
.join(", ");
let effects = behavior
.effects
.iter()
.map(|value| format!("\"{}\"", js_escape(value)))
.collect::<Vec<_>>()
.join(", ");
let events = behavior.events.iter().map(|event| {
if event.event == "keydown" && !behavior.keyboard.is_empty() {
format!("\"keydown\": ($noxEvent) => {{ if ([{keyboard}].includes($noxEvent.key)) return $noxActions.{}(); }}", event.action_name)
} else {
format!("\"{}\": () => $noxActions.{}()", js_escape(&event.event), event.action_name)
}
}).collect::<Vec<_>>().join(", ");
js.push_str(&format!(
" 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",
js_escape(&behavior.name),
js_escape(&behavior.ssr),
js_escape(behavior.id.as_str()),
));
}
for region in &component.regions {
let role = region
.semantic_role
.as_ref()
.map(|value| format!("\"{}\"", js_escape(value)))
.unwrap_or_else(|| "null".into());
js.push_str(&format!(
" validateComponentRegion([ ...$noxRoot.querySelectorAll('[region=\"{}\"]')], Object.freeze({{ cardinality: \"{}\", contentType: \"{}\", semanticRole: {role} }}), $noxOwner, Object.freeze({{ semanticId: \"{}\" }}));\n",
js_escape(®ion.name),
js_escape(®ion.cardinality),
js_escape(®ion.content_type.to_string()),
js_escape(region.id.as_str()),
));
}
if let Some(lifecycle) = &component.lifecycle {
let mount = lifecycle
.mount
.as_ref()
.map(|id| symbol_name(id).to_string())
.unwrap_or_else(|| "null".into());
let cleanup = lifecycle
.cleanup
.as_ref()
.map(|id| symbol_name(id).to_string())
.unwrap_or_else(|| "null".into());
js.push_str(&format!(
" try {{ registerLifecycle($noxOwner, \"{}\", {mount}, {cleanup}); }} catch ($noxError) {{ disposeOwner($noxOwner); $noxRoot.replaceChildren(); throw $noxError; }}\n",
js_escape(lifecycle.id.as_str())
));
}
let state_handles = component
.states
.iter()
.map(|state| format!("\"{}\": {}", js_escape(state.id.as_str()), state.name))
.collect::<Vec<_>>()
.join(", ");
js.push_str(&format!(
" globalThis.__NOXID_HMR__?.registerInstance({{ component: \"{}\", owner: $noxOwner, state: {{ {state_handles} }}, patchActions($noxNextModule) {{ if (typeof $noxNextModule.__noxidCreate{}Actions === \"function\") $noxActions = $noxNextModule.__noxidCreate{}Actions($noxActionScope); }} }});\n",
js_escape(&component.name),
component.name,
component.name,
));
if component.resources.is_empty() && component.streams.is_empty() && component.agents.is_empty()
{
js.push_str(" return { owner: $noxOwner, dispose() { disposeOwner($noxOwner); $noxRoot.replaceChildren(); } };\n}\n\n");
} else {
let resource_handles = component
.resources
.iter()
.map(|resource| format!("{}: $noxResourceHandle_{}", resource.name, resource.name))
.collect::<Vec<_>>()
.join(", ");
let stream_handles = component
.streams
.iter()
.map(|stream| format!("{}: $noxStreamHandle_{}", stream.name, stream.name))
.collect::<Vec<_>>()
.join(", ");
let agent_handles = component
.agents
.iter()
.map(|agent| format!("{}: $noxAgentHandle_{}", agent.name, agent.name))
.collect::<Vec<_>>()
.join(", ");
js.push_str(&format!(" return {{ owner: $noxOwner, resources: {{ {resource_handles} }}, streams: {{ {stream_handles} }}, agents: {{ {agent_handles} }}, dispose() {{ disposeOwner($noxOwner); $noxRoot.replaceChildren(); }} }};\n}}\n\n"));
}
if !component.resources.is_empty() {
emit_component_prefetch(js, component)?;
}
if hydration {
js.push_str(&format!(
"export function hydrate{}($noxRoot, $noxProps = {{}}, $noxEventHandlers = {{}}, $noxParentOwner = null, $noxRuntimeOptions = null) {{\n return mount{}($noxRoot, $noxProps, $noxEventHandlers, $noxParentOwner, $noxRuntimeOptions, true);\n}}\n\n",
component.name,
component.name,
));
}
Ok(())
}
fn emit_component_prefetch(js: &mut String, component: &ComponentDefinition) -> Result<(), String> {
let prop_ids = component
.props
.iter()
.map(|prop| prop.id.clone())
.collect::<BTreeSet<_>>();
let available = component.resources.iter().all(|resource| {
resource
.arguments
.iter()
.flat_map(|argument| &argument.dependencies)
.all(|dependency| prop_ids.contains(dependency))
});
js.push_str(&format!(
"export async function __noxidPrefetch{}($noxProps = {{}}, $noxRuntimeOptions = null) {{\n",
component.name
));
if !available {
js.push_str(&format!(
" 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",
js_escape(&component.name)
));
return Ok(());
}
js.push_str(&format!(
" const $noxOwner = createOwner(null, {{ semanticId: \"{}\", label: \"component-prefetch\" }});\n let $noxResourceOptions = $noxRuntimeOptions ?? {{}};\n if (!$noxResourceOptions.queryClient) $noxResourceOptions = {{ ...$noxResourceOptions, queryClient: __noxidQueryClient }};\n",
js_escape(component.id.as_str())
));
for prop in &component.props {
js.push_str(&format!(
" const {} = propSource($noxProps, \"{}\", \"{}\");\n",
prop.name,
js_escape(&prop.name),
js_escape(&component.name)
));
}
let requests = component
.resources
.iter()
.map(|resource| {
let arguments = resource
.arguments
.iter()
.map(|argument| {
Ok(format!(
"[\"{}\"]: {}",
js_escape(&argument.name),
emit_expr(&argument.value)?
))
})
.collect::<Result<Vec<_>, String>>()?
.join(", ");
Ok(format!(
"{}.prefetch({{ {arguments} }}, {{ ...$noxResourceOptions, owner: $noxOwner }})",
resource.resource_name
))
})
.collect::<Result<Vec<_>, String>>()?
.join(", ");
js.push_str(&format!(
" try {{\n await Promise.all([{requests}]);\n }} finally {{\n disposeOwner($noxOwner);\n }}\n}}\n\n"
));
Ok(())
}
fn emit_hmr_action_factory(
js: &mut String,
program: &SemanticProgram,
component: &ComponentDefinition,
) -> Result<(), String> {
let scope_names = hmr_scope_names(component);
let boundary_scope = if component
.actions
.iter()
.any(|action| action.execution.is_remote())
{
"\n const $noxExecuteBoundary = $noxScope.$noxExecuteBoundary ?? $noxScope.executeBoundary;"
} else {
""
};
let resource_options_scope = if component
.actions
.iter()
.any(|action| !action.invalidation.resources.is_empty())
{
"\n const $noxResourceOptions = $noxScope.$noxResourceOptions ?? $noxScope.resourceOptions ?? {};"
} else {
""
};
js.push_str(&format!(
"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",
component.name,
scope_names.join(", "),
));
for action in &component.actions {
let inputs = action
.parameters
.iter()
.map(|parameter| format!("$noxInput_{}", parameter.name))
.collect::<Vec<_>>()
.join(", ");
let remote_await = action
.statements
.iter()
.position(|statement| matches!(statement, SemanticStatement::RemoteAwait { .. }));
let async_keyword = if remote_await.is_some() { "async " } else { "" };
js.push_str(&format!(
" {async_keyword}function {}({inputs}) {{\n",
action.name
));
if action.execution.is_remote() {
let arguments = action
.parameters
.iter()
.map(|parameter| (parameter, format!("$noxInput_{}", parameter.name)))
.collect::<Vec<_>>();
let descriptor = emit_remote_action_descriptor(component, action, &arguments);
let invalidation = emit_action_invalidation(program, action);
js.push_str(&format!(
" 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",
js_escape(action.id.as_str()),
action.execution.as_str(),
js_escape(&action.name),
js_escape(action.id.as_str()),
action.execution.as_str(),
));
} else {
for parameter in &action.parameters {
js.push_str(&format!(
" const {} = toSource($noxInput_{});\n",
parameter.name, parameter.name
));
}
if let Some(remote_await) = remote_await {
emit_async_action(js, program, component, action, remote_await)?;
} else {
js.push_str(&format!(
" return runAction(\"{}\", $noxOwner, () => {{\n",
js_escape(action.id.as_str())
));
emit_statements(js, &action.statements, 4)?;
js.push_str(" });\n }\n");
}
}
}
js.push_str(&format!(
" return {{ {} }};\n}}\n\n",
component
.actions
.iter()
.map(|action| action.name.as_str())
.collect::<Vec<_>>()
.join(", ")
));
Ok(())
}
fn emit_async_action(
js: &mut String,
program: &SemanticProgram,
component: &ComponentDefinition,
action: &Action,
remote_await_index: usize,
) -> Result<(), String> {
let SemanticStatement::RemoteAwait {
action: remote_action_id,
arguments,
ok_arm,
err_arm,
..
} = &action.statements[remote_await_index]
else {
unreachable!("remote await index must identify a RemoteAwait statement")
};
let remote_action = component
.actions
.iter()
.find(|candidate| candidate.id == *remote_action_id)
.unwrap_or_else(|| {
panic!(
"remote await references missing sibling action `{}`",
remote_action_id
)
});
let descriptor_arguments = arguments
.iter()
.map(|argument| {
let parameter = remote_action
.parameters
.iter()
.find(|parameter| parameter.id == argument.parameter)
.unwrap_or_else(|| {
panic!(
"remote await argument `{}` references missing parameter `{}`",
argument.name, argument.parameter
)
});
Ok((parameter, emit_expr(&argument.value)?))
})
.collect::<Result<Vec<_>, String>>()?;
let descriptor = emit_remote_action_descriptor(component, remote_action, &descriptor_arguments);
let remote_invalidation = emit_action_invalidation(program, remote_action);
let action_id = js_escape(action.id.as_str());
let before_await = &action.statements[..remote_await_index];
// Locals initialized before the boundary may be read by its named
// arguments. Declare them in the async action's lexical scope, but keep
// their initialization inside the first runAction transaction so the
// transaction still closes before the Promise is awaited.
for statement in before_await {
if let SemanticStatement::Local { name, .. } = statement {
js.push_str(&format!(" let {name};\n"));
}
}
js.push_str(&format!(
" runAction(\"{action_id}\", $noxOwner, () => {{\n"
));
emit_pre_await_statements(js, before_await, 4)?;
js.push_str(" });\n");
js.push_str(" const $noxRemoteOutcome = await (async () => {\n");
js.push_str(&format!(
" 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",
remote_action.execution.as_str(),
js_escape(&remote_action.name),
js_escape(remote_action.id.as_str()),
remote_action.execution.as_str(),
));
js.push_str(" })();\n");
js.push_str(&format!(
" return runAction(\"{action_id}\", $noxOwner, () => {{\n"
));
emit_remote_await_arm(js, ok_arm, 4)?;
emit_remote_await_arm(js, err_arm, 4)?;
emit_statements(js, &action.statements[remote_await_index + 1..], 4)?;
js.push_str(" });\n }\n");
Ok(())
}
fn emit_pre_await_statements(
js: &mut String,
statements: &[SemanticStatement],
depth: usize,
) -> Result<(), String> {
let indent = " ".repeat(depth);
for statement in statements {
if let SemanticStatement::Local { name, value, .. } = statement {
js.push_str(&format!(
"{indent}{name} = toSource({});\n",
emit_expr(value)?
));
} else {
emit_statements(js, std::slice::from_ref(statement), depth)?;
}
}
Ok(())
}
fn emit_remote_await_arm(
js: &mut String,
arm: &RemoteAwaitArm,
depth: usize,
) -> Result<(), String> {
let indent = " ".repeat(depth);
let branch = if arm.name == "Ok" { "if" } else { "else if" };
js.push_str(&format!(
"{indent}{branch} ($noxRemoteOutcome.tag === \"{}\") {{\n",
js_escape(&arm.name)
));
if let Some(binding) = &arm.binding {
js.push_str(&format!(
"{indent} const {} = toSource($noxRemoteOutcome.value);\n",
binding.name
));
}
emit_statements(js, &arm.statements, depth + 1)?;
js.push_str(&format!("{indent}}}\n"));
Ok(())
}
fn emit_remote_action_descriptor(
component: &ComponentDefinition,
action: &Action,
arguments: &[(&ActionParameter, String)],
) -> String {
let arguments = arguments
.iter()
.map(|(parameter, value)| {
let type_id = parameter
.type_id
.as_ref()
.map(|id| format!("\"{}\"", js_escape(id.as_str())))
.unwrap_or_else(|| "null".into());
format!(
"{{ name: \"{}\", type: \"{}\", typeId: {type_id}, value: {value} }}",
js_escape(¶meter.name),
parameter.ty,
)
})
.collect::<Vec<_>>()
.join(", ");
let result_type_id = action
.result
.type_id
.as_ref()
.map(|id| format!("\"{}\"", js_escape(id.as_str())))
.unwrap_or_else(|| "null".into());
let capabilities = action
.capabilities
.iter()
.map(|capability| format!("\"{}\"", js_escape(&capability.name)))
.collect::<Vec<_>>()
.join(", ");
format!(
"Object.freeze({{ id: \"{}\", component: \"{}\", action: \"{}\", execution: \"{}\", arguments: Object.freeze([{arguments}]), result: Object.freeze({{ id: \"{}\", type: \"{}\", typeId: {result_type_id} }}), capabilities: Object.freeze([{capabilities}]) }})",
js_escape(action.id.as_str()),
js_escape(&component.name),
js_escape(&action.name),
action.execution.as_str(),
js_escape(action.result.id.as_str()),
action.result.ty,
)
}
fn emit_action_invalidation(program: &SemanticProgram, action: &Action) -> String {
if action.invalidation.resources.is_empty() {
return String::new();
}
let definitions = action
.invalidation
.resources
.iter()
.map(|resource| resource_definition_name(program, resource))
.collect::<Vec<_>>()
.join(", ");
format!(
" invalidateCompiledResources($noxResourceOptions.queryClient ?? __noxidQueryClient, [{definitions}], $noxResourceOptions, \"{}\");",
js_escape(action.id.as_str())
)
}
fn hmr_scope_names(component: &ComponentDefinition) -> Vec<String> {
let mut names = std::collections::BTreeSet::new();
names.extend(component.props.iter().map(|value| value.name.clone()));
names.extend(
component
.context_uses
.iter()
.flat_map(|context| context.fields.iter().map(|field| field.name.clone())),
);
names.extend(component.states.iter().map(|value| value.name.clone()));
names.extend(component.computed.iter().map(|value| value.name.clone()));
names.extend(component.resources.iter().map(|value| value.name.clone()));
names.extend(component.streams.iter().map(|value| value.name.clone()));
names.extend(component.agents.iter().map(|value| value.name.clone()));
names.into_iter().collect()
}
fn emit_statements(
js: &mut String,
statements: &[SemanticStatement],
depth: usize,
) -> Result<(), String> {
let indent = " ".repeat(depth);
for statement in statements {
match statement {
SemanticStatement::Return { value, .. } => {
js.push_str(&format!("{indent}return {};\n", emit_expr(value)?,))
}
SemanticStatement::Assignment { target, value, .. } => js.push_str(&format!(
"{indent}{}.set({});\n",
symbol_name(target),
emit_expr(value)?
)),
SemanticStatement::Transition {
target,
value,
machine,
event,
allowed,
..
} => {
let allowed = allowed
.iter()
.map(|(from, to)| format!("[\"{}\", \"{}\"]", js_escape(from), js_escape(to)))
.collect::<Vec<_>>()
.join(", ");
js.push_str(&format!(
"{indent}transitionMachine({}, {}, \"{}\", [{}], \"{}\");\n",
symbol_name(target),
emit_expr(value)?,
js_escape(event),
allowed,
js_escape(symbol_name(machine))
));
}
SemanticStatement::CollectionMutation {
target,
operation,
arguments,
..
} => js.push_str(&format!(
"{indent}{}.{}({});\n",
symbol_name(target),
operation.as_str(),
arguments
.iter()
.map(emit_expr)
.collect::<Result<Vec<_>, String>>()?
.join(", ")
)),
SemanticStatement::Emit { name, payload, .. } => js.push_str(&format!(
"{indent}$noxEmit(\"{}\", {});\n",
js_escape(name),
emit_expr(payload)?
)),
SemanticStatement::FieldAssignment {
target,
path,
value,
..
} => {
let base = format!("{}.get()", symbol_name(target));
js.push_str(&format!(
"{indent}{}.set({});\n",
symbol_name(target),
field_assignment_value(&base, path, &emit_expr(value)?)
));
}
// Locals are wrapped with toSource like action parameters, so
// references read them through the same .get() convention.
SemanticStatement::Local { name, value, .. } => js.push_str(&format!(
"{indent}let {} = toSource({});\n",
name,
emit_expr(value)?
)),
SemanticStatement::LocalAssignment { name, value, .. } => js.push_str(&format!(
"{indent}{} = toSource({});\n",
name,
emit_expr(value)?
)),
SemanticStatement::RemoteAwait { .. } => {
panic!("RemoteAwait must be lowered by the async client-action emitter")
}
// ADR 0137 rule 3: client code never observes a principal. The
// value exists only inside the generated server boundary, so
// reaching here means a semantic guard was lost — fail the build
// rather than emit a read of a `context` the browser has no way
// to hold.
SemanticStatement::PrincipalMatch { .. } => {
return Err(noxid_ir::emitter_rejection(
"PRINCIPAL_CONSTRUCTION_RESERVED",
"`#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",
));
}
SemanticStatement::If {
condition,
then_statements,
else_statements,
..
} => {
js.push_str(&format!("{indent}if ({}) {{\n", emit_expr(condition)?));
emit_statements(js, then_statements, depth + 1)?;
if else_statements.is_empty() {
js.push_str(&format!("{indent}}}\n"));
} else {
js.push_str(&format!("{indent}}} else {{\n"));
emit_statements(js, else_statements, depth + 1)?;
js.push_str(&format!("{indent}}}\n"));
}
}
SemanticStatement::ActionCall {
name, arguments, ..
} => js.push_str(&format!(
"{indent}{}({});\n",
name,
arguments
.iter()
.map(emit_expr)
.collect::<Result<Vec<_>, String>>()?
.join(", ")
)),
}
}
Ok(())
}
fn statements_declare_locals(statements: &[SemanticStatement]) -> bool {
statements.iter().any(|statement| match statement {
SemanticStatement::Local { .. } => true,
SemanticStatement::RemoteAwait {
ok_arm, err_arm, ..
} => {
ok_arm.binding.is_some()
|| err_arm.binding.is_some()
|| statements_declare_locals(&ok_arm.statements)
|| statements_declare_locals(&err_arm.statements)
}
SemanticStatement::If {
then_statements,
else_statements,
..
} => {
statements_declare_locals(then_statements) || statements_declare_locals(else_statements)
}
SemanticStatement::PrincipalMatch { arms, .. } => arms
.iter()
.any(|arm| arm.binding.is_some() || statements_declare_locals(&arm.statements)),
SemanticStatement::Return { .. }
| SemanticStatement::Assignment { .. }
| SemanticStatement::FieldAssignment { .. }
| SemanticStatement::LocalAssignment { .. }
| SemanticStatement::ActionCall { .. }
| SemanticStatement::Transition { .. }
| SemanticStatement::CollectionMutation { .. }
| SemanticStatement::Emit { .. } => false,
})
}
fn field_assignment_value(
base: &str,
path: &[noxid_ir::FieldPathSegment],
value_js: &str,
) -> String {
match path.split_first() {
None => value_js.to_string(),
Some((segment, rest)) => {
let inner_base = format!("{base}[\"{}\"]", js_escape(&segment.name));
format!(
"{{ ...{base}, \"{}\": {} }}",
js_escape(&segment.name),
field_assignment_value(&inner_base, rest, value_js)
)
}
}
}
fn emit_operations(
js: &mut String,
operations: &[Operation],
fragment: &str,
owner: &str,
depth: usize,
) -> Result<(), String> {
let indent = " ".repeat(depth);
for operation in operations {
match operation {
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()))),
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()))),
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()))),
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()))),
Operation::RoutePrefetch { marker, trigger } => {
let trigger = trigger.as_str();
js.push_str(&format!("{indent}const $noxPrefetchTarget{marker} = {fragment}.querySelector(\"[data-noxid-prefetch-{marker}]\");\n"));
js.push_str(&format!("{indent}attachCompiledPrefetch($noxPrefetchTarget{marker}, \"{trigger}\", () => $noxResourceOptions.prefetchRoute($noxPrefetchTarget{marker}.getAttribute(\"href\")), {owner}, $noxResourceOptions);\n"));
}
Operation::Attachment { attachment, marker } => js.push_str(&format!(
"{indent}installAnimateAttachment({fragment}.querySelector(\"[data-noxid-attach-{marker}]\"), {}, {owner}, \"{}\");\n",
emit_attachment_config(&attachment.config),
js_escape(attachment.id.as_str()),
)),
Operation::Component { id, marker, target, props, handlers, prefetch, children } => {
match children {
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()))),
Some(slot) => {
// Children render in the parent's scope: the closure
// captures the parent's signals and mounts under the
// child's slot owner.
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)?));
let slot_fragment = format!("$noxSlotFragment{marker}");
js.push_str(&format!("{indent} const {slot_fragment} = {}.content.cloneNode(true);\n", slot.template_name));
emit_operations(js, &slot.operations, &slot_fragment, "$noxBlockOwner", depth + 1)?;
js.push_str(&format!("{indent} $noxTarget.appendChild({slot_fragment});\n"));
js.push_str(&format!("{indent}}} }}), {}, {owner}, \"{}\");\n", emit_handlers(handlers), js_escape(id.as_str())));
}
}
if let Some(trigger) = prefetch {
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)?));
}
}
Operation::Slot { id, marker } => js.push_str(&format!("{indent}mountSlot(findMarker({fragment}, \"noxid-slot-{marker}\"), $noxProps.__noxidChildren, {owner}, \"{}\");\n", js_escape(id.as_str()))),
Operation::Conditional { id, marker, condition, dependencies, transition, template_name, operations } => {
js.push_str(&format!("{indent}mountIf(findMarker({fragment}, \"noxid-if-{marker}\"), {}, {owner}, ($noxTarget, $noxBlockOwner) => {{\n", emit_source(condition, dependencies, owner)?));
let nested_fragment = format!("$noxFragment{marker}");
js.push_str(&format!("{indent} const {nested_fragment} = {template_name}.content.cloneNode(true);\n"));
emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 1)?;
js.push_str(&format!("{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent}}}, \"{}\", {});\n", js_escape(id.as_str()), emit_transition(transition)));
}
Operation::Match {
id,
marker,
expression,
dependencies,
cases,
} => {
js.push_str(&format!(
"{indent}mountMatch(findMarker({fragment}, \"noxid-match-{marker}\"), {}, {owner}, {{\n",
emit_match_source(expression, dependencies, owner)?
));
for case in cases {
js.push_str(&format!(
"{indent} \"{}\": ($noxTarget, $noxBlockOwner, $noxMatchValue) => {{\n",
js_escape(&case.variant)
));
if let Some(binding) = &case.binding {
js.push_str(&format!(
"{indent} const {} = toSource($noxMatchValue.value);\n",
binding.name
));
}
let nested_fragment = format!("$noxMatchFragment{marker}{}", case.variant);
js.push_str(&format!(
"{indent} const {nested_fragment} = {}.content.cloneNode(true);\n",
case.template_name
));
emit_operations(
js,
&case.operations,
&nested_fragment,
"$noxBlockOwner",
depth + 2,
)?;
js.push_str(&format!(
"{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }},\n"
));
}
js.push_str(&format!("{indent}}}, \"{}\");\n", js_escape(id.as_str())));
}
Operation::Stream {
id,
marker,
expression,
dependencies,
cases,
} => {
js.push_str(&format!(
"{indent}mountStream(findMarker({fragment}, \"noxid-stream-{marker}\"), {}, {owner}, {{\n",
emit_source(expression, dependencies, owner)?
));
for case in cases {
js.push_str(&format!(
"{indent} \"{}\": ($noxTarget, $noxBlockOwner, $noxStreamEvent) => {{\n",
js_escape(&case.variant)
));
if let Some(binding) = &case.binding {
js.push_str(&format!(
"{indent} const {} = toSource($noxStreamEvent.value);\n",
binding.name
));
}
let nested_fragment = format!("$noxStreamFragment{marker}{}", case.variant);
js.push_str(&format!(
"{indent} const {nested_fragment} = {}.content.cloneNode(true);\n",
case.template_name
));
emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
js.push_str(&format!(
"{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }},\n"
));
}
js.push_str(&format!("{indent}}}, \"{}\");\n", js_escape(id.as_str())));
}
Operation::For {
id,
marker,
collection,
dependencies,
binding,
key,
template_name,
operations,
} => {
js.push_str(&format!(
"{indent}mountFor(findMarker({fragment}, \"noxid-for-{marker}\"), {}, {owner}, ({}) => {}, ($noxTarget, $noxBlockOwner, $noxItemSource) => {{\n",
emit_source(collection, dependencies, owner)?,
binding.name,
emit_expr(key)?,
));
js.push_str(&format!(
"{indent} const {} = $noxItemSource;\n",
binding.name
));
let nested_fragment = format!("$noxForFragment{marker}");
js.push_str(&format!(
"{indent} const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
));
emit_operations(
js,
operations,
&nested_fragment,
"$noxBlockOwner",
depth + 1,
)?;
js.push_str(&format!(
"{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent}}}, \"{}\");\n",
js_escape(id.as_str())
));
}
}
}
Ok(())
}
fn emit_hydration_operations(
js: &mut String,
operations: &[Operation],
root: &str,
owner: &str,
depth: usize,
) -> Result<(), String> {
let indent = " ".repeat(depth);
for operation in operations {
match operation {
Operation::Text {
id,
marker,
expression,
dependencies,
} => js.push_str(&format!(
"{indent}hydrateText(findMarker({root}, \"noxid-text-{marker}\"), () => {}, {owner}, {}, \"{}\");\n",
emit_expr(expression)?,
emit_sources(dependencies),
js_escape(id.as_str()),
)),
Operation::Attribute {
id,
marker,
name,
expression,
dependencies,
} => js.push_str(&format!(
"{indent}bindAttribute({root}.querySelector(\"[data-noxid-bind-{marker}]\"), \"{}\", () => {}, {owner}, {}, \"{}\");\n",
js_escape(name),
emit_expr(expression)?,
emit_sources(dependencies),
js_escape(id.as_str()),
)),
Operation::TwoWayBinding {
id,
marker,
name,
target,
} => js.push_str(&format!(
"{indent}bindProperty({root}.querySelector(\"[data-noxid-two-way-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n",
js_escape(name),
symbol_name(target),
js_escape(id.as_str()),
)),
Operation::Event {
id,
marker,
event,
action,
arguments,
} => js.push_str(&format!(
"{indent}listen({root}.querySelector(\"[data-noxid-event-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n",
js_escape(event),
event_handler_javascript(action, arguments)?,
js_escape(id.as_str()),
)),
Operation::RoutePrefetch { marker, trigger } => {
let trigger = trigger.as_str();
js.push_str(&format!("{indent}const $noxPrefetchTarget{marker} = {root}.querySelector(\"[data-noxid-prefetch-{marker}]\");\n"));
js.push_str(&format!("{indent}attachCompiledPrefetch($noxPrefetchTarget{marker}, \"{trigger}\", () => $noxResourceOptions.prefetchRoute($noxPrefetchTarget{marker}.getAttribute(\"href\")), {owner}, $noxResourceOptions);\n"));
}
Operation::Attachment { attachment, marker } => js.push_str(&format!(
"{indent}installAnimateAttachment({root}.querySelector(\"[data-noxid-attach-{marker}]\"), {}, {owner}, \"{}\");\n",
emit_attachment_config(&attachment.config),
js_escape(attachment.id.as_str()),
)),
Operation::Component {
id,
marker,
target,
props,
handlers,
prefetch,
children,
} => {
let props_javascript = match children {
None => emit_props(props, owner)?,
Some(slot) => {
let mut closure = String::new();
let slot_fragment = format!("$noxSlotFragment{marker}");
closure.push_str(&format!(
"Object.assign({}, {{ __noxidChildren: ($noxTarget, $noxBlockOwner, $noxHydratingSlot = false) => {{\n{indent} if ($noxHydratingSlot) {{\n",
emit_props(props, owner)?
));
emit_hydration_operations(
&mut closure,
&slot.operations,
"$noxTarget",
"$noxBlockOwner",
depth + 2,
)?;
closure.push_str(&format!(
"{indent} }} else {{\n{indent} const {slot_fragment} = {}.content.cloneNode(true);\n",
slot.template_name
));
emit_operations(
&mut closure,
&slot.operations,
&slot_fragment,
"$noxBlockOwner",
depth + 2,
)?;
closure.push_str(&format!(
"{indent} $noxTarget.appendChild({slot_fragment});\n{indent} }}\n{indent}}} }})"
));
closure
}
};
js.push_str(&format!(
"{indent}hydrateComponent(findMarker({root}, \"noxid-component-{marker}\"), \"noxid-component-end-{marker}\", hydrate{}, mount{}, {}, {}, {owner}, \"{}\", $noxResourceOptions);\n",
target_name(target),
target_name(target),
props_javascript,
emit_handlers(handlers),
js_escape(id.as_str()),
));
if let Some(trigger) = prefetch {
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)?));
}
}
Operation::Slot { id, marker } => js.push_str(&format!(
"{indent}hydrateSlot(findMarker({root}, \"noxid-slot-{marker}\"), \"noxid-slot-end-{marker}\", $noxProps.__noxidChildren, {owner}, \"{}\");\n",
js_escape(id.as_str()),
)),
Operation::Conditional {
id,
marker,
condition,
dependencies,
transition,
template_name,
operations,
} => {
js.push_str(&format!(
"{indent}hydrateIf(findMarker({root}, \"noxid-if-{marker}\"), \"noxid-if-end-{marker}\", {}, {owner}, ($noxTarget, $noxBlockOwner, $noxHydratingBlock) => {{\n{indent} if ($noxHydratingBlock) {{\n",
emit_source(condition, dependencies, owner)?,
));
emit_hydration_operations(js, operations, "$noxTarget", "$noxBlockOwner", depth + 2)?;
js.push_str(&format!("{indent} }} else {{\n"));
let nested_fragment = format!("$noxHydratedIfFragment{marker}");
js.push_str(&format!(
"{indent} const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
));
emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
js.push_str(&format!(
"{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }}\n{indent}}}, \"{}\", {});\n",
js_escape(id.as_str()),
emit_transition(transition),
));
}
Operation::Match {
id,
marker,
expression,
dependencies,
cases,
} => {
js.push_str(&format!(
"{indent}hydrateMatch(findMarker({root}, \"noxid-match-{marker}\"), \"noxid-match-end-{marker}\", {}, {owner}, {{\n",
emit_match_source(expression, dependencies, owner)?,
));
for case in cases {
js.push_str(&format!(
"{indent} \"{}\": ($noxTarget, $noxBlockOwner, $noxMatchValue, $noxHydratingBlock) => {{\n",
js_escape(&case.variant),
));
if let Some(binding) = &case.binding {
js.push_str(&format!(
"{indent} const {} = toSource($noxMatchValue.value);\n",
binding.name,
));
}
js.push_str(&format!("{indent} if ($noxHydratingBlock) {{\n"));
emit_hydration_operations(js, &case.operations, "$noxTarget", "$noxBlockOwner", depth + 3)?;
js.push_str(&format!("{indent} }} else {{\n"));
let nested_fragment = format!("$noxHydratedMatchFragment{marker}{}", case.variant);
js.push_str(&format!(
"{indent} const {nested_fragment} = {}.content.cloneNode(true);\n",
case.template_name,
));
emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 3)?;
js.push_str(&format!(
"{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }}\n{indent} }},\n"
));
}
js.push_str(&format!(
"{indent}}}, \"{}\");\n",
js_escape(id.as_str()),
));
}
Operation::For {
id,
marker,
collection,
dependencies,
binding,
key,
template_name,
operations,
} => {
js.push_str(&format!(
"{indent}hydrateFor(findMarker({root}, \"noxid-for-{marker}\"), \"noxid-for-end-{marker}\", {}, {owner}, ({}) => {}, ($noxTarget, $noxBlockOwner, $noxItemSource, $noxHydratingBlock) => {{\n",
emit_source(collection, dependencies, owner)?,
binding.name,
emit_expr(key)?,
));
js.push_str(&format!(
"{indent} const {} = $noxItemSource;\n{indent} if ($noxHydratingBlock) {{\n",
binding.name,
));
emit_hydration_operations(js, operations, "$noxTarget", "$noxBlockOwner", depth + 2)?;
js.push_str(&format!("{indent} }} else {{\n"));
let nested_fragment = format!("$noxHydratedForFragment{marker}");
js.push_str(&format!(
"{indent} const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
));
emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
js.push_str(&format!(
"{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }}\n{indent}}}, \"{}\");\n",
js_escape(id.as_str()),
));
}
Operation::Stream {
id,
marker,
expression,
dependencies,
cases,
} => {
js.push_str(&format!(
"{indent}hydrateStream(findMarker({root}, \"noxid-stream-{marker}\"), \"noxid-stream-end-{marker}\", {}, {owner}, {{\n",
emit_source(expression, dependencies, owner)?,
));
for case in cases {
js.push_str(&format!(
"{indent} \"{}\": ($noxTarget, $noxBlockOwner, $noxStreamEvent, $noxHydratingBlock) => {{\n",
js_escape(&case.variant),
));
if let Some(binding) = &case.binding {
js.push_str(&format!(
"{indent} const {} = toSource($noxStreamEvent.value);\n",
binding.name,
));
}
js.push_str(&format!("{indent} if ($noxHydratingBlock) {{\n"));
emit_hydration_operations(js, &case.operations, "$noxTarget", "$noxBlockOwner", depth + 3)?;
js.push_str(&format!("{indent} }} else {{\n"));
let nested_fragment = format!("$noxHydratedStreamFragment{marker}{}", case.variant);
js.push_str(&format!(
"{indent} const {nested_fragment} = {}.content.cloneNode(true);\n",
case.template_name,
));
emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 3)?;
js.push_str(&format!(
"{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }}\n{indent} }},\n"
));
}
js.push_str(&format!(
"{indent}}}, \"{}\");\n",
js_escape(id.as_str()),
));
}
}
}
Ok(())
}
fn emit_props(props: &[PropArgument], owner: &str) -> Result<String, String> {
let values = props
.iter()
.map(|prop| {
Ok(format!(
"{}: {}",
prop.name,
emit_source(&prop.expression, &prop.dependencies, owner)?
))
})
.collect::<Result<Vec<_>, String>>()?
.join(", ");
Ok(format!("{{ {values} }}"))
}
fn event_handler_javascript(
action: &SemanticId,
arguments: &Option<Vec<SemanticExpr>>,
) -> Result<String, String> {
Ok(match arguments {
// Bare form: the action itself is the listener and receives the event.
None => symbol_name(action).to_string(),
// Call form: arguments are evaluated when the event fires, so a
// handler inside a keyed block reads the block's current values.
Some(arguments) => format!(
"() => {}({})",
symbol_name(action),
arguments
.iter()
.map(emit_expr)
.collect::<Result<Vec<_>, String>>()?
.join(", ")
),
})
}
fn emit_handlers(handlers: &[ComponentEventHandler]) -> String {
let values = handlers
.iter()
.map(|handler| {
format!(
"\"{}\": {}",
js_escape(&handler.name),
symbol_name(&handler.action)
)
})
.collect::<Vec<_>>()
.join(", ");
format!("{{ {values} }}")
}
fn emit_resource_refresh_triggers(triggers: &[ResourceRefreshTrigger]) -> String {
triggers
.iter()
.map(|trigger| match trigger {
ResourceRefreshTrigger::Focus { .. } | ResourceRefreshTrigger::Reconnect { .. } => {
format!(
"Object.freeze({{ kind: \"{}\", milliseconds: null }})",
trigger.kind()
)
}
ResourceRefreshTrigger::Every { milliseconds, .. } => {
format!("Object.freeze({{ kind: \"every\", milliseconds: {milliseconds} }})")
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn emit_source(
expression: &SemanticExpr,
dependencies: &[SemanticId],
owner: &str,
) -> Result<String, String> {
if let SemanticExprKind::Reference(id) = &expression.kind {
return Ok(symbol_name(id).into());
}
if dependencies.is_empty() {
return emit_expr(expression);
}
Ok(format!(
"computed(() => {}, {owner}, {})",
emit_expr(expression)?,
emit_sources(dependencies)
))
}
fn emit_transition(transition: &Option<ConditionalTransition>) -> String {
transition.as_ref().map_or_else(
|| "null".into(),
|transition| {
format!(
"{{ semanticId: \"{}\", durationMilliseconds: {} }}",
js_escape(transition.id.as_str()),
transition.milliseconds,
)
},
)
}
fn emit_attachment_config(config: &AttachmentConfig) -> String {
match config {
AttachmentConfig::Animate { duration } => {
format!("{{ durationMilliseconds: {} }}", duration.milliseconds)
}
}
}
fn emit_match_source(
expression: &SemanticExpr,
dependencies: &[SemanticId],
owner: &str,
) -> Result<String, String> {
if !matches!(expression.ty, noxid_types::Type::Optional(_)) {
return emit_source(expression, dependencies, owner);
}
let optional = format!(
"(($noxOptional) => ($noxOptional == null ? {{ tag: \"None\" }} : {{ tag: \"Some\", value: $noxOptional }}))({})",
emit_expr(expression)?,
);
Ok(if dependencies.is_empty() {
optional
} else {
format!(
"computed(() => {optional}, {owner}, {})",
emit_sources(dependencies)
)
})
}
/// Emit an already type-checked expression with the client runtime calling
/// convention. Scenario harnesses use this narrow API so tests execute the
/// same expression lowering as the shipped component module.
pub fn emit_scenario_expression(expr: &SemanticExpr) -> Result<String, String> {
let emitted = emit_expr(expr)?;
Ok(if emitted.contains("$noxEqual(") {
format!(
"(($noxEqual) => ({}))({})",
emitted,
LANGUAGE_VALUE_EQUALITY_FUNCTION
.trim()
.strip_prefix("function $noxEqual")
.map(|body| format!("function $noxEqual{body}"))
.expect("equality helper is a named function")
)
} else {
emitted
})
}
fn has_language_value_equality(ty: &noxid_types::Type) -> bool {
use noxid_types::Type;
match ty {
Type::Array(_)
| Type::Map(_, _)
| Type::MapEntry(_, _)
| Type::Result(_, _)
| Type::Named(_) => true,
Type::Optional(inner)
| Type::Static(inner)
| Type::Reactive(inner)
| Type::Binding(inner) => has_language_value_equality(inner),
// `File` never reaches client code: an upload body field carries the
// compiler-owned `FileRef` shape by the time codegen sees it.
Type::File
| Type::Int
| Type::String
| Type::Boolean
| Type::Number
| Type::Float
| Type::Date
| Type::Function(_, _)
| Type::Unknown => false,
}
}
/// Lower one expression with the client runtime calling convention.
///
/// Fail-closed: a builtin the shared lowering table cannot serve returns a
/// stable `error[CODE]` instead of panicking, and never falls through to the
/// user-function form where `len(a, b)` would name a function the emitted
/// module never defines.
fn emit_expr(expr: &SemanticExpr) -> Result<String, String> {
Ok(match &expr.kind {
SemanticExprKind::Int(value) => value.to_string(),
SemanticExprKind::Float(value) => value.to_string(),
SemanticExprKind::String(value) => format!("\"{}\"", js_escape(value)),
SemanticExprKind::Boolean(value) => value.to_string(),
SemanticExprKind::Array(values) => format!(
"[{}]",
values
.iter()
.map(emit_expr)
.collect::<Result<Vec<_>, _>>()?
.join(", ")
),
SemanticExprKind::Struct { fields, .. } => format!(
"{{ {} }}",
fields
.iter()
.map(|field| {
Ok(format!(
"\"{}\": {}",
js_escape(&field.name),
emit_expr(&field.value)?
))
})
.collect::<Result<Vec<_>, String>>()?
.join(", ")
),
SemanticExprKind::FieldAccess { base, name, .. } => {
format!("{}[\"{}\"]", emit_expr(base)?, js_escape(name))
}
SemanticExprKind::CollectionQuery {
base,
kind,
field,
value,
} => {
let value = value.as_deref().map(emit_expr).transpose()?;
noxid_ir::collection_query_javascript(
*kind,
&emit_expr(base)?,
field.as_ref().map(|segment| segment.name.as_str()),
value.as_deref(),
match &base.ty {
noxid_types::Type::Map(key, _) => Some(key.as_ref()),
_ => None,
},
)
}
SemanticExprKind::Call {
function,
name,
arguments,
} => format!(
"__noxidValidateExternalResult(\"{}\", {}, {}({}))",
js_escape(function.as_str()),
external_type_descriptor(&expr.ty),
name,
arguments
.iter()
.map(emit_expr)
.collect::<Result<Vec<_>, _>>()?
.join(", ")
),
SemanticExprKind::Reference(id)
if id.as_str().starts_with("stream-use:")
|| id.as_str().starts_with("presence-use:") =>
{
format!(
"{}.get().map(($noxEnvelope) => $noxEnvelope.event)",
symbol_name(id)
)
}
SemanticExprKind::Reference(id) => format!("{}.get()", symbol_name(id)),
SemanticExprKind::Variant {
variant, payload, ..
} => {
let tag = symbol_name(variant);
match payload {
Some(payload) => format!(
"{{ tag: \"{}\", value: {} }}",
js_escape(tag),
emit_expr(payload)?
),
None => format!("{{ tag: \"{}\" }}", js_escape(tag)),
}
}
SemanticExprKind::Binary { left, op, right } => {
if matches!(op, SemanticBinaryOp::Equal | SemanticBinaryOp::NotEqual)
&& has_language_value_equality(&left.ty)
{
let equality = format!("$noxEqual({}, {})", emit_expr(left)?, emit_expr(right)?);
return Ok(if matches!(op, SemanticBinaryOp::NotEqual) {
format!("(!{equality})")
} else {
equality
});
}
let operator = match op {
SemanticBinaryOp::Equal => "===",
SemanticBinaryOp::NotEqual => "!==",
SemanticBinaryOp::Coalesce => "??",
other => other.as_str(),
};
format!("({} {operator} {})", emit_expr(left)?, emit_expr(right)?)
}
SemanticExprKind::Unary { op, operand } => {
format!("({}{})", op.as_str(), emit_expr(operand)?)
}
SemanticExprKind::StringTemplate(parts) => {
let mut pieces = vec!["\"\"".to_string()];
for part in parts {
pieces.push(match part {
SemanticTemplatePart::Literal(value) => format!("\"{}\"", js_escape(value)),
SemanticTemplatePart::Expression(expression) => {
format!("({})", emit_expr(expression)?)
}
});
}
format!("({})", pieces.join(" + "))
}
SemanticExprKind::FunctionCall {
function,
name,
arguments,
} => match emit_distinct_identity_call(function, arguments)? {
Some(javascript) => javascript,
None => match emit_builtin_call(function, arguments)? {
Some(javascript) => javascript,
// Only a genuine file-scoped `function` reaches the plain
// call form; the client emits those as real JS functions.
None => format!(
"{}({})",
name,
arguments
.iter()
.map(emit_expr)
.collect::<Result<Vec<_>, _>>()?
.join(", ")
),
},
},
})
}
fn emit_distinct_identity_call(
function: &SemanticId,
arguments: &[SemanticExpr],
) -> Result<Option<String>, String> {
if !function.is_distinct_call() {
return Ok(None);
}
match arguments.first() {
Some(argument) => emit_expr(argument).map(Some),
None => Ok(Some("undefined".into())),
}
}
/// `Ok(None)` means "not a builtin at all"; `Err` means the id claims to be a
/// builtin the shared lowering table cannot emit. Semantics rejects every such
/// call with BUILTIN_OVERLOAD_MISMATCH first, so this is a defensive backstop
/// — but it must fail closed rather than panic or emit a bare call.
fn emit_builtin_call(
function: &SemanticId,
arguments: &[SemanticExpr],
) -> Result<Option<String>, String> {
let Some(name) = function.as_str().strip_prefix("fn:@builtin.") else {
return Ok(None);
};
let emitted = arguments
.iter()
.map(emit_expr)
.collect::<Result<Vec<_>, _>>()?;
noxid_ir::builtin_javascript(name, &emitted)
.map(Some)
.ok_or_else(|| noxid_ir::rejected_builtin_call(name, emitted.len()))
}
// File-scoped pure functions share the action calling convention: inputs are
// wrapped with toSource so the shared statement emitter's .get() reads work.
fn emit_function_definitions(
js: &mut String,
functions: &[FunctionDefinition],
) -> Result<(), String> {
for function in functions {
js.push_str(&format!(
"function {}({}) {{\n",
function.name,
function
.parameters
.iter()
.map(|parameter| format!("{}Input", parameter.name))
.collect::<Vec<_>>()
.join(", ")
));
for parameter in &function.parameters {
js.push_str(&format!(
" const {} = toSource({}Input);\n",
parameter.name, parameter.name
));
}
emit_statements(js, &function.statements, 1)?;
js.push_str("}\n\n");
}
Ok(())
}
fn external_validation_prelude(program: &SemanticProgram) -> String {
let schemas = program
.types
.iter()
.chain(
program
.components
.iter()
.flat_map(|component| component.types.iter()),
)
.map(|definition| {
format!(
"\"{}\":{{{}}}",
js_escape(&definition.name),
definition
.fields
.iter()
.map(|field| format!(
"\"{}\":{}",
js_escape(&field.name),
external_type_descriptor(&field.ty)
))
.collect::<Vec<_>>()
.join(",")
)
})
.collect::<Vec<_>>()
.join(",");
format!(
r#"const __noxidExternalSchemas = Object.freeze({{{schemas}}});
function __noxidExternalFailure(code, symbol, expected, path, value) {{
const error = new TypeError(`${{code}}: ${{symbol}} returned invalid ${{expected}} at ${{path}}`);
error.code = code;
error.symbol = symbol;
error.expected = expected;
error.path = path;
error.received = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
throw error;
}}
function __noxidValidateExternalValue(symbol, schema, value, path) {{
switch (schema.kind) {{
case "string": if (typeof value === "string") return value; break;
case "boolean": if (typeof value === "boolean") return value; break;
case "number": if (typeof value === "number" && Number.isFinite(value)) return value; break;
case "int": if (Number.isSafeInteger(value)) return value; break;
case "date": if (value instanceof Date && Number.isFinite(value.getTime())) return value; break;
case "void": if (value === undefined) return value; break;
case "optional": if (value === undefined || value === null) return value; return __noxidValidateExternalValue(symbol, schema.value, value, path);
case "array": if (Array.isArray(value)) return Object.freeze(value.map((item, index) => __noxidValidateExternalValue(symbol, schema.item, item, `${{path}}[${{index}}]`))); break;
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;
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); }}
}}
return __noxidExternalFailure("EXTERNAL_RESULT_VALIDATION_FAILED", symbol, schema.name ?? schema.kind, path, value);
}}
function __noxidValidateExternalResult(symbol, schema, value) {{
if (value && typeof value.then === "function") return __noxidExternalFailure("EXTERNAL_ASYNC_RESULT_UNSUPPORTED", symbol, schema.name ?? schema.kind, "$", value);
return __noxidValidateExternalValue(symbol, schema, value, "$");
}}
"#
)
}
fn external_type_descriptor(ty: &noxid_types::Type) -> String {
use noxid_types::Type;
match ty {
Type::String => "{kind:\"string\"}".into(),
Type::Boolean => "{kind:\"boolean\"}".into(),
Type::Number | Type::Float => "{kind:\"number\"}".into(),
Type::Int => "{kind:\"int\"}".into(),
Type::Date => "{kind:\"date\"}".into(),
Type::Array(item) => format!("{{kind:\"array\",item:{}}}", external_type_descriptor(item)),
Type::Map(key, value) => format!(
"{{kind:\"map\",key:{},value:{}}}",
external_type_descriptor(key),
external_type_descriptor(value)
),
Type::Optional(value) => format!(
"{{kind:\"optional\",value:{}}}",
external_type_descriptor(value)
),
Type::Static(value) | Type::Reactive(value) | Type::Binding(value) => {
external_type_descriptor(value)
}
Type::Named(name) if matches!(name.as_str(), "Void" | "Undefined") => {
"{kind:\"void\"}".into()
}
Type::Named(name) => format!("{{kind:\"named\",name:\"{}\"}}", js_escape(name)),
Type::File
| Type::MapEntry(_, _)
| Type::Result(_, _)
| Type::Function(_, _)
| Type::Unknown => "{kind:\"unsupported\"}".into(),
}
}
fn target_name(id: &SemanticId) -> &str {
id.as_str()
.strip_prefix("component:")
.unwrap_or(id.as_str())
}
fn symbol_name(id: &SemanticId) -> &str {
if id.as_str().starts_with("presence-use:") {
return "presence";
}
id.as_str()
.rsplit('.')
.next()
.unwrap_or_else(|| target_name(id))
}
fn emit_sources(sources: &[SemanticId]) -> String {
format!(
"[{}]",
sources
.iter()
.map(symbol_name)
.collect::<Vec<_>>()
.join(", ")
)
}
#[cfg(test)]
mod tests {
use super::*;
use noxid_ir::{StructDefinition, StructFieldDefinition};
use noxid_source::Span;
use noxid_types::Type;
use std::process::Command;
// Every expression and component these tests build is lowerable; the
// fail-closed path has its own test that calls `super::emit_expr` directly.
fn emit_expr(expr: &SemanticExpr) -> String {
super::emit_expr(expr).expect("expression has a client lowering")
}
// WO-45 phase 2 parity: the same two expressions lower to the same two
// strings in codegen-ssr-js and codegen-server-js, whose sibling tests
// assert them literally. A distinct type is erased at every boundary.
#[test]
fn distinct_construct_and_unwrap_erase_to_the_same_plain_client_value() {
let literal = SemanticExpr {
kind: SemanticExprKind::String("u-1".into()),
ty: Type::String,
span: Span::new(0, 1),
};
let constructed = SemanticExpr {
kind: SemanticExprKind::FunctionCall {
function: SemanticId::distinct_construct("UserId"),
name: "UserId".into(),
arguments: vec![literal],
},
ty: Type::Named("UserId".into()),
span: Span::new(0, 1),
};
let unwrapped = SemanticExpr {
kind: SemanticExprKind::FunctionCall {
function: SemanticId::distinct_unwrap("UserId"),
name: "UserId.base".into(),
arguments: vec![constructed.clone()],
},
ty: Type::String,
span: Span::new(0, 1),
};
assert_eq!(emit_expr(&constructed), "\"u-1\"");
assert_eq!(emit_expr(&unwrapped), "\"u-1\"");
}
fn emit_scenario_expression(expr: &SemanticExpr) -> String {
super::emit_scenario_expression(expr).expect("expression has a client lowering")
}
/// Semantics refuses a mis-arity builtin with BUILTIN_OVERLOAD_MISMATCH
/// before emission, so this is a defensive backstop. The client emitter
/// used to `panic!` here; it now fails closed with the same message the
/// SSR and server emitters produce.
#[test]
fn a_builtin_with_no_lowering_fails_closed_in_the_client_emitter() {
let call = client_builtin(
"len",
vec![
SemanticExpr {
kind: SemanticExprKind::String("a".into()),
ty: Type::String,
span: Span::new(0, 1),
},
SemanticExpr {
kind: SemanticExprKind::String("b".into()),
ty: Type::String,
span: Span::new(0, 1),
},
],
Type::Int,
);
let error =
super::emit_expr(&call).expect_err("a two-argument `len` has no client lowering");
assert!(
error.starts_with("error[BUILTIN_OVERLOAD_MISMATCH]: builtin `len`"),
"{error}"
);
}
fn expression(kind: SemanticExprKind, ty: Type) -> SemanticExpr {
SemanticExpr {
kind,
ty,
span: Span::new(0, 1),
}
}
fn remote_await_component() -> ComponentDefinition {
let request_parameter = ActionParameter {
id: SemanticId::action_parameter("RemoteForm", "save", "request"),
name: "request".into(),
ty: Type::Int,
type_id: None,
span: Span::new(0, 1),
};
let remote_action = Action {
id: SemanticId::action("RemoteForm", "save"),
name: "save".into(),
execution: ExecutionTarget::Server,
parameters: vec![request_parameter.clone()],
result: ActionResult {
id: SemanticId::action_result("RemoteForm", "save"),
ty: Type::Int,
type_id: None,
span: Span::new(0, 1),
},
capabilities: vec![],
invalidation: ActionInvalidation {
mode: ActionInvalidationMode::None,
resources: vec![],
},
statements: vec![],
reads: vec![],
writes: vec![],
calls: vec![],
span: Span::new(0, 1),
};
let success = MatchBinding {
id: SemanticId::local("RemoteForm", "submit", "saved"),
name: "saved".into(),
ty: Type::Int,
};
let failure = MatchBinding {
id: SemanticId::local("RemoteForm", "submit", "error"),
name: "error".into(),
ty: Type::Named("RemoteError".into()),
};
let client_action = Action {
id: SemanticId::action("RemoteForm", "submit"),
name: "submit".into(),
execution: ExecutionTarget::Client,
parameters: vec![],
result: ActionResult {
id: SemanticId::action_result("RemoteForm", "submit"),
ty: Type::Unknown,
type_id: None,
span: Span::new(0, 1),
},
capabilities: vec![],
invalidation: ActionInvalidation {
mode: ActionInvalidationMode::None,
resources: vec![],
},
statements: vec![
SemanticStatement::Local {
id: SemanticId::local("RemoteForm", "submit", "request"),
name: "request".into(),
value: expression(SemanticExprKind::Int(3), Type::Int),
span: Span::new(0, 1),
},
SemanticStatement::Assignment {
target: SemanticId::state("RemoteForm", "savedId"),
value: expression(SemanticExprKind::Int(-1), Type::Int),
span: Span::new(0, 1),
},
SemanticStatement::RemoteAwait {
binding: MatchBinding {
id: SemanticId::local("RemoteForm", "submit", "outcome"),
name: "outcome".into(),
ty: Type::Result(
Box::new(Type::Int),
Box::new(Type::Named("RemoteError".into())),
),
},
action: remote_action.id.clone(),
name: remote_action.name.clone(),
arguments: vec![RemoteActionArgument {
parameter: request_parameter.id.clone(),
name: request_parameter.name.clone(),
value: expression(
SemanticExprKind::Reference(SemanticId::local(
"RemoteForm",
"submit",
"request",
)),
Type::Int,
),
span: Span::new(0, 1),
}],
ok_arm: Box::new(RemoteAwaitArm {
variant: SemanticId::remote_result_variant("Ok"),
name: "Ok".into(),
binding: Some(success.clone()),
statements: vec![SemanticStatement::Assignment {
target: SemanticId::state("RemoteForm", "savedId"),
value: expression(SemanticExprKind::Reference(success.id), Type::Int),
span: Span::new(0, 1),
}],
span: Span::new(0, 1),
}),
err_arm: Box::new(RemoteAwaitArm {
variant: SemanticId::remote_result_variant("Err"),
name: "Err".into(),
binding: Some(failure.clone()),
statements: vec![SemanticStatement::Assignment {
target: SemanticId::state("RemoteForm", "errorMessage"),
value: expression(
SemanticExprKind::FieldAccess {
base: Box::new(expression(
SemanticExprKind::Reference(failure.id),
Type::Named("RemoteError".into()),
)),
field: SemanticId::external_field("RemoteError", "message"),
name: "message".into(),
},
Type::String,
),
span: Span::new(0, 1),
}],
span: Span::new(0, 1),
}),
span: Span::new(0, 1),
},
SemanticStatement::Assignment {
target: SemanticId::state("RemoteForm", "finished"),
value: expression(SemanticExprKind::Boolean(true), Type::Boolean),
span: Span::new(0, 1),
},
],
reads: vec![],
writes: vec![],
calls: vec![remote_action.id.clone()],
span: Span::new(0, 1),
};
ComponentDefinition {
id: SemanticId::component("RemoteForm"),
name: "RemoteForm".into(),
route_metadata: None,
route_render: None,
render: ComponentRenderPolicy {
id: SemanticId::component_render("RemoteForm"),
mode: ComponentRenderMode::Universal,
hydration: HydrationMode::Eager,
span: Span::new(0, 1),
},
route_query: vec![],
middleware: vec![],
capabilities: vec![],
props: vec![],
events: vec![],
context_uses: vec![],
context_providers: vec![],
types: vec![],
distinct_types: vec![],
machines: vec![],
states: vec![],
computed: vec![],
loaders: vec![],
resources: vec![],
presence: None,
streams: vec![],
agents: vec![],
actions: vec![remote_action, client_action],
lifecycle: None,
effects: vec![],
behaviors: vec![],
regions: vec![],
intent: None,
invariants: vec![],
requirements: vec![],
scenarios: vec![],
view: vec![],
style: None,
span: Span::new(0, 1),
}
}
#[test]
fn remote_await_commits_optimistic_and_continuation_transactions_around_boundary() {
let mut generated = String::new();
emit_hmr_action_factory(
&mut generated,
&SemanticProgram {
imports: vec![],
functions: vec![],
external_modules: vec![],
contexts: vec![],
types: vec![],
distinct_types: vec![],
resources: vec![],
streams: vec![],
agents: vec![],
endpoints: vec![],
tasks: vec![],
queues: vec![],
models: vec![],
components: vec![],
},
&remote_await_component(),
)
.expect("component has a client lowering");
let generated = generated.replace("export ", "");
let script = format!(
r#"const transactions = [];
function runAction(id, _owner, callback) {{ transactions.push(id); return callback(); }}
function toSource(value) {{ return {{ get() {{ return value; }} }}; }}
function source(initial) {{ let value = initial; return {{ get() {{ return value; }}, set(next) {{ value = next; }} }}; }}
{generated}
let resolveBoundary;
const savedId = source(0);
const errorMessage = source("");
const finished = source(false);
const pending = new Promise((resolve) => {{ resolveBoundary = resolve; }});
const actions = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished, $noxExecuteBoundary(descriptor) {{
if (descriptor.id !== "action:RemoteForm.save" || descriptor.arguments[0].value !== 3) throw new Error("invalid descriptor");
return pending;
}} }});
const submission = actions.submit();
if (savedId.get() !== -1 || finished.get() || transactions.length !== 1) throw new Error("optimistic transaction did not commit before await");
resolveBoundary(7);
await submission;
if (savedId.get() !== 7 || !finished.get() || transactions.length !== 2) throw new Error("Ok continuation did not run in exactly one fresh transaction");
transactions.length = 0;
savedId.set(0); errorMessage.set(""); finished.set(false);
const rejected = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished, async $noxExecuteBoundary() {{ const error = new Error("already exists"); error.code = "CONFLICT"; throw error; }} }});
await rejected.submit();
if (savedId.get() !== -1 || errorMessage.get() !== "already exists" || !finished.get()) throw new Error("Err continuation did not consume the closed RemoteError value");
if (transactions.length !== 2) throw new Error("Err path did not preserve the two-transaction contract");
transactions.length = 0;
savedId.set(0); errorMessage.set(""); finished.set(false);
const unavailable = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished }});
await unavailable.submit();
if (savedId.get() !== -1 || !errorMessage.get().includes("No host executor is configured") || !finished.get()) throw new Error("missing boundary executor escaped the typed Err channel");
if (transactions.length !== 2) throw new Error("unavailable boundary did not preserve the two-transaction contract");
"#,
);
let output = Command::new("node")
.args(["--input-type=module", "-e", &script])
.output()
.expect("node must execute generated remote-await actions");
assert!(
output.status.success(),
"generated:\n{generated}\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn remote_await_arm_transitions_keep_the_runtime_helper_import() {
let mut component = remote_await_component();
let submit = component
.actions
.iter_mut()
.find(|action| action.name == "submit")
.unwrap();
let remote_await = submit
.statements
.iter_mut()
.find_map(|statement| match statement {
SemanticStatement::RemoteAwait { ok_arm, .. } => Some(ok_arm),
SemanticStatement::Return { .. }
| SemanticStatement::Assignment { .. }
| SemanticStatement::FieldAssignment { .. }
| SemanticStatement::Local { .. }
| SemanticStatement::LocalAssignment { .. }
| SemanticStatement::If { .. }
| SemanticStatement::ActionCall { .. }
| SemanticStatement::Transition { .. }
| SemanticStatement::CollectionMutation { .. }
| SemanticStatement::PrincipalMatch { .. }
| SemanticStatement::Emit { .. } => None,
})
.unwrap();
remote_await.statements = vec![SemanticStatement::Transition {
target: SemanticId::state("RemoteForm", "phase"),
value: expression(
SemanticExprKind::Variant {
machine: SemanticId::machine("RemoteForm", "Phase"),
variant: SemanticId::variant("RemoteForm", "Phase", "Ready"),
payload: None,
},
Type::Named("Phase".into()),
),
machine: SemanticId::machine("RemoteForm", "Phase"),
event: "submit".into(),
allowed: vec![("Idle".into(), "Ready".into())],
span: Span::new(0, 1),
}];
let mut imports = BTreeSet::new();
collect_component_runtime_imports(&component, &[], false, &mut imports);
assert!(imports.contains("transitionMachine"));
}
fn client_builtin(name: &str, arguments: Vec<SemanticExpr>, ty: Type) -> SemanticExpr {
SemanticExpr {
kind: SemanticExprKind::FunctionCall {
function: SemanticId::function(&format!("@builtin.{name}")),
name: name.into(),
arguments,
},
ty,
span: Span::new(0, 1),
}
}
#[test]
fn emits_every_scalar_builtin_with_reactive_client_arguments() {
let string = || SemanticExpr {
kind: SemanticExprKind::Reference(SemanticId::state("C", "name")),
ty: Type::String,
span: Span::new(0, 1),
};
let int = |value| SemanticExpr {
kind: SemanticExprKind::Int(value),
ty: Type::Int,
span: Span::new(0, 1),
};
let float = |value| SemanticExpr {
kind: SemanticExprKind::Float(value),
ty: Type::Float,
span: Span::new(0, 1),
};
let cases = [
(
client_builtin("len", vec![string()], Type::Int),
"name.get().length",
),
(
client_builtin(
"contains",
vec![
string(),
SemanticExpr {
kind: SemanticExprKind::String("a".into()),
ty: Type::String,
span: Span::new(0, 1),
},
],
Type::Boolean,
),
"name.get().includes(\"a\")",
),
(
client_builtin(
"startsWith",
vec![
string(),
SemanticExpr {
kind: SemanticExprKind::String("A".into()),
ty: Type::String,
span: Span::new(0, 1),
},
],
Type::Boolean,
),
"name.get().startsWith(\"A\")",
),
(
client_builtin("trim", vec![string()], Type::String),
"name.get().trim()",
),
(
client_builtin("lower", vec![string()], Type::String),
"name.get().toLowerCase()",
),
(
client_builtin("upper", vec![string()], Type::String),
"name.get().toUpperCase()",
),
(
client_builtin("min", vec![int(2), int(3)], Type::Int),
"Math.min(2, 3)",
),
(
client_builtin("max", vec![float(2.5), float(3.5)], Type::Float),
"Math.max(2.5, 3.5)",
),
(
client_builtin("abs", vec![int(-2)], Type::Int),
"Math.abs(-2)",
),
(
client_builtin("round", vec![float(2.5)], Type::Int),
"Math.round(2.5)",
),
(
client_builtin("floor", vec![float(2.5)], Type::Int),
"Math.floor(2.5)",
),
(
client_builtin("ceil", vec![float(2.5)], Type::Int),
"Math.ceil(2.5)",
),
(client_builtin("toFloat", vec![int(2)], Type::Float), "(2)"),
(
client_builtin("toInt", vec![float(2.5)], Type::Int),
"Math.trunc(2.5)",
),
(
client_builtin("toString", vec![int(2)], Type::String),
"String(2)",
),
];
for (expression, expected) in cases {
assert_eq!(emit_expr(&expression), expected);
}
let shadowing_user_function = SemanticExpr {
kind: SemanticExprKind::FunctionCall {
function: SemanticId::function("len"),
name: "len".into(),
arguments: vec![string()],
},
ty: Type::Int,
span: Span::new(0, 1),
};
assert_eq!(emit_expr(&shadowing_user_function), "len(name.get())");
}
fn empty_program() -> SemanticProgram {
SemanticProgram {
imports: vec![],
functions: vec![],
external_modules: vec![],
contexts: vec![],
types: vec![StructDefinition {
id: SemanticId::global_type("User"),
name: "User".into(),
fields: vec![
StructFieldDefinition {
id: SemanticId::global_type_field("User", "name"),
name: "name".into(),
ty: Type::String,
span: Span::new(0, 0),
},
StructFieldDefinition {
id: SemanticId::global_type_field("User", "scores"),
name: "scores".into(),
ty: Type::Array(Box::new(Type::Int)),
span: Span::new(0, 0),
},
],
span: Span::new(0, 0),
}],
distinct_types: vec![],
resources: vec![],
streams: vec![],
agents: vec![],
endpoints: vec![],
tasks: vec![],
queues: vec![],
models: vec![],
components: vec![],
}
}
#[test]
fn optional_matches_emit_reactive_tagged_sources_without_changing_machine_sources() {
let dependency = SemanticId::state("C", "page");
let optional = SemanticExpr {
kind: SemanticExprKind::Reference(dependency.clone()),
ty: Type::Optional(Box::new(Type::Int)),
span: Span::new(0, 0),
};
assert_eq!(
emit_match_source(&optional, std::slice::from_ref(&dependency), "$noxOwner")
.expect("optional match source emits"),
"computed(() => (($noxOptional) => ($noxOptional == null ? { tag: \"None\" } : { tag: \"Some\", value: $noxOptional }))(page.get()), $noxOwner, [page])"
);
assert!(match_source_requires_computed(
&optional,
std::slice::from_ref(&dependency)
));
let machine = SemanticExpr {
kind: SemanticExprKind::Reference(dependency.clone()),
ty: Type::Named("PageState".into()),
span: Span::new(0, 0),
};
assert_eq!(
emit_match_source(&machine, std::slice::from_ref(&dependency), "$noxOwner")
.expect("machine match source emits"),
"page"
);
assert!(!match_source_requires_computed(&machine, &[dependency]));
}
#[test]
fn imported_results_are_validated_normalized_and_async_safe() {
let mut script = external_validation_prelude(&empty_program());
script.push_str(
r#"
const user = __noxidValidateExternalResult("external-function:legacy.load", {kind:"named",name:"User"}, {name:"Ada",scores:[1,2],ignored:true});
if (user.name !== "Ada" || user.ignored !== undefined || !Object.isFrozen(user) || !Object.isFrozen(user.scores)) process.exit(2);
try { __noxidValidateExternalResult("external-function:legacy.bad", {kind:"int"}, "1"); process.exit(3); }
catch (error) { if (error.code !== "EXTERNAL_RESULT_VALIDATION_FAILED" || error.symbol !== "external-function:legacy.bad") process.exit(4); }
try { __noxidValidateExternalResult("external-function:legacy.async", {kind:"string"}, Promise.resolve("value")); process.exit(5); }
catch (error) { if (error.code !== "EXTERNAL_ASYNC_RESULT_UNSUPPORTED") process.exit(6); }
"#,
);
let output = Command::new("node")
.args(["--input-type=module", "-e", &script])
.output()
.expect("node must execute generated validation");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn client_compound_equality_executes_recursive_value_semantics() {
fn row(id: i64) -> SemanticExpr {
SemanticExpr {
kind: SemanticExprKind::Struct {
definition: SemanticId::global_type("Row"),
fields: vec![StructFieldValue {
field: SemanticId::global_type_field("Row", "id"),
name: "id".into(),
value: SemanticExpr {
kind: SemanticExprKind::Int(id),
ty: Type::Int,
span: Span::new(0, 0),
},
span: Span::new(0, 0),
}],
},
ty: Type::Named("Row".into()),
span: Span::new(0, 0),
}
}
fn rows(values: &[i64]) -> SemanticExpr {
SemanticExpr {
kind: SemanticExprKind::Array(values.iter().copied().map(row).collect()),
ty: Type::Array(Box::new(Type::Named("Row".into()))),
span: Span::new(0, 0),
}
}
let same = SemanticExpr {
kind: SemanticExprKind::Binary {
left: Box::new(rows(&[1, 2])),
op: SemanticBinaryOp::Equal,
right: Box::new(rows(&[1, 2])),
},
ty: Type::Boolean,
span: Span::new(0, 0),
};
let different = SemanticExpr {
kind: SemanticExprKind::Binary {
left: Box::new(rows(&[1, 2])),
op: SemanticBinaryOp::NotEqual,
right: Box::new(rows(&[1, 3])),
},
ty: Type::Boolean,
span: Span::new(0, 0),
};
let primitive = SemanticExpr {
kind: SemanticExprKind::Binary {
left: Box::new(SemanticExpr {
kind: SemanticExprKind::Int(1),
ty: Type::Int,
span: Span::new(0, 0),
}),
op: SemanticBinaryOp::Equal,
right: Box::new(SemanticExpr {
kind: SemanticExprKind::Int(1),
ty: Type::Int,
span: Span::new(0, 0),
}),
},
ty: Type::Boolean,
span: Span::new(0, 0),
};
assert_eq!(emit_expr(&primitive), "(1 === 1)");
let script = format!(
"if (!({}) || !({})) process.exit(1);",
emit_scenario_expression(&same),
emit_scenario_expression(&different)
);
let output = Command::new("node")
.args(["--input-type=module", "-e", &script])
.output()
.expect("node must execute generated compound equality");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
}