From 8721f0774cbb64c6e8386d55d701e71e4ad346c0 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Fri, 31 Jul 2026 22:31:08 -0700 Subject: [PATCH] fix: fire on-enter for the default route at startup (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ``'s `on-enter` hook did not fire for the default route when a `` first mounted; it only fired on subsequent navigations. The initial path is seeded through lazy initialization (`router_current_path`, from the render pass) without a path *change*, so `apply_one_navigation` — which fires hooks only when `old_path != new_path` — never saw the initial mount and skipped its `on-enter`. Mirror the existing deferred-navigation design: when `router_current_path` first seeds a router to its `default` (or `--route` override), enqueue the router id in a new `pending_initial_enters` queue and ping `data_notify`. The App poll loop drains it via `fire_pending_initial_enters()` right after `apply_pending_navigations()` and before the data-update pass, projecting the route and firing its `on-enter` once, outside the extension lock (the same re-entrancy-safe apply point as navigation hooks). Firing is deferred out of the render pass because `call_handler` holds the extension write lock. Adds regression test `test_initial_default_route_fires_on_enter`; updates the routing KB doc and log. Co-Authored-By: Claude Opus 4.8 --- crates/nemo/src/app.rs | 6 +- crates/nemo/src/runtime.rs | 147 +++++++++++++++++++++++-- docs/knowledgebase/log.md | 1 + docs/knowledgebase/patterns/routing.md | 14 ++- 4 files changed, 158 insertions(+), 10 deletions(-) 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#" + +