From 37a2fba10895d4248dc6f0ac982dbdb3d72eef73 Mon Sep 17 00:00:00 2001 From: SergeyMild <> Date: Wed, 16 Sep 2026 09:59:10 +0300 Subject: [PATCH] feat: say which views the sheet moved, so a mounting crash can be attributed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crashes worth chasing name nothing of ours on Android: "addViewAt: failed to insert view [child] into parent [parent] at index N", caused by "IndexOutOfBoundsException: index=N count=0", with a stack made only of React Native and framework frames. On iOS the symbols are ours (HostFittedSheet.setFittedSheetParams, dismissSheet inside RCTMountingManager.performTransaction) but the state that led there is not. So the library says it itself. SheetTreeLog records every place where a view React Native believes it owns is moved by us, naming views the way the crash report names them - by the React tag on Android, by tag and address on iOS: host.addView the previous child is dropped without React Native hearing host.removeView / host.removeViewAt sheet.addView an overlay child never reaches mHostView, yet getChildCount goes on counting it sheet.removeView / sheet.removeViewAt sheet.detachHost the host leaves the dialog container on dismiss inline.attachOverlay / dialog.attachOverlay the overlay is re-parented out of whatever parent React Native gave it sheet.insertReactSubview / sheet.removeReactSubview / sheet.attachOverlay / sheet.destroy the same on iOS host.mountChild / host.unmountChild / host.prepareForRecycle what the mounting layer asked for, and where the HostFittedSheet is thrown away and rebuilt Entries go to logcat under Sheet2Tree and to the unified log under com.sheet2, to a 32-entry ring, and on Android to JS as a "sheet2:tree" event. addSheetTreeListener turns them into app breadcrumbs - which is what survives into a native crash report - and getSheetTreeLog() reads the ring, the only channel iOS has for now. Nothing changes in behaviour: the same views are moved the same way. The two leftover println("😀 ...") debug lines go with it. --- .../main/java/com/sheet2/AppFittedSheet.kt | 28 ++++++++ .../java/com/sheet2/DialogRootViewGroup.kt | 26 +++++-- .../com/sheet2/FragmentModalBottomSheet.kt | 11 ++- .../java/com/sheet2/InlineSheetPresenter.kt | 11 ++- .../src/main/java/com/sheet2/SheetModule.kt | 7 ++ .../src/main/java/com/sheet2/SheetTreeLog.kt | 71 +++++++++++++++++++ ios/HostFittedSheet.swift | 23 ++++++ ios/SheetModule.mm | 4 ++ ios/SheetTreeLog.swift | 49 +++++++++++++ ios/SheetView.mm | 12 ++++ package.json | 2 +- src/NativeSheet.ts | 2 + src/SheetTreeLog.ts | 39 ++++++++++ src/index.tsx | 3 + 14 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 android/src/main/java/com/sheet2/SheetTreeLog.kt create mode 100644 ios/SheetTreeLog.swift create mode 100644 src/SheetTreeLog.ts diff --git a/android/src/main/java/com/sheet2/AppFittedSheet.kt b/android/src/main/java/com/sheet2/AppFittedSheet.kt index 7cd42dd..42adc67 100644 --- a/android/src/main/java/com/sheet2/AppFittedSheet.kt +++ b/android/src/main/java/com/sheet2/AppFittedSheet.kt @@ -211,6 +211,12 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent isSystemUILight = isSystemUILight ) { dismissAll -> val parent = mHostView.parent as? ViewGroup + SheetTreeLog.log( + context, + "sheet.detachHost", + "host=${SheetTreeLog.tag(mHostView)} parent=${SheetTreeLog.tag(parent)}" + + " parentChildren=${SheetTreeLog.childTags(parent)}", + ) parent?.removeViewAt(0) onSheetDismiss() if (dismissAll) { @@ -262,6 +268,16 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent override fun addView(child: View, index: Int) { UiThreadUtil.assertOnUiThread() + // An overlay child is kept out of mHostView while getChildCount keeps + // counting it, so from here on this view's children and React Native's idea + // of them are two different lists. + SheetTreeLog.log( + context, + "sheet.addView", + "child=${SheetTreeLog.tag(child)} index=$index self=${SheetTreeLog.tag(this)}" + + " hostChildren=${SheetTreeLog.childTags(mHostView)}" + + " inline=$useInlinePresentation", + ) if (useInlinePresentation && index > 0) { inlineOverlayView = child inlinePresenter.setOverlayView(child) @@ -289,6 +305,12 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent override fun removeView(child: View) { UiThreadUtil.assertOnUiThread() + SheetTreeLog.log( + context, + "sheet.removeView", + "child=${SheetTreeLog.tag(child)} self=${SheetTreeLog.tag(this)}" + + " hostChildren=${SheetTreeLog.childTags(mHostView)}", + ) if (child == inlineOverlayView) { inlineOverlayView = null inlinePresenter.setOverlayView(null) @@ -304,6 +326,12 @@ open class AppFittedSheet(context: Context) : ViewGroup(context), LifecycleEvent override fun removeViewAt(index: Int) { UiThreadUtil.assertOnUiThread() + SheetTreeLog.log( + context, + "sheet.removeViewAt", + "index=$index self=${SheetTreeLog.tag(this)}" + + " hostChildren=${SheetTreeLog.childTags(mHostView)}", + ) if (useInlinePresentation && index >= mHostView.childCount && inlineOverlayView != null) { inlineOverlayView = null inlinePresenter.setOverlayView(null) diff --git a/android/src/main/java/com/sheet2/DialogRootViewGroup.kt b/android/src/main/java/com/sheet2/DialogRootViewGroup.kt index 0f91f37..09b6779 100644 --- a/android/src/main/java/com/sheet2/DialogRootViewGroup.kt +++ b/android/src/main/java/com/sheet2/DialogRootViewGroup.kt @@ -6,7 +6,6 @@ import android.graphics.Outline import android.os.Looper import android.view.View import android.view.ViewOutlineProvider -import com.facebook.react.uimanager.PixelUtil.pxToDp import kotlin.math.max import kotlin.math.min @@ -86,7 +85,6 @@ class DialogRootViewGroup(context: Context) : BaseRNView(context) { if (sheetMaxHeightSize == Float.MAX_VALUE) return val newHeight = allowedHeight val newWidth = allowedWidth - println("😀 DialogRootViewGroup.setVirtualHeight ${newHeight.pxToDp()} :${newWidth.pxToDp()}") ensureLayoutParams() layoutParams?.height = newHeight translationX = centeringTranslationX(newWidth) @@ -105,19 +103,39 @@ class DialogRootViewGroup(context: Context) : BaseRNView(context) { } override fun addView(child: View, index: Int, params: LayoutParams) { - println("😀 DialogRootViewGroup.addView ${child.id}") - if (reactView != null) removeView(reactView) + // Dropping the previous child here is a removal React Native never hears + // about: it goes on counting that child, and its next insert lands at an + // index the group no longer has. + val replaced = reactView + SheetTreeLog.log( + context, + "host.addView", + "child=${SheetTreeLog.tag(child)} index=$index children=${SheetTreeLog.childTags(this)}" + + " replaces=${SheetTreeLog.tag(replaced)}", + ) + if (replaced != null) removeView(replaced) super.addView(child, index, params) reactView = child setVirtualHeight(sheetMaxHeightSize) } override fun removeView(view: View?) { + SheetTreeLog.log( + context, + "host.removeView", + "child=${SheetTreeLog.tag(view)} children=${SheetTreeLog.childTags(this)}", + ) if (view == reactView) releaseReactView() super.removeView(view) } override fun removeViewAt(index: Int) { + SheetTreeLog.log( + context, + "host.removeViewAt", + "index=$index child=${SheetTreeLog.tag(getChildAt(index))}" + + " children=${SheetTreeLog.childTags(this)}", + ) if (getChildAt(index) === reactView) releaseReactView() super.removeViewAt(index) } diff --git a/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt b/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt index bd90306..772ac00 100644 --- a/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt +++ b/android/src/main/java/com/sheet2/FragmentModalBottomSheet.kt @@ -84,7 +84,16 @@ class FragmentModalBottomSheet() : BottomSheetDialogFragment() { val view = overlayView ?: return val decorView = dialog?.window?.decorView as? ViewGroup ?: return detachOverlayHost() - (view.parent as? ViewGroup)?.removeView(view) + // Same as the inline presenter: the overlay is taken out of whatever parent + // React Native gave it and put into the dialog's own host. + val previousParent = view.parent as? ViewGroup + SheetTreeLog.log( + view.context, + "dialog.attachOverlay", + "overlay=${SheetTreeLog.tag(view)} from=${SheetTreeLog.tag(previousParent)}" + + " fromChildren=${SheetTreeLog.childTags(previousParent)}", + ) + previousParent?.removeView(view) val host = DialogPassThroughFrameLayout(decorView.context).apply { layoutParams = ViewGroup.LayoutParams( diff --git a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt index 3649a03..e348833 100644 --- a/android/src/main/java/com/sheet2/InlineSheetPresenter.kt +++ b/android/src/main/java/com/sheet2/InlineSheetPresenter.kt @@ -169,7 +169,16 @@ internal class InlineSheetPresenter( private fun attachOverlayView() { val host = overlayHost ?: return val view = overlayView ?: return - (view.parent as? ViewGroup)?.removeView(view) + // Re-parenting a view React Native mounted somewhere else: its old parent + // loses a child without React Native being told. + val previousParent = view.parent as? ViewGroup + SheetTreeLog.log( + view.context, + "inline.attachOverlay", + "overlay=${SheetTreeLog.tag(view)} from=${SheetTreeLog.tag(previousParent)}" + + " fromChildren=${SheetTreeLog.childTags(previousParent)} to=${SheetTreeLog.tag(host)}", + ) + previousParent?.removeView(view) view.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT, diff --git a/android/src/main/java/com/sheet2/SheetModule.kt b/android/src/main/java/com/sheet2/SheetModule.kt index 8f57a7b..d6d55e9 100644 --- a/android/src/main/java/com/sheet2/SheetModule.kt +++ b/android/src/main/java/com/sheet2/SheetModule.kt @@ -4,6 +4,7 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap class SheetModule(reactContext: ReactApplicationContext) : NativeSheetSpec(reactContext) { @@ -23,6 +24,12 @@ class SheetModule(reactContext: ReactApplicationContext) : NativeSheetSpec(react return Arguments.createMap() } + override fun getTreeLog(): WritableArray { + val entries = Arguments.createArray() + SheetTreeLog.snapshot().forEach { entries.pushString(it) } + return entries + } + override fun dismissAll() { reactApplicationContext.runOnUiQueueThread { reactApplicationContext.currentActivity?.let { AppFittedSheet.Companion.dismissAll(it as AppCompatActivity) } diff --git a/android/src/main/java/com/sheet2/SheetTreeLog.kt b/android/src/main/java/com/sheet2/SheetTreeLog.kt new file mode 100644 index 0000000..ba89e2b --- /dev/null +++ b/android/src/main/java/com/sheet2/SheetTreeLog.kt @@ -0,0 +1,71 @@ +package com.sheet2 + +import android.content.Context +import android.util.Log +import android.view.View +import android.view.ViewGroup +import com.facebook.react.bridge.ReactContext +import com.facebook.react.modules.core.DeviceEventManagerModule + +/** + * Every place where this library moves a view React Native believes it owns. + * + * The mounting crashes worth chasing carry no frame of ours: on Android the + * stack between `ViewGroupManager.addView` and `ViewGroup.addInArray` is React + * Native and the framework only ("addViewAt: failed to insert view [child] into + * parent [parent] at index N", caused by "IndexOutOfBoundsException: index=N + * count=0"). So the only way to tell afterwards whether a sheet was involved is + * to say so ourselves, naming the views by the id React Native uses as the tag — + * which is exactly what that message carries. + * + * Entries go to logcat, to a small in-memory ring for a local session, and — when + * a listener is registered from JS — to the app, which is expected to turn them + * into breadcrumbs. Breadcrumbs are what survives into a native crash report. + */ +object SheetTreeLog { + private const val TAG = "Sheet2Tree" + private const val EVENT = "sheet2:tree" + private const val CAPACITY = 32 + + private val entries = ArrayDeque() + + fun log(context: Context?, op: String, details: String) { + val entry = "$op $details" + + synchronized(entries) { + if (entries.size == CAPACITY) entries.removeFirst() + entries.addLast(entry) + } + + Log.d(TAG, entry) + emit(context, entry) + } + + /** The tag React Native knows the view by, or "-" when there is no view. */ + fun tag(view: View?): String = view?.let { "${it.id}" } ?: "-" + + fun childTags(parent: ViewGroup?): String { + if (parent == null) return "-" + val count = parent.childCount + if (count == 0) return "none" + + return (0 until count).joinToString(",") { "${parent.getChildAt(it).id}" } + } + + fun snapshot(): List = synchronized(entries) { entries.toList() } + + private fun emit(context: Context?, entry: String) { + val reactContext = context as? ReactContext ?: return + if (!reactContext.hasActiveReactInstance()) return + + try { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT, entry) + } catch (error: Throwable) { + // A sheet can be torn down while the instance is going away; diagnostics + // must never be the reason something crashes. + Log.d(TAG, "emit failed: ${error.message}") + } + } +} diff --git a/ios/HostFittedSheet.swift b/ios/HostFittedSheet.swift index 27f467b..b1f203f 100644 --- a/ios/HostFittedSheet.swift +++ b/ios/HostFittedSheet.swift @@ -133,7 +133,14 @@ public final class HostFittedSheet: UIView { } public override func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + SheetTreeLog.log( + "sheet.insertReactSubview", + details: "child=\(SheetTreeLog.name(subview)) index=\(atIndex) self=\(SheetTreeLog.name(self))" + + " presented=\(_isPresented)" + ) if atIndex > 0 { + // The overlay never becomes a subview of the content view React Native + // mounted it into. _overlaySubview = subview subview.isUserInteractionEnabled = false attachOverlaySubview() @@ -148,6 +155,11 @@ public final class HostFittedSheet: UIView { } public override func removeReactSubview(_ subview: UIView!) { + SheetTreeLog.log( + "sheet.removeReactSubview", + details: "child=\(SheetTreeLog.name(subview)) self=\(SheetTreeLog.name(self))" + + " presented=\(_isPresented)" + ) if let overlaySubview = _overlaySubview, subview === overlaySubview { _overlaySubview?.removeFromSuperview() _overlaySubview = nil @@ -166,6 +178,12 @@ public final class HostFittedSheet: UIView { guard let overlaySubview = _overlaySubview, let sheetView = _modalViewController?.view else { return } + SheetTreeLog.log( + "sheet.attachOverlay", + details: "overlay=\(SheetTreeLog.name(overlaySubview))" + + " from=\(SheetTreeLog.name(overlaySubview.superview))" + + " to=\(SheetTreeLog.name(sheetView))" + ) overlaySubview.removeFromSuperview() overlaySubview.frame = sheetView.bounds overlaySubview.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -416,6 +434,11 @@ public final class HostFittedSheet: UIView { @objc public func destroy() { + SheetTreeLog.log( + "sheet.destroy", + details: "self=\(SheetTreeLog.name(self)) content=\(SheetTreeLog.name(_reactSubview))" + + " overlay=\(SheetTreeLog.name(_overlaySubview)) presented=\(_isPresented)" + ) if !_isPresented && _modalViewController == nil { return } diff --git a/ios/SheetModule.mm b/ios/SheetModule.mm index db2df0b..e394213 100644 --- a/ios/SheetModule.mm +++ b/ios/SheetModule.mm @@ -37,6 +37,10 @@ - (NSDictionary *)viewportSize { return dict; } +- (NSArray *)getTreeLog { + return [SheetTreeLog snapshot]; +} + - (void)dismiss:(NSInteger)tag { NSLog(@"😀 dismissModule %d", [[NSNumber alloc] initWithInt:tag].intValue); } diff --git a/ios/SheetTreeLog.swift b/ios/SheetTreeLog.swift new file mode 100644 index 0000000..98aaccf --- /dev/null +++ b/ios/SheetTreeLog.swift @@ -0,0 +1,49 @@ +import Foundation +import UIKit +import os + +/** + Every place where this library moves a view React Native believes it owns — + the iOS half of the Android `SheetTreeLog`. + + On iOS the crashes do carry our symbols (`HostFittedSheet.setFittedSheetParams`, + `dismissSheet` inside `RCTMountingManager.performTransaction`), so what is + missing there is not the name but the state: which subview was mounted, which + one was taken away, and whether the sheet was already being destroyed. That is + what these entries carry. + + The trail lives in memory and in the unified log under the `com.sheet2` + subsystem; `getTreeLog()` on the Sheet module hands the last entries to JS. + */ +@objc public class SheetTreeLog: NSObject { + private static let capacity = 32 + private static let lock = NSLock() + private static var entries: [String] = [] + private static let osLog = OSLog(subsystem: "com.sheet2", category: "tree") + + @objc public static func log(_ op: String, details: String) { + let entry = "\(op) \(details)" + + lock.lock() + entries.append(entry) + if entries.count > capacity { + entries.removeFirst(entries.count - capacity) + } + lock.unlock() + + os_log("%{public}@", log: osLog, type: .debug, entry) + } + + @objc public static func snapshot() -> [String] { + lock.lock() + defer { lock.unlock() } + return entries + } + + /// A view named the way a crash report names it: the React tag when the view + /// carries one, and the address, which is what an EXC_BAD_ACCESS points at. + @objc public static func name(_ view: UIView?) -> String { + guard let view else { return "-" } + return "tag=\(view.tag)/\(Unmanaged.passUnretained(view).toOpaque())" + } +} diff --git a/ios/SheetView.mm b/ios/SheetView.mm index 994d0f8..c550077 100644 --- a/ios/SheetView.mm +++ b/ios/SheetView.mm @@ -135,6 +135,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & } - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { + [SheetTreeLog log:@"host.mountChild" + details:[NSString stringWithFormat:@"child=%@ index=%ld host=%@", + [SheetTreeLog name:childComponentView], (long)index, + [SheetTreeLog name:_view2]]]; [_view2 insertReactSubview:childComponentView atIndex:index]; __weak SheetView *weakSelf = self; @@ -150,10 +154,18 @@ - (void)mountChildComponentView:(UIView *)childCompone } - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { + [SheetTreeLog log:@"host.unmountChild" + details:[NSString stringWithFormat:@"child=%@ index=%ld host=%@", + [SheetTreeLog name:childComponentView], (long)index, + [SheetTreeLog name:_view2]]]; [_view2 removeReactSubview:childComponentView]; } - (void)prepareForRecycle { + // The HostFittedSheet is thrown away and rebuilt here, so anything the + // mounting layer still holds from before this point is stale. + [SheetTreeLog log:@"host.prepareForRecycle" + details:[NSString stringWithFormat:@"host=%@", [SheetTreeLog name:_view2]]]; [_view2 destroy]; _view2.onSheetDismiss = nil; diff --git a/package.json b/package.json index 90eee62..fa5091d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "example" ], "private": "true", - "version": "7.10.2", + "version": "7.11.0", "description": "Native implementation of Bottom sheet", "main": "src/index.tsx", "module": "src/index", diff --git a/src/NativeSheet.ts b/src/NativeSheet.ts index cc55f47..f0d3d19 100644 --- a/src/NativeSheet.ts +++ b/src/NativeSheet.ts @@ -13,6 +13,8 @@ export interface Spec extends TurboModule { viewportSize(): { width: Double; height: Double }; dismissAll(): void; dismissPresented(): void; + /** Last native view-tree moves this library made — see SheetTreeLog. */ + getTreeLog(): string[]; } export default TurboModuleRegistry.getEnforcing('Sheet'); diff --git a/src/SheetTreeLog.ts b/src/SheetTreeLog.ts new file mode 100644 index 0000000..a47f3af --- /dev/null +++ b/src/SheetTreeLog.ts @@ -0,0 +1,39 @@ +import { DeviceEventEmitter, type EmitterSubscription } from 'react-native'; + +import SheetModule from './NativeSheet'; + +const EVENT = 'sheet2:tree'; + +/** + * Native view-tree operations this library performs on views React Native + * believes it owns: presenting and dismissing a sheet moves them between + * containers, and an overlay child never reaches the host view at all. + * + * The mounting crashes those operations can end in name nothing of ours — the + * Android one is "addViewAt: failed to insert view [child] into parent [parent] + * at index N" with a stack made of React Native and framework frames. Each entry + * here names the views by the id React Native uses as the tag, so an app that + * turns these into breadcrumbs can answer, from the crash report alone, whether + * a sheet had touched the views in question. + * + * Android only for now; the iOS side is not wired yet. + * + * @example + * addSheetTreeListener(entry => + * Sentry.addBreadcrumb({ category: 'sheet2', message: entry, level: 'info' }), + * ) + */ +export function addSheetTreeListener( + handler: (entry: string) => void +): EmitterSubscription { + return DeviceEventEmitter.addListener(EVENT, handler); +} + +/** + * The last native view-tree moves, newest last (32 are kept). iOS has no event + * channel yet, so this is how the trail is read there — for a report attached by + * hand, or from a debug screen. + */ +export function getSheetTreeLog(): string[] { + return SheetModule.getTreeLog(); +} diff --git a/src/index.tsx b/src/index.tsx index 0e8cbe4..e7586e9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import { dismissFittedPresented, } from './PublicSheetView'; import SheetModule from './NativeSheet'; +import { addSheetTreeListener, getSheetTreeLog } from './SheetTreeLog'; import type { ReactNode } from 'react'; import { Fragment } from 'react'; @@ -49,5 +50,7 @@ export { presentGlobalFittedSheet, dismissGlobalFittedSheet, attachScrollViewToGlobalFittedSheet, + addSheetTreeListener, + getSheetTreeLog, type FittedSheetParams, };