Skip to content

ADR-003: Browser Support Floor of iOS/iPadOS 16, Enforced by Lint

Accepted

2026-08-20

The museum had no declared browser support floor. With no browserslist in package.json, Next.js fell back to its default target of roughly Safari 12 — a floor nobody had chosen, and one the code did not honour.

The gap surfaced during cross-device work on the Family Tree. The room called structuredClone, which requires Safari 15.4, from inside a mount effect. On a device below that the call throws before a single card renders. There is no error boundary and no client-error telemetry, so the result is a blank room that reaches us only if a family member writes in. The build was simultaneously claiming support for Safari 12 and transpiling for it — paying a bundle-size cost for browsers we were breaking anyway.

The audience makes this sharper than it would be elsewhere. The museums are used by family members aged 70–80+, who are disproportionately on older iPads, often hand-me-downs. The oldest device is the likeliest device, not the edge case.

Two things had to be decided: where the floor sits, and how it is kept.

"browserslist": [
"iOS >= 16.0", "Safari >= 16.0", "Android >= 8",
"Chrome >= 109", "Edge >= 109", "Firefox >= 115", "not dead"
]

The floor is set where we can verify it. An earlier draft of this decision put it at 15.4, chosen as a capability cliff (structuredClone, :focus-visible, Object.hasOwn and Array.prototype.at all land there) and as the oldest iPadOS Apple still security-patches.

That reasoning was sound and the floor was still wrong, because Xcode 26 will not download an iOS 15 simulator runtime-buildVersion 15.5 answers “not available for download”, and the oldest obtainable is 16.0. A floor a version below anything we can run is a claim about software we have never executed, which is the exact failure this ADR exists to prevent. Declaring 15.4 would have been the same kind of statement as the Family Tree spec’s unaudited “contrast SHALL meet WCAG AA”.

The cost of moving up is two devices: iPad Air 2 and iPad mini 4, both 2014, both capped at iPadOS 15. Everything from iPad 5th generation and iPhone 8 (both 2017) onward runs iOS 16, so the floor still reaches about nine years of hardware — past the “roughly the last eight years” this was meant to deliver. Android comes free: Chrome is evergreen and requires Android 8 (2017).

Raising the target from Safari 12 also reduces transpilation, so the bundle gets smaller. The correct floor is the cheaper one.

Note what this does not mean: a 2014 iPad is not blocked, and as of today the museum still opens on one. It means we do not promise it, do not test it, and may use an API that breaks it without noticing.

runbooks/system-requirements tells families “any device from 2020 onwards”. That is deliberately more conservative than what we support, so the promise holds without qualification and older devices are a pleasant surprise rather than a support argument. Version numbers are not published — they communicate nothing to a non-technical reader.

The floor is enforced by lint, not by prose

Section titled “The floor is enforced by lint, not by prose”

eslint-plugin-compat reads the same browserslist and fails the build when client code calls an API outside it.

This is the load-bearing half of the decision. A support policy written only in a document is the same failure mode as the Family Tree spec’s “contrast SHALL meet WCAG AA” — asserted for months, never checked, and wrong. A floor nobody verifies is not a floor.

Turning the rule on found three pre-existing violations immediately:

  • Regex lookbehind in transcript-format.ts (Safari gained it in 16.4). The worst of the three: an unparseable regex literal is a syntax error, so the module fails to load entirely, taking transcript-view.tsx down with it. Rewritten as a captured leading character.
  • document.fullscreenElement in media-controls.tsx, unprefixed only. iPad Safari below 16 ships just the webkit spelling, so the control never learned it had entered fullscreen. Both spellings and both events now.

The gate is a real net, but not a complete one. Measured against deliberate probes, it catches prototype methods on known globals (Array.toSorted), navigator/window/document members, and syntax-level features (lookbehind). It misses static methods like URL.canParse and instance calls whose receiver type it cannot infer, such as element.checkVisibility(). A green lint is evidence, not proof — it does not remove the need to run the room on a real old device.

settings.polyfills is an escape hatch that must stay honest. A name is added there only when the call site carries a real fallback for browsers below the floor. Adding one without writing the fallback converts the gate back into prose, silently.

Raising the floor later is cheap; lowering it is not. Moving up simply deletes guard code. Moving down means re-auditing every API used since.

The floor is now testable end to end. iOS 16.0 is the oldest simulator runtime Xcode 26 provides, and it is the floor, so every supported version can be exercised locally with no hardware and no device-cloud subscription. This was the deciding factor rather than a convenience: it is what turns the floor from a reasoned position into a measured one.

Usefully, the band that matters most is inside it — Safari gained regex lookbehind in 16.4, so iOS 16.0 through 16.3 (where that bug actually bit) is directly testable.

A physical iPad is still worth having, for the two things a simulator cannot answer: real performance on old silicon, and real touch. Neither is a correctness question, which is why neither blocks this decision.

The floor does not answer performance. It governs whether code runs, not whether it runs well. A 2014 iPad meeting the floor can still be too slow to enjoy. That question needs real hardware, not a lint rule.

Leave it implicit. Rejected: the implicit floor was both wrong and expensive, and it produced exactly the failure that prompted this.

Write the policy in documentation only. Rejected for the reason above — an unenforced claim about the product is the failure mode we already have a live example of.

Set the floor at Safari 12 and polyfill down to it. Rejected: it means carrying guard code and larger bundles to serve hardware Apple no longer patches, for an audience we have no evidence uses it.