RevenueCat / RevenueCat/react-native-purchases

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

Open
#1,836 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
1.2k
Forks
117
Avg merge
14h 40m
Merged PRs (30d)
40

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with WrappedPaywallComposeView and ComposeViewWrapper, then compare their owner setup with the PaywallFooterContainerView fix in issue #994. Reproduce the Android New Architecture crash by detaching or unmounting a Paywall during recomposition, and verify that the paywall no longer crashes when detached and that normal rendering still works.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin, react-native
Domain
mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
66/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.