Skip to main content

ferroui_hot_reload/
lib.rs

1//! Development reload transactions. All targets and commits remain on the creating UI thread.
2#![cfg(debug_assertions)]
3mod component;
4mod state_scope;
5mod handlers;
6#[cfg(test)]
7mod handler_tests;
8#[cfg(test)]
9mod template_state_tests;
10mod detached_context;
11mod deferred_context;
12mod frame_timing;
13pub use frame_timing::record_frame_timing;
14mod input_pause;
15mod include_tracking;
16pub use input_pause::InputPause;
17mod restart_handoff;
18pub use restart_handoff::{RestartHandoff, RestartParticipant, RestartResume};
19mod panel;
20mod resource_preparation;
21mod resources;
22pub use resource_preparation::ResourcePreparation;
23mod root_properties;
24mod root_resources;
25mod root_styles;
26mod root_preview;
27#[cfg(test)]
28mod root_binding_tests;
29mod styles;
30pub use panel::PanelReloadTarget;
31pub use resources::ResourceReloadTarget;
32pub use styles::StyleReloadTarget;
33mod content;
34mod control_state;
35pub use control_state::{CapturedControlState, ControlStateAdapter, ControlStateRestoreContext};
36pub use component::{ComponentRefresh, ComponentReloadTarget};
37mod contract;
38#[cfg(test)]
39mod runtime_contract_tests;
40mod identity;
41mod manifest;
42mod state;
43pub use identity::KEY_NAMESPACE;
44mod watcher;
45pub use content::ContentReloadTarget;
46pub use contract::DocumentContract;
47use ferroui_markup_xaml::source_overlay::{with_sources, Sources};
48pub use manifest::ReloadManifest;
49use std::{
50    collections::{BTreeMap, BTreeSet},
51    rc::{Rc, Weak},
52    time::{Duration, Instant},
53};
54pub use watcher::{FileBatch, FileChange, FileWatcher, WatchDirectory, WatchRevision};
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ReloadError {
58    Failed(String),
59    RestartRequired(String),
60}
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ReloadStatus {
63    Applied,
64    Failed,
65    RestartRequired,
66    Superseded,
67}
68#[derive(Debug, Clone)]
69pub struct ReloadOutcome {
70    pub revision: u64,
71    pub documents: Vec<String>,
72    pub status: ReloadStatus,
73    pub diagnostic: Option<String>,
74    pub preparation: Duration,
75    pub application: Duration,
76}
77/// An adapter must keep its prior state until `finish`. Rollback must also undo a partially
78/// failed commit. Preparation must not change the live tree or run irreversible user effects.
79pub trait PreparedReload {
80    fn commit(&mut self) -> Result<(), ReloadError>;
81    fn rollback(&mut self);
82    fn finish(&mut self) {}
83}
84pub trait ReloadTarget {
85    /// Complete state restoration from an earlier accepted revision at the UI
86    /// boundary. Runs against accepted sources, before candidate resource previews
87    /// or preparation. Custom adapters normally need only the default no-op.
88    fn complete_pending_state(&self) {}
89    /// Includes transitive document dependencies. A target may refresh this list after each revision.
90    fn documents(&self) -> Vec<String>;
91    fn prepare(&self, sources: Rc<Sources>) -> Result<Box<dyn PreparedReload>, ReloadError>;
92    /// Resource owners provide a lazy preview before ordinary view preparation.
93    fn resource_preparation(
94        self: Rc<Self>,
95        _sources: Rc<Sources>,
96    ) -> Result<Option<ResourcePreparation>, ReloadError> {
97        Ok(None)
98    }
99}
100/// Sources become authoritative only after every affected live target commits successfully.
101/// Session ownership is intentionally `!Send`: callers must pump it on the UI thread.
102#[derive(Default)]
103pub struct HotReloadSession {
104    watch_revision: Option<WatchRevision>,
105    accepted: Rc<Sources>,
106    published: Rc<std::cell::RefCell<Rc<Sources>>>,
107    overrides: Vec<(
108        &'static ferroui_base::TypeInfo,
109        ferroui_markup_xaml::PopulateOverride,
110    )>,
111    source_registration: Option<ferroui_markup_xaml::source_overlay::SourceRegistration>,
112    contracts: BTreeMap<String, DocumentContract>,
113    runtime_contracts: bool,
114    build_contracts: Option<Rc<contract::BuildContracts>>,
115    pending: BTreeMap<String, String>,
116    read_errors: BTreeMap<String, String>,
117    watch_errors: Vec<String>,
118    targets: Vec<Weak<dyn ReloadTarget>>,
119    includes: include_tracking::IncludeTracking,
120    revision: u64,
121    applied_revision: u64,
122    attempted_revision: u64,
123}
124impl HotReloadSession {
125    pub fn new() -> Self {
126        Self::default()
127    }
128    /// Creates a session for runtime-instantiated documents whose target adapters replace
129    /// the entire affected object graph, including namescopes and event subscriptions.
130    /// Structural contracts are accepted only after every target commits successfully.
131    /// Compiled class population overrides are prohibited in this mode; application and
132    /// manifest sessions retain the default restart protection for contract changes.
133    pub fn for_runtime_documents() -> Self {
134        let mut session = Self::new();
135        session.runtime_contracts = true;
136        session
137    }
138    pub fn register_document(&mut self, uri: String, source: String) -> Result<(), ReloadError> {
139        let uri = document_key(&uri);
140        if self.contracts.contains_key(&uri) {
141            return Err(ReloadError::Failed(format!(
142                "Document already registered: {uri}"
143            )));
144        }
145        let contract = DocumentContract::parse_with_build(&source, self.build_contracts.as_deref())?;
146        if self.source_registration.is_none() {
147            self.source_registration = Some(
148                ferroui_markup_xaml::source_overlay::SourceRegistration::new(&self.published)
149                    .map_err(ReloadError::Failed)?,
150            );
151        }
152        self.source_registration
153            .as_ref()
154            .unwrap()
155            .check_document(&uri)
156            .map_err(ReloadError::Failed)?;
157        self.source_registration.as_ref().unwrap().register_compiled_source(&uri, &source);
158        self.contracts.insert(uri.clone(), contract);
159        Rc::make_mut(&mut self.accepted).insert(uri, source);
160        *self.published.borrow_mut() = self.accepted.clone();
161        Ok(())
162    }
163    /// New instances populate from the last accepted snapshot. Existing instances need
164    /// a ReloadTarget adapter; this hook never clears a live root.
165    pub fn bind_class(
166        &mut self,
167        class: &'static ferroui_base::TypeInfo,
168        uri: String,
169        configuration: ferroui_markup_xaml::RuntimeXamlLoaderConfiguration,
170    ) -> Result<(), ReloadError> {
171        use ferroui_markup_xaml::{FerroXamlLoader, RuntimeXamlLoaderDocument};
172        if self.runtime_contracts {
173            return Err(ReloadError::Failed(
174                "Runtime document sessions cannot bind compiled class population overrides".into(),
175            ));
176        }
177        let uri = document_key(&uri);
178        if !self.accepted.contains_key(&uri) {
179            return Err(ReloadError::Failed(format!("Unregistered document: {uri}")));
180        }
181        if FerroXamlLoader::populate_override(class).is_some() {
182            return Err(ReloadError::Failed(
183                "Class already has a populate override".into(),
184            ));
185        }
186        let base = ferroui_base::utilities::Uri::try_create(
187            &uri,
188            ferroui_base::utilities::UriKind::Absolute,
189        )
190        .ok_or_else(|| ReloadError::Failed("Document URI must be absolute".into()))?;
191        let sources = self.published.clone();
192        let populate: ferroui_markup_xaml::PopulateOverride = Rc::new(move |sp, root| {
193            let sources = sources.borrow().clone();
194            let text = sources.get(&uri).ok_or_else(|| {
195                ferroui_markup_xaml::XamlLoadException::with_message("Reload document was removed")
196            })?;
197            let prepared = crate::identity::runtime_source(text).map_err(|error| {
198                ferroui_markup_xaml::XamlLoadException::with_message(format!("{error:?}"))
199            })?;
200            let mut document = RuntimeXamlLoaderDocument::with_base_uri_and_root_instance(
201                Some(base.clone()),
202                Some(root.clone()),
203                &prepared,
204            );
205            document.service_provider = sp.cloned();
206            with_sources(sources, || {
207                ferroui_markup_xaml_loader::FerroRuntimeXamlLoader::load_document(
208                    document,
209                    Some(configuration.clone()),
210                )
211                .map(|_| ())
212            })
213        });
214        FerroXamlLoader::set_populate_override(class, Some(populate.clone()));
215        self.overrides.push((class, populate));
216        Ok(())
217    }
218
219    /// The application owns its adapters. Closed components disappear when their adapters are dropped.
220    pub fn register_target(&mut self, target: &Rc<dyn ReloadTarget>) {
221        self.targets.push(Rc::downgrade(target));
222        if !self.pending.is_empty() {
223            self.revision += 1;
224        }
225    }
226    /// Track direct resource includes in this container, including later additions
227    /// and removals. Only includes whose documents belong to this session are adopted.
228    /// Owners are weakly held. Repeated registration keeps the first configuration.
229    /// All registered targets conservatively depend on these includes, so static
230    /// consumers prepare with the same resource revision. Do not also manually
231    /// register adapters for the same contributions.
232    pub fn track_resource_includes(
233        &mut self,
234        owner: &ferroui_base::Ref<ferroui_base::controls::ResourceDictionary>,
235        configuration: ferroui_markup_xaml::RuntimeXamlLoaderConfiguration,
236    ) {
237        self.includes.resources(owner, configuration);
238    }
239    /// Track direct style includes with the same ownership and dependency rules as
240    /// `track_resource_includes`. Arbitrary inline styles need explicit adapters.
241    pub fn track_style_includes(
242        &mut self,
243        owner: &ferroui_base::Ref<ferroui_base::styling::Styles>,
244        configuration: ferroui_markup_xaml::RuntimeXamlLoaderConfiguration,
245    ) {
246        self.includes.styles(owner, configuration);
247    }
248    pub fn submit(&mut self, uri: String, text: String) -> u64 {
249        self.revision += 1;
250        let uri = document_key(&uri);
251        self.read_errors.remove(&uri);
252        self.pending.insert(uri, text);
253        self.revision
254    }
255    /// Reapply an explicitly registered view after its Rust builder changes.
256    /// Keep a pending editor draft and any read errors: a builder request must
257    /// not silently replace newer text with the accepted source or hide a failure.
258    pub fn reapply_document(&mut self, uri: &str) -> Result<u64, ReloadError> {
259        let uri = document_key(uri);
260        let source = self
261            .pending
262            .get(&uri)
263            .or_else(|| self.accepted.get(&uri))
264            .cloned()
265            .ok_or_else(|| {
266                ReloadError::RestartRequired(format!("Unregistered builder document: {uri}"))
267            })?;
268        self.revision += 1;
269        self.pending.insert(uri, source);
270        Ok(self.revision)
271    }
272    /// Submit editor/supervisor text as one revision, without rebuilding targets
273    /// for unchanged accepted documents. Returning to accepted text discards that
274    /// document's failed draft; drafts and read errors for other documents remain.
275    /// Resubmitting a pending draft explicitly retries its failed transaction.
276    /// Use `submit` when a view must be reapplied even with identical markup.
277    pub fn submit_texts(&mut self, documents: impl IntoIterator<Item = (String, String)>) -> u64 {
278        let mut changed = false;
279        for (uri, text) in documents {
280            let uri = document_key(&uri);
281            changed |= self.read_errors.remove(&uri).is_some();
282            if self.accepted.get(&uri) == Some(&text) {
283                changed |= self.pending.remove(&uri).is_some();
284            } else {
285                self.pending.insert(uri, text);
286                changed = true;
287            }
288        }
289        if changed {
290            self.revision += 1;
291        }
292        self.revision
293    }
294    /// Submit a complete watcher snapshot as one revision. Read errors block the
295    /// transaction, including older pending edits, until that document recovers.
296    /// Editors can recover an unreadable file by submitting its unsaved text.
297    pub fn submit_files(&mut self, batch: FileBatch) -> u64 {
298        self.revision += 1;
299        self.watch_revision = batch.revision;
300        self.watch_errors = batch.diagnostics;
301        for file in batch.files {
302            let uri = document_key(&file.uri);
303            match file.contents {
304                Ok(text) => {
305                    self.read_errors.remove(&uri);
306                    if self.accepted.get(&uri) == Some(&text) {
307                        self.pending.remove(&uri);
308                    } else {
309                        self.pending.insert(uri, text);
310                    }
311                }
312                Err(error) => {
313                    self.read_errors.insert(uri, error);
314                }
315            }
316        }
317        self.revision
318    }
319    pub fn sources(&self) -> Rc<Sources> {
320        self.accepted.clone()
321    }
322    pub fn applied_revision(&self) -> u64 {
323        self.applied_revision
324    }
325    pub fn has_pending_changes(&self) -> bool {
326        !self.pending.is_empty()
327            || !self.read_errors.is_empty()
328            || !self.watch_errors.is_empty()
329            || self
330                .watch_revision
331                .as_ref()
332                .is_some_and(|revision| !revision.is_current())
333    }
334    pub fn apply_pending(&mut self) -> Option<ReloadOutcome> {
335        self.apply_pending_with(|| Ok(()))
336    }
337    /// Coordinate an external update with the markup transaction. The callback
338    /// runs once, after every target commits but before accepted sources publish
339    /// and old graphs/subscriptions are released. A callback error rolls back all
340    /// target commits. Preparation/commit failure never invokes the callback.
341    ///
342    /// The callback must leave its own state unchanged on error or panic. After
343    /// success, adapter `finish` is infallible cleanup: it must not panic. This
344    /// cannot roll back arbitrary external effects or an already installed patch.
345    /// A supervisor can install a validated synchronous function patch here and
346    /// publish both revisions only after installation succeeds. Observers invoked
347    /// synchronously by adapter commit still see the provisional graph; adapters
348    /// must honor their existing reversible-side-effect contract.
349    pub fn apply_pending_with(
350        &mut self,
351        finalize: impl FnOnce() -> Result<(), ReloadError>,
352    ) -> Option<ReloadOutcome> {
353        if self.pending.is_empty() && self.read_errors.is_empty() && self.watch_errors.is_empty() {
354            return None;
355        }
356        let start = Instant::now();
357        let discovered = catch_reload(|| self.includes.refresh(&self.accepted));
358        if self.attempted_revision == self.revision
359            && discovered.as_ref().is_ok_and(|(_, changed)| *changed)
360        {
361            // A newly attached include can validate a previously rejected draft
362            // without requiring another filesystem save.
363            self.revision += 1;
364        }
365        if self.attempted_revision == self.revision {
366            return None;
367        }
368        self.attempted_revision = self.revision;
369        // Keep failed drafts: a later fix must retry the entire coherent batch, including
370        // edits to other documents whose watcher has already observed their saved contents.
371        let pending = self.pending.clone();
372        let documents: Vec<_> = pending
373            .keys()
374            .chain(self.read_errors.keys())
375            .cloned()
376            .collect::<BTreeSet<_>>()
377            .into_iter()
378            .collect();
379        let mut outcome = ReloadOutcome {
380            revision: self.revision,
381            documents,
382            status: ReloadStatus::Applied,
383            diagnostic: None,
384            preparation: Duration::ZERO,
385            application: Duration::ZERO,
386        };
387        if self
388            .watch_revision
389            .as_ref()
390            .is_some_and(|revision| !revision.is_current())
391        {
392            outcome.status = ReloadStatus::Superseded;
393            outcome.diagnostic =
394                Some("A newer source read superseded this document revision".into());
395            return Some(outcome);
396        }
397        if !self.read_errors.is_empty() || !self.watch_errors.is_empty() {
398            outcome.fail(ReloadError::Failed(
399                self.read_errors
400                    .iter()
401                    .map(|(uri, error)| format!("{uri}: {error}"))
402                    .chain(self.watch_errors.iter().cloned())
403                    .collect::<Vec<_>>()
404                    .join("; "),
405            ));
406            return Some(outcome);
407        }
408        let completion = with_sources(self.accepted.clone(), || {
409            for target in self.targets.iter().filter_map(Weak::upgrade) {
410                catch_reload(|| {
411                    target.complete_pending_state();
412                    Ok(())
413                })?;
414            }
415            Ok::<_, ReloadError>(())
416        });
417        if let Err(error) = completion {
418            outcome.fail(error);
419            outcome.preparation = start.elapsed();
420            return Some(outcome);
421        }
422        let mut candidate = (*self.accepted).clone();
423        let mut candidate_contracts = BTreeMap::new();
424        for (uri, text) in &pending {
425            if let (Some(contracts), Some(before)) = (&self.build_contracts, self.accepted.get(uri)) {
426                let check = contracts.fingerprint(before).and_then(|baseline| {
427                    if baseline == contracts.fingerprint(text)? {
428                        Ok(())
429                    } else {
430                        Err(ReloadError::RestartRequired(format!(
431                            "Built handler or unresolved type contract changed: {uri}"
432                        )))
433                    }
434                });
435                if let Err(error) = check {
436                    outcome.fail(error);
437                    outcome.preparation = start.elapsed();
438                    return Some(outcome);
439                }
440            }
441            let check = DocumentContract::parse_with_build(text, self.build_contracts.as_deref())
442                .and_then(|contract| match self.contracts.get(uri) {
443                    Some(baseline)
444                        if baseline == &contract
445                            || (self.runtime_contracts
446                                && self.build_contracts.is_none()
447                                && self.overrides.is_empty()) =>
448                    {
449                        Ok(contract)
450                    }
451                    Some(_) => Err(ReloadError::RestartRequired(format!(
452                        "XAML contract changed: {uri}"
453                    ))),
454                    None => Err(ReloadError::RestartRequired(format!(
455                        "Unregistered document: {uri}"
456                    ))),
457                });
458            let contract = match check {
459                Ok(contract) => contract,
460                Err(error) => {
461                    outcome.fail(error);
462                    outcome.preparation = start.elapsed();
463                    return Some(outcome);
464                }
465            };
466            candidate_contracts.insert(uri.clone(), contract);
467            candidate.insert(uri.clone(), text.clone());
468        }
469        let candidate = Rc::new(candidate);
470        self.targets.retain(|target| target.strong_count() != 0);
471        let affected: BTreeSet<_> = pending.keys().cloned().collect();
472        let prepared = with_sources(candidate.clone(), || {
473            let (include_targets, _) = discovered?;
474            let ambient_documents: Vec<_> = include_targets
475                .iter()
476                .flat_map(|target| target.documents())
477                .collect();
478            let validation = ferroui_markup_xaml::reload_validation::ValidationScope::new();
479            let previews = ferroui_base::controls::resource_preview::ResourcePreviewScope::new();
480            let mut prepared = Vec::new();
481            let mut resources = Vec::new();
482            let mut consumers = Vec::new();
483            let mut covered = BTreeSet::new();
484            for target in self.targets.iter().filter_map(Weak::upgrade).chain(include_targets) {
485                let mut documents = target.documents();
486                documents.extend(ambient_documents.iter().cloned());
487                let dependencies = dependencies_of(documents, &candidate);
488                if !dependencies.is_disjoint(&affected) {
489                    if let Some(resource) =
490                        catch_reload(|| target.clone().resource_preparation(candidate.clone()))?
491                    {
492                        resource.register(&previews)?;
493                        resources.push(resource);
494                    } else {
495                        consumers.push(target);
496                    }
497                    covered.extend(dependencies);
498                }
499            }
500            if !affected.is_subset(&covered) {
501                return Err(ReloadError::RestartRequired(
502                    "Changed document has no live validation adapter".into(),
503                ));
504            }
505            // All factories are visible before any dictionary or view is built.
506            for resource in resources {
507                prepared.push(resource.take_change()?);
508            }
509            for target in consumers {
510                prepared.push(catch_reload(|| target.prepare(candidate.clone()))?);
511            }
512            catch_reload(|| {
513                validation
514                    .validate()
515                    .map_err(|error| ReloadError::Failed(error.to_string()))
516            })?;
517            Ok::<_, ReloadError>(prepared)
518        });
519        outcome.preparation = start.elapsed();
520        let mut prepared = match prepared {
521            Ok(prepared) => prepared,
522            Err(error) => {
523                outcome.fail(error);
524                return Some(outcome);
525            }
526        };
527        if self
528            .watch_revision
529            .as_ref()
530            .is_some_and(|revision| !revision.is_current())
531        {
532            outcome.status = ReloadStatus::Superseded;
533            outcome.diagnostic = Some("Sources changed while preparing the candidate view".into());
534            return Some(outcome);
535        }
536        let start = Instant::now();
537        let result = with_sources(candidate.clone(), || {
538            for index in 0..prepared.len() {
539                if let Err(error) = catch_reload(|| prepared[index].commit()) {
540                    for previous in prepared[..=index].iter_mut().rev() {
541                        previous.rollback();
542                    }
543                    return Err(error);
544                }
545            }
546            if let Err(error) = catch_reload(finalize) {
547                for change in prepared.iter_mut().rev() {
548                    change.rollback();
549                }
550                return Err(error);
551            }
552            Ok(())
553        });
554        outcome.application = start.elapsed();
555        match result {
556            Err(error) => outcome.fail(error),
557            Ok(()) => {
558                self.pending.clear();
559                self.contracts.extend(candidate_contracts);
560                self.accepted = candidate;
561                *self.published.borrow_mut() = self.accepted.clone();
562                self.applied_revision = self.revision;
563                for change in &mut prepared {
564                    change.finish();
565                }
566            }
567        }
568        Some(outcome)
569    }
570}
571impl ReloadOutcome {
572    fn fail(&mut self, error: ReloadError) {
573        let (status, diagnostic) = match error {
574            ReloadError::Failed(message) => (ReloadStatus::Failed, message),
575            ReloadError::RestartRequired(message) => (ReloadStatus::RestartRequired, message),
576        };
577        self.status = status;
578        self.diagnostic = Some(diagnostic);
579    }
580}
581#[cfg(test)]
582mod tests;
583
584fn catch_reload<T>(action: impl FnOnce() -> Result<T, ReloadError>) -> Result<T, ReloadError> {
585    std::panic::catch_unwind(std::panic::AssertUnwindSafe(action)).unwrap_or_else(|payload| {
586        if let Some(error) = payload.downcast_ref::<ReloadError>() {
587            return Err(error.clone());
588        }
589        let message = payload
590            .downcast_ref::<String>()
591            .cloned()
592            .or_else(|| payload.downcast_ref::<&str>().map(|text| text.to_string()))
593            .unwrap_or_else(|| "Reload callback panicked".into());
594        Err(ReloadError::Failed(message))
595    })
596}
597
598impl Drop for HotReloadSession {
599    fn drop(&mut self) {
600        for (class, installed) in &self.overrides {
601            ferroui_markup_xaml::FerroXamlLoader::remove_populate_override(class, installed);
602        }
603    }
604}
605
606fn document_key(uri: &str) -> String {
607    ferroui_base::utilities::Uri::try_create(uri, ferroui_base::utilities::UriKind::Absolute)
608        .map(|uri| uri.absolute_uri().to_string())
609        .unwrap_or_else(|| uri.to_string())
610}
611
612fn dependencies_of(roots: Vec<String>, sources: &Sources) -> BTreeSet<String> {
613    use ferroui_base::utilities::{Uri, UriKind};
614    let mut seen = BTreeSet::new();
615    let mut pending = roots;
616    while let Some(uri) = pending.pop() {
617        let key = document_key(&uri);
618        if !seen.insert(key.clone()) {
619            continue;
620        }
621        let Some(source) = sources.get(&key) else {
622            continue;
623        };
624        let (Ok(document), Some(base)) = (
625            roxmltree::Document::parse(source),
626            Uri::try_create(&key, UriKind::Absolute),
627        ) else {
628            continue;
629        };
630        for node in document.descendants().filter(|node| node.is_element()) {
631            if !matches!(
632                node.tag_name().name(),
633                "StyleInclude" | "ResourceInclude" | "MergeResourceInclude"
634            ) {
635                continue;
636            }
637            if let Some(relative) = node
638                .attribute("Source")
639                .and_then(|source| Uri::try_create(source, UriKind::RelativeOrAbsolute))
640            {
641                let absolute = if relative.is_absolute_uri() {
642                    relative
643                } else {
644                    Uri::combine(&base, &relative)
645                };
646                pending.push(absolute.absolute_uri().to_string());
647            }
648        }
649    }
650    seen
651}