fix(router): update route rune synchronously in navigate()
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s

navigate() wrote location.hash and left router.route to the asynchronous
hashchange event. bootstrap flips app.ready in the same tick right after
navigate('/login') on an unauthenticated cold start, so for one frame the app
rendered with app.ready=true and the stale route ('lobby', from the empty hash)
— briefly mounting the lobby shell (tab bar + a doomed games.list) under the new
login screen before hashchange settled it.

Update router.route synchronously inside navigate(); the later hashchange
re-parses to the same value. This removes the boot lobby-flash (and its spurious
games.list 401) and, as the root cause, the offline e2e flake: enterLobby could
latch that transient lobby tab-bar instead of clicking through login, then the
Settings-tab click at line 44 fought the tab detaching into the login slide
(~7% of runs, both engines).

Also:
- offline.spec enterLobby: wait for the guest button and click it, instead of a
  point-in-time count() that a pre-login splash frame sampled as 0 (skipping the
  click and hanging / latching the transient).
- New e2e router.spec pins the synchronous-route property (RED on the old code,
  which returned the previous route; GREEN now) via a mock-only __router seam.

Verify: check 0 / unit 490 / build / bundle 114.3/115 / full e2e 200 / the
offline+router stress at 40x on both engines 160/160 (was 6/80 failing).
This commit is contained in:
Ilia Denisov
2026-07-07 01:44:16 +02:00
parent ef8a32bb82
commit 16cd3d0411
4 changed files with 45 additions and 7 deletions
+7
View File
@@ -1310,4 +1310,11 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
closeStream();
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
};
// Expose the router so the e2e can assert navigate() updates the route synchronously (no
// hashchange lag): a route that trailed the hash for a frame let bootstrap's `app.ready` flip
// render the previous route under the new screen (the empty-hash lobby flashing under login).
(window as unknown as { __router?: { navigate(p: string): void; route(): string } }).__router = {
navigate,
route: () => router.route.name,
};
}
+7 -4
View File
@@ -17,12 +17,15 @@ if (typeof window !== 'undefined') {
});
}
/** navigate switches the hash route (and forces a re-parse if it is unchanged). */
/** navigate switches the hash route (and forces a re-parse if it is unchanged). The route rune is
* updated synchronously — not left to the asynchronous `hashchange` — so a caller that renders off
* the route in the same tick (notably bootstrap flipping `app.ready` right after `navigate('/login')`)
* never sees `router.route` lag the hash for a frame, which would briefly render the previous route
* (the empty-hash lobby) under the new screen. The later `hashchange` re-parses to the same value. */
export function navigate(path: string): void {
const target = '#' + path;
if (location.hash === target) {
router.route = parse(target);
} else {
router.route = parse(target);
if (location.hash !== target) {
location.hash = path;
}
}