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:
maziggy
2026-08-28 08:08:30 +02:00
parent ea0ceae42d
commit b3c67c6943
9 changed files with 410 additions and 280 deletions
+101
View File
@@ -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.`);