Skip to content

Migrate the Domain Suggestions Screen to wordpress-rs - #23255

Merged
oguzkocer merged 17 commits into
trunkfrom
integrate-wordpress-rs-domain-suggestions-vm
Aug 25, 2026
Merged

Migrate the Domain Suggestions Screen to wordpress-rs#23255
oguzkocer merged 17 commits into
trunkfrom
integrate-wordpress-rs-domain-suggestions-vm

Conversation

@oguzkocer

@oguzkocer oguzkocer commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Description

Moves DomainSuggestionsViewModel off FluxC. Domain products and suggestions now come from WpComApiClient.products().list() and WpComApiClient.domains().suggestions() rather than ProductsStore and a newSuggestDomainsAction dispatch, so the @Subscribe handler, the EventBus registration and the Dispatcher dependency all go away. CreateCartUseCase stays on FluxC; it is shared with PurchaseDomainViewModel and SiteCreationProgressViewModel, so it moves separately.

Behaviour is meant to match trunk. The pieces worth checking:

  • empty_results is not a failure. The API reports "no domains for that search" as an HTTP error, and SiteRestClient.suggestDomains turned it back into a successful empty list. The migration does the same, so the list empties instead of raising a toast.
  • invalid_query still carries the API's message, as on trunk. This screen treats it differently from the site creation search, where FluxC gave it its own empty state.
  • The on-sale check changed units. FluxC typed Product.saleCost as a decimal amount and tested > 0.0. wordpress-rs types it as Decimal2, which holds hundredths of the currency unit — 700 is 7.00 — so the test is > 0L. The two agree, where a null check would not.
  • A superseded response is still discarded. searchQuery is captured before the request rather than read after it, which preserves the check trunk made against OnSuggestedDomains.query.

The logging changed along the way. Both failure paths now report through toLogErrorString, and the product fetch names products rather than "site domains", which it never fetched. Its success path logged the entire product catalogue through AppLog.d; that is gone.

The failure path, and the layout animation

Several departures from a straight port, all found while testing the failure paths. They were rough enough on this screen to be worth fixing here rather than filing.

An offline failure says so. Every failure produced "Domain suggestions couldn't be loaded", including the one the user can do something about — FluxC's no-connection error carried an empty message (BaseRequest.java:88-92) and the fragment fell back to that string. wordpress-rs reports DeviceIsOfflineError separately, so that case now sets errorMessageResId to error_network_connection, "Check your network connection and try again".

A failed search clears the list, and is reported once. ListState.Error is built from the previous state's data, so a rejected query left the previous query's results on screen underneath the new text — searching ... after a real search showed that search's domains as though they answered it. Results are already hidden while a request is in flight, so this stops a failure restoring them rather than introducing a new behaviour, and it matches NewDomainSearchViewModel, whose exclusive UiState replaces the list the moment a search starts.

With the list always empty on failure, the empty view carries every error — title, and the reason as its subtitle — and the toast is gone. Previously the two could contradict each other, the empty view reporting no matches while a toast reported a failure, and the toast truncated the longer API messages.

Emptying the field clears the results for the same reason: an error explaining how a query has to be written outlived the query it described. The list returns to Init, which the fragment renders as a blank content area rather than an empty list, and the selected domain is dropped so the Select button cannot stay armed over it. This applies only when the query has moved off the site name — restoring the view state after a configuration change reports the emptied field as a change too, and the retained suggestions have to survive that.

A blank query is not searched for. initializeDefaultSuggestions searches for site.name, which runs on open and again whenever the field is emptied. A site with no name made that a blank query, the API rejects a blank query as invalid_query, and so emptying the field replaced the error it was meant to clear with an identical one. A blank query now settles on Init without a request.

The screen starts at rest. The layout declared the search icon gone and the progress bar visible — the inverse of the tools:visibility on each, which say what the resting state was meant to be. The fragment only corrects them once suggestions is first assigned, so a screen that opens without issuing a search sits on a spinner with a blank list behind it. Both defaults now match the not-loading state.

The implicit layout animation is gone. Typing the first character hides the intro block and swaps the search icon for a spinner, and animateLayoutChanges animated the resulting bounds change, which is what made the screen jump while the first results loaded. The views around the header now snap. #23247 drops the same attribute from the site creation search, where the animation was also taking focus off the search field.

Testing instructions

Reach the screen: My SiteDomains → the domain call to action at the bottom (Claim your free domain, Add your domain or Get your domain, depending on the site's plan and credit).

Stop before completing a purchase. Selecting a domain on this screen creates a shopping cart.

Typing is not interrupted:

  1. Type a single character into the search field.
  • Verify you can keep typing straight away, and that the screen does not jump as the intro block gives way to the results.

A normal search:

  1. Type an ordinary word, e.g. coolsite.
  • Verify paid suggestions appear with prices, and that no free .wordpress.com suggestion is among them.
  • Verify the most relevant suggestion is at the top.
  • Verify tapping one enables the button at the bottom.
  • If any suggestion is on sale, verify the struck-through price and the sale price both render.

A query with no matches:

  1. Search for a long run of the same letter, e.g. 200 or so z characters.
  • Verify the empty view says no domains were found, with no error alongside it.

A query the API refuses, after one that worked:

  1. Search for something ordinary and let the suggestions load.
  2. Replace it with ...
  • Verify the empty view reports the failure, carrying the API's message in full as its subtitle.
  • Verify the earlier search's domains are gone rather than sitting under the new text.
  1. Delete the whole query.
  • Verify the error goes with it and the intro comes back.
  • Verify the Select button is disabled again if you had a domain selected.

Rotation:

  1. Search for something and let the suggestions load.
  2. Rotate the device.
  • Verify the suggestions are still on screen.
  1. Rotate again without having typed anything.
  • Verify they are still there.

No connection:

  1. Turn networking off, then search.
  • Verify the empty view says the suggestions couldn't be loaded, with "Check your network connection and try again" beneath it, and no toast.
  • Verify the spinner stops and the screen settles, rather than sitting on a spinner or flickering.
  1. Turn networking back on and search again.
  • Verify suggestions load.

Typing quickly:

  1. Type a word one character at a time at a normal pace, then keep typing while results are loading.
  • Verify the list that settles matches the final query rather than an earlier one.

Screenshots

Results No matches Invalid query No connection
coolsite-search-working-as-expected too-many-zzz-s-leading-to-empty-suggestion-list invalid_query no-network-while-list-is-empty

@dangermattic

dangermattic commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ PR is not assigned to a milestone.

Generated by 🚫 Danger

@wpmobilebot

wpmobilebot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

App Icon📲 You can test the changes from this Pull Request in Jetpack Android by scanning the QR code below to install the corresponding build.

App NameJetpack Android
Build TypeDebug
Versionpr23255-977af14
Build Number1498
Application IDcom.jetpack.android.prealpha
Commit977af14
Installation URL1q9fphbacjb9g
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot

wpmobilebot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

App Icon📲 You can test the changes from this Pull Request in WordPress Android by scanning the QR code below to install the corresponding build.

App NameWordPress Android
Build TypeDebug
Versionpr23255-977af14
Build Number1498
Application IDorg.wordpress.android.prealpha
Commit977af14
Installation URL398ftgdr76cbo
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.76471% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 37.81%. Comparing base (88cf3d9) to head (977af14).
⚠️ Report is 1 commits behind head on trunk.

Files with missing lines Patch % Lines
...s/android/ui/domains/DomainSuggestionsViewModel.kt 92.85% 3 Missing and 3 partials ⚠️
...press/android/util/extensions/ProductExtensions.kt 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##            trunk   #23255      +/-   ##
==========================================
+ Coverage   37.76%   37.81%   +0.04%     
==========================================
  Files        2347     2347              
  Lines      128318   128360      +42     
  Branches    17760    17770      +10     
==========================================
+ Hits        48463    48542      +79     
+ Misses      75899    75861      -38     
- Partials     3956     3957       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@oguzkocer
oguzkocer marked this pull request as ready for review August 24, 2026 01:24
@oguzkocer
oguzkocer requested a review from adalpari August 24, 2026 01:25
@oguzkocer

Copy link
Copy Markdown
Contributor Author

@adalpari I requested your review on this since you got auto-assigned to #23247 and the context from that PR should be helpful in reviewing this one. Thanks in advance 🙇‍♂️

@adalpari

Copy link
Copy Markdown
Contributor

It looks good, but there are a couple of points I think it is worth it to have a look:

  1. Data race on suggestions — DomainSuggestionsViewModel.kt:370
    suggestions is read-modify-written from three threads with no synchronization: the Debouncer executor (fetchSuggestions → Loading), the main thread (onDomainSuggestionsFetched/onDomainSuggestionSelected), and a background coroutine (showLoadingButton via createCart). If a user taps Select while a debounced response lands, the two RMW cycles race — either the fetched list is dropped or rows stay enabled during cart creation, letting the user tap another row mid-purchase. Largely latent in the pre-existing threading model, but the migration reshuffles these paths.

  2. requireNotNull(accountStore.accessToken) can crash — :110
    Throws IllegalArgumentException inside the launched coroutine if the WP.com token is null (signed-out edge state / self-hosted context). The old FluxC path needed no token. Also, the throw happens before initializeDefaultSuggestions(), so default suggestions never load either. Note: sibling NewDomainsSearchRepository uses the same pattern, so this may be an accepted WP.com-only assumption.

@adalpari adalpari left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested the changes and they look good as well.

So, other than my previous comment LGTM!

Fetch domain products and suggestions through `WpComApiClient` instead of
`ProductsStore` and the `Dispatcher`/EventBus pair. Suspend calls remove the
need for the `@Subscribe` handler and the dispatcher registration.

Changes:
- Swap `ProductsStore` and `Dispatcher` for `WpComApiClientProvider` and
  `AccountStore`
- Map the `empty_results` rejection to an empty success, as FluxC's
  `SiteRestClient.suggestDomains` did
- Carry the API's message into `ListState.Error`, and log failures with
  `toLogErrorString`
- Retarget `Product.isOnSale` at the wordpress-rs `Product`, whose `saleCost`
  holds hundredths of the currency unit rather than a decimal amount
- Extract `buildSuggestionsParams`, and cover the sale, error and superseded
  query paths
Extract `buildProductsParams` so the filter can be asserted. `request` takes
an opaque lambda, so a verify on the client counts calls without pinning
which endpoint each one reached: swapping `ProductTypeFilter.Domains` for
another filter left every test green while no suggestion would carry a sale
price.

Changes:
- Extract `buildProductsParams` and assert it selects `Domains`
- Rename `domain products are fetched only at first start` to name the
  repeat-start guarantee its call count actually covers
The observers closed over the `onDomainSelectedEvents` and `suggestionStates`
fields, which `createViewModel` reassigns. A test that builds a second view
model leaves the first one's observers writing into the second one's lists.
The fallback to `R.string.domain_suggestions_fetch_error` happens in
`DomainSuggestionsFragment`, which this test does not exercise. It asserts
that `ListState.Error` carries no message.
`suggestions` is a plain field written from the search field, the debouncer
and the response handler. FluxC pinned the handler to the main thread through
`@Subscribe(threadMode = ThreadMode.MAIN)`; the coroutine that replaced it
runs on `BG_THREAD`, so a response landing while `fetchSuggestions` is still
running can be overwritten by the stale state that thread holds. `postValue`
then coalesces the two, leaving the spinner up with no error reported.

A request that fails without reaching the network, as it does with no
connection, is fast enough to make the overlap likely.

Changes:
- Inject the `UI_THREAD` dispatcher and run `onDomainSuggestionsFetched` on it
The screen reported every failure as "Domain suggestions couldn't be loaded",
including the one the user can act on. wordpress-rs distinguishes a request
that never reached the network, so an offline search now says to check the
connection.

Changes:
- Set `errorMessageResId` to `error_network_connection` when the result is a
  `DeviceIsOfflineError`
- Prefer `errorMessageResId` over `errorMessage` in the fragment's toast
Typing the first character hides the intro block and swaps the search icon
for a spinner. `animateLayoutChanges` animated the resulting bounds change,
which is what makes the screen jump and the keyboard flicker while the first
results load.

The views around the header now snap rather than animating. #23247 drops the
attribute from the site creation search, where the same animation was taking
focus off the search field.
A failed search with nothing already on screen showed two messages at once:
the empty view said no domains were found while a toast said the suggestions
could not be loaded. The empty view is attached to the list through
`setEmptyView`, so it appears for any empty list including the one an error
leaves behind.

The failure now goes into the empty view when the list is empty, with the
reason as its subtitle, and falls back to a toast only when there are still
results on screen to keep.
The layout shipped the search icon `gone` and the progress bar visible, the
inverse of the `tools:visibility` on each. The fragment corrects both the
first time `suggestions` is assigned, so any path that reaches the screen
without issuing a search leaves the spinner running with nothing behind it.
The list answers the query in the search field, and `ListState.Error` is
built from the previous state's data. Rejecting a query therefore left the
previous query's results on screen beneath the new text, so searching `...`
after a real search showed that search's domains as though they answered it.

The results are already hidden while a request is in flight, so this stops a
failure restoring them rather than introducing a new behaviour. With the list
always empty on failure, the empty view carries every error and the toast goes
away, which also stops the longer API messages being truncated.
Emptying the field left the last result on screen, so an error explaining how
a query has to be written stayed up after that query was deleted. Clearing
now returns the list to `Init`, the state the screen opens in, and the
fragment hides the list and its empty view there as it does while loading.

The default search that follows an emptied field is unchanged, so the site's
own suggestions still come back when they were not already showing.
A site with no name gives `initializeDefaultSuggestions` nothing to search
for, on open and again whenever the field is emptied. The blank query went to
the API, which rejects it as `invalid_query`, so emptying the field replaced
the error it was meant to clear with an identical one.

A blank query now leaves the list in `Init` without a request, which is the
resting state the field's placeholder already implies.
Restoring the view state sets the search field's text, and `TextView` reports
that as a change even when the text is empty, so a rotation tells the view
model the query was cleared. Clearing reset the list to `Init`, and no fetch
follows when the query is already the site name, so the retained suggestions
were replaced by a blank screen with no way back but typing.

Emptying the field now only resets the list when the query has moved off the
site name, which is the case the reset exists for.
`fetchSuggestions` clears the selection at the end, and the blank-query guard
returns before reaching it. Emptying the field on a site with no name left
the Select button enabled over a list that had just been cleared, holding a
domain the user could no longer see, and tapping it created a cart.

The reset also moves above the request, so every write this thread makes to
`suggestions` is done before a response can arrive on another one. The
comment on the response handler claimed that as an effect of running on the
main thread, which it never was — `fetchSuggestions` runs on the debouncer's
scheduler, so the two threads overlapped either way.
`AccountStore.accessToken` is typed nullable but reads `""` when signed out,
and is only null between an in-process sign out and the next launch. A null
check therefore passed in the case it was written for and the screen sent
`Authorization: Bearer ` for the server to reject, which arrived as the same
generic failure as a dropped connection. In the case it did catch it threw
inside `launch`, where nothing handles it.

`getOrCreateClient` now returns null for either shape, matching
`FetchAllDomainsUseCase`, and both requests report it instead: the product
fetch logs and carries on, and the search shows the error the fragment
already has a string for.
`suggestions` was read-modify-written from three threads: the debouncer's
scheduler in `fetchSuggestions`, the main thread from the row taps and the
response handler, and `BG_THREAD` from `showLoadingButton` during cart
creation. Tapping Select as a response landed could interleave two of those
cycles, dropping the fetched list or leaving rows enabled mid-purchase.

`fetchSuggestions` and `showLoadingButton` now run on the main thread with
the rest. The query is still read on the debouncer thread and passed in, so
it remains the one the search was scheduled for rather than whatever the
field holds by the time the main thread gets there.
@oguzkocer
oguzkocer force-pushed the integrate-wordpress-rs-domain-suggestions-vm branch from 5de0684 to 977af14 Compare August 25, 2026 22:09
@oguzkocer

Copy link
Copy Markdown
Contributor Author

@adalpari Thank you for the review.

  1. Data race condition has been addressed in 977af14 by consolidating all suggestions update to a single thread.
  2. The access token handling has been updated in 324fe1a to match FetchAllDomainsUseCase. Unfortunately, we don't have an established pattern around token handling at the moment and we need to consolidate and centralize them. I'm not sure what the best approach is, but in both PRs you reviewed today, I had opted for crashing the app when the token is missing because the user should not be able to make it to these screens without a token. If they do, there is something seriously wrong going on and logging and continuing is worse in my opinion because it doesn't give us the necessary heads up to address it. In any case, I don't want to hold up feature PRs over this, so I implemented the softer handling here.

Since the PR is approved, I'll land it. But if you have any thoughts about these 2 commits, please let me know and I'll either address them in #23247 or open a new PR.

@oguzkocer
oguzkocer enabled auto-merge (squash) August 25, 2026 22:17
@oguzkocer
oguzkocer merged commit 05e6f6b into trunk Aug 25, 2026
23 checks passed
@oguzkocer
oguzkocer deleted the integrate-wordpress-rs-domain-suggestions-vm branch August 25, 2026 22:21
oguzkocer added a commit that referenced this pull request Aug 26, 2026
`getOrCreateClient` treats a null and an empty `accessToken` alike, which reads
as a redundant check without knowing that `AccountStore` uses the two to mean
the same thing. Documents it, matching #23247 and #23255.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants