Skip to content

Fix: welcome dialog reappears on every page when the admin is served from an origin that differs from site_url#310

Merged
AllTerrainDeveloper merged 1 commit into
trunkfrom
fix-welcome-dialog
Jun 17, 2026
Merged

Fix: welcome dialog reappears on every page when the admin is served from an origin that differs from site_url#310
AllTerrainDeveloper merged 1 commit into
trunkfrom
fix-welcome-dialog

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

The first-run "Welcome to Desktop Mode" dialog is meant to appear once and
stay gone after you dismiss it. Instead, on sites where the admin is browsed
from an origin that differs from WordPress's stored site_url — an HTTPS edge
in front of an http://-configured site, a www vs non-www mismatch, a
reverse proxy, or a domain-mapped multisite — it re-appeared on every
classic-admin page load and could never be dismissed.

Root cause: the dialog persisted its dismissal by POSTing to the absolute
rest_url() URL, which is pinned to site_url(). When the page itself is
served from a different origin, that POST is cross-origin, and when the
schemes differ (HTTPS page → HTTP request) it is mixed active content,
which browsers hard-block per the W3C Mixed Content spec. The request's
.catch(function(){}) swallowed the block silently, the activation-welcome
slug was never written to the desktop_mode_seen_intros user meta, and the
gate kept rendering the dialog.

The fix re-issues the dismissal (and the "Enable it now" AJAX call) to the
origin the page was actually loaded from, where the request is same-origin
and the credentials are valid.

Root cause in detail

includes/welcome-dialog.php built its endpoints with:

$rest_url = esc_url_raw( rest_url( 'desktop-mode/v1/intros/seen' ) );
$ajax_url = esc_url_raw( admin_url( 'admin-ajax.php' ) );

Both resolve to the stored site_url() origin. When the admin is browsed from
a different origin:

  1. The page origin and the POST target origin differ.
  2. If the page is HTTPS and site_url is HTTP, the POST is mixed active
    content
    → the browser blocks it unconditionally. (A cross-origin
    credentialed POST is likewise blocked when the origins differ.)
  3. fetch(...).catch(function(){}) swallows the failure with no surface.
  4. The slug is never recorded → desktop_mode_should_show_welcome_dialog()
    keeps returning true → the dialog renders on every page.

The gate itself and the REST/persistence layer were always correct — verified
end-to-end (a request with a valid cookie + nonce + JSON body returns 200
and persists the slug). The only broken link was the client-side delivery
origin
.

This does not reproduce on a correctly-configured single-origin install, where
window.location.origin === site_url, so the absolute URL is already
same-origin — which is why a healthy site never shows the dialog twice.

The fix

includes/welcome-dialog.php (inline dialog script):

  • New sameOrigin( url ) helper rebuilds any absolute URL onto
    window.location.origin, preserving the path and query. In the normal
    single-origin case this is a no-op; under an origin mismatch it keeps the
    request same-origin.
  • persist() now sends the dismissal to sameOrigin( cfg.url ) and prefers
    navigator.sendBeacon, which:
    • is queued by the browser and survives the navigation that
      "Enable it now" triggers (no keepalive caveats), and
    • is inherently same-origin-credentialed.
      The wp_rest nonce rides along as ?_wpnonce= (REST cookie auth reads it
      from $_REQUEST), and the application/json Blob type lets the REST server
      parse the slug body param. A keepalive fetch() remains as a fallback
      for browsers without sendBeacon.
  • The "Enable it now" path POSTs to sameOrigin( cfg.ajaxUrl ).

Why the credentials still validate same-origin

  • The WordPress login cookie is domain-scoped, so it is sent to the
    browsing origin regardless of which port/scheme the request uses.
  • The wp_rest nonce is session-bound, not origin-bound, so it validates
    regardless of which origin the request lands on.
  • The cookie name (wordpress_logged_in_<COOKIEHASH>) derives from
    site_url() and is therefore stable across access origins.

Testing

  • Manual: with the admin served from an origin that differs from
    site_url (e.g. an HTTPS edge in front of an HTTP-configured site), Desktop
    Mode off, and the intro reset — dismiss with "Got it" and navigate between
    admin screens. The dialog now stays gone.
  • PHPUnit: added test_dismissal_is_sent_same_origin to
    Tests_DesktopMode_WelcomeDialog, asserting the rendered dialog routes the
    dismissal through window.location.origin and uses sendBeacon — a
    regression guard against reverting to the cross-origin POST. Full class is
    green (6/6).
  • php -l clean; npm run build green.
npm run test:php -- --filter='Tests_DesktopMode_WelcomeDialog'
# OK (6 tests, 8 assertions)

Risk / compatibility

  • No public API, hook, REST route, or payload change. This is purely a
    client-side delivery hardening inside the existing welcome dialog.
  • No behavior change on single-origin installs
    window.location.origin already equals the site_url origin there, so
    sameOrigin() returns the same URL.
  • "Reset what's-new dialogs" (DELETE /intros) is unaffected.

Files changed

  • includes/welcome-dialog.phpsameOrigin() helper; sendBeacon +
    same-origin delivery for dismissal and the enable-now AJAX call.
  • tests/phpunit/tests/welcomeDialog.php — regression test.
  • .gitignore — ignore /.scratch/
Open WordPress Playground Preview

@AllTerrainDeveloper AllTerrainDeveloper enabled auto-merge (squash) June 17, 2026 22:15
@AllTerrainDeveloper AllTerrainDeveloper merged commit 16c9b89 into trunk Jun 17, 2026
5 checks passed
@AllTerrainDeveloper AllTerrainDeveloper deleted the fix-welcome-dialog branch June 17, 2026 22:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant