callstack / callstack/liquid-glass
iOS 26: Fabric index corruption + dangling pointer-interaction registration cause use-after-free crashes
- Dominant language
- Objective-C++
- Stars
- 1.7k
- Forks
- 64
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 1
Description
## Summary
On iOS 26, apps using `LiquidGlassView` (especially with `interactive`) crash sporadically with memory-corruption signatures during React Native (Fabric) mount/unmount transactions. We root-caused two distinct mechanisms in the library's iOS implementation and validated fixes in production-style stress testing. Happy to send a PR if you agree with the approach.
## Environment
- `@callstack/liquid-glass` 0.8.0
- react-native 0.81.4 (New Architecture / Fabric)
- iOS 26.5 (simulator) and iOS 26.6 (device, field crashes)
## Crash signatures observed
1. `EXC_BAD_ACCESS` in `-[_UIPointerInteractionAssistant _assistantForView:]` / `-[UIView(UIPointerEventsPrivate) _containsView:]` while `RCTMountingManager performTransaction` mounts a child view (use-after-free — the assistant registry enumerates a freed view).
2. `NSInvalidArgumentException: -[__NSArrayM insertSublayer:atIndex:]: unrecognized selector` (and `OS_xpc_*` / `CALayerArray` variants) during mounting — a freed `CALayer`'s memory reused by an unrelated object, then messaged as a layer.
Both reproduce with rapid navigation churn across screens containing glass views (open/close product-detail screens with glass buttons/badges, tab switches). In our app this crashed roughly 1 in 3 sessions of ~100 navigations.
## Root causes
### 1. Fabric children are mounted directly into `UIVisualEffectView.contentView`
```objc
- (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index {
[_view.contentView insertSubview:childComponentView atIndex:index];
}
```
Fabric assumes it exclusively owns the index space of the container it mounts into. On iOS 26, UIKit's glass/pointer machinery injects private subviews (e.g. `_UIPointerEffectPlatterView`) into the effect view hierarchy at runtime. Once an injected view lands in `contentView`, every Fabric index is off by one: subsequent insert/remove transactions operate on the wrong subviews, detaching views the framework still references — producing the freed-layer `insertSublayer` crashes above.
### 2. Live glass effect (and its pointer-interaction registration) survives recycle/dealloc
With `isInteractive = true`, `UIGlassEffect` registers the view with UIKit's pointer-interaction system. The component view never tears the effect down:
- when Fabric recycles the component view (`prepareForRecycle`), the pooled instance keeps its live effect and registration;
- when the view deallocates, the registration is never removed while the view is alive.
The next pointer-interaction registry walk (which UIKit performs during ordinary `_addSubview` passes) messages the freed view — the `_UIPointerInteractionAssistant` crash above.
## Fix we validated
1. Mount Fabric children into a dedicated plain `UIView` container inside `contentView` (autoresized to its bounds). UIKit can then inject anything into the effect hierarchy without disturbing Fabric's index space.
2. Add a `resetForRecycle()` on `LiquidGlassViewImpl` that sets `effect = nil` and clears cached state; call it from `prepareForRecycle` and `dealloc` so UIKit unregisters the interactive-glass pointer machinery while the view is still alive.
Patch (applied via patch-package on 0.8.0):
```diff
--- a/ios/LiquidGlassView.mm
+++ b/ios/LiquidGlassView.mm
@@
@implementation LiquidGlassView {
LiquidGlassViewImpl * _view;
+ UIView * _childContainer;
BOOL _needsInvalidateLayer;
}
@@
_view = [[LiquidGlassViewImpl alloc] init];
-
+
+ _childContainer = [[UIView alloc] initWithFrame:CGRectZero];
+ _childContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+ [_view.contentView addSubview:_childContainer];
+
self.contentView = _view;
}
return self;
}
+- (void)dealloc
+{
+ [_view resetForRecycle];
+}
+
@@
- (void)layoutSubviews {
[super layoutSubviews];
_view.layer.cornerRadius = self.layer.cornerRadius;
_view.layer.cornerCurve = self.layer.cornerCurve;
+ _childContainer.frame = _view.contentView.bounds;
}
+
+- (void)prepareForRecycle
+{
+ [super prepareForRecycle];
+ [_view resetForRecycle];
+}
@@
- (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index {
- [_view.contentView insertSubview:childComponentView atIndex:index];
+ [_childContainer insertSubview:childComponentView atIndex:index];
}
--- a/ios/LiquidGlassView.swift
+++ b/ios/LiquidGlassView.swift
@@
+ @objc public func resetForRecycle() {
+ self.effect = nil
+ appliedStyle = nil
+ appliedTintColor = nil
+ appliedInteractive = nil
+ isFirstMount = true
+ effectTintColor = nil
+ interactive = false
+ style = .regular
+ animated = true
+ animationDuration = 0
+ hasAnimationDuration = false
+ }
```
(Plus an empty `resetForRecycle()` on the non-iOS-26 fallback class so the ObjC call compiles on older SDK branches.)
## Validation
Automated stress loop on iOS 26.5 simulator (Release configuration): repeated product-screen open/close over interactive glass buttons + tab switches, 10 runs x 15 navigation cycles. Before: crashes in ~1/3 of runs (both signatures). After: zero occurrences of either signature.
Related: facebook/react-native#55489 describes the same `_UIPointerInteractionAssistant` use-after-free pattern from the pointer-interaction side.
Contributor guide
Research direction
Read ios/LiquidGlassView.mm and ios/LiquidGlassView.swift, focusing on Fabric child mounting and recycle/deallocation paths. Run the described iOS 26 stress loop with interactive glass views and navigation churn; done means both reported crash signatures remain absent and the fallback class still compiles on older SDK branches.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, ios, objective-c, react-native, swift
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100