close

DEV Community

Cover image for Your Bundler's Default Target Ships a Blank Screen to iOS 15
Info Inlet
Info Inlet

Posted on

Your Bundler's Default Target Ships a Blank Screen to iOS 15

Four tests were failing in our mobile repo before I started last week's work. I fixed the two that had outgrown their screens in about twenty minutes.

The other two were right, and both described bugs that were live, on both app stores, for anyone who happened to own the wrong phone or add the wrong city.

One of them had never produced an error message in its life. The other threw a perfectly good, perfectly loud exception that no one was ever in a position to see.

They are opposite failures, and our test suite was blind to both for exactly the same reason.

We build Xenition, an AI workspace — and the mobile app is Flutter, 382 Dart files, on iOS and Android. Some of its surfaces are not Flutter at all. The diagram canvas is Excalidraw, the 3D surface is three.js, and the notebook runs Python through Pyodide — each one a real web bundle, vendored into the app's assets and rendered in a WebView, because rewriting Excalidraw in Dart is not a thing a small team gets to do.

That decision is defensible. What follows is the invoice for it.


Why a bundled WebView fails in a way native code cannot

When Dart throws, you get a stack trace. When a Flutter widget overflows, the screen turns into yellow-and-black hazard stripes and shouts at you. The framework is built on the assumption that a failure should be loud.

A JavaScript module loaded into a WebView has none of that, and one specific failure inside it is completely silent:

A module that fails to parse reports nothing.

Not a thrown exception. Not a rejected promise. Not an onerror. The parse happens before any of your code exists, so there is no code there to notice it failed. The WebView loads, the host page renders, your Flutter side sees a healthy page load event, and the module — the entire canvas — never runs. The user gets a spinner that spins forever.

Now add the two things that make this specific to mobile:

  1. Your users' WebView is not your WebView. On iOS it's WebKit, at whatever version the OS shipped with, and it does not update independently of the OS.
  2. Your build target is a number you set once, in a different repo, probably by accident.

Those two facts met in our app and produced a feature that had never worked for a slice of our users.


Bug 1: the canvas never started on iOS 15.0–16.3

The Excalidraw bundle is built in our web repo, by npm run build:diagram-host, and the vite config said:

build: {
  outDir: OUT,
  emptyOutDir: true,
  target: 'es2022',
  sourcemap: false,
},
Enter fullscreen mode Exit fullscreen mode

es2022 is a perfectly reasonable thing to type. It's recent-but-not-bleeding-edge, every desktop browser has supported it for years, and on the web — where that bundle also runs, and where the user's Chrome updates itself every six weeks — it is entirely correct.

The problem is one feature in that standard: class static blocks.

class Thing {
  static {
    // initialisation that needs statements, not just an expression
  }
}
Enter fullscreen mode Exit fullscreen mode

WebKit only parses those from Safari 16.4. Our app's ios/Podfile says:

platform :ios, '15.0'
Enter fullscreen mode Exit fullscreen mode

So every user on iOS 15.0 through 16.3 — a real population, on iPhone 6s and 7 hardware that Apple stopped updating — loaded a module that WebKit refused to parse. SyntaxError, thrown by the parser, before a single line of Excalidraw existed to catch it.

What those users saw: they tapped Diagram, and nothing happened. Forever. No error, no toast, no crash we could see in Crashlytics, because nothing crashed. From the app's perspective the page loaded fine.

Fixed in both places it can be fixed

The permanent fix is one line, and it's in the web repo:

build: {
  outDir: OUT,
  emptyOutDir: true,
  // NOT es2022: that emits class static blocks, which WebKit only parses from
  // Safari 16.4. The mobile app supports iOS 15.0 (ios/Podfile), and a module
  // that fails to parse reports nothing — the diagram canvas would simply
  // never start, silently, for every user on 15.0–16.3.
  // mobile/test/vendored_bundle_syntax_test.dart fails if this regresses.
  target: ['safari15', 'chrome90'],
  // Android WebView never ships these; skipping them keeps the APK smaller.
  sourcemap: false,
},
Enter fullscreen mode Exit fullscreen mode

Note the shape of that comment. It says what not to do, why, who it hurts, and what will fail if you undo it. A bare target: ['safari15', 'chrome90'] reads as arbitrary conservatism and the next person to touch the file bumps it back to something modern, for good reasons, in a repo where nothing tests iOS.

But changing the config only helps the next build. The 23 modules already vendored into the app's assets were still the old ones, so they got lowered in place with esbuild:

DIR="assets/diagram/assets"
TARGET="safari15,chrome90"
VERSION="esbuild@0.24.0"
MARKER='static{'   # class static blocks — Safari 16.4+
Enter fullscreen mode Exit fullscreen mode

Two details in that script that took longer to get right than the fix:

  • Only rewrite modules that actually carry the offending syntax. Re-minifying an already-minified module produces cosmetically different output every time, so touching every module in the bundle would churn all of it on every run and make every diff unreadable. Matching on the marker keeps it idempotent.
  • Exported names and import specifiers are preserved, so the module graph is unchanged. Local identifiers get renamed and that's fine — nothing outside the module can see them.

The part that makes the fix survive

Here is the uncomfortable truth about a vendored bundle: the fix does not survive a rebuild. Anybody who runs npm run build:diagram-host overwrites assets/diagram/ and brings the modern syntax straight back — silently, again, because the failure was silent the first time.

So the actual deliverable isn't the lowered bundle. It's this test:

/// Guards the vendored Excalidraw bundle against syntax old WebViews can't parse.
///
/// Covers every vendored JS bundle, not just Excalidraw's: the 3D surface ships
/// a three.js host built the same way, and it would fail the same way.
void main() {
  for (final path in ['assets/diagram/assets', 'assets/model3d']) {
    test('$path parses on the oldest iOS we support', () => _check(path));
  }
}

void _check(String path) {
  final dir = Directory(path);
  final modules = dir
      .listSync()
      .whereType<File>()
      .where((f) => f.path.endsWith('.js'))
      .toList();
  expect(modules, isNotEmpty, reason: 'the bundle should contain JS modules');

  // Class static blocks — `class A { static { … } }`. This is the newest
  // syntax an es2022 build emits and the one that actually breaks iOS 15, so
  // it doubles as the marker for "this bundle was rebuilt at the wrong
  // target". Matching the minified form avoids hits on the `static` keyword
  // used for ordinary class members.
  final offenders = [
    for (final f in modules)
      if (f.readAsStringSync().contains('static{')) f.uri.pathSegments.last,
  ]..sort();

  expect(
    offenders,
    isEmpty,
    reason: 'These modules use class static blocks, which WebKit only parses '
        'from Safari 16.4 — on iOS 15.0-16.3 the canvas will never start, '
        'silently: a module that fails to parse reports nothing.\n'
        'Fix it in one of two ways:\n'
        "  * permanent: in web/excalidraw-host/vite.config.ts set target: ['safari15', 'chrome90']\n"
        '  * here and now: run ./tool/lower_diagram_bundle.sh from mobile/\n'
        'Offending modules: $offenders',
  );
}
Enter fullscreen mode Exit fullscreen mode

It is a grep in a trench coat. It does not launch a simulator, it does not parse JavaScript, it does not know what Excalidraw is. It reads files and looks for four characters.

And it is the single highest-value test in that repo, because it converts a silent, device-specific, unreproducible-on-your-machine failure into a red line in CI that explains itself and names both fixes. The reason: string is longer than the assertion. That's deliberate — the person who hits this will be someone who ran an unrelated npm command in an unrelated repo, and every word they need has to be right there.

A guard test's job is not to be clever. It is to be the thing that shouts when the environment quietly changes underneath you.

The generalisation past our stack:

Your build target is a claim about your users' runtimes, and nobody validates it for you. Every bundler defaults to something modern. Every WebView on a phone is frozen to an OS. Those two facts are on a collision course in every app that ships a bundle, and the collision is silent.

If you ship any JS inside a mobile WebView, go and check that number now. It'll take ninety seconds and there's a real chance you find what we found.


Bug 2: the loud one nobody could hear

The second failure is the opposite kind, and that is why it's here.

The world clock — a small utility surface, 56 cities — threw LocationNotFoundException. On Africa/Accra.

No ambiguity, no silence, a named exception with a stack trace. Everything bug 1 wasn't. And it had been shipping just as long, because a loud error in a code path nothing executes is exactly as invisible as a silent one.

Accra is not obscure. It is a capital city, it has been in the IANA time zone database for decades, and Africa/Accra is exactly the id you'd expect to use. The reason it isn't there is that it is a link, not a zone.

The IANA database distinguishes canonical zones from backward-compatibility links — ids that used to be zones, or that duplicate one, kept alive so old configs don't break. Africa/Accra is one of them; the canonical zone it points at is Africa/Abidjan. Same offset, and neither has ever observed daylight saving.

And package:timezone's data/latest.dart ships the canonical zones and none of the links. Not a bug — links are a compatibility layer, they roughly double the table, and a mobile app has every reason to want the smaller one. But it means an id that is correct, current, and documented throws at runtime.

static const _zones = <String, String>{
  'UTC': 'UTC', 'London': 'Europe/London', 'Lisbon': 'Europe/Lisbon',
  // Africa/Accra is a backward-compatibility LINK in the IANA database and
  // data/latest.dart carries none of those. Abidjan is the canonical zone it
  // points at: same offset, and neither has ever observed DST.
  'Accra': 'Africa/Abidjan',
  // …
};
Enter fullscreen mode Exit fullscreen mode

And then the same trap from the other side

While fixing that I found UTC throws too.

getLocation('UTC') — the most obviously valid timezone identifier that exists, the one you'd use in a test as the safe value — raises LocationNotFoundException.

The reason is almost philosophical: UTC is not a row in the database. It is the origin every row is measured from. So the package hands it over as a constant instead:

/// Now, in that city — with whatever rule is in force today.
///
/// UTC is not a row in the zone database; it is the origin every row is
/// measured from, and `getLocation('UTC')` throws. The package hands it over
/// as a constant instead.
tz.TZDateTime _now(String city) {
  final id = _zones[city]!;
  return tz.TZDateTime.now(id == 'UTC' ? tz.UTC : tz.getLocation(id));
}
Enter fullscreen mode Exit fullscreen mode

Two ids, opposite reasons, identical exception. One is too old to be a zone; the other is too fundamental to be one.

And the blast radius was the same for both: adding either city to your clock threw during build, and the whole tool went down — not the one row, the entire surface. A user who added Accra didn't get a broken Accra card. They got a broken world clock, and the only way back was to work out that a city they'd added was the cause.

A lookup that throws instead of returning null turns one bad row into a dead screen. If a table is user-extensible, every lookup against it needs a policy for the id that isn't there.


Why 821 tests caught neither

This is the part I'd want someone to take away, because it's the one that transfers to codebases that ship no JavaScript and no timezones.

We had a smoke test for the tools surface. It built the world clock. It passed, every run, for months.

It built the world clock with its four default cities.

London, New York, Tokyo, Sydney. All canonical zones. All present in data/latest.dart. The test exercised the widget, the state, the rendering, the offset labels, the layout — everything except the fifty-two rows where the bug was.

The replacement is boring and it is the whole point:

/// tools_smoke_test builds this tool with its four default cities, which is why
/// it caught nothing for either of them. This one puts every city on the clock
/// at once, so the whole table gets looked up.
void main() {
  const cities = <String>[
    'UTC', 'London', 'Lisbon', 'Accra', 'Casablanca', 'Paris', 'Berlin',
    'Madrid', 'Rome', 'Lagos', 'Cairo', 'Athens', 'Johannesburg', 'Istanbul',
    // … all 56 …
  ];

  testWidgets('every city on the clock at once resolves and renders', (tester) async {
    // …
  });
}
Enter fullscreen mode Exit fullscreen mode

Fifty-six cities, one widget, one test. It runs in the same time as the old one because the lookup is a map read.

A smoke test that builds the defaults tests the defaults. Every static table your app ships — timezones, currencies, locales, country codes, MIME types, unit conversions — is a list of rows nobody has ever exercised, sitting behind a lookup that throws. The test that covers it is a for loop, and it is nearly free.

We ship 36 locales and 10,002 translation keys. I know exactly which test I'm writing next.


The checklist

What I'd actually run against any app that renders bundled web content:

  • [ ] What is your bundler's target? Write it down. Compare it to your minimum OS, not your test device.
  • [ ] Does anything in your build pipeline overwrite vendored assets? If yes, is there a test that fails when it does?
  • [ ] Is the failure of your WebView content visible from the native side at all — or does a dead module look exactly like a slow one?
  • [ ] Do you have a timeout on "the canvas is starting"? A spinner with no deadline is how a silent failure becomes a support ticket instead of a bug report.
  • [ ] For every static table you ship: is there a test that touches every row, or only the defaults?
  • [ ] For every lookup against those tables: what happens for an id that isn't there — a null you handle, or an exception that takes the screen down?
  • [ ] Do your device-lab / TestFlight testers include anyone on your oldest supported OS? If not, your minimum is a number in a Podfile, not a claim you've verified.
  • [ ] When a test fails: is the claim it makes still true? Decide that before deciding what to edit.

What I'd tell someone shipping a WebView in a mobile app

The vendoring decision was right. We would not have a diagram canvas, a 3D surface or a Python notebook on a phone if we'd insisted on writing all three natively, and users don't grade you on which rendering engine drew the thing.

But bundling web content into a native app means you have inherited a second runtime, and you don't control its version. Everything the web taught you about "just target modern browsers" is downstream of automatic browser updates, and an iPhone that stopped getting iOS updates in 2021 is still in someone's pocket, still on your store listing's supported-devices list, still tapping the button that does nothing.

Our diagram canvas never started for those users. Nobody filed a bug, because there was nothing to describe — you tap Diagram, and the app is just a bit rubbish, in a way that's hard to put in an email. It was caught by a test that reads files looking for four characters, on a laptop, in about forty milliseconds.

Both fixes are live. 821 tests pass, and the two silent ones are silent no more.


If you ship a bundle inside a WebView, I'd genuinely like to know what your target says — and whether you knew before you looked. That number is doing more work than almost anything else in your build config, and I'd never once checked ours.


I work on Xenition — one AI workspace for documents, decks, code, apps and media, free to start, on web, desktop, and both app stores: iOS and Android. The mobile app is Flutter — 382 Dart files, 26 feature modules, 36 languages, and, until last week, one canvas that never started.

Top comments (0)