feat(ui): diagnostic screen on /telegram/ instead of the landing bounce #126
+4
-1
@@ -11,7 +11,10 @@ launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the to
|
|||||||
runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App
|
runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App
|
||||||
launch that delivered none, as seen on some Android clients) the app renders a compact,
|
launch that delivered none, as seen on some Android clients) the app renders a compact,
|
||||||
shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than
|
shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than
|
||||||
redirecting to the site root.
|
redirecting to the site root. `telegram-web-app.js` is loaded **dynamically with a timeout**,
|
||||||
|
only on a Telegram entry — not a render-blocking `<script>` in the shared `index.html` shell —
|
||||||
|
so a network that blocks `telegram.org` cannot hang the page; `/app/` (web) and the native build
|
||||||
|
never load it.
|
||||||
|
|
||||||
## Layout shell (`components/Screen.svelte`)
|
## Layout shell (`components/Screen.svelte`)
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -1,13 +1,13 @@
|
|||||||
import { test as base } from '@playwright/test';
|
import { test as base } from '@playwright/test';
|
||||||
|
|
||||||
// All e2e specs run hermetically against the mock transport. Neutralise the real
|
// All e2e specs run hermetically against the mock transport. Neutralise the real
|
||||||
// telegram-web-app.js (loaded from the CDN in index.html) so the suite never blocks
|
// telegram-web-app.js (the app loads it dynamically — see lib/telegram.ts loadTelegramSDK) so the
|
||||||
// on telegram.org — it is unreachable from the CI runner, and a render-blocking
|
// suite never reaches telegram.org, which is unreachable from the CI runner. Specs that exercise
|
||||||
// <script> to it would hang every page load. Specs that exercise the Telegram launch
|
// the Telegram launch inject their own window.Telegram via addInitScript before navigating, so the
|
||||||
// inject their own window.Telegram via addInitScript before navigating.
|
// dynamic load short-circuits on the already-present SDK.
|
||||||
export const test = base.extend({
|
export const test = base.extend({
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
await page.route('**/telegram-web-app.js', (route) =>
|
await page.route('**/telegram-web-app.js*', (route) =>
|
||||||
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
|
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
|
||||||
);
|
);
|
||||||
await use(page);
|
await use(page);
|
||||||
|
|||||||
@@ -121,3 +121,15 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
|
|||||||
await expect(page).toHaveURL(/\/telegram\//);
|
await expect(page).toHaveURL(/\/telegram\//);
|
||||||
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
|
||||||
|
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
|
||||||
|
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
|
||||||
|
// fetch must not strand the page — the diagnostic screen still renders, reporting no SDK. (This
|
||||||
|
// route overrides the fixture's empty-body fulfill; the later registration wins.)
|
||||||
|
await page.route('**/telegram-web-app.js*', (route) => route.abort());
|
||||||
|
await page.goto('/telegram/');
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||||
|
await expect(page.getByText('sdk: no')).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
+5
-3
@@ -2,9 +2,11 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- Telegram Mini App SDK: defines window.Telegram.WebApp. Harmless outside
|
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
|
||||||
Telegram (initData is empty), so it loads on every entry. -->
|
render-blocking <script> to telegram.org hangs the whole page on a network that blocks
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even
|
||||||
|
the launch-diagnostic screen. The app loads it dynamically, with a timeout, only on a
|
||||||
|
Telegram entry — see lib/telegram.ts loadTelegramSDK and lib/app.svelte.ts bootstrap. -->
|
||||||
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
|
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
|
||||||
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
|
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
|
||||||
<meta
|
<meta
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
collectTelegramDiag,
|
collectTelegramDiag,
|
||||||
type TelegramDiag,
|
type TelegramDiag,
|
||||||
onTelegramPath,
|
onTelegramPath,
|
||||||
|
hasLaunchFragment,
|
||||||
|
loadTelegramSDK,
|
||||||
telegramColorScheme,
|
telegramColorScheme,
|
||||||
telegramContentSafeAreaTop,
|
telegramContentSafeAreaTop,
|
||||||
telegramSafeAreaTop,
|
telegramSafeAreaTop,
|
||||||
@@ -561,6 +563,12 @@ function applyTelegramChrome(launch: TelegramLaunch): void {
|
|||||||
telegramRequestFullscreen();
|
telegramRequestFullscreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
|
||||||
|
* the launch-error screen. A network that blocks telegram.org makes the script hang rather than
|
||||||
|
* fail fast (a connection refusal resolves immediately via the script's error event), so this only
|
||||||
|
* bounds a true hang; it is generous enough not to misfire on a slow but working network. */
|
||||||
|
const TELEGRAM_SDK_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
export async function bootstrap(): Promise<void> {
|
export async function bootstrap(): Promise<void> {
|
||||||
const prefs = await loadPrefs();
|
const prefs = await loadPrefs();
|
||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
@@ -584,6 +592,13 @@ export async function bootstrap(): Promise<void> {
|
|||||||
window.visualViewport.addEventListener('scroll', syncViewportHeight);
|
window.visualViewport.addEventListener('scroll', syncViewportHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
||||||
|
// longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common
|
||||||
|
// where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app
|
||||||
|
// or the diagnostic screen below. Skipped on a plain web / native entry, which never needs it.
|
||||||
|
if (onTelegramPath() || hasLaunchFragment()) {
|
||||||
|
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
|
||||||
|
}
|
||||||
// Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
|
// Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
|
||||||
// deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty
|
// deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty
|
||||||
// initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android
|
// initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android
|
||||||
@@ -675,6 +690,8 @@ export async function retryTelegramBoot(): Promise<void> {
|
|||||||
* so a reload could discard the very data we are waiting for.
|
* so a reload could discard the very data we are waiting for.
|
||||||
*/
|
*/
|
||||||
export async function retryTelegramLaunch(): Promise<void> {
|
export async function retryTelegramLaunch(): Promise<void> {
|
||||||
|
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
|
||||||
|
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
|
||||||
if (!insideTelegram()) {
|
if (!insideTelegram()) {
|
||||||
app.launchError = collectTelegramDiag();
|
app.launchError = collectTelegramDiag();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
collectTelegramDiag,
|
collectTelegramDiag,
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
|
loadTelegramSDK,
|
||||||
routeExternalLinkInTelegram,
|
routeExternalLinkInTelegram,
|
||||||
telegramClosingConfirmation,
|
telegramClosingConfirmation,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
@@ -197,3 +198,35 @@ describe('collectTelegramDiag', () => {
|
|||||||
expect(d.fieldsMissing).toEqual(['signature']);
|
expect(d.fieldsMissing).toEqual(['signature']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadTelegramSDK', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves true immediately when the SDK is already present', async () => {
|
||||||
|
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '' } } });
|
||||||
|
vi.stubGlobal('document', { createElement: vi.fn(), head: { appendChild: vi.fn() } });
|
||||||
|
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false when the script errors (telegram.org unreachable)', async () => {
|
||||||
|
const script: Record<string, unknown> = {};
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('document', {
|
||||||
|
createElement: () => script,
|
||||||
|
head: { appendChild: () => (script.onerror as () => void)() },
|
||||||
|
});
|
||||||
|
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false when the script hangs past the timeout', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('document', { createElement: () => ({}), head: { appendChild: vi.fn() } });
|
||||||
|
const p = loadTelegramSDK(10000);
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
await expect(p).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+53
-3
@@ -1,6 +1,7 @@
|
|||||||
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded in
|
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded dynamically with a
|
||||||
// index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses:
|
// timeout by loadTelegramSDK — not a render-blocking <script> in index.html, so a network that
|
||||||
// launch detection, initData (for auth.telegram), the deep-link start parameter,
|
// blocks telegram.org cannot hang the page) exposes window.Telegram.WebApp; this wraps the subset
|
||||||
|
// the app uses: launch detection, initData (for auth.telegram), the deep-link start parameter,
|
||||||
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
|
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
|
||||||
|
|
||||||
import type { TelegramThemeParams } from './theme';
|
import type { TelegramThemeParams } from './theme';
|
||||||
@@ -54,6 +55,47 @@ export function insideTelegram(): boolean {
|
|||||||
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
|
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ?NN suffix pins the Bot API SDK version Telegram serves (and busts the cache); keep it at the
|
||||||
|
// version the official Mini Apps page currently recommends so newer client features (fullscreen,
|
||||||
|
// safe-area insets, vertical-swipe guard, …) are available. Bump it when Telegram bumps theirs.
|
||||||
|
const sdkScriptSrc = 'https://telegram.org/js/telegram-web-app.js?62';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loadTelegramSDK injects the official telegram-web-app.js and resolves true once
|
||||||
|
* window.Telegram.WebApp is available, or false if the script errors or does not load within
|
||||||
|
* timeoutMs. It is loaded dynamically — not a render-blocking <script> in index.html — so a network
|
||||||
|
* that blocks telegram.org (common where Telegram itself reaches users only over a proxy) cannot
|
||||||
|
* hang the page and strand the app or the launch-error screen. Resolves true immediately when the
|
||||||
|
* SDK is already present (a cached load, or a future native injection); a fast connection failure
|
||||||
|
* resolves via the error event without waiting out the timeout, so the timeout only bounds a true
|
||||||
|
* hang.
|
||||||
|
*/
|
||||||
|
export function loadTelegramSDK(timeoutMs: number): Promise<boolean> {
|
||||||
|
if (typeof document === 'undefined') return Promise.resolve(false);
|
||||||
|
if (webApp()) return Promise.resolve(true);
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = (ok: boolean): void => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(!!webApp()), timeoutMs);
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = sdkScriptSrc;
|
||||||
|
s.async = true;
|
||||||
|
s.onload = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
finish(!!webApp());
|
||||||
|
};
|
||||||
|
s.onerror = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
finish(false);
|
||||||
|
};
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to
|
* telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to
|
||||||
* it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
|
* it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
|
||||||
@@ -303,6 +345,14 @@ export function onTelegramPath(): boolean {
|
|||||||
return location.pathname.startsWith('/telegram/');
|
return location.pathname.startsWith('/telegram/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** hasLaunchFragment reports whether the URL fragment carries Telegram launch params (tgWebApp…),
|
||||||
|
* the form Telegram appends when opening a Mini App — so the SDK is loaded for a Mini App opened
|
||||||
|
* at the site root too, not only the /telegram/ path. */
|
||||||
|
export function hasLaunchFragment(): boolean {
|
||||||
|
if (typeof location === 'undefined') return false;
|
||||||
|
return location.hash.includes('tgWebApp');
|
||||||
|
}
|
||||||
|
|
||||||
// --- Launch diagnostics (the /telegram/ entry without sign-in data) ---
|
// --- Launch diagnostics (the /telegram/ entry without sign-in data) ---
|
||||||
|
|
||||||
/** The initData fields a valid Telegram launch is expected to carry; their absence is the signal
|
/** The initData fields a valid Telegram launch is expected to carry; their absence is the signal
|
||||||
|
|||||||
Reference in New Issue
Block a user