Tags react-nativenew architecturefabricexpo-glview flatteningthree.js
All posts

react-native

The JS ran at 60fps and the screen stayed black

A 3D planet stopped animating on iPhone only. The GL context was alive, three.js rendered every frame, and endFrameEXP fired 60 times a second. Nothing reached the screen. The cause was React Native's view flattening.

August 13, 2026 3 min

A user told me the 3D planet in our app had stopped moving on their iPhone. Sometimes it did not appear at all. The surface objects never showed up. Android was fine.

I spent a while looking in the wrong place, so I want to write down what the evidence actually said.

Everything I measured said the code was fine

I put probes inside the react-three-fiber canvas and read them from the native log.

[PROBE] created bw=939 bh=940 size={"width":313.33,"height":313.33}
[PROBE] frame 1
[PROBE] frame 120
[PROBE] endFrame 120
[PROBE] frame 240
[PROBE] endFrame 240

The GL context was created. The drawing buffer was 939×940, which is exactly 313.33pt at 3× scale, so the size was right. three.js rendered 120 frames in 2.0 seconds, so the loop ran at 60fps. endFrameEXP() fired on every single frame, in lockstep.

Then I forced the clear color to opaque magenta.

st.gl.setClearColor('#ff00ff', 1);

That is the simplest thing a GL context can do. No geometry, no textures, no model files. Not one pixel appeared.

There were zero errors in the log.

What that rules out

A solid magenta clear that never shows up cannot be a scene problem, a material problem, or a model-format problem. It also cannot be a layout problem, because the buffer had the right size.

So the entire JS layer was innocent. The break was somewhere between endFrameEXP() and the screen.

The mechanism

expo-gl on iOS does this:

override func removeFromSuperview() {
  glContext.destroy()
  displayLink?.invalidate()
  displayLink = nil
  super.removeFromSuperview()
}

The CADisplayLink is the only thing that calls presentRenderbuffer. It is created once, in init. There is no code anywhere that recreates it when the view is added back.

So if that view is ever detached and reattached, it is dead forever, and it dies silently. The JS side keeps working perfectly.

What detached it

I guessed Fabric view recycling first. That was wrong.

public static func shouldBeRecycled() -> Bool {
  // Turn off recycling for Expo views.
  return false
}

Expo turns recycling off. I was stuck there until someone pointed me at view flattening.

React Native’s New Architecture removes views that do not need a native node. The rule lives in ViewShadowNode.cpp:

bool formsStackingContext = !collapsable
    || pointerEvents == BoxOnly || pointerEvents == None
    || opacity != 1.0 || transform != {} || events.bits.any() || ...;

Look at opacity != 1.0.

Our planet was wrapped like this:

<Pressable style={({pressed}) => pressed && {opacity: 0.92}}>
  <DreamPlanet />
</Pressable>

That style is a function. On press it flips between false and {opacity: 0.92}. So the view flips between flattened and not flattened, the subtree is unmounted and remounted, and the GL context dies.

Tapping the planet is the main interaction in that screen. Every user hits it.

The fix

<Pressable collapsable={false} style={({pressed}) => pressed && {opacity: 0.92}}>

I also checked two things I had assumed, and one of them was wrong.

pointerEvents="none" is in the list above, so a view with that prop already forms a native view. A wrapper I had blamed was never being flattened. I had written a comment saying it was, and I had to go back and correct it.

backgroundColor: 'transparent' does not prevent flattening, which is worth knowing because it gets suggested as a workaround:

inline bool hostPlatformColorIsColorMeaningful(Color color) {
  return alphaFromHostPlatformColor(color) > 0;
}

Alpha 0 is not meaningful. collapsable={false} is the only lever.

What I could not check

I could not verify the fix. The iOS Simulator on this machine never draws the planet at all, not even for one frame, while the reported bug is “it draws, then freezes.” Those are different symptoms, so the simulator cannot answer the question. It goes to a physical device.

I also do not know how many other components in this app have the same exposure. I fixed the ones I found by reading, not by testing each one.

The part worth keeping

The failure mode is what makes this expensive. TypeScript passed. ESLint passed. The native log was clean. The JS was measurably perfect at 60 frames per second.

If a library takes over a native view’s lifecycle (GL, camera, video, maps), check whether anything above it can be flattened. An opacity that toggles is enough.

Verified on 2026-08-13 with Expo SDK 56, React Native 0.85, expo-gl 56.0.5, @react-three/fiber 9.6.1. I have not tested older versions.

Previous Your CLAUDE.md rules disappear on the next machine