Skip to content

fix(mobile): keep the grid tile's frame above its screen background on iOS - #1655

Merged
janicduplessis merged 2 commits into
mainfrom
fix/1651-grid-tile-black
Sep 27, 2026
Merged

janicduplessis merged 2 commits into
mainfrom
fix/1651-grid-tile-black

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Description

On iOS, every Devices grid tile showed a black screen while its frames kept arriving: the tile's own #0C0A11 screen background was painted on top of the frame. The fix is collapsable={false} on the tile's screen View, so the frame mounts inside it.

Root cause, confirmed in a view hierarchy dump of the running app:

  • The screen View has a background but nothing that makes it a stacking context, so Fabric keeps it as a view and hoists its children into the nearest stacking context (ViewShadowNode.cpp). That is the Card, a Gesture Handler Touchable since feat(mobile): use Gesture Handler Touchable for presses #1537.
  • The iOS Gesture Handler button keeps an underlay CALayer at sublayer index 0 (init, each layout). UIKit's insertSubview:atIndex: counts that layer, so a child mounted after layout lands one place too early. The frame replaces the "Waiting for a frame" placeholder after layout, so it lands under the screen background.

DeviceTile on the workspace screen escapes this because its Card has no onPress, so it renders a plain View rather than a Gesture Handler button.

Solution

Upstream and risk: the ordering bug is in Gesture Handler and is still on its main; I found no upstream issue. Other Card/Touch parents could mis-order late-mounted children the same way, but it only shows when such a child overlaps an earlier sibling, and I saw no other visible case.

No unit test: a Jest render cannot observe native z-order. The screenshots are the verification.

lldb check of the UIKit behavior

On the simulator, with a bare CALayer inserted at index 0 of a view holding subview a, insertSubview:b atIndex:1 produces subviews [b, a].

Test plan

With pnpm run mock-server --port 7851 and a dev build paired via pnpm run dev:pair --mock --port 7851, open Devices from the menu.

  • iOS 27.0 simulator (iPhone 18 Pro), before: every iOS tile black.
  • After a full reload, and still after 20 s of 2 s refreshes: tiles show their frames. Tapping a thumbnail grows it into the viewer; closing shrinks it back into the tile, which shows its frame again.
  • Android emulator: tiles show their frames.

Fixes #1651

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review of #1655 (fixes #1651). I did not implement this change. I read the issue, the diff, React Native 0.88.0-rc.1's ViewShadowNode.cpp, RCTViewComponentView.mm and UIView+ComponentViewProtocol.mm, Gesture Handler 3.3.0's RNGestureHandlerButton*.mm, device-zoom.ts, and every Card/Touch user in apps/mobile/src. I did not run the app.

Correctness

The fix is correct.

  • ViewShadowNode::initialize sets formsStackingContext when !viewProps.collapsable, so the screen View becomes a stacking context and the frame Touch and placeholder mount inside it. Without the prop, the View has only backgroundColor and layout props, so it gets FormsView but not FormsStackingContext. Its children are hoisted into the Card as siblings after it.
  • RNGestureHandlerButton inserts _underlayLayer at sublayer index 0 in init (L137) and again in every layoutSubviews (L394). On iOS the button uses the default mountChildComponentView: from UIView+ComponentViewProtocol.mm, which calls insertSubview:atIndex:. The ordering the PR describes follows from that. After layout, the Card's layers are [underlay, screen, placeholder, ...]. When the frame Touch replaces the placeholder at view index 1, it goes to layer index 1, before screen, so it sits under the #0C0A11 background. This also accounts for the issue's report that a Web tile painted first and then went black: the frame was present when the Touch first mounted, and a later frame -> null -> frame remount put it back one place too early.
  • Inside the screen View (RCTViewComponentView), the background goes on layer.backgroundColor because the view has no border and no radius, so there is no extra sublayer to shift indices. The new parent does not have the same problem.
  • Zoom (openDeviceViewer): measureInWindow runs on the Touch ref, which is still its own host view. Window coordinates don't depend on which ancestor the view is mounted under, and nothing else in device-zoom.ts walks the hierarchy. useZoomedAway only changes the Image opacity. The zoom is not affected.
  • Android: collapsable={false} only keeps a View that already rendered. It is harmless there.

Actionable

  1. Comment wording is inaccurate. The comment says "Fabric flattens a View with only a background into the Card". The View is not flattened: it forms a view, and its children are hoisted into the Card. The PR description gets this right ("keeps it as a view and hoists its children"). Suggested wording: "Fabric hoists the children of a View that has only a background into the Card, ...". Apart from that, the comment fits the comment policy. It names the external constraint (Gesture Handler's index-0 underlay layer plus UIKit's insertSubview:atIndex:), and the code alone cannot explain it.
  2. The PR description misstates why DeviceTile escapes. It says DeviceTile works "because its screen View has onLayout". The more basic reason is that DeviceTile's Card has no onPress, so Card renders a plain View, not a Gesture Handler button. onLayout does make its screen View a stacking context, but the misordering needs a Gesture Handler button parent, and that tile has none. Please correct the sentence so nobody later relies on "add an event handler" as the general fix.

Non-blocking notes

  • Other late-mounted children still land one place early inside Gesture Handler buttons, but I found none that is visible:

    • In this tile, the frame && error stale Text, the device.page URL Text and the "Page failed to load" Pill are still hoisted into the Card (meta and badge have no background). They mount one place early, but nothing they are placed under overlaps them.
    • WorkspaceRow: the chip Pills and BuildProgressBar mount late into the row Touch through the flattened body/chips Views. They don't overlap their siblings.
    • Button (loading switches the ActivityIndicator/Icon) and ActionToast (pending ActivityIndicator): a child mounted late at index 0 lands under the underlay layer. That layer has opacity 0 for the card and opacity feedbacks, so nothing shows.
    • MacChip's absolutely positioned dot overlaps the icon, but both mount at the same time, so the order is correct. It would break only if the dot were ever mounted conditionally.
    • BuildCards' PlatformCard switches its header and progress bar inside styles.platform (padding and gap only, so flattened into the Card). Nothing overlaps.

    The risk note in the PR matches what I found. A future absolutely positioned overlay or badge mounted conditionally inside a Card/Touch would hit the same bug.

  • Upstream: I also found no matching Gesture Handler issue (searched underlay insertSubview, button children order underlayLayer, and button child hidden behind background ios). Consider filing one. Its link would then be a valid, shorter replacement for part of the comment under the policy's "direct issue link" rule.

  • Tests: No unit test is justified. A Jest render can't observe native z-order, and the before/after screenshots on iOS and Android are the right evidence.

  • Docs: No user-facing guidance changes. apps/mobile/README.md and website/docs don't need updates.

  • CI check passes. The branch is one commit with a conventional title.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mobile: Devices grid tiles render black while their frames keep arriving

1 participant