Skip to content

[Pro 1.6.0 / iOS] vw/vh styles collapse to 0 after the app returns from background — getScreenDimensions() caches a 0×0 sample while the scene is backgrounded #665

Description

@invivek26

What happened?

After the app spends a while in the background and comes back, every vw/vh-based style resolves to 0: w-[calc(50vw-18px)] buttons shrink to their labels, w-[calc(100vw-24px)] containers collapse so their children pile up in the middle, size-[60vw] images vanish. Styles sized from React Native's useWindowDimensions on the same screens are unaffected, and the layout snaps back on the next device rotation or theme flip. Reported by several production users on iPhone; Android is unaffected.

Mechanism (read from the shipped 1.6.0 sources):

  • vw compiles to rt.screen.width * N/100, so it is entirely driven by the native runtime's screen.
  • ios/NativePlatform+ios.swift getScreenDimensions() reads RCTPresentedViewController().view.window?.frame and returns Dimensions(width: 0, height: 0) when that is nil ("this should never happen"). getPixelRatio() falls back to 1.0 on the same path.
  • RCTPresentedViewController() is RCTKeyWindow().rootViewController, and RCTKeyWindow() only accepts a scene that is foregroundActive/foregroundInactive. While the scene is .background it returns nil, so the sample is 0×0.
  • NativePlatformListener+ios.swift re-samples on UIDevice.orientationDidChangeNotification, RCTWindowFrameDidChangeNotification, RCTUserInterfaceStyleDidChangeNotification, and (iOS 17+) window.registerForTraitChanges([UITraitUserInterfaceStyle.self]). The last one has no app-state guard and calls onNativePlatformChange() directly. After didEnterBackground, iOS flips the window's appearance light↔dark to render the app-switcher snapshots in both schemes (the same mechanism React Native guards against in RCTSurfaceHostingView traitCollectionDidChange: for Appearance addChangeListener handler is called when app goes to background with wrong color scheme react/react-native#28525), so the trait closure fires with the scene backgrounded and the 0×0 runtime is cached. Physical rotation (including face-up/face-down, since react-native-screens starts device-orientation notifications) reaching a backgrounded process does the same through the orientation publisher.
  • Nothing re-samples on UIApplication.didBecomeActiveNotification, so the zero persists until the next orientation or trait event.

This is the same bug unistyles had in jpudysz/react-native-unistyles#527 ("Runtime is equals to 0 on appState change"), fixed there in #540 by returning the cached last-known screen when the presented view controller is unavailable in the background. Pro 1.6.0 still ships the pre-fix code.

Suggested fix: never persist a nil-window sample — return the cached last-known screen/pixelRatio (or the window scene's screen bounds when there is no cache yet) instead of zeros — and add UIApplication.didBecomeActiveNotification to the merged publishers so anything sampled during a transition is re-checked once the app is active. Optionally skip the iOS 17 trait callback while UIApplication.shared.applicationState == .background, mirroring React Native's guard.

Note for reproduction: an explicit Uniwind.setTheme("dark" | "light") calls Appearance.setColorScheme, which sets overrideUserInterfaceStyle on every window, so the window trait never changes and the trait trigger is disabled. The repro therefore starts with setTheme("system").

Captured on an iPhone 17 Pro simulator (iOS 26.5) with NSLog probes added to getScreenDimensions() and the two listeners, after backgrounding the app, changing the system appearance, and foregrounding it. UIKit's own InterfaceStyle lines are interleaved:

08:09:50.660 [com.apple.UIKit:InterfaceStyle] Scene did update interface style to 1
08:09:50.661 [uniwind-probe] window trait change appState=2        <- UIApplicationStateBackground
08:09:50.661 [uniwind-probe] sample ZERO appState=2 keyWindow=0 presentedVC=0
08:09:50.705 [com.apple.UIKit:Application] Deactivation reason removed ...  <- scene becomes foreground-inactive
08:09:50.708 [uniwind-probe] notification RCTUserInterfaceStyleDidChangeNotification appState=1
08:09:50.708 [uniwind-probe] sample ok 402x874 appState=1 keyWindow=1

The deferred trait update lands while the app is still in .background, the iOS 17 trait callback samples 0×0, and the runtime is updated with it. On the simulator React Native's RCTUserInterfaceStyleDidChangeNotification happened to follow 47 ms later, after the scene became foreground-inactive, and re-sampled correctly. That second notification is guarded in RCTSurfaceHostingView to be skipped while applicationState == Background, so whenever React Native's trait update lands before the state transition (which is what users see on devices), nothing heals the 0×0 runtime until the next rotation or theme change.

Steps to Reproduce

  1. Open the vw screen in the linked repro and press setTheme("system").
  2. Press Home.
  3. While backgrounded, run xcrun simctl ui booted appearance dark, then light (on a device: flip appearance in Control Center, or rotate the phone).
  4. Return to the app.

Expected: the w-[100vw] bar stays full width and the on-screen log shows 100vw=<window width>.
Actual: the bar and the w-[50vw] box collapse to 0 while Dimensions.get("window").width on the same line is unchanged. One rotation in the foreground heals it.

Snack or Repository Link (Optional)

https://github.com/invivek26/uniwind-frozen-swap-repro — screen vw, README section "Third issue", probe patch in patches/uniwind-pro-1.6.0-probe.patch

Uniwind version

uniwind-pro 1.6.0

React Native Version

0.85.3 (Expo SDK 56)

Platforms

iOS

Expo

Yes

Additional information 〰

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions