mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
Keep a lookbehind Safari 16 cannot parse out of the bundle (issue #2971)
An iPhone on iOS 16 loaded nothing at all -- no error, no partial render, just white, over LAN IP and over an HTTPS domain alike, while the same install was fine on Android, macOS, Windows and Linux. remark-gfm, added in v1.2.5 for the folder README panel, reaches mdast-util-gfm-autolink-literal, whose module body carries a lookbehind assertion. Safari did not support lookbehind until 16.4. A regex literal is validated when its module is compiled, not when the function holding it runs, so this was never going to fail as a broken README panel. FolderReadmePanel -> FileManagerPage -> App is a plain static import chain, the regex landed in the entry chunk, and the browser refused to compile all 10 MB of it. Nothing executed, so nothing rendered. v1.2.4 is the last release that loads on those iOS versions. The panel now renders GFM through a locally composed plugin holding four of remark-gfm's five sub-extensions -- tables, strikethrough, task lists, footnotes -- and omitting autolink literals, the only one carrying the lookbehind. Composing rather than configuring is forced by the bug: importing remark-gfm at all is what breaks the page, so no runtime option could have reached it. Parity was measured rather than assumed. Serialized ASTs against real remark-gfm over a 34-case corpus, position data included, are identical in 29; the five that differ are exactly the autolink cases, where the only change is link -> text with table and list structure intact. Across 26 hostile inputs -- NUL bytes, a BOM, an RTL override, a lone surrogate, combining marks, a 200 KB line, 500 stacked tables, 60-deep nesting, malformed and ragged tables -- neither implementation throws and none diverge, and applying the plugin twice is idempotent for both. The visible cost is that a bare https://example.com or foo@example.com typed into a folder README no longer links itself; [text](url) and <https://example.com> are core markdown and still do. The wiki claimed "links all render" and now says which. remark-gfm, mdast-util-gfm and micromark-extension-gfm leave the dependency tree and their eight surviving sub-extensions are declared directly, at ranges equal to or tighter than the ^2.0.0 those two packages declared, so the resolution surface did not widen. The bundle is 23 KB smaller. Vite's build.target governs syntax lowering and esbuild does not rewrite regular expressions -- measured, a lookbehind builds silently under safari15, safari16.0 and es2020 alike, which is how this shipped and then sat unnoticed for two months. So the guard is a real check rather than a compiler setting: npm run build now ends in check-browser-baseline.mjs, which scans the emitted bundles for syntax Safari 16.0 cannot parse and fails with the offending snippet. It is scoped to parse-time failures only -- a missing runtime API breaks one feature, while one of these takes down the whole app and has no graceful degradation to fall back on. Verified firing on the stale bundle before the rebuild, and running correctly inside the Docker frontend stage where only frontend/ is copied. Seven renderer tests pin both halves of the trade: each surviving GFM feature still renders, and both forms of autolinking stay off on purpose so a future dependency bump cannot quietly bring the lookbehind back.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fail the build when the bundle uses a JS feature our oldest supported browser
|
||||
* cannot parse (#2971).
|
||||
*
|
||||
* Why this exists as a grep rather than a build target: Vite's `build.target`
|
||||
* only governs *syntax lowering*. esbuild does not rewrite regular expressions,
|
||||
* so a lookbehind assertion - unsupported before Safari 16.4 - builds silently
|
||||
* under `safari15`, `safari16.0` and `es2020` alike (measured, all three). That
|
||||
* is exactly how #2971 shipped: `remark-gfm` pulled a lookbehind regex literal
|
||||
* into the entry chunk, iOS 16.0-16.3 refused to compile the module, and every
|
||||
* page rendered as a blank white screen from v1.2.5 until it was found in the
|
||||
* field two months later.
|
||||
*
|
||||
* A regex literal is validated when its module is *compiled*, so one of these
|
||||
* anywhere in the entry chunk takes down the entire app, not just the feature
|
||||
* that pulled it in. There is no graceful degradation to fall back on, which is
|
||||
* why this is a hard build failure and not a warning.
|
||||
*
|
||||
* BASELINE: Safari 16.0 / iOS 16.0. Raising it is a product decision - if you
|
||||
* do, drop the entries that the new floor supports rather than deleting the
|
||||
* check.
|
||||
*
|
||||
* Scope: parse-time failures only. Runtime APIs (`Object.groupBy`,
|
||||
* `Promise.withResolvers`, ...) break one feature rather than the whole bundle
|
||||
* and are better caught by real-browser testing, so they are deliberately not
|
||||
* listed here.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ASSETS = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'assets');
|
||||
|
||||
/**
|
||||
* Each pattern must match only real occurrences of the feature. Anything that
|
||||
* needs context to tell a false positive from a real hit (regex flags, for
|
||||
* instance, are indistinguishable from division by a variable in a minified
|
||||
* bundle without parsing) is left out rather than made noisy.
|
||||
*/
|
||||
const FORBIDDEN = [
|
||||
{
|
||||
pattern: /\(\?<[=!]/g,
|
||||
feature: 'regex lookbehind assertion',
|
||||
since: 'Safari 16.4',
|
||||
hint: 'A dependency shipped `(?<=` or `(?<!` in a regex literal. Find it with:\n'
|
||||
+ ' grep -rl \'(?<[=!]\' --include=*.js node_modules/\n'
|
||||
+ ' then avoid importing that module (see src/utils/remarkGfmNoAutolink.ts).',
|
||||
},
|
||||
{
|
||||
// The one pattern here that can in principle fire on a string literal
|
||||
// containing the text `static {`. No bundle has ever hit it, and the
|
||||
// snippet printed above makes such a hit obvious at a glance - if that is
|
||||
// what you are looking at, narrow this pattern rather than deleting it.
|
||||
pattern: /\bstatic\s*\{/g,
|
||||
feature: 'class static initialisation block',
|
||||
since: 'Safari 16.4',
|
||||
hint: 'Set `build.target` low enough that esbuild lowers it, or drop the dependency.',
|
||||
},
|
||||
];
|
||||
|
||||
let bundles;
|
||||
try {
|
||||
bundles = readdirSync(ASSETS).filter((f) => f.endsWith('.js'));
|
||||
} catch {
|
||||
console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (bundles.length === 0) {
|
||||
console.error(`check-browser-baseline: no .js files in ${ASSETS} - did the build succeed?`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const name of bundles) {
|
||||
const source = readFileSync(join(ASSETS, name), 'utf8');
|
||||
for (const { pattern, feature, since, hint } of FORBIDDEN) {
|
||||
const hits = source.match(pattern);
|
||||
if (!hits) continue;
|
||||
const index = source.search(pattern);
|
||||
failures.push(
|
||||
` ${name}: ${hits.length}x ${feature} (requires ${since})\n`
|
||||
+ ` ...${source.slice(Math.max(0, index - 70), index + 70).replace(/\n/g, ' ')}...\n`
|
||||
+ ` ${hint}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`\ncheck-browser-baseline: bundle uses syntax that Safari 16.0 / iOS 16.0 cannot parse.\n`
|
||||
+ `A parse error takes down the WHOLE app on those browsers - blank white screen (#2971).\n\n`
|
||||
+ `${failures.join('\n\n')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ ${bundles.length} bundle(s) parse-compatible with the Safari 16.0 baseline.`);
|
||||
Reference in New Issue
Block a user