One click, two entries
Why a cross-app Headway deep link needs two back presses — and why the fix is a hook the chrome calls, not a push the app makes.
Clicking an inline headway: reference from another app (a card chip in a Dave
message, a timeline note) switches to the Headway app and opens the card. Backing out of it takes
two presses, and the first one lands on the Headway board root instead of
returning you to where you clicked.
What actually happens
The chrome's chrome_handle_app_action does two things in its Headway arm
(chrome.rs:2036-2045): switch_to_headway(), then
headway.open(note_id). Each one independently produces a global-history entry.
The app-switch entry carries a () token, which Headway's
render_nav downcasts to None and draws as the board root. Then
process_pending_open sets state.selected the next frame, Headway's own
before/after diff reads Board→Card, and reconcile_nav enqueues a second entry.
Dave and Notebook don't show the bug because neither has a render_nav route type:
their open() only mutates internal state, so just the one app-switch entry lands.
Headway is the only app with a real route today.
Why the app can't just push the entry itself
The obvious fix — have Headway call Navigator::push_active_route in its own
open path — does not work, and the reason is where the AppId comes from.
An app never learns its own slot (navigator.rs:104-113). So the active-owned
requests carry only the token, and the chrome completes them at drain time by tagging with
AppId(self.active) (chrome.rs, apply_nav_requests). During a
cross-app open, self.active is still the source app.
Only the chrome knows which app it just resolved the note to, so only the chrome can mint a correctly tagged entry. That asymmetry is the whole argument for a hook.
What landed in this commit
fn open_note_route(
&mut self,
ctx: &mut AppContext<'_>,
note_id: nostrdb_net::NoteId,
) -> Option<Rc<dyn Any>> {
let _ = (ctx, note_id);
None
}
A defaulted hook on notedeck::App, beside render_nav /
nav_title / cleanup_nav, plus its fan-out across every
NotedeckApp variant in the chrome — in the same commit, so the enum is
never silently non-forwarding while the trait has grown a method.
The contract in the doc comment: returning Some(token) buys exactly one
history entry, so the app must not also enqueue a push for the same open. It may mutate
state the route depends on but doesn't carry (which board is active) before returning.
None — the default — means a plain app switch, exactly as today.
- Nothing calls the hook yet. Every implementation is the trait default, so this commit is inert by construction — there is no behaviour to assert and no test was added. The contract above is a claim the later commits have to honour, not one this commit demonstrates.
- The two-entry trace is from the epic's investigation, read off the code paths named above. This session did not reproduce it in a running notedeck.
cargo check -p notedeck_chrome --all-featuresfails, but pre-existing and unrelated: it turns on bothprofiling/profile-with-tracyandprofile-with-puffin, which redefine each other's macros (E0428). The fan-out was verified against every app feature explicitly instead.
Where this sits
Commit 1 of 5 on headway:notedeck/obey-margin-scale. The remaining four:
don't back out of a card whose events haven't folded in; factor the board switch out of
process_pending_open; implement the hook in Headway; and finally have the chrome call
it — which is the commit a user can actually feel.