callstack / callstack/liquid-glass

Glass material is lost when a LiquidGlassView is re-presented inside a RN Modal (never reapplied after first mount)

Open
#47 1 comment 0 reactions 0 assignees View on GitHub
needs repro
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.