From 16cd3d0411fcbaaefc30b91af3ffa02aa6750543 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 01:44:16 +0200 Subject: [PATCH] fix(router): update route rune synchronously in navigate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui/e2e/offline.spec.ts | 8 +++++--- ui/e2e/router.spec.ts | 26 ++++++++++++++++++++++++++ ui/src/lib/app.svelte.ts | 7 +++++++ ui/src/lib/router.svelte.ts | 11 +++++++---- 4 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 ui/e2e/router.spec.ts diff --git a/ui/e2e/offline.spec.ts b/ui/e2e/offline.spec.ts index 4b30d47..591ea2c 100644 --- a/ui/e2e/offline.spec.ts +++ b/ui/e2e/offline.spec.ts @@ -13,10 +13,12 @@ async function forceStandalone(page: Page): Promise { }); } -// After a load or reload, land in the lobby (dismiss the login screen if it is shown). +// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so +// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes +// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the +// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby. async function enterLobby(page: Page): Promise { - const guest = page.getByRole('button', { name: /guest|гост/i }); - if (await guest.count()) await guest.first().click(); + await page.getByRole('button', { name: /guest|гост/i }).first().click(); // The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is // robust to locale, emoji variation selectors and the coachmark anchors (which the first-run // onboarding strips after it completes). diff --git a/ui/e2e/router.spec.ts b/ui/e2e/router.spec.ts new file mode 100644 index 0000000..c023ca6 --- /dev/null +++ b/ui/e2e/router.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from './fixtures'; + +// The hash router must update its reactive `route` rune synchronously inside navigate(), not defer it +// to the asynchronous `hashchange` event. Bootstrap flips `app.ready` in the same tick right after +// `navigate('/login')` on an unauthenticated cold start; a route that trailed the hash for one frame +// let App.svelte render the stale route (the empty-hash lobby) under the new login screen — a visible +// lobby-shell flash plus a doomed games.list. It also made the offline e2e flaky: enterLobby could +// latch that transient lobby tab-bar instead of clicking through the login screen. + +test('navigate updates the reactive route synchronously (no hashchange lag)', async ({ page }) => { + await page.goto('/'); + // The bundle has loaded and installed the mock __router seam once the login screen is up. + await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); + + // Read the route in the SAME microtask as the navigate: the fix makes it the new route at once; + // the pre-fix code returned the previous route (it waited for the async hashchange). + const routeAfterNavigate = await page.evaluate(() => { + const r = (window as unknown as { __router: { navigate(p: string): void; route(): string } }).__router; + r.navigate('/settings'); + return r.route(); + }); + expect(routeAfterNavigate).toBe('settings'); + + // The hash was written too, so a reload/back still resolves the same route. + expect(new URL(page.url()).hash).toBe('#/settings'); +}); diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 49ab6aa..7e33aaf 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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, + }; } diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts index 3e62dde..71e0d2f 100644 --- a/ui/src/lib/router.svelte.ts +++ b/ui/src/lib/router.svelte.ts @@ -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; } }