Make WordPress Core

Opened 2 months ago

Closed 2 weeks ago

Last modified 19 hours ago

#63842 closed enhancement (fixed)

Emoji detection inline script introduces needless parser/render blocking

Reported by: westonruter's profile westonruter Owned by: westonruter's profile westonruter
Milestone: 6.9 Priority: normal
Severity: normal Version: 4.6
Component: Emoji Keywords: has-patch has-unit-tests
Focuses: javascript, performance Cc:

Description (last modified by westonruter)

The inline script used for emoji detection, output in the head by _print_emoji_detection_script(), needlessly adds parser/render blocking to the page. If emoji loader already waits for DOMContentLoaded before it proceeds to load the add load the wp-emoji.js and twemoji.js scripts. This means the inline script need not be in the head at all. It could be moved to the footer, and it would also be better if it were added as an inline script module so that its execution is deferred until the DOM has loaded. Similarly, the emoji settings object exported from PHP need not be added as an inline script but it can instead be output as JSON (as is the method employed for script modules) to be parsed when the emoji detection script module executes.

This is closely related to #58472 which eliminated a long task in the emoji loader which caused significant render blocking on low-tier devices.

Change History (27)

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


2 months ago
#1

  • Keywords has-patch added

Trac ticket: https://core.trac.wordpress.org/ticket/63842

This splits up the inline script output in _print_emoji_detection_script() into two:

  1. A script of type application/json which contains the _wpemojiSettings data.
  2. A script of type module which contains wp-emoji-loader.js, which parses _wpemojiSettings out of the JSON.

The result is this inline script is eliminated from blocking the HTML parser from processing the page (and rendering the page) while waiting for the JavaScript to execute. The wp-emoji-loader.js script does not need to run in the head because it does not actually proceed with loading emoji (if needed) until DOMContentLoaded.

I used the `benchmark-web-vitals` command from GoogleChromeLabs/wpp-research to analyze the performance impact of this on a vanilla WordPress install on the Sample Page. For example:

npm run research -- benchmark-web-vitals --url http://localhost:8000/sample-page/  --output=csv --number=100

On a high-end machine (e.g. MacBook Pro with M4 Pro chip), the difference is negligible:

URL Before After Diff (ms) Diff (%)
FCP (median) 94.75 94.3 -0.45 -0.47%
LCP (median) 102.2 101.8 -0.4 -0.39%
TTFB (median) 58.1 58 -0.1 -0.17%
LCP-TTFB (median) 43.5 43.7 0.2 0.46%

However, when the CPU is throttled (via --throttle-cpu) to emulate a low-tier mobile phone, then there is a clear impact. I used CPU throttle factor of 20 because this value is close to how Chrome DevTools calibrates my CPU to emulate such a device:

https://github.com/user-attachments/assets/a9e41f6a-ebdf-491d-b21d-59a4258ba711

The command I run on trunk and again on this branch:

npm run research -- benchmark-web-vitals --url http://localhost:8000/sample-page/  --output=csv --number=100 --throttle-cpu=20

The results show a >5% improvement to LCP:

URL Before After Diff (ms) Diff (%)
FCP (median) 322.6 301.35 -21.25 -6.59%
LCP (median) 411.5 388.35 -23.15 -5.63%
TTFB (median) 58.85 59.35 0.5 0.85%
LCP-TTFB (median) 353.55 329.85 -23.7 -6.70%

This is the diff or a rendered page with Prettier formatting applied and SCRIPT_DEBUG enabled:

  • .html

    old new  
    2828                        title="WordPress Develop » Sample Page Comments Feed"
    2929                        href="http://localhost:8000/sample-page/feed/"
    3030                />
    31                 <script>
    32                         window._wpemojiSettings = {
    33                                 baseUrl:
    34                                         "https:\/\/s.w.org\/images\/core\/emoji\/16.0.1\/72x72\/",
    35                                 ext: ".png",
    36                                 svgUrl: "https:\/\/s.w.org\/images\/core\/emoji\/16.0.1\/svg\/",
    37                                 svgExt: ".svg",
    38                                 source: {
    39                                         wpemoji:
    40                                                 "http:\/\/localhost:8000\/wp-includes\/js\/wp-emoji.js?ver=6.9-alpha-60093-src",
    41                                         twemoji:
    42                                                 "http:\/\/localhost:8000\/wp-includes\/js\/twemoji.js?ver=6.9-alpha-60093-src",
    43                                 },
    44                         };
     31                <script id="wp-emoji-settings" type="application/json">
     32                        {
     33                                "baseUrl": "https://s.w.org/images/core/emoji/16.0.1/72x72/",
     34                                "ext": ".png",
     35                                "svgUrl": "https://s.w.org/images/core/emoji/16.0.1/svg/",
     36                                "svgExt": ".svg",
     37                                "source": {
     38                                        "wpemoji": "http://localhost:8000/wp-includes/js/wp-emoji.js?ver=6.9-alpha-60093-src",
     39                                        "twemoji": "http://localhost:8000/wp-includes/js/twemoji.js?ver=6.9-alpha-60093-src"
     40                                }
     41                        }
     42                </script>
     43                <script type="module">
    4544                        /**
    4645                         * @output wp-includes/js/wp-emoji-loader.js
    4746                         */
     
    5857                         * @property {?Function} readyCallback
    5958                         */
    6059
     60                        // For compatibility with other scripts that read from this global.
     61                        window._wpemojiSettings = /** @type {WPEmojiSettings} */ (
     62                                JSON.parse(
     63                                        document.getElementById("wp-emoji-settings").textContent,
     64                                )
     65                        );
     66
    6167                        /**
    6268                         * Support tests.
    6369                         * @typedef SupportTests

#2 @westonruter
2 months ago

  • Description modified (diff)
  • Focuses javascript added
  • Owner set to westonruter
  • Status changed from new to accepted

#3 @westonruter
2 months ago

  • Description modified (diff)
  • Keywords needs-testing needs-unit-tests added

#4 @westonruter
2 months ago

  • Description modified (diff)

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


2 months ago

#6 @westonruter
2 months ago

Here's a polyfill plugin that implements this change for testing and early access: https://gist.github.com/westonruter/6b201c637e40d6d3b865e5cf7548c59e

@peterwilsoncc commented on PR #9531:


2 months ago
#7

I've noticed a back-compat issue for scripts that depend on the global settings object.

add_action( 'wp_enqueue_scripts', function() {
        // Script that uses _wpemojiSettings. Something GDPR maybe?
        wp_register_script( 'pwcc-no-deps-head', false, [], '1.0' );
        wp_enqueue_script( 'pwcc-no-deps-head' );
        wp_add_inline_script( 'pwcc-no-deps-head', 'console.log( "pwcc-no-deps-head", window._wpemojiSettings  );' );

        wp_register_script( 'pwcc-no-deps-foot', false, [], '1.0', true );
        wp_enqueue_script( 'pwcc-no-deps-foot' );
        wp_add_inline_script( 'pwcc-no-deps-foot', 'console.log( "pwcc-no-deps-foot", window._wpemojiSettings  );' );
} );

On trunk both of these scripts log the settings object, on this branch they both log undefined .

Searching the plugin repo for _wpemojiSettings shows that it's referenced in WooCommerce Payments, 700K active sites in the file registered by this block of code, so I think we'll need some care if this is to change.

A few of the other results appear to be false positives.

@westonruter commented on PR #9531:


2 months ago
#8

Here's where _wpemojiSettings is referenced in the plugin:

https://github.com/Automattic/woocommerce-payments/blob/7d119265c70a452def2eccc11ef4f1729f904f16/includes/multi-currency/client/blocks/currency-switcher.js#L143

Specifically it's I'm a block's edit function in the editor, which would surely be executed after DOMContentLoaded, so it doesn't seem like it would be a problem.

This ticket was mentioned in Slack in #core-test by oglekler. View the logs.


7 weeks ago

#10 @oglekler
7 weeks ago

  • Keywords needs-testing removed

I am removing needs-testing due to this comment: https://github.com/WordPress/wordpress-develop/pull/9531/files#r2288862656 with relation to #58873

Let's wait until the patch is really ready. Thank you!

@westonruter commented on PR #9531:


3 weeks ago
#11

@peterwilsoncc:

I've noticed a back-compat issue for scripts that depend on the global settings object.

In regards to other plugins which may be directly referencing the _wpemojiSettings global (ill-advisedly), I think the best approach here will be to check to see if it is set, and if not, read it from the JSON script. Note that WooCommerce is already checking for whether or not the global exists:

/**
                 * WP Emoji replaces the flag emoji with an image if it's not natively
                 * supported by the browser. This behavior is problematic on Windows
                 * because it renders an <img> tag inside the <option>, which can lead to crashes.
                 * We need to guarantee that the OS supports flag emojis before rendering it.
                 */
                const supportsFlagEmoji = window._wpemojiSettings
                        ? window._wpemojiSettings.supports?.flag
                        : true;

So it could simply be modified to do something like:

let wpEmojiSettings = window._wpemojiSettings;
if ( ! wpEmojiSettings ) {
    const settingsScript = document.getElementById( 'wp-emoji-settings' );
    if ( settingsScript ) {
        wpEmojiSettings = JSON.parse( settingsScript.text );
    }
}

const supportsFlagEmoji = wpEmojiSettings
        ? wpEmojiSettings.supports?.flag
        : true;

Note that it is likely that any script attempting to access window._wpemojiSettings would have run after the module had been evaluated anyway, because modules execute before DOMContentLoaded. So the above back-compat code would likely not be needed anyway.

I think any compatibility issues can be addressed by a dev note and outreach.

#12 @westonruter
3 weeks ago

  • Keywords has-unit-tests added; needs-unit-tests removed

I've marked this as ready for review.

Note there are two related tickets that will touch this code:

  • #58873: Add function to pass variables to scripts (where a new API could be leveraged to print the JSON script).
  • #63887: Add sourceURL to inline scripts and styles (namely this PR).

I don't see these as blockers, however, since this PR is closest to completion. They'll need to account for the change here, however.

@westonruter commented on PR #9531:


3 weeks ago
#13

This seems like a nice improvement. I tested and didn't find any issues, but I don't actually know how to test the functionality of wpemoji. Do you know what behaviors I should look for? I'm happy to come back and confirm that things are working as expected.

@sirreal Good question. I usually resort to patching the JS to force Twemoji to run and to never use the sessionStorage cache, for example with the following change applied to this PR:

  • src/js/_enqueues/lib/emoji-loader.js

    a b function emojiRendersEmptyCenterPoint( context, emoji ) { 
    216216 * @return {boolean} True if the browser can render emoji, false if it cannot.
    217217 */
    218218function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ) {
     219        return false;
    219220        let isIdentical;
    220221
    221222        switch ( type ) {
    const domReadyPromise = new Promise( ( resolve ) => { 
    370371
    371372// Obtain the emoji support from the browser, asynchronously when possible.
    372373new Promise( ( resolve ) => {
    373         let supportTests = getSessionSupportTests();
    374         if ( supportTests ) {
    375                 resolve( supportTests );
    376                 return;
    377         }
     374        // let supportTests = getSessionSupportTests();
     375        // if ( supportTests ) {
     376        //      resolve( supportTests );
     377        //      return;
     378        // }
     379        let supportTests = null;
    378380
    379381        if ( supportsWorkerOffloading() ) {
    380382                try {

@westonruter commented on PR #9531:


3 weeks ago
#14

OK, I've re-run the metrics.

First, the re-obtaining results comparing trunk (classic blocking inline script) with an inline script module (deferred):

URL | Before | After | Diff (ms) | Diff (%)
-- | --: | --: | --: | --:
FCP (median) | 322.4 | 300.5 | -21.9 | -6.79%
LCP (median) | 407.4 | 391.9 | -15.6 | -3.82%
TTFB (median) | 58.4 | 59.0 | 0.6 | 1.11%
LCP-TTFB (median) | 348.7 | 333.4 | -15.4 | -4.40%

And then here is are the results comparing trunk with an inline script module with async:

URL | Before | After | Diff (ms) | Diff (%)
-- | --: | --: | --: | --:
FCP (median) | 322.4 | 323.0 | 0.6 | 0.20%
LCP (median) | 407.4 | 403.1 | -4.3 | -1.07%
TTFB (median) | 58.4 | 58.4 | 0.0 | 0.09%
LCP-TTFB (median) | 348.7 | 343.4 | -5.3 | -1.52%

<details><summary>Raw Results</summary>

These results were obtained via multiple calls to with different states of wordpress-develop checked out:

npm run research -- benchmark-web-vitals --url http://localhost:8000/sample-page/?enable_plugins=none  --output=csv --number=100 --throttle-cpu=20

trunk:

URL,http://localhost:8000/sample-page/?enable_plugins=none
Success Rate,100%
FCP (median),322.35
LCP (median),407.4
TTFB (median),58.35
LCP-TTFB (median),348.7

module (with defer):

URL,http://localhost:8000/sample-page/?enable_plugins=none
Success Rate,100%
FCP (median),300.45
LCP (median),391.85
TTFB (median),59
LCP-TTFB (median),333.35

module with async:

URL,http://localhost:8000/sample-page/?enable_plugins=none
Success Rate,100%
FCP (median),323
LCP (median),403.05
TTFB (median),58.4
LCP-TTFB (median),343.4

</details>

So in terms of LCP, it does seem like it would be better to actually remove async, which naturally makes sense. And it's logical to do as well, since it is extremely unlikely that an emoji will end up being the LCP element. Therefore, it makes sense to me that we should actually _not_ use async, and go ahead and let the emoji detection run after the DOM has loaded. Note that the emoji tests are cached in sessionStorage as well, which means subsequent page loads won't have to wait for the worker.

@jonsurrell commented on PR #9531:


3 weeks ago
#15

Therefore, it makes sense to me that we should actually not use async

Agreed. The fact that the script waited for DOMContentLoaded also suggests it was a good candidate for defer or the default module behavior like in this PR 👍

@westonruter commented on PR #9531:


3 weeks ago
#16

I just noticed an issue: because the IIFE was removed, the minification process wasn't able to reduce the length of the top-level symbols because it isn't aware that it is a module.

# Before

<script>
window._wpemojiSettings = {"baseUrl":"https:\/\/s.w.org\/images\/core\/emoji\/16.0.1\/72x72\/","ext":".png","svgUrl":"https:\/\/s.w.org\/images\/core\/emoji\/16.0.1\/svg\/","svgExt":".svg","source":{"concatemoji":"https:\/\/make.wordpress.org\/wp-includes\/js\/wp-emoji-release.min.js?ver=6.9-alpha-60888"}};
/*! This file is auto-generated */
!function(s,n){var o,i,e;function c(e){try{var t={supportTests:e,timestamp:(new Date).valueOf()};sessionStorage.setItem(o,JSON.stringify(t))}catch(e){}}function p(e,t,n){e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(t,0,0);var t=new Uint32Array(e.getImageData(0,0,e.canvas.width,e.canvas.height).data),a=(e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(n,0,0),new Uint32Array(e.getImageData(0,0,e.canvas.width,e.canvas.height).data));return t.every(function(e,t){return e===a[t]})}function u(e,t){e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(t,0,0);for(var n=e.getImageData(16,16,1,1),a=0;a<n.data.length;a++)if(0!==n.data[a])return!1;return!0}function f(e,t,n,a){switch(t){case"flag":return n(e,"\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f","\ud83c\udff3\ufe0f\u200b\u26a7\ufe0f")?!1:!n(e,"\ud83c\udde8\ud83c\uddf6","\ud83c\udde8\u200b\ud83c\uddf6")&&!n(e,"\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f","\ud83c\udff4\u200b\udb40\udc67\u200b\udb40\udc62\u200b\udb40\udc65\u200b\udb40\udc6e\u200b\udb40\udc67\u200b\udb40\udc7f");case"emoji":return!a(e,"\ud83e\udedf")}return!1}function g(e,t,n,a){var r="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?new OffscreenCanvas(300,150):s.createElement("canvas"),o=r.getContext("2d",{willReadFrequently:!0}),i=(o.textBaseline="top",o.font="600 32px Arial",{});return e.forEach(function(e){i[e]=t(o,e,n,a)}),i}function t(e){var t=s.createElement("script");t.src=e,t.defer=!0,s.head.appendChild(t)}"undefined"!=typeof Promise&&(o="wpEmojiSettingsSupports",i=["flag","emoji"],n.supports={everything:!0,everythingExceptFlag:!0},e=new Promise(function(e){s.addEventListener("DOMContentLoaded",e,{once:!0})}),new Promise(function(t){var n=function(){try{var e=JSON.parse(sessionStorage.getItem(o));if("object"==typeof e&&"number"==typeof e.timestamp&&(new Date).valueOf()<e.timestamp+604800&&"object"==typeof e.supportTests)return e.supportTests}catch(e){}return null}();if(!n){if("undefined"!=typeof Worker&&"undefined"!=typeof OffscreenCanvas&&"undefined"!=typeof URL&&URL.createObjectURL&&"undefined"!=typeof Blob)try{var e="postMessage("+g.toString()+"("+[JSON.stringify(i),f.toString(),p.toString(),u.toString()].join(",")+"));",a=new Blob([e],{type:"text/javascript"}),r=new Worker(URL.createObjectURL(a),{name:"wpTestEmojiSupports"});return void(r.onmessage=function(e){c(n=e.data),r.terminate(),t(n)})}catch(e){}c(n=g(i,f,p,u))}t(n)}).then(function(e){for(var t in e)n.supports[t]=e[t],n.supports.everything=n.supports.everything&&n.supports[t],"flag"!==t&&(n.supports.everythingExceptFlag=n.supports.everythingExceptFlag&&n.supports[t]);n.supports.everythingExceptFlag=n.supports.everythingExceptFlag&&!n.supports.flag,n.DOMReady=!1,n.readyCallback=function(){n.DOMReady=!0}}).then(function(){return e}).then(function(){var e;n.supports.everything||(n.readyCallback(),(e=n.source||{}).concatemoji?t(e.concatemoji):e.wpemoji&&e.twemoji&&(t(e.twemoji),t(e.wpemoji)))}))}((window,document),window._wpemojiSettings);
</script>

JS script byte length: 3,399 bytes

# After:

<script id="wp-emoji-settings" type="application/json">
{"baseUrl":"https://s.w.org/images/core/emoji/16.0.1/72x72/","ext":".png","svgUrl":"https://s.w.org/images/core/emoji/16.0.1/svg/","svgExt":".svg","source":{"concatemoji":"http://localhost:8000/wp-includes/js/wp-emoji-release.min.js?ver=6.9-alpha-60093-src"}}
</script>
<script type="module">
/*! This file is auto-generated */
const settings=JSON.parse(document.getElementById("wp-emoji-settings").textContent),sessionStorageKey=(window._wpemojiSettings=settings,"wpEmojiSettingsSupports"),tests=["flag","emoji"];function supportsWorkerOffloading(){return"undefined"!=typeof Worker&&"undefined"!=typeof OffscreenCanvas&&"undefined"!=typeof URL&&URL.createObjectURL&&"undefined"!=typeof Blob}function getSessionSupportTests(){try{var t=JSON.parse(sessionStorage.getItem(sessionStorageKey));if("object"==typeof t&&"number"==typeof t.timestamp&&(new Date).valueOf()<t.timestamp+604800&&"object"==typeof t.supportTests)return t.supportTests}catch(t){}return null}function setSessionSupportTests(t){try{var e={supportTests:t,timestamp:(new Date).valueOf()};sessionStorage.setItem(sessionStorageKey,JSON.stringify(e))}catch(t){}}function emojiSetsRenderIdentically(t,e,s){t.clearRect(0,0,t.canvas.width,t.canvas.height),t.fillText(e,0,0);e=new Uint32Array(t.getImageData(0,0,t.canvas.width,t.canvas.height).data);t.clearRect(0,0,t.canvas.width,t.canvas.height),t.fillText(s,0,0);const n=new Uint32Array(t.getImageData(0,0,t.canvas.width,t.canvas.height).data);return e.every((t,e)=>t===n[e])}function emojiRendersEmptyCenterPoint(t,e){t.clearRect(0,0,t.canvas.width,t.canvas.height),t.fillText(e,0,0);var s=t.getImageData(16,16,1,1);for(let t=0;t<s.data.length;t++)if(0!==s.data[t])return!1;return!0}function browserSupportsEmoji(t,e,s,n){switch(e){case"flag":return s(t,"\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f","\ud83c\udff3\ufe0f\u200b\u26a7\ufe0f")?!1:!s(t,"\ud83c\udde8\ud83c\uddf6","\ud83c\udde8\u200b\ud83c\uddf6")&&!s(t,"\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f","\ud83c\udff4\u200b\udb40\udc67\u200b\udb40\udc62\u200b\udb40\udc65\u200b\udb40\udc6e\u200b\udb40\udc67\u200b\udb40\udc7f");case"emoji":return!n(t,"\ud83e\udedf")}return!1}function testEmojiSupports(t,e,s,n){let r;const o=(r="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?new OffscreenCanvas(300,150):document.createElement("canvas")).getContext("2d",{willReadFrequently:!0}),i=(o.textBaseline="top",o.font="600 32px Arial",{});return t.forEach(t=>{i[t]=e(o,t,s,n)}),i}function addScript(t){var e=document.createElement("script");e.src=t,e.defer=!0,document.head.appendChild(e)}settings.supports={everything:!0,everythingExceptFlag:!0},new Promise(e=>{let s=getSessionSupportTests();if(!s){if(supportsWorkerOffloading())try{var t="postMessage("+testEmojiSupports.toString()+"("+[JSON.stringify(tests),browserSupportsEmoji.toString(),emojiSetsRenderIdentically.toString(),emojiRendersEmptyCenterPoint.toString()].join(",")+"));",n=new Blob([t],{type:"text/javascript"});const r=new Worker(URL.createObjectURL(n),{name:"wpTestEmojiSupports"});return void(r.onmessage=t=>{setSessionSupportTests(s=t.data),r.terminate(),e(s)})}catch(t){}setSessionSupportTests(s=testEmojiSupports(tests,browserSupportsEmoji,emojiSetsRenderIdentically,emojiRendersEmptyCenterPoint))}e(s)}).then(t=>{for(const e in t)settings.supports[e]=t[e],settings.supports.everything=settings.supports.everything&&settings.supports[e],"flag"!==e&&(settings.supports.everythingExceptFlag=settings.supports.everythingExceptFlag&&settings.supports[e]);settings.supports.everythingExceptFlag=settings.supports.everythingExceptFlag&&!settings.supports.flag,settings.DOMReady=!1,settings.readyCallback=()=>{settings.DOMReady=!0}}).then(()=>{var t;settings.supports.everything||(settings.readyCallback(),(t=settings.source||{}).concatemoji?addScript(t.concatemoji):t.wpemoji&&t.twemoji&&(addScript(t.twemoji),addScript(t.wpemoji)))});
//# sourceURL=http://localhost:8000/wp-includes/js/wp-emoji-loader.min.js
</script>

JS script byte length: 3,708

@westonruter commented on PR #9531:


3 weeks ago
#17

OK, I've got it. With 589e11c31ab1ae47a2ea80ba0c282a2828bd7150 I've forced UglifyJS to consider the wp-emoji-loader.js to be a module and to minify the top-level symbols. Now the result is 3,009 bytes, so even a tiny bit smaller than the original non-module JS at 3,055 bytes:

const n=JSON.parse(document.getElementById("wp-emoji-settings").textContent),o=(window._wpemojiSettings=n,"wpEmojiSettingsSupports"),s=["flag","emoji"];function i(e){try{var t={supportTests:e,timestamp:(new Date).valueOf()};sessionStorage.setItem(o,JSON.stringify(t))}catch(e){}}function c(e,t,n){e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(t,0,0);t=new Uint32Array(e.getImageData(0,0,e.canvas.width,e.canvas.height).data);e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(n,0,0);const a=new Uint32Array(e.getImageData(0,0,e.canvas.width,e.canvas.height).data);return t.every((e,t)=>e===a[t])}function p(e,t){e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(t,0,0);var n=e.getImageData(16,16,1,1);for(let e=0;e<n.data.length;e++)if(0!==n.data[e])return!1;return!0}function u(e,t,n,a){switch(t){case"flag":return n(e,"\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f","\ud83c\udff3\ufe0f\u200b\u26a7\ufe0f")?!1:!n(e,"\ud83c\udde8\ud83c\uddf6","\ud83c\udde8\u200b\ud83c\uddf6")&&!n(e,"\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f","\ud83c\udff4\u200b\udb40\udc67\u200b\udb40\udc62\u200b\udb40\udc65\u200b\udb40\udc6e\u200b\udb40\udc67\u200b\udb40\udc7f");case"emoji":return!a(e,"\ud83e\udedf")}return!1}function f(e,t,n,a){let r;const o=(r="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?new OffscreenCanvas(300,150):document.createElement("canvas")).getContext("2d",{willReadFrequently:!0}),s=(o.textBaseline="top",o.font="600 32px Arial",{});return e.forEach(e=>{s[e]=t(o,e,n,a)}),s}function t(e){var t=document.createElement("script");t.src=e,t.defer=!0,document.head.appendChild(t)}n.supports={everything:!0,everythingExceptFlag:!0},new Promise(t=>{let n=function(){try{var e=JSON.parse(sessionStorage.getItem(o));if("object"==typeof e&&"number"==typeof e.timestamp&&(new Date).valueOf()<e.timestamp+604800&&"object"==typeof e.supportTests)return e.supportTests}catch(e){}return null}();if(!n){if("undefined"!=typeof Worker&&"undefined"!=typeof OffscreenCanvas&&"undefined"!=typeof URL&&URL.createObjectURL&&"undefined"!=typeof Blob)try{var e="postMessage("+f.toString()+"("+[JSON.stringify(s),u.toString(),c.toString(),p.toString()].join(",")+"));",a=new Blob([e],{type:"text/javascript"});const r=new Worker(URL.createObjectURL(a),{name:"wpTestEmojiSupports"});return void(r.onmessage=e=>{i(n=e.data),r.terminate(),t(n)})}catch(e){}i(n=f(s,u,c,p))}t(n)}).then(e=>{for(const t in e)n.supports[t]=e[t],n.supports.everything=n.supports.everything&&n.supports[t],"flag"!==t&&(n.supports.everythingExceptFlag=n.supports.everythingExceptFlag&&n.supports[t]);n.supports.everythingExceptFlag=n.supports.everythingExceptFlag&&!n.supports.flag,n.DOMReady=!1,n.readyCallback=()=>{n.DOMReady=!0}}).then(()=>{var e;n.supports.everything||(n.readyCallback(),(e=n.source||{}).concatemoji?t(e.concatemoji):e.wpemoji&&e.twemoji&&(t(e.twemoji),t(e.wpemoji)))});
//# sourceURL=http://localhost:8000/wp-includes/js/wp-emoji-loader.min.js

Aside: We should separately look into moving this script module to be printed at wp_footer instead:

  • src/wp-includes/formatting.php

    a b function print_emoji_detection_script() { 
    59125912
    59135913        $printed = true;
    59145914
    5915         _print_emoji_detection_script();
     5915        add_action( 'wp_print_footer_scripts', '_print_emoji_detection_script' );
    59165916}
    59175917
    59185918/**

This would cut out the following 3,403 bytes of HTML from being needlessly in the head, ensuring that what is actually critical (i.e. CSS) is parsed while the document is getting streamed (cc @dmsnell):

<script id="wp-emoji-settings" type="application/json">
{"baseUrl":"https://s.w.org/images/core/emoji/16.0.1/72x72/","ext":".png","svgUrl":"https://s.w.org/images/core/emoji/16.0.1/svg/","svgExt":".svg","source":{"concatemoji":"http://localhost:8000/wp-includes/js/wp-emoji-release.min.js?ver=6.9-alpha-60093-src"}}
</script>
<script type="module">
/*! This file is auto-generated */
const n=JSON.parse(document.getElementById("wp-emoji-settings").textContent),o=(window._wpemojiSettings=n,"wpEmojiSettingsSupports"),s=["flag","emoji"];function i(e){try{var t={supportTests:e,timestamp:(new Date).valueOf()};sessionStorage.setItem(o,JSON.stringify(t))}catch(e){}}function c(e,t,n){e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(t,0,0);t=new Uint32Array(e.getImageData(0,0,e.canvas.width,e.canvas.height).data);e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(n,0,0);const a=new Uint32Array(e.getImageData(0,0,e.canvas.width,e.canvas.height).data);return t.every((e,t)=>e===a[t])}function p(e,t){e.clearRect(0,0,e.canvas.width,e.canvas.height),e.fillText(t,0,0);var n=e.getImageData(16,16,1,1);for(let e=0;e<n.data.length;e++)if(0!==n.data[e])return!1;return!0}function u(e,t,n,a){switch(t){case"flag":return n(e,"\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f","\ud83c\udff3\ufe0f\u200b\u26a7\ufe0f")?!1:!n(e,"\ud83c\udde8\ud83c\uddf6","\ud83c\udde8\u200b\ud83c\uddf6")&&!n(e,"\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f","\ud83c\udff4\u200b\udb40\udc67\u200b\udb40\udc62\u200b\udb40\udc65\u200b\udb40\udc6e\u200b\udb40\udc67\u200b\udb40\udc7f");case"emoji":return!a(e,"\ud83e\udedf")}return!1}function f(e,t,n,a){let r;const o=(r="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?new OffscreenCanvas(300,150):document.createElement("canvas")).getContext("2d",{willReadFrequently:!0}),s=(o.textBaseline="top",o.font="600 32px Arial",{});return e.forEach(e=>{s[e]=t(o,e,n,a)}),s}function t(e){var t=document.createElement("script");t.src=e,t.defer=!0,document.head.appendChild(t)}n.supports={everything:!0,everythingExceptFlag:!0},new Promise(t=>{let n=function(){try{var e=JSON.parse(sessionStorage.getItem(o));if("object"==typeof e&&"number"==typeof e.timestamp&&(new Date).valueOf()<e.timestamp+604800&&"object"==typeof e.supportTests)return e.supportTests}catch(e){}return null}();if(!n){if("undefined"!=typeof Worker&&"undefined"!=typeof OffscreenCanvas&&"undefined"!=typeof URL&&URL.createObjectURL&&"undefined"!=typeof Blob)try{var e="postMessage("+f.toString()+"("+[JSON.stringify(s),u.toString(),c.toString(),p.toString()].join(",")+"));",a=new Blob([e],{type:"text/javascript"});const r=new Worker(URL.createObjectURL(a),{name:"wpTestEmojiSupports"});return void(r.onmessage=e=>{i(n=e.data),r.terminate(),t(n)})}catch(e){}i(n=f(s,u,c,p))}t(n)}).then(e=>{for(const t in e)n.supports[t]=e[t],n.supports.everything=n.supports.everything&&n.supports[t],"flag"!==t&&(n.supports.everythingExceptFlag=n.supports.everythingExceptFlag&&n.supports[t]);n.supports.everythingExceptFlag=n.supports.everythingExceptFlag&&!n.supports.flag,n.DOMReady=!1,n.readyCallback=()=>{n.DOMReady=!0}}).then(()=>{var e;n.supports.everything||(n.readyCallback(),(e=n.source||{}).concatemoji?t(e.concatemoji):e.wpemoji&&e.twemoji&&(t(e.twemoji),t(e.wpemoji)))});
//# sourceURL=http://localhost:8000/wp-includes/js/wp-emoji-loader.min.js
</script>

@westonruter commented on PR #9531:


3 weeks ago
#18

With the changes in this PR, I applied this patch:

  • src/wp-includes/formatting.php

    a b function print_emoji_detection_script() { 
    59125912
    59135913        $printed = true;
    59145914
    5915         _print_emoji_detection_script();
     5915        if ( isset( $_GET['print_emoji_detection_script_position'] ) && 'footer' === $_GET['print_emoji_detection_script_position'] ) {
     5916                add_action( 'wp_print_footer_scripts', '_print_emoji_detection_script' );
     5917        } else {
     5918                _print_emoji_detection_script();
     5919        }
    59165920}
    59175921
    59185922/**

I then obtained the metrics for 100 requests for the script being printed in wp_head vs wp_footer over a Fast 4G emulated connection:

npm run research -- benchmark-web-vitals --url="http://localhost:8000/sample-page/?enable_plugins=none&print_emoji_detection_script_position=head" --url="http://localhost:8000/sample-page/?enable_plugins=none&print_emoji_detection_script_position=footer" --output=md --number=100 --network-conditions="Fast 4G" --diff

The results show a modest yet clear ~1% improvement to LCP and ~2% improvement to FCP:

Metric wp_head wp_footer Diff (ms) Diff (%)
:----------------------------:--------------: --------: -------:
FCP (median) 429.1 421.4 -7.70 -1.8%
LCP (median) 526.3 519.9 -6.40 -1.2%
TTFB (median) 59.35 58.5 -0.85 -1.4%
LCP-TTFB (median) 466.35 461.8 -4.55 -1.0%

@westonruter commented on PR #9531:


3 weeks ago
#19

I did for same while emulating Slow 3G and got the following results, 100 for wp_head and 100 for wp_footer. Still an improvement although about half as much as Fast 4G:

Metric wp_head wp_footer Diff (ms) Diff (%)
:----------------------------:------------:----------: -------:
FCP (median) 4902.4 4870.9 -31.55 -0.6%
LCP (median) 4997.2 4972.9 -24.30 -0.5%
TTFB (median) 60.6 60.6 +0.1 +0.1%
LCP-TTFB (median) 4936.1 4912.7 -23.40 -0.5%

#20 @westonruter
3 weeks ago

  • Resolution set to fixed
  • Status changed from accepted to closed

In 60899:

Emoji: Convert the emoji loader from an inline blocking script to a (deferred) script module.

This modernizes the emoji loader script by converting it from a blocking inline script with an IIFE to a script module. Using a script module improves the performance of the FCP and LCP metrics since it does not block the HTML parser. Since script modules are deferred and run immediately before DOMContentLoaded, the logic to wait until that event is also now removed. Additionally, since the script is loaded as a module, it has been modernized to use const, let, and arrow functions. The sourceURL comment is also added. See #63887.

The emoji settings are now passed via a separate script tag with a type of application/json, following the new "pull" paradigm introduced for exporting data from PHP to script modules. See #58873. The JSON data is also now encoded in a more resilient way according to #63851. When the wp-emoji-loader.js script module executes, it continues to populate the window._wpemojiSettings global for backwards-compatibility for any extensions that may be using it.

A new uglify:emoji-loader grunt task is added which ensures wp-includes/js/wp-emoji-loader.js is processed as a module and that top-level symbols are minified.

Follow-up to [60681].

Props westonruter, jonsurrell, adamsilverstein, peterwilsoncc.
See #63851, #63887.
Fixes #63842.

#21 follow-up: @wildworks
2 weeks ago

I think this change caused errors related to script modules on Firefox. Based on my research, I believe this change also caused E2E tests related to navigation block to start failing in the Gutenberg project.

See: https://github.com/WordPress/gutenberg/actions/workflows/end2end-test.yml

Now that the emoji loader is loaded via a script module, the source code looks like this:

<script type="module">
/**
 * @output wp-includes/js/wp-emoji-loader.js
 */
...
</script>

<script type="importmap" id="wp-importmap">
{"imports":{"@wordpress/interactivity":"http://localhost:8889/wp-includes/js/dist/script-modules/interactivity/debug.js?ver=844167190aa4a0e411f0"}}
</script>

<script type="module" src="http://localhost:8889/wp-includes/js/dist/script-modules/block-library/navigation/view.js?ver=52594d8ac17824d31d5e" id="@wordpress/block-library/navigation/view-js-module" fetchpriority="low"></script>

As you can see, the import map is defined after the emoji loader.

My understanding is that script modules are treated more strictly in Firefox than in Chrome, so running a script module before the import map will result in an error.

If you launch the latest wordpress-develop in Firefox, you should be able to see the error in the browser console.

I think we can enqueue _wpemojiSettings with wp_print_inline_script_tag, but we may be able to simply use wp_enqueue_script_module for wp-emoji-loader.js.

Last edited 2 weeks ago by wildworks (previous) (diff)

This ticket was mentioned in Slack in #core-editor by wildworks. View the logs.


2 weeks ago

#23 @swissspidy
2 weeks ago

  • Resolution fixed deleted
  • Status changed from closed to reopened

#24 in reply to: ↑ 21 @westonruter
2 weeks ago

Replying to wildworks:

As you can see, the import map is defined after the emoji loader.

@wildworks Oh! Thank you for pointing this out. This should actually be fixed with #64076 by moving the script to be printed in the footer. Can you test? (I can check in a few hours otherwise.)

Last edited 2 weeks ago by westonruter (previous) (diff)

#25 @westonruter
2 weeks ago

I can confirm that PR #10145 (for #64076) fixes the issue in Firefox in both block themes and classic themes by ensuring that the emoji loader script module is printed after the importmap script.

(I corrected the PR link.)

Last edited 2 weeks ago by westonruter (previous) (diff)

#26 @westonruter
2 weeks ago

  • Resolution set to fixed
  • Status changed from reopened to closed

In 60902:

Emoji: Move printing of emoji loader script module from wp_head to wp_print_footer_scripts.

This removes ~3KB of HTML from the critical rendering path of markup in the head, thus marginally improving FCP/LCP in slower connections. It also fixes a Firefox issue with script modules by ensuring the emoji loader script module is printed after the importmap.

Existing plugins that disable emoji by unhooking the action as follows will continue to work as expected:

remove_action( 'wp_head', 'print_emoji_detection_script', 7 );

Additionally, some obsolete DOMReady and readyCallback logic was removed. A script module (as it has a deferred execution) only ever executes when the DOM is fully loaded. This means there was no need for a DOMContentLoaded event which was removed in [60899], and the remaining ready detection logic can be removed.

Follow-up to [60899].

Developed in https://github.com/WordPress/wordpress-develop/pull/10145.

Props westonruter, wildworks.
Fixes #63842.
Fixes #64076.

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


19 hours ago

Note: See TracTickets for help on using tickets.