feat(ui): support old Android in-app WebViews (es2019 + core-js + engine screen)
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s

Old Android System WebViews (the Telegram/VK in-app browser; a real device with Google
Play auto-updates the WebView, but emulators / no-Play / restricted devices stay frozen)
showed a white screen. Two causes, fixed in order:

- build.target es2022 shipped ?./?? verbatim, which the old engine cannot parse. Lowered
  to es2019 so esbuild down-levels the syntax.
- The es2020+ runtime globals the bundle and its deps call (globalThis, structuredClone,
  Array.at, ...) are missing on those engines. A conditional core-js loader (emitPolyfills
  writes dist/polyfills.js, document.write'd by an index.html gate only when needed) covers
  them; modern engines download nothing, so the bundle-size budget is untouched.

BigInt (the 64-bit FlatBuffers timestamp decode) and Proxy (Svelte 5 runes) cannot be
polyfilled, so the effective floor is Chrome 67. Below it, a permanent ES5 boot guard in
index.html shows a friendly "this device's OS or browser can't run the app" screen (with
the web-version link inside a Mini App) and a "Diagnostic information" view with a Copy
button, instead of a white screen. A reactive net raises the same screen on an uncaught
boot error that never signals window.__booted.

Board tile glyphs used container-query units (cqw, Chrome 105+) under .cell font-size:0,
so they collapsed to 0px on Chrome 74 (invisible letters); added vmin fallbacks.

The unsupported-engine telemetry beacon is a separate follow-up PR.
This commit was merged in pull request #176.
This commit is contained in:
2026-07-04 20:36:05 +00:00
parent 44117e906c
commit 4dfedd02a3
7 changed files with 280 additions and 4 deletions
+203
View File
@@ -2,6 +2,209 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<!-- Boot capability guard. Runs before the deferred ES module, in plain ES5 so it survives even
on an engine too old to parse the bundle. Three jobs, as early as possible:
1. Hard gate — if an UNPOLYFILLABLE essential is missing (BigInt for the 64-bit
FlatBuffers wire decode, Proxy for Svelte 5 runes) the app cannot run at all: show the
unsupported-engine screen (an old Android System WebView, e.g. Chromium 66, is the case
this guards) instead of a white screen.
2. Soft gate — if only polyfillable es2020+ globals are missing (globalThis,
structuredClone, Array.at, …) pull core-js (emitted as polyfills.js) BEFORE the module
runs. document.write is deliberate: the only way to inject a parser-blocking <script>
guaranteed to run ahead of a deferred module; its argument is a static literal, so no
injection surface.
3. Reactive net — record any uncaught error/rejection during boot; if the app has not
signalled window.__booted within a grace period AND an error fired, show the same
screen with the captured cause (covers a bundle that fails to parse, or an unforeseen
incompatibility). __booted is set in App.svelte once bootstrap resolves.
The screen has a "Diagnostic information" view (engine + feature table + reason + version)
with a Copy button. On a capable engine nothing here renders, so neither the bundle-size
budget nor the mock e2e is affected. -->
<script>
(function () {
'use strict';
var nav = navigator;
var ua = nav.userAgent || '';
var VERSION = '__BOOT_VERSION__'; // replaced at build (vite.config injectBootVersion)
var RU = (nav.language || '').toLowerCase().indexOf('ru') === 0;
var MINIAPP = /\/(?:telegram|vk)\//.test(location.pathname);
var WEBURL = location.protocol + '//' + location.host + '/app/';
// [label, test, Chrome version it landed in, hard?] — hard = required and unpolyfillable.
function has(fn) {
try {
return !!fn();
} catch (e) {
return false;
}
}
var probes = [
['BigInt', function () { return typeof BigInt !== 'undefined'; }, 67, true],
['Proxy', function () { return typeof Proxy !== 'undefined'; }, 49, true],
['globalThis', function () { return typeof globalThis !== 'undefined'; }, 71, false],
['structuredClone', function () { return typeof structuredClone === 'function'; }, 98, false],
['Array.prototype.at', function () { return typeof [].at === 'function'; }, 92, false],
['Array.prototype.findLast', function () { return typeof [].findLast === 'function'; }, 97, false],
['Object.hasOwn', function () { return typeof Object.hasOwn === 'function'; }, 93, false],
['Object.fromEntries', function () { return typeof Object.fromEntries === 'function'; }, 73, false],
['Promise.allSettled', function () { return !!Promise.allSettled; }, 76, false],
['Promise.any', function () { return !!Promise.any; }, 85, false],
['WeakRef', function () { return typeof WeakRef !== 'undefined'; }, 84, false],
['queueMicrotask', function () { return typeof queueMicrotask === 'function'; }, 71, false]
];
var results = [];
var hardMissing = [];
var softMissing = false;
for (var i = 0; i < probes.length; i++) {
var ok = has(probes[i][1]);
results.push({ name: probes[i][0], ok: ok, chrome: probes[i][2], hard: probes[i][3] });
if (!ok) {
if (probes[i][3]) hardMissing.push(probes[i][0]);
else softMissing = true;
}
}
// The diagnostic report (built on demand, behind the button).
function diag(reason) {
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
var wv = /;\s*wv\)/.test(ua) || /\bwv\b/.test(ua);
var out = [];
out.push('reason : ' + reason);
out.push('app : ' + VERSION);
out.push('chromium : ' + (cm ? cm[1] : 'n/a') + (wv ? ' (Android WebView)' : ''));
out.push('userAgent : ' + ua);
out.push('url : ' + location.href);
out.push('viewport : ' + window.innerWidth + 'x' + window.innerHeight + ' @' + (window.devicePixelRatio || 1));
out.push('lang : ' + (nav.language || '?') + ' online: ' + nav.onLine);
out.push('');
out.push('features:');
for (var k = 0; k < results.length; k++) {
var r = results[k];
out.push(' ' + (r.ok ? 'OK' : 'NO') + ' ' + r.name + ' (' + (r.hard ? 'required' : 'polyfilled') + ', Chrome ' + r.chrome + ')');
}
return out.join('\n');
}
function el(tag, style, text) {
var e = document.createElement(tag);
if (style) e.setAttribute('style', style);
if (text != null) e.appendChild(document.createTextNode(text));
return e;
}
function btn(bg, fg) {
return 'display:inline-block;margin:0 10px 10px 0;padding:11px 18px;border:0;border-radius:8px;' +
'font:600 15px/1 inherit;cursor:pointer;color:' + fg + ';background:' + bg + ';';
}
var shown = false;
function show(reason) {
if (shown) return;
shown = true;
var root = el('div', 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' +
'-webkit-overflow-scrolling:touch;background:#0f1115;color:#e8eaed;box-sizing:border-box;padding:24px;' +
'font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;');
var wrap = el('div', 'max-width:520px;margin:0 auto;');
root.appendChild(wrap);
var msg = el('div', null);
msg.appendChild(el('div', 'font-size:22px;font-weight:700;margin-bottom:14px;', 'Эрудит'));
msg.appendChild(el('p', 'margin:0 0 14px;', RU
? 'На вашей версии операционной системы или браузера приложение не сможет работать.'
: "This app can't run on your device's operating system or browser version."));
if (MINIAPP) {
msg.appendChild(el('p', 'margin:0 0 6px;', RU
? 'Откройте веб-версию в обычном браузере (Chrome, Firefox):'
: 'Open the web version in a regular browser (Chrome, Firefox):'));
var a = el('a', 'color:#8ab4ff;word-break:break-all;', WEBURL);
a.setAttribute('href', WEBURL);
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener');
var lp = el('p', 'margin:0 0 18px;');
lp.appendChild(a);
msg.appendChild(lp);
}
var infoBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Диагностическая информация' : 'Diagnostic information');
msg.appendChild(infoBtn);
wrap.appendChild(msg);
var dv = el('div', 'display:none;');
var pre = el('pre', 'white-space:pre-wrap;word-break:break-word;background:#0b0d11;border-radius:8px;padding:12px;' +
'font:12.5px/1.45 ui-monospace,Menlo,Consolas,monospace;overflow:auto;', diag(reason));
dv.appendChild(pre);
var copyBtn = el('button', btn('#8ab4ff', '#0b0d11'), RU ? 'Копировать' : 'Copy');
var backBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Назад' : 'Back');
var row = el('div', 'margin-top:12px;');
row.appendChild(copyBtn);
row.appendChild(backBtn);
dv.appendChild(row);
wrap.appendChild(dv);
infoBtn.onclick = function () {
msg.style.display = 'none';
dv.style.display = 'block';
};
backBtn.onclick = function () {
dv.style.display = 'none';
msg.style.display = 'block';
};
copyBtn.onclick = function () {
var txt = diag(reason);
try {
if (nav.clipboard && nav.clipboard.writeText) {
nav.clipboard.writeText(txt);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
return;
}
} catch (e) {}
try {
var ta = document.createElement('textarea');
ta.value = txt;
ta.setAttribute('style', 'position:fixed;left:-9999px;top:0;');
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
} catch (e2) {
copyBtn.firstChild.nodeValue = RU ? 'Выделите и скопируйте текст' : 'Select and copy the text';
}
};
function mount() {
document.body.insertBefore(root, document.body.firstChild);
}
if (document.body) mount();
else document.addEventListener('DOMContentLoaded', mount);
}
// 1 + 2: the gate.
if (hardMissing.length) {
show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', '));
} else if (softMissing) {
document.write('<script src="polyfills.js"><\/script>');
}
// 3: the reactive net for an unforeseen boot failure.
var bootErr = null;
function note(m) {
if (!bootErr) bootErr = m;
}
window.onerror = function (m, s, l, c, e) {
note(String(m) + (e && e.stack ? '\n' + e.stack : s ? ' @ ' + s + ':' + l : ''));
return false;
};
window.addEventListener('error', function (e) {
if (e && e.message) note(e.message + (e.filename ? ' @ ' + e.filename + ':' + e.lineno : ''));
}, true);
window.addEventListener('unhandledrejection', function (e) {
var r = e && e.reason;
note(r && (r.stack || r.message) ? r.stack || r.message : String(r));
});
setTimeout(function () {
if (!window.__booted && bootErr && !shown) show((RU ? 'ошибка запуска: ' : 'boot error: ') + bootErr);
}, 8000);
})();
</script>
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
reach this tag). -->
+1
View File
@@ -27,6 +27,7 @@
"@playwright/test": "^1.49.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0",
"svelte": "^5.15.0",
"svelte-check": "^4.1.0",
"typescript": "^5.7.0",
+8
View File
@@ -36,6 +36,9 @@ importers:
'@types/node':
specifier: ^22.10.0
version: 22.19.19
core-js-bundle:
specifier: ^3.49.0
version: 3.49.0
svelte:
specifier: ^5.15.0
version: 5.56.0
@@ -508,6 +511,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
core-js-bundle@3.49.0:
resolution: {integrity: sha512-WXc7oOsePN3aKFOJVG5zQdi+h/Jm2W0WIPYvRc4IG3vkNcbC2w6LlSzTmnhOl6N1xmOJEzCSNieX3mwF+3zBGw==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1132,6 +1138,8 @@ snapshots:
clsx@2.1.1: {}
core-js-bundle@3.49.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
+4
View File
@@ -1,4 +1,8 @@
# pnpm 11 records build-script approval here. esbuild's postinstall materialises
# its CLI shim; the platform binary itself ships as an optional dependency.
# core-js-bundle ships a prebuilt minified.js (we only read that file at build time,
# via vite.config emitPolyfills) and its install script is just a funding banner, so
# it is denied — nothing to build.
allowBuilds:
core-js-bundle: false
esbuild: true
+6 -1
View File
@@ -26,7 +26,12 @@
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => {
void bootstrap();
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
// unsupported-engine screen on a healthy boot — it only fires when an uncaught error occurred
// AND this flag never sets (a bundle that failed to parse, or an unforeseen incompatibility).
void bootstrap().then(() => {
(window as unknown as { __booted?: boolean }).__booted = true;
});
});
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
+11
View File
@@ -479,6 +479,11 @@
position: absolute;
top: 5%;
left: 8%;
/* Chrome < 105 has no container-query units: a dropped `cqw` would inherit the .cell
`font-size: 0` and the glyph would vanish. Fall back to a viewport-relative size that tracks
the board (≈ the viewport-fitted query container) and the zoom (--z), so old Android System
WebViews still draw the tile glyphs. Chrome 105+ takes the exact `cqw` line below. */
font-size: calc(4.2vmin * var(--z, 1));
font-size: 4.2cqw;
font-weight: 700;
line-height: 1;
@@ -487,12 +492,14 @@
position: absolute;
right: 5%;
bottom: 3%;
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.4cqw;
font-weight: 600;
}
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark {
font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.8cqw;
bottom: 0;
}
@@ -501,6 +508,7 @@
inset: 0;
display: grid;
place-items: center;
font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 3.6cqw;
opacity: 0.7;
}
@@ -509,6 +517,7 @@
inset: 0;
display: grid;
place-items: center;
font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.7cqw;
font-weight: 600;
opacity: 0.9;
@@ -526,10 +535,12 @@
padding: 0 1px;
}
.bt {
font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.7cqw;
font-weight: 600;
}
.bb {
font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.9cqw;
font-weight: 700;
white-space: nowrap;
+47 -3
View File
@@ -1,7 +1,42 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
/**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
* __BOOT_VERSION__ placeholder with the same VITE_APP_VERSION build-arg that feeds __APP_VERSION__.
* The guard runs before the bundle, so it cannot read the bundle's version; this lets its on-demand
* diagnostic report name the client version anyway. Falls back to "dev" for a local build.
*/
function injectBootVersion(): Plugin {
const version = process.env.VITE_APP_VERSION || 'dev';
return {
name: 'inject-boot-version',
transformIndexHtml(html) {
return html.replace(/__BOOT_VERSION__/g, version);
},
};
}
/**
* emitPolyfills writes the prebuilt core-js bundle to `dist/polyfills.js`. The index.html gate
* script loads it via document.write only on an old engine that lacks the es2020+ runtime APIs the
* app uses (globalThis, structuredClone, Array.at, …) — e.g. the Chromium 66 Android System WebView
* behind the Telegram/VK in-app browser — before the deferred module runs. A modern engine never
* requests it, so it adds nothing to that payload and stays out of the module graph the bundle-size
* gate measures.
*/
function emitPolyfills(): Plugin {
return {
name: 'emit-polyfills',
generateBundle() {
const src = readFileSync(resolve(import.meta.dirname, 'node_modules/core-js-bundle/minified.js'), 'utf8');
this.emitFile({ type: 'asset', fileName: 'polyfills.js', source: src });
},
};
}
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
// not speak h2c directly) talks to the dev server on the same origin. In `mock`
@@ -19,7 +54,9 @@ export default defineConfig(({ mode }) => ({
// so a missing build-arg never breaks the build.
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
},
plugins: [svelte()],
// emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
plugins: [svelte(), emitPolyfills(), injectBootVersion()],
server: {
port: 5173,
proxy:
@@ -33,7 +70,14 @@ export default defineConfig(({ mode }) => ({
},
},
build: {
target: 'es2022',
// Down-level to es2019 so an old Android System WebView (seen: Telegram/VK in-app WebView on
// Chromium 66, Android 9) can parse the bundle — esbuild lowers es2020+ syntax (?., ??, private
// fields, static blocks, …) that Chrome 66 rejects with "Unexpected token ?", which left the SPA
// on a white screen. esbuild lowers syntax only, not runtime APIs — the es2020+ globals the
// bundle/deps call at runtime (globalThis, structuredClone, Array.at, …) are covered separately
// by the conditional core-js loader (emitPolyfills + the index.html gate), loaded only on an
// engine that actually lacks them.
target: 'es2019',
// Emit sourcemaps everywhere except the production build. A shipped `.map`
// carries full `sourcesContent` — the entire TypeScript/Svelte source — and the
// gateway/landing images serve `dist/` verbatim, so production maps would expose