Skip to content

Conversation

@westonruter
Copy link
Member

@westonruter westonruter commented Aug 19, 2025

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:

image

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:

--- before.html	2025-08-19 12:46:30
+++ after.html	2025-08-19 12:47:16
@@ -28,20 +28,19 @@
 			title="WordPress Develop » Sample Page Comments Feed"
 			href="http://localhost:8000/sample-page/feed/"
 		/>
-		<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: {
-					wpemoji:
-						"http:\/\/localhost:8000\/wp-includes\/js\/wp-emoji.js?ver=6.9-alpha-60093-src",
-					twemoji:
-						"http:\/\/localhost:8000\/wp-includes\/js\/twemoji.js?ver=6.9-alpha-60093-src",
-				},
-			};
+		<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": {
+					"wpemoji": "http://localhost:8000/wp-includes/js/wp-emoji.js?ver=6.9-alpha-60093-src",
+					"twemoji": "http://localhost:8000/wp-includes/js/twemoji.js?ver=6.9-alpha-60093-src"
+				}
+			}
+		</script>
+		<script type="module">
 			/**
 			 * @output wp-includes/js/wp-emoji-loader.js
 			 */
@@ -58,6 +57,13 @@
 			 * @property {?Function} readyCallback
 			 */
 
+			// For compatibility with other scripts that read from this global.
+			window._wpemojiSettings = /** @type {WPEmojiSettings} */ (
+				JSON.parse(
+					document.getElementById("wp-emoji-settings").textContent,
+				)
+			);
+
 			/**
 			 * Support tests.
 			 * @typedef SupportTests

This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@github-actions
Copy link

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • The Plugin and Theme Directories cannot be accessed within Playground.
  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

)
);

wp_print_inline_script_tag(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you consider a new function for this? Might improve adoption vs. only a new approach.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, absolutely. I'm keeping this PR a draft because I think it should reuse whatever comes out of Core-58873.

Copy link
Member

@adamsilverstein adamsilverstein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@peterwilsoncc
Copy link
Contributor

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
Copy link
Member Author

westonruter commented Aug 21, 2025

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.

It doesn't seem like the plugin is using a best practice here anyway.

@westonruter westonruter marked this pull request as ready for review September 30, 2025 03:59
@github-actions
Copy link

github-actions bot commented Sep 30, 2025

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, adamsilverstein, jonsurrell, peterwilsoncc.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@westonruter
Copy link
Member Author

@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.

@westonruter westonruter requested a review from sirreal September 30, 2025 04:08
Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

);

wp_print_inline_script_tag(
file_get_contents( ABSPATH . WPINC . '/js/wp-emoji-loader' . wp_scripts_get_suffix() . '.js' ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we append "\n//# sourceURL=…" here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I had drafted that as part of #9955

See #9955 (comment)

But it makes sense to add it here since this PR is directly about this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 2bc812d

Comment on lines 17 to 20
// For compatibility with other scripts that read from this global.
window._wpemojiSettings = /** @type {WPEmojiSettings} */ (
JSON.parse( document.getElementById( 'wp-emoji-settings' ).textContent )
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this loading as a module, there are some tweaks that could be made. This is just an observation, not a request for additional changes.

  • Waiting for a DOMContentReady promise can likely be removed.
  • The IIFE could be considered for removal.

Do you happen to know if this is registered as a script or if it's only loaded inline?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIFE removed in ad2fcfb.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In regards to the DOMContentReady, I think we actually should keep it. I realized that by putting the logic into a module the result is that the emoji detection worker task is now not running until after the DOM has fully loaded, whereas before it starts running in the HEAD when the inline script is encountered.

So what I've done now in 2792ead is I've made the script module async so that it can run in the HEAD as before, but since it is a script module it won't block the parser. (I should re-test the benchmarks with high throttling to see if there is still a performance benefit after this change.) with the move to an async module, keeping the DCL promise is important to ensure that the readyCallback doesn't fire before the DOM has finished loaded, which could happen if the emoji test worker finishes executing before the HTML finishes being sent.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you happen to know if this is registered as a script or if it's only loaded inline?

I took a quick look and it appears that the script is not registered but is only used as an inline script.

Therefore, it seems safe to switch to a module and to modify its contents accordingly.

Comment on lines -33 to -35
if ( typeof Promise === 'undefined' ) {
return;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This can be removed because all browsers that support modules will support Promise.

'//# sourceURL=' . includes_url( $emoji_loader_script_path ),
array(
'type' => 'module',
'async' => true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered making this async, but is that potentially harmful for performance? That will make it start to evaluate earlier, potentially competing with other things like classic scripts.

Is there a benefit to having it evaluate earlier?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The benefit is it allows the worker to execute in parallel while the HTML is being parsed. I'll re-test.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I see the context in #9531 (comment).

@westonruter
Copy link
Member Author

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:

--- a/src/js/_enqueues/lib/emoji-loader.js
+++ b/src/js/_enqueues/lib/emoji-loader.js
@@ -216,6 +216,7 @@ function emojiRendersEmptyCenterPoint( context, emoji ) {
  * @return {boolean} True if the browser can render emoji, false if it cannot.
  */
 function browserSupportsEmoji( context, type, emojiSetsRenderIdentically, emojiRendersEmptyCenterPoint ) {
+	return false;
 	let isIdentical;
 
 	switch ( type ) {
@@ -370,11 +371,12 @@ const domReadyPromise = new Promise( ( resolve ) => {
 
 // Obtain the emoji support from the browser, asynchronously when possible.
 new Promise( ( resolve ) => {
-	let supportTests = getSessionSupportTests();
-	if ( supportTests ) {
-		resolve( supportTests );
-		return;
-	}
+	// let supportTests = getSessionSupportTests();
+	// if ( supportTests ) {
+	// 	resolve( supportTests );
+	// 	return;
+	// }
+	let supportTests = null;
 
 	if ( supportsWorkerOffloading() ) {
 		try {

return;
}
// Obtain the emoji support from the browser, asynchronously when possible.
new Promise( ( resolve ) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to use await instead, but top-level await modules, but support for it doesn't have universal support: https://caniuse.com/mdn-javascript_operators_await_top_level

Compared with modules generally: https://caniuse.com/es6-module

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could re-introduce the IIFE as an async function if we wanted to use await

@westonruter
Copy link
Member Author

westonruter commented Oct 3, 2025

OK, I've re-run the metrics.

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

Metric 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:

Metric 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%
Raw Results

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

So in terms of LCP (and FCP), 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.

@westonruter westonruter requested a review from sirreal October 3, 2025 05:25
wp_print_inline_script_tag(
sprintf( 'window._wpemojiSettings = %s;', wp_json_encode( $settings ) ) . "\n" .
file_get_contents( ABSPATH . WPINC . '/js/wp-emoji-loader' . wp_scripts_get_suffix() . '.js' )
wp_json_encode( $settings, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, the JSON flags were missed in [60681]. Good to get it here 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside: Did you know that r has been added as an autolink reference prefix? So r60681 automatically links to the SVN changeset.

@sirreal
Copy link
Member

sirreal commented Oct 3, 2025

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 👍

Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've done some more testing to verify the functionality and I believe this works without regressions.

@westonruter
Copy link
Member Author

westonruter commented Oct 3, 2025

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

!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);

JS script byte length: 3,055 bytes

After:

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

JS script byte length: 3,672

@westonruter
Copy link
Member Author

westonruter commented Oct 3, 2025

OK, I've got it. With 589e11c 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:

--- a/src/wp-includes/formatting.php
+++ b/src/wp-includes/formatting.php
@@ -5912,7 +5912,11 @@ function print_emoji_detection_script() {
 
 	$printed = true;
 
-	_print_emoji_detection_script();
+	if ( did_action( 'wp_print_footer_scripts' ) ) {
+		_print_emoji_detection_script();
+	} else {
+		add_action( 'wp_print_footer_scripts', '_print_emoji_detection_script' );
+	}
 }
 
 /**

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
Copy link
Member Author

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

--- a/src/wp-includes/formatting.php
+++ b/src/wp-includes/formatting.php
@@ -5912,7 +5912,11 @@ function print_emoji_detection_script() {
 
 	$printed = true;
 
-	_print_emoji_detection_script();
+	if ( isset( $_GET['print_emoji_detection_script_position'] ) && 'footer' === $_GET['print_emoji_detection_script_position'] ) {
+		add_action( 'wp_print_footer_scripts', '_print_emoji_detection_script' );
+	} else {
+		_print_emoji_detection_script();
+	}
 }
 
 /**

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
Copy link
Member Author

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%

@github-actions
Copy link

github-actions bot commented Oct 4, 2025

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 60899
GitHub commit: b7b1441

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions bot closed this Oct 4, 2025
Comment on lines 430 to +431
} )
// Once the browser emoji support has been obtained from the session, finalize the settings.
.then( function ( supportTests ) {
/*
* Tests the browser support for flag emojis and other emojis, and adjusts the
* support settings accordingly.
*/
for ( var test in supportTests ) {
settings.supports[ test ] = supportTests[ test ];

settings.supports.everything =
settings.supports.everything && settings.supports[ test ];

if ( 'flag' !== test ) {
settings.supports.everythingExceptFlag =
settings.supports.everythingExceptFlag &&
settings.supports[ test ];
}
}

settings.supports.everythingExceptFlag =
settings.supports.everythingExceptFlag &&
! settings.supports.flag;

// Sets DOMReady to false and assigns a ready function to settings.
settings.DOMReady = false;
settings.readyCallback = function () {
settings.DOMReady = true;
};
} )
.then( function () {
return domReadyPromise;
} )
.then( function () {
// When the browser can not render everything we need to load a polyfill.
if ( ! settings.supports.everything ) {
settings.readyCallback();

var src = settings.source || {};

if ( src.concatemoji ) {
addScript( src.concatemoji );
} else if ( src.wpemoji && src.twemoji ) {
addScript( src.twemoji );
addScript( src.wpemoji );
}
.then( () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This secondary then() can be removed since there is no intervening domReadyPromise to wait to resolve.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5afbdcb as part of follow-up PR: #10145

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants