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.

FRAME N — THE CLICK switch_to_headway() FRAME N+1 Dave / () Dave / () Headway / () renders the BOARD ROOT reconcile_nav Dave / () Headway / () Headway / Card what you asked for back #1 → Headway root    back #2 → Dave one click, two entries

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.

HEADWAY ENQUEUES CHROME DRAINS & TAGS RESULT ActiveNavEntry { token: HeadwayRoute::Card } .tag(AppId(self.active)) active == Dave, not Headway Dave / HeadwayRoute render_nav hands DAVE a HeadwayRoute to downcast THE HOOK INSTEAD chrome asks the TARGET app for a token → tags it with the target's AppId → pushes exactly one entry

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.

Not verified, and deliberately so

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.

Commit · 802f471a860f — notedeck: add App::open_note_route for cross-app note deep links
Card · headway:notedeck/enable-floor-decrease
Checks · ./scripts/ci-local green (changelog, lint, linux-test, android, snapshot-test); test count 419, unchanged