[wp-trac] [WordPress Trac] #65103: Single-page admin screens (Connectors, Font Library) fail to mount in Chrome when @wordpress/boot wins a race against its classic-script deps

WordPress Trac noreply at wordpress.org
Mon Apr 20 10:25:12 UTC 2026


#65103: Single-page admin screens (Connectors, Font Library) fail to mount in
Chrome when @wordpress/boot wins a race against its classic-script deps
----------------------------+-----------------------------
 Reporter:  fabiankaegy     |      Owner:  (none)
     Type:  defect (bug)    |     Status:  new
 Priority:  normal          |  Milestone:  Awaiting Review
Component:  Script Loader   |    Version:  trunk
 Severity:  normal          |   Keywords:  script-modules
  Focuses:  administration  |
----------------------------+-----------------------------
 == What happened ==

 On a fast CDN-fronted host (reproducible on WordPress VIP) in Chrome, the
 Connectors screen at `/wp-admin/options-connectors.php` never mounts. The
 `<div id="options-connectors-wp-admin-app">` stays empty and the console
 shows:

 {{{
 Uncaught Error: Cannot unlock an undefined object.
     at k (wp-includes/js/dist/private-apis.min.js)
     at wp-includes/js/dist/script-modules/boot/index.min.js:1:34623
 }}}

 The Font Library admin screen, and any plugin/theme using the same
 `wp_register_*_wp_admin_*` pattern generated into `wp-
 includes/build/pages/*`, are affected for the same reason.

 Not reproducible locally, and not reproducible in Firefox/Safari often
 enough to notice. The race window only opens reliably when the boot module
 lands before the parser reaches the classic deps.

 == Root cause ==

 File: `wp-includes/build/pages/options-connectors/page-wp-admin.php`
 (auto-generated from the Gutenberg build). The same code is emitted for
 `page.php` and for `wp-includes/build/pages/font-library/*`.

 Lines 157-164:

 {{{#!php
 wp_add_inline_script(
     'options-connectors-wp-admin-prerequisites',
     sprintf(
         'import("@wordpress/boot").then(mod =>
 mod.initSinglePage({mountId: "%s", routes: %s}));',
         'options-connectors-wp-admin-app',
         wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
     )
 );
 }}}

 The prerequisites handle is registered with an empty src:

 {{{#!php
 wp_register_script(
     'options-connectors-wp-admin-prerequisites',
     '',
     $asset['dependencies'],
     $asset['version'],
     true
 );
 }}}

 Because the handle has no src, WP prints only the attached inline-before
 /inline-after scripts. They render as classic `<script>` tags (no
 `type="module"`), so they run immediately when the HTML parser reaches
 them.

 On the rendered page (WP 7.1-alpha, build 62246) the order is:

 ||= DOM position =||= Script =||
 || 51 || `<script type="importmap">` ||
 || 52 || inline `<script type="module">` (core tmpl-attachment) ||
 || 53 || `<script type="module" src=".../loader.js">` ||
 || **57** || **`<script id="options-connectors-wp-admin-prerequisites-js-
 after">` with `import("@wordpress/boot").then(initSinglePage)`** ||
 || 88 || `<script id="wp-private-apis-js">` ||
 || 94 || `<script id="wp-components-js">` ||
 || 110 || `<script id="wp-theme-js">` ||

 The classic deps (`wp-private-apis`, `wp-components`, `wp-theme`) declare
 themselves via `boot/index.min.asset.php['dependencies']`, but they print
 in the standard classic-script-printing pass, which runs **after** the
 script-module printing pass that carries the empty-src handle's inline
 scripts. So the inline `import()` fires at DOM order 57, while `wp-theme-
 js` has not yet been parsed.

 `@wordpress/boot` is preloaded via `<link rel="modulepreload">`. On a fast
 CDN the bundle is effectively free, so the dynamic import resolves and
 evaluates the boot module before the parser has reached the classic deps
 at DOM positions 88/94/110.

 At its top-level, boot accesses the global set up by those deps:

 {{{#!js
 // boot/index.min.js
 var ja = i(ko(), 1);                       // ja = window.wp.theme
 var Kr = x(ja.privateApis).ThemeProvider;  //
 unlock(window.wp.theme.privateApis)
 }}}

 `window.wp.theme` exists as `{}` (created by the core global bootstrap),
 but `wp.theme.privateApis` is `undefined` because `wp-theme.min.js` has
 not executed yet. `unlock(undefined)` throws, `initSinglePage` never runs,
 and the mount stays empty.

 == Why only Chrome + fast CDN ==

 * On VIP, `@wordpress/boot` is delivered off the CDN almost instantly
 after modulepreload, so the module evaluates before the parser reaches
 `wp-theme-js`.
 * Locally, the module fetch is slow enough relative to the parser that the
 classic deps usually finish first.
 * Firefox and Safari schedule module evaluation slightly later than Chrome
 and do not lose the race as consistently.

 == Repro ==

 1. Any environment where modulepreloaded script modules are served fast
 (VIP, CloudFront in front of a build, etc.) on WP 7.0+ for Connectors /
 7.1-alpha for both affected screens.
 2. Open Chrome.
 3. Visit `/wp-admin/options-connectors.php` or the Font Library admin
 page.
 4. Hard-reload a few times.
 5. Expected: the app mounts.
    Actual: the mount div stays empty and the console reports "Cannot
 unlock an undefined object".

 == Suggested fixes ==

 Any of these would close the race. Listed from least invasive to most:

 1. **Wrap the inline call in `DOMContentLoaded`.** `DOMContentLoaded`
 fires only after all parser-blocking classic scripts have executed, so
 `wp.theme.privateApis` is guaranteed to be set by the time boot evaluates.
 One-line change, no build change:

 {{{#!php
 sprintf(
     '(function(){var
 i=function(){import("@wordpress/boot").then(function(m){m.initSinglePage({mountId:%s,routes:%s});});};if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",i);}else{i();}})();',
     wp_json_encode( 'options-connectors-wp-admin-app' ),
     wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
 )
 }}}

 2. **Emit the inline as a module script.** Module scripts are deferred by
 the HTML spec and wait for parsing to finish. This would need either
 `wp_add_inline_script_module()` (does not exist yet) or a
 `script_loader_tag` filter.

 3. **Move `initSinglePage` into `loader.js`.** `loader.js` is already
 registered as a script module with `@wordpress/boot` as a static dep. If
 the init call lived there, evaluation order would be guaranteed by the
 module graph. Routes and mount ID would pass via a small `window.*` data
 blob set by an inline-before.

 Option 1 is the minimal safe fix and matches what we applied in a
 downstream project-level copy of the pattern with success.

 == Related ==

 * Not a regression per se: the pattern worked everywhere the race has been
 too tight to fire. Surfaces only in fast-CDN + Chrome setups.
 * Connectors screen landed in 7.0. Font Library uses the same generator
 output in `wp-includes/build/pages/font-library/`.
 * Any downstream consumer that followed the same
 `wp_register_script('-prerequisites', '', ...)` +
 `wp_add_inline_script('import("@wordpress/boot")...')` pattern has the
 same bug.

-- 
Ticket URL: <https://core.trac.wordpress.org/ticket/65103>
WordPress Trac <https://core.trac.wordpress.org/>
WordPress publishing platform


More information about the wp-trac mailing list