diff --git a/RELEASE-NOTES-1.36 b/RELEASE-NOTES-1.36 index a97a53720f0..c1fea057a19 100644 --- a/RELEASE-NOTES-1.36 +++ b/RELEASE-NOTES-1.36 @@ -88,6 +88,9 @@ this is no longer recommended and the option has been removed. BoxedCommand. A BoxedCommand object can be obtained with MediaWikiServices::getInstance()->getCommandFactory()->createBoxed(). (T260330) +* ResourceLoader modules can now mark themselves as ES6-only by setting + 'es6' => true in their module definition. ES6-only modules will not be + executed in browsers that don't support ES6, such as IE11. * … === External library changes in 1.36 === diff --git a/docs/extension.schema.v1.json b/docs/extension.schema.v1.json index 6f1efec612a..6ed5ca450df 100644 --- a/docs/extension.schema.v1.json +++ b/docs/extension.schema.v1.json @@ -307,6 +307,10 @@ "items": { "type": ["string", "object"] } + }, + "es6": { + "type": "boolean", + "description": "Whether this module requires an ES6-capable browser. If set to true, loading this module in a non-ES6 browser will cause an error. Using ES6 syntax in modules is not yet supported, but will be in the near future. Default is false." } } }, diff --git a/docs/extension.schema.v2.json b/docs/extension.schema.v2.json index 5e9432f97aa..4b7dac660f2 100644 --- a/docs/extension.schema.v2.json +++ b/docs/extension.schema.v2.json @@ -317,6 +317,10 @@ "items": { "type": ["string", "object"] } + }, + "es6": { + "type": "boolean", + "description": "Whether this module requires an ES6-capable browser. If set to true, loading this module in a non-ES6 browser will cause an error. Using ES6 syntax in modules is not yet supported, but will be in the near future. Default is false." } } }, diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 08087569341..17700fbf3bf 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -3908,6 +3908,16 @@ $wgMangleFlashPolicy = true; * * Default: `[]` * + * - es6 `{bool}`: + * If true, this module will only be executed in browsers that support ES6. You should set this + * flag for modules that use ES6 in their JavaScript. Only use this for modules that provide + * progressive enhancements that are safe to not load in browsers that are not modern but still + * have a substantial user base, like IE11. + * + * Since: **MW 1.36** + * + * Default: `false` + * * ## Examples * * @par Example: Using an alternate subclass diff --git a/includes/resourceloader/ResourceLoaderFileModule.php b/includes/resourceloader/ResourceLoaderFileModule.php index a9c67c7046a..6d64223efc4 100644 --- a/includes/resourceloader/ResourceLoaderFileModule.php +++ b/includes/resourceloader/ResourceLoaderFileModule.php @@ -156,6 +156,9 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { /** @var bool Whether CSSJanus flipping should be skipped for this module */ protected $noflip = false; + /** @var bool Whether this module requires the client to support ES6 */ + protected $es6 = false; + /** * @var bool Whether getStyleURLsForDebug should return raw file paths, * or return load.php urls @@ -260,6 +263,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { // Single booleans case 'debugRaw': case 'noflip': + case 'es6': $this->{$member} = (bool)$option; break; } @@ -514,6 +518,10 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { return $this->getFileContents( $localPath, 'skip function' ); } + public function requiresES6() { + return $this->es6; + } + /** * Disable module content versioning. * diff --git a/includes/resourceloader/ResourceLoaderModule.php b/includes/resourceloader/ResourceLoaderModule.php index 3d3539ced66..ff3fd9f8d9c 100644 --- a/includes/resourceloader/ResourceLoaderModule.php +++ b/includes/resourceloader/ResourceLoaderModule.php @@ -447,6 +447,20 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { return null; } + /** + * Whether the module requires ES6 support in the client. + * + * If the client does not support ES6, attempting to load a module that requires ES6 will + * result in an error. + * + * @stable to override + * @since 1.36 + * @return bool + */ + public function requiresES6() { + return false; + } + /** * Get the indirect dependencies for this module persuant to the skin/language context * diff --git a/includes/resourceloader/ResourceLoaderStartUpModule.php b/includes/resourceloader/ResourceLoaderStartUpModule.php index 1e7ad8a7eec..7927895e028 100644 --- a/includes/resourceloader/ResourceLoaderStartUpModule.php +++ b/includes/resourceloader/ResourceLoaderStartUpModule.php @@ -243,6 +243,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { $registryData[$name] = [ 'version' => $versionHash, 'dependencies' => $module->getDependencies( $context ), + 'es6' => $module->requiresES6(), 'group' => $this->getGroupId( $module->getGroup() ), 'source' => $module->getSource(), 'skip' => $skipFunction, @@ -260,7 +261,10 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { // Call mw.loader.register(name, version, dependencies, group, source, skip) $registrations[] = [ $name, - $data['version'], + // HACK: signify ES6 with a ! added at the end of the version + // This avoids having to add another register() parameter, and generating + // a bunch of nulls for ES6-only modules + $data['version'] . ( $data['es6'] ? '!' : '' ), $data['dependencies'], $data['group'], // Swap default (local) for null diff --git a/resources/src/startup/mediawiki.js b/resources/src/startup/mediawiki.js index 9ab8d636f9e..80eefbdf4c2 100644 --- a/resources/src/startup/mediawiki.js +++ b/resources/src/startup/mediawiki.js @@ -11,7 +11,7 @@ ( function () { 'use strict'; - var mw, StringSet, log, + var mw, StringSet, log, isES6Supported, hasOwn = Object.hasOwnProperty, console = window.console; @@ -340,6 +340,35 @@ } }; + // Check whether the browser supports ES6. + // + // Most browsers that support native Promises also support all the ES6 features we need. + // The exceptions are: + // - Android 4.4.3, which supports almost no ES6 features besides Promise + // - Edge 17 and 18, which don't support RegExp-related features + // - Safari and iOS versions below 14, which don't support non-BMP characters in variable names + // (older versions have other problems too) + isES6Supported = + // Check for Promise support (filters out most non-ES6 browsers) + typeof Promise === 'function' && + // eslint-disable-next-line no-undef + Promise.prototype.finally && + + // Check for RegExp.prototype.flags (filters out Android 4.4.3 and Edge <= 18) + /./g.flags === 'g' && + + // Try a non-BMP variable name (filters out Safari < 14, iOS < 14) + ( function () { + try { + // \ud800\udec0 is U+102C0 CARIAN LETTER G + // eslint-disable-next-line no-new, no-new-func + new Function( 'var \ud800\udec0;' ); + return true; + } catch ( e ) { + return false; + } + }() ); + /** * @class mw */ @@ -488,6 +517,7 @@ * 'moduleName': { * // From mw.loader.register() * 'version': '########' (hash) + * 'requiresES6': bool * 'dependencies': ['required.foo', 'bar.also', ...] * 'group': string, integer, (or) null * 'source': 'local', (or) 'anotherwiki' @@ -877,10 +907,19 @@ * @throws {Error} If an unknown module or a circular dependency is encountered */ function sortDependencies( module, resolved, unresolved ) { - var i, skip, deps; + var e, i, skip, deps; if ( !( module in registry ) ) { - throw new Error( 'Unknown module: ' + module ); + e = new Error( 'Unknown module: ' + module ); + e.name = 'DependencyError'; + throw e; + } + + // Check requiresES6 before skip, to avoid executing an ES6 skip function in an ES5 client + if ( !isES6Supported && registry[ module ].requiresES6 ) { + e = new Error( 'Module requires ES6 but ES6 is not supported: ' + module ); + e.name = 'ES6Error'; + throw e; } if ( typeof registry[ module ].skip === 'string' ) { @@ -905,9 +944,11 @@ for ( i = 0; i < deps.length; i++ ) { if ( resolved.indexOf( deps[ i ] ) === -1 ) { if ( unresolved.has( deps[ i ] ) ) { - throw new Error( + e = new Error( 'Circular reference detected: ' + module + ' -> ' + deps[ i ] ); + e.name = 'DependencyError'; + throw e; } sortDependencies( deps[ i ], resolved, unresolved ); @@ -953,19 +994,28 @@ try { sortDependencies( modules[ i ], resolved ); } catch ( err ) { - // This module is not currently known, or has invalid dependencies. - // Most likely due to a cached reference after the module was - // removed, otherwise made redundant, or omitted from the registry - // by the ResourceLoader "target" system. resolved = saved; - mw.log.warn( 'Skipped unresolvable module ' + modules[ i ] ); - if ( modules[ i ] in registry ) { - // If the module was known but had unknown or circular dependencies, - // also track it as an error. - mw.trackError( 'resourceloader.exception', { - exception: err, - source: 'resolve' - } ); + + if ( err.name === 'ES6Error' ) { + // These errors are common, since trying to load ES6-only modules + // in non-ES6 clients is OK and should fail gracefully. Don't track + // them as errors, and display a custom warning message. + mw.log.warn( 'Skipped ES6-only module ' + modules[ i ] ); + } else { + // err.name === 'DependencyError' + // This module is not currently known, or has invalid dependencies. + // Most likely due to a cached reference after the module was + // removed, otherwise made redundant, or omitted from the registry + // by the ResourceLoader "target" system. + mw.log.warn( 'Skipped unresolvable module ' + modules[ i ] ); + if ( modules[ i ] in registry ) { + // If the module was known but had unknown or circular dependencies, + // also track it as an error. + mw.trackError( 'resourceloader.exception', { + exception: err, + source: 'resolve' + } ); + } } } } @@ -1709,9 +1759,18 @@ * @param {string} [skip] */ function registerOne( module, version, dependencies, group, source, skip ) { + var requiresES6 = false; if ( module in registry ) { throw new Error( 'module already registered: ' + module ); } + + // requiresES6 is encoded as a ! at the end of version + version = String( version || '' ); + if ( version.slice( -1 ) === '!' ) { + version = version.slice( 0, -1 ); + requiresES6 = true; + } + registry[ module ] = { // Exposed to execute() for mw.loader.implement() closures. // Import happens via require(). @@ -1720,7 +1779,8 @@ }, // module.export objects for each package file inside this module packageExports: {}, - version: String( version || '' ), + version: version, + requiresES6: requiresES6, dependencies: dependencies || [], group: typeof group === 'undefined' ? null : group, source: typeof source === 'string' ? source : 'local', @@ -1872,6 +1932,7 @@ * a list of arguments compatible with this method * @param {string|number} [version] Module version hash (falls backs to empty string) * Can also be a number (timestamp) for compatibility with MediaWiki 1.25 and earlier. + * A version string that ends with '!' signifies that the module requires ES6 support. * @param {string[]} [dependencies] Array of module names on which this module depends. * @param {string} [group=null] Group which the module is in * @param {string} [source='local'] Name of the source diff --git a/tests/phpunit/ResourceLoaderTestCase.php b/tests/phpunit/ResourceLoaderTestCase.php index 1baed319b2c..f5ca9b9d2ae 100644 --- a/tests/phpunit/ResourceLoaderTestCase.php +++ b/tests/phpunit/ResourceLoaderTestCase.php @@ -96,6 +96,7 @@ class ResourceLoaderTestModule extends ResourceLoaderModule { protected $script = ''; protected $styles = ''; protected $skipFunction = null; + protected $es6 = false; protected $isRaw = false; protected $isKnownEmpty = false; protected $type = ResourceLoaderModule::LOAD_GENERAL; @@ -148,6 +149,10 @@ class ResourceLoaderTestModule extends ResourceLoaderModule { return $this->skipFunction; } + public function requiresES6() { + return $this->es6; + } + public function isRaw() { return $this->isRaw; } diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php index de21e40591a..acced59cddc 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php @@ -867,4 +867,16 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { $this->assertEquals( $expected, $module->getScript( $context ) ); } } + + /** + * @covers ResourceLoaderFileModule::requiresES6 + */ + public function testRequiresES6() { + $module = new ResourceLoaderFileModule(); + $this->assertFalse( $module->requiresES6(), 'requiresES6 defaults to false' ); + $module = new ResourceLoaderFileModule( [ 'es6' => false ] ); + $this->assertFalse( $module->requiresES6(), 'requiresES6 is false when set to false' ); + $module = new ResourceLoaderFileModule( [ 'es6' => true ] ); + $this->assertTrue( $module->requiresES6(), 'requiresES6 is true when set to true' ); + } } diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php index b539ec7a844..8d60486778a 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php @@ -520,6 +520,25 @@ mw.loader.register([ 2 ] ] +]);', + ] ], + [ [ + 'msg' => 'ES6-only module', + 'modules' => [ + 'test.es6' => [ + 'class' => ResourceLoaderTestModule::class, + 'es6' => true + ], + ], + 'out' => ' +mw.loader.addSource({ + "local": "/w/load.php" +}); +mw.loader.register([ + [ + "test.es6", + "{blankVer}!" + ] ]);', ] ], [ [ @@ -591,6 +610,10 @@ mw.loader.register([ 'source' => 'example', 'targets' => [ 'x-foo' ], ], + 'test.es6' => [ + 'class' => ResourceLoaderTestModule::class, + 'es6' => true + ] ], 'out' => ' mw.loader.addSource({ @@ -660,6 +683,10 @@ mw.loader.register([ [], 3, "example" + ], + [ + "test.es6", + "{blankVer}!" ] ]);' ] ],