1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4 ComponentNode, ComputedPurity, ComputedValue, EffectStatementSyntaxKind, ExecutionBoundary,
5 ExpressionGraph, IrComputedEvaluationPlan, IrReactiveGraph, IrReactiveNode, IrReactiveNodeKind,
6 IrReactiveTransitiveAnalysis, IrUpdateScheduler, SemanticId, SemanticOwner, SemanticTypeModel,
7 SourceProvenance, UnsupportedEffectStatementKind,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EffectExecutionPolicy {
17 AfterInitialRenderAndCompletedActionBatch,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EffectValidation {
23 Unvalidated,
24 Valid,
25 Invalid,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
30pub enum EffectSemanticViolationKind {
31 Async,
32 CleanupLifecycleUnavailable,
33 ReactiveStateMutation,
34 ActionInvocation,
35 EffectInvocation,
36 ComponentMethodInvocation,
37 UnresolvedComponentCall,
38 UnresolvedComponentAssignment,
39 UnknownExternalCapability,
40 CapabilitySignature,
41 CapabilityBoundary,
42 CapabilitySerialization,
43 ValueReturn,
44 UnsupportedStatement,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct EffectSemanticViolation {
50 pub kind: EffectSemanticViolationKind,
51 pub statement: Option<SemanticId>,
52 pub provenance: SourceProvenance,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct EffectReactiveAnalysis {
58 pub effect: SemanticId,
59 pub dependencies: Vec<SemanticId>,
60 pub dependents: Vec<SemanticId>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ActionBatch {
65 pub id: SemanticId,
66 pub authored_action_endpoint: SemanticId,
67 pub ordered_write_records: Vec<SemanticId>,
68 pub written_states: Vec<SemanticId>,
69 pub provenance: SourceProvenance,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ActionBatchEffectTrigger {
74 pub action_batch: SemanticId,
75 pub effects: Vec<SemanticId>,
76 pub matched_states: BTreeMap<SemanticId, Vec<SemanticId>>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Default)]
80pub struct EffectTriggerPlan {
81 pub initial_effects: Vec<SemanticId>,
82 pub action_batches: BTreeMap<SemanticId, ActionBatch>,
83 pub action_batch_triggers: Vec<ActionBatchEffectTrigger>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct EffectComputedPrerequisiteBatch {
93 pub source_batch_index: u32,
94 pub computed: Vec<SemanticId>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum EffectRenderBoundary {
100 AfterInitialRender,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct EffectExecutionBatch {
106 pub index: u32,
107 pub effects: Vec<SemanticId>,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum UnplannedEffectReason {
113 UnavailableComputedPrerequisite,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct UnplannedEffect {
119 pub effect: SemanticId,
120 pub reason: UnplannedEffectReason,
121 pub computed_dependencies: Vec<SemanticId>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Default)]
126pub struct InitialEffectExecutionPlan {
127 pub required_computed: Vec<SemanticId>,
128 pub prerequisite_batches: Vec<EffectComputedPrerequisiteBatch>,
129 pub render_boundary: Option<EffectRenderBoundary>,
130 pub effect_batches: Vec<EffectExecutionBatch>,
131 pub unplanned_effects: Vec<UnplannedEffect>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ActionEffectExecutionPlan {
137 pub action_batch: SemanticId,
138 pub required_computed: Vec<SemanticId>,
139 pub prerequisite_batches: Vec<EffectComputedPrerequisiteBatch>,
140 pub effect_batches: Vec<EffectExecutionBatch>,
141 pub unplanned_effects: Vec<UnplannedEffect>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct EffectExecutionPlan {
147 pub initial: InitialEffectExecutionPlan,
148 pub actions: Vec<ActionEffectExecutionPlan>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum EffectDeclaration {
154 LegacyMethod(SemanticId),
155 V2Field,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Effect {
161 pub id: SemanticId,
162 pub owner: SemanticOwner,
163 pub declaration: EffectDeclaration,
164 pub name: String,
165 pub declaration_order: Option<u32>,
167 pub execution_boundary: ExecutionBoundary,
168 pub execution_policy: EffectExecutionPolicy,
169 pub validation: EffectValidation,
170 pub semantic_violations: Vec<EffectSemanticViolation>,
171 pub provenance: SourceProvenance,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct EffectBody {
177 pub effect: SemanticId,
178 pub statements: Vec<SemanticId>,
179 pub cleanup: Option<EffectCleanupBody>,
180 pub provenance: SourceProvenance,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct EffectCleanupBody {
186 pub statements: Vec<SemanticId>,
187 pub provenance: SourceProvenance,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct EffectStatement {
193 pub id: SemanticId,
194 pub owner: SemanticId,
195 pub kind: EffectStatementKind,
196 pub provenance: SourceProvenance,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum EffectStatementKind {
201 ExternalMemberAssignment {
202 target: SemanticId,
203 value: SemanticId,
204 },
205 CapabilityCall {
206 callee: SemanticId,
207 arguments: Vec<SemanticId>,
208 },
209 EffectReturn {
210 value: Option<SemanticId>,
211 },
212 Empty,
213 Unsupported(UnsupportedEffectStatementKind),
214}
215
216#[must_use]
219pub fn collect_effects(
220 components: &[ComponentNode],
221 provenance: &BTreeMap<SemanticId, SourceProvenance>,
222) -> BTreeMap<SemanticId, Effect> {
223 let mut effects = BTreeMap::new();
224 for component in components {
225 for method in component.methods.iter().filter(|method| method.is_effect()) {
226 let id = component.id.effect(&method.name);
227 let method_provenance = provenance
228 .get(&method.id)
229 .expect("effect methods should have canonical provenance")
230 .clone();
231 effects.insert(
232 id.clone(),
233 Effect {
234 id,
235 owner: SemanticOwner::entity(component.id.clone()),
236 declaration: EffectDeclaration::LegacyMethod(method.id.clone()),
237 name: method.name.clone(),
238 declaration_order: None,
239 execution_boundary: ExecutionBoundary::Client,
240 execution_policy:
241 EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch,
242 validation: EffectValidation::Unvalidated,
243 semantic_violations: Vec::new(),
244 provenance: method_provenance,
245 },
246 );
247 }
248 for field in &component.effect_fields {
249 let id = component.id.effect(&field.name);
250 effects.insert(
251 id.clone(),
252 Effect {
253 id,
254 owner: field.owner.clone(),
255 declaration: EffectDeclaration::V2Field,
256 name: field.name.clone(),
257 declaration_order: Some(field.declaration_order),
258 execution_boundary: ExecutionBoundary::Client,
259 execution_policy:
260 EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch,
261 validation: EffectValidation::Unvalidated,
262 semantic_violations: Vec::new(),
263 provenance: field.provenance.clone(),
264 },
265 );
266 }
267 }
268 effects
269}
270
271#[allow(clippy::too_many_lines)]
273#[must_use]
274pub fn validate_effects(
275 components: &[ComponentNode],
276 mut effects: BTreeMap<SemanticId, Effect>,
277 statements: &BTreeMap<SemanticId, EffectStatement>,
278 semantic_types: &SemanticTypeModel,
279) -> BTreeMap<SemanticId, Effect> {
280 for effect in effects.values_mut() {
281 let mut violations = Vec::new();
282 if effect_is_async(components, effect) {
283 violations.push(EffectSemanticViolation {
284 kind: EffectSemanticViolationKind::Async,
285 statement: None,
286 provenance: effect.provenance.clone(),
287 });
288 }
289 if effect_has_cleanup(components, effect)
290 && !matches!(effect.declaration, EffectDeclaration::V2Field)
291 {
292 violations.push(EffectSemanticViolation {
293 kind: EffectSemanticViolationKind::CleanupLifecycleUnavailable,
294 statement: None,
295 provenance: effect.provenance.clone(),
296 });
297 }
298 for statement in statements
299 .values()
300 .filter(|statement| statement.owner == effect.id)
301 {
302 let Some(record) = semantic_types.effect_statements.get(&statement.id) else {
303 continue;
304 };
305 let statement_violation = match record.operation_classification {
306 crate::EffectOperationClassification::ReactiveStateAssignment => {
307 Some(EffectSemanticViolationKind::ReactiveStateMutation)
308 }
309 crate::EffectOperationClassification::ComponentActionCall => {
310 Some(EffectSemanticViolationKind::ActionInvocation)
311 }
312 crate::EffectOperationClassification::ComponentEffectCall => {
313 Some(EffectSemanticViolationKind::EffectInvocation)
314 }
315 crate::EffectOperationClassification::ComponentMethodCall => {
316 Some(EffectSemanticViolationKind::ComponentMethodInvocation)
317 }
318 crate::EffectOperationClassification::UnresolvedComponentCall => {
319 Some(EffectSemanticViolationKind::UnresolvedComponentCall)
320 }
321 crate::EffectOperationClassification::UnresolvedComponentAssignment => {
322 Some(EffectSemanticViolationKind::UnresolvedComponentAssignment)
323 }
324 crate::EffectOperationClassification::UnknownExternalCapability => {
325 Some(EffectSemanticViolationKind::UnknownExternalCapability)
326 }
327 crate::EffectOperationClassification::ValueReturn => {
328 Some(EffectSemanticViolationKind::ValueReturn)
329 }
330 crate::EffectOperationClassification::UnsupportedStatement => {
331 Some(EffectSemanticViolationKind::UnsupportedStatement)
332 }
333 crate::EffectOperationClassification::RecognizedCapability
334 | crate::EffectOperationClassification::BareReturn
335 | crate::EffectOperationClassification::Empty => None,
336 };
337 if let Some(kind) = statement_violation {
338 violations.push(EffectSemanticViolation {
339 kind,
340 statement: Some(statement.id.clone()),
341 provenance: statement.provenance.clone(),
342 });
343 }
344 for (compatibility, kind) in [
345 (
346 record.signature_compatibility,
347 EffectSemanticViolationKind::CapabilitySignature,
348 ),
349 (
350 record.boundary_compatibility,
351 EffectSemanticViolationKind::CapabilityBoundary,
352 ),
353 (
354 record.serialization_compatibility,
355 EffectSemanticViolationKind::CapabilitySerialization,
356 ),
357 ] {
358 if compatibility != crate::EffectCompatibility::Compatible
359 && record.operation_classification
360 == crate::EffectOperationClassification::RecognizedCapability
361 {
362 violations.push(EffectSemanticViolation {
363 kind,
364 statement: Some(statement.id.clone()),
365 provenance: statement.provenance.clone(),
366 });
367 }
368 }
369 }
370 violations.sort_by(|left, right| {
371 (
372 left.kind,
373 left.provenance.path.as_path(),
374 left.provenance.span.start,
375 left.provenance.span.end,
376 )
377 .cmp(&(
378 right.kind,
379 right.provenance.path.as_path(),
380 right.provenance.span.start,
381 right.provenance.span.end,
382 ))
383 });
384 violations.dedup_by(|left, right| {
385 left.kind == right.kind
386 && left.statement == right.statement
387 && left.provenance == right.provenance
388 });
389 effect.validation = if violations.is_empty() {
390 EffectValidation::Valid
391 } else {
392 EffectValidation::Invalid
393 };
394 effect.semantic_violations = violations;
395 }
396 effects
397}
398
399#[must_use]
401pub fn analyze_effect_reactivity(
402 components: &[ComponentNode],
403 computed_values: &BTreeMap<SemanticId, crate::ComputedValue>,
404 effects: &BTreeMap<SemanticId, Effect>,
405 transitive: &IrReactiveTransitiveAnalysis,
406) -> BTreeMap<SemanticId, EffectReactiveAnalysis> {
407 let identities = components
408 .iter()
409 .flat_map(|component| component.state_fields.iter().map(|field| field.id.clone()))
410 .chain(computed_values.keys().cloned())
411 .chain(effects.keys().cloned())
412 .map(|id| (id.as_str().to_string(), id))
413 .collect::<BTreeMap<_, _>>();
414 effects
415 .values()
416 .filter(|effect| effect.validation == EffectValidation::Valid)
417 .map(|effect| {
418 let map_ids = |ids: Option<&Vec<String>>| {
419 ids.into_iter()
420 .flatten()
421 .filter_map(|id| identities.get(id).cloned())
422 .collect()
423 };
424 (
425 effect.id.clone(),
426 EffectReactiveAnalysis {
427 effect: effect.id.clone(),
428 dependencies: map_ids(transitive.dependencies.get(effect.id.as_str())),
429 dependents: map_ids(transitive.dependents.get(effect.id.as_str())),
430 },
431 )
432 })
433 .collect()
434}
435
436#[must_use]
440pub fn derive_effect_trigger_plan(
441 components: &[ComponentNode],
442 effects: &BTreeMap<SemanticId, Effect>,
443 analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
444 provenance: &BTreeMap<SemanticId, SourceProvenance>,
445) -> EffectTriggerPlan {
446 let initial_effects = effects
447 .values()
448 .filter(|effect| effect.validation == EffectValidation::Valid)
449 .map(|effect| effect.id.clone())
450 .collect::<Vec<_>>();
451 let mut action_batches = BTreeMap::new();
452 for component in components {
453 for (name, endpoint) in component.action_endpoint_ids() {
454 let writes = component
455 .actions
456 .iter()
457 .filter(|action| action.method == name)
458 .collect::<Vec<_>>();
459 let id = component.id.action_batch(&name);
460 action_batches.insert(
461 id.clone(),
462 ActionBatch {
463 id,
464 authored_action_endpoint: endpoint.clone(),
465 ordered_write_records: writes.iter().map(|action| action.id.clone()).collect(),
466 written_states: writes
467 .iter()
468 .map(|action| component.id.state_field(&action.field))
469 .collect::<std::collections::BTreeSet<_>>()
470 .into_iter()
471 .collect(),
472 provenance: provenance
473 .get(&endpoint)
474 .expect("action endpoints should have canonical provenance")
475 .clone(),
476 },
477 );
478 }
479 }
480 let action_batch_triggers = action_batches
481 .values()
482 .filter_map(|batch| {
483 let matched_states = initial_effects
484 .iter()
485 .filter_map(|effect| {
486 let matches = analysis
487 .get(effect)?
488 .dependencies
489 .iter()
490 .filter(|dependency| batch.written_states.contains(dependency))
491 .cloned()
492 .collect::<Vec<_>>();
493 (!matches.is_empty()).then(|| (effect.clone(), matches))
494 })
495 .collect::<BTreeMap<_, _>>();
496 (!matched_states.is_empty()).then(|| ActionBatchEffectTrigger {
497 action_batch: batch.id.clone(),
498 effects: matched_states.keys().cloned().collect(),
499 matched_states,
500 })
501 })
502 .collect();
503 EffectTriggerPlan {
504 initial_effects,
505 action_batches,
506 action_batch_triggers,
507 }
508}
509
510#[must_use]
517pub fn plan_effect_execution(
518 computed_values: &BTreeMap<SemanticId, ComputedValue>,
519 effects: &BTreeMap<SemanticId, Effect>,
520 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
521 trigger_plan: &EffectTriggerPlan,
522 reactive_transitive_analysis: &IrReactiveTransitiveAnalysis,
523 computed_evaluation_plan: &IrComputedEvaluationPlan,
524) -> EffectExecutionPlan {
525 let computed_by_text = computed_values
526 .keys()
527 .map(|id| (id.as_str().to_string(), id.clone()))
528 .collect::<BTreeMap<_, _>>();
529 let executable = computed_evaluation_plan
530 .evaluation_order
531 .iter()
532 .filter_map(|id| computed_by_text.get(id))
533 .filter(|id| {
534 computed_values
535 .get(*id)
536 .is_some_and(|computed| computed.purity == ComputedPurity::Pure)
537 })
538 .cloned()
539 .collect::<BTreeSet<_>>();
540 let initial = build_initial_effect_execution_plan(
541 &trigger_plan.initial_effects,
542 effects,
543 effect_analysis,
544 computed_values,
545 &executable,
546 computed_evaluation_plan,
547 );
548 let actions = trigger_plan
549 .action_batch_triggers
550 .iter()
551 .filter_map(|trigger| {
552 let batch = trigger_plan.action_batches.get(&trigger.action_batch)?;
553 let invalidated = batch
554 .written_states
555 .iter()
556 .flat_map(|state| reactive_transitive_analysis.dependents_of(state.as_str()))
557 .filter_map(|id| computed_by_text.get(id).cloned())
558 .collect::<BTreeSet<_>>();
559 Some(build_action_effect_execution_plan(
560 &batch.id,
561 &trigger.effects,
562 effects,
563 effect_analysis,
564 computed_values,
565 &executable,
566 &invalidated,
567 computed_evaluation_plan,
568 ))
569 })
570 .collect();
571
572 EffectExecutionPlan { initial, actions }
573}
574
575fn build_initial_effect_execution_plan(
576 eligible_effects: &[SemanticId],
577 effects: &BTreeMap<SemanticId, Effect>,
578 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
579 computed_values: &BTreeMap<SemanticId, ComputedValue>,
580 executable: &BTreeSet<SemanticId>,
581 computed_evaluation_plan: &IrComputedEvaluationPlan,
582) -> InitialEffectExecutionPlan {
583 let membership = effect_execution_membership(
584 eligible_effects,
585 effects,
586 effect_analysis,
587 computed_values,
588 executable,
589 );
590 let required_computed = membership
591 .schedulable_effects
592 .iter()
593 .flat_map(|effect| required_computed_for_effect(effect, effect_analysis, computed_values))
594 .collect::<BTreeSet<_>>();
595 InitialEffectExecutionPlan {
596 prerequisite_batches: select_prerequisite_batches(
597 &required_computed,
598 computed_evaluation_plan,
599 computed_values,
600 ),
601 required_computed: required_computed.into_iter().collect(),
602 render_boundary: Some(EffectRenderBoundary::AfterInitialRender),
603 effect_batches: schedule_terminal_effects(&membership.schedulable_effects, effects),
604 unplanned_effects: membership.unplanned_effects,
605 }
606}
607
608#[allow(clippy::too_many_arguments)]
609fn build_action_effect_execution_plan(
610 action_batch: &SemanticId,
611 eligible_effects: &[SemanticId],
612 effects: &BTreeMap<SemanticId, Effect>,
613 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
614 computed_values: &BTreeMap<SemanticId, ComputedValue>,
615 executable: &BTreeSet<SemanticId>,
616 invalidated: &BTreeSet<SemanticId>,
617 computed_evaluation_plan: &IrComputedEvaluationPlan,
618) -> ActionEffectExecutionPlan {
619 let membership = effect_execution_membership(
620 eligible_effects,
621 effects,
622 effect_analysis,
623 computed_values,
624 executable,
625 );
626 let required_computed = membership
627 .schedulable_effects
628 .iter()
629 .flat_map(|effect| required_computed_for_effect(effect, effect_analysis, computed_values))
630 .filter(|computed| invalidated.contains(computed))
631 .collect::<BTreeSet<_>>();
632 ActionEffectExecutionPlan {
633 action_batch: action_batch.clone(),
634 prerequisite_batches: select_prerequisite_batches(
635 &required_computed,
636 computed_evaluation_plan,
637 computed_values,
638 ),
639 required_computed: required_computed.into_iter().collect(),
640 effect_batches: schedule_terminal_effects(&membership.schedulable_effects, effects),
641 unplanned_effects: membership.unplanned_effects,
642 }
643}
644
645#[derive(Debug, Default)]
646struct EffectExecutionMembership {
647 schedulable_effects: Vec<SemanticId>,
648 unplanned_effects: Vec<UnplannedEffect>,
649}
650
651fn effect_execution_membership(
652 eligible_effects: &[SemanticId],
653 effects: &BTreeMap<SemanticId, Effect>,
654 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
655 computed_values: &BTreeMap<SemanticId, ComputedValue>,
656 executable: &BTreeSet<SemanticId>,
657) -> EffectExecutionMembership {
658 let mut membership = EffectExecutionMembership::default();
659 for effect in eligible_effects {
660 if !effects.contains_key(effect) {
661 continue;
662 }
663 let unavailable = required_computed_for_effect(effect, effect_analysis, computed_values)
664 .into_iter()
665 .filter(|computed| !executable.contains(computed))
666 .collect::<Vec<_>>();
667 if unavailable.is_empty() {
668 membership.schedulable_effects.push(effect.clone());
669 } else {
670 membership.unplanned_effects.push(UnplannedEffect {
671 effect: effect.clone(),
672 reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
673 computed_dependencies: unavailable,
674 });
675 }
676 }
677 membership.schedulable_effects.sort();
678 membership.schedulable_effects.dedup();
679 membership
680 .unplanned_effects
681 .sort_by(|left, right| left.effect.cmp(&right.effect));
682 membership
683}
684
685fn required_computed_for_effect(
686 effect: &SemanticId,
687 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
688 computed_values: &BTreeMap<SemanticId, ComputedValue>,
689) -> Vec<SemanticId> {
690 effect_analysis
691 .get(effect)
692 .into_iter()
693 .flat_map(|analysis| &analysis.dependencies)
694 .filter(|dependency| computed_values.contains_key(*dependency))
695 .cloned()
696 .collect::<BTreeSet<_>>()
697 .into_iter()
698 .collect()
699}
700
701fn select_prerequisite_batches(
702 required_computed: &BTreeSet<SemanticId>,
703 computed_evaluation_plan: &IrComputedEvaluationPlan,
704 computed_values: &BTreeMap<SemanticId, ComputedValue>,
705) -> Vec<EffectComputedPrerequisiteBatch> {
706 computed_evaluation_plan
707 .update_batches
708 .iter()
709 .enumerate()
710 .filter_map(|(index, batch)| {
711 let computed = batch
712 .iter()
713 .filter_map(|id| {
714 computed_values
715 .keys()
716 .find(|computed| computed.as_str() == id)
717 })
718 .filter(|computed| required_computed.contains(*computed))
719 .cloned()
720 .collect::<Vec<_>>();
721 (!computed.is_empty()).then_some(EffectComputedPrerequisiteBatch {
722 source_batch_index: u32::try_from(index)
723 .expect("computed scheduler batch index should fit u32"),
724 computed,
725 })
726 })
727 .collect()
728}
729
730fn schedule_terminal_effects(
731 effects: &[SemanticId],
732 canonical_effects: &BTreeMap<SemanticId, Effect>,
733) -> Vec<EffectExecutionBatch> {
734 let nodes = effects
735 .iter()
736 .filter_map(|effect| {
737 canonical_effects.get(effect).map(|canonical| {
738 (
739 effect.as_str().to_string(),
740 IrReactiveNode {
741 id: effect.as_str().to_string(),
742 kind: IrReactiveNodeKind::Effect,
743 provenance: canonical.provenance.clone(),
744 },
745 )
746 })
747 })
748 .collect();
749 let inspection = IrUpdateScheduler::new(IrReactiveGraph {
750 nodes,
751 edges: Vec::new(),
752 })
753 .inspect();
754 inspection
755 .batches
756 .into_iter()
757 .enumerate()
758 .filter_map(|(index, batch)| {
759 let mut effects = batch
760 .into_iter()
761 .filter_map(|id| {
762 canonical_effects
763 .keys()
764 .find(|effect| effect.as_str() == id)
765 .cloned()
766 })
767 .collect::<Vec<_>>();
768 effects.sort_by(|left, right| {
769 let left_effect = canonical_effects
770 .get(left)
771 .expect("scheduled effect should remain canonical");
772 let right_effect = canonical_effects
773 .get(right)
774 .expect("scheduled effect should remain canonical");
775 left_effect
776 .declaration_order
777 .cmp(&right_effect.declaration_order)
778 .then_with(|| left.cmp(right))
779 });
780 (!effects.is_empty()).then_some(EffectExecutionBatch {
781 index: u32::try_from(index).expect("effect scheduler batch index should fit u32"),
782 effects,
783 })
784 })
785 .collect()
786}
787
788#[must_use]
790pub fn lower_effect_bodies(
791 components: &[ComponentNode],
792 effects: &BTreeMap<SemanticId, Effect>,
793 expression_graph: &ExpressionGraph,
794) -> (
795 BTreeMap<SemanticId, EffectBody>,
796 BTreeMap<SemanticId, EffectStatement>,
797) {
798 let mut bodies = BTreeMap::new();
799 let mut statements = BTreeMap::new();
800 for effect in effects.values() {
801 let Some(syntax) = effect_body_syntax(components, effect) else {
802 continue;
803 };
804 let mut body_statement_ids = Vec::new();
805 for (index, statement) in syntax.statements.iter().enumerate() {
806 let id = effect.id.effect_statement(index);
807 let path = format!("statement:{index}");
808 let expression = |suffix: &str| effect.id.expression(&format!("{path}/{suffix}"));
809 let kind = effect_statement_kind(statement, &expression);
810 assert_effect_statement_expressions_exist(&kind, expression_graph);
811 body_statement_ids.push(id.clone());
812 statements.insert(
813 id.clone(),
814 EffectStatement {
815 id,
816 owner: effect.id.clone(),
817 kind,
818 provenance: SourceProvenance::new(&effect.provenance.path, statement.span),
819 },
820 );
821 }
822 let cleanup = syntax.cleanup.as_ref().map(|cleanup| {
823 let mut cleanup_statement_ids = Vec::new();
824 for (index, statement) in cleanup.body.statements.iter().enumerate() {
825 let id = effect.id.effect_cleanup_statement(index);
826 let path = format!("cleanup/statement:{index}");
827 let expression = |suffix: &str| effect.id.expression(&format!("{path}/{suffix}"));
828 let kind = effect_statement_kind(statement, &expression);
829 assert_effect_statement_expressions_exist(&kind, expression_graph);
830 cleanup_statement_ids.push(id.clone());
831 statements.insert(
832 id.clone(),
833 EffectStatement {
834 id,
835 owner: effect.id.clone(),
836 kind,
837 provenance: SourceProvenance::new(&effect.provenance.path, statement.span),
838 },
839 );
840 }
841 EffectCleanupBody {
842 statements: cleanup_statement_ids,
843 provenance: SourceProvenance::new(&effect.provenance.path, cleanup.span),
844 }
845 });
846 bodies.insert(
847 effect.id.clone(),
848 EffectBody {
849 effect: effect.id.clone(),
850 statements: body_statement_ids,
851 cleanup,
852 provenance: effect.provenance.clone(),
853 },
854 );
855 }
856 (bodies, statements)
857}
858
859fn effect_statement_kind(
860 statement: &crate::EffectStatementSyntax,
861 expression: &impl Fn(&str) -> SemanticId,
862) -> EffectStatementKind {
863 match &statement.kind {
864 EffectStatementSyntaxKind::StaticMemberAssignment { .. } => {
865 EffectStatementKind::ExternalMemberAssignment {
866 target: expression("target"),
867 value: expression("value"),
868 }
869 }
870 EffectStatementSyntaxKind::CapabilityCall { arguments, .. } => {
871 EffectStatementKind::CapabilityCall {
872 callee: expression("callee"),
873 arguments: (0..arguments.len())
874 .map(|argument| expression(&format!("argument:{argument}")))
875 .collect(),
876 }
877 }
878 EffectStatementSyntaxKind::EffectReturn { value } => EffectStatementKind::EffectReturn {
879 value: value.as_ref().map(|_| expression("return")),
880 },
881 EffectStatementSyntaxKind::Empty => EffectStatementKind::Empty,
882 EffectStatementSyntaxKind::Unsupported(kind) => EffectStatementKind::Unsupported(*kind),
883 }
884}
885
886fn effect_is_async(components: &[ComponentNode], effect: &Effect) -> bool {
887 let Some(component_id) = effect.owner.entity_id() else {
888 return false;
889 };
890 let Some(component) = components
891 .iter()
892 .find(|component| component.id == *component_id)
893 else {
894 return false;
895 };
896 match &effect.declaration {
897 EffectDeclaration::LegacyMethod(method_id) => component
898 .methods
899 .iter()
900 .find(|method| method.id == *method_id)
901 .is_some_and(|method| method.is_async),
902 EffectDeclaration::V2Field => component
903 .effect_fields
904 .iter()
905 .find(|field| field.name == effect.name)
906 .is_some_and(|field| field.is_async),
907 }
908}
909
910fn effect_has_cleanup(components: &[ComponentNode], effect: &Effect) -> bool {
911 effect_body_syntax(components, effect).is_some_and(|body| body.cleanup.is_some())
912}
913
914fn effect_body_syntax<'a>(
915 components: &'a [ComponentNode],
916 effect: &Effect,
917) -> Option<&'a crate::EffectBodySyntax> {
918 let component_id = effect.owner.entity_id()?;
919 let component = components
920 .iter()
921 .find(|component| component.id == *component_id)?;
922 match &effect.declaration {
923 EffectDeclaration::LegacyMethod(method_id) => component
924 .methods
925 .iter()
926 .find(|method| method.id == *method_id)
927 .and_then(|method| method.effect_body.as_ref()),
928 EffectDeclaration::V2Field => component
929 .effect_fields
930 .iter()
931 .find(|field| field.name == effect.name)
932 .map(|field| &field.body),
933 }
934}
935
936fn assert_effect_statement_expressions_exist(kind: &EffectStatementKind, graph: &ExpressionGraph) {
937 let expressions = match kind {
938 EffectStatementKind::ExternalMemberAssignment { target, value } => vec![target, value],
939 EffectStatementKind::CapabilityCall { callee, arguments } => {
940 let mut expressions = vec![callee];
941 expressions.extend(arguments);
942 expressions
943 }
944 EffectStatementKind::EffectReturn { value } => value.iter().collect(),
945 EffectStatementKind::Empty | EffectStatementKind::Unsupported(_) => Vec::new(),
946 };
947 assert!(expressions.iter().all(|id| graph.node(id).is_some()));
948}
949
950#[cfg(test)]
951mod tests {
952 use super::{schedule_terminal_effects, Effect, EffectExecutionBatch};
953
954 use crate::{
955 build_application_semantic_model, build_component_graph, build_semantic_graph,
956 collect_effects, validate_application_semantic_model, EffectDeclaration,
957 EffectExecutionPolicy, EffectSemanticViolationKind, EffectStatementKind, EffectValidation,
958 ExecutionBoundary, ExpressionNodeKind, SemanticEntity, SemanticEntityKind, SemanticId,
959 SemanticOwner, SemanticReferenceKind, SourceProvenance, UnsupportedEffectStatementKind,
960 };
961
962 #[test]
963 fn collects_stable_effect_entities_from_decorated_methods() {
964 let parsed = presolve_parser::parse_file(
965 "src/Effects.tsx",
966 r#"
967@component("x-effects")
968class Effects extends Component {
969 @effect()
970 syncTitle() {
971 document.title = "Presolve";
972 }
973}
974"#,
975 );
976 let graph = build_component_graph(&parsed);
977 let component = &graph.components[0];
978 let effect = collect_effects(&graph.components, &graph.provenance)
979 .into_values()
980 .next()
981 .expect("effect entity");
982
983 assert_eq!(effect.id.as_str(), "component:x-effects/effect:syncTitle");
984 assert_eq!(
985 effect.declaration,
986 EffectDeclaration::LegacyMethod(component.methods[0].id.clone())
987 );
988 assert_eq!(effect.owner, component.methods[0].owner);
989 assert_eq!(effect.execution_boundary, ExecutionBoundary::Client);
990 assert_eq!(
991 effect.execution_policy,
992 EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch
993 );
994 }
995
996 #[test]
997 fn schedules_v2_effects_by_field_declaration_order() {
998 let component = SemanticId::component(Some("x-v2-order"), "V2Order");
999 let early = component.effect("zEarly");
1000 let late = component.effect("aLate");
1001 let provenance = SourceProvenance::new(
1002 "src/V2Order.tsx",
1003 presolve_parser::SourceSpan {
1004 start: 0,
1005 end: 0,
1006 line: 1,
1007 column: 1,
1008 },
1009 );
1010 let effect = |id: SemanticId, name: &str, declaration_order| Effect {
1011 id,
1012 owner: SemanticOwner::entity(component.clone()),
1013 declaration: EffectDeclaration::V2Field,
1014 name: name.to_owned(),
1015 declaration_order: Some(declaration_order),
1016 execution_boundary: ExecutionBoundary::Client,
1017 execution_policy: EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch,
1018 validation: EffectValidation::Valid,
1019 semantic_violations: Vec::new(),
1020 provenance: provenance.clone(),
1021 };
1022 let effects = [
1023 (late.clone(), effect(late.clone(), "aLate", 4)),
1024 (early.clone(), effect(early.clone(), "zEarly", 1)),
1025 ]
1026 .into_iter()
1027 .collect();
1028
1029 assert_eq!(
1030 schedule_terminal_effects(&[late, early.clone()], &effects),
1031 vec![EffectExecutionBatch {
1032 index: 0,
1033 effects: vec![early, component.effect("aLate")],
1034 }]
1035 );
1036 }
1037
1038 #[test]
1039 fn assembles_effects_into_canonical_asm_without_reactive_products() {
1040 let parsed = presolve_parser::parse_file(
1041 "src/Effects.tsx",
1042 r#"
1043@component("x-effects")
1044class Effects extends Component {
1045 title = state("Presolve");
1046
1047 @effect()
1048 syncTitle() {
1049 document.title = this.title;
1050 }
1051
1052 render() {
1053 return <p>{this.title}</p>;
1054 }
1055}
1056"#,
1057 );
1058 let asm = build_application_semantic_model(&parsed);
1059 let component = &asm.components[0];
1060 let effect_id = component.id.effect("syncTitle");
1061 let effect = asm.effect(&effect_id).expect("effect entity");
1062
1063 assert_eq!(
1064 effect.declaration,
1065 EffectDeclaration::LegacyMethod(component.id.method("syncTitle"))
1066 );
1067 assert_eq!(effect.owner, SemanticOwner::entity(component.id.clone()));
1068 assert_eq!(asm.owner(&effect_id), Some(&effect.owner));
1069 assert_eq!(
1070 asm.provenance(&effect_id),
1071 asm.provenance(&component.id.method("syncTitle"))
1072 );
1073 assert_eq!(
1074 asm.entity(&effect_id).map(SemanticEntity::kind),
1075 Some(SemanticEntityKind::Effect)
1076 );
1077 assert!(asm.references_from(&effect_id).iter().any(|reference| {
1078 reference.kind == SemanticReferenceKind::EffectState
1079 && reference.target == component.id.state_field("title")
1080 }));
1081 assert!(asm.semantic_type_of(&effect_id).is_none());
1082 assert_eq!(validate_application_semantic_model(&asm), Vec::new());
1083 assert!(build_semantic_graph(&asm)
1084 .nodes
1085 .iter()
1086 .any(|node| node.id == effect_id));
1087 }
1088
1089 #[test]
1090 fn lowers_ordered_effect_statements_and_expression_operands_without_resolution() {
1091 let parsed = presolve_parser::parse_file(
1092 "src/Effects.tsx",
1093 include_str!("../../../fixtures/0052-effect-body-lowering/input/Effects.tsx"),
1094 );
1095 let asm = build_application_semantic_model(&parsed);
1096 let component = &asm.components[0];
1097 let sync = component.id.effect("sync");
1098 let body = asm.effect_body(&sync).expect("effect body");
1099
1100 assert_eq!(body.statements.len(), 3);
1101 let assignment = asm
1102 .effect_statement(&body.statements[0])
1103 .expect("assignment");
1104 let call = asm.effect_statement(&body.statements[1]).expect("call");
1105 let completion = asm.effect_statement(&body.statements[2]).expect("return");
1106 let EffectStatementKind::ExternalMemberAssignment { target, value } = &assignment.kind
1107 else {
1108 panic!("expected static member assignment");
1109 };
1110 assert!(matches!(
1111 asm.expression(target).map(|node| &node.kind),
1112 Some(ExpressionNodeKind::MemberAccess { property, .. }) if property == "title"
1113 ));
1114 assert!(matches!(
1115 asm.expression(value).map(|node| &node.kind),
1116 Some(ExpressionNodeKind::ThisMember { name }) if name == "title"
1117 ));
1118 let EffectStatementKind::CapabilityCall { callee, arguments } = &call.kind else {
1119 panic!("expected capability call");
1120 };
1121 assert_eq!(arguments.len(), 2);
1122 assert!(matches!(
1123 asm.expression(callee).map(|node| &node.kind),
1124 Some(ExpressionNodeKind::MemberAccess { property, .. }) if property == "track"
1125 ));
1126 assert!(matches!(
1127 asm.expression(&arguments[1]).map(|node| &node.kind),
1128 Some(ExpressionNodeKind::Arithmetic { .. })
1129 ));
1130 assert!(matches!(
1131 completion.kind,
1132 EffectStatementKind::EffectReturn { value: None }
1133 ));
1134 assert!(asm.references_from(&sync).iter().any(|reference| {
1135 reference.kind == SemanticReferenceKind::EffectState
1136 && reference.target == component.id.state_field("title")
1137 }));
1138 assert!(asm.semantic_types.assignments.keys().any(|id| id == target));
1139 assert_eq!(
1140 asm.semantic_type_of(value),
1141 Some(&crate::SemanticType::String)
1142 );
1143
1144 let invalid = asm
1145 .effect_body(&component.id.effect("invalid"))
1146 .expect("invalid body");
1147 assert!(matches!(
1148 asm.effect_statement(&invalid.statements[0])
1149 .map(|statement| &statement.kind),
1150 Some(EffectStatementKind::ExternalMemberAssignment { .. })
1151 ));
1152 assert!(matches!(
1153 asm.effect_statement(&invalid.statements[1])
1154 .map(|statement| &statement.kind),
1155 Some(EffectStatementKind::CapabilityCall { .. })
1156 ));
1157 assert!(matches!(
1158 asm.effect_statement(&invalid.statements[2])
1159 .map(|statement| &statement.kind),
1160 Some(EffectStatementKind::Unsupported(
1161 UnsupportedEffectStatementKind::CleanupReturnCandidate
1162 ))
1163 ));
1164 assert!(matches!(
1165 asm.effect_statement(&invalid.statements[3])
1166 .map(|statement| &statement.kind),
1167 Some(EffectStatementKind::Unsupported(
1168 UnsupportedEffectStatementKind::LocalDeclaration
1169 ))
1170 ));
1171 }
1172
1173 #[test]
1174 #[allow(clippy::too_many_lines)]
1175 fn types_effect_statements_against_the_versioned_capability_registry() {
1176 let parsed = presolve_parser::parse_file(
1177 "src/Effects.tsx",
1178 r#"
1179@component("x-effects")
1180class Effects extends Component {
1181 title = state("Presolve");
1182 theme = state("dark");
1183 total = state(3);
1184 profile = state({ name: "Ada" });
1185
1186 @action()
1187 increment() { this.total += 1; this.total += 1; }
1188
1189 helper() {}
1190
1191 @effect()
1192 sync() {
1193 document.title = this.title;
1194 console.log("total", this.total);
1195 console.info(this.title);
1196 console.warn(this.title);
1197 console.error(this.title);
1198 localStorage.setItem("theme", this.theme);
1199 localStorage.removeItem("theme");
1200 sessionStorage.setItem("theme", this.theme);
1201 sessionStorage.removeItem("theme");
1202 }
1203
1204 @effect()
1205 facts() {
1206 document.title = this.profile;
1207 analytics.track(this.total);
1208 this.total = 1;
1209 this.increment();
1210 this.sync();
1211 this.helper();
1212 this.unknown();
1213 return;
1214 }
1215
1216 render() { return <p />; }
1217}
1218"#,
1219 );
1220 let asm = build_application_semantic_model(&parsed);
1221 let component = &asm.components[0];
1222 let sync = asm
1223 .effect_body(&component.id.effect("sync"))
1224 .expect("sync body");
1225 let facts = asm
1226 .effect_body(&component.id.effect("facts"))
1227 .expect("facts body");
1228 let record = |statement: &crate::SemanticId| {
1229 asm.effect_statement_type(statement)
1230 .expect("statement type")
1231 };
1232
1233 assert_eq!(crate::EFFECT_CAPABILITY_REGISTRY.version(), 1);
1234 assert_eq!(
1235 record(&sync.statements[0]).capability_operation,
1236 Some(crate::CapabilityOperationId(
1237 "builtin.browser.document.title.assign"
1238 ))
1239 );
1240 for statement in &sync.statements[1..5] {
1241 assert_eq!(
1242 record(statement).operation_classification,
1243 crate::EffectOperationClassification::RecognizedCapability
1244 );
1245 assert_eq!(
1246 record(statement).serialization_compatibility,
1247 crate::EffectCompatibility::Compatible
1248 );
1249 }
1250 for statement in &sync.statements[5..] {
1251 assert_eq!(
1252 record(statement).signature_compatibility,
1253 crate::EffectCompatibility::Compatible
1254 );
1255 }
1256 assert_eq!(
1257 record(&facts.statements[0]).signature_compatibility,
1258 crate::EffectCompatibility::Incompatible
1259 );
1260 assert_eq!(
1261 record(&facts.statements[1]).operation_classification,
1262 crate::EffectOperationClassification::UnknownExternalCapability
1263 );
1264 assert_eq!(
1265 record(&facts.statements[1]).signature_compatibility,
1266 crate::EffectCompatibility::Incompatible
1267 );
1268 assert_eq!(
1269 record(&facts.statements[1]).operand_types,
1270 vec![crate::SemanticType::Number]
1271 );
1272 assert_eq!(
1273 record(&facts.statements[2]).operation_classification,
1274 crate::EffectOperationClassification::ReactiveStateAssignment
1275 );
1276 assert_eq!(
1277 record(&facts.statements[3]).operation_classification,
1278 crate::EffectOperationClassification::ComponentActionCall
1279 );
1280 assert_eq!(
1281 record(&facts.statements[4]).operation_classification,
1282 crate::EffectOperationClassification::ComponentEffectCall
1283 );
1284 assert_eq!(
1285 record(&facts.statements[5]).operation_classification,
1286 crate::EffectOperationClassification::ComponentMethodCall
1287 );
1288 assert_eq!(
1289 record(&facts.statements[6]).operation_classification,
1290 crate::EffectOperationClassification::UnresolvedComponentCall
1291 );
1292 assert_eq!(
1293 record(&facts.statements[7]).operation_classification,
1294 crate::EffectOperationClassification::BareReturn
1295 );
1296 assert_eq!(
1297 asm.effect(&component.id.effect("sync"))
1298 .expect("valid effect")
1299 .validation,
1300 EffectValidation::Valid
1301 );
1302 let violations = &asm
1303 .effect(&component.id.effect("facts"))
1304 .expect("invalid effect")
1305 .semantic_violations;
1306 assert_eq!(
1307 asm.effect(&component.id.effect("facts"))
1308 .expect("invalid effect")
1309 .validation,
1310 EffectValidation::Invalid
1311 );
1312 for kind in [
1313 EffectSemanticViolationKind::CapabilitySignature,
1314 EffectSemanticViolationKind::UnknownExternalCapability,
1315 EffectSemanticViolationKind::ReactiveStateMutation,
1316 EffectSemanticViolationKind::ActionInvocation,
1317 EffectSemanticViolationKind::EffectInvocation,
1318 EffectSemanticViolationKind::ComponentMethodInvocation,
1319 EffectSemanticViolationKind::UnresolvedComponentCall,
1320 ] {
1321 assert!(violations.iter().any(|violation| violation.kind == kind));
1322 }
1323 let sync_id = component.id.effect("sync");
1324 let facts_id = component.id.effect("facts");
1325 assert_eq!(
1326 asm.reactive_graph.nodes[sync_id.as_str()].kind,
1327 crate::IrReactiveNodeKind::Effect
1328 );
1329 assert!(!asm.reactive_graph.nodes.contains_key(facts_id.as_str()));
1330 assert!(asm.reactive_graph.edges.iter().any(|edge| {
1331 edge.source == sync_id.as_str()
1332 && edge.target == component.id.state_field("title").as_str()
1333 && edge.kind == crate::IrReactiveEdgeKind::Reads
1334 }));
1335 assert!(asm.reactive_graph.edges.iter().any(|edge| {
1336 edge.source == component.id.state_field("title").as_str()
1337 && edge.target == sync_id.as_str()
1338 && edge.kind == crate::IrReactiveEdgeKind::Invalidates
1339 }));
1340 let analysis = asm
1341 .effect_reactive_analysis(&sync_id)
1342 .expect("effect reactive analysis");
1343 assert!(analysis
1344 .dependencies
1345 .contains(&component.id.state_field("title")));
1346 assert!(analysis
1347 .dependencies
1348 .contains(&component.id.state_field("total")));
1349 assert!(analysis.dependents.is_empty());
1350 assert!(asm.effect_reactive_analysis(&facts_id).is_none());
1351 let trigger_plan = asm.effect_trigger_plan();
1352 assert!(trigger_plan.initial_effects.contains(&sync_id));
1353 assert!(!trigger_plan.initial_effects.contains(&facts_id));
1354 let batch = trigger_plan
1355 .action_batches
1356 .get(&component.id.action_batch("increment"))
1357 .expect("increment action batch");
1358 assert_eq!(batch.ordered_write_records.len(), 2);
1359 assert_eq!(
1360 batch.written_states,
1361 vec![component.id.state_field("total")]
1362 );
1363 let trigger = trigger_plan
1364 .action_batch_triggers
1365 .iter()
1366 .find(|trigger| trigger.action_batch == batch.id)
1367 .expect("batch trigger");
1368 assert_eq!(trigger.effects, vec![sync_id.clone()]);
1369 assert_eq!(
1370 trigger.matched_states[&sync_id],
1371 vec![component.id.state_field("total")]
1372 );
1373 assert_eq!(validate_application_semantic_model(&asm), Vec::new());
1374 }
1375
1376 #[test]
1377 #[allow(clippy::too_many_lines)]
1378 fn schedules_minimal_effect_prerequisites_from_existing_computed_batches() {
1379 let parsed = presolve_parser::parse_file(
1380 "src/EffectExecutionPlan.tsx",
1381 r#"
1382@component("x-effect-execution-plan")
1383class EffectExecutionPlan extends Component {
1384 price = state(1);
1385 locale = state("en-US");
1386 theme = state("light");
1387
1388 @computed()
1389 get subtotal() { return this.price * 2; }
1390
1391 @computed()
1392 get total() { return this.subtotal + 1; }
1393
1394 @computed()
1395 get currentLocale() { return this.locale; }
1396
1397 @computed()
1398 get unrelated() { return this.theme; }
1399
1400 @action()
1401 increment() { this.price += 1; }
1402
1403 @action()
1404 restyle() { this.theme = "dark"; }
1405
1406 @effect()
1407 report() { console.log(this.total, this.currentLocale); }
1408
1409 @effect()
1410 audit() { console.log(this.price); }
1411
1412 @effect()
1413 bootLog() { console.log("ready"); }
1414
1415 render() { return <p />; }
1416}
1417"#,
1418 );
1419 let asm = build_application_semantic_model(&parsed);
1420 let component = &asm.components[0];
1421 let subtotal = component.id.computed("subtotal");
1422 let total = component.id.computed("total");
1423 let current_locale = component.id.computed("currentLocale");
1424 let unrelated = component.id.computed("unrelated");
1425 let report = component.id.effect("report");
1426 let audit = component.id.effect("audit");
1427 let boot_log = component.id.effect("bootLog");
1428 let execution = asm.effect_execution_plan();
1429
1430 assert_eq!(
1431 execution.initial.required_computed,
1432 vec![current_locale.clone(), subtotal.clone(), total.clone()]
1433 );
1434 assert_eq!(
1435 execution.initial.render_boundary,
1436 Some(crate::EffectRenderBoundary::AfterInitialRender)
1437 );
1438 assert_eq!(
1439 execution
1440 .initial
1441 .prerequisite_batches
1442 .iter()
1443 .map(|batch| batch.computed.clone())
1444 .collect::<Vec<_>>(),
1445 vec![vec![current_locale, subtotal.clone()], vec![total.clone()]]
1446 );
1447 assert!(!execution.initial.required_computed.contains(&unrelated));
1448 assert_eq!(
1449 execution.initial.effect_batches,
1450 vec![crate::EffectExecutionBatch {
1451 index: 0,
1452 effects: vec![audit.clone(), boot_log.clone(), report.clone()],
1453 }]
1454 );
1455 assert!(execution.initial.unplanned_effects.is_empty());
1456
1457 assert_eq!(execution.actions.len(), 1);
1458 let action = &execution.actions[0];
1459 assert_eq!(action.action_batch, component.id.action_batch("increment"));
1460 assert_eq!(action.required_computed, vec![subtotal, total.clone()]);
1461 assert_eq!(
1462 action
1463 .prerequisite_batches
1464 .iter()
1465 .map(|batch| batch.computed.clone())
1466 .collect::<Vec<_>>(),
1467 vec![
1468 vec![component.id.computed("subtotal")],
1469 vec![component.id.computed("total")],
1470 ]
1471 );
1472 assert_eq!(
1473 action.effect_batches,
1474 vec![crate::EffectExecutionBatch {
1475 index: 0,
1476 effects: vec![audit, report.clone()],
1477 }]
1478 );
1479 assert!(action.unplanned_effects.is_empty());
1480
1481 let unavailable_plan = crate::IrComputedEvaluationPlan {
1482 evaluation_order: asm
1483 .computed_evaluation_plan
1484 .evaluation_order
1485 .iter()
1486 .filter(|computed| computed.as_str() != total.as_str())
1487 .cloned()
1488 .collect(),
1489 update_batches: asm
1490 .computed_evaluation_plan
1491 .update_batches
1492 .iter()
1493 .map(|batch| {
1494 batch
1495 .iter()
1496 .filter(|computed| computed.as_str() != total.as_str())
1497 .cloned()
1498 .collect()
1499 })
1500 .collect(),
1501 unplanned: vec![total.as_str().to_string()],
1502 };
1503 let unavailable = crate::plan_effect_execution(
1504 &asm.computed_values,
1505 &asm.effects,
1506 &asm.effect_reactive_analysis,
1507 asm.effect_trigger_plan(),
1508 &asm.reactive_transitive_analysis,
1509 &unavailable_plan,
1510 );
1511 assert_eq!(
1512 unavailable.initial.unplanned_effects,
1513 vec![crate::UnplannedEffect {
1514 effect: report,
1515 reason: crate::UnplannedEffectReason::UnavailableComputedPrerequisite,
1516 computed_dependencies: vec![total],
1517 }]
1518 );
1519 assert_eq!(validate_application_semantic_model(&asm), Vec::new());
1520 }
1521
1522 #[test]
1523 fn rejects_async_effects_without_runtime_execution() {
1524 let parsed = presolve_parser::parse_file(
1525 "src/AsyncEffect.tsx",
1526 r#"
1527@component("x-async-effect")
1528class AsyncEffect extends Component {
1529 title = state("Presolve");
1530
1531 @effect()
1532 async syncTitle() {
1533 document.title = this.title;
1534 }
1535
1536 @effect()
1537 invalidValueReturn() {
1538 return this.title;
1539 }
1540
1541 @effect()
1542 invalidCleanupReturn() {
1543 return () => unsubscribe();
1544 }
1545
1546 render() { return <p />; }
1547}
1548"#,
1549 );
1550 let asm = build_application_semantic_model(&parsed);
1551 let effect = asm
1552 .effect(&asm.components[0].id.effect("syncTitle"))
1553 .expect("effect");
1554
1555 assert_eq!(effect.validation, EffectValidation::Invalid);
1556 assert!(effect
1557 .semantic_violations
1558 .iter()
1559 .any(|violation| violation.kind == EffectSemanticViolationKind::Async));
1560 for (name, kind) in [
1561 (
1562 "invalidValueReturn",
1563 EffectSemanticViolationKind::ValueReturn,
1564 ),
1565 (
1566 "invalidCleanupReturn",
1567 EffectSemanticViolationKind::UnsupportedStatement,
1568 ),
1569 ] {
1570 let effect = asm
1571 .effect(&asm.components[0].id.effect(name))
1572 .expect("invalid return effect");
1573 assert_eq!(effect.validation, EffectValidation::Invalid);
1574 assert!(effect
1575 .semantic_violations
1576 .iter()
1577 .any(|violation| violation.kind == kind));
1578 }
1579 let codes = asm
1580 .diagnostics
1581 .iter()
1582 .map(|diagnostic| diagnostic.code.as_str())
1583 .collect::<Vec<_>>();
1584 assert_eq!(codes, vec!["PSC1042", "PSC1046", "PSC1046"]);
1585 }
1586}