Repository navigation
[web] Report env(safe-area-inset-*) as FlutterView.viewPadding - #191647
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for safe area insets on Flutter Web by reading CSS env(safe-area-inset-*) values via a DOM probe when the viewport is configured with viewport-fit=cover. The changes include updating EngineFlutterView to lazily compute and cache viewPadding, preserving viewport-fit in FullPageEmbeddingStrategy, and adding associated tests and templates. Review feedback highlights a potential issue where parseFloat returning NaN for empty strings could propagate into ViewPadding, a parsing bug in parseViewportContent when spaces surround the = character, and opportunities to replace null-assertions on domDocument.body with null-safe optional chaining to prevent runtime exceptions.
| double inset(String property) { | ||
| // A browser that doesn't understand `env()` leaves the padding at its | ||
| // initial value, which reads back as `0px`, so there is no separate code | ||
| // path for it. `parseFloat` only returns null for an empty string, which | ||
| // can't happen for an element that is in the document. | ||
| return (parseFloat(probeStyle.getPropertyValue(property)) ?? 0.0) * devicePixelRatio; | ||
| } |
There was a problem hiding this comment.
The assumption that parseFloat returns null for an empty string is incorrect. In JavaScript, parseFloat("") returns NaN. When compiled to Dart, NaN is a valid double and is not null, so the null-coalescing operator ?? 0.0 will not be triggered, resulting in NaN propagating into ViewPadding. This can cause layout assertions and crashes in the Flutter framework.
Instead, explicitly check if the parsed value is null or isNaN before multiplying by devicePixelRatio.
double inset(String property) {
final double? value = parseFloat(probeStyle.getPropertyValue(property));
return (value == null || value.isNaN) ? 0.0 : value * devicePixelRatio;
}There was a problem hiding this comment.
This isn't JavaScript's parseFloat — it's web_ui's wrapper in safe_browser_api.dart, which turns NaN into null before returning:
final double result = parseFloatImpl(source);
if (result.isNaN) {
return null;
}So ?? 0.0 does fire for the empty string, and NaN can't reach ViewPadding.
The comment was wrong, though: it said null only comes back for an empty string, when it comes back for anything unparseable. Reworded.
| // The host page owns <body>, and is free to empty it, e.g. to take a | ||
| // custom loading indicator down. `getComputedStyle` of a detached element | ||
| // resolves to nothing, so put the probe back before measuring it. | ||
| domDocument.body!.append(safeAreaProbe); |
There was a problem hiding this comment.
Avoid using the null-assertion operator ! on domDocument.body here as well. If the body element is temporarily unavailable or removed, this will throw a runtime exception. Use the null-safe optional chaining operator ?. instead.
| domDocument.body!.append(safeAreaProbe); | |
| domDocument.body?.append(safeAreaProbe); |
There was a problem hiding this comment.
Same as the one above. This is also the path that puts the probe back when the host page has emptied <body> — skipping silently here would leave it detached, and getComputedStyle on a detached element resolves to nothing, so every inset would read zero.
`SafeArea` has always been a no-op on the web because nothing ever fed the device safe area into the framework. Two things were missing. 1. `FullPageEmbeddingStrategy` removed every `<meta name="viewport">` on the page and wrote its own without `viewport-fit`. An app that opted into a full-bleed layout with `viewport-fit=cover` had that opt-in deleted at startup, and WebKit then kept laying the app out inside the safe area, so `env(safe-area-inset-*)` had nothing to report. This is why the workarounds in the issue never worked. The `viewport-fit` of the last viewport meta tag on the page, which is the one the browser itself honours, is now carried over to the tag that the engine writes, and, as a result, across hot restarts too. The "this tag will be replaced" warning now names the descriptors that are actually being dropped, and stays quiet when none are, so an app following the web template's advice no longer provokes a warning on every debug run. 2. Nothing in `web_ui` read `env(safe-area-inset-*)`. `DimensionsProvider` gains `computeSafeAreaInsets()`. `FullPageDimensionsProvider` implements it by reading the values back from a `display: none` probe element whose paddings are set to `env(...)`; the browser resolves `env()` at computed-value time, so no layout or paint is involved. `CustomElementDimensionsProvider` reports zero, because the host application is the one that positions an embedded view on the page. `EngineFlutterView.viewPadding` is now backed by those insets, computed lazily and recomputed on the same events that recompute the physical size, and `padding` is `viewPadding` minus `viewInsets`, clamped at zero, matching the other platforms: when the on-screen keyboard covers the bottom safe area, the bottom padding goes to zero. Parsing a `viewport-fit` descriptor lives in one new file, `view_embedder/viewport_fit.dart`, used both by the strategy that writes the tag and by the provider that reads it back, so that the two sides cannot drift apart over the exact spelling of a `content` attribute. The insets are only reported for a page that declared `viewport-fit=cover` itself. That is a stricter rule than the one the browser applies to `env()`: on an iPhone 17 Pro (iOS 18.7, WebKit 605.1.15, Safari 26.1) `env(safe-area-inset-bottom)` is `20px` in landscape whether or not the page is full-bleed, and passing that on would have moved the content of every existing Flutter web app on iOS, including the ones that ask for nothing. A page that never declared `viewport-fit` keeps a byte-identical viewport meta tag and a `viewPadding` of `ViewPadding.zero`, exactly as before this change. Apps that do declare `viewport-fit=cover` in their `index.html` change, and that is the point of the fix: the browser stops keeping them within the safe area. On the device above, a Flutter page in landscape reports `window.innerWidth` of 750 against 874 for a plain HTML control that declares the same tag, a difference of exactly the `62 + 62` of left and right inset the control reports. Content that is not wrapped in a `SafeArea` moves under the display cutout and the rounded corners, which is what `viewport-fit=cover` asks the browser for. The post-change behaviour is covered by unit tests; it has not been measured on a device, and `env(safe-area-inset-top)` was zero in every device reading taken, so the top axis in particular rests on unit coverage alone. The `flutter create` web template gains the tag, commented out, next to the guidance it already carries for `<base href>`. `systemGestureInsets` is deliberately left at zero. `env()` describes the parts of the viewport obstructed by the device, not the parts the system reserves for its own gestures, and browsers do not expose the latter. This covers full-page embeddings only. A view hosted in a custom element still reports zero, so the issue stays open for embedded and multi-view apps. See flutter#84833
0a291d1 to
2c55f0e
Compare
|
Thanks — one of these was a real bug and is now fixed. Taking them in turn.
double? parseFloat(String source) {
final double result = parseFloatImpl(source);
if (result.isNaN) {
return null;
}
return result;
}So
Full engine suite is green: 1232 passing, 14 skipped, no regressions. |
The comment claimed it only returns null for an empty string. The wrapper in safe_browser_api.dart returns null wherever JavaScript's parseFloat returns NaN, which is a wider set, and the `?? 0.0` covers all of it.
|
Heads up: the The Re-adding the label whenever convenient is all it needs. |
|
Thank you for contributing this fix! |
|
Thanks for the review, @mdebbar. Everything is green now — Google testing, Dashboard Checks and the merge queue guard all passed. Anything left from my side, or does it just need the |
There was a problem hiding this comment.
LGTM. I verified this on a real device, IOS 27, with before(https://flutter-demo-63-before.web.app/)/after(https://flutter-demo-63-after.web.app/) builds of the same app. The app's index.html declares viewport-fit=cover and apple-mobile-web-app-status-bar-style=black-translucent. Both builds also draw a browser-rendered outline inset by env(safe-area-inset-*), so it's easy to see whether SafeArea lines up with what the browser reports.
[web] Report
env(safe-area-inset-*)asFlutterView.viewPaddingThe problem
SafeAreadoes nothing on the web. On an iPhone running the app as astandalone PWA, content sits under the notch, the Dynamic Island and the home
indicator, and
MediaQuery.paddingOf(context)isEdgeInsets.zeroon everybrowser. The issue has been open since 2021, and the workaround that everybody
in the thread reaches for — adding
<meta name="viewport" content="..., viewport-fit=cover">toindex.html— has no effect.Issue: #84833
Root cause
There are two independent causes, and fixing either one alone leaves the bug in
place.
1. The engine deletes the app's opt-in.
FullPageEmbeddingStrategy._applyViewportMeta()(
engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart:70)runs
querySelectorAll('meta[name="viewport"]'), callsremove()on everymatch (line 86), and appends its own tag with a hard-coded
content = 'width=device-width, initial-scale=1.0, maximum-scale=5.0'(line 96). There is no
viewport-fitin it. So an app that wroteviewport-fit=coverin itsindex.htmlhas that descriptor deleted duringbootstrap, WebKit reverts to keeping the viewport inside the safe area, and
env(safe-area-inset-*)correctly reports that there is nothing to inset. Thisis why every workaround in the thread fails.
2. Nothing ever reads
env(safe-area-inset-*).Grepping
safe-area|safeAreaInset|viewport-fit|viewportFitacrossengine/src/flutter/lib/web_ui/lib/returns no matches before this change.EngineFlutterView.viewPadding(window.dart:264) andEngineFlutterView.padding(window.dart:270) both read afinal ViewConfiguration _viewConfiguration = const ViewConfiguration();(
window.dart:97) that is never reassigned, so they are permanentlyui.ViewPadding.zero. OnlyviewInsetsworks, because it is backed by amutable field that
_computeOnScreenKeyboardInsets(window.dart:363) updatesthrough
DimensionsProvider.computeKeyboardInsets.Reproduction
Measured on an iPhone simulator,
devicePixelRatio3, UAMozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1. Two pages wereserved side by side from the same origin: a plain HTML control that declares
viewport-fit=coverand readsenv(safe-area-inset-*)out ofgetComputedStyle, and a Flutter app whoseindex.htmldeclares the same tagand which additionally reports
MediaQuery.viewPaddingOf/paddingOf. Bothdeclare
apple-mobile-web-app-status-bar-style=black-translucent. Each was readin a Safari tab and as a standalone add-to-home-screen PWA, in both
orientations. 11 configurations were recorded in all, from 997 samples; the four
below are the ones the argument rests on.
The Flutter app was built with Flutter 3.44.6 stable, not with this branch,
so the engine tag quoted in row 3 is the one that release writes. Current
mainwrites
maximum-scale=5.0and nouser-scalabledescriptor. The difference isimmaterial here: what matters is that neither carries a
viewport-fit, and bothremove the tag the app authored.
innerWidthenv()T/R/B/LMediaQuery.viewPaddingOf…, viewport-fit=cover(author's)0 / 62 / 20 / 62…, viewport-fit=cover(author's)0 / 0 / 34 / 0width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no(flt-viewport)0 / 0 / 20 / 0zeroflt-viewport, noviewport-fit)0 / 0 / 0 / 0zeroTwo things to read off this:
viewport-fit=coverthe app wrote is not in the DOM on the Flutterpage. The tag that is there is the engine's, and it has no
viewport-fit.That is cause 1, observed directly.
the same orientation on the same device. The difference, 124, is exactly
62 + 62, the left and right insets the control reports. The app is beinglaid out inside the safe area.
MediaQuery.viewPaddingOfwasEdgeInsets.zeroin all 997 readings takenfrom the Flutter page, including the runs where the viewport meta tag was
restored by hand so that the page did go full-bleed and
env()did report0 / 62 / 20 / 62. That isolates cause 2: even with the browser reporting asafe area correctly, nothing in the engine reads it.
What has not been measured on device. The numbers above are all pre-patch.
The patched engine has unit coverage (below) but has not been run on a device or
simulator, so this PR claims no measured post-patch
MediaQuery.padding.env(safe-area-inset-top)was zero in all 11 configurations, including thetwo standalone-PWA ones, where there is no Safari chrome to explain it away. I
don't have an account of why, so I'm not offering one: the top axis is simply
uncovered on device and rests on unit tests. If a reviewer knows the
configuration that produces a non-zero top inset in mobile WebKit, I'd like to
measure it. An end-to-end confirmation that a
SafeAreachild moves is alsostill owed. Both are repeated under "Not covered".
The same run also shows why the
env()values cannot be passed throughverbatim: with no
viewport-fitanywhere, iOS Safari still reportsenv(safe-area-inset-bottom) = 20px(row 3 above). See "Backwardscompatibility" below.
What the fix does
Layer 1 — preserve the app's
viewport-fit._applyViewportMeta()now reads theviewport-fitdescriptor off the viewportmeta tags it is about to remove and copies it into the tag it writes. Parsing
lives in one new file,
view_embedder/viewport_fit.dart, so that the side thatreads a descriptor and the side that writes it can never drift apart. The
resolution matches the browser's:
declares no
viewport-fit(WebKit builds a fresh set of viewport argumentsper tag, so descriptors that the last tag omits revert to their defaults);
,,;and whitespace are all accepted as descriptor separators, becausebrowsers accept all three —
content="width=400;initial-scale=1"is honouredby both Blink and WebKit;
auto,containandcoverare copied over, so nothing unknown canreach the tag the engine writes.
Because the tag the engine writes carries the descriptor, the value also
survives hot restart: the tag read at the start of
_applyViewportMeta()on thenext run is the
flt-viewporttag left behind by the previous one.The "this tag will be replaced" warning now fires only when the engine's tag
really does say less than the app's — it lists the descriptors being dropped —
so following the template's own advice below no longer provokes a warning on
every debug run.
Layer 2 — feed
env(safe-area-inset-*)into the framework.DimensionsProvidergainscomputeSafeAreaInsets().FullPageDimensionsProviderimplements it with adisplay: noneprobe elementwhose four paddings are
env(safe-area-inset-*, 0px); the browser substitutesenv()at computed-value time, sogetComputedStylereturns absolute lengthswithout the element ever being laid out or painted. Every declaration on the
probe is written with
importantpriority, so an ordinary* { padding: 0 !important }reset in the host page cannot silently zero themeasurement. The probe is created up front, in the constructor, so reading the
safe area is a pure measurement and never a DOM mutation in the middle of a
frame; it is re-attached if the host page ever empties
<body>, and removed inclose().The insets are gated on the app's own opt-in.
computeSafeAreaInsets()returns
ViewPadding.zerounless the viewport meta tag the engine wrote carriesviewport-fit=cover, i.e. unless the app declared it in itsindex.html. Thegate reads that tag back with
readViewportFit, the same function that wroteit, so the two halves are not coupled through an exact spelling: changing how
_applyViewportMetajoins its descriptors cannot silently switch the featureoff.
EngineFlutterView.viewPaddingis now backed by those insets, computed lazilyand recomputed by
_handleBrowserResizeon the same events that recompute thephysical size.
paddingisviewPaddingminusviewInsets, clamped at zero(
ViewPadding.minus), which is the relationship the other platforms use: whenthe on-screen keyboard covers the bottom safe area, the bottom padding goes to
zero. No framework-side change is needed —
MediaQuery.fromViewalready readsview.paddingandview.viewPadding.systemGestureInsetsis deliberately left at zero.env()describes the partsof the viewport that the device obstructs, not the parts the system reserves for
its own gestures, and browsers do not expose the latter.
Tooling.
packages/flutter_tools/templates/app/web/index.html.tmplgainsthe tag, commented out, with an explanation, next to the guidance it already
carries for
<base href>. New projects are not opted in by default; the commenttells developers how to opt in.
Cleanup.
ViewConfiguration.viewInsets,.viewPaddingand.padding(
platform_dispatcher.dart) are removed.viewInsetswas already dead, and theother two become dead with this change;
ViewConfigurationis only everinstantiated as
const ViewConfiguration().Backwards compatibility
The gate is what keeps this from being a breaking change, and it is there
because the naive version is one.
Apps that never declared
viewport-fit— including everythingflutter createproduces, since the web template ships no active viewport meta tag —are unchanged. The engine writes a byte-identical viewport meta tag, and
viewPadding,paddingandMediaQuery.paddingOfstayzero.This is not automatic. iOS Safari reports
env(safe-area-inset-bottom) = 20pxin landscape with no
viewport-fitat all, so an implementation that simplyforwarded
env()would have given every existing Flutter web app on iOS anuninvited
bottom: 20logical pixels, shrinking the child of everySafeAreain an app that asked for nothing. The
20pxis a measured reading (row 3 ofthe table above); the resulting layout shift is the arithmetic consequence,
not a second measurement.
FullPageDimensionsProvider.computeSafeAreaInsets()therefore returns zerounless the page opted in, and the doc comment on
DimensionsProvider.computeSafeAreaInsetssays so explicitly.Apps that declared
viewport-fit=coverin theirindex.htmldo change,and this is the fix working rather than a side effect. Today the engine
deletes the descriptor, so those apps are laid out inside the safe area; after
this change they are laid out full-bleed. On the device above, that is the
difference between the Flutter page's 750 logical pixels and the control's
874 in the same orientation, and content that is not
wrapped in a
SafeAreamoves under the cutout and the rounded corners.This population matters:
viewport-fit=coverinindex.htmlis the mostrepeated workaround in issue [web][PWA] SafeArea Widget does not work on iOS Safari PWA #84833 itself and in five years of blog posts
about it. Many authors added the tag, saw nothing happen, and never wrapped
anything in
SafeArea. For them this is a visible layout change. It is thebehaviour the tag has always requested, and the tag is the app's own explicit
opt-in, but it should be called out in the release notes and it is the reason
the PR does not carry
Fixes #84833on its own.Embedded (
hostElement) and multi-view apps are untouched._applyViewportMetaonly exists inFullPageEmbeddingStrategy, andCustomElementDimensionsProvider.computeSafeAreaInsets()returns zero, sopadding == zero.minus(viewInsets) == zeroexactly as before.Browsers without
env()support degrade to zero rather than toNaN: anunsupported
env()leaves the padding at its initial value, which reads backas
0px.If the team would rather not move the
viewport-fit=coverpopulation in asingle release, the cover pass-through in
_applyViewportMetais the one placeto put a
FlutterConfigurationflag; the rest of the change is inert withoutit.
Tests
42 new unit tests, run with
felt test --browser chrome --compiler dart2jsoverthe whole
test/enginedirectory. The engine suite goes from 1190 to 1232passing, 14 skipped, no failures and no regressions.
A new
viewport_fit_test.dartcovers the parser directly: every specificationvalue, unknown values, a descriptor that merely ends in
viewport-fit,,;and whitespace separators,=spacing, case-insensitivity,last-declaration-wins, and a round trip between what
ViewportFitwrites andwhat
readViewportFitreads.parseViewportContentis held to the sametolerance, so the two functions cannot disagree about what a
contentattributesays. The rest extend the four existing suites: the
descriptor surviving
_applyViewportMetain each of those spellings; the gatestaying shut for
autoandcontain, which the engine preserves but which mustnot unlock the insets; the probe's structure, its
importantpriority beating a* { padding: 5px !important }rule in the host page, its re-attachment and itsremoval on
close();viewPaddingrefreshed by a realresizeevent ratherthan only by
debugForceResize, so_handleBrowserResizeis covered; zero foran embedded view; and
ViewPadding.minusat no, partial and full clamping.Two of them run a real
FullPageEmbeddingStrategyover a page declaringviewport-fit=cover, hand the page it leaves behind to a realFullPageDimensionsProvider, and assert a non-zero result (and a zero one forthe negative twin). That seam between the two halves of the fix is where a
hand-written
contentstring would have hidden a break.Two mutations were run to check the tests bite. Making the gate accept any
viewport-fitrather than onlycoverfails theauto/containtest. Changinghow
_applyViewportMetajoins its descriptors from', 'to';'leaves theprovider suite green — which is the point of sharing the parser, and would not
have been true of an earlier revision of this patch that matched the joined
string exactly.
Not covered, left for follow-up
insets from the intersection of the host element's bounding rect with the
page's
env()values; there is aTODOonCustomElementDimensionsProvider.computeSafeAreaInsetsfor it. Issue [web][PWA] SafeArea Widget does not work on iOS Safari PWA #84833should stay open until that lands, which is why this PR references the issue
rather than closing it.
_viewPaddingis refreshed only from_handleBrowserResize. On iOS thestatus bar grows during a call, screen recording or personal hotspot without
changing the viewport size, so
env(safe-area-inset-top)changes with noresizeevent. AResizeObserveron a probe whosetop/left/width/heightare theenv()values would close this; the limitation is documentedon both
DimensionsProvider.computeSafeAreaInsetsandEngineFlutterView._viewPadding.viewport-fitinjected from JavaScript after bootstrap is not re-read._applyViewportMetaruns once, in the strategy's constructor.systemGestureInsetsstays zero; browsers do not expose the information.env(safe-area-inset-top)has unit coverage but no on-device coverage.It was zero in all 11 measured configurations, standalone PWAs included, and I
have no explanation for that. It should be measured on a configuration that
does report a non-zero top inset before this ships.
viewport-fit=coveropt-in on the web display-modes/initialization page andadd a web caveat to the
SafeAreaAPI docs.Pre-launch Checklist
///).Notes on the checklist:
declare
viewport-fit=coversees any difference inMediaQuery.padding,MediaQuery.viewPaddingor its viewport meta tag. Apps that did declare itmove from a safe-area-contained layout to a full-bleed one, with the measured
numbers above.
customer_testing/ flutter/tests should be run against thisbranch before landing, and if anything moves, a migration guide from the
flutter/website template should land with it.
side.