From ca0082d75e7ff6dc00dcfefbb03378400e6292dc Mon Sep 17 00:00:00 2001 From: sebaslogen Date: Sun, 8 Oct 2023 21:58:45 +0200 Subject: [PATCH] Update docs, tests and clean up --- README.md | 14 ++++----- resaca/build.gradle.kts | 3 +- .../resaca/ScopedViewModelContainer.kt | 31 +++---------------- .../sebaslogen/resaca/ScopedViewModelUtils.kt | 2 -- resacahilt/build.gradle.kts | 3 +- resacakoin/build.gradle.kts | 3 +- .../sample/ui/main/ComposeActivity.kt | 21 ------------- .../sample/ClearRememberScopedObjectTests.kt | 5 +-- .../sample/ClearScopedViewModelTests.kt | 4 +-- .../sample/hilt/ClearScopedViewModelTests.kt | 6 ++-- .../sample/koin/ClearScopedViewModelTests.kt | 6 ++-- 11 files changed, 26 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 7b46afcc..fecb1e1e 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Here are a few suggestions of how to provide objects in combination with this li - When using the Lazy* family of Composables it is recommended that you use `rememberScoped`/`viewModelScoped` outside the scope of Composables created by Lazy constructors (e.g. LazyColumn) because there is a chance that a lazy initialized Composable will be disposed of when it is not visible anymore (e.g. scrolled - away) and that will also dispose of the `rememberScoped`/`viewModelScoped` object (after a few seconds), this might not be the intended behavior. For more + away) and that will also dispose of the `rememberScoped`/`viewModelScoped` object immediately, this might not be the intended behavior. For more info see Compose's [State Hoisting](https://developer.android.com/jetpack/compose/state#state-hoisting). - When a Composable is used more than once in the same screen with the same input, then the ViewModel (or business logic object) should be provided only once with `viewModelScoped` at a higher level in the tree using Compose's [State Hoisting](https://developer.android.com/jetpack/compose/state#state-hoisting). @@ -277,19 +277,19 @@ This project uses internally a ViewModel as a container to store all scoped View What happens when a Composable is disposed? When a Composable is disposed of, we don't know for sure if it will return again later. So at the moment of disposal, we mark in our container the associated -object to be disposed of after a small delay (currently 5 seconds). During the span of time of this delay, a few things can happen: +object to be disposed of after the next frame when the Activity is resumed. During the span of time of this next frame, a few things can happen: -- The Composable is not part of the composition anymore after the delay and then the associated object is disposed of. 🚮 +- The Composable is not part of the composition anymore after the next frame and the associated object is disposed of. 🚮 - The LifecycleOwner of the disposed Composable (i.e. the navigation destination where the Composable lived) is paused (e.g. screen went to background) before - the delay finishes. Then the disposal of the scoped object is canceled, but the object is still marked for disposal at a later stage. + the next frame happened. Then the disposal of the scoped object is canceled, but the object is still marked for disposal at a later stage. - This can happen when the application goes through a configuration change and the container Activity is recreated. - Also when the Composable is part of a Fragment that has been pushed to the backstack. - And also when the Composable is part of a Compose Navigation destination that has been pushed to the backstack. - When the LifecycleOwner of the disposed Composable is resumed (e.g. Fragment comes back to foreground), then the disposal of the associated object is - scheduled again to happen after a small delay. At this point two things can happen: + scheduled again to happen after the next frame when the Activity is resumed. At this point two things can happen: - The Composable becomes part of the composition again and the `rememberScoped`/`viewModelScoped` function restores the associated object while also - canceling any pending delayed disposal. 🎉 - - The Composable is not part of the composition anymore after the delay and then the associated object is disposed of. 🚮 + cancelling any pending disposal in the next frame when the Activity is resumed. 🎉 + - The Composable is not part of the composition anymore after the next frame and then the associated object is disposed of. 🚮 > **Note**: diff --git a/resaca/build.gradle.kts b/resaca/build.gradle.kts index 2d913664..c820aaef 100644 --- a/resaca/build.gradle.kts +++ b/resaca/build.gradle.kts @@ -12,7 +12,6 @@ android { compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { minSdk = libs.versions.minSdk.get().toInt() - targetSdk = libs.versions.targetSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { // Enables Jetpack Compose for this module @@ -34,7 +33,7 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } - packagingOptions { + packaging { resources { excludes += setOf( // Exclude AndroidX version files diff --git a/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelContainer.kt b/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelContainer.kt index a77cb9f3..5fae04f4 100644 --- a/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelContainer.kt +++ b/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelContainer.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.Log import android.view.Choreographer import androidx.compose.runtime.Composable import androidx.compose.runtime.DisallowComposableCalls @@ -85,7 +84,7 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { /** * Lock to wait for the first composition after Activity resumes. - * This is apparently only required in automated Espresso tests. + * This is apparently only required in automated tests. */ private var compositionResumedTimeout = CountDownLatch(1) @@ -138,7 +137,6 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { ): T { @Composable fun buildAndStoreObject() = builder.invoke().apply { scopedObjectsContainer[positionalMemoizationKey] = this } - Log.d("Sebas", "REQUEST CANCEL DISPOSAL OF Object $positionalMemoizationKey - it's being requested again - ${Thread.currentThread().name}") cancelDisposal(positionalMemoizationKey) val originalObject: Any? = scopedObjectsContainer[positionalMemoizationKey] @@ -271,17 +269,12 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { if (disposingJobs.containsKey(key)) return // Already disposing, quit val newDisposingJob = viewModelScope.launch { - Log.d("Sebas", "scheduleToDispose of $key - ${Thread.currentThread().name}") - if (!isInForeground) awaitChoreographerFramePostFrontOfQueue(key) // When in background, wait for the next frame when the Activity is resumed + if (!isInForeground) awaitChoreographerFramePostFrontOfQueue() // When in background, wait for the next frame when the Activity is resumed withContext(NonCancellable) { // We treat the disposal/remove/clear block as an atomic transaction - Log.d("Sebas", "scheduleToDispose REMOVING $key - ${Thread.currentThread().name}") if (isInForeground || isChangingConfiguration) { - Log.d("Sebas", "scheduleToDispose REMOVED $key - ${Thread.currentThread().name}") markedForDisposal.remove(key) scopedObjectKeys.remove(key) scopedObjectsContainer.remove(key)?.also { clearLastDisposedObject(it) } - } else { - Log.d("Sebas", "scheduleToDispose NOT REMOVED $key because we are in the background - ${Thread.currentThread().name}") } disposingJobs.remove(key) } @@ -300,35 +293,27 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { * If the Activity never comes back, then the work will be cancelled and * the FrameCallback will be removed thanks to the coroutine scope cancellation. */ - private suspend fun awaitChoreographerFramePostFrontOfQueue(key: String) { + private suspend fun awaitChoreographerFramePostFrontOfQueue() { val localCoroutineScope = CoroutineScope(coroutineContext) suspendCancellableCoroutine { continuation -> val frameCallback = Choreographer.FrameCallback { - Log.d("Sebas", "FrameCallback executed for $key - ${Thread.currentThread().name}") handler.postAtFrontOfQueue { // This needs to be posted and run right after Activity resumes - Log.d("Sebas", "postAtFrontOfQueue for $key - ${Thread.currentThread().name}") localCoroutineScope.launch { withContext(Dispatchers.IO) { // This needs to be done in IO because it's a blocking call - // This extra wait is needed to make sure Composition happens after resume on Espresso tests - Log.d("Sebas", "waiting resume signal for $key - ${Thread.currentThread().name}") - compositionResumedTimeout.await(COMPOSITION_RESUMED_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) // Wait for Compose lifecycle to resume and first composition to happen + // This extra wait is needed to make sure Composition happens after resume on automated tests + compositionResumedTimeout.await(COMPOSITION_RESUMED_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) } - Log.d("Sebas", "finished waiting for resume for $key - ${Thread.currentThread().name}") handler.post { - Log.d("Sebas", "post for $key - ${Thread.currentThread().name}") if (!continuation.isCompleted) { - Log.d("Sebas", "post + resume + should remove NOW for $key - ${Thread.currentThread().name}") continuation.resume(Unit) } } } } } - Log.d("Sebas", "FrameCallback posted for $key - ${Thread.currentThread().name}") Choreographer.getInstance().postFrameCallback(frameCallback) continuation.invokeOnCancellation { - Log.d("Sebas", "FrameCallback cancelled for $key - ${Thread.currentThread().name}") Choreographer.getInstance().removeFrameCallback(frameCallback) } } @@ -342,7 +327,6 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { } private fun cancelDisposal(key: String) { - Log.d("Sebas", "CANCELLING DISPOSAL OF $key - ${Thread.currentThread().name}") disposingJobs.remove(key)?.cancel() // Cancel scheduled disposal markedForDisposal.remove(key) // Un-mark for disposal in case it's not yet scheduled for disposal } @@ -353,7 +337,6 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { override fun onCleared() { // Cancel disposal jobs, all those references will be garbage collected anyway with this ViewModel disposingJobs.forEach { (_, job) -> job.cancel() } - Log.d("Sebas", "CLEARING ALL ${scopedObjectsContainer.size} OBJECTS - ${Thread.currentThread().name}") // Cancel all coroutines, Closeables and ViewModels hosted in this object val objectsToClear: MutableList = scopedObjectsContainer.values.toMutableList() while (objectsToClear.isNotEmpty()) { @@ -367,7 +350,6 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_RESUME -> { // Note Fragment View creation happens before this onResume - Log.d("Sebas", "ON_RESUME") isInForeground = true isChangingConfiguration = false // Clear this flag when the scope is resumed compositionResumedTimeout.countDown() // Signal that the first composition after resume is happening @@ -375,19 +357,16 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { } Lifecycle.Event.ON_PAUSE -> { - Log.d("Sebas", "ON_PAUSE") isInForeground = false } Lifecycle.Event.ON_DESTROY -> { // Remove ourselves so that this ViewModel can be garbage collected - Log.d("Sebas", "ON_DESTROY") source.lifecycle.removeObserver(this) compositionResumedTimeout.countDown() // Clear any pending waiting latch compositionResumedTimeout = CountDownLatch(1) // Start a new latch for the next time this ViewModel is used after resume } else -> { - Log.d("Sebas", "onSomethingElse ${event.name}") // No-Op: the other lifecycle event are irrelevant for this class } } diff --git a/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelUtils.kt b/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelUtils.kt index ad02257c..621e3d29 100644 --- a/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelUtils.kt +++ b/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelUtils.kt @@ -1,7 +1,6 @@ package com.sebaslogen.resaca import android.os.Bundle -import android.util.Log import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -40,7 +39,6 @@ internal object ScopedViewModelUtils { scopedObjectKeys: MutableMap, cancelDisposal: ((String) -> Unit) ): T { - Log.d("Sebas", "REQUEST CANCEL DISPOSAL OF ViewModel $positionalMemoizationKey - it's being requested again") cancelDisposal(positionalMemoizationKey) val originalScopedViewModelOwner: ScopedViewModelOwner? = diff --git a/resacahilt/build.gradle.kts b/resacahilt/build.gradle.kts index f3bf1b14..48b04097 100644 --- a/resacahilt/build.gradle.kts +++ b/resacahilt/build.gradle.kts @@ -14,7 +14,6 @@ android { compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { minSdk = libs.versions.minSdk.get().toInt() - targetSdk = libs.versions.targetSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } @@ -37,7 +36,7 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } - packagingOptions { + packaging { resources { excludes += setOf( // Exclude AndroidX version files diff --git a/resacakoin/build.gradle.kts b/resacakoin/build.gradle.kts index 4a965270..ecd4a954 100644 --- a/resacakoin/build.gradle.kts +++ b/resacakoin/build.gradle.kts @@ -12,7 +12,6 @@ android { compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { minSdk = libs.versions.minSdk.get().toInt() - targetSdk = libs.versions.targetSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } @@ -35,7 +34,7 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } - packagingOptions { + packaging { resources { excludes += setOf( // Exclude AndroidX version files diff --git a/sample/src/main/java/com/sebaslogen/resacaapp/sample/ui/main/ComposeActivity.kt b/sample/src/main/java/com/sebaslogen/resacaapp/sample/ui/main/ComposeActivity.kt index e67687dc..8ffb6351 100644 --- a/sample/src/main/java/com/sebaslogen/resacaapp/sample/ui/main/ComposeActivity.kt +++ b/sample/src/main/java/com/sebaslogen/resacaapp/sample/ui/main/ComposeActivity.kt @@ -2,9 +2,6 @@ package com.sebaslogen.resacaapp.sample.ui.main import android.app.Activity import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.isSystemInDarkTheme @@ -64,26 +61,8 @@ class ComposeActivity : ComponentActivity() { const val START_DESTINATION = "START_DESTINATION" } - override fun onPause() { - super.onPause() - Log.d("Sebas", "PAUSE") - } - - override fun onResume() { - super.onResume() - Log.d("Sebas", "RESUME") - } - - override fun onDestroy() { - super.onDestroy() - Log.d("Sebas", "DESTROY") - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Log.d("Sebas", "CREATE") - val handler = Handler(Looper.getMainLooper()) - handler.post { Log.d("Sebas", "Posted ON_CREATE") } setContent { ResacaAppTheme { diff --git a/sample/src/test/java/com/sebaslogen/resacaapp/sample/ClearRememberScopedObjectTests.kt b/sample/src/test/java/com/sebaslogen/resacaapp/sample/ClearRememberScopedObjectTests.kt index b9fb46e9..24a43b53 100644 --- a/sample/src/test/java/com/sebaslogen/resacaapp/sample/ClearRememberScopedObjectTests.kt +++ b/sample/src/test/java/com/sebaslogen/resacaapp/sample/ClearRememberScopedObjectTests.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.sebaslogen.resaca.COMPOSITION_RESUMED_TIMEOUT_IN_SECONDS import com.sebaslogen.resacaapp.sample.ui.main.compose.examples.DemoScopedObjectComposable import com.sebaslogen.resacaapp.sample.ui.main.data.FakeRepo import com.sebaslogen.resacaapp.sample.utils.ComposeTestUtils @@ -50,7 +51,7 @@ class ClearRememberScopedObjectTests : ComposeTestUtils { val initialAmountOfCloseableClosed = closeableClosedGloballySharedCounter.get() composablesShown = false // Trigger disposal composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfCloseableClosed = closeableClosedGloballySharedCounter.get() @@ -84,7 +85,7 @@ class ClearRememberScopedObjectTests : ComposeTestUtils { val initialAmountOfCloseablesCleared = closeableClosedGloballySharedCounter.get() composablesShown = false // Trigger disposal composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfCloseablesCleared = closeableClosedGloballySharedCounter.get() diff --git a/sample/src/test/java/com/sebaslogen/resacaapp/sample/ClearScopedViewModelTests.kt b/sample/src/test/java/com/sebaslogen/resacaapp/sample/ClearScopedViewModelTests.kt index 495a5f95..9cbdecbe 100644 --- a/sample/src/test/java/com/sebaslogen/resacaapp/sample/ClearScopedViewModelTests.kt +++ b/sample/src/test/java/com/sebaslogen/resacaapp/sample/ClearScopedViewModelTests.kt @@ -92,7 +92,7 @@ class ClearScopedViewModelTests : ComposeTestUtils { val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() composablesShown = false // Trigger disposal composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() @@ -126,7 +126,7 @@ class ClearScopedViewModelTests : ComposeTestUtils { val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() composablesShown = false // Trigger disposal composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() diff --git a/sample/src/test/java/com/sebaslogen/resacaapp/sample/hilt/ClearScopedViewModelTests.kt b/sample/src/test/java/com/sebaslogen/resacaapp/sample/hilt/ClearScopedViewModelTests.kt index 8fbc4423..89c905b9 100644 --- a/sample/src/test/java/com/sebaslogen/resacaapp/sample/hilt/ClearScopedViewModelTests.kt +++ b/sample/src/test/java/com/sebaslogen/resacaapp/sample/hilt/ClearScopedViewModelTests.kt @@ -102,7 +102,7 @@ class ClearScopedViewModelTests : ComposeTestUtils { composablesShown = false // Trigger disposal printComposeUiTreeToLog() // Required to trigger recomposition onNodeWithTestTag("Hilt FakeInjectedViewModel Scoped", assertDisplayed = false).assertDoesNotExist() - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() @@ -135,7 +135,7 @@ class ClearScopedViewModelTests : ComposeTestUtils { val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() composablesShown = false // Trigger disposal composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() @@ -168,7 +168,7 @@ class ClearScopedViewModelTests : ComposeTestUtils { val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() composablesShown = false // Trigger disposal composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() diff --git a/sample/src/test/java/com/sebaslogen/resacaapp/sample/koin/ClearScopedViewModelTests.kt b/sample/src/test/java/com/sebaslogen/resacaapp/sample/koin/ClearScopedViewModelTests.kt index abd3292c..5b61b0c1 100644 --- a/sample/src/test/java/com/sebaslogen/resacaapp/sample/koin/ClearScopedViewModelTests.kt +++ b/sample/src/test/java/com/sebaslogen/resacaapp/sample/koin/ClearScopedViewModelTests.kt @@ -93,7 +93,7 @@ class ClearScopedViewModelTests : ComposeTestUtils { composablesShown = false // Trigger disposal printComposeUiTreeToLog() // Required to trigger recomposition onNodeWithTestTag("Koin FakeInjectedViewModel Scoped", assertDisplayed = false).assertDoesNotExist() - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() @@ -126,7 +126,7 @@ class ClearScopedViewModelTests : ComposeTestUtils { val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() composablesShown = false // Trigger disposal composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() @@ -159,7 +159,7 @@ class ClearScopedViewModelTests : ComposeTestUtils { val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get() composablesShown = false // Trigger disposal composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition - advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer + advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter printComposeUiTreeToLog() val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get()