diff --git a/crates/nemo/src/app.rs b/crates/nemo/src/app.rs index 1aa1e33..b44c2f9 100644 --- a/crates/nemo/src/app.rs +++ b/crates/nemo/src/app.rs @@ -55,8 +55,12 @@ impl App { // Apply queued navigations first so any route path/param projections // they flag are picked up by the data-update pass that follows. let navigated = poll_runtime.apply_pending_navigations(); + // Fire the initial `on-enter` for any router the render pass just + // seeded to its default path, before the data-update pass picks up + // the route projection it flags (issue #81). + let initial_enters = poll_runtime.fire_pending_initial_enters(); let data_updated = poll_runtime.apply_pending_data_updates(); - if navigated || data_updated { + if navigated || initial_enters || data_updated { let _ = this.update(cx, |_app: &mut App, cx: &mut Context| { cx.notify(); }); diff --git a/crates/nemo/src/runtime.rs b/crates/nemo/src/runtime.rs index ad59e02..f381a4d 100644 --- a/crates/nemo/src/runtime.rs +++ b/crates/nemo/src/runtime.rs @@ -171,6 +171,10 @@ pub struct NemoRuntime { /// Launch-time router starting-path override (from `--route`); consulted /// once when a router is first initialized. initial_route: Arc>>, + /// Router ids that were just seeded to their default/`--route` path by the + /// render pass and still owe a one-shot initial `on-enter`. Drained outside + /// the extension lock by [`Self::fire_pending_initial_enters`]. + pending_initial_enters: Arc>>, } impl NemoRuntime { @@ -216,6 +220,7 @@ impl NemoRuntime { router_states: Arc::new(RwLock::new(HashMap::new())), nav_intents: Arc::new(Mutex::new(Vec::new())), initial_route: Arc::new(Mutex::new(None)), + pending_initial_enters: Arc::new(Mutex::new(Vec::new())), }) } @@ -984,14 +989,30 @@ impl NemoRuntime { .initial_path_for(router_id) .unwrap_or_else(|| default_path.to_string()); let mut states = self.router_states.write().expect("router_states poisoned"); - let st = states - .entry(router_id.to_string()) - .or_insert_with(|| RouterState { - history: vec![init_path.clone()], - index: 0, - params: HashMap::new(), - projected: false, - }); + // A concurrent render may have raced us to initialize this router; only + // the render that actually seeds the state owes the initial `on-enter`. + if !states.contains_key(router_id) { + states.insert( + router_id.to_string(), + RouterState { + history: vec![init_path.clone()], + index: 0, + params: HashMap::new(), + projected: false, + }, + ); + drop(states); + // The default route (or `--route` override) is seeded without a + // path *change*, so `apply_one_navigation` never fires its + // `on-enter`. Queue it to fire once from the poll loop, outside the + // render pass and the extension lock. + if let Ok(mut q) = self.pending_initial_enters.lock() { + q.push(router_id.to_string()); + } + self.data_notify.notify_one(); + return init_path; + } + let st = states.get(router_id).expect("router state just checked"); st.history.get(st.index).cloned().unwrap_or(init_path) } @@ -1149,6 +1170,55 @@ impl NemoRuntime { any } + /// Fires the one-shot `on-enter` hook for routers that the render pass just + /// seeded to their default (or `--route`) path. A router's initial path is + /// set through lazy initialization ([`Self::router_current_path`]) without a + /// path *change*, so [`Self::apply_one_navigation`] never fires it; draining + /// the queue here makes the initial mount consistent with later navigations + /// (issue #81). Runs on the UI thread from the poll loop, **outside** the + /// extension lock. Returns `true` if any hook fired (so the caller + /// re-renders). + pub fn fire_pending_initial_enters(&self) -> bool { + let router_ids: Vec = { + let mut q = self + .pending_initial_enters + .lock() + .expect("pending_initial_enters poisoned"); + if q.is_empty() { + return false; + } + std::mem::take(&mut *q) + }; + + let mut any = false; + for router_id in router_ids { + let info = match self.router_info(&router_id) { + Some(info) => info, + None => continue, + }; + let path = match self.router_current_path_peek(&router_id) { + Some(path) => path, + None => continue, + }; + let patterns: Vec = info.routes.iter().map(|r| r.pattern.clone()).collect(); + let (idx, params) = match crate::containers::router::resolve_route(&patterns, &path) { + Some(resolved) => resolved, + None => continue, + }; + + // Project path + params before firing the hook, mirroring the order + // in `apply_one_navigation`, so the handler can read route params. + self.write_route_to_repo(&router_id, &path, ¶ms); + self.mark_route_dirty(&router_id, ¶ms); + + if let Some(handler) = info.routes.get(idx).and_then(|r| r.on_enter.clone()) { + self.call_handler(&handler, &router_id, "enter"); + any = true; + } + } + any + } + /// Applies a single navigation intent. Returns `true` if the current path /// actually changed. fn apply_one_navigation(&self, intent: NavIntent) -> bool { @@ -5128,6 +5198,67 @@ mod error_path_tests { assert!(get("data.route.main.params.id").is_none()); } + /// Regression guard for issue #81: the default route's `on-enter` fires + /// once at startup. The render pass seeds the router to its `default` via + /// [`NemoRuntime::router_current_path`], which queues the initial enter; + /// draining the queue fires the hook — with no prior navigation. + #[test] + fn test_initial_default_route_fires_on_enter() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let scripts_dir = dir.path().join("scripts"); + std::fs::create_dir(&scripts_dir).unwrap(); + { + let mut f = std::fs::File::create(scripts_dir.join("handlers.rhai")).unwrap(); + writeln!(f, "fn check(id, ev) {{ set_data(\"test.entered\", id); }}").unwrap(); + } + let config_path = dir.path().join("app.xml"); + { + let mut f = std::fs::File::create(&config_path).unwrap(); + write!( + f, + r#" + +