Skip to content

fix(ui): land a refused view switch on the identity's own home view - #297

Merged
argszero merged 1 commit into
mainfrom
fix/refused-view-switch-lands-on-an-unrendered-dashboard
Sep 24, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/refused-view-switch-lands-on-an-unrendered-dashboard

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

switchView() refused a view the current identity may not enter with a bare return;,
and enterApp() ignored the outcome. Reusing the DOM as-is is right while a view is on
screen
(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 the
untouched DOM is still the static skeleton.

enterApp() picks its destination from the URL hash, so a hash naming a gated view lands the
user on a dashboard that was never rendered:

  • #/admin for a non-admin, #/ops for everyone (no production code ever writes
    role = 'ops', so that guard is true for every account);
  • #dash-stats / #dash-trend / #dash-month-changes / #dash-sharings stay empty,
    no request is issued afterwards, and nothing re-renders it — enterApp() runs once.

Reachable by ordinary use: exitGuest() (logout / 401) does not reset location.hash, so
logging out from #/admin and 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;, and enterApp() falls back to homeView() when
the 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 dashboard
re-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 return false, success returns true); new homeView() derives the fallback from the identity; enterApp() consumes the outcome and falls back
  • src/state_gate.rs — new gate the_refused_view_switch_never_leaves_an_unrendered_view (four rules, every name derived from the code) plus the_r163_roster_is_real and the_r163_rules_have_teeth
  • src/state_gate.rs — the R173 gate's "every guard precedes the keyboard clear" rule now exempts only the return that 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 out
  • ui/README.md — new section stating the contract, the gate's scope and the R173 interface
  • ui/index.html — cache-bust bump for ui/js/app.js (20260924-4 → 20260925-1)
  • No configuration or data-structure change

Tests

  • cargo test — 384 passed / 0 failed (baseline 315ce7b was 381; +3 new tests)
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • New tests added: the gate has four independently-toothed rules (one mutation leg per rule; the faithful pre-fix shape — refusal falls through and the caller drops the fallback — is a separate roster control, and it is noted there that dropping the fallback necessarily also removes the fallback function from the derivation)

Instrumentation (each instrument declares every leg's expectation up front):

  1. Compile-time gate, A/B on the real tree — with ui/js/app.js restored to the pre-fix
    blob (2390d877…), the axis test fails with exactly the declared reading
    r1=false r2=false r3=false r4=true (toasts=3 falling=3 tail=false); with the fix it is
    green. 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 a
    switchView that only returns on guards).
  2. jsdom probe, 11 legs, three trees — base on the pre-fix tree is 11/11 as declared with
    exactly B1/B2/B3/F1 red (the axis legs: non-admin #/admin, admin #/ops, no
    self-heal, and the URL still naming the wrong view); fix is 11/11 green; the landed bytes
    (no patch applied) are 11/11 green; the competitive fix m_redirect — refuse by always
    redirecting to dashboard — is rejected by E1/E3 (the guest is bounced back and forth
    and 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 #/ops is reachable for everyone
(the reachability of the ops role is a separate recorded axis awaiting a decision). That half
belongs to the jsdom probe; the two instruments each see what the other cannot.

Checklist

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits
  • Single responsibility, minimal change

`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`.
@argszero

argszero commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner Author

Self-check before merging (the author cannot formally approve their own PR, so the check is
recorded here — same shape as the earlier landings in this series).

  • cargo test 384 passed / 0 failed; cargo fmt --check and
    cargo clippy --all-targets -- -D warnings are both clean locally, and CI
    (test / fmt / clippy) is green.
  • A/B on the real tree: with ui/js/app.js restored to the pre-fix blob the axis test fails
    with exactly the declared reading (r1=r2=r3=false, r4=true), and the R173 tests stay
    green on that same tree — the R173 refinement is behaviour-preserving for the old shape.
  • jsdom probe: base 11/11 as declared with exactly the four axis legs red; fix 11/11
    green; the landed bytes (no patch applied) 11/11 green; the competing fix
    m_redirect rejected by E1/E3.
  • No i18n key added or changed; no configuration or data-structure change.

@argszero
argszero merged commit 31bbff6 into main Sep 24, 2026
1 check passed
@argszero
argszero deleted the fix/refused-view-switch-lands-on-an-unrendered-dashboard branch September 24, 2026 22:49
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