Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/nemo/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<App>| {
cx.notify();
});
Expand Down
147 changes: 139 additions & 8 deletions crates/nemo/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<Option<InitialRoute>>>,
/// 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<Mutex<Vec<String>>>,
}

impl NemoRuntime {
Expand Down Expand Up @@ -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())),
})
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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<String> = {
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<String> = 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, &params);
self.mark_route_dirty(&router_id, &params);

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 {
Expand Down Expand Up @@ -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#"<nemo>
<app title="t"/>
<script src="./scripts"/>
<layout type="stack">
<router id="main" default="/connect" primary="true">
<route path="/connect" on-enter="check"></route>
<route path="/other"></route>
</router>
</layout>
</nemo>"#
)
.unwrap();
}

let rt = NemoRuntime::new(&config_path).unwrap();
rt.load_config().unwrap();
rt.initialize().unwrap();

// Nothing queued and no enter fired until the render pass seeds the
// router to its default.
assert!(!rt.fire_pending_initial_enters());

// The render pass lazily initializes the router to its `default`.
assert_eq!(rt.router_current_path("main", "/connect"), "/connect");

// Draining the queue fires the default route's `on-enter` — no prior
// navigation required.
assert!(rt.fire_pending_initial_enters());
let get = |p: &str| {
rt.data_engine
.repository
.get(&nemo_data::DataPath::parse(p).unwrap())
};
assert_eq!(
get("data.test.entered").and_then(|v| v.as_str().map(String::from)),
Some("main".to_string())
);

// It fires exactly once — a second drain is a no-op.
assert!(!rt.fire_pending_initial_enters());
}

/// `--route settings=/general` (explicit router id) overrides that router's
/// starting path on lazy init, and leaves other routers on their default.
#[test]
Expand Down
1 change: 1 addition & 0 deletions docs/knowledgebase/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,4 @@
## 2026-07-31
* **Fix**: Issue #82 — `nemo screenshot` rendered the initial layout but bound values (`<binding>`/`bind-*`) stayed at their placeholders in the PNG even though they populate in the running app. **Root cause:** startup ordering. A data source's `start()` broadcasts its initial `full` value immediately, and a tokio `broadcast` channel drops messages sent while no receiver is attached; the runtime subscribed the update loops *after* `data_engine.start_all()`, so each source's first value was lost and the repository was never seeded. Interactive runs recover via a later event (e.g. a file-watcher change); a one-shot capture never does. **Fix (two parts):** (1) `runtime.rs` `initialize()` now calls `start_data_update_loop().await` **before** `data_engine.start_all().await`, so receivers are attached before sources broadcast — the actual root-cause fix, benefits every one-shot/early-read path, not just screenshots. (2) `commands/screenshot.rs` capture task now *pumps* the data→binding loop across the `--settle-ms` window in 50ms slices (grabbing `ActiveProject`'s runtime, calling `apply_pending_navigations()`/`apply_pending_data_updates()` and `window.refresh()` when anything changed), then does a final refresh + one executor-yield timer before `render_to_image()` — because `render_to_image` reads the window's *last drawn* frame and `refresh()` only sets a dirty flag; the interactive `App` data task drives this on every `data_notify`, so the one-shot path must drive it itself. Verified: `examples/data-binding` screenshot now shows live timer/HTTP values instead of placeholders; 233 nemo + 18 macro tests green; `cargo build -p nemo --features screenshot` clean. Updated [data flow](/docs/knowledgebase/concepts/data-flow.md) (new startup-ordering note under Data sources).
* **Feature**: Opt-in **header-bar dropdown menu**. Apps declare `<menu-item label icon on-click separator>` children under `<header-bar>`; when present, a far-left hamburger icon opens a native dropdown, each entry invoking a Rhai handler via `runtime.call_handler(handler, "header-bar", "click")`. Parser: added `process_header_bar` (`crates/nemo-config/src/xml_parser.rs`) so repeated `<menu-item>` children survive as a `menu_items` array at `app.window.header_bar.menu_items` (the generic `process_nested_block` would collapse them; note `__type__` is already snake-cased, so the special-case matches `header_bar`/`menu_item`). Render: `HeaderBar` (`crates/nemo/src/workspace/header_bar.rs`) gained `menu_items`/`runtime` fields + a `menu_items_from_config` helper, building `DropdownButton` + `PopupMenuItem` (reusing `components::icon::map_icon_name` for item icons); both construction sites (`main.rs`, `workspace/mod.rs::create_header_bar`) pass them. Declared `menu-item` in the schema surface (`crates/nemo-registry/src/schema_surface.rs`). Demoed in `examples/basic`. Verified: new parser test `test_parse_header_bar_menu_items` + `cargo build -p nemo` clean. Updated [configuration](/docs/knowledgebase/concepts/configuration.md) and the nemo-xml-reference skill.
* **Fix**: Issue #81 — a `<route>`'s `on-enter` hook did not fire for the **default route** at startup; it only fired on subsequent navigations. **Root cause:** the initial route is seeded through lazy initialization (`router_current_path`, called from the render pass) without a path *change*, so `apply_one_navigation` — which fires `on-leave`/`on-enter` only when `old_path != new_path` — never sees the initial mount and skips its `on-enter`. **Fix (`crates/nemo/src/runtime.rs` + `app.rs`):** added a `pending_initial_enters` queue on `NemoRuntime`; when `router_current_path` first seeds a router to its `default` (or `--route` override) it enqueues the router id and pings `data_notify`. The `App` poll loop calls the new `fire_pending_initial_enters()` right after `apply_pending_navigations()` and before the data-update pass — it projects the route (path+params) and fires that route's `on-enter` **once**, outside the extension lock (same re-entrancy-safe apply point as navigation hooks). Firing is deferred out of the render pass because `call_handler` holds the `extension_manager` write lock. Verified: new regression test `test_initial_default_route_fires_on_enter` (proves the default route's `on-enter` fires with no prior navigation and fires exactly once) + existing navigation/override tests green; clippy clean. Updated [routing](/docs/knowledgebase/patterns/routing.md).
14 changes: 13 additions & 1 deletion docs/knowledgebase/patterns/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,19 @@ through a **deferred queue**, mirroring the `plugin_dirty_paths` reactivity path

`on-enter`/`on-leave` are parsed as normal handlers (kebab→snake `on_*` →
`handlers["enter"]`/`handlers["leave"]`) and fire with `(router_id, "enter" |
"leave")`. They fire only on an actual path change.
"leave")`. On a navigation they fire only on an actual path change.

The **initial** route is a special case (issue #81). Its path is seeded through
lazy initialization (`router_current_path`, called from the render pass) without
a path *change*, so `apply_one_navigation` never sees it and would skip its
`on-enter`. Instead, when the render pass first seeds a router to its `default`
(or `--route` override), it enqueues the router id in `pending_initial_enters`
and pings `data_notify`; the poll loop then calls
`NemoRuntime::fire_pending_initial_enters()` (right after
`apply_pending_navigations`, before the data-update pass) to project the route
and fire that route's `on-enter` **once**, outside the extension lock. So the
default route's `on-enter` fires at startup, consistent with later navigations —
each route's `on-enter` fires exactly once when it becomes active.

# Path matching

Expand Down
Loading