Make WordPress Core

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: fabiankaegy's profile fabiankaegy 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/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:
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 )
)
  1. 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.
  1. 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.

  • 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)

#1 @westonruter
3 weeks ago

  • Milestone changed from Awaiting Review to 7.0

This ticket was mentioned in PR #11611 on WordPress/wordpress-develop by @khokansardar.


3 weeks ago
#2

  • Keywords has-patch added

## 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 plus Uncaught Error: Cannot unlock an undefined object in the console.

## Root cause

The four auto-generated files below register a -prerequisites classic-script handle with an empty src and 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/boot is <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/boot reads window.wp.theme.privateApis, which is still undefined at that point, so unlock(undefined) throws and initSinglePage / init never 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 guarantees DOMContentLoaded fires only after every parser-blocking classic <script> has executed, so wp.theme.privateApis (and the rest) are populated before @wordpress/boot evaluates. A document.readyState === "loading" guard preserves behavior when the inline is ever re-run after DOM ready (e.g. AJAX-injected contexts).

Before:

import("@wordpress/boot").then(mod => mod.initSinglePage({mountId: "…", routes: [...]}));

After:

(function(){
  var run = function(){
    import("@wordpress/boot").then(function(mod){
      mod.initSinglePage({mountId: "…", routes: [...]});
    });
  };
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", run);
  } else {
    run();
  }
})();

## 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

  • Option 2 (emit the inline as a module script) would require a new wp_add_inline_script_module() API or a script_loader_tag filter — out of scope for a bugfix.
  • Option 3 (move initSinglePage into loader.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-develop copies 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

  • [ ] Manual: on a CloudFront-fronted dev environment in Chrome (or with DevTools Network throttling set to simulate CDN on module responses only), load /wp-admin/options-connectors.php and the Font Library admin screen. Confirm the app mounts and "Cannot unlock an undefined object" is not logged.
  • [ ] Manual: hard-reload several times to exercise the race window.
  • [ ] Manual: repeat in Firefox and Safari to confirm no regression.
  • [ ] Confirm document.readyState guard: visit the page, then in the console re-evaluate the inline body and verify mod.init / mod.initSinglePage is still called exactly once (behavior preserved for late-arriving scripts).

## Related

  • Not a regression — the pattern worked everywhere the race was too tight to fire. Surfaces only in fast-CDN + Chrome setups.
  • Connectors screen landed in 7.0; Font Library uses the same generator output.
  • Any downstream consumer using the same wp_register_script('-prerequisites', '', …) + inline import("@wordpress/boot") pattern has the same bug.

#3 @tusharaddweb
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-develop copies 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 @westonruter
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 )
        )
);
Last edited 2 weeks ago by westonruter (previous) (diff)

This ticket was mentioned in Slack in #core by juanmaguitar. View the logs.


3 weeks ago

#7 follow-up: @jorbin
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 @khokansardar
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 @westonruter
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 @westonruter
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?

#12 @westonruter
3 days ago

  • Keywords has-test-info removed
Note: See TracTickets for help on using tickets.