Skip to content

Unwind the host-mode stack when navigating to a page you are already inside (CS-12434) - #5688

Draft
burieberry wants to merge 3 commits into
mainfrom
cs-12434-host-mode-stack-unwind-on-ancestor
Draft

Unwind the host-mode stack when navigating to a page you are already inside (CS-12434)#5688
burieberry wants to merge 3 commits into
mainfrom
cs-12434-host-mode-stack-unwind-on-ancestor

Conversation

@burieberry

@burieberry burieberry commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

CS-12434

Why

Host mode only ever pushes onto its stack, so following an in-page link back to a page you are already inside stacks a second copy of it instead of unwinding to it.

Found on the boxel.ai blog: from the index, opening a post pushes it correctly, but the post's "← Back to Boxel Blog" link then pushes the index on top of the post — index → post → index — rather than closing the post and returning to the root. It applies to any host-mode realm with an "up" link.

Cards are only ever handed viewCard; no pop or truncate is exposed to them, so realm content cannot work around it.

What

Opening a card already behind you in the trail is a navigation back to it, so unwind rather than push. Pushing stays correct for a card not yet on the trail:

  • the card is the primary card → close the whole stack
  • the card is somewhere in the stack → truncate everything above it
  • otherwise → push

Applied to both stacks, which is the part worth reviewing. They are separate implementations and only the second one governs the published site:

  • OperatorModeStateService#addToHostModeStack — host submode in the workbench (host-submode.gtsviewCard)
  • HostModeStateService#pushCard — the published site (templates/index.gtsviewCard)

Dismiss had to move with it

The stack closed its top card on any click outside itself, which undid the unwind: the same click that unwound to a card also read as an outside-click and closed it. The trail emptied, serialize() returned undefined, hostModeStack dropped out of the URL and the whole stack unmounted.

Dismiss is now scoped to the scrim and ignores clicks landing inside a stack item (stack.gts). The breadcrumbs exception drops out — breadcrumbs render outside the scrim and never reached the handler.

Scope, since it affects how much of this needs scrutiny: only host mode was reachable. Submode drives the trail from stack items and breadcrumbs, so no outside-click existed there — verified by reverting the fix, where the submode test still passes while the host-mode one fails. And no visitor could hit it in either mode, because the scrim covers the primary card; only a synthetic click() reaches through. So this was a latent bug the new behaviour exposed, not a production break.

Testing

Run locally against the dev stack: host mode 26/26, host submode 40/40. lint:hbs, lint:types, eslint and prettier clean.

The ViewCardDemo fixtures are already cycles — index → secondary → tertiary → index, and 1 → 2 → 3 → 1 in submode — so the "up" link is genuine rather than contrived.

host-mode-test.gts

  • viewing the primary card from deeper in the trail closes the stack — walks down two levels, clicks through to the root, asserts both stack items are gone
  • viewing a card already in the trail unwinds to it — seeds [secondary, tertiary], views secondary, asserts tertiary closes and secondary stays on top
  • viewing a card below it in the trail unwinds to that card — the same unwind by the route a visitor can actually take, clicking a stack item's own button
  • clicking the scrim closes the top card of the trail — regression cover for the dismiss change

host-submode-test.gts — the unwind, and dismiss by scrim. That side had no unwind coverage at all, so addToHostModeStack was shipping untested.

One assertion fixed while here: the query-param check read window.location, which in an acceptance test is the test runner's own URL and never carries app params, so it passed regardless of behaviour. The new assertions read currentURL().

Notes

Not a regression. The behaviour has been push-only since the stack landed in #3361 and viewCard was wired to it in #3381; no commit has touched those function bodies since. So this is new behaviour rather than a revert — worth a glance at those PRs if there was original intent I have missed.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   2h 12m 16s ⏱️ - 26m 10s
3 835 tests +8  3 821 ✅ + 9  14 💤 ±0  0 ❌ ±0 
3 854 runs  +8  3 840 ✅ +10  14 💤 ±0  0 ❌  - 1 

Results for commit 645d5cd. ± Comparison against earlier commit a5a4d31.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   13m 45s ⏱️ -28s
2 064 tests ±0  2 064 ✅ ±0  0 💤 ±0  0 ❌ ±0 
2 143 runs  ±0  2 143 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 645d5cd. ± Comparison against earlier commit a5a4d31.

burieberry and others added 3 commits August 4, 2026 18:51
The published site does not go through addToHostModeStack: templates/index.gts
calls HostModeStateService#pushCard, a separate stack with the same push-only
behaviour. Fixing only the operator-mode service left boxel.ai — the case that
matters — still stacking a duplicate of the page you came from.

Tests use the existing ViewCardDemo fixture, which is already a cycle
(index → secondary → tertiary → index), so tertiary's button is a real "up"
link: one test walks down two levels and clicks through to the root, the other
seeds a trail and unwinds to its middle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stack closed its top card on any click outside itself. Combined with the
unwind, that undid the navigation it was reacting to: opening a card already in
the trail unwinds to it, leaving that card on top, and the same click then read
as an outside-click and closed it. The trail emptied, serialize() returned
undefined, the hostModeStack param dropped out of the URL and the whole stack
unmounted.

Dismiss is now scoped to the scrim itself and ignores clicks landing inside a
stack item, so the gesture is defined by where it applies rather than by
exclusion. The breadcrumbs exception drops out — breadcrumbs render outside the
scrim and never reached the handler.

Only host mode was affected. Host submode drives the trail from stack items and
breadcrumbs, so no click outside the stack existed there; a real visitor could
not hit it in either mode, since the scrim covers the primary card and only a
synthetic click reaches through it.

Tests: the unwind by the route a visitor can take (a stack item targeting a card
below it) in both modes, and dismiss-by-scrim in both. Host submode had no
unwind coverage at all, so addToHostModeStack was untested. The existing
query-param assertion read window.location, which is the test runner's URL and
never carries app params; the new one reads currentURL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@burieberry
burieberry force-pushed the cs-12434-host-mode-stack-unwind-on-ancestor branch from a5a4d31 to 645d5cd Compare August 5, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant