Repository navigation
iOS Fabric: recycled RCTViewComponentView retains custom-container state; suspected child-order and unmount assertion failures #59166
Description
Description
In our Expo app on React Native 0.86.3 (New Architecture), a debug build hit two RCTAssert failures in -[RCTViewComponentView unmountChildComponentView:index:] on two different dates. Reading the source, we think both come from container state that survives view recycling. We could not build a minimal React Native reproduction, so this report describes a source-level hazard, two app observations, and a UIKit-only model. We would be grateful for a maintainer's view on whether the mechanism is real.
What was observed and what was inspected. App failures were observed on React Native 0.86.3 on two dates, with different assertion messages: a child-index mismatch on 2026-10-03 and a parent/superview mismatch on 2026-10-09. These are separate observed failures with a proposed shared recycling mechanism.
React Native v0.88.0 and the main snapshot (commit 27e7721, 2026-10-17) were inspected statically on 2026-10-17. The relevant container lifecycle and mutation ordering remain present. Neither version was built or runtime-tested for this report. No minimal React Native reproduction is attached.
Files (line numbers below are v0.88.0 / main @27e77216):
packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mmpackages/react-native/React/Fabric/Mounting/RCTMountingManager.mmpackages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp
Suspected mechanism
finalizeUpdates computes _useCustomContainerView from styleWouldClipOverflowInk. The predicate requires clipping plus a nonempty boxShadow, a nonzero outline width, or nonzero border width when enableIOSViewClipToPaddingBox is enabled. The currentContainerView getter lazily creates or removes the custom container.
finalizeUpdates:RCTViewComponentView.mm:733–743 (v0.88.0) / :829–839 (main @27e77216); the flag is assigned at:736 (v0.88.0) / :832 (main @27e77216).styleWouldClipOverflowInk:RCTViewComponentView.mm:952–959 (v0.88.0) / :1049–1056 (main @27e77216).currentContainerView:RCTViewComponentView.mm:1015–1045 (v0.88.0) / :1112–1142 (main @27e77216). Migration back is in the getter'selsebranch,RCTViewComponentView.mm:1033–1041 (v0.88.0) / :1130–1138 (main @27e77216): the subviews are re-added toeffectiveContentViewwithaddSubview:, the container is removed, and_containerView = nil.effectiveContentViewis ordinarilyselfbut can be SwiftUI wrapper content (RCTViewComponentView.mm:966–1010 (v0.88.0) / :1063–1107 (main @27e77216)).
prepareForRecycle does not explicitly reset _containerView or _useCustomContainerView. For a newly created concrete parent and subtree, the differentiator emits descendant Inserts before the parent's own Insert. The mounting manager's Create branch dequeues/registers the view; configuration occurs during Insert. Consequently, children can be mounted while the recycled parent still carries its previous container flag.
prepareForRecycle:RCTViewComponentView.mm:745–784 (v0.88.0) / :841–881 (main @27e77216). It clears the decorative layers and ends with_removeClippedSubviews = NO;,_reactSubviews = [NSMutableArray new];,_layoutMetrics = EmptyLayoutMetrics;. There is no_containerViewreset, no_useCustomContainerViewreset, and no child migration. The additional safe-area cleanup on main (:874) does not reconcile children.- Differentiator, new concrete child: its Insert is collected into
insertMutations, its Create intocreateMutations, and its subtree is generated recursively intodownwardMutations(Differentiator.cpp:1024–1043 (v0.88.0) / :1026–1045 (main @27e77216)). The general new-subtree path also recurses intodownwardMutations(Differentiator.cpp:1308–1324 (v0.88.0) / :1310–1326 (main @27e77216)). The final emission order iscreateMutations, thendownwardMutations, theninsertMutations(Differentiator.cpp:1345–1356 (v0.88.0) / :1347–1358 (main @27e77216)). - This applies to newly created nodes, not to every parent update: Updates of existing nodes are emitted earlier (
Differentiator.cpp:1333–1336 (v0.88.0) / :1335–1338 (main @27e77216)). RCTMountingManager.mm:53–60 (v0.88.0) / :53–60 (main @27e77216): Create dequeues and registers the view, without props or layout.RCTMountingManager.mm:74–91 (v0.88.0) / :74–91 (main @27e77216): Insert appliesupdateProps(:83),updateLayoutMetrics(:86–87) andfinalizeUpdates(:88), then mounts (:90); the lines are identical in both versions.- A child mount goes through the getter with the parent's current flag:
[self.currentContainerView insertSubview:childComponentView atIndex:index];atRCTViewComponentView.mm:236 (v0.88.0) / :237 (main @27e77216), inmountChildComponentView:(:223–238 (v0.88.0) / :224–239 (main @27e77216)).
The parent's subsequent finalizeUpdates recomputes that flag. If it becomes false while the effective content layer is exactly 0 × 0, invalidateLayer returns before accessing currentContainerView, leaving reconciliation deferred until a later getter access.
- Zero-size guard:
RCTViewComponentView.mm:1047–1053 (v0.88.0) / :1144–1150 (main @27e77216):CALayer *layer = self.effectiveContentView.layer;thenif (CGSizeEqualToSize(layer.bounds.size, CGSizeZero)) { return; }. - The getter calls this guard skips:
RCTViewComponentView.mm:1117 (v0.88.0) / :1214 (main @27e77216)(inside the border-rendering selection,:1109–1119 (v0.88.0) / :1206–1216 (main @27e77216), and only reached for some border styles) and the clipping section,RCTViewComponentView.mm:1369–1370 (v0.88.0) / :1466–1467 (main @27e77216). finalizeUpdatesalso returns beforeinvalidateLayerwhen_needsInvalidateLayeris false:RCTViewComponentView.mm:737–739 (v0.88.0) / :833–835 (main @27e77216).
Two distinct failure paths are suspected
- Deferred reconciliation at unmount: after zero-size invalidation skips reconciliation, the parent/superview assertion compares
childComponentView.superviewwith the side-effectingcurrentContainerViewgetter. There is no explicit reconciliation before this comparison. A failure depends on the actual evaluation sequence and on reconciliation not having occurred through another getter access. - Late insertion after migration: the UIKit model constructs a separate background sublayer before migrating children back, then performs a later insertion and checks subview indices. This models the child-index assertion hypothesis; it does not exercise the zero-size early-return path.
Release behavior and visual effects have not been verified. Assertion-only expressions cannot be relied upon to perform reconciliation when assertions are compiled out.
Relevant code: unmountChildComponentView:index: at RCTViewComponentView.mm:240–273 (v0.88.0) / :241–274 (main @27e77216). The superview comparison, childComponentView.superview == self.currentContainerView, is at :251–257 (v0.88.0) / :252–258 (main @27e77216); the getter is evaluated inside that expression and can migrate children. The index check takes a fresh containerSubviews snapshot at :258–269 (v0.88.0) / :259–270 (main @27e77216), inside #ifndef NS_BLOCK_ASSERTIONS.
On background layers: plain background colors can use layer.backgroundColor directly (RCTViewComponentView.mm:1129–1132 (v0.88.0) / :1226–1229 (main @27e77216)). Separate background-color and background-image layers are added with addSublayer: at a negative z position (:1135–1138 (v0.88.0) / :1232–1235 (main @27e77216); :1338–1339 (v0.88.0) / :1435–1436 (main @27e77216)). Whether a separate layer is added before the children are migrated back depends on the style, so the ordering in the late-insertion path is conditional.
Not fixed in the inspected snapshots
prepareForRecycle still omits the reset on main (RCTViewComponentView.mm:745–784 (v0.88.0) / :841–881 (main @27e77216)), and mountChildComponentView: still inserts through the getter without recomputing the parent's flag (:236 (v0.88.0) / :237 (main @27e77216)). The related recycle change on main (RCTViewComponentView.mm:361–366 (v0.88.0) / :361–368 (main @27e77216)) compares _removeClippedSubviews against the current state; it does not touch _useCustomContainerView. We looked at source snapshots, not commit history, so we cannot rule out a fix elsewhere.
Occurrence
The failures were intermittent. The October 3 investigation recorded an approximate one-in-three rate for the breathing-sheet sequence (a fixed navigation sequence ending in a full-height sheet). The October 9 failure was observed once; three subsequent reproduction attempts did not reproduce it. Dependence on the recycled pool's contents is a source-based hypothesis; the prior owner of the crashing native view was not captured. In the 0.86.3 component view registry we read, the pool is kept per component handle, not shared across all component types.
Steps to reproduce
No minimal React Native reproduction is attached.
Unverified RN trigger hypothesis
Render, then unmount, a View with overflow: 'hidden' and a boxShadow (or a nonzero outline). In a later commit, mount a new View that holds children and is laid out 0 × 0 (for example, an auto-sized box whose only children are absolutely positioned), so that it may be dequeued from the pool entry left by the first view. Later, remove that subtree. In a debug build, the remove may assert with "mounted inside a different view". A variant for the index path: give the recycled parent a style that adds a separate background layer, and insert a child at index ≥ 1 in a later commit than its siblings.
A future RN reproduction must preserve a concrete native parent, establish actual reuse of a previously configured container, distinguish the two failure paths, and demonstrate the failure. We have not done this.
Supporting UIKit model; RN reproduction not attached
probe2.m is a 36-line UIKit-only model. It manually constructs an assumed recycled-container state, adds a background layer, migrates children, performs a late insertion, and checks subview indices. Its "CRASH" output is a label printed when its own check returns false; the program does not trigger React Native's assertion or deliberately abort. probe.m prints sublayer and subview array orders.
These probes do not exercise React Native, Yoga, view flattening, the recycle pool, mutation generation, or the zero-size invalidation guard. Their Mac Catalyst behavior is supporting evidence, not confirmation of current iOS simulator/device behavior.
The background layer in both probes has zPosition = -1024 (the value of BACKGROUND_COLOR_ZPOSITION, RCTViewComponentView.mm:41 (v0.88.0) / :42 (main @27e77216)). The probes check array order only; they render no pixels. In the "Fix B" case, probe2.m manually creates an intermediate view; it does not test React Native's collapsable behavior.
probe2.m (36 lines)
#import <UIKit/UIKit.h>
// Models RCTViewComponentView on a RECYCLED view that kept _containerView (C) + _useCustomContainerView=YES:
// children inserted into C; finalizeUpdates -> invalidateLayer appends the backgroundImage layer, then the "clipping"
// section's currentContainerView migrates C's subviews back with addSubview (after the BG layer).
static UIView *recycledPanel(NSArray<UIView *> *kids) {
UIView *p = [UIView new]; UIView *C = [UIView new]; [p addSubview:C];
for (NSUInteger i = 0; i < kids.count; i++) [C insertSubview:kids[i] atIndex:i];
CALayer *bg = [CALayer layer]; bg.zPosition = -1024; [p.layer addSublayer:bg];
for (UIView *s in C.subviews) [p addSubview:s];
[C removeFromSuperview];
return p;
}
static BOOL teardown(UIView *p, NSArray<UIView *> *model) { // descending removes, as Fabric emits them
for (NSInteger i = (NSInteger)model.count - 1; i >= 0; i--) {
if (!(p.subviews.count > (NSUInteger)i && p.subviews[i] == model[i])) { printf(" MISMATCH at index %ld (actual %lu)\n", (long)i, (unsigned long)[p.subviews indexOfObject:model[i]]); return NO; }
[model[i] removeFromSuperview];
}
return YES;
}
int main(void) { @autoreleasepool {
UIView *t=[UIView new],*x=[UIView new],*g=[UIView new],*u=[UIView new],*c=[UIView new];
// HEAD: the bloom inserted late at index 2
UIView *p = recycledPanel(@[t,x,g,u]); [p insertSubview:c atIndex:2];
printf("HEAD (late insert @2): teardown %s\n", teardown(p, @[t,x,c,g,u]) ? "OK" : "CRASH");
// Fix A: the bloom mounted with its siblings in the open transaction (no later insert into the panel)
t=[UIView new];x=[UIView new];g=[UIView new];u=[UIView new];c=[UIView new];
p = recycledPanel(@[t,x,c,g,u]);
printf("Fix A (bloom in first commit): teardown %s\n", teardown(p, @[t,x,c,g,u]) ? "OK" : "CRASH");
// Fix B: stage concrete; the bloom's late insert lands at index 0 of the stage (even a recycled, skewed stage)
t=[UIView new];x=[UIView new];g=[UIView new];u=[UIView new];c=[UIView new];
UIView *stage = recycledPanel(@[g]); // worst case: the stage itself recycled + a front non-view layer
p = recycledPanel(@[t,x,stage,u]);
[stage insertSubview:c atIndex:0];
BOOL okStage = teardown(stage, @[c,g]);
printf("Fix B (stage collapsable=false): stage teardown %s, panel teardown %s\n", okStage ? "OK" : "CRASH", teardown(p, @[t,x,stage,u]) ? "OK" : "CRASH");
} return 0; }Build and run (Mac Catalyst; no simulator needed):
SDK=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk
clang -target arm64-apple-ios17.0-macabi -isysroot "$SDK" \
-iframework "$SDK/System/iOSSupport/System/Library/Frameworks" \
-F "$SDK/System/iOSSupport/System/Library/Frameworks" -L "$SDK/System/iOSSupport/usr/lib" \
-framework UIKit -framework Foundation -framework QuartzCore -fobjc-arc probe2.m -o probe2 && ./probe2Output as recorded on 2026-10-03 (not re-run for this report): the HEAD line reports teardown CRASH, preceded by MISMATCH at index 2 (actual 1); the Fix A and Fix B lines report OK. In the model, the late-inserted view ends one slot early in subviews when a non-view sublayer precedes the migrated subviews in the layer's sublayers.
probe.m (52 lines; use the command above with probe2.m → probe.m, -o probe2 → -o probe, and ./probe2 → ./probe)
The comment on line 38 cites 0.86.3 line numbers; the same migration is at RCTViewComponentView.mm:1033–1041 (v0.88.0) / :1130–1138 (main @27e77216).
#import <UIKit/UIKit.h>
#import <QuartzCore/QuartzCore.h>
static NSString *names(UIView *p, NSDictionary *nm) {
NSMutableArray *a = [NSMutableArray new];
for (UIView *v in p.subviews) [a addObject:nm[[NSValue valueWithNonretainedObject:v]] ?: @"?"];
return [a componentsJoinedByString:@","];
}
static NSString *lnames(UIView *p, NSDictionary *nm, CALayer *bg) {
NSMutableArray *a = [NSMutableArray new];
for (CALayer *l in p.layer.sublayers) {
if (l == bg) { [a addObject:@"BG"]; continue; }
id d = l.delegate;
[a addObject:(d ? (nm[[NSValue valueWithNonretainedObject:d]] ?: @"view?") : @"layer?")];
}
return [a componentsJoinedByString:@","];
}
int main(int argc, char **argv) {
@autoreleasepool {
NSMutableDictionary *nm = [NSMutableDictionary new];
UIView *(^mk)(NSString *) = ^UIView *(NSString *n) { UIView *v = [[UIView alloc] initWithFrame:CGRectMake(0,0,10,10)]; nm[[NSValue valueWithNonretainedObject:v]] = n; return v; };
// Case 1: BG appended AFTER the children (RN's normal path)
UIView *p1 = mk(@"P1"); CALayer *bg1 = [CALayer layer]; bg1.zPosition = -1024;
UIView *t = mk(@"title"), *x = mk(@"tbx"), *g = mk(@"glass"), *u = mk(@"guide"), *c = mk(@"canvas");
[p1 insertSubview:t atIndex:0]; [p1 insertSubview:x atIndex:1]; [p1 insertSubview:g atIndex:2]; [p1 insertSubview:u atIndex:3];
[p1.layer addSublayer:bg1];
[p1 insertSubview:c atIndex:2];
printf("case1 (BG last): sublayers=[%s] subviews=[%s] canvas@%lu\n", lnames(p1,nm,bg1).UTF8String, names(p1,nm).UTF8String, (unsigned long)[p1.subviews indexOfObject:c]);
// Case 2: RN's container migration on a recycled view: children live in C, BG appended after C, then migrated back
UIView *p2 = mk(@"P2"); UIView *C = mk(@"C"); CALayer *bg2 = [CALayer layer]; bg2.zPosition = -1024;
[p2 addSubview:C];
UIView *t2 = mk(@"title"), *x2 = mk(@"tbx"), *g2 = mk(@"glass"), *u2 = mk(@"guide"), *c2 = mk(@"canvas");
[C insertSubview:t2 atIndex:0]; [C insertSubview:x2 atIndex:1]; [C insertSubview:g2 atIndex:2]; [C insertSubview:u2 atIndex:3];
[p2.layer addSublayer:bg2]; // invalidateLayer: the background-image layer appended
for (UIView *s in C.subviews) [p2 addSubview:s]; // currentContainerView: migrate back (RCTViewComponentView.mm:976-983)
[C removeFromSuperview];
printf("case2 after migrate: sublayers=[%s] subviews=[%s]\n", lnames(p2,nm,bg2).UTF8String, names(p2,nm).UTF8String);
[p2 insertSubview:c2 atIndex:2]; // the late Insert(canvas, panel, 2)
NSUInteger ai = [p2.subviews indexOfObject:c2];
UIView *at2 = p2.subviews.count > 2 ? p2.subviews[2] : nil;
printf("case2 after insert@2: sublayers=[%s] subviews=[%s] canvas actual index=%lu, view at index 2=%s\n", lnames(p2,nm,bg2).UTF8String, names(p2,nm).UTF8String, (unsigned long)ai, [nm[[NSValue valueWithNonretainedObject:at2]] UTF8String]);
// the teardown's removes, as RCTViewComponentView unmountChildComponentView checks them
BOOL ok4 = p2.subviews[4] == u2; [u2 removeFromSuperview];
BOOL ok3 = p2.subviews[3] == g2; [g2 removeFromSuperview];
BOOL ok2 = p2.subviews.count > 2 && p2.subviews[2] == c2;
printf("teardown: guide@4 %s, glass@3 %s, canvas@2 %s (actual %lu, tag-at-2 = %s)\n", ok4?"ok":"MISMATCH", ok3?"ok":"MISMATCH", ok2?"ok":"MISMATCH", (unsigned long)[p2.subviews indexOfObject:c2], [nm[[NSValue valueWithNonretainedObject:p2.subviews[2]]] UTF8String]);
}
return 0;
}React Native Version
0.86.3 (observed). v0.88.0 and main @27e77216 inspected statically on 2026-10-17; not built or run.
Affected Platforms
Runtime - iOS; Fabric / New Architecture
Areas
Fabric - The New Renderer
Output of npx @react-native-community/cli info
Omitted: this is an Expo SDK 57 project. Equivalent facts:
- React Native 0.86.3, New Architecture (Fabric), Hermes
- Expo SDK 57, with precompiled React Native modules (
EXPO_USE_PRECOMPILED_MODULES), so the React Native sources were read, not patched or rebuilt - Debug build (development client); iOS deployment target 17.0
- iOS 27.0 simulators: iPhone 16 and iPhone SE (3rd generation). Both observed failures were on the iPhone 16 simulator.
- Xcode: not recorded
Stacktrace or Logs
2026-10-03, child-index mismatch (iPhone 16 simulator, debug build). The full message text was not retained; the recorded fields were:
-[RCTViewComponentView unmountChildComponentView:index:]
"Attempt to unmount a view which has a different index."
parent: RCTViewComponentView, 393 x 852 (a full-height sheet panel)
child: SkiaPictureView (@shopify/react-native-skia), 393 x 622 at y 127
index: 2, actual index: 1 (the view at index 2 was a sibling, the sheet's close button)
2026-10-09, parent/superview mismatch (iPhone 16 simulator, debug build; SIGABRT, NSInternalInconsistencyException, main thread):
Attempt to unmount a view which is mounted inside a different view. (parent: <RCTViewComponentView: 0x157f18f00; frame = (-31 0; 0 0); tag = 3724; layer = <CALayer: 0x140c1be70>>, child: <RCTParagraphComponentView: 0x158238e00; frame = (0 1.938; 24 35.3333); transform = [1, 0, 0, 1, 0, 1.937999963760376]; clipsToBounds = YES; opaque = NO; tag = 3722; layer = <CALayer: 0x156314420>; attributedText = >, index: 2, existing parent: 3724)
Last exception backtrace (top frames):
CoreFoundation __exceptionPreprocess+160
libobjc.A.dylib objc_exception_throw+72
Foundation -[NSMutableDictionary(NSMutableDictionary) classForCoder]+0
React -[RCTViewComponentView unmountChildComponentView:index:]+1512
React RCTPerformMountInstructions(std::__1::vector<facebook::react::ShadowViewMutation, ...> const&, RCTComponentViewRegistry*, RCTMountingTransactionObserverCoordinator&, int)+1452
React -[RCTMountingManager performTransaction:]::$_2::operator()(facebook::react::MountingTransaction const&, facebook::react::SurfaceTelemetry const&) const+80
Two details of the October 9 message: the parent's frame is 0 × 0, and existing parent: 3724 is the parent's own tag. The message arguments are evaluated after the failed comparison (RCTViewComponentView.mm:251–257 (v0.88.0) / :252–258 (main @27e77216)), so this is consistent with the child having been in another view when its superview was read and having been moved into the parent by the getter during the same assertion. It does not prove that sequence.
Reproducer
None attached. The UIKit model under "Steps to reproduce" is the only code we can share; it is not a React Native reproducer.
Screenshots and Videos
None.
Expected
Recycling and container transitions preserve Fabric's child identity and ordering, including for 0 × 0 parents and subsequent insertions/removals. Structural assertions should compare against an already reconciled container.
Potential fixes to evaluate
Reset custom-container state safely during recycling, or reconcile immediately after recomputing the flag in finalizeUpdates, before either invalidation early return (RCTViewComponentView.mm:737–739 and :1051–1053 (v0.88.0) / :833–835 and :1148–1150 (main @27e77216)). Preserve child order, clipping, masks, and effective-content-view ownership. Independently, resolve currentContainerView into a local variable before comparing it with child.superview during unmount. These proposals have not been implemented or verified.
App-level mitigations we used
App-specific mitigations were to mount the affected late child from the initial commit, and to give selected 0 × 0 native parents a 1-point bottom-padding floor. The latter bypasses this exact zero-size guard when the effective layer becomes 0 × 1. Clean runs do not establish a general fix for intermittent recycling failures. On October 3, control runs without the first mitigation were also clean (0 of 12) at the time, so these simulator runs do not establish the mitigation's effectiveness.
Activity
react-native-bot commentedon Oct 17, 2026
Warning
Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:
You can read more about about it on our website: How to report a bug.