Skip to content

Android: RevenueCatUI.Paywall crashes with "No ViewModelStoreOwner was provided" when its view detaches during recomposition #1836

Description

@ivannzme

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions