Repository navigation
fix(ui): land a refused view switch on the identity's own home view - #297
Merged
argszero merged 1 commit intoSep 24, 2026
Merged
Conversation
`switchView()` refused a view the current identity may not enter with a bare
`return`, and `enterApp()` ignored the outcome. That is right while a view is
on screen (the Back-button case) but wrong at session establishment: C2136 made
boot render no view at all and C2137 removed the boot's unconditional
`renderView("dashboard")`, so at that moment nothing has been rendered and the
DOM is still the static skeleton. `enterApp()` takes its destination from the
URL hash, so a hash naming a gated view (`#/admin` for a non-admin, `#/ops` for
everyone -- no production code ever writes role 'ops') left the user on a
dashboard that was never rendered: empty cards, no request afterwards, one
toast, and no self-heal, because `enterApp()` runs only once. Reachable by
ordinary use -- `exitGuest()` does not reset `location.hash`, so logging out
from `#/admin` and back in as a lower-role account reproduces it.
Report the outcome instead of swallowing it: every refusal returns `false` and
the success path ends with `return true;`. `enterApp()` falls back to
`homeView()` when the destination was refused, and the fallback target is
derived from the identity (a guest's home is the marketplace, not the
dashboard). The refusal keeps toasting, so the user is still told why.
Gate `state_gate::the_refused_view_switch_never_leaves_an_unrendered_view`
(four rules; the switcher, the caller and the fallback are all derived from the
code, so no function name is pinned), plus a roster test with the two-halves
defect as a control and four single-rule mutation legs. `ui/README.md` states
the contract and the gate's scope. The R173 gate's "every guard precedes the
keyboard clear" rule is tightened to exempt only the return that is the
function's last statement -- with a new mutation leg proving the exemption did
not hollow the rule out. Cache-bust bumped for `ui/js/app.js`.
Owner
Author
|
Self-check before merging (the author cannot formally approve their own PR, so the check is
|
argszero
deleted the
fix/refused-view-switch-lands-on-an-unrendered-dashboard
branch
September 24, 2026 22:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
switchView()refused a view the current identity may not enter with a barereturn;,and
enterApp()ignored the outcome. Reusing the DOM as-is is right while a view is onscreen (the Back-button case — the user asked for something they may not enter and stays
where they were, with a toast explaining why). It is not right at session establishment:
C2136 made boot render no view at all (it only wires the shell) and C2137 removed the boot's
unconditional
renderView("dashboard"), so at that moment nothing has been rendered and theuntouched DOM is still the static skeleton.
enterApp()picks its destination from the URL hash, so a hash naming a gated view lands theuser on a dashboard that was never rendered:
#/adminfor a non-admin,#/opsfor everyone (no production code ever writesrole = 'ops', so that guard is true for every account);#dash-stats/#dash-trend/#dash-month-changes/#dash-sharingsstay empty,no request is issued afterwards, and nothing re-renders it —
enterApp()runs once.Reachable by ordinary use:
exitGuest()(logout / 401) does not resetlocation.hash, sologging out from
#/adminand signing back in as a lower-role account reproduces it.Make the switch report its outcome instead of swallowing it: every refusal returns
false,the success path ends with
return true;, andenterApp()falls back tohomeView()whenthe destination was refused. The fallback target is derived from the identity — a guest's
home is the marketplace, not the dashboard — because an unconditional redirect to
dashboardre-enters the guest guard and loses the explanatory toast (measured:
RangeError: Maximum call stack size exceeded). The refusal keeps toasting, so the user is still told why.Related Issue
No linked issue — the repository has no open issues; this is a self-contained correctness fix
(same shape as the previous UI-contract fixes in this series).
Changes
ui/js/app.js—switchView()reports whether it entered (three refusals returnfalse, success returnstrue); newhomeView()derives the fallback from the identity;enterApp()consumes the outcome and falls backsrc/state_gate.rs— new gatethe_refused_view_switch_never_leaves_an_unrendered_view(four rules, every name derived from the code) plusthe_r163_roster_is_realandthe_r163_rules_have_teethsrc/state_gate.rs— the R173 gate's "every guard precedes the keyboard clear" rule now exempts only thereturnthat is the function's last statement (R163 gives the success path a return value, and it legitimately sits after the clear); a new mutation leg (r173_variant_guard_after_clear) proves the exemption did not hollow the rule outui/README.md— new section stating the contract, the gate's scope and the R173 interfaceui/index.html— cache-bust bump forui/js/app.js(20260924-4→20260925-1)Tests
cargo test— 384 passed / 0 failed (baseline315ce7bwas 381; +3 new tests)cargo fmt --check— cleancargo clippy --all-targets -- -D warnings— cleanInstrumentation (each instrument declares every leg's expectation up front):
ui/js/app.jsrestored to the pre-fixblob (
2390d877…), the axis test fails with exactly the declared readingr1=false r2=false r3=false r4=true(toasts=3 falling=3 tail=false); with the fix it isgreen. The same pre-fix tree keeps every R173 test green, so the R173 refinement is
behaviour-preserving for the old shape (
returns.iter().all(|&r| r < calls[0])on aswitchViewthat only returns on guards).baseon the pre-fix tree is 11/11 as declared withexactly
B1/B2/B3/F1red (the axis legs: non-admin#/admin, admin#/ops, noself-heal, and the URL still naming the wrong view);
fixis 11/11 green; the landed bytes(no patch applied) are 11/11 green; the competitive fix
m_redirect— refuse by alwaysredirecting to
dashboard— is rejected byE1/E3(the guest is bounced back and forthand the recursion blows the stack). The probe's own patch differs from the landed file only
in comments and blank lines (checked by normalising both).
Scope, stated honestly: the gate is lexical — it proves the three shapes (a refusal is
reported, the caller reacts, the fallback target is identity-derived). It does not prove that
something is actually rendered on screen, nor the premise that
#/opsis reachable for everyone(the reachability of the
opsrole is a separate recorded axis awaiting a decision). That halfbelongs to the jsdom probe; the two instruments each see what the other cannot.
Checklist
fix/…)