A card that hasn’t arrived is not a card that left

Why a Headway deep link opened and instantly snapped back to the board — and the one field that tells the two cases apart.

headway:notedeck/super-hello-emotion · commit e757177ea8d8 · epic headway:notedeck/obey-margin-scale (2 of 5)

The loop that produced the bug

Headway doesn’t own its navigation. Each frame the chrome hands it a route token through render_nav; Headway seeds state.selected from it, renders, and then diffs where the UI ended up against where the route said it started. That diff — reconcile_nav — is what turns a click into a global-history push, or a dismissal into a Back.

So anything that quietly clears selected mid-frame is indistinguishable, to the diff, from the user pressing ✕.

One frame of Headway route token Card(id) set_selected before = Card board_ui find_card misses selected = None reconcile_nav Card→Board ⇒ Back chrome pops the entry → next frame’s token is Board

What the user saw

jb55 reported that clicking an inline headway: reference from another app needed two back presses, the first landing on the board root. Commit 1 of this epic explains the doubled push. This card is the other half: even once the push is single, the opened card wouldn’t stay open. A cross-app deep link pushes the routed entry before Headway has necessarily folded that card in from nostrdb — so find_card misses on the very first frame, the selection is cleared, and the entry the chrome just pushed is popped by the app itself.

The discriminator: detail_for

Both failure and success look identical from selected alone: a NoteId that find_card can’t resolve. The field that already distinguishes them is detail_for — the card the detail pane’s edit buffers were last seeded from. It’s set the first time the pane actually renders a card, and cleared when it closes. So:

statemeaningright move
detail_for == selected we rendered this card here; it has since left the board (deleted, archived, moved away) clear → emit Back
detail_for == None the pane never showed it: the card hasn’t arrived yet (fresh deep link, remote card in flight) hold → emit nothing
-    if state.selected.take().is_some() {
-        state.detail_for = None;
-    }
+    if state.detail_for.is_some() && state.detail_for == state.selected {
+        state.selected = None;
+        state.detail_for = None;
+    }

Held costs nothing visible: the board grid draws underneath, and the detail opens on the frame the card’s events land. Nothing on the grid render path reads selected — the only write is the click handler, which overwrites it anyway.

Deliberate asymmetry

The graph_epic branch directly above still drops unconditionally, and now says why in a comment: a dependency graph is only ever entered from an epic already on the board. No deep link mints a Graph route, so there is no hasn’t-arrived-yet case to wait for.

Measured, both directions

Two tests in crates/notedeck_headway/tests/snapshot_tests.rs, both behavioural (no lavapipe). The regression test sets nav_token to a Card route for an id on no board, pumps five frames, and counts NavRequest::Back:

before 5 Backs after 0 Backs one spurious Back per render pass — it keeps firing, it isn’t a one-shot
Over-suppression guard

Holding an unresolved selection must not swallow the real dismissal. The second test opens a card’s detail for real, clicks Delete card, and asserts exactly one Back. It works because resolve_detail_outcome clears selected itself in the same frame, so the Card→Board diff still fires without depending on find_card missing. That path had no test before this commit.

Not verified here

Gates run

./scripts/ci-local — changelog-check, lint (fmt + clippy), linux-test, android, snapshot-test: all passed.
cargo test -p notedeck_headway --test snapshot_tests — 10 passed, 0 failed, 22 ignored (was 8 passed).