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