fix(ui): load telegram-web-app.js dynamically with a timeout
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The Telegram SDK was a render-blocking <script src="telegram.org/..."> in the shared index.html shell, so it ran on every entry (/telegram/, /app/, native). On a network that blocks telegram.org — common where Telegram itself reaches users only over a proxy — the script hangs forever, stranding the whole page, including the launch-diagnostic screen meant to surface exactly this failure. This is the likely root cause of the Android "won't open" reports (all launch methods fail identically; iOS on a different network works). Remove the head <script> and load the SDK dynamically (lib/telegram.ts loadTelegramSDK) with a 10s timeout, only on a Telegram entry (the /telegram/ path or a tgWebApp launch fragment). The SPA — served from our own reachable origin — boots first and controls the load: on a block/error it falls through to the diagnostic screen (reporting sdk: no) instead of hanging, and Retry re-attempts. /app/ and the native build no longer touch telegram.org. Pin the SDK to the version the official page recommends (?62) for the newer client features the app already uses (fullscreen, safe areas, swipe guard). Tests: loadTelegramSDK unit tests (present / error / timeout); an e2e that aborts the script fetch and asserts the diagnostic still renders.
This commit is contained in:
+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