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, };