Repository: wordpress-mobile/WordPress-Android (language: kotlin)

Issue:
🤖 Cancel stale requests on domain search screen
When searching for a new domain (to purchase) within the domain management flow, the results of multiple requests are loaded sequentially after typing. Only the latest request should be used to update the pending / loading screen.

Note that this is a separate concern from the debounce delay (used to throttle requests and reduce wasted fetches on keystrokes). Increasing the debounce delay _could_ mitigate the buggy results ux, but this has a tradeoff of increasing the total time to success. Instead, we should ensure that only the response to the latest request can update the ui state.


Discussion hints:
nan

Pull request: Cancel stale request on domain search screen
Fixes #19563

The request is canceled when it takes longer than the updated query is received. It was reached by using the flatMapLatest operator, which is a part of the Experimental Coroutines API though.

To test:
- Enable `domain_management` feature flag
- Go to Me -> Domains -> (+)
- Try to type random characters slowly (slower than the query debounce time (250ms)) but while you can see a loading stipes
- [ ] Ensure that you can see results for the very last query you typed

<details>
  <summary> Before </summary>

  https://github.com/wordpress-mobile/WordPress-Android/assets/16563318/e3626dd5-76f0-4b09-89fd-b23ab1f6949d

</details>

<details>
  <summary> After </summary>

https://github.com/wordpress-mobile/WordPress-Android/assets/16563318/e03aca49-6525-45f3-b240-b5482a81778a

</details>

## Regression Notes
1. Potential unintended areas of impact
Search for a domain screen

2. What I did to test those areas of impact (or what existing automated tests I relied on)
Manual testing / Unit testing

3. What automated tests I added (or what prevented me from doing so)
N/A

PR submission checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

UI Changes testing checklist:

- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)


Test command: ./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.domains.management.newdomainsearch.NewDomainSearchViewModelTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/newdomainsearch/NewDomainSearchViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/newdomainsearch/NewDomainSearchViewModel.kt
index 9c79f4b6a217..f1a3a1af30d2 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/newdomainsearch/NewDomainSearchViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/newdomainsearch/NewDomainSearchViewModel.kt
@@ -3,6 +3,7 @@ package org.wordpress.android.ui.domains.management.newdomainsearch
 import androidx.lifecycle.viewModelScope
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.FlowPreview
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -10,11 +11,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.debounce
 import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import org.wordpress.android.analytics.AnalyticsTracker
 import org.wordpress.android.modules.UI_THREAD
 import org.wordpress.android.ui.domains.management.newdomainsearch.domainsfetcher.NewDomainsSearchRepository
+import org.wordpress.android.ui.domains.management.newdomainsearch.domainsfetcher.NewDomainsSearchRepository.DomainsResult
 import org.wordpress.android.ui.domains.management.newdomainsearch.domainsfetcher.ProposedDomain
 import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper
 import org.wordpress.android.viewmodel.ScopedViewModel
@@ -23,7 +27,7 @@ import javax.inject.Named
 
 private const val SEARCH_QUERY_DELAY_MS = 250L
 
-@OptIn(FlowPreview::class)
+@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
 @HiltViewModel
 class NewDomainSearchViewModel @Inject constructor(
     @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher,
@@ -43,24 +47,28 @@ class NewDomainSearchViewModel @Inject constructor(
         debouncedQuery
             .filter { it.isNotBlank() }
             .debounce(SEARCH_QUERY_DELAY_MS)
-            .onEach(::fetchDomains)
+            .flatMapLatest { query -> flowOf(fetchDomains(query)) }
+            .onEach(::handleDomainsResult)
             .launchIn(viewModelScope)
     }
 
-    private suspend fun fetchDomains(query: String) {
+    private suspend fun fetchDomains(query: String): DomainsResult {
         _uiStateFlow.emit(UiState.Loading)
-        val result = newDomainsSearchRepository.searchForDomains(query)
+        return newDomainsSearchRepository.searchForDomains(query)
+    }
+
+    private suspend fun handleDomainsResult(result: DomainsResult) {
         _uiStateFlow.emit(
             when (result) {
-                is NewDomainsSearchRepository.DomainsResult.Success -> UiState.PopulatedDomains(result.proposedDomains)
-                is NewDomainsSearchRepository.DomainsResult.Error -> UiState.Error
+                is DomainsResult.Success -> UiState.PopulatedDomains(result.proposedDomains)
+                is DomainsResult.Error -> UiState.Error
             }
         )
     }
 
     fun onSearchQueryChanged(query: String) {
         launch {
-           debouncedQuery.emit(query.trim())
+            debouncedQuery.emit(query.trim())
         }
     }
 
@@ -99,6 +107,7 @@ class NewDomainSearchViewModel @Inject constructor(
             val domain: String,
             val supportsPrivacy: Boolean
         ) : ActionEvent()
+
         data class TransferDomain(val url: String) : ActionEvent()
         object GoBack : ActionEvent()
     }


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/domains/management/newdomainsearch/NewDomainSearchViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/domains/management/newdomainsearch/NewDomainSearchViewModelTest.kt
index 6298a2ee56d5..c27fdfb1fe44 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/domains/management/newdomainsearch/NewDomainSearchViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/domains/management/newdomainsearch/NewDomainSearchViewModelTest.kt
@@ -11,6 +11,7 @@ import org.junit.Test
 import org.mockito.Mock
 import org.mockito.MockitoAnnotations
 import org.mockito.kotlin.argWhere
+import org.mockito.kotlin.doSuspendableAnswer
 import org.mockito.kotlin.eq
 import org.mockito.kotlin.never
 import org.mockito.kotlin.times
@@ -89,11 +90,11 @@ class NewDomainSearchViewModelTest : BaseUnitTest() {
 
     @Test
     fun `WHEN transfer domain button pressed THEN track DOMAIN_MANAGEMENT_TRANSFER_DOMAIN_TAPPED event`() = test {
-            viewModel.onTransferDomainClicked()
-            advanceUntilIdle()
+        viewModel.onTransferDomainClicked()
+        advanceUntilIdle()
 
-            verify(analyticsTracker).track(AnalyticsTracker.Stat.DOMAIN_MANAGEMENT_TRANSFER_DOMAIN_TAPPED)
-        }
+        verify(analyticsTracker).track(AnalyticsTracker.Stat.DOMAIN_MANAGEMENT_TRANSFER_DOMAIN_TAPPED)
+    }
 
     @Test
     fun `WHEN back button pressed THEN send GoBack action event`() = testWithActionEvents { events ->
@@ -106,8 +107,9 @@ class NewDomainSearchViewModelTest : BaseUnitTest() {
     @Suppress("MaxLineLength")
     @Test
     fun `GIVEN few queries requested within 250 ms query delay WHEN onSearchQueryChanged THEN search for domains with the last query only`() =
-        test {
-            whenever(repository.searchForDomains("third")).thenReturn(DomainsResult.Success(emptyList()))
+        testWithUiStates { states ->
+            val result = listOf(ProposedDomain(0, "", "", "", true))
+            whenever(repository.searchForDomains("third")).thenReturn(DomainsResult.Success(result))
 
             viewModel.onSearchQueryChanged("first")
             delay(200)
@@ -118,16 +120,22 @@ class NewDomainSearchViewModelTest : BaseUnitTest() {
 
             verify(repository, never()).searchForDomains("first")
             verify(repository, never()).searchForDomains("second")
-            verify(repository).searchForDomains("third")
+
+            assertThat(states.first()).isEqualTo(UiState.PopulatedDomains(emptyList())) // Initial
+            assertThat(states[1]).isEqualTo(UiState.Loading)
+            assertThat(states[2]).isEqualTo(UiState.PopulatedDomains(result))
         }
 
     @Suppress("MaxLineLength")
     @Test
     fun `GIVEN few queries requested outside 250 ms delay WHEN onSearchQueryChanged THEN search for domains with all the queries`() =
-        test {
-            whenever(repository.searchForDomains("first")).thenReturn(DomainsResult.Success(emptyList()))
-            whenever(repository.searchForDomains("second")).thenReturn(DomainsResult.Success(emptyList()))
-            whenever(repository.searchForDomains("third")).thenReturn(DomainsResult.Success(emptyList()))
+        testWithUiStates { states ->
+            val firstResult = listOf(ProposedDomain(0, "", "", "", true))
+            val secondResult = listOf(ProposedDomain(1, "", "", "", true))
+            val thirdResult = listOf(ProposedDomain(2, "", "", "", true))
+            whenever(repository.searchForDomains("first")).thenReturn(DomainsResult.Success(firstResult))
+            whenever(repository.searchForDomains("second")).thenReturn(DomainsResult.Success(secondResult))
+            whenever(repository.searchForDomains("third")).thenReturn(DomainsResult.Success(thirdResult))
 
             viewModel.onSearchQueryChanged("first")
             delay(250)
@@ -136,9 +144,36 @@ class NewDomainSearchViewModelTest : BaseUnitTest() {
             viewModel.onSearchQueryChanged("third")
             advanceUntilIdle()
 
-            verify(repository).searchForDomains("first")
-            verify(repository).searchForDomains("second")
-            verify(repository).searchForDomains("third")
+            assertThat(states.first()).isEqualTo(UiState.PopulatedDomains(emptyList())) // Initial
+            assertThat(states[1]).isEqualTo(UiState.Loading)
+            assertThat(states[2]).isEqualTo(UiState.PopulatedDomains(firstResult))
+            assertThat(states[3]).isEqualTo(UiState.Loading)
+            assertThat(states[4]).isEqualTo(UiState.PopulatedDomains(secondResult))
+            assertThat(states[5]).isEqualTo(UiState.Loading)
+            assertThat(states[6]).isEqualTo(UiState.PopulatedDomains(thirdResult))
+        }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `GIVEN recent query is appeared when previous request is in progress WHEN onSearchQueryChanged THEN cancel previous request and return result for the last query only`() =
+        testWithUiStates { states ->
+            val firstResult = listOf(ProposedDomain(0, "", "", "", true))
+            val secondResult = listOf(ProposedDomain(1, "", "", "", true))
+
+            whenever(repository.searchForDomains("first")).doSuspendableAnswer {
+                delay(301)
+                DomainsResult.Success(firstResult)
+            }
+            whenever(repository.searchForDomains("second")).thenReturn(DomainsResult.Success(secondResult))
+
+            viewModel.onSearchQueryChanged("first")
+            delay(300)
+            viewModel.onSearchQueryChanged("second")
+            advanceUntilIdle()
+
+            assertThat(states.first()).isEqualTo(UiState.PopulatedDomains(emptyList())) // Initial
+            assertThat(states[1]).isEqualTo(UiState.Loading)
+            assertThat(states[2]).isEqualTo(UiState.PopulatedDomains(secondResult))
         }
 
     @Test
@@ -186,20 +221,21 @@ class NewDomainSearchViewModelTest : BaseUnitTest() {
 
     @Suppress("MaxLineLength")
     @Test
-    fun `GIVEN repeated queries with redundant blank symbols WHEN onSearchQueryChanged THEN fetch domains only once`() = test {
-        whenever(repository.searchForDomains("query")).thenReturn(DomainsResult.Success(emptyList()))
+    fun `GIVEN repeated queries with redundant blank symbols WHEN onSearchQueryChanged THEN fetch domains only once`() =
+        test {
+            whenever(repository.searchForDomains("query")).thenReturn(DomainsResult.Success(emptyList()))
 
-        viewModel.onSearchQueryChanged("query")
-        delay(250)
-        viewModel.onSearchQueryChanged(" query")
-        delay(250)
-        viewModel.onSearchQueryChanged("query ")
-        advanceUntilIdle()
+            viewModel.onSearchQueryChanged("query")
+            delay(250)
+            viewModel.onSearchQueryChanged(" query")
+            delay(250)
+            viewModel.onSearchQueryChanged("query ")
+            advanceUntilIdle()
 
-        verify(repository, times(1)).searchForDomains("query")
-        verify(repository, never()).searchForDomains(" query")
-        verify(repository, never()).searchForDomains("query ")
-    }
+            verify(repository, times(1)).searchForDomains("query")
+            verify(repository, never()).searchForDomains(" query")
+            verify(repository, never()).searchForDomains("query ")
+        }
 
     private fun testWithActionEvents(block: suspend TestScope.(events: List<ActionEvent>) -> Unit) = test {
         val actionEvents = mutableListOf<ActionEvent>()