UI: fix the lobby slide on Telegram cold launch (correct the cause)
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 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s

The first attempt (the App.svelte `started` gate) targeted the first pane mount,
but the slide is a second render. On a Telegram cold launch the URL fragment is
Telegram's #tgWebAppData=... launch params, which the router parsed as notfound;
bootstrap's navigate('/') then corrected it to the lobby asynchronously, re-keying
the route pane (notfound -> lobby) and sliding the lobby in as if returning from a
screen. A reload was static because the hash was already #/.

Treat a Telegram launch fragment as the lobby root in the router, so the route is
correct from the first pane (no re-key, no slide). Extract the pure hash->Route
parsing into routeparse.ts so it unit-tests without a DOM, and revert the gate
(the first pane never slid — local transitions skip the initial mount, as clean
browser launches showed).

Tests: routeparse unit tests (incl. the tgWebApp fragment); an e2e that launches
with the fragment in the URL and asserts the lobby plus the normalised #/ hash.
This commit is contained in:
Ilia Denisov
2026-06-11 23:40:40 +02:00
parent 9277a70565
commit c32a15730a
5 changed files with 119 additions and 59 deletions
+17
View File
@@ -35,6 +35,23 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme',
.toBe('#101418'); .toBe('#101418');
}); });
test('a Telegram launch fragment in the URL still lands on the lobby and normalises the hash', async ({
page,
}) => {
await page.addInitScript((stub) => {
Object.assign(window, stub);
}, webAppStub());
// Telegram appends its launch params to the URL fragment on a cold launch; the router must
// not treat that as a route (it parsed as notfound, which re-keyed the pane and slid the
// lobby in as if returning from another screen).
await page.goto(
'/#tgWebAppData=query_id%3Dtest%26user%3D%257B%2522id%2522%253A1%257D&tgWebAppVersion=7.0&tgWebAppPlatform=ios',
);
await expect(page.getByText('Your turn')).toBeVisible();
// The lobby is the root: bootstrap normalised the launch-param fragment to '#/'.
await expect.poll(() => new URL(page.url()).hash).toBe('#/');
});
test('tg-fullscreen header keeps a constant native-nav gap as the font scales', async ({ page }) => { test('tg-fullscreen header keeps a constant native-nav gap as the font scales', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
+3 -12
View File
@@ -32,9 +32,8 @@
// Screen transitions: the lobby is the navigation root. Entering a screen from the // Screen transitions: the lobby is the navigation root. Entering a screen from the
// lobby slides it in from the right (forward); returning to the lobby slides the // lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). The first pane shown after boot // screen out to the right and reveals the lobby (back). Transitions are local, so
// does not slide (the `started` gate below), and transitions collapse to nothing under // they do not play on the initial mount, and collapse to nothing under reduce-motion.
// reduce-motion.
// Slide direction by route depth: going deeper (lobby → game → chat/check) slides forward, // Slide direction by route depth: going deeper (lobby → game → chat/check) slides forward,
// returning to a shallower screen slides back. A plain "lobby is back" rule slid the chat/check // returning to a shallower screen slides back. A plain "lobby is back" rule slid the chat/check
// back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it // back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it
@@ -53,15 +52,7 @@
const enterSign = $derived(dir === 'forward' ? 1 : -1); const enterSign = $derived(dir === 'forward' ? 1 : -1);
const leaveSign = $derived(dir === 'forward' ? -1 : 1); const leaveSign = $derived(dir === 'forward' ? -1 : 1);
const routeKey = $derived(router.route.name + (router.route.params.id ?? '')); const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
// The first pane shown once the app is ready must not slide: it would look like the app const animMs = $derived(app.reduceMotion ? 0 : 260);
// was navigated into rather than launched. The pane is a local transition inside a {#key}
// block, so it plays on that block's own first mount anyway — hold the duration at 0 until
// just after that mount (the effect runs post-render), then animate every later change.
let started = $state(false);
$effect(() => {
if (app.ready) started = true;
});
const animMs = $derived(!started || app.reduceMotion ? 0 : 260);
// slideX slides a pane horizontally by a full width. sign>0 enters from / exits to // slideX slides a pane horizontally by a full width. sign>0 enters from / exits to
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading // the right; sign<0 the left. Percentage keeps it viewport-relative without reading
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { parse } from './routeparse';
describe('parse', () => {
it('maps the empty hash and the root to the lobby', () => {
expect(parse('').name).toBe('lobby');
expect(parse('#/').name).toBe('lobby');
});
it('maps known paths to their routes', () => {
expect(parse('#/login').name).toBe('login');
expect(parse('#/new').name).toBe('new');
expect(parse('#/settings').name).toBe('settings');
expect(parse('#/stats').name).toBe('stats');
expect(parse('#/game/abc')).toEqual({ name: 'game', params: { id: 'abc' } });
expect(parse('#/game/abc/chat')).toEqual({ name: 'gameChat', params: { id: 'abc' } });
expect(parse('#/game/abc/check')).toEqual({ name: 'gameCheck', params: { id: 'abc' } });
});
it('maps an unknown path and a game without an id to notfound', () => {
expect(parse('#/bogus').name).toBe('notfound');
expect(parse('#/game').name).toBe('notfound');
});
it('treats a Telegram launch fragment as the lobby root, not notfound', () => {
// Telegram appends its Mini App launch params to the URL fragment on a cold launch; they
// are launch metadata, not a route. Parsing them as notfound made bootstrap's navigate('/')
// re-key the route pane (notfound -> lobby) and slide the lobby in on launch.
expect(parse('#tgWebAppData=query_id%3Dx&tgWebAppVersion=7.0&tgWebAppPlatform=ios').name).toBe(
'lobby',
);
expect(parse('#tgWebAppStartParam=foo').name).toBe('lobby');
});
});
+61
View File
@@ -0,0 +1,61 @@
// Pure hash-path -> Route parsing for the router. Kept dependency-free and free of any
// reactive state so it unit-tests in the node environment; router.svelte.ts wraps it with
// the reactive route rune and navigation.
export type RouteName =
| 'login'
| 'lobby'
| 'new'
| 'game'
| 'gameChat'
| 'gameCheck'
| 'profile'
| 'settings'
| 'about'
| 'friends'
| 'stats'
| 'notfound';
export interface Route {
name: RouteName;
params: Record<string, string>;
}
/**
* parse maps a location hash to a Route. An empty hash is the lobby root. A Telegram Mini
* App cold launch appends its launch params to the URL fragment (#tgWebAppData=...&
* tgWebAppVersion=...); those are launch metadata, not a route, so the fragment is treated
* as the lobby root. Otherwise it would parse as notfound, and bootstrap's navigate('/')
* would then re-key the route pane (notfound -> lobby), sliding the lobby in on launch as if
* returning from another screen.
*/
export function parse(hash: string): Route {
const raw = hash.replace(/^#/, '');
if (raw === '' || raw.startsWith('tgWebApp')) return { name: 'lobby', params: {} };
const path = raw.split('?')[0];
const seg = path.split('/').filter(Boolean);
if (seg.length === 0) return { name: 'lobby', params: {} };
switch (seg[0]) {
case 'login':
return { name: 'login', params: {} };
case 'new':
return { name: 'new', params: {} };
case 'game':
if (!seg[1]) return { name: 'notfound', params: {} };
if (seg[2] === 'chat') return { name: 'gameChat', params: { id: seg[1] } };
if (seg[2] === 'check') return { name: 'gameCheck', params: { id: seg[1] } };
return { name: 'game', params: { id: seg[1] } };
case 'profile':
return { name: 'profile', params: {} };
case 'settings':
return { name: 'settings', params: {} };
case 'about':
return { name: 'about', params: {} };
case 'friends':
return { name: 'friends', params: {} };
case 'stats':
return { name: 'stats', params: {} };
default:
return { name: 'notfound', params: {} };
}
}
+4 -47
View File
@@ -1,54 +1,11 @@
// Minimal dependency-free hash router. Hash routing survives a reload and works on // Minimal dependency-free hash router. Hash routing survives a reload and works on
// a file:// origin (Capacitor native packaging), where there is no server to honour // a file:// origin (Capacitor native packaging), where there is no server to honour
// deep paths. The route is a reactive rune so screens re-render on navigation. // deep paths. The route is a reactive rune so screens re-render on navigation. The pure
// hash->Route parsing lives in routeparse.ts (unit-tested without a DOM).
export type RouteName = import { parse, type Route, type RouteName } from './routeparse';
| 'login'
| 'lobby'
| 'new'
| 'game'
| 'gameChat'
| 'gameCheck'
| 'profile'
| 'settings'
| 'about'
| 'friends'
| 'stats'
| 'notfound';
export interface Route { export type { Route, RouteName };
name: RouteName;
params: Record<string, string>;
}
function parse(hash: string): Route {
const path = (hash.replace(/^#/, '') || '/').split('?')[0];
const seg = path.split('/').filter(Boolean);
if (seg.length === 0) return { name: 'lobby', params: {} };
switch (seg[0]) {
case 'login':
return { name: 'login', params: {} };
case 'new':
return { name: 'new', params: {} };
case 'game':
if (!seg[1]) return { name: 'notfound', params: {} };
if (seg[2] === 'chat') return { name: 'gameChat', params: { id: seg[1] } };
if (seg[2] === 'check') return { name: 'gameCheck', params: { id: seg[1] } };
return { name: 'game', params: { id: seg[1] } };
case 'profile':
return { name: 'profile', params: {} };
case 'settings':
return { name: 'settings', params: {} };
case 'about':
return { name: 'about', params: {} };
case 'friends':
return { name: 'friends', params: {} };
case 'stats':
return { name: 'stats', params: {} };
default:
return { name: 'notfound', params: {} };
}
}
export const router = $state<{ route: Route }>({ export const router = $state<{ route: Route }>({
route: parse(typeof location !== 'undefined' ? location.hash : ''), route: parse(typeof location !== 'undefined' ? location.hash : ''),