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.
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 ✕.
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:
| state | meaning | right 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.
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:
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
-
No real cross-app deep link was exercised. That’s commit 5
(
headway:notedeck/duck-echo-chronic). This commit removes a blocker for it; the end-to-end “one back press” claim is only provable there. -
The held-then-resolved transition isn’t directly tested. The
regression test proves no
Backis emitted while the card is absent; it does not ingest the card mid-test and watch the detail open. Worth adding when commit 5 lands a real deep-link test. -
Stale-token behaviour under the real chrome. In this chrome-less
harness the token stays
Card(deleted)after a delete, which would re-seed the selection on the next frame. Under the real chrome theBackis drained and the token becomesBoardfirst. The delete test therefore only asserts the single frame after the click. - Snapshot images not regenerated — none of the 22 lavapipe snapshots are affected (no visual change on a board with all its cards present).
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).