callstack / callstack/liquid-glass
Glass material is lost when a LiquidGlassView is re-presented inside a RN Modal (never reapplied after first mount)
- Dominant language
- Objective-C++
- Stars
- 1.7k
- Forks
- 64
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 1
Description
### Summary
A `LiquidGlassView` inside a React Native `` renders its glass material the first time the modal is presented, and renders completely flat on every presentation after that. The same buttons on the screen behind the modal, which never unmount, keep their material for the life of the app.
The root cause looks like the effect only ever being applied once, from `layoutSubviews`, with nothing that reapplies it when the view is attached to a different window. A `UIVisualEffectView` binds its backdrop to the window it is in, and RN's modal presents into its own `UIWindow`.
### Environment
- `@callstack/liquid-glass` 0.8.0
- `react-native` 0.85.3, New Architecture (Fabric)
- `react` 19.2.3
- iOS 26, physical device (`isLiquidGlassSupported === true`)
### Reproduction
```tsx
const [open, setOpen] = useState(false)
return (
<>
setOpen(true)}>open
{open && (
setOpen(false)}>
setOpen(false)}>close
)}
)
```
1. Open the modal — the button inside it has a glass material.
2. Close it.
3. Open it again — the button inside the modal is flat. The button outside is still fine.
Reopening never recovers. Killing and relaunching the app resets it, so the first presentation works again.
### What I found in the source
Everything below is read from the shipped source; the behaviour above is what I observed on device.
**1. The effect is applied once and never reapplied**
`ios/LiquidGlassView.swift:36-43`:
```swift
public override func layoutSubviews() {
if (self.effect != nil) { return }
setupView()
...
}
```
There is no `prepareForRecycle` in `ios/LiquidGlassView.mm`, so a recycled view comes back from Fabric's pool with `self.effect` still set and `isFirstMount == false`. `layoutSubviews` then returns immediately. The only other path to `setupView()` is `updateProps`, which runs it only when a glass prop actually changed (`ios/LiquidGlassView.mm:125`) — on a recycle the props are usually identical, so nothing rebuilds the material.
`layoutSubviews` also never calls `super.layoutSubviews()`, which I would expect `UIVisualEffectView` to need for its backdrop and `contentView`.
There is no `didMoveToWindow`, so nothing reacts to the view being attached to a different window.
**2. Reassigning `.effect` on the existing view is not enough**
I tried forcing `setupView()` to run on the existing view by toggling a glass prop after mount (`interactive`, then `colorScheme`). `setupView` does run — toggling `effect` to `'none'` visibly broke the *first* presentation, which proves the path is reached — but the material still does not come back. Only genuinely remounting the component (a changed React `key`, so a fresh native view is mounted into the presented window) restores it.
**3. A view mounted under an alpha-0 ancestor never builds a material at all**
If the `LiquidGlassView` is mounted while an ancestor is at `opacity: 0` and only faded in afterwards, the material never appears, even at full opacity later. Because the effect is applied once and `layoutSubviews` early-returns from then on, there is no second chance. Keeping the ancestor at ~2% opacity instead of 0 is enough to make it build.
**4. `effect="none"` leaves the view unrecoverable**
`ios/LiquidGlassView.swift:63-67` assigns a bare `UIVisualEffect()` and carries a TODO about it. Once a view has taken that path, later assigning a real `UIGlassEffect` does not bring the material back in my testing.
**5. `animated` defaults to applying the effect inside a UIView animation**
`ios/LiquidGlassView.swift:85-99`: after the first mount, `applyEffect` assigns `.effect` inside `UIView.animate`. When that happens while the view is not yet on-window, the assignment appears to be dropped. Passing `animated={false}` avoids that branch, though on its own it does not fix the modal case.
### Suggested fixes
- Implement `prepareForRecycle` on the component view and reset the impl (`effect = nil`, `isFirstMount = true`) so a pooled view rebuilds.
- Call `super.layoutSubviews()`.
- Rebuild the effect in `didMoveToWindow` when `window != nil`, so a view that moves between windows rebinds its backdrop.
- Consider not swallowing the rebuild when `effect != nil` — or tracking the window the effect was built for.
### Current workaround
For anyone hitting this before a fix lands, what worked for us:
- Bump a counter in `Modal`'s `onShow` and use it as a React `key` on the glass views so they are remounted into the presented window.
- Keep the transition at progress 0 until `onShow` fires, so that remount is not visible.
- Never let an ancestor of a glass view sit at exactly `opacity: 0` while it mounts — we floor it at `0.02`.
- Pass `animated={false}` everywhere.
Happy to test a patch, or to open a PR if the maintainers would like the direction above.
Contributor guide
Research direction
Start with ios/LiquidGlassView.swift:36-43 and 63-99, then inspect ios/LiquidGlassView.mm:125 and its recycling behavior. Reproduce the issue with the React Native Modal example, checking window changes, recycling, opacity-zero mounting, and animated effects. Done means glass material reliably returns after repeated modal presentations and remains recoverable in the described cases.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ios, react-native, swift
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100