1use std::cell::{Cell, RefCell};
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4use std::rc::Rc;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{Duration, Instant};
8
9use rhai::{
10 AST, ASTNode, Array, Dynamic, Engine, EvalAltResult, Expr, FnPtr, FuncArgs, FuncRegistration,
11 ImmutableString, Map, Module, ModuleResolver, Position, Scope, Stmt,
12};
13use thiserror::Error;
14
15use crate::animation::register_animation_api;
16use crate::asset::{AssetId, ImageDecodeHandle, asset_id_from_script};
17use crate::backend::{AstInterpreter, ExecutionBackend};
18use crate::canvas::register_canvas_api;
19use crate::column_resize::{
20 ColumnMeasurementRegistry, ColumnResizePrimitiveHandler, IntrinsicTextMeasurePrimitiveHandler,
21 column_resize_primitive_descriptor, intrinsic_text_measure_primitive_descriptor,
22};
23use crate::component::{ComponentExportCollector, ComponentExportError, ComponentRegistry};
24use crate::context::{UiContext, register_ui_context_api};
25use crate::date::register_date_api;
26use crate::node::{
27 asset_image_node, box_node, canvas_node, column_node, directional_asset_image_node,
28 directional_image_node, error_boundary_node, fragment_node, generic_directional_image_node,
29 generic_image_node, image_node, layer_node, lazy_error_boundary_node, overlay_node,
30 rich_text_node, row_node, span_value, stack_node, svg_node, text_node,
31};
32use crate::primitive::{PrimitiveDescriptor, PrimitiveError, PrimitiveHandler, PrimitiveRegistry};
33use crate::range_input::{RangeInputPrimitiveHandler, range_input_primitive_descriptor};
34use crate::style::register_style_api;
35use crate::text_area::{
36 TextAreaPrimitiveHandler, register_text_area_api, text_area_primitive_descriptor,
37};
38use crate::text_input::{TextInputPrimitiveHandler, text_input_primitive_descriptor};
39use crate::value::OpaqueHandle;
40use crate::{
41 ComponentInstancePath, ComponentStateSchema, EventSchema, ExecutionPhase, ModuleId,
42 RenderStateTransaction, UiNode, UiRuntimeState, UiValue,
43};
44use crate::{SubscriptionHandle, TaskHandle};
45
46#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct ScriptGeneration(u64);
48
49impl ScriptGeneration {
50 #[must_use]
51 pub const fn get(self) -> u64 {
52 self.0
53 }
54
55 #[cfg(test)]
56 #[must_use]
57 pub(crate) fn initial() -> Self {
58 Self(1)
59 }
60
61 #[cfg(test)]
62 #[must_use]
63 pub(crate) fn next(self) -> Self {
64 Self(self.0.saturating_add(1))
65 }
66}
67
68impl fmt::Display for ScriptGeneration {
69 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70 self.0.fmt(formatter)
71 }
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub enum ExecutionOperation {
76 Compile,
77 Render,
78 ComponentReuse(usize),
82 VirtualCollection(String),
83 Lifecycle(String),
84 Callback(String),
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct ExecutionTiming {
89 pub operation: ExecutionOperation,
90 pub source: String,
91 pub duration: Duration,
92 pub operations: u64,
93 pub operation_semantics: u32,
94 pub slow: bool,
95 pub succeeded: bool,
96}
97
98pub const OPERATION_SEMANTICS_VERSION: u32 = 2;
99
100#[derive(Debug, Error)]
101pub enum RuntimeError {
102 #[error("failed to compile Rhai UI source: {0}")]
103 Compile(#[source] Box<EvalAltResult>),
104 #[error("failed to evaluate Rhai UI source: {0}")]
105 Evaluate(#[source] Box<EvalAltResult>),
106 #[error("callback `{name}` is invalid: {source}")]
107 CallbackDefinition {
108 name: String,
109 #[source]
110 source: Box<EvalAltResult>,
111 },
112 #[error("callback `{name}` cannot be retained: {source}")]
113 RetainedCallback {
114 name: String,
115 source: ScriptCallbackDefinitionError,
116 },
117 #[error(
118 "callback `{name}` belongs to script generation {callback_generation}, but current generation is {current_generation}"
119 )]
120 StaleCallback {
121 name: String,
122 callback_generation: ScriptGeneration,
123 current_generation: ScriptGeneration,
124 },
125 #[error("callback `{name}` belongs to an unmounted incarnation of component `{component}`")]
126 StaleComponentCallback {
127 name: String,
128 component: ComponentInstancePath,
129 },
130 #[error("component runtime failed: {0}")]
131 ComponentRuntime(String),
132 #[error("component invocation `{0}` is not retained in the active generation")]
133 MissingComponentInvocation(ComponentInstancePath),
134 #[error("invalid script import: {0}")]
135 Import(String),
136 #[error(
137 "compiled Rhai source contains an assignment target that Rhai 1.26 cannot evaluate safely at {0}"
138 )]
139 InvalidAssignmentTarget(Position),
140}
141
142#[derive(Debug, Error)]
143pub enum ScriptCallbackDefinitionError {
144 #[error("anonymous or capturing functions cannot escape one synchronous Rhai evaluation")]
145 Anonymous,
146 #[error("curried argument {index} cannot cross the retained callback boundary: {source}")]
147 InvalidCurry {
148 index: usize,
149 source: crate::UiValueError,
150 },
151}
152
153#[derive(Clone, Debug)]
159struct ComponentCallbackBinding {
160 component: ComponentInstancePath,
161 incarnation: crate::ComponentIncarnation,
162 events: BTreeMap<String, EventSchema>,
163 context: Option<crate::invocation::ScriptInvocationContext>,
164}
165
166#[derive(Clone)]
167pub struct CompiledUi {
168 ast: AST,
169 generation: ScriptGeneration,
170}
171
172#[derive(Clone, Debug)]
173pub struct ScriptCallback {
174 function: FnPtr,
175 curry: Vec<UiValue>,
176 generation: ScriptGeneration,
177 component: Option<ComponentInstancePath>,
178 incarnation: Option<crate::ComponentIncarnation>,
179 events: BTreeMap<String, EventSchema>,
180 #[allow(deprecated)]
181 native_context: Option<crate::invocation::ScriptInvocationContext>,
182}
183
184impl PartialEq for ScriptCallback {
185 fn eq(&self, other: &Self) -> bool {
186 self.name() == other.name()
187 && self.curry == other.curry
188 && self.generation == other.generation
189 && self.component == other.component
190 && self.incarnation == other.incarnation
191 }
192}
193
194impl Eq for ScriptCallback {}
195
196impl ScriptCallback {
197 #[must_use]
198 pub fn name(&self) -> &str {
199 self.function.fn_name()
200 }
201
202 #[must_use]
203 pub fn generation(&self) -> ScriptGeneration {
204 self.generation
205 }
206
207 pub(crate) fn try_from_fn_ptr(
208 mut function: FnPtr,
209 generation: ScriptGeneration,
210 ) -> Result<Self, ScriptCallbackDefinitionError> {
211 if function.is_anonymous() {
212 return Err(ScriptCallbackDefinitionError::Anonymous);
213 }
214 let mut binding = None;
215 let mut curry = Vec::new();
216 for (index, value) in function.iter_curry().cloned().enumerate() {
217 if value.is::<ComponentCallbackBinding>() {
218 binding = Some(value.cast::<ComponentCallbackBinding>());
219 continue;
220 }
221 curry.push(
222 UiValue::from_dynamic(value).map_err(|source| {
223 ScriptCallbackDefinitionError::InvalidCurry { index, source }
224 })?,
225 );
226 }
227 function.set_curry(curry.iter().cloned().map(UiValue::into_dynamic));
228 let (component, incarnation, events, native_context) = binding.map_or_else(
229 || (None, None, BTreeMap::new(), None),
230 |binding| {
231 (
232 Some(binding.component),
233 Some(binding.incarnation),
234 binding.events,
235 binding.context,
236 )
237 },
238 );
239 Ok(Self {
240 function,
241 curry,
242 generation,
243 component,
244 incarnation,
245 events,
246 native_context,
247 })
248 }
249
250 pub(crate) fn bind_generation(&mut self, generation: ScriptGeneration) {
251 self.generation = generation;
252 }
253
254 #[cfg(test)]
255 pub(crate) fn bind_component_if_unset(
256 &mut self,
257 component: ComponentInstancePath,
258 events: BTreeMap<String, EventSchema>,
259 ) {
260 if self.component.is_none() {
261 self.component = Some(component);
262 self.events = events;
263 }
264 }
265
266 pub(crate) fn bind_component_scope_if_unset(
267 &mut self,
268 component: &ComponentInstancePath,
269 incarnation: crate::ComponentIncarnation,
270 events: BTreeMap<String, EventSchema>,
271 ) {
272 if self.component.is_none() {
273 self.component = Some(component.clone());
274 self.events = events;
275 }
276 if self.component.as_ref() == Some(component) && self.incarnation.is_none() {
277 self.incarnation = Some(incarnation);
278 }
279 }
280
281 pub(crate) fn bind_native_context_if_unset(
282 &mut self,
283 context: crate::invocation::ScriptInvocationContext,
284 ) {
285 if self.native_context.is_none() {
286 self.native_context = Some(context);
287 }
288 }
289
290 pub(crate) fn component(&self) -> Option<&ComponentInstancePath> {
291 self.component.as_ref()
292 }
293
294 pub(crate) const fn incarnation(&self) -> Option<crate::ComponentIncarnation> {
295 self.incarnation
296 }
297
298 pub(crate) fn events(&self) -> &BTreeMap<String, EventSchema> {
299 &self.events
300 }
301
302 pub(crate) fn native_context(&self) -> Option<&crate::invocation::ScriptInvocationContext> {
303 self.native_context.as_ref()
304 }
305}
306
307impl CompiledUi {
308 #[must_use]
309 pub fn generation(&self) -> ScriptGeneration {
310 self.generation
311 }
312
313 #[must_use]
314 pub fn has_function(&self, name: &str, parameter_count: usize) -> bool {
315 self.ast
316 .iter_functions()
317 .any(|function| function.name == name && function.params.len() == parameter_count)
318 }
319}
320
321struct ActiveComponentRender {
322 root_context: UiContext,
323 stack: Vec<ComponentInstancePath>,
324 contexts: Vec<UiContext>,
325 seen: BTreeSet<ComponentInstancePath>,
326 positions: BTreeMap<String, usize>,
327 transaction: RenderStateTransaction,
328 generation: ScriptGeneration,
329 state_snapshot: crate::StateStore,
330 event_handlers: BTreeMap<(ComponentInstancePath, String), ScriptCallback>,
331 invocations: BTreeMap<ComponentInstancePath, ComponentInvocationRecipe>,
332 effect_keys: Vec<Option<BTreeSet<String>>>,
333 effects: BTreeMap<crate::EffectId, crate::EffectDescriptor>,
334 timers: BTreeMap<crate::TimerId, crate::TimerDescriptor>,
335 signals: BTreeMap<crate::SignalId, crate::signal::SignalDescriptor>,
336 element_refs: BTreeSet<crate::ElementRefId>,
337 virtual_collections: BTreeMap<crate::VirtualCollectionId, VirtualCollectionRecipe>,
338 environment: ComponentRenderEnvironment,
339 reuse: Option<ComponentReuseSnapshot>,
340 reused: Vec<(String, usize)>,
341}
342
343#[derive(Clone, Copy, Debug, Eq, PartialEq)]
344struct ComponentRenderEnvironment {
345 theme_generation: Option<u64>,
346 component_style_generation: u64,
347 locale_generation: Option<u64>,
348 calendar_day: crate::GregorianDate,
349}
350
351#[derive(Clone)]
352struct ComponentReusePlan {
353 previous_root: Rc<UiNode>,
354 subtrees: crate::node::ComponentSubtreeIndex,
355 dirty: BTreeSet<ComponentInstancePath>,
356}
357
358impl ComponentReusePlan {
359 fn new(previous_root: Rc<UiNode>, dirty: BTreeSet<ComponentInstancePath>) -> Self {
360 let subtrees = crate::node::ComponentSubtreeIndex::new(&previous_root);
361 Self {
362 previous_root,
363 subtrees,
364 dirty,
365 }
366 }
367}
368
369#[derive(Clone)]
370struct ComponentReuseSnapshot {
371 previous_root: Rc<UiNode>,
372 subtrees: crate::node::ComponentSubtreeIndex,
373 dirty: BTreeSet<ComponentInstancePath>,
374 invocations: BTreeMap<ComponentInstancePath, ComponentInvocationRecipe>,
375 event_handlers: BTreeMap<(ComponentInstancePath, String), ScriptCallback>,
376 effects: BTreeMap<crate::EffectId, crate::EffectDescriptor>,
377 timers: BTreeMap<crate::TimerId, crate::TimerDescriptor>,
378 signals: BTreeMap<crate::SignalId, crate::signal::SignalDescriptor>,
379 element_refs: BTreeSet<crate::ElementRefId>,
380 virtual_collections: BTreeMap<crate::VirtualCollectionId, VirtualCollectionRecipe>,
381}
382
383struct ReusedComponentScope {
384 node: UiNode,
385 invocations: BTreeMap<ComponentInstancePath, ComponentInvocationRecipe>,
386 event_handlers: BTreeMap<(ComponentInstancePath, String), ScriptCallback>,
387 resources: ReusedComponentResources,
388}
389
390struct ReusedComponentResources {
391 effects: BTreeMap<crate::EffectId, crate::EffectDescriptor>,
392 timers: BTreeMap<crate::TimerId, crate::TimerDescriptor>,
393 signals: BTreeMap<crate::SignalId, crate::signal::SignalDescriptor>,
394 element_refs: BTreeSet<crate::ElementRefId>,
395 virtual_collections: BTreeMap<crate::VirtualCollectionId, VirtualCollectionRecipe>,
396}
397
398#[derive(Clone, Debug)]
399struct VirtualCollectionRecipe {
400 id: crate::VirtualCollectionId,
401 data: crate::VirtualCollectionData,
402 renderer: FnPtr,
403 renderer_events: BTreeMap<String, crate::EventSchema>,
404 renderer_context: crate::invocation::ScriptInvocationContext,
405 context: UiContext,
406 event_context: UiContext,
407 generation: ScriptGeneration,
408}
409
410type ActiveComponentRenderState = Rc<RefCell<Option<ActiveComponentRender>>>;
411
412#[derive(Clone)]
413struct PendingComponentCommit {
414 root: ComponentInstancePath,
415 previous: BTreeSet<ComponentInstancePath>,
416 active: BTreeSet<ComponentInstancePath>,
417 transaction: RenderStateTransaction,
418 event_handlers: BTreeMap<(ComponentInstancePath, String), ScriptCallback>,
419}
420
421#[derive(Clone, Debug)]
422pub struct ComponentInvocationRecipe {
423 path: ComponentInstancePath,
424 parent: ComponentInstancePath,
425 component: ModuleId,
426 export: String,
427 declared_key: Option<String>,
428 props: crate::ComponentProps,
429 script_props: Map,
430 component_context: UiContext,
431 caller_context: UiContext,
432 part_styles: BTreeMap<String, crate::Style>,
433 event_callbacks: Vec<(String, FnPtr)>,
434 component_events: BTreeMap<String, EventSchema>,
435 declared_effects: BTreeSet<String>,
436 render: FnPtr,
437 snapshot: crate::node::ComponentOwnedSnapshot,
438 context: crate::invocation::ScriptInvocationContext,
439 generation: ScriptGeneration,
440 environment: ComponentRenderEnvironment,
441 reusable: bool,
442}
443
444#[derive(Clone)]
445pub(crate) struct RegisteredComponentRender {
446 generation: ScriptGeneration,
447 render: FnPtr,
448}
449
450type ComponentRenderRegistry = Rc<RefCell<BTreeMap<ModuleId, RegisteredComponentRender>>>;
451
452impl ComponentInvocationRecipe {
453 #[must_use]
454 pub fn path(&self) -> &ComponentInstancePath {
455 &self.path
456 }
457
458 #[must_use]
459 pub fn parent(&self) -> &ComponentInstancePath {
460 &self.parent
461 }
462
463 #[must_use]
464 pub fn component(&self) -> &ModuleId {
465 &self.component
466 }
467
468 #[must_use]
469 pub fn export(&self) -> &str {
470 &self.export
471 }
472
473 #[must_use]
474 pub fn declared_key(&self) -> Option<&str> {
475 self.declared_key.as_deref()
476 }
477
478 #[must_use]
479 pub fn props(&self) -> &crate::ComponentProps {
480 &self.props
481 }
482
483 #[must_use]
484 pub fn render_name(&self) -> &str {
485 self.render.fn_name()
486 }
487
488 #[must_use]
489 pub fn has_invocation_context(&self) -> bool {
490 let _ = &self.context;
491 true
492 }
493
494 #[must_use]
495 pub fn generation(&self) -> ScriptGeneration {
496 self.generation
497 }
498}
499
500pub struct RuntimeEngine {
501 engine: Engine,
502 generation: ScriptGeneration,
503 preparation_generation: Option<ScriptGeneration>,
504 component_exports: ComponentExportCollector,
505 evaluation_generation: Rc<Cell<ScriptGeneration>>,
506 primitives: PrimitiveRegistry,
507 primitive_modules: BTreeMap<String, Module>,
508 timings: RefCell<Vec<ExecutionTiming>>,
509 operation_tracker: Rc<OperationTracker>,
510 slow_threshold: Duration,
511 component_render: ActiveComponentRenderState,
512 component_invocations: BTreeMap<ComponentInstancePath, ComponentInvocationRecipe>,
513 component_effects: BTreeMap<crate::EffectId, crate::EffectDescriptor>,
514 component_timers: BTreeMap<crate::TimerId, crate::TimerDescriptor>,
515 component_signals: BTreeMap<crate::SignalId, crate::signal::SignalDescriptor>,
516 component_element_refs: BTreeSet<crate::ElementRefId>,
517 pending_component_commits: BTreeMap<ComponentInstancePath, PendingComponentCommit>,
518 component_renderers: ComponentRenderRegistry,
519 native_handlers: crate::NativeHandlerRegistry,
520 syntax_registry: crate::SyntaxRegistry,
521 document_runtime: crate::DocumentRuntimeConfig,
522 virtual_collections: BTreeMap<crate::VirtualCollectionId, VirtualCollectionRecipe>,
523}
524
525const MAX_SCRIPT_OPERATIONS: u64 = 1_000_000;
526
527#[derive(Debug, Default)]
528struct OperationTracker {
529 last_absolute: Cell<u64>,
530 total: Cell<u64>,
531}
532
533impl OperationTracker {
534 fn begin(&self, inherited_base: u64) {
535 self.last_absolute.set(inherited_base);
536 self.total.set(0);
537 }
538
539 fn observe(&self, absolute: u64) -> u64 {
540 let last_absolute = self.last_absolute.get();
541 let delta = if absolute >= last_absolute {
542 absolute - last_absolute
543 } else {
544 1
548 };
549 let total = self.total.get().saturating_add(delta);
550 self.total.set(total);
551 self.last_absolute.set(absolute);
552 total
553 }
554
555 fn align(&self, inherited_base: u64) {
556 self.last_absolute.set(inherited_base);
561 }
562}
563
564#[derive(Clone, Copy)]
565struct ExecutionTimingStart {
566 instant: Instant,
567 operations: u64,
568}
569
570#[derive(Clone)]
571pub(crate) struct RuntimeEngineCheckpoint {
572 generation: ScriptGeneration,
573 evaluation_generation: ScriptGeneration,
574 component_invocations: BTreeMap<ComponentInstancePath, ComponentInvocationRecipe>,
575 component_effects: BTreeMap<crate::EffectId, crate::EffectDescriptor>,
576 component_timers: BTreeMap<crate::TimerId, crate::TimerDescriptor>,
577 component_signals: BTreeMap<crate::SignalId, crate::signal::SignalDescriptor>,
578 component_element_refs: BTreeSet<crate::ElementRefId>,
579 pending_component_commits: BTreeMap<ComponentInstancePath, PendingComponentCommit>,
580 virtual_collections: BTreeMap<crate::VirtualCollectionId, VirtualCollectionRecipe>,
581 component_snapshots: Vec<(crate::node::ComponentOwnedSnapshot, Option<Rc<UiNode>>)>,
582}
583
584impl Default for RuntimeEngine {
585 fn default() -> Self {
586 Self::new()
587 }
588}
589
590impl RuntimeEngine {
591 #[cfg(feature = "dev-reload")]
592 pub(crate) fn candidate_engine(&self) -> Self {
593 let mut candidate = Self::new();
594 candidate.slow_threshold = self.slow_threshold;
595 candidate
596 }
597
598 #[must_use]
605 pub fn new() -> Self {
606 let mut engine = Engine::new();
607 engine.set_module_resolver(crate::source::RestrictedModuleResolver::new());
608 let operation_tracker = Rc::new(OperationTracker::default());
609 let progress = Rc::clone(&operation_tracker);
610 engine.on_progress(move |operations| {
611 let total = progress.observe(operations);
612 (total > MAX_SCRIPT_OPERATIONS).then(|| {
613 Dynamic::from(format!(
614 "script operation budget exceeded: {total} > {MAX_SCRIPT_OPERATIONS}"
615 ))
616 })
617 });
618 engine.build_type::<UiNode>();
619 engine.build_type::<OpaqueHandle>();
620 engine.build_type::<TaskHandle>();
621 engine.build_type::<SubscriptionHandle>();
622 engine.build_type::<AssetId>();
623 engine.build_type::<ImageDecodeHandle>();
624 engine.build_type::<crate::NativeSignal>();
625 engine.build_type::<crate::EventResponse>();
626 engine.build_type::<crate::ElementRef>();
627 engine.build_type::<crate::NativeHandlerRef>();
628 crate::native_collection::register_native_collection_api(&mut engine);
629 crate::document::register_document_api(&mut engine);
630 engine.build_type::<crate::Span>();
631 register_ui_context_api(&mut engine);
632 register_date_api(&mut engine);
633 register_style_api(&mut engine);
634 register_animation_api(&mut engine);
635 register_text_area_api(&mut engine);
636 register_canvas_api(&mut engine);
637 let evaluation_generation = Rc::new(Cell::new(ScriptGeneration::default()));
638 let component_exports = ComponentExportCollector::new();
639 let (component_render, component_renderers) = register_component_runtime_apis(
640 &mut engine,
641 &component_exports,
642 &evaluation_generation,
643 );
644 let primitives = PrimitiveRegistry::new();
645 let native_handlers = crate::NativeHandlerRegistry::new();
646 let syntax_registry = crate::SyntaxRegistry::new();
647 let document_runtime = crate::DocumentRuntimeConfig::new();
648 register_native_handler_api(&mut engine, &native_handlers);
649
650 register_node_apis(&mut engine);
651
652 configure_engine_limits(&mut engine);
653
654 let mut runtime = Self {
655 engine,
656 generation: ScriptGeneration::default(),
657 preparation_generation: None,
658 component_exports,
659 evaluation_generation,
660 primitives,
661 primitive_modules: BTreeMap::new(),
662 timings: RefCell::new(Vec::new()),
663 operation_tracker,
664 slow_threshold: Duration::from_millis(16),
665 component_render,
666 component_invocations: BTreeMap::new(),
667 component_effects: BTreeMap::new(),
668 component_timers: BTreeMap::new(),
669 component_signals: BTreeMap::new(),
670 component_element_refs: BTreeSet::new(),
671 pending_component_commits: BTreeMap::new(),
672 component_renderers,
673 native_handlers,
674 syntax_registry,
675 document_runtime,
676 virtual_collections: BTreeMap::new(),
677 };
678 runtime.register_builtin_primitives();
679 runtime
680 }
681
682 fn register_builtin_primitives(&mut self) {
683 let column_measurements = ColumnMeasurementRegistry::default();
684 self.register_primitive(
685 column_resize_primitive_descriptor(),
686 ColumnResizePrimitiveHandler::new(column_measurements.clone()),
687 )
688 .expect("built-in column resize primitive descriptor is valid");
689 self.register_primitive(
690 intrinsic_text_measure_primitive_descriptor(),
691 IntrinsicTextMeasurePrimitiveHandler::new(column_measurements),
692 )
693 .expect("built-in intrinsic text measurement primitive descriptor is valid");
694 self.register_primitive(
695 text_input_primitive_descriptor(),
696 TextInputPrimitiveHandler::default(),
697 )
698 .expect("built-in TextInput primitive descriptor is valid");
699 self.register_primitive(
700 text_area_primitive_descriptor(),
701 TextAreaPrimitiveHandler::default(),
702 )
703 .expect("built-in Textarea primitive descriptor is valid");
704 self.register_primitive(
705 range_input_primitive_descriptor(),
706 RangeInputPrimitiveHandler::default(),
707 )
708 .expect("built-in RangeInput primitive descriptor is valid");
709 self.register_primitive(
710 crate::code_viewer_primitive_descriptor(),
711 crate::CodeViewerPrimitiveHandler::new(
712 self.syntax_registry.clone(),
713 self.document_runtime.clone(),
714 ),
715 )
716 .expect("built-in CodeViewer primitive descriptor is valid");
717 self.register_primitive(
718 crate::diff_viewer_primitive_descriptor(),
719 crate::DiffViewerPrimitiveHandler::new(
720 self.syntax_registry.clone(),
721 self.document_runtime.clone(),
722 ),
723 )
724 .expect("built-in DiffViewer primitive descriptor is valid");
725 }
726
727 pub fn compile(&mut self, source: &str) -> Result<CompiledUi, RuntimeError> {
733 self.compile_named("ui/main.rhai", source)
734 }
735
736 pub fn compile_named(
742 &mut self,
743 source_name: &str,
744 source: &str,
745 ) -> Result<CompiledUi, RuntimeError> {
746 let generation = self.candidate_generation();
747 self.evaluation_generation.set(generation);
748 self.begin_execution_session();
749 let started = self.begin_timing();
750 let result = self.engine.compile(source);
751 self.record_timing(
752 ExecutionOperation::Compile,
753 source_name,
754 started,
755 result.is_ok(),
756 );
757 let mut ast = result.map_err(|error| RuntimeError::Compile(error.into()))?;
758 validate_assignment_targets(&ast)?;
759 ast.set_source(source_name);
760 Ok(CompiledUi { ast, generation })
761 }
762
763 pub fn compile_self_contained_named(
769 &mut self,
770 source_name: &str,
771 source: &str,
772 ) -> Result<CompiledUi, RuntimeError> {
773 let generation = self.candidate_generation();
774 self.evaluation_generation.set(generation);
775 self.begin_execution_session();
776 let started = self.begin_timing();
777 let result = self
778 .engine
779 .compile_into_self_contained(&Scope::new(), source);
780 self.record_timing(
781 ExecutionOperation::Compile,
782 source_name,
783 started,
784 result.is_ok(),
785 );
786 let mut ast = result.map_err(RuntimeError::Compile)?;
787 validate_literal_imports(&ast)?;
788 validate_assignment_targets(&ast)?;
789 ast.set_source(source_name);
790 Ok(CompiledUi { ast, generation })
791 }
792
793 pub fn set_module_resolver(&mut self, resolver: impl ModuleResolver + 'static) {
794 self.engine.set_module_resolver(resolver);
795 }
796
797 pub fn with_program_preparation<T, E>(
805 &mut self,
806 operation: impl FnOnce(&mut Self) -> Result<T, E>,
807 ) -> Result<T, E> {
808 if self.preparation_generation.is_some() {
809 return operation(self);
810 }
811 let generation = Self::allocate_generation();
812 self.preparation_generation = Some(generation);
813 let result = operation(self);
814 self.preparation_generation = None;
815 result
816 }
817
818 fn begin_component_render(
819 &self,
820 context: UiContext,
821 generation: ScriptGeneration,
822 root_effects: Option<BTreeSet<String>>,
823 reuse_plan: Option<ComponentReusePlan>,
824 ) -> Result<(), RuntimeError> {
825 let root = context.component_path().clone();
826 context.reset_non_reusable_render_reads();
827 let (state_snapshot, environment, event_handlers) = {
828 let runtime = context.runtime().try_borrow().map_err(|_| {
829 RuntimeError::ComponentRuntime("UI state is already borrowed".to_owned())
830 })?;
831 (
832 runtime.component_state.clone(),
833 ComponentRenderEnvironment {
834 theme_generation: runtime.theme.as_ref().map(crate::ThemeManager::generation),
835 component_style_generation: runtime.component_style_generation(),
836 locale_generation: runtime
837 .locale
838 .as_ref()
839 .map(crate::LocaleManager::generation),
840 calendar_day: runtime.calendar_clock.today(),
841 },
842 reuse_plan
843 .as_ref()
844 .map(|_| runtime.component_event_handlers_in_scope(&root)),
845 )
846 };
847 let mut transaction = context
848 .runtime()
849 .try_borrow()
850 .map_err(|_| RuntimeError::ComponentRuntime("UI state is already borrowed".to_owned()))?
851 .component_state
852 .begin_render_scope(root.clone());
853 let _ = transaction.retain_existing(&root);
854 let reuse = reuse_plan.map(|plan| ComponentReuseSnapshot {
855 subtrees: plan.subtrees,
856 previous_root: plan.previous_root,
857 dirty: plan.dirty,
858 invocations: self
859 .component_invocations
860 .iter()
861 .filter(|(path, _)| path.is_within(&root))
862 .map(|(path, recipe)| (path.clone(), recipe.clone()))
863 .collect(),
864 event_handlers: event_handlers.unwrap_or_default(),
865 effects: self
866 .component_effects
867 .iter()
868 .filter(|(id, _)| id.component().is_within(&root))
869 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
870 .collect(),
871 timers: self
872 .component_timers
873 .iter()
874 .filter(|(id, _)| id.component().is_within(&root))
875 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
876 .collect(),
877 signals: self
878 .component_signals
879 .iter()
880 .filter(|(id, _)| id.component().is_within(&root))
881 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
882 .collect(),
883 element_refs: self
884 .component_element_refs
885 .iter()
886 .filter(|id| id.component().is_within(&root))
887 .cloned()
888 .collect(),
889 virtual_collections: self
890 .virtual_collections
891 .iter()
892 .filter(|(id, _)| id.component.is_within(&root))
893 .map(|(id, recipe)| (id.clone(), recipe.clone()))
894 .collect(),
895 });
896 let mut active = self.component_render.try_borrow_mut().map_err(|_| {
897 RuntimeError::ComponentRuntime("component render stack is already borrowed".to_owned())
898 })?;
899 *active = Some(ActiveComponentRender {
900 root_context: context.clone(),
901 stack: vec![root],
902 contexts: vec![context],
903 seen: BTreeSet::new(),
904 positions: BTreeMap::new(),
905 transaction,
906 generation,
907 state_snapshot,
908 event_handlers: BTreeMap::new(),
909 invocations: BTreeMap::new(),
910 effect_keys: vec![root_effects],
911 effects: BTreeMap::new(),
912 timers: BTreeMap::new(),
913 signals: BTreeMap::new(),
914 element_refs: BTreeSet::new(),
915 virtual_collections: BTreeMap::new(),
916 environment,
917 reuse,
918 reused: Vec::new(),
919 });
920 Ok(())
921 }
922
923 fn finish_component_render(&mut self, commit: bool) -> Result<(), RuntimeError> {
924 let active = self
925 .component_render
926 .try_borrow_mut()
927 .map_err(|_| {
928 RuntimeError::ComponentRuntime(
929 "component render stack is already borrowed".to_owned(),
930 )
931 })?
932 .take();
933 let Some(active) = active else {
934 return Ok(());
935 };
936 if commit {
937 self.timings
938 .borrow_mut()
939 .extend(
940 active
941 .reused
942 .iter()
943 .map(|(source, components)| ExecutionTiming {
944 operation: ExecutionOperation::ComponentReuse(*components),
945 source: source.clone(),
946 duration: Duration::ZERO,
947 operations: 0,
948 operation_semantics: OPERATION_SEMANTICS_VERSION,
949 slow: false,
950 succeeded: true,
951 }),
952 );
953 let root = active.root_context.component_path().clone();
954 let previous = active.state_snapshot.paths();
955 self.pending_component_commits.insert(
956 root.clone(),
957 PendingComponentCommit {
958 root: root.clone(),
959 previous,
960 active: active.seen,
961 transaction: active.transaction,
962 event_handlers: active.event_handlers,
963 },
964 );
965 self.component_invocations
966 .retain(|path, _| !path.is_within(&root));
967 self.component_invocations.extend(active.invocations);
968 self.component_effects
969 .retain(|id, _| !id.component().is_within(&root));
970 self.component_effects.extend(active.effects);
971 self.component_timers
972 .retain(|id, _| !id.component().is_within(&root));
973 self.component_timers.extend(active.timers);
974 self.component_signals
975 .retain(|id, _| !id.component().is_within(&root));
976 self.component_signals.extend(active.signals);
977 self.component_element_refs
978 .retain(|id| !id.component().is_within(&root));
979 self.component_element_refs.extend(active.element_refs);
980 self.virtual_collections
981 .retain(|id, _| !id.component.is_within(&root));
982 self.virtual_collections.extend(active.virtual_collections);
983 } else {
984 active
985 .root_context
986 .runtime()
987 .try_borrow_mut()
988 .map_err(|_| {
989 RuntimeError::ComponentRuntime("UI state is already borrowed".to_owned())
990 })?
991 .component_state = active.state_snapshot;
992 }
993 Ok(())
994 }
995
996 pub fn render(&mut self, compiled: &CompiledUi) -> Result<UiNode, RuntimeError> {
1003 self.evaluation_generation.set(compiled.generation);
1004 self.begin_execution_session();
1005 let checkpoint = self.execution_checkpoint();
1006 let root_path = ComponentInstancePath::root("App", "root");
1007 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
1008 let context = UiContext::new(
1009 Rc::clone(&runtime),
1010 root_path.clone(),
1011 None,
1012 ExecutionPhase::Render,
1013 BTreeMap::new(),
1014 )
1015 .with_generation(compiled.generation);
1016 self.begin_component_render(context, compiled.generation, None, None)?;
1017 let started = self.begin_timing();
1018 let result = AstInterpreter::call_fn::<UiNode, _>(
1019 &self.engine,
1020 &compiled.ast,
1021 &mut Scope::new(),
1022 "view",
1023 (),
1024 );
1025 self.record_timing(
1026 ExecutionOperation::Render,
1027 compiled.ast.source().unwrap_or("<script>"),
1028 started,
1029 result.is_ok(),
1030 );
1031 self.finish_component_render(result.is_ok())?;
1032 let mut root = result.map_err(RuntimeError::Evaluate)?;
1033 if !self.component_effects_in_scope(&root_path).is_empty()
1034 || !self.component_timers_in_scope(&root_path).is_empty()
1035 || !self.component_signals_in_scope(&root_path).is_empty()
1036 || !self.component_element_refs_in_scope(&root_path).is_empty()
1037 || !self.virtual_collection_ids_in_scope(&root_path).is_empty()
1038 {
1039 self.restore_execution_checkpoint(checkpoint);
1040 return Err(RuntimeError::ComponentRuntime(
1041 "effectful, timer/signal/ref-owning, or virtual components require ScriptLifecycle"
1042 .to_owned(),
1043 ));
1044 }
1045 if let Err(error) = self.commit_component_renders(&mut runtime.borrow_mut()) {
1046 self.restore_execution_checkpoint(checkpoint);
1047 return Err(error);
1048 }
1049 root.bind_generation(compiled.generation);
1050 self.generation = compiled.generation;
1051 Ok(root)
1052 }
1053
1054 pub fn render_with_context(
1061 &mut self,
1062 compiled: &CompiledUi,
1063 context: UiContext,
1064 ) -> Result<UiNode, RuntimeError> {
1065 let runtime = Rc::clone(context.runtime());
1066 let root = context.component_path().clone();
1067 let snapshot = runtime
1068 .try_borrow_mut()
1069 .map_err(|_| RuntimeError::ComponentRuntime("UI state is already borrowed".to_owned()))?
1070 .begin_transaction()
1071 .map_err(|error| RuntimeError::ComponentRuntime(error.to_string()))?;
1072 let checkpoint = self.execution_checkpoint();
1073 let result = self.render_with_context_staged(compiled, context);
1074 let result = result.and_then(|node| {
1075 if !self.component_effects_in_scope(&root).is_empty()
1076 || !self.component_timers_in_scope(&root).is_empty()
1077 || !self.component_signals_in_scope(&root).is_empty()
1078 || !self.component_element_refs_in_scope(&root).is_empty()
1079 || !self.virtual_collection_ids_in_scope(&root).is_empty()
1080 {
1081 return Err(RuntimeError::ComponentRuntime(
1082 "effectful, timer/signal/ref-owning, or virtual components require ScriptLifecycle"
1083 .to_owned(),
1084 ));
1085 }
1086 self.commit_component_renders(&mut runtime.borrow_mut())?;
1087 Ok(node)
1088 });
1089 match result {
1090 Ok(node) => {
1091 runtime
1092 .try_borrow_mut()
1093 .map_err(|_| {
1094 RuntimeError::ComponentRuntime("UI state is already borrowed".to_owned())
1095 })?
1096 .commit_transaction()
1097 .map_err(|error| RuntimeError::ComponentRuntime(error.to_string()))?;
1098 Ok(node)
1099 }
1100 Err(error) => {
1101 runtime
1102 .try_borrow_mut()
1103 .map_err(|_| {
1104 RuntimeError::ComponentRuntime("UI state is already borrowed".to_owned())
1105 })?
1106 .restore(snapshot)
1107 .map_err(|restore| RuntimeError::ComponentRuntime(restore.to_string()))?;
1108 self.restore_execution_checkpoint(checkpoint);
1109 Err(error)
1110 }
1111 }
1112 }
1113
1114 pub(crate) fn render_with_context_staged(
1115 &mut self,
1116 compiled: &CompiledUi,
1117 context: UiContext,
1118 ) -> Result<UiNode, RuntimeError> {
1119 self.render_with_context_staged_impl(compiled, context, None)
1120 }
1121
1122 pub(crate) fn render_with_context_staged_reusing(
1123 &mut self,
1124 compiled: &CompiledUi,
1125 context: UiContext,
1126 previous_root: Rc<UiNode>,
1127 dirty: &BTreeSet<ComponentInstancePath>,
1128 ) -> Result<UiNode, RuntimeError> {
1129 self.render_with_context_staged_impl(
1130 compiled,
1131 context,
1132 Some(ComponentReusePlan::new(previous_root, dirty.clone())),
1133 )
1134 }
1135
1136 fn render_with_context_staged_impl(
1137 &mut self,
1138 compiled: &CompiledUi,
1139 context: UiContext,
1140 reuse: Option<ComponentReusePlan>,
1141 ) -> Result<UiNode, RuntimeError> {
1142 self.evaluation_generation.set(compiled.generation);
1143 self.begin_execution_session();
1144 self.begin_component_render(context.clone(), compiled.generation, None, reuse)?;
1145 let started = self.begin_timing();
1146 let result = AstInterpreter::call_fn::<UiNode, _>(
1147 &self.engine,
1148 &compiled.ast,
1149 &mut Scope::new(),
1150 "view",
1151 (context,),
1152 );
1153 self.record_timing(
1154 ExecutionOperation::Render,
1155 compiled.ast.source().unwrap_or("<script>"),
1156 started,
1157 result.is_ok(),
1158 );
1159 self.finish_component_render(result.is_ok())?;
1160 let mut root = result.map_err(RuntimeError::Evaluate)?;
1161 root.bind_generation(compiled.generation);
1162 self.generation = compiled.generation;
1163 Ok(root)
1164 }
1165
1166 pub(crate) fn rerender_component(
1167 &mut self,
1168 component: &ComponentInstancePath,
1169 previous_root: &UiNode,
1170 dirty: &BTreeSet<ComponentInstancePath>,
1171 ) -> Result<UiNode, RuntimeError> {
1172 let mut recipe = self
1173 .component_invocations
1174 .get(component)
1175 .cloned()
1176 .ok_or_else(|| RuntimeError::MissingComponentInvocation(component.clone()))?;
1177 if recipe.generation != self.generation {
1178 return Err(RuntimeError::StaleCallback {
1179 name: recipe.render.fn_name().to_owned(),
1180 callback_generation: recipe.generation,
1181 current_generation: self.generation,
1182 });
1183 }
1184 recipe
1185 .component_context
1186 .runtime()
1187 .try_borrow_mut()
1188 .map_err(|_| RuntimeError::ComponentRuntime("UI state is already borrowed".to_owned()))?
1189 .reset_component_readers(component);
1190 for value in recipe.script_props.values_mut() {
1191 hydrate_replayed_node_value(value);
1192 }
1193 let reuse = ComponentReusePlan::new(Rc::new(previous_root.clone()), dirty.clone());
1194 self.begin_component_render(
1195 recipe.component_context.clone(),
1196 recipe.generation,
1197 Some(recipe.declared_effects.clone()),
1198 Some(reuse),
1199 )?;
1200 register_component_invocation(&self.component_render, recipe.clone())
1201 .map_err(RuntimeError::Evaluate)?;
1202
1203 self.align_execution_session_to(recipe.context.operation_base());
1204 let started = self.begin_timing();
1205 let result = (|| {
1206 let mut node = recipe
1207 .context
1208 .call::<UiNode>(
1209 self.engine(),
1210 &recipe.render,
1211 (
1212 recipe.component_context.clone(),
1213 recipe.script_props.clone(),
1214 ),
1215 )
1216 .map_err(RuntimeError::Evaluate)?;
1217 register_component_event_callbacks(
1218 &self.component_render,
1219 &recipe.path,
1220 &recipe.caller_context,
1221 recipe.event_callbacks.clone(),
1222 &recipe.context,
1223 )
1224 .map_err(RuntimeError::Evaluate)?;
1225 node = node.with_owned_part_styles(recipe.part_styles.clone());
1226 node =
1227 node.with_component_root_snapshot(recipe.path.clone(), recipe.snapshot.reference());
1228 node.bind_component_scope(
1229 &recipe.path,
1230 recipe.component_context.component_incarnation(),
1231 &recipe.component_events,
1232 Some(&recipe.context),
1233 );
1234 node.bind_generation(recipe.generation);
1235 Ok(node)
1236 })();
1237 {
1238 let mut active = self.component_render.try_borrow_mut().map_err(|_| {
1239 RuntimeError::ComponentRuntime(
1240 "component render stack is already borrowed".to_owned(),
1241 )
1242 })?;
1243 if let Some(active) = active.as_mut() {
1244 let environment = active.environment;
1245 if let Some(retained) = active.invocations.get_mut(&recipe.path) {
1246 retained.environment = environment;
1247 retained.reusable = recipe.component_context.component_render_is_reusable();
1248 if let Ok(node) = &result {
1249 retained.snapshot.update_if_active(node);
1250 }
1251 }
1252 }
1253 }
1254 self.record_timing(
1255 ExecutionOperation::Render,
1256 recipe.component.as_str(),
1257 started,
1258 result.is_ok(),
1259 );
1260 self.finish_component_render(result.is_ok())?;
1261 result
1262 }
1263
1264 pub(crate) fn execution_checkpoint(&self) -> RuntimeEngineCheckpoint {
1265 RuntimeEngineCheckpoint {
1266 generation: self.generation,
1267 evaluation_generation: self.evaluation_generation.get(),
1268 component_invocations: self.component_invocations.clone(),
1269 component_effects: self.component_effects.clone(),
1270 component_timers: self.component_timers.clone(),
1271 component_signals: self.component_signals.clone(),
1272 component_element_refs: self.component_element_refs.clone(),
1273 pending_component_commits: self.pending_component_commits.clone(),
1274 virtual_collections: self.virtual_collections.clone(),
1275 component_snapshots: self
1276 .component_invocations
1277 .values()
1278 .map(|recipe| (recipe.snapshot.clone(), recipe.snapshot.current()))
1279 .collect(),
1280 }
1281 }
1282
1283 pub(crate) fn restore_execution_checkpoint(&mut self, checkpoint: RuntimeEngineCheckpoint) {
1284 for (snapshot, value) in &checkpoint.component_snapshots {
1285 snapshot.restore(value.clone());
1286 }
1287 self.generation = checkpoint.generation;
1288 self.evaluation_generation
1289 .set(checkpoint.evaluation_generation);
1290 self.component_invocations = checkpoint.component_invocations;
1291 self.component_effects = checkpoint.component_effects;
1292 self.component_timers = checkpoint.component_timers;
1293 self.component_signals = checkpoint.component_signals;
1294 self.component_element_refs = checkpoint.component_element_refs;
1295 self.pending_component_commits = checkpoint.pending_component_commits;
1296 self.virtual_collections = checkpoint.virtual_collections;
1297 }
1298
1299 pub(crate) fn commit_component_renders(
1300 &mut self,
1301 runtime: &mut UiRuntimeState,
1302 ) -> Result<(), RuntimeError> {
1303 let commits = std::mem::take(&mut self.pending_component_commits);
1304 for (_, commit) in commits {
1305 runtime
1306 .reconcile_component_lifetimes(&commit.root, &commit.active, &commit.previous)
1307 .map_err(|error| RuntimeError::ComponentRuntime(error.to_string()))?;
1308 runtime.replace_component_event_handlers(&commit.root, commit.event_handlers);
1309 runtime.component_state.commit_render(commit.transaction);
1310 }
1311 Ok(())
1312 }
1313
1314 pub(crate) fn component_effects_in_scope(
1315 &self,
1316 root: &ComponentInstancePath,
1317 ) -> BTreeMap<crate::EffectId, crate::EffectDescriptor> {
1318 self.component_effects
1319 .iter()
1320 .filter(|(id, _)| id.component().is_within(root))
1321 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
1322 .collect()
1323 }
1324
1325 pub(crate) fn component_signals_in_scope(
1326 &self,
1327 root: &ComponentInstancePath,
1328 ) -> BTreeMap<crate::SignalId, crate::signal::SignalDescriptor> {
1329 self.component_signals
1330 .iter()
1331 .filter(|(id, _)| id.component().is_within(root))
1332 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
1333 .collect()
1334 }
1335
1336 pub(crate) fn component_timers_in_scope(
1337 &self,
1338 root: &ComponentInstancePath,
1339 ) -> BTreeMap<crate::TimerId, crate::TimerDescriptor> {
1340 self.component_timers
1341 .iter()
1342 .filter(|(id, _)| id.component().is_within(root))
1343 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
1344 .collect()
1345 }
1346
1347 pub(crate) fn component_element_refs_in_scope(
1348 &self,
1349 root: &ComponentInstancePath,
1350 ) -> BTreeSet<crate::ElementRefId> {
1351 self.component_element_refs
1352 .iter()
1353 .filter(|id| id.component().is_within(root))
1354 .cloned()
1355 .collect()
1356 }
1357
1358 pub(crate) fn virtual_collection_ids_in_scope(
1359 &self,
1360 root: &ComponentInstancePath,
1361 ) -> BTreeSet<crate::VirtualCollectionId> {
1362 self.virtual_collections
1363 .keys()
1364 .filter(|id| id.component.is_within(root))
1365 .cloned()
1366 .collect()
1367 }
1368
1369 pub(crate) fn realize_virtual_collection(
1370 &mut self,
1371 id: &crate::VirtualCollectionId,
1372 indices: &BTreeSet<usize>,
1373 ) -> Result<BTreeMap<usize, UiNode>, RuntimeError> {
1374 let recipe = self.virtual_collections.get(id).cloned().ok_or_else(|| {
1375 RuntimeError::ComponentRuntime(format!(
1376 "virtual collection `{}` is not retained in the active generation",
1377 id.key
1378 ))
1379 })?;
1380 if recipe.generation != self.generation || recipe.id != *id {
1381 return Err(RuntimeError::StaleCallback {
1382 name: recipe.renderer.fn_name().to_owned(),
1383 callback_generation: recipe.generation,
1384 current_generation: self.generation,
1385 });
1386 }
1387 let scope = id.component.child("VirtualCollection", id.key.clone());
1388 let context = recipe
1389 .context
1390 .for_component(scope, BTreeMap::new())
1391 .with_generation(recipe.generation);
1392 self.begin_component_render(context.clone(), recipe.generation, None, None)?;
1393 let result = (|| {
1394 let mut realized = BTreeMap::new();
1395 for index in indices
1396 .iter()
1397 .copied()
1398 .filter(|index| *index < recipe.data.len())
1399 {
1400 let item = recipe
1401 .data
1402 .item(index)
1403 .map_err(|error| RuntimeError::ComponentRuntime(error.to_string()))?
1404 .ok_or_else(|| {
1405 RuntimeError::ComponentRuntime(format!(
1406 "virtual collection item {index} disappeared"
1407 ))
1408 })?;
1409 let (key, payload) =
1410 collection_payload(&item, index).map_err(RuntimeError::Evaluate)?;
1411 let invocation = &recipe.renderer_context;
1412 self.align_execution_session_to(invocation.operation_base());
1413 let started = self.begin_timing();
1414 let item = invocation.call::<UiNode>(
1415 self.engine(),
1416 &recipe.renderer,
1417 (context.clone(), Dynamic::from_map(payload)),
1418 );
1419 self.record_timing(
1420 ExecutionOperation::VirtualCollection(id.key.clone()),
1421 id.key.as_str(),
1422 started,
1423 item.is_ok(),
1424 );
1425 let mut node = item.map_err(RuntimeError::Evaluate)?.with_key(key);
1426 node.bind_generation(recipe.generation);
1427 node.bind_component_scope(
1428 recipe.event_context.component_path(),
1429 recipe.event_context.component_incarnation(),
1430 &recipe.renderer_events,
1431 Some(&recipe.renderer_context),
1432 );
1433 realized.insert(index, node);
1434 }
1435 Ok(realized)
1436 })();
1437 self.finish_component_render(result.is_ok())?;
1438 result
1439 }
1440
1441 pub(crate) fn update_virtual_collection_snapshot(
1442 &mut self,
1443 id: &crate::VirtualCollectionId,
1444 items: &BTreeMap<usize, UiNode>,
1445 ) -> Result<(), RuntimeError> {
1446 let Some(owner) = self.component_invocations.get_mut(&id.component) else {
1447 return Ok(());
1448 };
1449 let Some(current) = owner.snapshot.current() else {
1450 return Ok(());
1451 };
1452 let mut rendered = current.as_ref().clone();
1453 if !rendered.replace_virtual_collection_items(id, items.clone()) {
1454 return Err(RuntimeError::ComponentRuntime(format!(
1455 "virtual collection `{}` is missing from its owning component snapshot",
1456 id.key
1457 )));
1458 }
1459 owner.snapshot.replace_if_active(Rc::new(rendered));
1460 Ok(())
1461 }
1462
1463 pub fn call_optional_lifecycle(
1469 &self,
1470 compiled: &CompiledUi,
1471 function: &str,
1472 context: UiContext,
1473 ) -> Result<bool, RuntimeError> {
1474 if !compiled.has_function(function, 1) {
1475 return Ok(false);
1476 }
1477 self.evaluation_generation.set(compiled.generation);
1478 self.begin_execution_session();
1479 let started = self.begin_timing();
1480 let result = AstInterpreter::call_fn::<Dynamic, _>(
1481 &self.engine,
1482 &compiled.ast,
1483 &mut Scope::new(),
1484 function,
1485 (context,),
1486 );
1487 self.record_timing(
1488 ExecutionOperation::Lifecycle(function.to_owned()),
1489 compiled.ast.source().unwrap_or("<script>"),
1490 started,
1491 result.is_ok(),
1492 );
1493 let _ = result.map_err(RuntimeError::Evaluate)?;
1494 Ok(true)
1495 }
1496
1497 pub(crate) fn call_optional_lifecycle_with_value(
1498 &self,
1499 compiled: &CompiledUi,
1500 function: &str,
1501 context: UiContext,
1502 value: UiValue,
1503 ) -> Result<bool, RuntimeError> {
1504 if !compiled.has_function(function, 2) {
1505 return Ok(false);
1506 }
1507 self.evaluation_generation.set(compiled.generation);
1508 self.begin_execution_session();
1509 let started = self.begin_timing();
1510 let result = AstInterpreter::call_fn::<Dynamic, _>(
1511 &self.engine,
1512 &compiled.ast,
1513 &mut Scope::new(),
1514 function,
1515 (context, value.into_dynamic()),
1516 );
1517 self.record_timing(
1518 ExecutionOperation::Lifecycle(function.to_owned()),
1519 compiled.ast.source().unwrap_or("<script>"),
1520 started,
1521 result.is_ok(),
1522 );
1523 let _ = result.map_err(RuntimeError::Evaluate)?;
1524 Ok(true)
1525 }
1526
1527 pub fn root_state_schema(
1534 &self,
1535 compiled: &CompiledUi,
1536 ) -> Result<ComponentStateSchema, RuntimeError> {
1537 if !compiled.has_function("state_schema", 0) {
1538 return Ok(ComponentStateSchema::default());
1539 }
1540 self.begin_execution_session();
1541 let raw: Dynamic = self
1542 .engine
1543 .call_fn(&mut Scope::new(), &compiled.ast, "state_schema", ())
1544 .map_err(RuntimeError::Evaluate)?;
1545 rhai::serde::from_dynamic(&raw).map_err(RuntimeError::Evaluate)
1546 }
1547
1548 pub fn callback(
1555 &self,
1556 compiled: &CompiledUi,
1557 function_name: &str,
1558 ) -> Result<ScriptCallback, RuntimeError> {
1559 let function =
1560 FnPtr::new(function_name).map_err(|source| RuntimeError::CallbackDefinition {
1561 name: function_name.to_owned(),
1562 source,
1563 })?;
1564 ScriptCallback::try_from_fn_ptr(function, compiled.generation).map_err(|source| {
1565 RuntimeError::RetainedCallback {
1566 name: function_name.to_owned(),
1567 source,
1568 }
1569 })
1570 }
1571
1572 pub fn invoke_callback(
1579 &self,
1580 compiled: &CompiledUi,
1581 callback: &ScriptCallback,
1582 args: impl FuncArgs,
1583 ) -> Result<Dynamic, RuntimeError> {
1584 if !self.is_current(callback.generation) || callback.generation != compiled.generation {
1585 return Err(RuntimeError::StaleCallback {
1586 name: callback.name().to_owned(),
1587 callback_generation: callback.generation,
1588 current_generation: self.generation,
1589 });
1590 }
1591
1592 self.invoke_callback_for_generation(compiled, callback, args)
1593 }
1594
1595 pub(crate) fn invoke_callback_for_generation(
1596 &self,
1597 compiled: &CompiledUi,
1598 callback: &ScriptCallback,
1599 args: impl FuncArgs,
1600 ) -> Result<Dynamic, RuntimeError> {
1601 if callback.generation != compiled.generation {
1602 return Err(RuntimeError::StaleCallback {
1603 name: callback.name().to_owned(),
1604 callback_generation: callback.generation,
1605 current_generation: compiled.generation,
1606 });
1607 }
1608 self.evaluation_generation.set(compiled.generation);
1609 let operation_base = callback.native_context.as_ref().map_or(
1610 0,
1611 crate::invocation::ScriptInvocationContext::operation_base,
1612 );
1613 self.begin_execution_session_from(operation_base);
1614 let started = self.begin_timing();
1615 #[allow(deprecated)]
1616 let result = if let Some(context) = callback.native_context.as_ref() {
1617 context.call(self.engine(), &callback.function, args)
1618 } else {
1619 callback.function.call(self.engine(), &compiled.ast, args)
1620 };
1621 self.record_timing(
1622 ExecutionOperation::Callback(callback.name().to_owned()),
1623 compiled.ast.source().unwrap_or("<script>"),
1624 started,
1625 result.is_ok(),
1626 );
1627 result.map_err(RuntimeError::Evaluate)
1628 }
1629
1630 #[must_use]
1631 pub fn is_current(&self, generation: ScriptGeneration) -> bool {
1632 generation == self.generation
1633 }
1634
1635 #[must_use]
1636 pub fn engine(&self) -> &Engine {
1637 &self.engine
1638 }
1639
1640 #[must_use]
1645 pub fn definition_source(&self) -> String {
1646 self.engine
1647 .definitions()
1648 .include_standard_packages(false)
1649 .single_file()
1650 }
1651
1652 pub fn lint_known_calls(
1664 &mut self,
1665 entry_name: &str,
1666 entry_source: &str,
1667 modules: &BTreeMap<ModuleId, String>,
1668 ) -> Result<Vec<crate::KnownCallDiagnostic>, crate::KnownCallLintError> {
1669 crate::script_lint::lint_known_calls(&mut self.engine, entry_name, entry_source, modules)
1670 }
1671
1672 pub fn component_exports(&self) -> Result<ComponentRegistry, ComponentExportError> {
1679 self.component_exports.snapshot()
1680 }
1681
1682 pub fn component_invocations(
1683 &self,
1684 ) -> impl ExactSizeIterator<Item = &ComponentInvocationRecipe> {
1685 self.component_invocations.values()
1686 }
1687
1688 pub(crate) fn component_renderer_snapshot(
1689 &self,
1690 ) -> Result<BTreeMap<ModuleId, RegisteredComponentRender>, ComponentExportError> {
1691 self.component_renderers
1692 .try_borrow()
1693 .map(|renderers| renderers.clone())
1694 .map_err(|_| ComponentExportError::Borrowed)
1695 }
1696
1697 pub(crate) fn restore_component_renderers(
1698 &self,
1699 renderers: BTreeMap<ModuleId, RegisteredComponentRender>,
1700 ) -> Result<(), ComponentExportError> {
1701 *self
1702 .component_renderers
1703 .try_borrow_mut()
1704 .map_err(|_| ComponentExportError::Borrowed)? = renderers;
1705 Ok(())
1706 }
1707
1708 pub fn clear_component_exports(&self) -> Result<(), ComponentExportError> {
1714 self.component_renderers
1715 .try_borrow_mut()
1716 .map_err(|_| ComponentExportError::Borrowed)?
1717 .clear();
1718 self.component_exports.clear()
1719 }
1720
1721 pub fn restore_component_exports(
1727 &self,
1728 registry: ComponentRegistry,
1729 ) -> Result<(), ComponentExportError> {
1730 self.component_exports.replace(registry)
1731 }
1732
1733 pub fn register_primitive(
1739 &mut self,
1740 descriptor: PrimitiveDescriptor,
1741 handler: impl PrimitiveHandler + 'static,
1742 ) -> Result<(), PrimitiveError> {
1743 self.primitives.register(descriptor.clone(), handler)?;
1744
1745 let namespace = descriptor.id.namespace().to_owned();
1746 let export = descriptor.export.clone();
1747 let id = descriptor.id;
1748 let primitives = self.primitives.clone();
1749 let generation = Rc::clone(&self.evaluation_generation);
1750 let module = self.primitive_modules.entry(namespace.clone()).or_default();
1751 FuncRegistration::new(export).set_into_module(
1752 module,
1753 move |mut props: Map| -> Result<UiNode, Box<EvalAltResult>> {
1754 let key = props
1755 .remove("key")
1756 .map(|value| {
1757 if value.is::<ImmutableString>() {
1758 Ok(value.cast::<ImmutableString>().to_string())
1759 } else {
1760 Err(Box::new(EvalAltResult::ErrorRuntime(
1761 "primitive key must be a string".into(),
1762 Position::NONE,
1763 )))
1764 }
1765 })
1766 .transpose()?;
1767 primitives
1768 .create_node(&id, key, &props, generation.get())
1769 .map_err(|error| {
1770 Box::new(EvalAltResult::ErrorRuntime(
1771 error.to_string().into(),
1772 Position::NONE,
1773 ))
1774 })
1775 },
1776 );
1777 self.engine
1778 .register_static_module(namespace, module.clone().into());
1779 Ok(())
1780 }
1781
1782 #[must_use]
1783 pub fn primitive_registry(&self) -> PrimitiveRegistry {
1784 self.primitives.clone()
1785 }
1786
1787 pub fn register_native_handler(
1793 &self,
1794 descriptor: crate::NativeHandlerDescriptor,
1795 handler: impl FnMut(
1796 crate::NativeEvent,
1797 &mut UiRuntimeState,
1798 &mut gpui::Window,
1799 &mut gpui::App,
1800 ) -> Result<crate::EventResponse, String>
1801 + 'static,
1802 ) -> Result<(), crate::NativeHandlerError> {
1803 self.native_handlers.register(descriptor, handler)
1804 }
1805
1806 #[must_use]
1807 pub fn native_handler_registry(&self) -> crate::NativeHandlerRegistry {
1808 self.native_handlers.clone()
1809 }
1810
1811 #[must_use]
1812 pub fn syntax_registry(&self) -> crate::SyntaxRegistry {
1813 self.syntax_registry.clone()
1814 }
1815
1816 #[must_use]
1817 pub fn document_runtime_config(&self) -> crate::DocumentRuntimeConfig {
1818 self.document_runtime.clone()
1819 }
1820
1821 pub fn set_slow_threshold(&mut self, threshold: Duration) {
1822 self.slow_threshold = threshold;
1823 }
1824
1825 #[must_use]
1826 pub fn take_timings(&self) -> Vec<ExecutionTiming> {
1827 std::mem::take(&mut *self.timings.borrow_mut())
1828 }
1829
1830 fn record_timing(
1831 &self,
1832 operation: ExecutionOperation,
1833 source: &str,
1834 started: ExecutionTimingStart,
1835 succeeded: bool,
1836 ) {
1837 let duration = started.instant.elapsed();
1838 let operations = self.operation_total().saturating_sub(started.operations);
1839 self.timings.borrow_mut().push(ExecutionTiming {
1840 operation,
1841 source: source.to_owned(),
1842 duration,
1843 operations,
1844 operation_semantics: OPERATION_SEMANTICS_VERSION,
1845 slow: duration >= self.slow_threshold,
1846 succeeded,
1847 });
1848 }
1849
1850 pub(crate) fn begin_execution_session(&self) {
1851 self.begin_execution_session_from(0);
1852 }
1853
1854 fn begin_execution_session_from(&self, inherited_base: u64) {
1855 self.operation_tracker.begin(inherited_base);
1856 }
1857
1858 fn align_execution_session_to(&self, inherited_base: u64) {
1859 self.operation_tracker.align(inherited_base);
1860 }
1861
1862 fn operation_total(&self) -> u64 {
1863 self.operation_tracker.total.get()
1864 }
1865
1866 fn begin_timing(&self) -> ExecutionTimingStart {
1867 ExecutionTimingStart {
1868 instant: Instant::now(),
1869 operations: self.operation_total(),
1870 }
1871 }
1872
1873 fn candidate_generation(&mut self) -> ScriptGeneration {
1874 self.preparation_generation
1875 .unwrap_or_else(Self::allocate_generation)
1876 }
1877
1878 fn allocate_generation() -> ScriptGeneration {
1879 static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
1880 ScriptGeneration(NEXT_GENERATION.fetch_add(1, Ordering::Relaxed))
1881 }
1882}
1883
1884pub(crate) fn validate_assignment_targets(ast: &AST) -> Result<(), RuntimeError> {
1885 let mut invalid = None;
1886 ast.walk(&mut |path| {
1887 let Some(ASTNode::Stmt(Stmt::Assignment(assignment))) = path.last() else {
1888 return true;
1889 };
1890 if !matches!(
1891 assignment.1.lhs,
1892 Expr::ThisPtr(_) | Expr::Variable(..) | Expr::Index(..) | Expr::Dot(..)
1893 ) {
1894 invalid = Some(assignment.1.lhs.position());
1895 return false;
1896 }
1897 true
1898 });
1899 invalid.map_or(Ok(()), |position| {
1900 Err(RuntimeError::InvalidAssignmentTarget(position))
1901 })
1902}
1903
1904fn validate_literal_imports(ast: &AST) -> Result<(), RuntimeError> {
1905 let mut dynamic = None;
1906 ast.walk(&mut |path| {
1907 let Some(ASTNode::Stmt(Stmt::Import(import, position))) = path.last() else {
1908 return true;
1909 };
1910 if matches!(import.0, Expr::StringConstant(..)) {
1911 true
1912 } else {
1913 dynamic = Some(*position);
1914 false
1915 }
1916 });
1917 dynamic.map_or(Ok(()), |position| {
1918 Err(RuntimeError::Import(format!(
1919 "Rhai imports must use a literal module string at {position}"
1920 )))
1921 })
1922}
1923
1924fn register_node_apis(engine: &mut Engine) {
1925 FuncRegistration::new("text")
1926 .in_global_namespace()
1927 .register_into_engine(engine, text_node);
1928 FuncRegistration::new("text")
1929 .in_global_namespace()
1930 .register_into_engine(engine, rich_text_node);
1931 FuncRegistration::new("span")
1932 .in_global_namespace()
1933 .register_into_engine(engine, span_value);
1934 FuncRegistration::new("canvas")
1935 .in_global_namespace()
1936 .register_into_engine(engine, canvas_node);
1937 FuncRegistration::new("svg")
1938 .in_global_namespace()
1939 .register_into_engine(engine, svg_node);
1940 FuncRegistration::new("box")
1941 .in_global_namespace()
1942 .register_into_engine(engine, box_node);
1943 FuncRegistration::new("fragment")
1944 .in_global_namespace()
1945 .register_into_engine(engine, fragment_node);
1946 FuncRegistration::new("stack")
1947 .in_global_namespace()
1948 .register_into_engine(engine, stack_node);
1949 FuncRegistration::new("handled")
1950 .in_global_namespace()
1951 .register_into_engine(engine, || crate::EventResponse::new().stop());
1952 FuncRegistration::new("propagate")
1953 .in_global_namespace()
1954 .register_into_engine(engine, crate::EventResponse::new);
1955 FuncRegistration::new("event_response")
1956 .in_global_namespace()
1957 .register_into_engine(engine, crate::EventResponse::new);
1958 FuncRegistration::new("column")
1959 .in_global_namespace()
1960 .register_into_engine(engine, column_node);
1961 FuncRegistration::new("row")
1962 .in_global_namespace()
1963 .register_into_engine(engine, row_node);
1964 FuncRegistration::new("error_boundary")
1965 .in_global_namespace()
1966 .register_into_engine(engine, error_boundary_node);
1967 FuncRegistration::new("error_boundary_lazy")
1968 .in_global_namespace()
1969 .register_into_engine(engine, lazy_error_boundary_node);
1970 FuncRegistration::new("asset")
1971 .in_global_namespace()
1972 .register_into_engine(engine, asset_id_from_script);
1973 FuncRegistration::new("image")
1974 .in_global_namespace()
1975 .register_into_engine(engine, image_node);
1976 FuncRegistration::new("image")
1977 .in_global_namespace()
1978 .register_into_engine(engine, asset_image_node);
1979 FuncRegistration::new("directional_image")
1980 .in_global_namespace()
1981 .register_into_engine(engine, directional_image_node);
1982 FuncRegistration::new("directional_image")
1983 .in_global_namespace()
1984 .register_into_engine(engine, directional_asset_image_node);
1985 FuncRegistration::new("image_source")
1986 .in_global_namespace()
1987 .register_into_engine(engine, generic_image_node);
1988 FuncRegistration::new("directional_image_source")
1989 .in_global_namespace()
1990 .register_into_engine(engine, generic_directional_image_node);
1991 FuncRegistration::new("overlay")
1992 .in_global_namespace()
1993 .register_into_engine(engine, overlay_node);
1994 FuncRegistration::new("layer")
1995 .in_global_namespace()
1996 .register_into_engine(engine, layer_node);
1997}
1998
1999fn register_native_handler_api(engine: &mut Engine, registry: &crate::NativeHandlerRegistry) {
2000 let registry = registry.clone();
2001 FuncRegistration::new("native_handler")
2002 .in_global_namespace()
2003 .register_into_engine(
2004 engine,
2005 move |id: ImmutableString| -> Result<crate::NativeHandlerRef, Box<EvalAltResult>> {
2006 let id = crate::NativeHandlerId::parse(id.to_string())
2007 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2008 registry
2009 .resolve(&id)
2010 .map_err(|error| Box::new(component_render_error(error.to_string())))
2011 },
2012 );
2013}
2014
2015fn configure_engine_limits(engine: &mut Engine) {
2016 engine.set_max_call_levels(64);
2017 engine.set_max_expr_depths(64, 32);
2018 engine.set_max_operations(0);
2022 engine.set_max_array_size(10_000);
2023 engine.set_max_map_size(100_000);
2024 engine.set_max_string_size(1_048_576);
2025}
2026
2027fn register_define_component_api(
2028 engine: &mut Engine,
2029 exports: &ComponentExportCollector,
2030 renderers: &ComponentRenderRegistry,
2031 generation: &Rc<Cell<ScriptGeneration>>,
2032) {
2033 let exports = exports.clone();
2034 let renderers = Rc::clone(renderers);
2035 let generation = Rc::clone(generation);
2036 FuncRegistration::new("define_component")
2037 .in_global_namespace()
2038 .register_into_engine(
2039 engine,
2040 move |mut raw: Map| -> Result<(), Box<EvalAltResult>> {
2041 let render = raw.remove("render").ok_or_else(|| {
2042 Box::new(component_render_error(
2043 "define_component requires a named `render` function",
2044 ))
2045 })?;
2046 if !render.is::<FnPtr>() {
2047 return Err(Box::new(component_render_error(
2048 "define_component `render` must be a function pointer",
2049 )));
2050 }
2051 let render = render.cast::<FnPtr>();
2052 if render.is_anonymous() || render.is_curried() {
2053 return Err(Box::new(component_render_error(
2054 "formal component render must be an uncurried named function",
2055 )));
2056 }
2057 let decoded: crate::ComponentDefinition =
2058 rhai::serde::from_dynamic(&Dynamic::from_map(raw))?;
2059 let definition = crate::ComponentDefinition::new(decoded.metadata, decoded.schema)
2060 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2061 let id = definition.metadata.id.clone();
2062 exports
2063 .register_definition(definition)
2064 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2065 renderers
2066 .try_borrow_mut()
2067 .map_err(|_| {
2068 Box::new(component_render_error(
2069 "component render registry is already borrowed",
2070 ))
2071 })?
2072 .insert(
2073 id,
2074 RegisteredComponentRender {
2075 generation: generation.get(),
2076 render,
2077 },
2078 );
2079 Ok(())
2080 },
2081 );
2082}
2083
2084fn register_component_runtime_apis(
2085 engine: &mut Engine,
2086 exports: &ComponentExportCollector,
2087 generation: &Rc<Cell<ScriptGeneration>>,
2088) -> (ActiveComponentRenderState, ComponentRenderRegistry) {
2089 let active = Rc::new(RefCell::new(None));
2090 let renderers = Rc::new(RefCell::new(BTreeMap::new()));
2091 register_define_component_api(engine, exports, &renderers, generation);
2092 register_render_component_api(engine, exports, &renderers, &active);
2093 register_effect_api(engine, &active);
2094 register_timer_api(engine, &active);
2095 register_signal_api(engine, &active);
2096 register_element_ref_api(engine, &active);
2097 register_virtual_collection_api(engine, &active);
2098 (active, renderers)
2099}
2100
2101fn register_render_component_api(
2102 engine: &mut Engine,
2103 exports: &ComponentExportCollector,
2104 renderers: &ComponentRenderRegistry,
2105 active: &ActiveComponentRenderState,
2106) {
2107 let exports = exports.clone();
2108 let renderers = Rc::clone(renderers);
2109 let active = Rc::clone(active);
2110 FuncRegistration::new("render_component")
2111 .in_global_namespace()
2112 .register_into_engine(
2113 engine,
2114 move |call: rhai::NativeCallContext<'_>,
2115 id: ImmutableString,
2116 props: Map|
2117 -> Result<UiNode, Box<EvalAltResult>> {
2118 let id = ModuleId::parse(id.to_string())
2119 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2120 let generation = active
2121 .try_borrow()
2122 .map_err(|_| {
2123 Box::new(component_render_error(
2124 "component render stack is already borrowed",
2125 ))
2126 })?
2127 .as_ref()
2128 .ok_or_else(|| {
2129 Box::new(component_render_error(
2130 "render_component may run only inside view",
2131 ))
2132 })?
2133 .generation;
2134 let registered = renderers
2135 .try_borrow()
2136 .map_err(|_| {
2137 Box::new(component_render_error(
2138 "component render registry is already borrowed",
2139 ))
2140 })?
2141 .get(&id)
2142 .cloned()
2143 .ok_or_else(|| {
2144 Box::new(component_render_error(format!(
2145 "component `{id}` has no registered render function"
2146 )))
2147 })?;
2148 if registered.generation != generation {
2149 return Err(Box::new(component_render_error(format!(
2150 "component `{id}` render belongs to stale generation {}",
2151 registered.generation
2152 ))));
2153 }
2154 execute_component_render(
2155 &call,
2156 &exports,
2157 &active,
2158 id.as_str().into(),
2159 props,
2160 ®istered.render,
2161 )
2162 },
2163 );
2164}
2165
2166#[allow(clippy::too_many_lines)] fn execute_component_render(
2168 call: &rhai::NativeCallContext<'_>,
2169 exports: &ComponentExportCollector,
2170 active: &ActiveComponentRenderState,
2171 id: ImmutableString,
2172 props: Map,
2173 render: &FnPtr,
2174) -> Result<UiNode, Box<EvalAltResult>> {
2175 let generation = active
2176 .try_borrow()
2177 .map_err(|_| {
2178 Box::new(component_render_error(
2179 "component render stack is already borrowed",
2180 ))
2181 })?
2182 .as_ref()
2183 .ok_or_else(|| {
2184 Box::new(component_render_error(
2185 "formal component rendering may run only inside view",
2186 ))
2187 })?
2188 .generation;
2189 let (component, mut invocation) = resolve_component_invocation(exports, id, props, generation)?;
2190 if render.is_anonymous() || render.is_curried() {
2191 return Err(Box::new(component_render_error(
2192 "formal component render must be an uncurried named function",
2193 )));
2194 }
2195 let part_styles = component_part_styles(&invocation.props);
2196 let recipe_props = invocation.retained_props.clone();
2197 let declared_key = invocation.key.clone();
2198 let render_recipe = render.clone();
2199 let (path, caller_context) = reserve_component_path(call, &component, &invocation, active)?;
2200 if let Some(node) = try_reuse_component_subtree(
2201 active,
2202 &path,
2203 &caller_context,
2204 &component,
2205 &invocation,
2206 render,
2207 )? {
2208 return Ok(node);
2209 }
2210 let native_context = crate::invocation::ScriptInvocationContext::capture(call);
2211 let caller_binding = caller_component_binding(call, &caller_context);
2212 bind_component_callback_props(
2213 &mut invocation.props,
2214 &component.schema.props,
2215 &caller_binding,
2216 );
2217 bind_component_node_props(
2218 &mut invocation.props,
2219 &component.schema.props,
2220 &caller_binding,
2221 );
2222 let event_callbacks = component_event_callbacks(&component, &invocation.props);
2223 let recipe_event_callbacks = event_callbacks.clone();
2224 let script_props = invocation.props.clone();
2225 let context = enter_component_render(
2226 &path,
2227 &component,
2228 &invocation,
2229 native_context.clone(),
2230 active,
2231 )?;
2232 let recipe_component_context = context.clone();
2233 let result = render.call_within_context::<UiNode>(call, (context, invocation.props));
2234 register_component_event_callbacks(
2235 active,
2236 &path,
2237 &caller_context,
2238 event_callbacks,
2239 &native_context,
2240 )?;
2241 let (environment, reusable) = component_render_metadata(active, &recipe_component_context)?;
2242 leave_component_render(active)?;
2243 let mut node = result?;
2244 node = node.with_owned_part_styles(part_styles.clone());
2245 let snapshot = crate::node::ComponentOwnedSnapshot::default();
2246 node = node.with_component_root_snapshot(path.clone(), snapshot.reference());
2247 node.bind_component_scope(
2248 &path,
2249 recipe_component_context.component_incarnation(),
2250 &component.schema.events,
2251 Some(&native_context),
2252 );
2253 register_component_invocation(
2254 active,
2255 ComponentInvocationRecipe {
2256 path: path.clone(),
2257 parent: caller_context.component_path().clone(),
2258 component: component.metadata.id.clone(),
2259 export: component.metadata.export.clone(),
2260 declared_key,
2261 props: recipe_props,
2262 script_props,
2263 component_context: recipe_component_context,
2264 caller_context: caller_context.clone(),
2265 part_styles,
2266 event_callbacks: recipe_event_callbacks,
2267 component_events: component.schema.events.clone(),
2268 declared_effects: component.schema.effects.clone(),
2269 render: render_recipe,
2270 snapshot,
2271 context: native_context.clone(),
2272 generation,
2273 environment,
2274 reusable,
2275 },
2276 )?;
2277 Ok(node)
2278}
2279
2280fn caller_component_binding(
2281 call: &rhai::NativeCallContext<'_>,
2282 caller_context: &UiContext,
2283) -> ComponentCallbackBinding {
2284 ComponentCallbackBinding {
2285 component: caller_context.component_path().clone(),
2286 incarnation: caller_context.component_incarnation(),
2287 events: caller_context.event_schemas().clone(),
2288 context: Some(
2289 caller_context
2290 .native_context()
2291 .cloned()
2292 .unwrap_or_else(|| crate::invocation::ScriptInvocationContext::capture_entry(call)),
2293 ),
2294 }
2295}
2296
2297fn component_render_metadata(
2298 active: &ActiveComponentRenderState,
2299 context: &UiContext,
2300) -> Result<(ComponentRenderEnvironment, bool), Box<EvalAltResult>> {
2301 let guard = active.try_borrow().map_err(|_| {
2302 Box::new(component_render_error(
2303 "component render stack is already borrowed",
2304 ))
2305 })?;
2306 let render = guard.as_ref().ok_or_else(|| {
2307 Box::new(component_render_error(
2308 "formal component rendering may run only inside view",
2309 ))
2310 })?;
2311 Ok((render.environment, context.component_render_is_reusable()))
2312}
2313
2314fn try_reuse_component_subtree(
2315 shared: &ActiveComponentRenderState,
2316 path: &ComponentInstancePath,
2317 caller_context: &UiContext,
2318 component: &crate::ComponentDefinition,
2319 invocation: &crate::ComponentInvocation,
2320 render: &FnPtr,
2321) -> Result<Option<UiNode>, Box<EvalAltResult>> {
2322 let mut guard = shared.try_borrow_mut().map_err(|_| {
2323 Box::new(component_render_error(
2324 "component render stack is already borrowed",
2325 ))
2326 })?;
2327 let active = guard.as_mut().ok_or_else(|| {
2328 Box::new(component_render_error(
2329 "formal component rendering may run only inside view",
2330 ))
2331 })?;
2332 let Some(scope) =
2333 reusable_component_scope(active, path, caller_context, component, invocation, render)
2334 else {
2335 return Ok(None);
2336 };
2337
2338 let reused_components = scope.invocations.len();
2339 for candidate in scope.invocations.keys() {
2340 let retained = active.transaction.retain_existing(candidate);
2341 debug_assert!(
2342 retained,
2343 "reuse candidates were checked against the state snapshot"
2344 );
2345 }
2346 active.seen.extend(scope.invocations.keys().cloned());
2347 active.invocations.extend(scope.invocations);
2348 active.event_handlers.extend(scope.event_handlers);
2349 active.effects.extend(scope.resources.effects);
2350 active.timers.extend(scope.resources.timers);
2351 active.signals.extend(scope.resources.signals);
2352 active.element_refs.extend(scope.resources.element_refs);
2353 active
2354 .virtual_collections
2355 .extend(scope.resources.virtual_collections);
2356 active
2357 .reused
2358 .push((component.metadata.id.to_string(), reused_components));
2359 Ok(Some(scope.node))
2360}
2361
2362fn reusable_component_scope(
2363 active: &ActiveComponentRender,
2364 path: &ComponentInstancePath,
2365 caller_context: &UiContext,
2366 component: &crate::ComponentDefinition,
2367 invocation: &crate::ComponentInvocation,
2368 render: &FnPtr,
2369) -> Option<ReusedComponentScope> {
2370 let reuse = active.reuse.as_ref()?;
2371 if reuse.dirty.iter().any(|dirty| dirty.is_within(path)) {
2372 return None;
2373 }
2374 let recipe = reuse.invocations.get(path)?;
2375 if !recipe.reusable
2376 || recipe.generation != active.generation
2377 || recipe.environment != active.environment
2378 || recipe.parent != *caller_context.component_path()
2379 || recipe.component != component.metadata.id
2380 || recipe.export != component.metadata.export
2381 || recipe.declared_key != invocation.key
2382 || recipe.render.fn_name() != render.fn_name()
2383 || !recipe.props.reusable_eq(&invocation.retained_props)
2384 {
2385 return None;
2386 }
2387 let invocations = reuse
2388 .invocations
2389 .range(path.clone()..)
2390 .take_while(|(candidate, _)| candidate.is_within(path))
2391 .map(|(candidate, recipe)| (candidate.clone(), recipe.clone()))
2392 .collect::<BTreeMap<_, _>>();
2393 if invocations.is_empty()
2394 || invocations.values().any(|recipe| !recipe.reusable)
2395 || invocations
2396 .keys()
2397 .any(|candidate| !active.state_snapshot.contains_instance(candidate))
2398 {
2399 return None;
2400 }
2401 let node = if let Some(snapshot) = recipe.snapshot.current() {
2402 let mut node = snapshot.as_ref().clone();
2403 node.hydrate_component_subtrees();
2404 node
2405 } else {
2406 reuse.subtrees.get(&reuse.previous_root, path)?.clone()
2407 };
2408 Some(ReusedComponentScope {
2409 node,
2410 invocations,
2411 event_handlers: reuse
2412 .event_handlers
2413 .range((path.clone(), String::new())..)
2414 .take_while(|((candidate, _), _)| candidate.is_within(path))
2415 .map(|(id, callback)| (id.clone(), callback.clone()))
2416 .collect(),
2417 resources: reused_component_resources(reuse, path),
2418 })
2419}
2420
2421fn reused_component_resources(
2422 reuse: &ComponentReuseSnapshot,
2423 path: &ComponentInstancePath,
2424) -> ReusedComponentResources {
2425 ReusedComponentResources {
2426 effects: reuse
2427 .effects
2428 .iter()
2429 .filter(|(id, _)| id.component().is_within(path))
2430 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
2431 .collect(),
2432 timers: reuse
2433 .timers
2434 .iter()
2435 .filter(|(id, _)| id.component().is_within(path))
2436 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
2437 .collect(),
2438 signals: reuse
2439 .signals
2440 .iter()
2441 .filter(|(id, _)| id.component().is_within(path))
2442 .map(|(id, descriptor)| (id.clone(), descriptor.clone()))
2443 .collect(),
2444 element_refs: reuse
2445 .element_refs
2446 .iter()
2447 .filter(|id| id.component().is_within(path))
2448 .cloned()
2449 .collect(),
2450 virtual_collections: reuse
2451 .virtual_collections
2452 .iter()
2453 .filter(|(id, _)| id.component.is_within(path))
2454 .map(|(id, recipe)| (id.clone(), recipe.clone()))
2455 .collect(),
2456 }
2457}
2458
2459fn register_component_invocation(
2460 active: &ActiveComponentRenderState,
2461 recipe: ComponentInvocationRecipe,
2462) -> Result<(), Box<EvalAltResult>> {
2463 let mut guard = active.try_borrow_mut().map_err(|_| {
2464 Box::new(component_render_error(
2465 "component render stack is already borrowed",
2466 ))
2467 })?;
2468 let active = guard.as_mut().ok_or_else(|| {
2469 Box::new(component_render_error(
2470 "formal component rendering may run only inside view",
2471 ))
2472 })?;
2473 active.invocations.insert(recipe.path.clone(), recipe);
2474 Ok(())
2475}
2476
2477fn resolve_component_invocation(
2478 exports: &ComponentExportCollector,
2479 id: ImmutableString,
2480 props: Map,
2481 generation: ScriptGeneration,
2482) -> Result<(crate::ComponentDefinition, crate::ComponentInvocation), Box<EvalAltResult>> {
2483 let id: String = id.into();
2484 let id =
2485 ModuleId::parse(id).map_err(|error| Box::new(component_render_error(error.to_string())))?;
2486 let registry = exports
2487 .snapshot()
2488 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2489 let component = registry.get(&id).cloned().ok_or_else(|| {
2490 Box::new(component_render_error(format!(
2491 "component `{id}` is not exported"
2492 )))
2493 })?;
2494 let key = props.get("key").and_then(|value| {
2495 value
2496 .is::<ImmutableString>()
2497 .then(|| value.clone_cast::<ImmutableString>().to_string())
2498 });
2499 let invocation = component
2500 .invoke_with_generation(key, props, generation)
2501 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2502 Ok((component, invocation))
2503}
2504
2505fn reserve_component_path(
2506 call: &rhai::NativeCallContext<'_>,
2507 component: &crate::ComponentDefinition,
2508 invocation: &crate::ComponentInvocation,
2509 shared: &ActiveComponentRenderState,
2510) -> Result<(ComponentInstancePath, UiContext), Box<EvalAltResult>> {
2511 let mut guard = shared.try_borrow_mut().map_err(|_| {
2512 Box::new(component_render_error(
2513 "component render stack is already borrowed",
2514 ))
2515 })?;
2516 let active = guard.as_mut().ok_or_else(|| {
2517 Box::new(component_render_error(
2518 "formal component rendering may run only inside view",
2519 ))
2520 })?;
2521 let caller_context = active.contexts.last().cloned().ok_or_else(|| {
2522 Box::new(component_render_error(
2523 "component render context stack has no caller",
2524 ))
2525 })?;
2526 let parent = active.stack.last().cloned().ok_or_else(|| {
2527 Box::new(component_render_error(
2528 "component render stack has no application root",
2529 ))
2530 })?;
2531 let key = invocation.key.clone().unwrap_or_else(|| {
2532 let callsite = format!(
2533 "{}@{}",
2534 call.call_source().unwrap_or("<script>"),
2535 call.call_position()
2536 );
2537 let occurrence = active
2538 .positions
2539 .entry(format!("{parent}/{}/{callsite}", component.metadata.export))
2540 .or_default();
2541 let key = format!("{callsite}#{occurrence}");
2542 *occurrence = occurrence.saturating_add(1);
2543 key
2544 });
2545 let path = parent.child(&component.metadata.export, key);
2546 if !active.seen.insert(path.clone()) {
2547 return Err(Box::new(component_render_error(format!(
2548 "duplicate component instance path `{path}`; add a stable key"
2549 ))));
2550 }
2551 Ok((path, caller_context))
2552}
2553
2554fn enter_component_render(
2555 path: &ComponentInstancePath,
2556 component: &crate::ComponentDefinition,
2557 invocation: &crate::ComponentInvocation,
2558 native_context: crate::invocation::ScriptInvocationContext,
2559 shared: &ActiveComponentRenderState,
2560) -> Result<UiContext, Box<EvalAltResult>> {
2561 let mut guard = shared.try_borrow_mut().map_err(|_| {
2562 Box::new(component_render_error(
2563 "component render stack is already borrowed",
2564 ))
2565 })?;
2566 let active = guard.as_mut().ok_or_else(|| {
2567 Box::new(component_render_error(
2568 "formal component rendering may run only inside view",
2569 ))
2570 })?;
2571 active
2572 .transaction
2573 .mount(path.clone(), &component.schema.state)
2574 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2575 let global_part_styles = {
2576 let mut runtime = active
2577 .root_context
2578 .runtime()
2579 .try_borrow_mut()
2580 .map_err(|_| Box::new(component_render_error("UI state is already borrowed")))?;
2581 runtime
2582 .component_state
2583 .mount_instance(path.clone(), &component.schema.state)
2584 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2585 runtime
2586 .component_styles()
2587 .component(&component.metadata.id)
2588 .cloned()
2589 .unwrap_or_default()
2590 };
2591 let context = active
2592 .root_context
2593 .for_component(path.clone(), component.schema.events.clone())
2594 .with_component_styles(
2595 global_part_styles,
2596 component_root_style(&invocation.props),
2597 component_part_styles(&invocation.props),
2598 )
2599 .with_native_context(Some(native_context))
2600 .with_generation(active.generation);
2601 active.stack.push(path.clone());
2602 active.contexts.push(context.clone());
2603 active
2604 .effect_keys
2605 .push(Some(component.schema.effects.clone()));
2606 Ok(context)
2607}
2608
2609fn leave_component_render(active: &ActiveComponentRenderState) -> Result<(), Box<EvalAltResult>> {
2610 let mut guard = active.try_borrow_mut().map_err(|_| {
2611 Box::new(component_render_error(
2612 "component render stack is already borrowed",
2613 ))
2614 })?;
2615 if let Some(active) = guard.as_mut() {
2616 active.stack.pop();
2617 active.contexts.pop();
2618 active.effect_keys.pop();
2619 }
2620 Ok(())
2621}
2622
2623fn register_effect_api(engine: &mut Engine, active: &ActiveComponentRenderState) {
2624 let active = Rc::clone(active);
2625 FuncRegistration::new("effect")
2626 .in_global_namespace()
2627 .register_into_engine(
2628 engine,
2629 move |call: rhai::NativeCallContext<'_>,
2630 key: ImmutableString,
2631 dependencies: Dynamic,
2632 start: FnPtr,
2633 cleanup: FnPtr|
2634 -> Result<(), Box<EvalAltResult>> {
2635 let key = key.to_string();
2636 let (component, context, generation, declared) = {
2637 let guard = active.try_borrow().map_err(|_| {
2638 Box::new(component_render_error(
2639 "component render stack is already borrowed",
2640 ))
2641 })?;
2642 let render = guard.as_ref().ok_or_else(|| {
2643 Box::new(component_render_error(
2644 "effect may run only during formal component render",
2645 ))
2646 })?;
2647 let context = render.contexts.last().cloned().ok_or_else(|| {
2648 Box::new(component_render_error(
2649 "component render context stack is empty",
2650 ))
2651 })?;
2652 let declared =
2653 render
2654 .effect_keys
2655 .last()
2656 .cloned()
2657 .flatten()
2658 .ok_or_else(|| {
2659 Box::new(component_render_error(
2660 "effects may be declared only by formal components",
2661 ))
2662 })?;
2663 (
2664 context.component_path().clone(),
2665 context,
2666 render.generation,
2667 declared,
2668 )
2669 };
2670 if !declared.contains(&key) {
2671 return Err(Box::new(component_render_error(
2672 crate::EffectError::Undeclared { component, key }.to_string(),
2673 )));
2674 }
2675 let id = crate::EffectId::new(component.clone(), key.clone())
2676 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2677 let dependencies = UiValue::from_dynamic(dependencies)
2678 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2679 let native_context = crate::invocation::ScriptInvocationContext::capture(&call);
2680 let mut start = ScriptCallback::try_from_fn_ptr(start, generation)
2681 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2682 start.bind_component_scope_if_unset(
2683 &component,
2684 context.component_incarnation(),
2685 context.event_schemas().clone(),
2686 );
2687 start.bind_native_context_if_unset(native_context.clone());
2688 let mut cleanup = ScriptCallback::try_from_fn_ptr(cleanup, generation)
2689 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2690 cleanup.bind_component_scope_if_unset(
2691 &component,
2692 context.component_incarnation(),
2693 context.event_schemas().clone(),
2694 );
2695 cleanup.bind_native_context_if_unset(native_context);
2696 let descriptor =
2697 crate::EffectDescriptor::new(id.clone(), dependencies, start, cleanup);
2698 let mut guard = active.try_borrow_mut().map_err(|_| {
2699 Box::new(component_render_error(
2700 "component render stack is already borrowed",
2701 ))
2702 })?;
2703 let render = guard.as_mut().ok_or_else(|| {
2704 Box::new(component_render_error(
2705 "effect may run only during formal component render",
2706 ))
2707 })?;
2708 if render.effects.insert(id, descriptor).is_some() {
2709 return Err(Box::new(component_render_error(
2710 crate::EffectError::Duplicate { component, key }.to_string(),
2711 )));
2712 }
2713 Ok(())
2714 },
2715 );
2716}
2717
2718fn register_timer_api(engine: &mut Engine, active: &ActiveComponentRenderState) {
2719 let active = Rc::clone(active);
2720 FuncRegistration::new("timeout")
2721 .in_global_namespace()
2722 .register_into_engine(
2723 engine,
2724 move |call: rhai::NativeCallContext<'_>,
2725 key: ImmutableString,
2726 delay_ms: rhai::INT,
2727 paused: bool,
2728 callback: FnPtr,
2729 payload: Dynamic|
2730 -> Result<(), Box<EvalAltResult>> {
2731 let (component, context, generation) = {
2732 let guard = active.try_borrow().map_err(|_| {
2733 Box::new(component_render_error(
2734 "component render stack is already borrowed",
2735 ))
2736 })?;
2737 let render = guard.as_ref().ok_or_else(|| {
2738 Box::new(component_render_error(
2739 "timeout may run only during formal component render",
2740 ))
2741 })?;
2742 let context = render.contexts.last().cloned().ok_or_else(|| {
2743 Box::new(component_render_error(
2744 "component render context stack is empty",
2745 ))
2746 })?;
2747 (context.component_path().clone(), context, render.generation)
2748 };
2749 let delay_ms = u64::try_from(delay_ms).map_err(|_| {
2750 Box::new(component_render_error(
2751 "timeout delay must be a positive integer",
2752 ))
2753 })?;
2754 let id = crate::TimerId::new(component.clone(), key.to_string())
2755 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2756 let payload = UiValue::from_dynamic(payload)
2757 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2758 let native_context = crate::invocation::ScriptInvocationContext::capture(&call);
2759 let mut callback = ScriptCallback::try_from_fn_ptr(callback, generation)
2760 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2761 callback.bind_component_scope_if_unset(
2762 &component,
2763 context.component_incarnation(),
2764 context.event_schemas().clone(),
2765 );
2766 callback.bind_native_context_if_unset(native_context);
2767 let descriptor = crate::TimerDescriptor::new(
2768 id.clone(),
2769 Duration::from_millis(delay_ms),
2770 paused,
2771 callback,
2772 payload,
2773 )
2774 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2775 let mut guard = active.try_borrow_mut().map_err(|_| {
2776 Box::new(component_render_error(
2777 "component render stack is already borrowed",
2778 ))
2779 })?;
2780 let render = guard.as_mut().ok_or_else(|| {
2781 Box::new(component_render_error(
2782 "timeout may run only during formal component render",
2783 ))
2784 })?;
2785 if render.timers.insert(id.clone(), descriptor).is_some() {
2786 return Err(Box::new(component_render_error(format!(
2787 "timeout `{}` is declared more than once in `{}`",
2788 id.key(),
2789 id.component()
2790 ))));
2791 }
2792 Ok(())
2793 },
2794 );
2795}
2796
2797fn register_signal_api(engine: &mut Engine, active: &ActiveComponentRenderState) {
2798 let scalar_active = Rc::clone(active);
2799 FuncRegistration::new("signal")
2800 .in_global_namespace()
2801 .register_into_engine(
2802 engine,
2803 move |key: ImmutableString,
2804 initial: Dynamic|
2805 -> Result<crate::NativeSignal, Box<EvalAltResult>> {
2806 let key = key.to_string();
2807 let initial = crate::SignalValue::from_dynamic(initial)
2808 .map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))?;
2809 declare_signal(&scalar_active, key, initial)
2810 },
2811 );
2812
2813 let optional_active = Rc::clone(active);
2814 FuncRegistration::new("optional_float_signal")
2815 .in_global_namespace()
2816 .register_into_engine(
2817 engine,
2818 move |key: ImmutableString| -> Result<crate::NativeSignal, Box<EvalAltResult>> {
2819 declare_signal(
2820 &optional_active,
2821 key.to_string(),
2822 crate::SignalValue::OptionalFloat(None),
2823 )
2824 },
2825 );
2826}
2827
2828fn declare_signal(
2829 active: &ActiveComponentRenderState,
2830 key: String,
2831 initial: crate::SignalValue,
2832) -> Result<crate::NativeSignal, Box<EvalAltResult>> {
2833 let mut guard = active.try_borrow_mut().map_err(|_| {
2834 Box::new(crate::signal::signal_runtime_error(
2835 &"component render stack is already borrowed",
2836 ))
2837 })?;
2838 let render = guard.as_mut().ok_or_else(|| {
2839 Box::new(crate::signal::signal_runtime_error(
2840 &"signal may run only during formal component render",
2841 ))
2842 })?;
2843 if render.effect_keys.last().and_then(Option::as_ref).is_none() {
2844 return Err(Box::new(crate::signal::signal_runtime_error(
2845 &"signals may be declared only by formal components",
2846 )));
2847 }
2848 let context = render.contexts.last().ok_or_else(|| {
2849 Box::new(crate::signal::signal_runtime_error(
2850 &"component render context stack is empty",
2851 ))
2852 })?;
2853 let component = context.component_path().clone();
2854 let incarnation = context.component_incarnation();
2855 if render
2856 .signals
2857 .keys()
2858 .any(|id| id.component() == &component && id.key() == key)
2859 {
2860 return Err(Box::new(crate::signal::signal_runtime_error(
2861 &crate::SignalError::Duplicate { component, key },
2862 )));
2863 }
2864 let id = crate::SignalId::new_scoped(component, incarnation, key, initial.kind())
2865 .map_err(|error| Box::new(crate::signal::signal_runtime_error(&error)))?;
2866 let signal = crate::NativeSignal::new(id.clone());
2867 render
2868 .signals
2869 .insert(id, crate::signal::SignalDescriptor::new(initial));
2870 Ok(signal)
2871}
2872
2873fn register_element_ref_api(engine: &mut Engine, active: &ActiveComponentRenderState) {
2874 let active = Rc::clone(active);
2875 FuncRegistration::new("element_ref")
2876 .in_global_namespace()
2877 .register_into_engine(
2878 engine,
2879 move |key: ImmutableString| -> Result<crate::ElementRef, Box<EvalAltResult>> {
2880 let key = key.to_string();
2881 let mut guard = active.try_borrow_mut().map_err(|_| {
2882 Box::new(component_render_error(
2883 "component render stack is already borrowed",
2884 ))
2885 })?;
2886 let render = guard.as_mut().ok_or_else(|| {
2887 Box::new(component_render_error(
2888 "element_ref may run only during formal component render",
2889 ))
2890 })?;
2891 if render.effect_keys.last().and_then(Option::as_ref).is_none() {
2892 return Err(Box::new(component_render_error(
2893 "element refs may be declared only by formal components",
2894 )));
2895 }
2896 let component = render
2897 .contexts
2898 .last()
2899 .ok_or_else(|| {
2900 Box::new(component_render_error(
2901 "component render context stack is empty",
2902 ))
2903 })?
2904 .component_path()
2905 .clone();
2906 let id = crate::ElementRefId::new(component.clone(), key.clone())
2907 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
2908 if !render.element_refs.insert(id.clone()) {
2909 return Err(Box::new(component_render_error(format!(
2910 "element ref `{key}` is declared more than once by component `{component}`"
2911 ))));
2912 }
2913 Ok(crate::ElementRef::new(id))
2914 },
2915 );
2916}
2917
2918#[derive(Debug)]
2919struct DecodedVirtualCollection {
2920 key: String,
2921 label: String,
2922 data: crate::VirtualCollectionData,
2923 estimated_height: f64,
2924 height: Option<f64>,
2925 overdraw_pixels: f64,
2926 bottom_align: bool,
2927 follow_tail: bool,
2928 reveal_key: Option<String>,
2929 sticky_headers: Arc<BTreeSet<usize>>,
2930}
2931
2932impl DecodedVirtualCollection {
2933 fn into_spec(
2934 self,
2935 id: crate::VirtualCollectionId,
2936 realized: BTreeMap<usize, UiNode>,
2937 ) -> crate::VirtualCollectionNodeSpec {
2938 crate::VirtualCollectionNodeSpec {
2939 id,
2940 label: self.label,
2941 data: self.data,
2942 realized,
2943 estimated_height: self.estimated_height,
2944 height: self.height,
2945 overdraw_pixels: self.overdraw_pixels,
2946 bottom_align: self.bottom_align,
2947 follow_tail: self.follow_tail,
2948 reveal_key: self.reveal_key,
2949 sticky_headers: self.sticky_headers,
2950 }
2951 }
2952}
2953
2954fn decode_virtual_collection(
2955 mut config: Map,
2956) -> Result<DecodedVirtualCollection, Box<EvalAltResult>> {
2957 let key = collection_string(&mut config, "key")?;
2958 let label = collection_optional_string(&mut config, "label")?.unwrap_or_default();
2959 let estimated_height = collection_positive(&mut config, "estimated_height")?;
2960 let height = collection_optional_number(&mut config, "height")?;
2961 let fill_height = collection_optional_bool(&mut config, "fill_height")?.unwrap_or(false);
2962 let height = match (height, fill_height) {
2963 (Some(_), true) => {
2964 return Err(Box::new(component_render_error(
2965 "virtual collection must specify either `height` or `fill_height`, not both",
2966 )));
2967 }
2968 (None, false) => {
2969 return Err(Box::new(component_render_error(
2970 "virtual collection requires `height` or `fill_height: true`",
2971 )));
2972 }
2973 (None, true) => None,
2974 (Some(height), false) if height.is_finite() && height > 0.0 => Some(height),
2975 (Some(_), false) => {
2976 return Err(Box::new(component_render_error(
2977 "virtual collection `height` must be finite and positive",
2978 )));
2979 }
2980 };
2981 let overdraw_pixels = collection_optional_number(&mut config, "overdraw_pixels")?
2982 .unwrap_or(estimated_height * 2.0);
2983 if !overdraw_pixels.is_finite() || !(0.0..=10_000.0).contains(&overdraw_pixels) {
2984 return Err(Box::new(component_render_error(
2985 "virtual collection overdraw_pixels must be between 0 and 10000",
2986 )));
2987 }
2988 let alignment =
2989 collection_optional_string(&mut config, "alignment")?.unwrap_or_else(|| "top".to_owned());
2990 let bottom_align = match alignment.as_str() {
2991 "top" => false,
2992 "bottom" => true,
2993 _ => {
2994 return Err(Box::new(component_render_error(
2995 "virtual collection alignment must be `top` or `bottom`",
2996 )));
2997 }
2998 };
2999 let follow_tail = collection_optional_bool(&mut config, "follow_tail")?.unwrap_or(false);
3000 let data = collection_data(&mut config)?;
3001 let reveal_key = collection_optional_string(&mut config, "reveal_key")?;
3002 if reveal_key.as_deref() == Some("") {
3003 return Err(Box::new(component_render_error(
3004 "virtual collection `reveal_key` must not be empty",
3005 )));
3006 }
3007 if let Some(reveal_key) = reveal_key.as_deref()
3008 && !(0..data.len()).any(|index| data.key(index) == Some(reveal_key))
3009 {
3010 return Err(Box::new(component_render_error(format!(
3011 "virtual collection reveal key `{reveal_key}` is not present in its data"
3012 ))));
3013 }
3014 if follow_tail && reveal_key.is_some() {
3015 return Err(Box::new(component_render_error(
3016 "virtual collection must not combine `follow_tail` with `reveal_key`",
3017 )));
3018 }
3019 let sticky_headers = collection_optional_indices(&mut config, "sticky_headers")?
3020 .map_or_else(|| data.sticky_headers(), Arc::new);
3021 if let Some(index) = sticky_headers.iter().find(|index| **index >= data.len()) {
3022 return Err(Box::new(component_render_error(format!(
3023 "virtual collection sticky header index {index} is outside {} items",
3024 data.len()
3025 ))));
3026 }
3027 if bottom_align && !sticky_headers.is_empty() {
3028 return Err(Box::new(component_render_error(
3029 "sticky headers require top-aligned virtual collections",
3030 )));
3031 }
3032 if let Some((unknown, _)) = config.into_iter().next() {
3033 return Err(Box::new(component_render_error(format!(
3034 "unknown virtual collection field `{unknown}`"
3035 ))));
3036 }
3037 Ok(DecodedVirtualCollection {
3038 key,
3039 label,
3040 data,
3041 estimated_height,
3042 height,
3043 overdraw_pixels,
3044 bottom_align,
3045 follow_tail,
3046 reveal_key,
3047 sticky_headers,
3048 })
3049}
3050
3051fn register_virtual_collection_api(engine: &mut Engine, active: &ActiveComponentRenderState) {
3052 let active = Rc::clone(active);
3053 FuncRegistration::new("virtual_collection")
3054 .in_global_namespace()
3055 .register_into_engine(
3056 engine,
3057 move |call: rhai::NativeCallContext<'_>,
3058 config: Map,
3059 renderer: FnPtr|
3060 -> Result<UiNode, Box<EvalAltResult>> {
3061 validate_virtual_renderer(&renderer)?;
3062 let decoded = decode_virtual_collection(config)?;
3063
3064 let VirtualCollectionContext {
3065 component,
3066 context,
3067 generation,
3068 events,
3069 } = virtual_collection_context(&active)?;
3070 let id = crate::VirtualCollectionId {
3071 component: component.clone(),
3072 key: decoded.key.clone(),
3073 };
3074 let collection_context = context
3075 .for_component(
3076 component.child("VirtualCollection", decoded.key.clone()),
3077 BTreeMap::new(),
3078 )
3079 .with_generation(generation);
3080 let native_context = crate::invocation::ScriptInvocationContext::capture(&call);
3081 let realized = realize_seeded_virtual_collection(
3082 &call,
3083 &renderer,
3084 &collection_context,
3085 &active,
3086 &id,
3087 &decoded,
3088 )?;
3089 let recipe = VirtualCollectionRecipe {
3090 id: id.clone(),
3091 data: decoded.data.clone(),
3092 renderer,
3093 renderer_events: events,
3094 renderer_context: native_context,
3095 context: collection_context,
3096 event_context: context,
3097 generation,
3098 };
3099 let mut guard = active.try_borrow_mut().map_err(|_| {
3100 Box::new(component_render_error(
3101 "component render stack is already borrowed",
3102 ))
3103 })?;
3104 let render = guard.as_mut().ok_or_else(|| {
3105 Box::new(component_render_error(
3106 "virtual_collection may run only during view render",
3107 ))
3108 })?;
3109 if render
3110 .virtual_collections
3111 .insert(id.clone(), recipe)
3112 .is_some()
3113 {
3114 return Err(Box::new(component_render_error(format!(
3115 "virtual collection `{}` is declared more than once in `{}`",
3116 decoded.key, id.component
3117 ))));
3118 }
3119 Ok(UiNode::virtual_collection(decoded.into_spec(id, realized)))
3120 },
3121 );
3122}
3123
3124fn validate_virtual_renderer(renderer: &FnPtr) -> Result<(), Box<EvalAltResult>> {
3125 if renderer.is_anonymous() {
3126 return Err(Box::new(component_render_error(
3127 "virtual collection item renderer must be a named function",
3128 )));
3129 }
3130 for (index, value) in renderer.iter_curry().enumerate() {
3131 validate_virtual_renderer_curry(value).map_err(|error| {
3132 Box::new(component_render_error(format!(
3133 "virtual collection renderer curry {index} is not durable presentation data: {error}"
3134 )))
3135 })?;
3136 }
3137 Ok(())
3138}
3139
3140fn validate_virtual_renderer_curry(value: &Dynamic) -> Result<(), crate::UiValueError> {
3141 if value.is::<UiNode>() || value.is::<crate::NativeSignal>() {
3142 return Ok(());
3143 }
3144 if value.is::<Array>() {
3145 for value in value.clone_cast::<Array>() {
3146 validate_virtual_renderer_curry(&value)?;
3147 }
3148 return Ok(());
3149 }
3150 if value.is::<Map>() {
3151 for value in value.clone_cast::<Map>().values() {
3152 validate_virtual_renderer_curry(value)?;
3153 }
3154 return Ok(());
3155 }
3156 UiValue::from_dynamic(value.clone()).map(|_| ())
3157}
3158
3159fn realize_initial_collection(
3160 call: &rhai::NativeCallContext<'_>,
3161 renderer: &FnPtr,
3162 context: &UiContext,
3163 data: &crate::VirtualCollectionData,
3164 indices: &BTreeSet<usize>,
3165) -> Result<BTreeMap<usize, UiNode>, Box<EvalAltResult>> {
3166 let mut realized = BTreeMap::new();
3167 for index in indices.iter().copied().filter(|index| *index < data.len()) {
3168 let item = data
3169 .item(index)
3170 .map_err(|error| Box::new(component_render_error(error.to_string())))?
3171 .ok_or_else(|| {
3172 Box::new(component_render_error(format!(
3173 "virtual collection item {index} disappeared"
3174 )))
3175 })?;
3176 let (item_key, payload) = collection_payload(&item, index)?;
3177 let node = renderer
3178 .call_within_context::<UiNode>(call, (context.clone(), payload))?
3179 .with_key(item_key);
3180 realized.insert(index, node);
3181 }
3182 Ok(realized)
3183}
3184
3185fn realize_seeded_virtual_collection(
3186 call: &rhai::NativeCallContext<'_>,
3187 renderer: &FnPtr,
3188 context: &UiContext,
3189 active: &ActiveComponentRenderState,
3190 id: &crate::VirtualCollectionId,
3191 decoded: &DecodedVirtualCollection,
3192) -> Result<BTreeMap<usize, UiNode>, Box<EvalAltResult>> {
3193 let previous_metrics = context
3194 .runtime()
3195 .try_borrow()
3196 .map_err(|_| {
3197 Box::new(component_render_error(
3198 "UI state is already borrowed while seeding a virtual collection",
3199 ))
3200 })?
3201 .virtual_requests
3202 .metrics(id);
3203 let viewport = decoded.height.unwrap_or_else(|| {
3204 previous_metrics
3205 .as_ref()
3206 .map_or(decoded.estimated_height, |metrics| {
3207 if metrics.viewport_height > 0.0 {
3208 metrics.viewport_height
3209 } else {
3210 decoded.estimated_height
3211 }
3212 })
3213 });
3214 let count = nonnegative_usize(
3215 ((viewport + decoded.overdraw_pixels) / decoded.estimated_height).ceil() + 1.0,
3216 )
3217 .min(decoded.data.len());
3218 let indices =
3219 virtual_collection_seed_indices(active, id, decoded, count, previous_metrics.as_ref())?;
3220 enter_virtual_collection_scope(active, context.clone())?;
3221 let realized = realize_initial_collection(call, renderer, context, &decoded.data, &indices);
3222 leave_component_render(active)?;
3223 realized
3224}
3225
3226fn virtual_collection_seed_indices(
3227 active: &ActiveComponentRenderState,
3228 id: &crate::VirtualCollectionId,
3229 decoded: &DecodedVirtualCollection,
3230 count: usize,
3231 metrics: Option<&crate::VirtualCollectionMetrics>,
3232) -> Result<BTreeSet<usize>, Box<EvalAltResult>> {
3233 let mut indices = BTreeSet::new();
3234 let guard = active.try_borrow().map_err(|_| {
3235 Box::new(component_render_error(
3236 "component render stack is already borrowed",
3237 ))
3238 })?;
3239 let reuse = guard.as_ref().and_then(|render| render.reuse.as_ref());
3240 let previous = reuse.and_then(|reuse| reuse.previous_root.virtual_collection_spec(id));
3241 let preserves_scroll = previous.is_some_and(|previous| {
3242 previous.estimated_height.to_bits() == decoded.estimated_height.to_bits()
3243 && previous.overdraw_pixels.to_bits() == decoded.overdraw_pixels.to_bits()
3244 && previous.bottom_align == decoded.bottom_align
3245 && same_collection_key_order(&previous.data, &decoded.data)
3246 });
3247
3248 if preserves_scroll {
3249 if let Some(previous) = previous {
3250 indices.extend(previous.realized.keys().copied());
3251 }
3252 if let Some(metrics) = metrics {
3253 extend_virtual_window(
3254 &mut indices,
3255 metrics.scroll_item,
3256 count,
3257 decoded.data.len(),
3258 virtual_overdraw_items(decoded),
3259 );
3260 }
3261 } else if decoded.reveal_key.is_none() && !decoded.bottom_align && !decoded.follow_tail {
3262 extend_virtual_window(&mut indices, 0, count, decoded.data.len(), 0);
3263 }
3264 drop(guard);
3265
3266 if let Some(reveal) = decoded.reveal_key.as_deref()
3267 && let Some(index) =
3268 (0..decoded.data.len()).find(|index| decoded.data.key(*index) == Some(reveal))
3269 {
3270 extend_virtual_window(
3271 &mut indices,
3272 index,
3273 count,
3274 decoded.data.len(),
3275 virtual_overdraw_items(decoded),
3276 );
3277 if let Some(header) = decoded.sticky_headers.range(..=index).next_back() {
3278 indices.insert(*header);
3279 }
3280 }
3281 if (decoded.bottom_align || decoded.follow_tail) && !decoded.data.is_empty() {
3282 let start = decoded.data.len().saturating_sub(count);
3283 indices.extend(start..decoded.data.len());
3284 }
3285 Ok(indices)
3286}
3287
3288fn same_collection_key_order(
3289 current: &crate::VirtualCollectionData,
3290 next: &crate::VirtualCollectionData,
3291) -> bool {
3292 current.len() == next.len()
3293 && (0..current.len()).all(|index| current.key(index) == next.key(index))
3294}
3295
3296fn virtual_overdraw_items(decoded: &DecodedVirtualCollection) -> usize {
3297 nonnegative_usize((decoded.overdraw_pixels / decoded.estimated_height).ceil())
3298}
3299
3300fn extend_virtual_window(
3301 indices: &mut BTreeSet<usize>,
3302 anchor: usize,
3303 count: usize,
3304 len: usize,
3305 leading: usize,
3306) {
3307 let start = anchor.saturating_sub(leading).min(len);
3308 let end = anchor.saturating_add(count).min(len);
3309 indices.extend(start..end);
3310}
3311
3312struct VirtualCollectionContext {
3313 component: ComponentInstancePath,
3314 context: UiContext,
3315 generation: ScriptGeneration,
3316 events: BTreeMap<String, EventSchema>,
3317}
3318
3319fn enter_virtual_collection_scope(
3320 active: &ActiveComponentRenderState,
3321 context: UiContext,
3322) -> Result<(), Box<EvalAltResult>> {
3323 let mut guard = active.try_borrow_mut().map_err(|_| {
3324 Box::new(component_render_error(
3325 "component render stack is already borrowed",
3326 ))
3327 })?;
3328 let render = guard.as_mut().ok_or_else(|| {
3329 Box::new(component_render_error(
3330 "virtual_collection may run only during view render",
3331 ))
3332 })?;
3333 render.stack.push(context.component_path().clone());
3334 render.contexts.push(context);
3335 render.effect_keys.push(None);
3336 Ok(())
3337}
3338
3339fn virtual_collection_context(
3340 active: &ActiveComponentRenderState,
3341) -> Result<VirtualCollectionContext, Box<EvalAltResult>> {
3342 let guard = active.try_borrow().map_err(|_| {
3343 Box::new(component_render_error(
3344 "component render stack is already borrowed",
3345 ))
3346 })?;
3347 let render = guard.as_ref().ok_or_else(|| {
3348 Box::new(component_render_error(
3349 "virtual_collection may run only during view render",
3350 ))
3351 })?;
3352 let context = render.contexts.last().cloned().ok_or_else(|| {
3353 Box::new(component_render_error(
3354 "component render context stack is empty",
3355 ))
3356 })?;
3357 Ok(VirtualCollectionContext {
3358 component: context.component_path().clone(),
3359 context: context.clone(),
3360 generation: render.generation,
3361 events: context.event_schemas().clone(),
3362 })
3363}
3364
3365fn collection_string(config: &mut Map, name: &str) -> Result<String, Box<EvalAltResult>> {
3366 config
3367 .remove(name)
3368 .and_then(Dynamic::try_cast::<ImmutableString>)
3369 .map(|value| value.to_string())
3370 .ok_or_else(|| {
3371 Box::new(component_render_error(format!(
3372 "virtual collection `{name}` must be a string"
3373 )))
3374 })
3375}
3376
3377fn collection_optional_string(
3378 config: &mut Map,
3379 name: &str,
3380) -> Result<Option<String>, Box<EvalAltResult>> {
3381 config
3382 .remove(name)
3383 .map(|value| {
3384 value
3385 .try_cast::<ImmutableString>()
3386 .map(|value| value.to_string())
3387 .ok_or_else(|| {
3388 Box::new(component_render_error(format!(
3389 "virtual collection `{name}` must be a string"
3390 )))
3391 })
3392 })
3393 .transpose()
3394}
3395
3396fn collection_optional_number(
3397 config: &mut Map,
3398 name: &str,
3399) -> Result<Option<f64>, Box<EvalAltResult>> {
3400 config
3401 .remove(name)
3402 .map(|value| {
3403 if value.is::<rhai::FLOAT>() {
3404 Ok(value.cast::<rhai::FLOAT>())
3405 } else if value.is::<rhai::INT>() {
3406 value
3407 .cast::<rhai::INT>()
3408 .to_string()
3409 .parse::<f64>()
3410 .map_err(|_| Box::new(component_render_error("invalid collection number")))
3411 } else {
3412 Err(Box::new(component_render_error(format!(
3413 "virtual collection `{name}` must be a number"
3414 ))))
3415 }
3416 })
3417 .transpose()
3418}
3419
3420fn collection_positive(config: &mut Map, name: &str) -> Result<f64, Box<EvalAltResult>> {
3421 let value = collection_optional_number(config, name)?.ok_or_else(|| {
3422 Box::new(component_render_error(format!(
3423 "virtual collection `{name}` is required"
3424 )))
3425 })?;
3426 if value.is_finite() && value > 0.0 {
3427 Ok(value)
3428 } else {
3429 Err(Box::new(component_render_error(format!(
3430 "virtual collection `{name}` must be finite and positive"
3431 ))))
3432 }
3433}
3434
3435fn collection_optional_bool(
3436 config: &mut Map,
3437 name: &str,
3438) -> Result<Option<bool>, Box<EvalAltResult>> {
3439 config
3440 .remove(name)
3441 .map(|value| {
3442 value.try_cast::<bool>().ok_or_else(|| {
3443 Box::new(component_render_error(format!(
3444 "virtual collection `{name}` must be bool"
3445 )))
3446 })
3447 })
3448 .transpose()
3449}
3450
3451fn collection_optional_indices(
3452 config: &mut Map,
3453 name: &str,
3454) -> Result<Option<BTreeSet<usize>>, Box<EvalAltResult>> {
3455 let Some(value) = config.remove(name) else {
3456 return Ok(None);
3457 };
3458 let values = value.try_cast::<Array>().ok_or_else(|| {
3459 Box::new(component_render_error(format!(
3460 "virtual collection `{name}` must be an array of non-negative integers"
3461 )))
3462 })?;
3463 let mut indices = BTreeSet::new();
3464 for (position, value) in values.into_iter().enumerate() {
3465 let index = value.try_cast::<rhai::INT>().ok_or_else(|| {
3466 Box::new(component_render_error(format!(
3467 "virtual collection `{name}` item {position} must be an integer"
3468 )))
3469 })?;
3470 let index = usize::try_from(index).map_err(|_| {
3471 Box::new(component_render_error(format!(
3472 "virtual collection `{name}` item {position} must be non-negative"
3473 )))
3474 })?;
3475 if !indices.insert(index) {
3476 return Err(Box::new(component_render_error(format!(
3477 "virtual collection `{name}` contains duplicate index {index}"
3478 ))));
3479 }
3480 }
3481 Ok(Some(indices))
3482}
3483
3484fn collection_data(config: &mut Map) -> Result<crate::VirtualCollectionData, Box<EvalAltResult>> {
3485 let data = config.remove("data").ok_or_else(|| {
3486 Box::new(component_render_error(
3487 "virtual collection `data` is required",
3488 ))
3489 })?;
3490 if data.is::<crate::NativeCollection>() {
3491 return Ok(crate::VirtualCollectionData::Native(
3492 data.cast::<crate::NativeCollection>(),
3493 ));
3494 }
3495 let data = data.try_cast::<Array>().ok_or_else(|| {
3496 Box::new(component_render_error(
3497 "virtual collection `data` must be an array or NativeCollection",
3498 ))
3499 })?;
3500 let data = data
3501 .into_iter()
3502 .map(|value| {
3503 UiValue::from_dynamic(value)
3504 .map_err(|error| Box::new(component_render_error(error.to_string())))
3505 })
3506 .collect::<Result<Vec<_>, _>>()?;
3507 let mut keys = BTreeSet::new();
3508 for (index, item) in data.iter().enumerate() {
3509 let (key, _) = collection_payload(item, index)?;
3510 if !keys.insert(key.clone()) {
3511 return Err(Box::new(component_render_error(format!(
3512 "virtual collection data key `{key}` is duplicated"
3513 ))));
3514 }
3515 }
3516 Ok(crate::VirtualCollectionData::Values(data))
3517}
3518
3519fn collection_payload(item: &UiValue, index: usize) -> Result<(String, Map), Box<EvalAltResult>> {
3520 let UiValue::Map(map) = item else {
3521 return Err(Box::new(component_render_error(
3522 "virtual collection data items must be maps with a string `key`",
3523 )));
3524 };
3525 let key = match map.get("key") {
3526 Some(UiValue::String(key)) if !key.is_empty() => key.clone(),
3527 _ => {
3528 return Err(Box::new(component_render_error(
3529 "virtual collection data item `key` must be a non-empty string",
3530 )));
3531 }
3532 };
3533 Ok((
3534 key.clone(),
3535 Map::from_iter([
3536 ("key".into(), Dynamic::from(key)),
3537 (
3538 "index".into(),
3539 Dynamic::from_int(rhai::INT::try_from(index).unwrap_or(rhai::INT::MAX)),
3540 ),
3541 ("item".into(), item.clone().into_dynamic()),
3542 ]),
3543 ))
3544}
3545
3546fn nonnegative_usize(value: f64) -> usize {
3547 value.to_string().parse().unwrap_or(usize::MAX)
3548}
3549
3550fn component_event_callbacks(
3551 component: &crate::ComponentDefinition,
3552 props: &Map,
3553) -> Vec<(String, FnPtr)> {
3554 component
3555 .schema
3556 .events
3557 .keys()
3558 .filter_map(|event| {
3559 props
3560 .get(format!("on_{event}").as_str())
3561 .filter(|value| value.is::<FnPtr>())
3562 .map(|value| (event.clone(), value.clone_cast::<FnPtr>()))
3563 })
3564 .collect()
3565}
3566
3567fn bind_component_callback_props(
3568 props: &mut Map,
3569 schema: &BTreeMap<String, crate::ObjectField>,
3570 binding: &ComponentCallbackBinding,
3571) {
3572 for (name, field) in schema {
3573 if schema_contains_callback(&field.schema)
3574 && let Some(value) = props.get_mut(name.as_str())
3575 {
3576 bind_component_callback_value(&field.schema, value, binding);
3577 }
3578 }
3579}
3580
3581fn schema_contains_callback(schema: &crate::ValueSchema) -> bool {
3582 match schema {
3583 crate::ValueSchema::Callback => true,
3584 crate::ValueSchema::Array { items, .. }
3585 | crate::ValueSchema::Map { values: items }
3586 | crate::ValueSchema::Optional { value: items } => schema_contains_callback(items),
3587 crate::ValueSchema::Object { fields, .. } => fields
3588 .values()
3589 .any(|field| schema_contains_callback(&field.schema)),
3590 crate::ValueSchema::OneOf { variants } => variants.iter().any(schema_contains_callback),
3591 _ => false,
3592 }
3593}
3594
3595fn bind_component_callback_value(
3596 schema: &crate::ValueSchema,
3597 value: &mut Dynamic,
3598 binding: &ComponentCallbackBinding,
3599) {
3600 match schema {
3601 crate::ValueSchema::Callback if value.is::<FnPtr>() => {
3602 let mut function = value.clone_cast::<FnPtr>();
3603 if !function
3604 .iter_curry()
3605 .any(Dynamic::is::<ComponentCallbackBinding>)
3606 {
3607 function.add_curry(Dynamic::from(binding.clone()));
3608 *value = Dynamic::from(function);
3609 }
3610 }
3611 crate::ValueSchema::Optional { value: inner } if !value.is_unit() => {
3612 bind_component_callback_value(inner, value, binding);
3613 }
3614 crate::ValueSchema::OneOf { variants } => {
3615 if let Some(variant) = variants
3616 .iter()
3617 .find(|variant| variant.validate(value).is_ok())
3618 {
3619 bind_component_callback_value(variant, value, binding);
3620 }
3621 }
3622 crate::ValueSchema::Array { items, .. }
3623 if schema_contains_callback(items) && value.is::<Array>() =>
3624 {
3625 let mut values = value.clone_cast::<Array>();
3626 for value in &mut values {
3627 bind_component_callback_value(items, value, binding);
3628 }
3629 *value = Dynamic::from_array(values);
3630 }
3631 crate::ValueSchema::Map {
3632 values: item_schema,
3633 } if schema_contains_callback(item_schema) && value.is::<Map>() => {
3634 let mut values = value.clone_cast::<Map>();
3635 for value in values.values_mut() {
3636 bind_component_callback_value(item_schema, value, binding);
3637 }
3638 *value = Dynamic::from_map(values);
3639 }
3640 crate::ValueSchema::Object { fields, .. } if value.is::<Map>() => {
3641 let mut values = value.clone_cast::<Map>();
3642 for (name, field) in fields {
3643 if schema_contains_callback(&field.schema)
3644 && let Some(value) = values.get_mut(name.as_str())
3645 {
3646 bind_component_callback_value(&field.schema, value, binding);
3647 }
3648 }
3649 *value = Dynamic::from_map(values);
3650 }
3651 _ => {}
3652 }
3653}
3654
3655fn bind_component_node_props(
3656 props: &mut Map,
3657 schema: &BTreeMap<String, crate::ObjectField>,
3658 binding: &ComponentCallbackBinding,
3659) {
3660 for (name, field) in schema {
3661 if schema_contains_node(&field.schema)
3662 && let Some(value) = props.get_mut(name.as_str())
3663 {
3664 bind_component_node_value(&field.schema, value, binding);
3665 }
3666 }
3667}
3668
3669fn schema_contains_node(schema: &crate::ValueSchema) -> bool {
3670 match schema {
3671 crate::ValueSchema::Node => true,
3672 crate::ValueSchema::Array { items, .. }
3673 | crate::ValueSchema::Map { values: items }
3674 | crate::ValueSchema::Optional { value: items } => schema_contains_node(items),
3675 crate::ValueSchema::Object { fields, .. } => fields
3676 .values()
3677 .any(|field| schema_contains_node(&field.schema)),
3678 crate::ValueSchema::OneOf { variants } => variants.iter().any(schema_contains_node),
3679 _ => false,
3680 }
3681}
3682
3683fn bind_component_node_value(
3684 schema: &crate::ValueSchema,
3685 value: &mut Dynamic,
3686 binding: &ComponentCallbackBinding,
3687) {
3688 match schema {
3689 crate::ValueSchema::Node if value.is::<UiNode>() => {
3690 let mut node = value.clone_cast::<UiNode>();
3691 node.bind_component_scope(
3692 &binding.component,
3693 binding.incarnation,
3694 &binding.events,
3695 binding.context.as_ref(),
3696 );
3697 node.activate_component_snapshots();
3698 *value = Dynamic::from(node);
3699 }
3700 crate::ValueSchema::Optional { value: inner } if !value.is_unit() => {
3701 bind_component_node_value(inner, value, binding);
3702 }
3703 crate::ValueSchema::OneOf { variants } => {
3704 if let Some(variant) = variants
3705 .iter()
3706 .find(|variant| variant.validate(value).is_ok())
3707 {
3708 bind_component_node_value(variant, value, binding);
3709 }
3710 }
3711 crate::ValueSchema::Array { items, .. }
3712 if schema_contains_node(items) && value.is::<Array>() =>
3713 {
3714 let mut values = value.clone_cast::<Array>();
3715 for value in &mut values {
3716 bind_component_node_value(items, value, binding);
3717 }
3718 *value = Dynamic::from_array(values);
3719 }
3720 crate::ValueSchema::Map {
3721 values: item_schema,
3722 } if schema_contains_node(item_schema) && value.is::<Map>() => {
3723 let mut values = value.clone_cast::<Map>();
3724 for value in values.values_mut() {
3725 bind_component_node_value(item_schema, value, binding);
3726 }
3727 *value = Dynamic::from_map(values);
3728 }
3729 crate::ValueSchema::Object { fields, .. } if value.is::<Map>() => {
3730 let mut values = value.clone_cast::<Map>();
3731 for (name, field) in fields {
3732 if schema_contains_node(&field.schema)
3733 && let Some(value) = values.get_mut(name.as_str())
3734 {
3735 bind_component_node_value(&field.schema, value, binding);
3736 }
3737 }
3738 *value = Dynamic::from_map(values);
3739 }
3740 _ => {}
3741 }
3742}
3743
3744fn hydrate_replayed_node_value(value: &mut Dynamic) {
3745 if value.is::<UiNode>() {
3746 let mut node = value.clone_cast::<UiNode>();
3747 node.hydrate_component_subtrees();
3748 *value = Dynamic::from(node);
3749 } else if value.is::<Array>() {
3750 let mut values = value.clone_cast::<Array>();
3751 for value in &mut values {
3752 hydrate_replayed_node_value(value);
3753 }
3754 *value = Dynamic::from_array(values);
3755 } else if value.is::<Map>() {
3756 let mut values = value.clone_cast::<Map>();
3757 for value in values.values_mut() {
3758 hydrate_replayed_node_value(value);
3759 }
3760 *value = Dynamic::from_map(values);
3761 }
3762}
3763
3764fn component_part_styles(props: &Map) -> BTreeMap<String, crate::Style> {
3765 props
3766 .get("part_styles")
3767 .filter(|value| value.is::<Map>())
3768 .map(Dynamic::clone_cast::<Map>)
3769 .unwrap_or_default()
3770 .into_iter()
3771 .filter_map(|(name, value)| {
3772 value
3773 .is::<crate::Style>()
3774 .then(|| (name.to_string(), value.cast::<crate::Style>()))
3775 })
3776 .collect()
3777}
3778
3779fn component_root_style(props: &Map) -> Option<crate::Style> {
3780 props
3781 .get("style")
3782 .filter(|value| value.is::<crate::Style>())
3783 .map(Dynamic::clone_cast::<crate::Style>)
3784}
3785
3786fn register_component_event_callbacks(
3787 active: &ActiveComponentRenderState,
3788 component: &ComponentInstancePath,
3789 caller: &UiContext,
3790 callbacks: Vec<(String, FnPtr)>,
3791 native_context: &crate::invocation::ScriptInvocationContext,
3792) -> Result<(), Box<EvalAltResult>> {
3793 let mut guard = active.try_borrow_mut().map_err(|_| {
3794 Box::new(component_render_error(
3795 "component render stack is already borrowed",
3796 ))
3797 })?;
3798 let active = guard.as_mut().ok_or_else(|| {
3799 Box::new(component_render_error(
3800 "formal component rendering may run only inside view",
3801 ))
3802 })?;
3803 for (event, function) in callbacks {
3804 let mut callback = ScriptCallback::try_from_fn_ptr(function, active.generation)
3805 .map_err(|error| Box::new(component_render_error(error.to_string())))?;
3806 callback.bind_component_scope_if_unset(
3807 caller.component_path(),
3808 caller.component_incarnation(),
3809 caller.event_schemas().clone(),
3810 );
3811 callback.bind_native_context_if_unset(native_context.clone());
3812 active
3813 .event_handlers
3814 .insert((component.clone(), event), callback);
3815 }
3816 Ok(())
3817}
3818
3819fn component_render_error(message: impl Into<String>) -> EvalAltResult {
3820 EvalAltResult::ErrorRuntime(Dynamic::from(message.into()), Position::NONE)
3821}
3822
3823#[cfg(test)]
3824mod tests {
3825 use super::*;
3826
3827 fn virtual_config(sticky: &[i64], alignment: &str) -> Map {
3828 let mut item = Map::new();
3829 item.insert("key".into(), Dynamic::from("header"));
3830 let mut config = Map::new();
3831 config.insert("key".into(), Dynamic::from("sections"));
3832 config.insert(
3833 "data".into(),
3834 Dynamic::from_array(vec![Dynamic::from_map(item)]),
3835 );
3836 config.insert("estimated_height".into(), Dynamic::from_float(30.0));
3837 config.insert("height".into(), Dynamic::from_float(120.0));
3838 config.insert("alignment".into(), Dynamic::from(alignment.to_owned()));
3839 config.insert(
3840 "sticky_headers".into(),
3841 Dynamic::from_array(sticky.iter().copied().map(Dynamic::from_int).collect()),
3842 );
3843 config
3844 }
3845
3846 #[test]
3847 fn sticky_virtual_collection_indices_are_bounded_unique_and_top_aligned() {
3848 let decoded = decode_virtual_collection(virtual_config(&[0], "top")).unwrap();
3849 assert_eq!(decoded.sticky_headers.as_ref(), &BTreeSet::from([0]));
3850
3851 for (sticky, alignment, expected) in [
3852 (&[1][..], "top", "outside 1 items"),
3853 (&[0, 0][..], "top", "duplicate index 0"),
3854 (&[0][..], "bottom", "require top-aligned"),
3855 ] {
3856 let error = decode_virtual_collection(virtual_config(sticky, alignment)).unwrap_err();
3857 assert!(error.to_string().contains(expected), "{error}");
3858 }
3859 }
3860
3861 #[test]
3862 fn virtual_collection_reveal_key_is_known_and_has_one_scroll_owner() {
3863 let mut config = virtual_config(&[], "top");
3864 config.insert("reveal_key".into(), Dynamic::from("header"));
3865 let decoded = decode_virtual_collection(config).unwrap();
3866 assert_eq!(decoded.reveal_key.as_deref(), Some("header"));
3867
3868 let mut unknown = virtual_config(&[], "top");
3869 unknown.insert("reveal_key".into(), Dynamic::from("missing"));
3870 let error = decode_virtual_collection(unknown).unwrap_err();
3871 assert!(error.to_string().contains("is not present"), "{error}");
3872
3873 let mut competing = virtual_config(&[], "top");
3874 competing.insert("reveal_key".into(), Dynamic::from("header"));
3875 competing.insert("follow_tail".into(), Dynamic::from(true));
3876 let error = decode_virtual_collection(competing).unwrap_err();
3877 assert!(error.to_string().contains("must not combine"), "{error}");
3878 }
3879
3880 #[test]
3881 fn script_builds_a_declarative_tree() {
3882 let mut runtime = RuntimeEngine::new();
3883 let compiled = runtime
3884 .compile(
3885 r#"
3886 fn view() {
3887 column([
3888 text("Hello").with_key("greeting"),
3889 text("from Rhai")
3890 ])
3891 }
3892 "#,
3893 )
3894 .expect("script should compile");
3895
3896 let root = runtime.render(&compiled).expect("view should render");
3897 assert_eq!(
3898 root.source().map(|source| source.module.as_str()),
3899 Some("ui/main.rhai")
3900 );
3901 let crate::UiNodeKind::Box { children } = root.kind() else {
3902 panic!("column must render a box");
3903 };
3904 assert_eq!(children.len(), 2);
3905 assert_eq!(
3906 children[0].key().map(crate::NodeKey::as_str),
3907 Some("greeting")
3908 );
3909 assert!(children.iter().all(|child| child.source().is_some()));
3910 }
3911
3912 #[test]
3913 fn runtime_emits_official_rhai_language_server_definitions() {
3914 let runtime = RuntimeEngine::new();
3915 let definitions = runtime.definition_source();
3916 assert!(definitions.starts_with("module static;"));
3917 assert!(definitions.contains("fn box"));
3918 assert!(definitions.contains("fn timeout"));
3919 assert!(definitions.contains("fn canvas_fill_path"));
3920 assert!(definitions.contains("UiNode"));
3921 }
3922
3923 #[test]
3924 fn retained_callbacks_reject_capturing_functions_and_non_data_curry() {
3925 let mut runtime = RuntimeEngine::new();
3926 let compiled = runtime
3927 .compile(r#"fn view() { text("x").on_click(|| ()) }"#)
3928 .unwrap();
3929 let error = runtime.render(&compiled).unwrap_err();
3930 assert!(
3931 error
3932 .to_string()
3933 .contains("anonymous or capturing functions")
3934 );
3935
3936 let mut callback = FnPtr::new("clicked").unwrap();
3937 callback.add_curry(Dynamic::from(UiNode::text("not durable data")));
3938 assert!(matches!(
3939 ScriptCallback::try_from_fn_ptr(callback, ScriptGeneration::initial()),
3940 Err(ScriptCallbackDefinitionError::InvalidCurry { index: 0, .. })
3941 ));
3942 }
3943
3944 #[test]
3945 fn replay_hydrates_formal_subtrees_inside_nested_dynamic_node_shapes() {
3946 let root = ComponentInstancePath::root("View", "main");
3947 let outer = root.child("Outer", "outer");
3948 let inner = root.child("Inner", "inner");
3949 let inner_snapshot = crate::node::ComponentOwnedSnapshot::default();
3950 let mut current_inner = UiNode::text("current")
3951 .with_component_root_snapshot(inner.clone(), inner_snapshot.reference());
3952 current_inner.activate_component_snapshots();
3953 let outer_snapshot = crate::node::ComponentOwnedSnapshot::default();
3954 let mut current_outer = UiNode::box_node(vec![
3955 UiNode::text("outer"),
3956 UiNode::text("stale-inner")
3957 .with_component_root_snapshot(inner, inner_snapshot.reference()),
3958 ])
3959 .with_component_root_snapshot(outer.clone(), outer_snapshot.reference());
3960 current_outer.activate_component_snapshots();
3961 let stale = UiNode::box_node(vec![
3962 UiNode::text("stale-outer")
3963 .with_component_root_snapshot(outer, outer_snapshot.reference())
3964 .with_attribute("presentation", UiValue::Bool(true)),
3965 ]);
3966 let mut nested = Map::new();
3967 nested.insert(
3968 "object".into(),
3969 Dynamic::from_map(Map::from_iter([(
3970 "nodes".into(),
3971 Dynamic::from_array(vec![Dynamic::from(stale)]),
3972 )])),
3973 );
3974 let mut value = Dynamic::from_map(nested);
3975
3976 hydrate_replayed_node_value(&mut value);
3977
3978 let nested = value.cast::<Map>();
3979 let object = nested["object"].clone_cast::<Map>();
3980 let nodes = object["nodes"].clone_cast::<Array>();
3981 let node = nodes[0].clone_cast::<UiNode>();
3982 let crate::UiNodeKind::Box { children } = node.kind() else {
3983 panic!("nested node shape must preserve its raw wrapper");
3984 };
3985 assert_eq!(
3986 children[0].attributes().get("presentation"),
3987 Some(&UiValue::Bool(true))
3988 );
3989 let crate::UiNodeKind::Box { children } = children[0].kind() else {
3990 panic!("outer component must hydrate its owned snapshot");
3991 };
3992 assert!(
3993 matches!(children[1].kind(), crate::UiNodeKind::Text { text } if text == "current")
3994 );
3995 }
3996
3997 #[test]
3998 fn final_component_api_registers_render_once_and_invokes_by_id() {
3999 let mut runtime = RuntimeEngine::new();
4000 let compiled = runtime
4001 .compile(
4002 r#"
4003 define_component(#{
4004 metadata: #{
4005 id: "components/message", "export": "Message", version: "0.1.0",
4006 runtime_api: #{ min_inclusive: 1, max_exclusive: 2 },
4007 dependencies: [], capabilities: #{}
4008 },
4009 schema: #{ props: #{}, state: #{ fields: #{} }, events: #{}, slots: #{}, parts: [] },
4010 render: Fn("render_Message")
4011 });
4012 fn Message(props) { render_component("components/message", props) }
4013 fn render_Message(ctx, props) { text("final component api") }
4014 fn view() { Message(#{ key: "message" }) }
4015 "#,
4016 )
4017 .unwrap();
4018 let root = runtime.render(&compiled).unwrap();
4019 assert!(matches!(
4020 root.kind(),
4021 crate::UiNodeKind::Text { text } if text == "final component api"
4022 ));
4023 let recipe = runtime.component_invocations().next().unwrap();
4024 assert_eq!(recipe.render_name(), "render_Message");
4025 }
4026
4027 #[test]
4028 fn self_contained_compile_rejects_dynamic_imports_before_evaluation() {
4029 let mut runtime = RuntimeEngine::new();
4030 assert!(matches!(
4031 runtime.compile_self_contained_named(
4032 "ui/dynamic_import.rhai",
4033 "let path = \"components/button\"; import path as button;",
4034 ),
4035 Err(RuntimeError::Import(_))
4036 ));
4037 }
4038
4039 #[test]
4040 fn script_builds_an_error_boundary() {
4041 let mut runtime = RuntimeEngine::new();
4042 let compiled = runtime
4043 .compile(
4044 r#"
4045 fn view() {
4046 error_boundary(text("content"), text("fallback"))
4047 }
4048 "#,
4049 )
4050 .unwrap();
4051 let root = runtime.render(&compiled).unwrap();
4052 assert!(matches!(
4053 root.kind(),
4054 crate::UiNodeKind::ErrorBoundary { .. }
4055 ));
4056 }
4057
4058 #[test]
4059 fn lazy_error_boundary_catches_rhai_evaluation_failure() {
4060 let mut runtime = RuntimeEngine::new();
4061 let compiled = runtime
4062 .compile(
4063 r#"
4064 fn broken() { throw "boom"; }
4065 fn fallback() { text("fallback") }
4066 fn view() { error_boundary_lazy(Fn("broken"), Fn("fallback")) }
4067 "#,
4068 )
4069 .unwrap();
4070 let root = runtime.render(&compiled).unwrap();
4071 assert!(matches!(root.kind(), crate::UiNodeKind::Text { text } if text == "fallback"));
4072 assert!(matches!(
4073 root.attributes().get("boundary_error"),
4074 Some(crate::UiValue::String(message)) if message.contains("boom")
4075 ));
4076 }
4077
4078 #[test]
4079 fn script_builds_a_typed_controlled_overlay() {
4080 let mut runtime = RuntimeEngine::new();
4081 let compiled = runtime
4082 .compile(
4083 r#"
4084 fn changed(ctx, open) { () }
4085 fn view() {
4086 overlay(
4087 text("trigger"),
4088 text("content"),
4089 #{
4090 id: "account",
4091 kind: "popover",
4092 placement: "bottom",
4093 open: true,
4094 gap: 6
4095 }
4096 ).on_open_change(Fn("changed"))
4097 }
4098 "#,
4099 )
4100 .unwrap();
4101 let root = runtime.render(&compiled).unwrap();
4102 assert!(matches!(
4103 root.kind(),
4104 crate::UiNodeKind::Overlay { spec, .. }
4105 if spec.id.as_str() == "account"
4106 && spec.open
4107 && (spec.gap - 6.0).abs() < f64::EPSILON
4108 && spec.placement == crate::OverlayPlacement::Bottom
4109 ));
4110 assert_eq!(
4111 root.handler("open_change")
4112 .unwrap()
4113 .as_script()
4114 .unwrap()
4115 .generation(),
4116 compiled.generation()
4117 );
4118 }
4119
4120 #[test]
4121 fn click_handlers_can_carry_typed_semantic_payloads() {
4122 let mut runtime = RuntimeEngine::new();
4123 let compiled = runtime
4124 .compile(
4125 r#"
4126 fn changed(ctx, value) { () }
4127 fn view() {
4128 text("toggle").on_click_value(
4129 Fn("changed"), #{ checked: true, source: "pointer" }
4130 )
4131 }
4132 "#,
4133 )
4134 .unwrap();
4135 let root = runtime.render(&compiled).unwrap();
4136 assert_eq!(
4137 root.handler_payload("click"),
4138 Some(&crate::UiValue::Map(BTreeMap::from([
4139 ("checked".to_owned(), crate::UiValue::Bool(true)),
4140 (
4141 "source".to_owned(),
4142 crate::UiValue::String("pointer".to_owned()),
4143 ),
4144 ])))
4145 );
4146 assert!(root.handlers().contains_key("click"));
4147 }
4148
4149 #[test]
4150 fn rhai_nodes_retain_ordered_handlers_for_all_event_phases() {
4151 let mut runtime = RuntimeEngine::new();
4152 let compiled = runtime
4153 .compile(
4154 r#"
4155 fn first(ctx, event) { event_response().prevent_default() }
4156 fn second(ctx, event) { event_response().stop_immediate() }
4157 fn capture(ctx, event) { propagate() }
4158 fn view() {
4159 text("drag")
4160 .on_capture("pointer_down", Fn("capture"))
4161 .on("pointer_down", Fn("first"))
4162 .on("pointer_down", Fn("second"))
4163 }
4164 "#,
4165 )
4166 .unwrap();
4167 let root = runtime.render(&compiled).unwrap();
4168 let bindings = root.event_handlers("pointer_down");
4169 assert_eq!(bindings.len(), 3);
4170 assert_eq!(bindings[0].phase(), crate::EventPhase::Capture);
4171 assert_eq!(bindings[1].phase(), crate::EventPhase::Target);
4172 assert_eq!(bindings[2].phase(), crate::EventPhase::Target);
4173 assert_eq!(bindings[2].handler().as_script().unwrap().name(), "second");
4174 }
4175
4176 #[test]
4177 fn rhai_nodes_attach_only_registered_schema_checked_native_handlers() {
4178 let mut runtime = RuntimeEngine::new();
4179 let id = crate::NativeHandlerId::parse("timeline.drag").unwrap();
4180 runtime
4181 .register_native_handler(
4182 crate::NativeHandlerDescriptor::new(
4183 id,
4184 BTreeMap::from([("pointer_down".to_owned(), crate::ValueSchema::UiValue)]),
4185 )
4186 .unwrap(),
4187 |_, _, _, _| Ok(crate::EventResponse::new().capture_pointer()),
4188 )
4189 .unwrap();
4190 let compiled = runtime
4191 .compile(
4192 r#"
4193 fn view() {
4194 text("drag").on(
4195 "pointer_down", native_handler("timeline.drag")
4196 )
4197 }
4198 "#,
4199 )
4200 .unwrap();
4201 let root = runtime.render(&compiled).unwrap();
4202 assert!(matches!(
4203 root.handler("pointer_down"),
4204 Some(crate::UiEventHandler::Native(reference))
4205 if reference.descriptor().id.as_str() == "timeline.drag"
4206 ));
4207
4208 let input = runtime
4209 .compile(
4210 r#"
4211 fn view() {
4212 gpui_rhai::TextInputPrimitive(#{
4213 key: "native-input", value: "", placeholder: "Edit",
4214 disabled: false, read_only: false, typography: "body",
4215 on_change: native_handler("timeline.drag")
4216 })
4217 }
4218 "#,
4219 )
4220 .unwrap();
4221 assert!(
4222 runtime.render(&input).is_ok(),
4223 "native handlers must cross formal component and primitive callback props"
4224 );
4225
4226 let missing = runtime
4227 .compile(
4228 "fn view() { text(\"x\").on(\"pointer_down\", native_handler(\"app.missing\")) }",
4229 )
4230 .unwrap();
4231 assert!(runtime.render(&missing).is_err());
4232 }
4233
4234 #[test]
4235 fn final_box_and_fragment_atoms_are_distinct_snapshots() {
4236 let mut runtime = RuntimeEngine::new();
4237 let compiled = runtime
4238 .compile(
4239 r#"
4240 fn view() {
4241 box([text("a"), fragment([text("b"), text("c")])])
4242 }
4243 "#,
4244 )
4245 .unwrap();
4246 let root = runtime.render(&compiled).unwrap();
4247 let crate::UiNodeKind::Box { children } = root.kind() else {
4248 panic!("root must be the final Box atom");
4249 };
4250 assert!(matches!(
4251 children[1].kind(),
4252 crate::UiNodeKind::Fragment { children } if children.len() == 2
4253 ));
4254 }
4255
4256 #[test]
4257 fn text_accepts_typed_inline_span_runs() {
4258 let mut runtime = RuntimeEngine::new();
4259 let compiled = runtime
4260 .compile(
4261 r#"
4262 fn view() {
4263 text([
4264 span("Hello ").bold(),
4265 span("world").color(rgb(0x22cc88)).italic()
4266 ])
4267 }
4268 "#,
4269 )
4270 .unwrap();
4271 let root = runtime.render(&compiled).unwrap();
4272 let crate::UiNodeKind::RichText { text, spans } = root.kind() else {
4273 panic!("typed spans must produce rich text");
4274 };
4275 assert_eq!(text.as_str(), "Hello world");
4276 assert_eq!(spans.len(), 2);
4277 assert!(spans[0].is_bold());
4278 assert!(spans[1].is_italic());
4279 }
4280
4281 #[test]
4282 fn canvas_scene_is_keyed_validated_and_script_constructible() {
4283 let mut runtime = RuntimeEngine::new();
4284 let compiled = runtime
4285 .compile(
4286 r#"
4287 fn view() {
4288 canvas(canvas_scene([
4289 canvas_rect("panel", 0.0, 0.0, 100.0, 40.0, rgb(0x112233)),
4290 canvas_circle("dot", 20.0, 20.0, 8.0, rgb(0xffcc00)),
4291 canvas_line("axis", 0.0, 39.0, 100.0, 39.0, 1.0, rgb(0xffffff))
4292 ]))
4293 }
4294 "#,
4295 )
4296 .unwrap();
4297 let root = runtime.render(&compiled).unwrap();
4298 assert!(matches!(
4299 root.kind(),
4300 crate::UiNodeKind::Canvas { scene } if scene.commands().len() == 3
4301 ));
4302 }
4303
4304 #[test]
4305 fn inline_svg_atom_is_bounded_and_self_contained() {
4306 let mut runtime = RuntimeEngine::new();
4307 let compiled = runtime
4308 .compile(
4309 r#"
4310 fn view() {
4311 svg("<svg viewBox='0 0 10 10'><path fill='currentColor' d='M0 0L10 10'/></svg>")
4312 }
4313 "#,
4314 )
4315 .unwrap();
4316 let root = runtime.render(&compiled).unwrap();
4317 assert!(matches!(
4318 root.kind(),
4319 crate::UiNodeKind::Svg { source } if source.as_str().contains("currentColor")
4320 ));
4321
4322 let rejected = runtime
4323 .compile(r#"fn view() { svg("<svg><image href='https://example.com/a.png'/></svg>") }"#)
4324 .unwrap();
4325 assert!(runtime.render(&rejected).is_err());
4326 }
4327
4328 #[test]
4329 fn recompilation_invalidates_previous_generation() {
4330 let mut runtime = RuntimeEngine::new();
4331 let first = runtime.compile("fn view() { text(\"first\") }").unwrap();
4332 runtime.render(&first).unwrap();
4333 let second = runtime.compile("fn view() { text(\"second\") }").unwrap();
4334
4335 assert!(runtime.is_current(first.generation()));
4336 assert!(!runtime.is_current(second.generation()));
4337 runtime.render(&second).unwrap();
4338 assert!(!runtime.is_current(first.generation()));
4339 assert!(runtime.is_current(second.generation()));
4340 }
4341
4342 #[test]
4343 fn callbacks_are_rejected_after_reload() {
4344 let mut runtime = RuntimeEngine::new();
4345 let first = runtime
4346 .compile(
4347 r#"
4348 fn view() { text("first") }
4349 fn add_suffix(value) { value + "!" }
4350 "#,
4351 )
4352 .unwrap();
4353 runtime.render(&first).unwrap();
4354 let callback = runtime.callback(&first, "add_suffix").unwrap();
4355
4356 let value = runtime
4357 .invoke_callback(&first, &callback, ("hello",))
4358 .unwrap();
4359 assert_eq!(value.into_immutable_string().unwrap(), "hello!");
4360
4361 let second = runtime.compile("fn view() { text(\"second\") }").unwrap();
4362 runtime.render(&second).unwrap();
4363 assert!(matches!(
4364 runtime.invoke_callback(&second, &callback, ("hello",)),
4365 Err(RuntimeError::StaleCallback { .. })
4366 ));
4367 }
4368
4369 #[test]
4370 fn failed_candidate_keeps_last_good_generation_active() {
4371 let mut runtime = RuntimeEngine::new();
4372 let active = runtime
4373 .compile(
4374 r#"
4375 fn view() { text("active") }
4376 fn action(value) { value }
4377 "#,
4378 )
4379 .unwrap();
4380 runtime.render(&active).unwrap();
4381 let callback = runtime.callback(&active, "action").unwrap();
4382
4383 let candidate = runtime
4384 .compile("fn view() { throw \"render failed\"; }")
4385 .unwrap();
4386 assert!(runtime.render(&candidate).is_err());
4387 assert!(runtime.is_current(active.generation()));
4388 let _ = runtime
4389 .invoke_callback(&active, &callback, ("still active",))
4390 .unwrap();
4391 }
4392
4393 #[test]
4394 fn execution_timings_cover_compile_and_render() {
4395 let mut runtime = RuntimeEngine::new();
4396 runtime.set_slow_threshold(Duration::ZERO);
4397 let compiled = runtime
4398 .compile(
4399 r#"
4400 fn view() { text("timed") }
4401 fn counted() { let total = 0; for value in 0..100 { total += value; } total }
4402 "#,
4403 )
4404 .unwrap();
4405 runtime.render(&compiled).unwrap();
4406 let callback = runtime.callback(&compiled, "counted").unwrap();
4407 let _ = runtime.invoke_callback(&compiled, &callback, ()).unwrap();
4408 let timings = runtime.take_timings();
4409 assert_eq!(timings.len(), 3);
4410 assert_eq!(timings[0].operation, ExecutionOperation::Compile);
4411 assert_eq!(timings[1].operation, ExecutionOperation::Render);
4412 assert_eq!(
4413 timings[2].operation,
4414 ExecutionOperation::Callback("counted".to_owned())
4415 );
4416 assert_eq!(timings[0].operations, 0);
4417 assert!(timings[1].operations > 0);
4418 assert!(timings[2].operations > timings[1].operations);
4419 assert!(timings.iter().all(|timing| timing.slow && timing.succeeded));
4420 }
4421
4422 #[test]
4423 fn independent_candidates_never_share_callback_authority() {
4424 let mut runtime = RuntimeEngine::new();
4425 let first = runtime
4426 .compile("fn view() { text(\"first\") } fn action() { 1 }")
4427 .unwrap();
4428 let callback = runtime.callback(&first, "action").unwrap();
4429 let second = runtime
4430 .compile("fn view() { text(\"second\") } fn action() { 2 }")
4431 .unwrap();
4432 assert_ne!(first.generation(), second.generation());
4433 runtime.render(&second).unwrap();
4434 assert!(matches!(
4435 runtime.invoke_callback(&second, &callback, ()),
4436 Err(RuntimeError::StaleCallback { .. })
4437 ));
4438 }
4439
4440 #[test]
4441 fn callbacks_cannot_cross_runtime_engines() {
4442 let mut first_engine = RuntimeEngine::new();
4443 let first = first_engine
4444 .compile("fn view() { text(\"first\") } fn action() { 1 }")
4445 .unwrap();
4446 first_engine.render(&first).unwrap();
4447 let callback = first_engine.callback(&first, "action").unwrap();
4448
4449 let mut second_engine = RuntimeEngine::new();
4450 let second = second_engine
4451 .compile("fn view() { text(\"second\") } fn action() { 2 }")
4452 .unwrap();
4453 second_engine.render(&second).unwrap();
4454 assert!(matches!(
4455 second_engine.invoke_callback(&second, &callback, ()),
4456 Err(RuntimeError::StaleCallback { .. })
4457 ));
4458 }
4459
4460 #[test]
4461 fn one_program_preparation_shares_identity_across_module_and_entry_compilation() {
4462 let mut runtime = RuntimeEngine::new();
4463 let (module, entry) = runtime
4464 .with_program_preparation(|runtime| {
4465 Ok::<_, RuntimeError>((
4466 runtime.compile_named("module", "fn helper() { 1 }")?,
4467 runtime.compile_named("entry", "fn view() { text(\"entry\") }")?,
4468 ))
4469 })
4470 .unwrap();
4471 assert_eq!(module.generation(), entry.generation());
4472 let next = runtime.compile("fn view() { text(\"next\") }").unwrap();
4473 assert_ne!(entry.generation(), next.generation());
4474 }
4475
4476 #[test]
4477 fn unsafe_const_container_assignment_is_rejected_before_evaluation() {
4478 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4479 RuntimeEngine::new()
4480 .compile("fn view() { const m = #{ x: 1 }; m.x = 2; text(\"done\") }")
4481 }));
4482 assert!(matches!(
4483 result,
4484 Ok(Err(RuntimeError::InvalidAssignmentTarget(_)))
4485 ));
4486 }
4487
4488 #[test]
4489 fn default_runtime_rejects_filesystem_imports() {
4490 let directory = tempfile::tempdir().unwrap();
4491 let module = directory.path().join("external.rhai");
4492 std::fs::write(&module, "fn value() { \"external\" }").unwrap();
4493 let mut runtime = RuntimeEngine::new();
4494 let source = format!(
4495 "import {:?} as external; fn view() {{ text(external::value()) }}",
4496 module.with_extension("").to_string_lossy()
4497 );
4498 let compiled = runtime.compile(&source).unwrap();
4499 assert!(runtime.render(&compiled).is_err());
4500 }
4501
4502 #[test]
4503 fn nested_component_work_consumes_one_shared_operation_budget() {
4504 let module = r#"
4505 define_component(#{
4506 metadata: #{ id: "components/heavy", "export": "Heavy", version: "0.1.1",
4507 runtime_api: #{ min_inclusive: 1, max_exclusive: 2 }, dependencies: [], capabilities: #{} },
4508 schema: #{ props: #{}, state: #{ fields: #{} }, events: #{}, slots: #{}, parts: ["root"] },
4509 render: Fn("render_Heavy")
4510 });
4511 fn Heavy(props) { render_component("components/heavy", props) }
4512 fn render_Heavy(ctx, props) {
4513 let n = 0;
4514 for i in 0..200000 { n += 1; }
4515 text(`${n}`)
4516 }
4517 "#;
4518 let mut resolver = crate::RestrictedModuleResolver::new();
4519 resolver.insert("components/heavy", module).unwrap();
4520 let mut runtime = RuntimeEngine::new();
4521 runtime.set_module_resolver(resolver);
4522 let compiled = runtime
4523 .compile_self_contained_named(
4524 "budget",
4525 r#"import "components/heavy" as h;
4526 fn view() { column([h::Heavy(#{}), h::Heavy(#{}), h::Heavy(#{}), h::Heavy(#{} )]) }"#,
4527 )
4528 .unwrap();
4529 let result = runtime.render(&compiled);
4530 assert!(result.is_err(), "nested work must exceed the shared budget");
4531 assert!(
4532 runtime
4533 .take_timings()
4534 .iter()
4535 .any(|timing| timing.operations > MAX_SCRIPT_OPERATIONS)
4536 );
4537 }
4538
4539 #[test]
4540 fn retained_component_budget_ignores_parent_history_without_resetting_session_total() {
4541 let module = r#"
4542 define_component(#{
4543 metadata: #{ id: "components/probe", "export": "Probe", version: "0.1.1",
4544 runtime_api: #{ min_inclusive: 1, max_exclusive: 2 }, dependencies: [], capabilities: #{} },
4545 schema: #{ props: #{ key: #{ schema: #{ type: "string" }, required: true, sensitive: false } },
4546 state: #{ fields: #{ heavy: #{ schema: #{ type: "bool" }, "default": #{ type: "bool", value: false } } } },
4547 events: #{}, slots: #{}, parts: ["root"] },
4548 render: Fn("render_Probe")
4549 });
4550 fn Probe(props) { render_component("components/probe", props) }
4551 fn render_Probe(ctx, props) {
4552 let n = 0;
4553 if ctx.get_state("heavy") { for i in 0..100000 { n += 1; } }
4554 text(`${n}`)
4555 }
4556 "#;
4557 let mut measured = Vec::new();
4558 for parent_work in [0, 250_000] {
4559 let mut resolver = crate::RestrictedModuleResolver::new();
4560 resolver.insert("components/probe", module).unwrap();
4561 let mut engine = RuntimeEngine::new();
4562 engine.set_module_resolver(resolver);
4563 let source = format!(
4564 r#"import "components/probe" as probe;
4565 fn view(ctx) {{
4566 let n = 0;
4567 for i in 0..{parent_work} {{ n += 1; }}
4568 probe::Probe(#{{ key: "probe" }})
4569 }}"#
4570 );
4571 let compiled = engine
4572 .compile_self_contained_named("incremental-budget", &source)
4573 .unwrap();
4574 let runtime = Rc::new(RefCell::new(crate::UiRuntimeState::new()));
4575 let mut lifecycle = crate::ScriptLifecycle::new(
4576 compiled,
4577 Rc::clone(&runtime),
4578 ComponentInstancePath::root("App", "budget"),
4579 None,
4580 BTreeMap::new(),
4581 &ComponentStateSchema::default(),
4582 )
4583 .unwrap();
4584 lifecycle.start(&mut engine).unwrap();
4585 let owner = engine
4586 .component_invocations()
4587 .next()
4588 .unwrap()
4589 .path()
4590 .clone();
4591 let _ = engine.take_timings();
4592 runtime
4593 .borrow_mut()
4594 .set_component_state_from_host(&owner, "heavy", UiValue::Bool(true))
4595 .unwrap();
4596 assert!(lifecycle.render_dirty(&mut engine).unwrap());
4597 let operations = engine
4598 .take_timings()
4599 .into_iter()
4600 .filter(|timing| timing.operation == ExecutionOperation::Render)
4601 .map(|timing| timing.operations)
4602 .sum::<u64>();
4603 assert!(operations < MAX_SCRIPT_OPERATIONS / 2, "{operations}");
4604 measured.push(operations);
4605 }
4606 assert_eq!(measured[0], measured[1]);
4607 }
4608
4609 #[test]
4610 fn multiple_dirty_components_share_one_incremental_budget() {
4611 let module = r#"
4612 define_component(#{
4613 metadata: #{ id: "components/probe", "export": "Probe", version: "0.1.1",
4614 runtime_api: #{ min_inclusive: 1, max_exclusive: 2 }, dependencies: [], capabilities: #{} },
4615 schema: #{ props: #{ key: #{ schema: #{ type: "string" }, required: true, sensitive: false } },
4616 state: #{ fields: #{ heavy: #{ schema: #{ type: "bool" }, "default": #{ type: "bool", value: false } } } },
4617 events: #{}, slots: #{}, parts: ["root"] }, render: Fn("render_Probe") }
4618 );
4619 fn Probe(props) { render_component("components/probe", props) }
4620 fn render_Probe(ctx, props) {
4621 let n = 0;
4622 if ctx.get_state("heavy") { for i in 0..200000 { n += 1; } }
4623 text(`${n}`)
4624 }
4625 "#;
4626 let mut resolver = crate::RestrictedModuleResolver::new();
4627 resolver.insert("components/probe", module).unwrap();
4628 let mut engine = RuntimeEngine::new();
4629 engine.set_module_resolver(resolver);
4630 let compiled = engine
4631 .compile_self_contained_named(
4632 "incremental-aggregate-budget",
4633 r#"import "components/probe" as probe;
4634 fn view(ctx) {
4635 column([
4636 probe::Probe(#{ key: "first" }),
4637 probe::Probe(#{ key: "second" }),
4638 ])
4639 }"#,
4640 )
4641 .unwrap();
4642 let runtime = Rc::new(RefCell::new(crate::UiRuntimeState::new()));
4643 let mut lifecycle = crate::ScriptLifecycle::new(
4644 compiled,
4645 Rc::clone(&runtime),
4646 ComponentInstancePath::root("App", "budget"),
4647 None,
4648 BTreeMap::new(),
4649 &ComponentStateSchema::default(),
4650 )
4651 .unwrap();
4652 lifecycle.start(&mut engine).unwrap();
4653 let owners = engine
4654 .component_invocations()
4655 .map(|recipe| recipe.path().clone())
4656 .collect::<Vec<_>>();
4657 assert_eq!(owners.len(), 2);
4658 for owner in owners {
4659 runtime
4660 .borrow_mut()
4661 .set_component_state_from_host(&owner, "heavy", UiValue::Bool(true))
4662 .unwrap();
4663 }
4664 assert!(lifecycle.render_dirty(&mut engine).is_err());
4665 }
4666
4667 #[test]
4668 fn retained_virtual_renderer_budget_ignores_parent_history() {
4669 let mut measured = Vec::new();
4670 for parent_work in [0, 250_000] {
4671 let mut engine = RuntimeEngine::new();
4672 let source = format!(
4673 r#"
4674 fn render_item(ctx, payload) {{
4675 let n = 0;
4676 if payload.item.heavy {{ for i in 0..100000 {{ n += 1; }} }}
4677 text(`${{n}}`).with_key(payload.key)
4678 }}
4679 fn view(ctx) {{
4680 let n = 0;
4681 for i in 0..{parent_work} {{ n += 1; }}
4682 let data = [];
4683 for index in 0..32 {{
4684 data.push(#{{ key: `row-${{index}}`, heavy: index == 31 }});
4685 }}
4686 virtual_collection(#{{
4687 key: "rows", label: "Rows", data: data,
4688 estimated_height: 24, height: 24, overdraw_pixels: 0,
4689 alignment: "top", follow_tail: false,
4690 }}, Fn("render_item"))
4691 }}
4692 "#
4693 );
4694 let compiled = engine.compile_named("virtual-budget", &source).unwrap();
4695 let runtime = Rc::new(RefCell::new(crate::UiRuntimeState::new()));
4696 let root_path = ComponentInstancePath::root("App", "budget");
4697 let mut lifecycle = crate::ScriptLifecycle::new(
4698 compiled,
4699 Rc::clone(&runtime),
4700 root_path.clone(),
4701 None,
4702 BTreeMap::new(),
4703 &ComponentStateSchema::default(),
4704 )
4705 .unwrap();
4706 lifecycle.start(&mut engine).unwrap();
4707 let id = engine
4708 .virtual_collection_ids_in_scope(&root_path)
4709 .into_iter()
4710 .next()
4711 .unwrap();
4712 let _ = engine.take_timings();
4713 runtime.borrow().virtual_requests.request(id, [31]);
4714 assert!(lifecycle.realize_virtual_requests(&mut engine).unwrap());
4715 let operations = engine
4716 .take_timings()
4717 .into_iter()
4718 .filter(|timing| {
4719 matches!(timing.operation, ExecutionOperation::VirtualCollection(_))
4720 })
4721 .map(|timing| timing.operations)
4722 .sum::<u64>();
4723 assert!(operations < MAX_SCRIPT_OPERATIONS / 2, "{operations}");
4724 measured.push(operations);
4725 }
4726 assert_eq!(measured[0], measured[1]);
4727 }
4728
4729 #[test]
4730 fn retained_virtual_renderer_replays_curried_node_presentation() {
4731 let mut engine = RuntimeEngine::new();
4732 let compiled = engine
4733 .compile_named(
4734 "virtual-rich-presentation",
4735 r#"
4736 fn render_item(content, ctx, payload) {
4737 content[payload.key]
4738 }
4739 fn view(ctx) {
4740 let data = [];
4741 let content = #{};
4742 for index in 0..32 {
4743 let key = `row-${index}`;
4744 data.push(#{ key: key });
4745 content[key] = text(`Rich ${index}`);
4746 }
4747 virtual_collection(#{
4748 key: "rows", label: "Rows", data: data,
4749 estimated_height: 24, height: 24, overdraw_pixels: 0,
4750 alignment: "top", follow_tail: false,
4751 }, Fn("render_item").curry(content))
4752 }
4753 "#,
4754 )
4755 .unwrap();
4756 let runtime = Rc::new(RefCell::new(crate::UiRuntimeState::new()));
4757 let root_path = ComponentInstancePath::root("App", "rich");
4758 let mut lifecycle = crate::ScriptLifecycle::new(
4759 compiled,
4760 Rc::clone(&runtime),
4761 root_path.clone(),
4762 None,
4763 BTreeMap::new(),
4764 &ComponentStateSchema::default(),
4765 )
4766 .unwrap();
4767 lifecycle.start(&mut engine).unwrap();
4768 let id = engine
4769 .virtual_collection_ids_in_scope(&root_path)
4770 .into_iter()
4771 .next()
4772 .unwrap();
4773 runtime.borrow().virtual_requests.request(id, [31]);
4774 assert!(lifecycle.realize_virtual_requests(&mut engine).unwrap());
4775 let crate::UiNodeKind::VirtualCollection { spec } = lifecycle.root().unwrap().kind() else {
4776 unreachable!()
4777 };
4778 assert!(matches!(
4779 spec.realized[&31].kind(),
4780 crate::UiNodeKind::Text { text } if text == "Rich 31"
4781 ));
4782 }
4783
4784 #[test]
4785 fn delayed_component_callback_starts_with_a_fresh_operation_budget() {
4786 let module = r#"
4787 define_component(#{
4788 metadata: #{ id: "components/counter", "export": "Counter", version: "0.1.1",
4789 runtime_api: #{ min_inclusive: 1, max_exclusive: 2 }, dependencies: [], capabilities: #{} },
4790 schema: #{ props: #{}, state: #{ fields: #{} }, events: #{}, slots: #{}, parts: ["root"] },
4791 render: Fn("render_Counter")
4792 });
4793 fn Counter(props) { render_component("components/counter", props) }
4794 fn render_Counter(ctx, props) { text("counter").on_click(Fn("clicked")) }
4795 fn clicked(ctx, payload) {
4796 let n = 0;
4797 for i in 0..100000 { n += 1; }
4798 n
4799 }
4800 "#;
4801 let mut resolver = crate::RestrictedModuleResolver::new();
4802 resolver.insert("components/counter", module).unwrap();
4803 let mut engine = RuntimeEngine::new();
4804 engine.set_module_resolver(resolver);
4805 let compiled = engine
4806 .compile_self_contained_named(
4807 "callback-budget",
4808 r#"import "components/counter" as c;
4809 fn view(ctx) {
4810 let n = 0;
4811 for i in 0..250000 { n += 1; }
4812 c::Counter(#{} )
4813 }"#,
4814 )
4815 .unwrap();
4816 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
4817 let mut lifecycle = crate::ScriptLifecycle::new(
4818 compiled,
4819 runtime,
4820 ComponentInstancePath::root("App", "budget"),
4821 None,
4822 BTreeMap::new(),
4823 &ComponentStateSchema::default(),
4824 )
4825 .unwrap();
4826 let root = lifecycle.start(&mut engine).unwrap();
4827 let crate::UiEventHandler::Script(callback) = root.handlers()["click"][0].handler() else {
4828 panic!("component must expose a script click callback");
4829 };
4830 let callback = callback.clone();
4831 assert!(
4832 lifecycle
4833 .invoke_callback_transactional(&engine, &callback, UiValue::Null)
4834 .is_ok()
4835 );
4836 }
4837
4838 #[test]
4839 fn old_component_handles_cannot_target_a_same_key_remount() {
4840 let module = r#"
4841 define_component(#{
4842 metadata: #{ id: "components/incarnation", "export": "Counter", version: "0.1.1",
4843 runtime_api: #{ min_inclusive: 1, max_exclusive: 2 }, dependencies: [], capabilities: #{} },
4844 schema: #{ props: #{ key: #{ schema: #{ type: "string" }, required: true, sensitive: false } },
4845 state: #{ fields: #{ count: #{ schema: #{ type: "integer" }, "default": #{ type: "integer", value: 0 } } } },
4846 events: #{}, slots: #{}, parts: ["root"] }, render: Fn("render_Counter")
4847 });
4848 fn Counter(props) { render_component("components/incarnation", props) }
4849 fn increment(ctx, payload) { ctx.set_state("count", ctx.get_state("count") + 1); }
4850 fn render_Counter(ctx, props) {
4851 let progress = signal("progress", 0.0);
4852 text(`${ctx.get_state("count")}`).bind_signal("opacity", progress).on_click(Fn("increment"))
4853 }
4854 "#;
4855 let mut resolver = crate::RestrictedModuleResolver::new();
4856 resolver.insert("components/incarnation", module).unwrap();
4857 let mut engine = RuntimeEngine::new();
4858 engine.set_module_resolver(resolver);
4859 let compiled = engine
4860 .compile_self_contained_named(
4861 "incarnation",
4862 r#"import "components/incarnation" as c;
4863 fn view(ctx) {
4864 if ctx.get_state("visible") { c::Counter(#{ key: "same" }) }
4865 else { text("hidden") }
4866 }"#,
4867 )
4868 .unwrap();
4869 let runtime = Rc::new(RefCell::new(UiRuntimeState::new()));
4870 let root_path = ComponentInstancePath::root("App", "incarnation");
4871 let schema = ComponentStateSchema::new(BTreeMap::from([(
4872 "visible".to_owned(),
4873 crate::StateField::new(crate::ValueSchema::Bool, UiValue::Bool(true)),
4874 )]))
4875 .unwrap();
4876 let mut lifecycle = crate::ScriptLifecycle::new(
4877 compiled,
4878 Rc::clone(&runtime),
4879 root_path.clone(),
4880 None,
4881 BTreeMap::new(),
4882 &schema,
4883 )
4884 .unwrap();
4885 lifecycle.start(&mut engine).unwrap();
4886 let component = engine
4887 .component_invocations()
4888 .next()
4889 .unwrap()
4890 .path()
4891 .clone();
4892 let crate::UiEventHandler::Script(old_callback) =
4893 lifecycle.root().unwrap().handlers()["click"][0].handler()
4894 else {
4895 panic!("component must expose its callback");
4896 };
4897 let old_callback = old_callback.clone();
4898 let old_signal = runtime
4899 .borrow()
4900 .signals
4901 .resolve(&component, "progress")
4902 .unwrap();
4903 runtime
4904 .borrow_mut()
4905 .set_component_state_from_host(&root_path, "visible", UiValue::Bool(false))
4906 .unwrap();
4907 lifecycle.render_dirty(&mut engine).unwrap();
4908 runtime
4909 .borrow_mut()
4910 .set_component_state_from_host(&root_path, "visible", UiValue::Bool(true))
4911 .unwrap();
4912 lifecycle.render_dirty(&mut engine).unwrap();
4913 assert!(matches!(
4914 lifecycle.invoke_callback_transactional(&engine, &old_callback, UiValue::Null),
4915 Err(crate::LifecycleError::Runtime(
4916 RuntimeError::StaleComponentCallback { .. }
4917 ))
4918 ));
4919 assert!(matches!(
4920 runtime
4921 .borrow_mut()
4922 .signals
4923 .write(&old_signal, crate::SignalValue::Float(0.5)),
4924 Err(crate::SignalError::Stale(_))
4925 ));
4926 }
4927}