Skip to content

iOS Fabric: recycled RCTViewComponentView retains custom-container state; suspected child-order and unmount assertion failures #59166

Open

Description

@Dantehhhhhh

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.mm
  • packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm
  • packages/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's else branch, RCTViewComponentView.mm:1033–1041 (v0.88.0) / :1130–1138 (main @27e77216): the subviews are re-added to effectiveContentView with addSubview:, the container is removed, and _containerView = nil. effectiveContentView is ordinarily self but 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 _containerView reset, no _useCustomContainerView reset, 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 into createMutations, and its subtree is generated recursively into downwardMutations (Differentiator.cpp:1024–1043 (v0.88.0) / :1026–1045 (main @27e77216)). The general new-subtree path also recurses into downwardMutations (Differentiator.cpp:1308–1324 (v0.88.0) / :1310–1326 (main @27e77216)). The final emission order is createMutations, then downwardMutations, then insertMutations (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 applies updateProps (:83), updateLayoutMetrics (:86–87) and finalizeUpdates (: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]; at RCTViewComponentView.mm:236 (v0.88.0) / :237 (main @27e77216), in mountChildComponentView: (: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; then if (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).
  • finalizeUpdates also returns before invalidateLayer when _needsInvalidateLayer is 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.superview with the side-effecting currentContainerView getter. 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 && ./probe2

Output 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

  1. react-native-bot commented on Oct 17, 2026

    @react-native-bot
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions