diff --git a/bin/plugin/commands/performance.js b/bin/plugin/commands/performance.js index 4a2cfa2df29686..d181e214ce03c0 100644 --- a/bin/plugin/commands/performance.js +++ b/bin/plugin/commands/performance.js @@ -262,7 +262,7 @@ async function runPerformanceTests( branches, options ) { log( '>> Starting the WordPress environment' ); await runShellScript( 'npm run wp-env start', environmentDirectory ); - const testSuites = [ 'post-editor', 'site-editor' ]; + const testSuites = [ 'post-editor', 'i18n-filters', 'site-editor' ]; /** @type {Record>} */ let results = {}; diff --git a/lib/client-assets.php b/lib/client-assets.php index 1e978a73ff7a9c..53f183d95f4174 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -88,11 +88,11 @@ function gutenberg_override_script( $scripts, $handle, $src, $deps = array(), $v * `WP_Dependencies::set_translations` will fall over on itself if setting * translations on the `wp-i18n` handle, since it internally adds `wp-i18n` * as a dependency of itself, exhausting memory. The same applies for the - * polyfill script, which is a dependency _of_ `wp-i18n`. + * polyfill and hooks scripts, which are dependencies _of_ `wp-i18n`. * * See: https://core.trac.wordpress.org/ticket/46089 */ - if ( 'wp-i18n' !== $handle && 'wp-polyfill' !== $handle ) { + if ( ! in_array( $handle, array( 'wp-i18n', 'wp-polyfill', 'wp-hooks' ), true ) ) { $scripts->set_translations( $handle, 'default' ); } } diff --git a/package-lock.json b/package-lock.json index 9bba8b8260661b..baaad43e9edb4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12263,6 +12263,7 @@ "version": "file:packages/i18n", "requires": { "@babel/runtime": "^7.12.5", + "@wordpress/hooks": "file:packages/hooks", "gettext-parser": "^1.3.1", "lodash": "^4.17.19", "memize": "^1.1.0", diff --git a/packages/e2e-tests/specs/performance/i18n-filters.test.js b/packages/e2e-tests/specs/performance/i18n-filters.test.js new file mode 100644 index 00000000000000..fb47418c1e1964 --- /dev/null +++ b/packages/e2e-tests/specs/performance/i18n-filters.test.js @@ -0,0 +1,291 @@ +/** + * External dependencies + */ +import { basename, join } from 'path'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; + +/** + * WordPress dependencies + */ +import { + createNewPost, + saveDraft, + insertBlock, + openGlobalBlockInserter, + closeGlobalBlockInserter, +} from '@wordpress/e2e-test-utils'; + +function readFile( filePath ) { + return existsSync( filePath ) + ? readFileSync( filePath, 'utf8' ).trim() + : ''; +} + +function deleteFile( filePath ) { + if ( existsSync( filePath ) ) { + unlinkSync( filePath ); + } +} + +function isKeyEvent( item ) { + return ( + item.cat === 'devtools.timeline' && + item.name === 'EventDispatch' && + item.dur && + item.args && + item.args.data + ); +} + +function isKeyDownEvent( item ) { + return isKeyEvent( item ) && item.args.data.type === 'keydown'; +} + +function isKeyPressEvent( item ) { + return isKeyEvent( item ) && item.args.data.type === 'keypress'; +} + +function isKeyUpEvent( item ) { + return isKeyEvent( item ) && item.args.data.type === 'keyup'; +} + +function isFocusEvent( item ) { + return isKeyEvent( item ) && item.args.data.type === 'focus'; +} + +function isClickEvent( item ) { + return isKeyEvent( item ) && item.args.data.type === 'click'; +} + +function isMouseOverEvent( item ) { + return isKeyEvent( item ) && item.args.data.type === 'mouseover'; +} + +function isMouseOutEvent( item ) { + return isKeyEvent( item ) && item.args.data.type === 'mouseout'; +} + +function getEventDurationsForType( trace, filterFunction ) { + return trace.traceEvents + .filter( filterFunction ) + .map( ( item ) => item.dur / 1000 ); +} + +function getTypingEventDurations( trace ) { + return [ + getEventDurationsForType( trace, isKeyDownEvent ), + getEventDurationsForType( trace, isKeyPressEvent ), + getEventDurationsForType( trace, isKeyUpEvent ), + ]; +} + +function getSelectionEventDurations( trace ) { + return [ getEventDurationsForType( trace, isFocusEvent ) ]; +} + +function getClickEventDurations( trace ) { + return [ getEventDurationsForType( trace, isClickEvent ) ]; +} + +function getHoverEventDurations( trace ) { + return [ + getEventDurationsForType( trace, isMouseOverEvent ), + getEventDurationsForType( trace, isMouseOutEvent ), + ]; +} + +page.on( 'load', function () { + page.evaluate( () => { + const filters = [ + 'i18n.gettext', + 'i18n.gettext_default', + 'i18n.ngettext', + 'i18n.ngettext_default', + 'i18n.gettext_with_context', + 'i18n.gettext_with_context_default', + 'i18n.ngettext_with_context', + 'i18n.ngettext_with_context_default', + ]; + filters.forEach( ( filter ) => { + wp.hooks.addFilter( + filter, + 'e2e-tests', + ( ...args ) => { + return args[ 0 ]; + }, + 90 + ); + } ); + } ); +} ); + +jest.setTimeout( 1000000 ); + +describe( 'Post Editor Performance (with i18n filters)', () => { + it( 'Loading, typing and selecting blocks', async () => { + const results = { + load: [], + type: [], + focus: [], + inserterOpen: [], + inserterHover: [], + }; + + const html = readFile( + join( __dirname, '../../assets/large-post.html' ) + ); + + await createNewPost(); + + await page.evaluate( ( _html ) => { + const { parse } = window.wp.blocks; + const { dispatch } = window.wp.data; + const blocks = parse( _html ); + + blocks.forEach( ( block ) => { + if ( block.name === 'core/image' ) { + delete block.attributes.id; + delete block.attributes.url; + } + } ); + + dispatch( 'core/block-editor' ).resetBlocks( blocks ); + }, html ); + await saveDraft(); + + let i = 1; + + // Measuring loading time + while ( i-- ) { + const startTime = new Date(); + await page.reload(); + await page.waitForSelector( '.wp-block' ); + results.load.push( new Date() - startTime ); + } + + // Measure time to open inserter + await page.waitForSelector( '.edit-post-layout' ); + const traceFile = __dirname + '/trace.json'; + let traceResults; + for ( let j = 0; j < 10; j++ ) { + await page.tracing.start( { + path: traceFile, + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + await openGlobalBlockInserter(); + await page.tracing.stop(); + + traceResults = JSON.parse( readFile( traceFile ) ); + const [ mouseClickEvents ] = getClickEventDurations( traceResults ); + for ( let k = 0; k < mouseClickEvents.length; k++ ) { + results.inserterOpen.push( mouseClickEvents[ k ] ); + } + await closeGlobalBlockInserter(); + } + + // Measure inserter hover performance + const paragraphBlockItem = + '.block-editor-inserter__menu .editor-block-list-item-paragraph'; + const headingBlockItem = + '.block-editor-inserter__menu .editor-block-list-item-heading'; + await openGlobalBlockInserter(); + await page.waitForSelector( paragraphBlockItem ); + await page.hover( paragraphBlockItem ); + await page.hover( headingBlockItem ); + for ( let j = 0; j < 20; j++ ) { + await page.tracing.start( { + path: traceFile, + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + await page.hover( paragraphBlockItem ); + await page.hover( headingBlockItem ); + await page.tracing.stop(); + + traceResults = JSON.parse( readFile( traceFile ) ); + const [ mouseOverEvents, mouseOutEvents ] = getHoverEventDurations( + traceResults + ); + for ( let k = 0; k < mouseOverEvents.length; k++ ) { + results.inserterHover.push( + mouseOverEvents[ k ] + mouseOutEvents[ k ] + ); + } + } + await closeGlobalBlockInserter(); + + // Measuring typing performance + await insertBlock( 'Paragraph' ); + i = 200; + await page.tracing.start( { + path: traceFile, + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + while ( i-- ) { + await page.keyboard.type( 'x' ); + } + + await page.tracing.stop(); + traceResults = JSON.parse( readFile( traceFile ) ); + const [ + keyDownEvents, + keyPressEvents, + keyUpEvents, + ] = getTypingEventDurations( traceResults ); + + if ( + keyDownEvents.length === keyPressEvents.length && + keyPressEvents.length === keyUpEvents.length + ) { + for ( let j = 0; j < keyDownEvents.length; j++ ) { + results.type.push( + keyDownEvents[ j ] + keyPressEvents[ j ] + keyUpEvents[ j ] + ); + } + } + + // Save the draft so we don't get browser dialogs about leaving unsaved page. + await saveDraft(); + + // Measuring block selection performance + await createNewPost(); + await page.evaluate( () => { + const { createBlock } = window.wp.blocks; + const { dispatch } = window.wp.data; + const blocks = window.lodash + .times( 1000 ) + .map( () => createBlock( 'core/paragraph' ) ); + dispatch( 'core/block-editor' ).resetBlocks( blocks ); + } ); + + const paragraphs = await page.$$( '.wp-block' ); + + await page.tracing.start( { + path: traceFile, + screenshots: false, + categories: [ 'devtools.timeline' ], + } ); + for ( let j = 0; j < 10; j++ ) { + await paragraphs[ j ].click(); + } + + await page.tracing.stop(); + + traceResults = JSON.parse( readFile( traceFile ) ); + const [ focusEvents ] = getSelectionEventDurations( traceResults ); + results.focus = focusEvents; + + const resultsFilename = basename( __filename, '.js' ) + '.results.json'; + + writeFileSync( + join( __dirname, resultsFilename ), + JSON.stringify( results, null, 2 ) + ); + + deleteFile( traceFile ); + + expect( true ).toBe( true ); + } ); +} ); diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 366b034f453b78..a713321f1edfbd 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -35,6 +35,7 @@ _Parameters_ - _initialData_ `[LocaleData]`: Locale data configuration. - _initialDomain_ `[string]`: Domain for which configuration applies. +- _hooks_ `[ApplyFiltersInterface]`: Hooks implementation. _Returns_ diff --git a/packages/i18n/package.json b/packages/i18n/package.json index b3d187e3a8a2b6..bdb383108a1d20 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", + "@wordpress/hooks": "file:../hooks", "gettext-parser": "^1.3.1", "lodash": "^4.17.19", "memize": "^1.1.0", diff --git a/packages/i18n/src/create-i18n.js b/packages/i18n/src/create-i18n.js index 0175a947d6f4d5..cb504f77bcc8ff 100644 --- a/packages/i18n/src/create-i18n.js +++ b/packages/i18n/src/create-i18n.js @@ -30,6 +30,10 @@ const DEFAULT_LOCALE_DATA = { * * @see http://messageformat.github.io/Jed/ */ +/** + * @typedef {(domain?: string) => string} GetFilterDomain + * Retrieve the domain to use when calling domain-specific filters. + */ /** * @typedef {(text: string, domain?: string) => string} __ * @@ -70,6 +74,9 @@ const DEFAULT_LOCALE_DATA = { * language written RTL. The opposite of RTL, LTR (Left To Right) is used in other languages, * including English (`en`, `en-US`, `en-GB`, etc.), Spanish (`es`), and French (`fr`). */ +/** + * @typedef {{ applyFilters: (hookName:string, ...args: unknown[]) => unknown}} ApplyFiltersInterface + */ /* eslint-enable jsdoc/valid-types */ /** @@ -92,9 +99,10 @@ const DEFAULT_LOCALE_DATA = { * * @param {LocaleData} [initialData] Locale data configuration. * @param {string} [initialDomain] Domain for which configuration applies. + * @param {ApplyFiltersInterface} [hooks] Hooks implementation. * @return {I18n} I18n instance */ -export const createI18n = ( initialData, initialDomain ) => { +export const createI18n = ( initialData, initialDomain, hooks ) => { /** * The underlying instance of Tannin to which exported functions interface. * @@ -147,24 +155,167 @@ export const createI18n = ( initialData, initialDomain ) => { return tannin.dcnpgettext( domain, context, single, plural, number ); }; + /** @type {GetFilterDomain} */ + const getFilterDomain = ( domain ) => { + if ( typeof domain === 'undefined' ) { + return 'default'; + } + return domain; + }; + /** @type {__} */ const __ = ( text, domain ) => { - return dcnpgettext( domain, undefined, text ); + let translation = dcnpgettext( domain, undefined, text ); + /** + * Filters text with its translation. + * + * @param {string} translation Translated text. + * @param {string} text Text to translate. + * @param {string} domain Text domain. Unique identifier for retrieving translated strings. + */ + if ( typeof hooks === 'undefined' ) { + return translation; + } + translation = /** @type {string} */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.gettext', + translation, + text, + domain + ) + ); + return /** @type {string} */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.gettext_' + getFilterDomain( domain ), + translation, + text, + domain + ) + ); }; /** @type {_x} */ const _x = ( text, context, domain ) => { - return dcnpgettext( domain, context, text ); + let translation = dcnpgettext( domain, context, text ); + /** + * Filters text with its translation based on context information. + * + * @param {string} translation Translated text. + * @param {string} text Text to translate. + * @param {string} context Context information for the translators. + * @param {string} domain Text domain. Unique identifier for retrieving translated strings. + */ + if ( typeof hooks === 'undefined' ) { + return translation; + } + translation = /** @type {string} */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.gettext_with_context', + translation, + text, + context, + domain + ) + ); + return /** @type {string} */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.gettext_with_context_' + getFilterDomain( domain ), + translation, + text, + context, + domain + ) + ); }; /** @type {_n} */ const _n = ( single, plural, number, domain ) => { - return dcnpgettext( domain, undefined, single, plural, number ); + let translation = dcnpgettext( + domain, + undefined, + single, + plural, + number + ); + if ( typeof hooks === 'undefined' ) { + return translation; + } + /** + * Filters the singular or plural form of a string. + * + * @param {string} translation Translated text. + * @param {string} single The text to be used if the number is singular. + * @param {string} plural The text to be used if the number is plural. + * @param {string} number The number to compare against to use either the singular or plural form. + * @param {string} domain Text domain. Unique identifier for retrieving translated strings. + */ + translation = /** @type {string} */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.ngettext', + translation, + single, + plural, + number, + domain + ) + ); + return /** @type {string} */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.ngettext_' + getFilterDomain( domain ), + translation, + single, + plural, + number, + domain + ) + ); }; /** @type {_nx} */ const _nx = ( single, plural, number, context, domain ) => { - return dcnpgettext( domain, context, single, plural, number ); + let translation = dcnpgettext( + domain, + context, + single, + plural, + number + ); + if ( typeof hooks === 'undefined' ) { + return translation; + } + /** + * Filters the singular or plural form of a string with gettext context. + * + * @param {string} translation Translated text. + * @param {string} single The text to be used if the number is singular. + * @param {string} plural The text to be used if the number is plural. + * @param {string} number The number to compare against to use either the singular or plural form. + * @param {string} context Context information for the translators. + * @param {string} domain Text domain. Unique identifier for retrieving translated strings. + */ + translation = /** @type {string} */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.ngettext_with_context', + translation, + single, + plural, + number, + context, + domain + ) + ); + + return /** @type {string} */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.ngettext_with_context_' + getFilterDomain( domain ), + translation, + single, + plural, + number, + context, + domain + ) + ); }; /** @type {IsRtl} */ diff --git a/packages/i18n/src/default-i18n.js b/packages/i18n/src/default-i18n.js index c5fdc8c06548be..5099a62b04b7fc 100644 --- a/packages/i18n/src/default-i18n.js +++ b/packages/i18n/src/default-i18n.js @@ -1,9 +1,14 @@ +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + /** * Internal dependencies */ import { createI18n } from './create-i18n'; -const i18n = createI18n(); +const i18n = createI18n( undefined, undefined, { applyFilters } ); /* * Comments in this file are duplicated from ./i18n due to diff --git a/packages/i18n/src/test/create-i18n.js b/packages/i18n/src/test/create-i18n.js index ef21d468a33721..6a82285cc7f6d4 100644 --- a/packages/i18n/src/test/create-i18n.js +++ b/packages/i18n/src/test/create-i18n.js @@ -191,4 +191,63 @@ describe( 'createI18n', () => { } ); } ); +describe( 'i18n filters', () => { + test( '__() calls filters', () => { + const i18n = createI18n( undefined, undefined, { + applyFilters: ( filter, translation ) => translation + filter, + } ); + expect( i18n.__( 'hello' ) ).toEqual( + 'helloi18n.gettexti18n.gettext_default' + ); + expect( i18n.__( 'hello', 'domain' ) ).toEqual( + 'helloi18n.gettexti18n.gettext_domain' + ); + } ); + test( '_x() calls filters', () => { + const i18n = createI18n( undefined, undefined, { + applyFilters: ( filter, translation ) => translation + filter, + } ); + expect( i18n._x( 'hello', 'context' ) ).toEqual( + 'helloi18n.gettext_with_contexti18n.gettext_with_context_default' + ); + expect( i18n._x( 'hello', 'context', 'domain' ) ).toEqual( + 'helloi18n.gettext_with_contexti18n.gettext_with_context_domain' + ); + } ); + test( '_n() calls filters', () => { + const i18n = createI18n( undefined, undefined, { + applyFilters: ( filter, translation ) => translation + filter, + } ); + expect( i18n._n( 'hello', 'hellos', 1 ) ).toEqual( + 'helloi18n.ngettexti18n.ngettext_default' + ); + expect( i18n._n( 'hello', 'hellos', 1, 'domain' ) ).toEqual( + 'helloi18n.ngettexti18n.ngettext_domain' + ); + expect( i18n._n( 'hello', 'hellos', 2 ) ).toEqual( + 'hellosi18n.ngettexti18n.ngettext_default' + ); + expect( i18n._n( 'hello', 'hellos', 2, 'domain' ) ).toEqual( + 'hellosi18n.ngettexti18n.ngettext_domain' + ); + } ); + test( '_nx() calls filters', () => { + const i18n = createI18n( undefined, undefined, { + applyFilters: ( filter, translation ) => translation + filter, + } ); + expect( i18n._nx( 'hello', 'hellos', 1, 'context' ) ).toEqual( + 'helloi18n.ngettext_with_contexti18n.ngettext_with_context_default' + ); + expect( i18n._nx( 'hello', 'hellos', 1, 'context', 'domain' ) ).toEqual( + 'helloi18n.ngettext_with_contexti18n.ngettext_with_context_domain' + ); + expect( i18n._nx( 'hello', 'hellos', 2, 'context' ) ).toEqual( + 'hellosi18n.ngettext_with_contexti18n.ngettext_with_context_default' + ); + expect( i18n._nx( 'hello', 'hellos', 2, 'context', 'domain' ) ).toEqual( + 'hellosi18n.ngettext_with_contexti18n.ngettext_with_context_domain' + ); + } ); +} ); + /* eslint-enable @wordpress/i18n-text-domain, @wordpress/i18n-translator-comments */ diff --git a/packages/i18n/src/test/default-i18n.js b/packages/i18n/src/test/default-i18n.js new file mode 100644 index 00000000000000..5b598f73f2d142 --- /dev/null +++ b/packages/i18n/src/test/default-i18n.js @@ -0,0 +1,46 @@ +/* eslint-disable @wordpress/i18n-text-domain, @wordpress/i18n-translator-comments */ + +/** + * WordPress dependencies + */ +import { __, _x, _n, _nx } from '@wordpress/i18n'; +import { addFilter } from '@wordpress/hooks'; + +describe( 'i18n filters', () => { + test( 'Default i18n functions call filters', () => { + addFilter( 'i18n.gettext', 'tests', () => { + return 'goodbye'; + } ); + expect( __( 'hello' ) ).toBe( 'goodbye' ); + addFilter( 'i18n.gettext_with_context', 'tests', () => { + return 'goodbye'; + } ); + expect( _x( 'hello', 'context' ) ).toBe( 'goodbye' ); + addFilter( + 'i18n.ngettext', + 'tests', + ( translation, singular, plural, count ) => { + if ( count === 1 ) { + return 'goodbye'; + } + return 'goodbyes'; + } + ); + expect( _n( 'hello', 'hellos', 1 ) ).toBe( 'goodbye' ); + expect( _n( 'hello', 'hellos', 2 ) ).toBe( 'goodbyes' ); + addFilter( + 'i18n.ngettext_with_context', + 'tests', + ( translation, singular, plural, count ) => { + if ( count === 1 ) { + return 'goodbye'; + } + return 'goodbyes'; + } + ); + expect( _nx( 'hello', 'hellos', 1, 'context' ) ).toBe( 'goodbye' ); + expect( _nx( 'hello', 'hellos', 2, 'context' ) ).toBe( 'goodbyes' ); + } ); +} ); + +/* eslint-enable @wordpress/i18n-text-domain, @wordpress/i18n-translator-comments */ diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json index 3c2c31f506f132..ef6ead9528f1a3 100644 --- a/packages/i18n/tsconfig.json +++ b/packages/i18n/tsconfig.json @@ -4,5 +4,8 @@ "rootDir": "src", "declarationDir": "build-types" }, - "include": [ "src/**/*" ] + "references": [ + { "path": "../hooks" }, + ], + "include": [ "src/**/*" ], }