[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