Environment
react-native-purchases / react-native-purchases-ui: 10.0.1 (source also checked through 10.4.1)
- React Native 0.83, New Architecture (Fabric) enabled, Expo SDK 55
- Android only. iOS is unaffected.
What happens
RevenueCatUI.Paywall crashes on Android when its host view is detached from the window while a Compose recomposition is pending (for example when the paywall is unmounted, or when it lives inside a container that detaches, such as react-native-pager-view or a @shopify/flash-list cell):
Fatal Exception: java.lang.IllegalStateException: No ViewModelStoreOwner was provided via LocalViewModelStoreOwner
at com.revenuecat.purchases.ui.revenuecatui.InternalPaywallKt.getPaywallViewModel(InternalPaywall.kt:473)
at com.revenuecat.purchases.ui.revenuecatui.InternalPaywallKt.InternalPaywall(InternalPaywall.kt:64)
at com.revenuecat.purchases.ui.revenuecatui.InternalPaywallKt$InternalPaywall$8.invoke(:14)
at com.revenuecat.purchases.ui.revenuecatui.InternalPaywallKt$InternalPaywall$8.invoke(:8)
at androidx.compose.runtime.RecomposeScopeImpl.compose(RecomposeScopeImpl.kt:201)
at androidx.compose.runtime.ComposerImpl.recomposeToGroupEnd(ComposerImpl.kt:1690)
at androidx.compose.runtime.ComposerImpl.skipCurrentGroup(ComposerImpl.kt:2026)
at androidx.compose.runtime.ComposerImpl.doCompose-aFTiNEg(ComposerImpl.kt:2659)
at androidx.compose.runtime.ComposerImpl.recompose-aFTiNEg$runtime(ComposerImpl.kt:2583)
at androidx.compose.runtime.CompositionImpl.recompose(Composition.kt:1080)
at androidx.compose.runtime.Recomposer.performRecompose(Recomposer.kt:1406)
at androidx.compose.runtime.Recomposer.access$performRecompose(Recomposer.kt:159)
at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2.invokeSuspend$lambda$2(Recomposer.kt:638)
at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2.$r8$lambda$sdKIQuFT6MpOW8QdHT9yWSawFoM()
at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$$ExternalSyntheticLambda0.invoke(D8$$SyntheticClass)
at androidx.compose.ui.platform.AndroidUiFrameClock$withFrameNanos$2$callback$1.doFrame(AndroidUiFrameClock.android.kt:39)
at androidx.compose.ui.platform.AndroidUiDispatcher.performFrameDispatch(AndroidUiDispatcher.android.kt:108)
at androidx.compose.ui.platform.AndroidUiDispatcher.access$performFrameDispatch(AndroidUiDispatcher.android.kt:41)
at androidx.compose.ui.platform.AndroidUiDispatcher$dispatchCallback$1.doFrame(AndroidUiDispatcher.android.kt:69)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1959)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1970)
at android.view.Choreographer.doCallbacks(Choreographer.java:1423)
at android.view.Choreographer.doFrame(Choreographer.java:1338)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1930)
at android.os.Handler.handleCallback(Handler.java:1070)
at android.os.Handler.dispatchMessage(Handler.java:125)
at android.os.Looper.dispatchMessage(Looper.java:358)
at android.os.Looper.loopOnce(Looper.java:288)
at android.os.Looper.loop(Looper.java:392)
at android.app.ActivityThread.main(ActivityThread.java:10346)
at java.lang.reflect.Method.invoke(Method.java)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:638)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:972)
Root cause
WrappedPaywallComposeView extends ComposeViewWrapper, and neither sets a ViewTreeViewModelStoreOwner (nor the lifecycle / saved-state owners) on the host view. The Compose PaywallView resolves LocalViewModelStoreOwner via ViewTreeViewModelStoreOwner.get(view), which walks up the attached view tree. React Native does not set one, so it depends on an ancestor providing it. When the view detaches while a recompose is in flight, that lookup returns null and Compose throws.
This is the same class of bug as #994, but that fix only covered PaywallFooterContainerView. The full Paywall path still sets no owner.
Reproduction
Mount RevenueCatUI.Paywall inside a container that detaches while a recomposition is pending on Android New Architecture (e.g. a react-native-pager-view page or a FlashList cell), then swipe or unmount it. It crashes intermittently. iOS does not.
Suggested fix
Set the host Activity (a ComponentActivity, which is a ViewModelStoreOwner / LifecycleOwner / SavedStateRegistryOwner) as the ViewTree*Owner on the wrapper when it is created, so the lookup succeeds even while detached. This is presumably what the #994 footer fix did; extending it to the paywall wrapper would resolve it.
Environment
react-native-purchases/react-native-purchases-ui: 10.0.1 (source also checked through 10.4.1)What happens
RevenueCatUI.Paywallcrashes on Android when its host view is detached from the window while a Compose recomposition is pending (for example when the paywall is unmounted, or when it lives inside a container that detaches, such asreact-native-pager-viewor a@shopify/flash-listcell):Root cause
WrappedPaywallComposeViewextendsComposeViewWrapper, and neither sets aViewTreeViewModelStoreOwner(nor the lifecycle / saved-state owners) on the host view. The ComposePaywallViewresolvesLocalViewModelStoreOwnerviaViewTreeViewModelStoreOwner.get(view), which walks up the attached view tree. React Native does not set one, so it depends on an ancestor providing it. When the view detaches while a recompose is in flight, that lookup returns null and Compose throws.This is the same class of bug as #994, but that fix only covered
PaywallFooterContainerView. The fullPaywallpath still sets no owner.Reproduction
Mount
RevenueCatUI.Paywallinside a container that detaches while a recomposition is pending on Android New Architecture (e.g. areact-native-pager-viewpage or a FlashList cell), then swipe or unmount it. It crashes intermittently. iOS does not.Suggested fix
Set the host Activity (a
ComponentActivity, which is aViewModelStoreOwner/LifecycleOwner/SavedStateRegistryOwner) as theViewTree*Owneron the wrapper when it is created, so the lookup succeeds even while detached. This is presumably what the #994 footer fix did; extending it to the paywall wrapper would resolve it.