Make WordPress Core

Changeset 56074


Ignore:
Timestamp:
06/27/2023 05:22:59 PM (13 months ago)
Author:
westonruter
Message:

Emoji: Optimize emoji loader with sessionStorage, willReadFrequently, and OffscreenCanvas.

  • Use sessionStorage to remember the previous results of calls to browserSupportsEmoji() for 1 week.
  • Optimize reading from canvas by supplying the willReadFrequently option for the 2D context.
  • When OffscreenCanvas is available, offload browserSupportsEmoji() to a web worker to prevent blocking the main thread. This is of primary benefit to Safari which does not yet support willReadFrequently.
  • Remove obsolete support for IE11 since promises are now utilized. Nevertheless, ES3 syntax is maintained and IE11 will simply short-circuit.

Props westonruter, dmsnell, peterwilsoncc, valterlorran, flixos90, spacedmonkey, joemcgill.
Fixes #58472.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/.jshintrc

    r56049 r56074  
    2525        "export": false,
    2626        "module": false,
    27         "require": false
     27        "require": false,
     28        "WorkerGlobalScope": false,
     29        "self": false,
     30        "OffscreenCanvas": false,
     31        "Promise": false
    2832    }
    2933}
  • trunk/src/js/_enqueues/lib/emoji-loader.js

    r55241 r56074  
    33 */
    44
    5 ( function( window, document, settings ) {
    6     var src, ready, ii, tests;
    7 
    8     // Create a canvas element for testing native browser support of emoji.
    9     var canvas = document.createElement( 'canvas' );
    10     var context = canvas.getContext && canvas.getContext( '2d' );
     5/**
     6 * Emoji Settings as exported in PHP via _print_emoji_detection_script().
     7 * @typedef WPEmojiSettings
     8 * @type {object}
     9 * @property {?object} source
     10 * @property {?string} source.concatemoji
     11 * @property {?string} source.twemoji
     12 * @property {?string} source.wpemoji
     13 * @property {?boolean} DOMReady
     14 * @property {?Function} readyCallback
     15 */
     16
     17/**
     18 * Support tests.
     19 * @typedef SupportTests
     20 * @type {object}
     21 * @property {?boolean} flag
     22 * @property {?boolean} emoji
     23 */
     24
     25/**
     26 * IIFE to detect emoji support and load Twemoji if needed.
     27 *
     28 * @param {Window} window
     29 * @param {Document} document
     30 * @param {WPEmojiSettings} settings
     31 */
     32( function wpEmojiLoader( window, document, settings ) {
     33    if ( typeof Promise === 'undefined' ) {
     34        return;
     35    }
     36
     37    var sessionStorageKey = 'wpEmojiSettingsSupports';
     38    var tests = [ 'flag', 'emoji' ];
     39
     40    /**
     41     * Checks whether the browser supports offloading to a Worker.
     42     *
     43     * @since 6.3.0
     44     *
     45     * @private
     46     *
     47     * @returns {boolean}
     48     */
     49    function supportsWorkerOffloading() {
     50        return (
     51            typeof Worker !== 'undefined' &&
     52            typeof OffscreenCanvas !== 'undefined' &&
     53            typeof URL !== 'undefined' &&
     54            URL.createObjectURL &&
     55            typeof Blob !== 'undefined'
     56        );
     57    }
     58
     59    /**
     60     * @typedef SessionSupportTests
     61     * @type {object}
     62     * @property {number} timestamp
     63     * @property {SupportTests} supportTests
     64     */
     65
     66    /**
     67     * Get support tests from session.
     68     *
     69     * @since 6.3.0
     70     *
     71     * @private
     72     *
     73     * @returns {?SupportTests} Support tests, or null if not set or older than 1 week.
     74     */
     75    function getSessionSupportTests() {
     76        if (
     77            typeof sessionStorage !== 'undefined' &&
     78            sessionStorageKey in sessionStorage
     79        ) {
     80            try {
     81                /** @type {SessionSupportTests} */
     82                var item = JSON.parse(
     83                    sessionStorage.getItem( sessionStorageKey )
     84                );
     85                if (
     86                    typeof item === 'object' &&
     87                    typeof item.timestamp === 'number' &&
     88                    new Date().valueOf() < item.timestamp + 604800 && // Note: Number is a week in seconds.
     89                    typeof item.supportTests === 'object'
     90                ) {
     91                    return item.supportTests;
     92                }
     93            } catch ( e ) {}
     94        }
     95        return null;
     96    }
     97
     98    /**
     99     * Persist the supports in session storage.
     100     *
     101     * @since 6.3.0
     102     *
     103     * @private
     104     *
     105     * @param {SupportTests} supportTests Support tests.
     106     */
     107    function setSessionSupportTests( supportTests ) {
     108        if ( typeof sessionStorage !== 'undefined' ) {
     109            try {
     110                /** @type {SessionSupportTests} */
     111                var item = {
     112                    supportTests: supportTests,
     113                    timestamp: new Date().valueOf()
     114                };
     115
     116                sessionStorage.setItem(
     117                    sessionStorageKey,
     118                    JSON.stringify( item )
     119                );
     120            } catch ( e ) {}
     121        }
     122    }
    11123
    12124    /**
    13125     * Checks if two sets of Emoji characters render the same visually.
    14126     *
     127     * This function may be serialized to run in a Worker. Therefore, it cannot refer to variables from the containing
     128     * scope. Everything must be passed by parameters.
     129     *
    15130     * @since 4.9.0
    16131     *
    17132     * @private
    18133     *
     134     * @param {CanvasRenderingContext2D} context 2D Context.
    19135     * @param {string} set1 Set of Emoji to test.
    20136     * @param {string} set2 Set of Emoji to test.
     
    22138     * @return {boolean} True if the two sets render the same.
    23139     */
    24     function emojiSetsRenderIdentically( set1, set2 ) {
     140    function emojiSetsRenderIdentically( context, set1, set2 ) {
    25141        // Cleanup from previous test.
    26         context.clearRect( 0, 0, canvas.width, canvas.height );
     142        context.clearRect( 0, 0, context.canvas.width, context.canvas.height );
    27143        context.fillText( set1, 0, 0 );
    28         var rendered1 = canvas.toDataURL();
     144        var rendered1 = new Uint32Array(
     145            context.getImageData(
     146                0,
     147                0,
     148                context.canvas.width,
     149                context.canvas.height
     150            ).data
     151        );
    29152
    30153        // Cleanup from previous test.
    31         context.clearRect( 0, 0, canvas.width, canvas.height );
     154        context.clearRect( 0, 0, context.canvas.width, context.canvas.height );
    32155        context.fillText( set2, 0, 0 );
    33         var rendered2 = canvas.toDataURL();
    34 
    35         return rendered1 === rendered2;
     156        var rendered2 = new Uint32Array(
     157            context.getImageData(
     158                0,
     159                0,
     160                context.canvas.width,
     161                context.canvas.height
     162            ).data
     163        );
     164
     165        return rendered1.every( function ( rendered2Data, index ) {
     166            return rendered2Data === rendered2[ index ];
     167        } );
    36168    }
    37169
     
    39171     * Determines if the browser properly renders Emoji that Twemoji can supplement.
    40172     *
     173     * This function may be serialized to run in a Worker. Therefore, it cannot refer to variables from the containing
     174     * scope. Everything must be passed by parameters.
     175     *
    41176     * @since 4.2.0
    42177     *
    43178     * @private
    44179     *
     180     * @param {CanvasRenderingContext2D} context 2D Context.
    45181     * @param {string} type Whether to test for support of "flag" or "emoji".
    46182     *
    47183     * @return {boolean} True if the browser can render emoji, false if it cannot.
    48184     */
    49     function browserSupportsEmoji( type ) {
     185    function browserSupportsEmoji( context, type ) {
    50186        var isIdentical;
    51187
    52         if ( ! context || ! context.fillText ) {
    53             return false;
    54         }
     188        switch ( type ) {
     189            case 'flag':
     190                /*
     191                 * Test for Transgender flag compatibility. Added in Unicode 13.
     192                 *
     193                 * To test for support, we try to render it, and compare the rendering to how it would look if
     194                 * the browser doesn't render it correctly (white flag emoji + transgender symbol).
     195                 */
     196                isIdentical = emojiSetsRenderIdentically(
     197                    context,
     198                    '\uD83C\uDFF3\uFE0F\u200D\u26A7\uFE0F', // as a zero-width joiner sequence
     199                    '\uD83C\uDFF3\uFE0F\u200B\u26A7\uFE0F' // separated by a zero-width space
     200                );
     201
     202                if ( isIdentical ) {
     203                    return false;
     204                }
     205
     206                /*
     207                 * Test for UN flag compatibility. This is the least supported of the letter locale flags,
     208                 * so gives us an easy test for full support.
     209                 *
     210                 * To test for support, we try to render it, and compare the rendering to how it would look if
     211                 * the browser doesn't render it correctly ([U] + [N]).
     212                 */
     213                isIdentical = emojiSetsRenderIdentically(
     214                    context,
     215                    '\uD83C\uDDFA\uD83C\uDDF3', // as the sequence of two code points
     216                    '\uD83C\uDDFA\u200B\uD83C\uDDF3' // as the two code points separated by a zero-width space
     217                );
     218
     219                if ( isIdentical ) {
     220                    return false;
     221                }
     222
     223                /*
     224                 * Test for English flag compatibility. England is a country in the United Kingdom, it
     225                 * does not have a two letter locale code but rather a five letter sub-division code.
     226                 *
     227                 * To test for support, we try to render it, and compare the rendering to how it would look if
     228                 * the browser doesn't render it correctly (black flag emoji + [G] + [B] + [E] + [N] + [G]).
     229                 */
     230                isIdentical = emojiSetsRenderIdentically(
     231                    context,
     232                    // as the flag sequence
     233                    '\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F',
     234                    // with each code point separated by a zero-width space
     235                    '\uD83C\uDFF4\u200B\uDB40\uDC67\u200B\uDB40\uDC62\u200B\uDB40\uDC65\u200B\uDB40\uDC6E\u200B\uDB40\uDC67\u200B\uDB40\uDC7F'
     236                );
     237
     238                return ! isIdentical;
     239            case 'emoji':
     240                /*
     241                 * Why can't we be friends? Everyone can now shake hands in emoji, regardless of skin tone!
     242                 *
     243                 * To test for Emoji 14.0 support, try to render a new emoji: Handshake: Light Skin Tone, Dark Skin Tone.
     244                 *
     245                 * The Handshake: Light Skin Tone, Dark Skin Tone emoji is a ZWJ sequence combining 🫱 Rightwards Hand,
     246                 * 🏻 Light Skin Tone, a Zero Width Joiner, 🫲 Leftwards Hand, and 🏿 Dark Skin Tone.
     247                 *
     248                 * 0x1FAF1 == Rightwards Hand
     249                 * 0x1F3FB == Light Skin Tone
     250                 * 0x200D == Zero-Width Joiner (ZWJ) that links the code points for the new emoji or
     251                 * 0x200B == Zero-Width Space (ZWS) that is rendered for clients not supporting the new emoji.
     252                 * 0x1FAF2 == Leftwards Hand
     253                 * 0x1F3FF == Dark Skin Tone.
     254                 *
     255                 * When updating this test for future Emoji releases, ensure that individual emoji that make up the
     256                 * sequence come from older emoji standards.
     257                 */
     258                isIdentical = emojiSetsRenderIdentically(
     259                    context,
     260                    '\uD83E\uDEF1\uD83C\uDFFB\u200D\uD83E\uDEF2\uD83C\uDFFF', // as the zero-width joiner sequence
     261                    '\uD83E\uDEF1\uD83C\uDFFB\u200B\uD83E\uDEF2\uD83C\uDFFF' // separated by a zero-width space
     262                );
     263
     264                return ! isIdentical;
     265        }
     266
     267        return false;
     268    }
     269
     270    /**
     271     * Checks emoji support tests.
     272     *
     273     * This function may be serialized to run in a Worker. Therefore, it cannot refer to variables from the containing
     274     * scope. Everything must be passed by parameters.
     275     *
     276     * @since 6.3.0
     277     *
     278     * @private
     279     *
     280     * @param {string[]} tests Tests.
     281     *
     282     * @return {SupportTests} Support tests.
     283     */
     284    function testEmojiSupports( tests ) {
     285        var canvas;
     286        if (
     287            typeof WorkerGlobalScope !== 'undefined' &&
     288            self instanceof WorkerGlobalScope
     289        ) {
     290            canvas = new OffscreenCanvas( 300, 150 ); // Dimensions are default for HTMLCanvasElement.
     291        } else {
     292            canvas = document.createElement( 'canvas' );
     293        }
     294
     295        var context = canvas.getContext( '2d', { willReadFrequently: true } );
    55296
    56297        /*
     
    62303        context.font = '600 32px Arial';
    63304
    64         switch ( type ) {
    65             case 'flag':
    66                 /*
    67                  * Test for Transgender flag compatibility. Added in Unicode 13.
    68                  *
    69                  * To test for support, we try to render it, and compare the rendering to how it would look if
    70                  * the browser doesn't render it correctly (white flag emoji + transgender symbol).
    71                  */
    72                 isIdentical = emojiSetsRenderIdentically(
    73                     '\uD83C\uDFF3\uFE0F\u200D\u26A7\uFE0F', // as a zero-width joiner sequence
    74                     '\uD83C\uDFF3\uFE0F\u200B\u26A7\uFE0F'  // separated by a zero-width space
    75                 );
    76 
    77                 if ( isIdentical ) {
    78                     return false;
    79                 }
    80 
    81                 /*
    82                  * Test for UN flag compatibility. This is the least supported of the letter locale flags,
    83                  * so gives us an easy test for full support.
    84                  *
    85                  * To test for support, we try to render it, and compare the rendering to how it would look if
    86                  * the browser doesn't render it correctly ([U] + [N]).
    87                  */
    88                 isIdentical = emojiSetsRenderIdentically(
    89                     '\uD83C\uDDFA\uD83C\uDDF3',       // as the sequence of two code points
    90                     '\uD83C\uDDFA\u200B\uD83C\uDDF3'  // as the two code points separated by a zero-width space
    91                 );
    92 
    93                 if ( isIdentical ) {
    94                     return false;
    95                 }
    96 
    97                 /*
    98                  * Test for English flag compatibility. England is a country in the United Kingdom, it
    99                  * does not have a two letter locale code but rather a five letter sub-division code.
    100                  *
    101                  * To test for support, we try to render it, and compare the rendering to how it would look if
    102                  * the browser doesn't render it correctly (black flag emoji + [G] + [B] + [E] + [N] + [G]).
    103                  */
    104                 isIdentical = emojiSetsRenderIdentically(
    105                     // as the flag sequence
    106                     '\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F',
    107                     // with each code point separated by a zero-width space
    108                     '\uD83C\uDFF4\u200B\uDB40\uDC67\u200B\uDB40\uDC62\u200B\uDB40\uDC65\u200B\uDB40\uDC6E\u200B\uDB40\uDC67\u200B\uDB40\uDC7F'
    109                 );
    110 
    111                 return ! isIdentical;
    112             case 'emoji':
    113                 /*
    114                  * Why can't we be friends? Everyone can now shake hands in emoji, regardless of skin tone!
    115                  *
    116                  * To test for Emoji 14.0 support, try to render a new emoji: Handshake: Light Skin Tone, Dark Skin Tone.
    117                  *
    118                  * The Handshake: Light Skin Tone, Dark Skin Tone emoji is a ZWJ sequence combining 🫱 Rightwards Hand,
    119                  * 🏻 Light Skin Tone, a Zero Width Joiner, 🫲 Leftwards Hand, and 🏿 Dark Skin Tone.
    120                  *
    121                  * 0x1FAF1 == Rightwards Hand
    122                  * 0x1F3FB == Light Skin Tone
    123                  * 0x200D == Zero-Width Joiner (ZWJ) that links the code points for the new emoji or
    124                  * 0x200B == Zero-Width Space (ZWS) that is rendered for clients not supporting the new emoji.
    125                  * 0x1FAF2 == Leftwards Hand
    126                  * 0x1F3FF == Dark Skin Tone.
    127                  *
    128                  * When updating this test for future Emoji releases, ensure that individual emoji that make up the
    129                  * sequence come from older emoji standards.
    130                  */
    131                 isIdentical = emojiSetsRenderIdentically(
    132                     '\uD83E\uDEF1\uD83C\uDFFB\u200D\uD83E\uDEF2\uD83C\uDFFF', // as the zero-width joiner sequence
    133                     '\uD83E\uDEF1\uD83C\uDFFB\u200B\uD83E\uDEF2\uD83C\uDFFF'  // separated by a zero-width space
    134                 );
    135 
    136                 return ! isIdentical;
    137         }
    138 
    139         return false;
     305        var supports = {};
     306        tests.forEach( function ( test ) {
     307            supports[ test ] = browserSupportsEmoji( context, test );
     308        } );
     309        return supports;
    140310    }
    141311
     
    147317     * @since 4.2.0
    148318     *
    149      * @param {Object} src The url where the script is located.
     319     * @param {string} src The url where the script is located.
     320     *
    150321     * @return {void}
    151322     */
    152323    function addScript( src ) {
    153324        var script = document.createElement( 'script' );
    154 
    155325        script.src = src;
    156         script.defer = script.type = 'text/javascript';
    157         document.getElementsByTagName( 'head' )[0].appendChild( script );
    158     }
    159 
    160     tests = Array( 'flag', 'emoji' );
     326        script.defer = true;
     327        document.head.appendChild( script );
     328    }
    161329
    162330    settings.supports = {
     
    165333    };
    166334
    167     /*
    168      * Tests the browser support for flag emojis and other emojis, and adjusts the
    169      * support settings accordingly.
    170      */
    171     for( ii = 0; ii < tests.length; ii++ ) {
    172         settings.supports[ tests[ ii ] ] = browserSupportsEmoji( tests[ ii ] );
    173 
    174         settings.supports.everything = settings.supports.everything && settings.supports[ tests[ ii ] ];
    175 
    176         if ( 'flag' !== tests[ ii ] ) {
    177             settings.supports.everythingExceptFlag = settings.supports.everythingExceptFlag && settings.supports[ tests[ ii ] ];
    178         }
    179     }
    180 
    181     settings.supports.everythingExceptFlag = settings.supports.everythingExceptFlag && ! settings.supports.flag;
    182 
    183     // Sets DOMReady to false and assigns a ready function to settings.
    184     settings.DOMReady = false;
    185     settings.readyCallback = function() {
    186         settings.DOMReady = true;
    187     };
    188 
    189     // When the browser can not render everything we need to load a polyfill.
    190     if ( ! settings.supports.everything ) {
    191         ready = function() {
    192             settings.readyCallback();
    193         };
    194 
    195         /*
    196          * Cross-browser version of adding a dom ready event.
    197          */
    198         if ( document.addEventListener ) {
    199             document.addEventListener( 'DOMContentLoaded', ready, false );
    200             window.addEventListener( 'load', ready, false );
    201         } else {
    202             window.attachEvent( 'onload', ready );
    203             document.attachEvent( 'onreadystatechange', function() {
    204                 if ( 'complete' === document.readyState ) {
    205                     settings.readyCallback();
     335    // Create a promise for DOMContentLoaded since the worker logic may finish after the event has fired.
     336    var domReadyPromise = new Promise( function ( resolve ) {
     337        document.addEventListener( 'DOMContentLoaded', resolve, {
     338            once: true
     339        } );
     340    } );
     341
     342    // Obtain the emoji support from the browser, asynchronously when possible.
     343    new Promise( function ( resolve ) {
     344        var supportTests = getSessionSupportTests();
     345        if ( supportTests ) {
     346            resolve( supportTests );
     347            return;
     348        }
     349
     350        if ( supportsWorkerOffloading() ) {
     351            try {
     352                /*
     353                 * Note that this string contains the real source code for the
     354                 * copied functions, _not_ a string representation of them. This
     355                 * is because it's not possible to transfer a Function across
     356                 * threads. The lack of quotes is intentional. The function names
     357                 * are copied to variable names since minification will munge the
     358                 * function names, thus breaking the ability for the functions to
     359                 * refer to each other.
     360                 */
     361                var workerScript =
     362                    'var emojiSetsRenderIdentically = ' + emojiSetsRenderIdentically + ';' +
     363                    'var browserSupportsEmoji = ' + browserSupportsEmoji + ';' +
     364                    'var testEmojiSupports = ' + testEmojiSupports + ';' +
     365                    'postMessage(testEmojiSupports(' + JSON.stringify(tests) + '));';
     366                var blob = new Blob( [ workerScript ], {
     367                    type: 'text/javascript'
     368                } );
     369                var worker = new Worker( URL.createObjectURL( blob ) );
     370                worker.onmessage = function ( event ) {
     371                    supportTests = event.data;
     372                    setSessionSupportTests( supportTests );
     373                    resolve( supportTests );
     374                };
     375                return;
     376            } catch ( e ) {}
     377        }
     378
     379        supportTests = testEmojiSupports( tests );
     380        setSessionSupportTests( supportTests );
     381        resolve( supportTests );
     382    } )
     383        // Once the browser emoji support has been obtained from the session, finalize the settings.
     384        .then( function ( supportTests ) {
     385            /*
     386             * Tests the browser support for flag emojis and other emojis, and adjusts the
     387             * support settings accordingly.
     388             */
     389            for ( var test in supportTests ) {
     390                settings.supports[ test ] = supportTests[ test ];
     391
     392                settings.supports.everything =
     393                    settings.supports.everything && settings.supports[ test ];
     394
     395                if ( 'flag' !== test ) {
     396                    settings.supports.everythingExceptFlag =
     397                        settings.supports.everythingExceptFlag &&
     398                        settings.supports[ test ];
    206399                }
    207             } );
    208         }
    209 
    210         src = settings.source || {};
    211 
    212         if ( src.concatemoji ) {
    213             addScript( src.concatemoji );
    214         } else if ( src.wpemoji && src.twemoji ) {
    215             addScript( src.twemoji );
    216             addScript( src.wpemoji );
    217         }
    218     }
    219 
     400            }
     401
     402            settings.supports.everythingExceptFlag =
     403                settings.supports.everythingExceptFlag &&
     404                ! settings.supports.flag;
     405
     406            // Sets DOMReady to false and assigns a ready function to settings.
     407            settings.DOMReady = false;
     408            settings.readyCallback = function () {
     409                settings.DOMReady = true;
     410            };
     411        } )
     412        .then( function () {
     413            return domReadyPromise;
     414        } )
     415        .then( function () {
     416            // When the browser can not render everything we need to load a polyfill.
     417            if ( ! settings.supports.everything ) {
     418                settings.readyCallback();
     419
     420                var src = settings.source || {};
     421
     422                if ( src.concatemoji ) {
     423                    addScript( src.concatemoji );
     424                } else if ( src.wpemoji && src.twemoji ) {
     425                    addScript( src.twemoji );
     426                    addScript( src.wpemoji );
     427                }
     428            }
     429        } );
    220430} )( window, document, window._wpemojiSettings );
Note: See TracChangeset for help on using the changeset viewer.