Opened 3 weeks ago
Last modified 3 days ago
#65103 new defect (bug)
Single-page admin screens (Connectors, Font Library) fail to mount in Chrome when @wordpress/boot wins a race against its classic-script deps
| Reported by: |
|
Owned by: | |
|---|---|---|---|
| Milestone: | 7.0 | Priority: | high |
| Severity: | normal | Version: | trunk |
| Component: | Script Loader | Keywords: | script-modules has-patch |
| Focuses: | administration | Cc: |
Description
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:
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:
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:
// 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/bootis delivered off the CDN almost instantly after modulepreload, so the module evaluates before the parser reacheswp-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
- 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.
- Open Chrome.
- Visit
/wp-admin/options-connectors.phpor the Font Library admin page. - Hard-reload a few times.
- 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:
- Wrap the inline call in
DOMContentLoaded.DOMContentLoadedfires only after all parser-blocking classic scripts have executed, sowp.theme.privateApisis guaranteed to be set by the time boot evaluates. One-line change, no build change:
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 )
)
- 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 ascript_loader_tagfilter.
- Move
initSinglePageintoloader.js.loader.jsis already registered as a script module with@wordpress/bootas 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 smallwindow.*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.
Change History (12)
This ticket was mentioned in PR #11611 on WordPress/wordpress-develop by @khokansardar.
3 weeks ago
#2
- Keywords has-patch added
#3
@
3 weeks ago
Tested this issue in Chrome and was able to reproduce the problem where the admin screen (Font Library/Connectors) does not mount properly.
It appears to be caused by @wordpress/boot executing before its dependencies are fully loaded, leading to a race condition during initialization.
After apply the patch, the issue is resolved and the screens load consistently. I did not notice any regressions in other admin pages during testing.
@westonruter commented on PR #11611:
3 weeks ago
#4
These files are marked "Auto-generated by build process. Do not edit this file manually." The generator lives in Gutenberg's build pipeline. A search of the local Gutenberg checkout finds no matching template string, so the
wordpress-developcopies are effectively the current source of truth — but the Gutenberg-side template should receive the same change (or the next sync from Gutenberg will silently revert this). Happy to open a follow-up Gutenberg PR once this lands.
The change shouldn't be done in core here, but rather in Gutenberg.
The related code is located here: https://github.com/WordPress/gutenberg/blob/9083dd07b5705f281aff2a68f647f62b3cdf0868/packages/wp-build/templates/page-wp-admin.php.template#L156-L164
#5
@
3 weeks ago
- Keywords changes-requested added
Let's avoid committing minified JS and lean into modern JS syntax (e.g. const and async/await).
For example, using the JS code in the PR, this is much easier to read and debug, thanks to IDEs being able to do syntax checking (although Trac doesn't do syntax highlighting of the JS):
<?php $init_js_function = <<<'JS' ( mountId, routes ) => { const run = async () => { const mod = await import( "@wordpress/boot" ); mod.initSinglePage( { mountId, routes } ); }; if ( document.readyState === "loading" ) { document.addEventListener( "DOMContentLoaded", run ); } else { run(); } } JS; wp_add_inline_script( $mount_id . '-prerequisites', sprintf( '( %s )( %s, %s );', $init_js_function, wp_json_encode( $mount_id, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) ) );
This ticket was mentioned in Slack in #core by juanmaguitar. View the logs.
3 weeks ago
#7
follow-up:
↓ 8
@
6 days ago
- Priority changed from normal to high
As this issues means that a new 7.0 feature is broken, I am bumping up the prioirty.
@khokansardar are you able to follow up on the changes that @westonruter suggested above?
#8
in reply to:
↑ 7
@
3 days ago
hey @jorbin I've added the changes here - https://github.com/WordPress/wordpress-develop/pull/11611/changes/94a8ed5f4f6a99e3651b36d1a298652794a37b6d Could you please take a look. Replying to jorbin:
As this issues means that a new 7.0 feature is broken, I am bumping up the prioirty.
@khokansardar are you able to follow up on the changes that @westonruter suggested above?
#9
@
3 days ago
@khokansardar These changes need to be made in the Gutenberg repo, as far as I know.
This is the template code needing to be modified: https://github.com/WordPress/gutenberg/blob/5fc7223e96b2751c57b6c4ae840bb9e838bee9f0/packages/wp-build/templates/page-wp-admin.php.template#L156-L164
Note that you can also now indent the PHP nowdoc strings with the flexible syntax thanks to WordPress increasing the minimum PHP version to 7.4.
So you can indent consistently like this, adding indents to your patch:
<?php /* * Add inline script to initialize the app using initSinglePage (no menuItems). * The dynamic import is deferred until DOMContentLoaded so that all classic * script dependencies of @wordpress/boot (wp-private-apis, wp-components, * wp-theme, etc.) have finished parsing and executing before the boot module * evaluates. Otherwise, a modulepreloaded @wordpress/boot can win the race * against the classic-script-printing pass on fast CDN-fronted hosts in * Chrome, evaluating before wp.theme.privateApis is defined and throwing * "Cannot unlock an undefined object". See #65103. */ $init_js_function = <<<'JS' ( mountId, routes ) => { const run = async () => { const mod = await import( "@wordpress/boot" ); mod.initSinglePage( { mountId, routes } ); }; if ( document.readyState === "loading" ) { document.addEventListener( "DOMContentLoaded", run ); } else { run(); } } JS;
@westonruter commented on PR #11611:
3 days ago
#10
I've ported the changes over to a Gutenberg PR: https://github.com/WordPress/gutenberg/pull/78136
#11
@
3 days ago
- Keywords has-test-info added; changes-requested removed
I've adapted the changes over to a Gutenberg PR: https://github.com/WordPress/gutenberg/pull/78136
@fabiankaegy Can you test that to see if it fixes the issue you observed?
## Trac ticket
https://core.trac.wordpress.org/ticket/65103
## Summary
Fixes a race condition that prevents the Connectors (
/wp-admin/options-connectors.php) and Font Library admin screens from mounting in Chrome on fast-CDN-fronted hosts (reproducible on WordPress VIP). The failure surfaces as an empty mount div plusUncaught Error: Cannot unlock an undefined objectin the console.## Root cause
The four auto-generated files below register a
-prerequisitesclassic-script handle with an emptysrcand attach an inline script that does a dynamic ESM import:wp_register_script( '…-prerequisites', '', $asset['dependencies'], $asset['version'], true ); wp_add_inline_script( '…-prerequisites', 'import("@wordpress/boot").then(mod => mod.init({…}));' );Because the handle has no
src, only the inline is printed, and it runs as a classic<script>the instant the HTML parser reaches it.@wordpress/bootis<link rel="modulepreload">-ed in<head>. On a fast CDN the bundle is effectively free, so the dynamic import resolves and the module evaluates before the parser has reached the classic deps it relies on (wp-private-apis,wp-components,wp-theme).At its top-level
@wordpress/bootreadswindow.wp.theme.privateApis, which is stillundefinedat that point, sounlock(undefined)throws andinitSinglePage/initnever runs.Only Chrome + fast CDN reliably loses the race; Firefox/Safari schedule module eval slightly later, and local dev is slow enough that the classic deps usually finish first.
## The fix (Option 1 from the ticket)
Wrap the dynamic import in
DOMContentLoaded. The HTML spec guaranteesDOMContentLoadedfires only after every parser-blocking classic<script>has executed, sowp.theme.privateApis(and the rest) are populated before@wordpress/bootevaluates. Adocument.readyState === "loading"guard preserves behavior when the inline is ever re-run after DOM ready (e.g. AJAX-injected contexts).Before:
After:
## Files changed
All four auto-generated files that share this pattern:
src/wp-includes/build/pages/options-connectors/page.php(init)src/wp-includes/build/pages/options-connectors/page-wp-admin.php(initSinglePage)src/wp-includes/build/pages/font-library/page.php(init)src/wp-includes/build/pages/font-library/page-wp-admin.php(initSinglePage)Each file's
wp_add_inline_script(…)call is updated, plus an explanatory comment referencing Trac #65103. No registration logic, dependency lists, script module graph, or style registration is touched.## Why not the other options from the ticket
wp_add_inline_script_module()API or ascript_loader_tagfilter — out of scope for a bugfix.initSinglePageintoloader.js) would change the public module contract, require a coordinated Gutenberg-side change, and migrate every downstream consumer of the pattern.## Follow-up note for committers
These files are marked "Auto-generated by build process. Do not edit this file manually." The generator lives in Gutenberg's build pipeline. A search of the local Gutenberg checkout finds no matching template string, so the
wordpress-developcopies are effectively the current source of truth — but the Gutenberg-side template should receive the same change (or the next sync from Gutenberg will silently revert this). Happy to open a follow-up Gutenberg PR once this lands.## Test plan
/wp-admin/options-connectors.phpand the Font Library admin screen. Confirm the app mounts and "Cannot unlock an undefined object" is not logged.document.readyStateguard: visit the page, then in the console re-evaluate the inline body and verifymod.init/mod.initSinglePageis still called exactly once (behavior preserved for late-arriving scripts).## Related
wp_register_script('-prerequisites', '', …)+ inlineimport("@wordpress/boot")pattern has the same bug.