Skip to content

stylo: servo ignores the viewport variants (sv*, lv*, dv* all equal vh) and `env(safe-area-inset-*)` is always 0, with no embedder input for either

Generated from repros/stylo-servo-viewport-variants-and-safe-area/README.md when the site was built at 7aa23e2.

A page that asks for <meta name="viewport" content="viewport-fit=cover"> is laid out under the status bar, the Dynamic Island or camera cutout, and the home indicator or navigation bar. To keep its content clear of them it needs the safe-area insets, which the web gives as env(safe-area-inset-top|right|bottom|left), and to size things to the part of the screen the bars leave it needs the small, large and dynamic viewport units (svh, lvh, dvh and the rest). Servo-engine stylo, which blitz uses, has neither:

  • env() reads Device::safe_area_insets() (style/custom_properties.rs:66-80), which for the servo engine returns SideOffsets2D::zero() (style/device/servo.rs:499). Gecko’s asks the embedder (style/device/gecko.rs:443).
  • Stylo parses the viewport variants, and resolves them through Device::au_viewport_size_for_viewport_unit_resolution(variant). Servo’s ignores the variant (style/device/servo.rs:215-223: “Servo doesn’t have dynamic UA interfaces that affect the viewport”), so 100svh, 100lvh and 100dvh all equal 100vh. Gecko’s sizes each variant from its toolbar state (style/device/gecko.rs:294-340) and records when a page used the dynamic one, so the embedder knows to restyle when it changes; servo never sets used_dynamic_viewport_size.
  • MediaData, the embedder’s input to the device, has no field for any of it.

So niso’s hosts publish the insets as custom properties instead, through the platform stylesheet (Dom::set_platform_style): --niso-safe-area-inset-top and the rest, set only for cover pages. Pages write

padding-top: max(env(safe-area-inset-top), var(--niso-safe-area-inset-top, 0px));

which works in browsers (the custom property is unset there) and keeps working once stylo reads real insets. There is no such workaround for the viewport units; pages get vh for all three until stylo resolves them. The sizes niso would give each variant are in DECISIONS.md and at ROOT in niso_runtime.h.

Same code on main (7f0189de128e6dbd2b504acd3aebee3befa78358): safe_area_insets is still SideOffsets2D::zero() and the variant is still ignored. No issue or PR found in servo/stylo or servo/servo.

An iPhone 17 in portrait with a cover page: the viewport is the 402x874 screen, and the status bar (62 high) and home indicator (34) stay on it.

$ cargo run
small viewport (sv*): expected [402.0, 778.0], actual [402.0, 874.0]
large viewport (lv*): expected [402.0, 874.0], actual [402.0, 874.0]
dynamic viewport (dv*): expected [402.0, 874.0], actual [402.0, 874.0]
env(safe-area-inset-top right bottom left): expected [62.0, 0.0, 34.0, 0.0], actual [0.0, 0.0, 0.0, 0.0]
servo resolves every viewport variant against the one viewport size, its safe area insets are always zero, and MediaData has no way to set either

cargo run --features host-viewport builds the embedder side of the patch below: a MediaData carrying the sizes and insets. Without the patch it does not compile, because MediaData has no viewport_metrics field.

Terminal window
CARGO_TARGET_DIR=~/.capy/work/repro-target cargo run

Exit status 1 while the bug reproduces.

servo-viewport-metrics.patch is against stylo 2dca345 (1 file, +37 -5). It gives servo’s Device one embedder input for both, MediaData::viewport_metrics, a ViewportMetrics:

  • small, large and dynamic: the size each viewport variant resolves against, each the viewport size when None. vh (the UA-default variant) keeps the viewport size. Resolving a dynamic unit sets used_dynamic_viewport_size, as Gecko does.
  • safe_area_insets: what Device::safe_area_insets returns.

Its default is today’s behaviour. Apply it from the stylo root with patch -p1. With it applied to a copy of the checkout and every stylo package [patch]ed to that copy, cargo run --features host-viewport prints the expected values for all four rows and exits 0. Embedders change the metrics the way they change the viewport size, with Device::set_media_data or media_data_mut, which already restyle as needed.

Adding a public field to MediaData breaks embedders that build it with a struct literal, as blitz does (blitz-dom/src/stylo_device.rs), so blitz has to set it too (.. with the default, or a setter on its document beside the viewport’s). It sits next to the MediaData fields in stylo-servo-user-preference-media-features; the two patches touch neighbouring lines of the same struct.

niso does not use the patch: per DECISIONS.md it does not fork stylo. The recommendation is to send the patch upstream (servo/stylo), then blitz’s half.

servo: viewport-percentage variants (sv, lv, dv*) resolve like vh and env(safe-area-inset-*) is always 0, with no embedder input for either**

Device::au_viewport_size_for_viewport_unit_resolution for the servo engine ignores its ViewportVariant, and Device::safe_area_insets returns SideOffsets2D::zero(). MediaData has no field for either, so an embedder that shows system UI over or around the page (a phone, a browser with retractable toolbars) cannot give pages the small, large and dynamic viewports or the safe area. Gecko asks its embedder for both. Proposal: a viewport_metrics: ViewportMetrics field on MediaData with optional sizes for the small, large and dynamic viewports (the viewport size when unset) and the safe area insets, which those two Device methods return, and used_dynamic_viewport_size set when a dynamic unit is resolved. Patch and repro attached.

Not filed.