Skip to content

Offline indicator latches permanently when the initial connectivity probe fails #525

Description

@jkmassel

Summary

OfflineIndicator runs a one-shot connectivity probe on mount when navigator.onLine === true. If that probe rejects — most plausibly because the WP REST root for a Jetpack-tunneled site takes longer than the hard 5s timeout to respond on first hit — setIsConnected(false) latches and the "Working Offline" bar stays up indefinitely. The only path back to connected is a browser online event, which is fired exclusively on a real offline → online transition. If navigator.onLine never went to false, the event never fires, the probe never re-runs, and the editor displays "Working Offline" while the network is perfectly fine.

Repro

  1. Editor on a Jetpack-connected self-hosted site whose JP tunnel happens to cold-start slowly (or any condition where the first HEAD to siteApiRoot exceeds 5s).

  2. Open the editor. "Working Offline" bar appears.

  3. From DevTools attached to the WebView:

    ({navigatorOnLine: navigator.onLine, probeUrl: window.GBKit.siteApiRoot})

    {navigatorOnLine: true, probeUrl: "https://<site>/wp-json/"}. The browser thinks it's online; the URL is correct.

  4. Manually re-run the probe with the same parameters as probeConnectivity():

    await fetch(window.GBKit.siteApiRoot, {method:'HEAD', mode:'no-cors', cache:'no-store', signal: AbortSignal.timeout(5000)});

    → resolves in ~1.6s. Connectivity is fine now; it wasn't at mount.

  5. Force a re-probe:

    window.dispatchEvent(new Event('online'));

    → bar disappears immediately. Confirms the state is latched, not the network.

Verified on org.wordpress.gutenbergkit:android:v0.17.2 in the WordPress-Android jetpack.wpmt.co test site editor.

Root Cause

src/components/offline-indicator/index.jsx, useNetworkConnectivity:

useEffect( () => {
    const abortController = new AbortController();

    if ( navigator.onLine ) {
        probeConnectivity().then( ( connected ) => {
            if ( ! abortController.signal.aborted ) {
                setIsConnected( connected );
            }
        } );
    }

    const handleOnline = async () => {  };       // only fires on offline → online
    const handleOffline = () => setIsConnected( false );

    window.addEventListener( 'online', handleOnline );
    window.addEventListener( 'offline', handleOffline );
    
}, [] );

A single probe drives the only setIsConnected(true) path on mount. Recovery only happens via online, which Chrome dispatches strictly on a real network-interface transition. navigator.onLine staying true the whole time means there's nothing to dispatch.

The 5s timeout is treated as hard authority. A cold REST root or a momentary DNS lag is enough to flip the state and there's no path back.

Suggested Fix

Several non-exclusive options:

  1. Soft initial timeout / retry-once before latching. If the first probe rejects with TimeoutError, retry once after a short delay (1–2s) before committing setIsConnected(false). Cheap and catches the common cold-start case.

  2. Periodic re-probe while latched offline. A modest backoff timer (e.g. 5s → 15s → 30s capped) that only runs while isConnected === false self-heals the latch without trusting the browser event. No cost while online.

  3. Re-probe on user-driven signals. visibilitychange returning to visible is a strong "the user just looked at the editor" cue; pageshow, focus on the WebView, or a debounced input/scroll handler would catch the cases where the user has clearly resumed activity. Cheap and well-bounded.

  4. Probe interpretation: distinguish "couldn't reach API root" from "no network at all." If the probe rejects with a network error vs. a timeout vs. an HTTP-style error, those imply different recovery strategies. A TypeError from fetch (no network interface) is much more likely to require an online event. A TimeoutError is much more likely to require retry.

(1) alone would have prevented this incident. (2) is the most robust against future regressions of this shape.

Affected

  • All consumers of OfflineIndicator (i.e., every site loaded in GutenbergKit). Worst case where the indicator persists is sites with a non-trivial cold-path to siteApiRoot — Jetpack-connected self-hosted sites in particular.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions