Skip to content

Unify editor capability detection into a single per-site state #22942

@jkmassel

Description

@jkmassel

Summary

  • Editor capability detection (EditorSettingsRepository.fetchEditorCapabilitiesForSite) runs from multiple call sites with different race profiles, and the My Site connectivity banner coordinates it against the asynchronous application-password mint by hand.
  • That coordination — a process-global event bus (ApplicationPasswordMonitor) plus polling flags — is where the recurring banner bugs live (see Fix false connectivity banner on private Atomic sites #22926).
  • Proposal: make capability detection a single per-site state, owned by one component and exposed as a StateFlow, that owns its async preconditions. Every consumer — connectivity banner, editor preloader, EditorCapabilityResolver, application-password card — becomes a subscriber that renders its slice of that state.

Background

#22883 changed the Atomic route-support probe to run REST API autodiscovery against the site's own host instead of the WP.com proxy. #22926 fixes the resulting regression — a false "Unable to connect to your site" banner on private Atomic sites, whose host gates the unauthenticated discovery request — by falling back to an authenticated app-password probe.

The core fix is small. The complexity is everything added to make it work on first login, where the application password is minted asynchronously on the My Site screen and the capability fetch races it:

  • suppress the banner while credentials are pending (isAwaitingApplicationPassword),
  • a re-detect signal when credentials are established (ApplicationPasswordMonitor),
  • re-surface the banner when the mint fails terminally (hasMintFailed).

Adversarial review of #22926 found two defects in that layer:

  1. ApplicationPasswordViewModelSlice calls onMintFailed for every non-success, including transient failures. OnApplicationPasswordCreated carries notSupported (terminal vs. transient), but it's ignored — so a transient first-login mint failure latches hasMintFailed = true and shows the exact false banner the change set out to suppress.
  2. The re-detect collector reads SelectedSiteRepository.getSelectedSite() — an in-memory instance refreshed by an async OnSiteChanged EventBus callback, with no ordering vs. the credential-established signal — so it can observe a credential-less SiteModel.

Both are symptoms of the same thing: capability detection and its preconditions are spread across components that coordinate at a distance.

Root cause

The connectivity banner uses fetchEditorCapabilitiesForSite() as a reachability probe. For Atomic sites that operation has two async preconditions provisioned elsewhere:

  • an application password (minted by ApplicationPasswordViewModelSlice / ApplicationPasswordLoginHelper), and
  • a recovered wpApiRestUrl (SiteApiRestUrlRecoverer).

The same "detect editor capabilities for this site" operation is implemented or triggered in three places, each with a different race profile:

  • SiteConnectivityBannerViewModelSlice.fetchCapabilities — for the banner; gated once-per-site.
  • GutenbergEditorPreloader.preloadIfNeeded — for editor preload; recovers wpApiRestUrl itself, then calls fetchEditorCapabilitiesForSite; gated once-per-site.
  • ApplicationPasswordViewModelSlice.healApiRestUrlIfMissing — recovers the wpApiRestUrl the other two depend on.

There is no single owner, no single result, and no single point that observes credential changes — so each consumer re-derives state and they coordinate via ApplicationPasswordMonitor.

Proposed approach

1. Separate two domains

  • Site auth / provisioning state — does this site have working application-password credentials? Owned by an application-password repository; consumed by the application-password card.
  • Editor capability state — given a usable auth context, what does the REST API support? Owned by editor-capability detection; consumed by the editor.

The connectivity banner is a derived view of both, which is why it kept tangling with the auth lifecycle.

2. Model each as a typed state, not a boolean

// Auth domain — fed by ONE FluxC observer
sealed interface SiteAuthState {
    object NotApplicable   // non-Atomic / already REST-authed
    object Provisioning    // mint in flight        <- pending, NOT failure
    object Provisioned     // creds usable
    object Unprovisionable // terminal: not supported / mint failed
}

// Capability domain — a function of (site, auth)
sealed interface EditorCapabilityState {
    object Pending                              // waiting on auth, or probing
    data class Ready(val caps: EditorCapabilities)
    object Unreachable                          // creds present, transport probe failed
    object TransientError                       // retry next resume
}

The transient-vs-terminal distinction (Provisioning vs. Unprovisionable) is now a type, so the #22926 false-banner bug is unrepresentable.

3. One detector, exposed as a StateFlow

@Singleton
class EditorCapabilityDetector @Inject constructor(
    private val auth: ApplicationPasswordRepository,   // StateFlow<SiteAuthState> per site
    private val editorSettings: EditorSettingsRepository,
) {
    fun stateFor(site: SiteModel): StateFlow<EditorCapabilityState> // cached, shared, deduped
    fun refresh(siteId: Int)                                        // PTR / retry
    fun clear()                                                     // wire into sign-out reset
}

The flow is the notification — late subscribers and config changes get the current value for free. This replaces ApplicationPasswordMonitor (no replay buffer, no failed-id set), the two ad-hoc once-per-site caches, the duplicate wpApiRestUrl heal, and the getSelectedSite() re-read (one FluxC observer; no consumer touches SiteModel staleness).

4. Consumers become subscribers

Combined state Connectivity banner Application-password card Editor
auth=Provisioning, or cap=Pending/TransientError hidden hidden wait
auth=Unprovisionable hidden auth prompt
cap=Unreachable show hidden
cap=Ready hidden hidden use caps

SiteConnectivityBannerViewModelSlice, GutenbergEditorPreloader, EditorCapabilityResolver, and the application-password card all collect from the same source.

Why this is better

Migration plan

  1. Fix false connectivity banner on private Atomic sites #22926 (release/26.8): land the minimal fix — authenticated fallback + isAwaitingApplicationPassword suppression — and roll back the ApplicationPasswordMonitor hardening. Low-risk regression fix.
  2. Introduce EditorCapabilityState + EditorCapabilityDetector; migrate SiteConnectivityBannerViewModelSlice to collect it.
  3. Migrate GutenbergEditorPreloader and EditorCapabilityResolver onto the same source (removes the duplicate probe + heal); migrate the application-password card onto the auth StateFlow.
  4. Wire EditorCapabilityDetector.clear() into the existing sign-out reset (AppInitializer.removeWpComUserRelatedData, which already calls clearAllClients()).

Out of scope

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions