resourceloader: Move queue formatting out of OutputPage
authorTimo Tijhof <krinklemail@gmail.com>
Fri, 15 Jul 2016 14:13:09 +0000 (15:13 +0100)
committerTimo Tijhof <krinklemail@gmail.com>
Mon, 8 Aug 2016 19:23:09 +0000 (12:23 -0700)
HTML formatting of the queue was distributed over several OutputPage methods.
Each method demanding a snippet of HTML by calling makeResourceLoaderLink()
with a limited amount of information. As such, makeResourceLoaderLink() was
unable to provide the client with the proper state information.

Centralising it also allows it to better reduce duplication in HTML output
and maintain a more accurate state.

Problems fixed by centralising:

1. The 'user' module is special (due to per-user 'version' and 'user' params).
   It is manually requested via script-src. To avoid a separate (and wrong)
   request from something that requires it, we set state=loading directly.
   However, because the module is in the bottom, the old HTML formatter could
   only put state=loading in the bottom also. This sometimes caused a wrong
   request to be fired for modules=user if something in the top queue
   triggered a requirement for it.

2. Since a464d1d4 (T87871) we track states of page-style modules, with purpose
   of allowing dependencies on style modules without risking duplicate loading
   on pages where the styles are loaded already. This didn't work, because the
   state information about page-style modules is output near the stylesheet,
   which is after the script tag with mw.loader.load(). That runs first, and
   mw.loader would still make a duplicate request before it learns the state.

Changes:

* Document reasons for style/script tag order in getHeadHtml (per 09537e83).

* Pass $type from getModuleStyles() to getAllowedModules(). This wasn't needed
  before since a duplicate check in makeResourceLoaderLink() verified the
  origin a second time.

* Declare explicit position 'top' on 'user.options' and 'user.tokens' module.
  Previously, OutputPage hardcoded them in the top. The new formatter doesn't.

* Remove getHeadScripts().
* Remove getInlineHeadScripts().
* Remove getExternalHeadScripts().
* Remove buildCssLinks().
* Remove getScriptsForBottomQueue().

* Change where Skin::setupSkinUserCss() is called. This methods lets the skin
  add modules to the queue. Previously it was called from buildCssLinks(),
  via headElement(), via prepareQuickTemplate(), via OutputPage::output().
  It's now in OutputPage::output() directly (slightly earlier). This is needed
  because prepareQuickTemplate() calls bottomScripts() before headElement().
  And bottomScript() would lazy-initialise the queue and lock it before
  setupSkinUserCss() is called from headElement().
  This makes execution order more predictable instead of being dependent on
  the arbitrary order of data extraction in prepareQuickTemplate (which varies
  from one skin to another).

* Compute isUserModulePreview() and isKnownEmpty() for the 'user' module early
  on so. This avoids wrongful loading and fixes problem 1.

Effective changes in output:

* mw.loader.state() is now before mw.loader.load(). This fixes problem 2.
* mw.loader.state() now sets 'user.options' and 'user.tokens' to "loading".
* mw.loader.state() now sets 'user' (as "loading" or "ready"). Fixes problem 1.

* The <script async src> tag for 'startup' changed position (slightly).
  Previously it was after all inline scripts and stylesheets. It's still after
  all inline scripts and after most stylesheets, but before any user styles.
  Since the queue is now formatted outside OutputPage, it can't inject the
  meta-ResourceLoaderDynamicStyles tag and user-stylesheet hack in the middle
  of existing output. This shouldn't have any noticable impact.

Bug: T87871
Change-Id: I605b8cd1e1fc009b4662a0edbc54d09dd65ee1df

14 files changed:
autoload.php
docs/kss/Makefile
includes/OutputPage.php
includes/resourceloader/DerivativeResourceLoaderContext.php
includes/resourceloader/ResourceLoader.php
includes/resourceloader/ResourceLoaderClientHtml.php [new file with mode: 0644]
includes/resourceloader/ResourceLoaderContext.php
includes/resourceloader/ResourceLoaderUserOptionsModule.php
includes/resourceloader/ResourceLoaderUserTokensModule.php
includes/specials/SpecialJavaScriptTest.php
resources/src/startup.js
tests/phpunit/ResourceLoaderTestCase.php
tests/phpunit/includes/OutputPageTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php [new file with mode: 0644]

index 2587add..7d5b9fb 100644 (file)
@@ -1146,6 +1146,7 @@ $wgAutoloadLocalClasses = [
        'ResetUserTokens' => __DIR__ . '/maintenance/resetUserTokens.php',
        'ResourceFileCache' => __DIR__ . '/includes/cache/ResourceFileCache.php',
        'ResourceLoader' => __DIR__ . '/includes/resourceloader/ResourceLoader.php',
+       'ResourceLoaderClientHtml' => __DIR__ . '/includes/resourceloader/ResourceLoaderClientHtml.php',
        'ResourceLoaderContext' => __DIR__ . '/includes/resourceloader/ResourceLoaderContext.php',
        'ResourceLoaderEditToolbarModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderEditToolbarModule.php',
        'ResourceLoaderFileModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderFileModule.php',
index dadfb47..392ad1a 100644 (file)
@@ -5,7 +5,7 @@ kss: kssnodecheck
 # KSS style guide
        $(eval KSS_RL_TMP := $(shell mktemp /tmp/tmp.XXXXXXXXXX))
        $(eval MODULE_STR := $(shell paste -sd "|" styleGuideModules.txt))
-# See OutputPage::makeResourceLoaderLink.
+# See ResourceLoaderClientHtml::makeLoad.
        @curl -sG "${MEDIAWIKI_LOAD_URL}?modules=${MODULE_STR}&only=styles" > $(KSS_RL_TMP)
        @node_modules/.bin/kss-node ../../resources/src/mediawiki.ui static/ --css $(KSS_RL_TMP) -t styleguide-template
        @rm $(KSS_RL_TMP)
index 753c3b7..53eb64c 100644 (file)
@@ -154,6 +154,15 @@ class OutputPage extends ContextSource {
        /** @var ResourceLoader */
        protected $mResourceLoader;
 
+       /** @var ResourceLoaderClientHtml */
+       private $rlClient;
+
+       /** @var ResourceLoaderContext */
+       private $rlClientContext;
+
+       /** @var string */
+       private $rlUserModuleState;
+
        /** @var array */
        protected $mJsConfigVars = [];
 
@@ -501,7 +510,7 @@ class OutputPage extends ContextSource {
         * Add a self-contained script tag with the given contents
         * Internal use only. Use OutputPage::addModules() if possible.
         *
-        * @param string $script JavaScript text, no "<script>" tags
+        * @param string $script JavaScript text, no script tags
         */
        public function addInlineScript( $script ) {
                $this->mScripts .= Html::inlineScript( $script );
@@ -541,10 +550,12 @@ class OutputPage extends ContextSource {
         * @param string $param
         * @return array Array of module names
         */
-       public function getModules( $filter = false, $position = null, $param = 'mModules' ) {
+       public function getModules( $filter = false, $position = null, $param = 'mModules',
+               $type = ResourceLoaderModule::TYPE_COMBINED
+       ) {
                $modules = array_values( array_unique( $this->$param ) );
                return $filter
-                       ? $this->filterModules( $modules, $position )
+                       ? $this->filterModules( $modules, $position, $type )
                        : $modules;
        }
 
@@ -564,11 +575,12 @@ class OutputPage extends ContextSource {
         *
         * @param bool $filter
         * @param string|null $position
-        *
         * @return array Array of module names
         */
        public function getModuleScripts( $filter = false, $position = null ) {
-               return $this->getModules( $filter, $position, 'mModuleScripts' );
+               return $this->getModules( $filter, $position, 'mModuleScripts',
+                       ResourceLoaderModule::TYPE_SCRIPTS
+               );
        }
 
        /**
@@ -587,11 +599,12 @@ class OutputPage extends ContextSource {
         *
         * @param bool $filter
         * @param string|null $position
-        *
         * @return array Array of module names
         */
        public function getModuleStyles( $filter = false, $position = null ) {
-               return $this->getModules( $filter, $position, 'mModuleStyles' );
+               return $this->getModules( $filter, $position, 'mModuleStyles',
+                       ResourceLoaderModule::TYPE_STYLES
+               );
        }
 
        /**
@@ -2255,7 +2268,7 @@ class OutputPage extends ContextSource {
                        // add skin specific modules
                        $modules = $sk->getDefaultModules();
 
-                       // Enforce various default modules for all skins
+                       // Enforce various default modules for all pages and all skins
                        $coreModules = [
                                // Keep this list as small as possible
                                'site',
@@ -2277,6 +2290,7 @@ class OutputPage extends ContextSource {
                        // Hook that allows last minute changes to the output page, e.g.
                        // adding of CSS or Javascript by extensions.
                        Hooks::run( 'BeforePageDisplay', [ &$this, &$sk ] );
+                       $this->getSkin()->setupSkinUserCss( $this );
 
                        try {
                                $sk->outputPage();
@@ -2601,6 +2615,70 @@ class OutputPage extends ContextSource {
                $this->addReturnTo( $titleObj, wfCgiToArray( $returntoquery ) );
        }
 
+       private function getRlClientContext() {
+               if ( !$this->rlClientContext ) {
+                       $query = ResourceLoader::makeLoaderQuery(
+                               [], // modules; not relevant
+                               $this->getLanguage()->getCode(),
+                               $this->getSkin()->getSkinName(),
+                               $this->getUser()->isLoggedIn() ? $this->getUser()->getName() : null,
+                               null, // version; not relevant
+                               ResourceLoader::inDebugMode(),
+                               null, // only; not relevant
+                               $this->isPrintable(),
+                               $this->getRequest()->getBool( 'handheld' )
+                       );
+                       $this->rlClientContext = new ResourceLoaderContext(
+                               $this->getResourceLoader(),
+                               new FauxRequest( $query )
+                       );
+               }
+               return $this->rlClientContext;
+       }
+
+       /**
+        * Call this to freeze the module queue and JS config and create a formatter.
+        *
+        * Depending on the Skin, this may get lazy-initialised in either headElement() or
+        * getBottomScripts(). See SkinTemplate::prepareQuickTemplate(). Calling this too early may
+        * cause unexpected side-effects since disallowUserJs() may be called at any time to change
+        * the module filters retroactively. Skins and extension hooks may also add modules until very
+        * late in the request lifecycle.
+        *
+        * @return ResourceLoaderClientHtml
+        */
+       public function getRlClient() {
+               if ( !$this->rlClient ) {
+                       $context = $this->getRlClientContext();
+                       $userModule = $this->getResourceLoader()->getModule( 'user' );
+                       // Manually handled by getBottomScripts()
+                       $userState = $userModule->isKnownEmpty( $context ) && !$this->isUserModulePreview()
+                               ? 'ready'
+                               : 'loading';
+                       $this->rlUserModuleState = $userState;
+
+                       $this->addModules( [
+                               'user.options',
+                               'user.tokens',
+                       ] );
+                       $rlClient = new ResourceLoaderClientHtml( $context );
+                       $rlClient->setConfig( $this->getJSVars() );
+                       $rlClient->setModules( $this->getModules( /*filter*/ true ) );
+                       $rlClient->setModuleStyles( $this->getModuleStyles( /*filter*/ true ) );
+                       $rlClient->setModuleScripts( $this->getModuleScripts( /*filter*/ true ) );
+                       $rlClient->setExemptStates( [
+                               'user' => $userState,
+                               // Manually handled by buildExemptModules() and getBottomScripts()
+                               'site.styles' => 'ready',
+                               'noscript' => 'ready',
+                               'user.cssprefs' => 'ready',
+                               'user.styles' => 'ready',
+                       ] );
+                       $this->rlClient = $rlClient;
+               }
+               return $this->rlClient;
+       }
+
        /**
         * @param Skin $sk The given Skin
         * @param bool $includeStyle Unused
@@ -2613,17 +2691,16 @@ class OutputPage extends ContextSource {
                $sitedir = $wgContLang->getDir();
 
                $pieces = [];
-               $pieces[] = Html::htmlHeader( $sk->getHtmlElementAttributes() );
+               $pieces[] = Html::htmlHeader( Sanitizer::mergeAttributes(
+                       $this->getRlClient()->getDocumentAttributes(),
+                       $sk->getHtmlElementAttributes()
+               ) );
+               $pieces[] = Html::openElement( 'head' );
 
                if ( $this->getHTMLTitle() == '' ) {
                        $this->setHTMLTitle( $this->msg( 'pagetitle', $this->getPageTitle() )->inContentLanguage() );
                }
 
-               $openHead = Html::openElement( 'head' );
-               if ( $openHead ) {
-                       $pieces[] = $openHead;
-               }
-
                if ( !Html::isXmlMimeType( $this->getConfig()->get( 'MimeType' ) ) ) {
                        // Add <meta charset="UTF-8">
                        // This should be before <title> since it defines the charset used by
@@ -2637,22 +2714,11 @@ class OutputPage extends ContextSource {
                }
 
                $pieces[] = Html::element( 'title', null, $this->getHTMLTitle() );
-               $pieces[] = $this->getInlineHeadScripts();
-               $pieces[] = $this->buildCssLinks();
-               $pieces[] = $this->getExternalHeadScripts();
-
-               foreach ( $this->getHeadLinksArray() as $item ) {
-                       $pieces[] = $item;
-               }
-
-               foreach ( $this->mHeadItems as $item ) {
-                       $pieces[] = $item;
-               }
-
-               $closeHead = Html::closeElement( 'head' );
-               if ( $closeHead ) {
-                       $pieces[] = $closeHead;
-               }
+               $pieces[] = $this->getRlClient()->getHeadHtml();
+               $pieces[] = $this->buildExemptModules();
+               $pieces = array_merge( $pieces, array_values( $this->getHeadLinksArray() ) );
+               $pieces = array_merge( $pieces, array_values( $this->mHeadItems ) );
+               $pieces[] = Html::closeElement( 'head' );
 
                $bodyClasses = [];
                $bodyClasses[] = 'mediawiki';
@@ -2687,7 +2753,7 @@ class OutputPage extends ContextSource {
 
                $pieces[] = Html::openElement( 'body', $bodyAttrs );
 
-               return WrappedStringList::join( "\n", $pieces );
+               return self::combineWrappedStrings( $pieces );
        }
 
        /**
@@ -2706,388 +2772,98 @@ class OutputPage extends ContextSource {
        }
 
        /**
-        * Construct neccecary html and loader preset states to load modules on a page.
-        *
-        * Use getHtmlFromLoaderLinks() to convert this array to HTML.
+        * Explicily load or embed modules on a page.
         *
         * @param array|string $modules One or more module names
         * @param string $only ResourceLoaderModule TYPE_ class constant
         * @param array $extraQuery [optional] Array with extra query parameters for the request
-        * @return array A list of HTML strings and array of client loader preset states
+        * @return string|WrappedStringList HTML
         */
        public function makeResourceLoaderLink( $modules, $only, array $extraQuery = [] ) {
-               $modules = (array)$modules;
-
-               $links = [
-                       // List of html strings
-                       'html' => [],
-                       // Associative array of module names and their states
-                       'states' => [],
-               ];
-
-               if ( !count( $modules ) ) {
-                       return $links;
-               }
-
-               if ( count( $modules ) > 1 ) {
-                       // Remove duplicate module requests
-                       $modules = array_unique( $modules );
-                       // Sort module names so requests are more uniform
-                       sort( $modules );
-
-                       if ( ResourceLoader::inDebugMode() ) {
-                               // Recursively call us for every item
-                               foreach ( $modules as $name ) {
-                                       $link = $this->makeResourceLoaderLink( $name, $only, $extraQuery );
-                                       $links['html'] = array_merge( $links['html'], $link['html'] );
-                                       $links['states'] += $link['states'];
-                               }
-                               return $links;
-                       }
-               }
-
-               if ( !is_null( $this->mTarget ) ) {
-                       $extraQuery['target'] = $this->mTarget;
-               }
-
-               // Create keyed-by-source and then keyed-by-group list of module objects from modules list
-               $sortedModules = [];
-               $resourceLoader = $this->getResourceLoader();
-               foreach ( $modules as $name ) {
-                       $module = $resourceLoader->getModule( $name );
-                       # Check that we're allowed to include this module on this page
-                       if ( !$module
-                               || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_SCRIPTS )
-                                       && $only == ResourceLoaderModule::TYPE_SCRIPTS )
-                               || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_STYLES )
-                                       && $only == ResourceLoaderModule::TYPE_STYLES )
-                               || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_COMBINED )
-                                       && $only == ResourceLoaderModule::TYPE_COMBINED )
-                               || ( $this->mTarget && !in_array( $this->mTarget, $module->getTargets() ) )
-                       ) {
-                               continue;
-                       }
-
-                       if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
-                               if ( $module->getType() !== ResourceLoaderModule::LOAD_STYLES ) {
-                                       $logger = $resourceLoader->getLogger();
-                                       $logger->debug( 'Unexpected general module "{module}" in styles queue.', [
-                                               'module' => $name,
-                                       ] );
-                               } else {
-                                       $links['states'][$name] = 'ready';
-                               }
-                       }
-
-                       $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module;
-               }
-
-               foreach ( $sortedModules as $source => $groups ) {
-                       foreach ( $groups as $group => $grpModules ) {
-                               // Special handling for user-specific groups
-                               $user = null;
-                               if ( ( $group === 'user' || $group === 'private' ) && $this->getUser()->isLoggedIn() ) {
-                                       $user = $this->getUser()->getName();
-                               }
-
-                               // Create a fake request based on the one we are about to make so modules return
-                               // correct timestamp and emptiness data
-                               $query = ResourceLoader::makeLoaderQuery(
-                                       [], // modules; not determined yet
-                                       $this->getLanguage()->getCode(),
-                                       $this->getSkin()->getSkinName(),
-                                       $user,
-                                       null, // version; not determined yet
-                                       ResourceLoader::inDebugMode(),
-                                       $only === ResourceLoaderModule::TYPE_COMBINED ? null : $only,
-                                       $this->isPrintable(),
-                                       $this->getRequest()->getBool( 'handheld' ),
-                                       $extraQuery
-                               );
-                               $context = new ResourceLoaderContext( $resourceLoader, new FauxRequest( $query ) );
-
-                               // Extract modules that know they're empty and see if we have one or more
-                               // raw modules
-                               $isRaw = false;
-                               foreach ( $grpModules as $key => $module ) {
-                                       // Inline empty modules: since they're empty, just mark them as 'ready' (bug 46857)
-                                       // If we're only getting the styles, we don't need to do anything for empty modules.
-                                       if ( $module->isKnownEmpty( $context ) ) {
-                                               unset( $grpModules[$key] );
-                                               if ( $only !== ResourceLoaderModule::TYPE_STYLES ) {
-                                                       $links['states'][$key] = 'ready';
-                                               }
-                                       }
-
-                                       $isRaw |= $module->isRaw();
-                               }
-
-                               // If there are no non-empty modules, skip this group
-                               if ( count( $grpModules ) === 0 ) {
-                                       continue;
-                               }
-
-                               // Inline private modules. These can't be loaded through load.php for security
-                               // reasons, see bug 34907. Note that these modules should be loaded from
-                               // getExternalHeadScripts() before the first loader call. Otherwise other modules can't
-                               // properly use them as dependencies (bug 30914)
-                               if ( $group === 'private' ) {
-                                       if ( $only == ResourceLoaderModule::TYPE_STYLES ) {
-                                               $links['html'][] = Html::inlineStyle(
-                                                       $resourceLoader->makeModuleResponse( $context, $grpModules )
-                                               );
-                                       } else {
-                                               $links['html'][] = ResourceLoader::makeInlineScript(
-                                                       $resourceLoader->makeModuleResponse( $context, $grpModules )
-                                               );
-                                       }
-                                       continue;
-                               }
-
-                               // Special handling for the user group; because users might change their stuff
-                               // on-wiki like user pages, or user preferences; we need to find the highest
-                               // timestamp of these user-changeable modules so we can ensure cache misses on change
-                               // This should NOT be done for the site group (bug 27564) because anons get that too
-                               // and we shouldn't be putting timestamps in CDN-cached HTML
-                               $version = null;
-                               if ( $group === 'user' ) {
-                                       $query['version'] = $resourceLoader->getCombinedVersion( $context, array_keys( $grpModules ) );
-                               }
-
-                               $query['modules'] = ResourceLoader::makePackedModulesString( array_keys( $grpModules ) );
-                               $moduleContext = new ResourceLoaderContext( $resourceLoader, new FauxRequest( $query ) );
-                               $url = $resourceLoader->createLoaderURL( $source, $moduleContext, $extraQuery );
-
-                               // Automatically select style/script elements
-                               if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
-                                       $link = Html::linkedStyle( $url );
-                               } else {
-                                       if ( $context->getRaw() || $isRaw ) {
-                                               // Startup module can't load itself, needs to use <script> instead of mw.loader.load
-                                               $link = Html::element( 'script', [
-                                                       // In SpecialJavaScriptTest, QUnit must load synchronous
-                                                       'async' => !isset( $extraQuery['sync'] ),
-                                                       'src' => $url
-                                               ] );
-                                       } else {
-                                               $link = ResourceLoader::makeInlineScript(
-                                                       Xml::encodeJsCall( 'mw.loader.load', [ $url ] )
-                                               );
-                                       }
-
-                                       // For modules requested directly in the html via <script> or mw.loader.load
-                                       // tell mw.loader they are being loading to prevent duplicate requests.
-                                       foreach ( $grpModules as $key => $module ) {
-                                               // Don't output state=loading for the startup module.
-                                               if ( $key !== 'startup' ) {
-                                                       $links['states'][$key] = 'loading';
-                                               }
-                                       }
-                               }
-
-                               if ( $group == 'noscript' ) {
-                                       $links['html'][] = Html::rawElement( 'noscript', [], $link );
-                               } else {
-                                       $links['html'][] = $link;
-                               }
-                       }
-               }
-
-               return $links;
+               return ResourceLoaderClientHtml::makeLoad(
+                       $this->getRlClientContext(),
+                       (array)$modules,
+                       $only,
+                       $extraQuery
+               );
        }
 
        /**
-        * Build html output from an array of links from makeResourceLoaderLink.
-        * @param array $links
+        * Combine WrappedString chunks and filter out empty ones
+        *
+        * @param array $chunks
         * @return string|WrappedStringList HTML
         */
-       protected static function getHtmlFromLoaderLinks( array $links ) {
-               $html = [];
-               $states = [];
-               foreach ( $links as $link ) {
-                       if ( !is_array( $link ) ) {
-                               $html[] = $link;
-                       } else {
-                               $html = array_merge( $html, $link['html'] );
-                               $states += $link['states'];
-                       }
-               }
+       protected static function combineWrappedStrings( array $chunks ) {
                // Filter out empty values
-               $html = array_filter( $html, 'strlen' );
-
-               if ( $states ) {
-                       array_unshift( $html, ResourceLoader::makeInlineScript(
-                               ResourceLoader::makeLoaderStateScript( $states )
-                       ) );
-               }
-
-               return WrappedString::join( "\n", $html );
+               $chunks = array_filter( $chunks, 'strlen' );
+               return WrappedString::join( "\n", $chunks );
        }
 
-       /**
-        * JS stuff to put in the "<head>". This is the startup module, config
-        * vars and modules marked with position 'top'
-        *
-        * @return string HTML fragment
-        */
-       function getHeadScripts() {
-               return $this->getInlineHeadScripts() . $this->getExternalHeadScripts();
-       }
-
-       /**
-        * <script src="..."> tags for "<head>".This is the startup module
-        * and other modules marked with position 'top'.
-        *
-        * @return string|WrappedStringList HTML
-        */
-       function getExternalHeadScripts() {
-               // Startup - this provides the client with the module
-               // manifest and loads jquery and mediawiki base modules
-               $links = [];
-               $links[] = $this->makeResourceLoaderLink( 'startup', ResourceLoaderModule::TYPE_SCRIPTS );
-               return self::getHtmlFromLoaderLinks( $links );
+       /** @return bool */
+       private function isUserModulePreview() {
+               return $this->getConfig()->get( 'AllowUserJs' )
+                       && $this->getUser()->isLoggedIn()
+                       && $this->getTitle()
+                       && $this->getTitle()->isJsSubpage()
+                       && $this->userCanPreview();
        }
 
        /**
-        * Inline "<script>" tags to put in "<head>".
+        * JS stuff to put at the bottom of the `<body>`. These are modules with position 'bottom',
+        * legacy scripts ($this->mScripts), and user JS.
         *
         * @return string|WrappedStringList HTML
         */
-       function getInlineHeadScripts() {
-               $links = [];
-
-               // Client profile classes for <html>. Allows for easy hiding/showing of UI components.
-               // Must be done synchronously on every page to avoid flashes of wrong content.
-               // Note: This class distinguishes MediaWiki-supported JavaScript from the rest.
-               // The "rest" includes browsers that support JavaScript but not supported by our runtime.
-               // For the performance benefit of the majority, this is added unconditionally here and is
-               // then fixed up by the startup module for unsupported browsers.
-               $links[] = Html::inlineScript(
-                       'document.documentElement.className = document.documentElement.className'
-                       . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );'
-               );
-
-               // Load config before anything else
-               $links[] = ResourceLoader::makeInlineScript(
-                       ResourceLoader::makeConfigSetScript( $this->getJSVars() )
-               );
-
-               // Load embeddable private modules before any loader links
-               // This needs to be TYPE_COMBINED so these modules are properly wrapped
-               // in mw.loader.implement() calls and deferred until mw.user is available
-               $embedScripts = [ 'user.options' ];
-               $links[] = $this->makeResourceLoaderLink(
-                       $embedScripts,
-                       ResourceLoaderModule::TYPE_COMBINED
-               );
-               // Separate user.tokens as otherwise caching will be allowed (T84960)
-               $links[] = $this->makeResourceLoaderLink(
-                       'user.tokens',
-                       ResourceLoaderModule::TYPE_COMBINED
-               );
-
-               // Modules requests - let the client calculate dependencies and batch requests as it likes
-               // Only load modules that have marked themselves for loading at the top
-               $modules = $this->getModules( true, 'top' );
-               if ( $modules ) {
-                       $links[] = ResourceLoader::makeInlineScript(
-                               Xml::encodeJsCall( 'mw.loader.load', [ $modules ] )
-                       );
+       public function getBottomScripts() {
+               $chunks = [];
+               $chunks[] = $this->getRlClient()->getBodyHtml();
+
+               // Legacy non-ResourceLoader scripts
+               $chunks[] = $this->mScripts;
+
+               // Exempt 'user' module
+               // - May need excludepages for live preview. (T28283)
+               // - Must use TYPE_COMBINED so its response is handled by mw.loader.implement() which
+               //   ensures execution is scheduled after the "site" module.
+               // - Don't load if module state is already resolved as "ready".
+               if ( $this->rlUserModuleState === 'loading' ) {
+                       if ( $this->isUserModulePreview() ) {
+                               $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED,
+                                       [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
+                               );
+                               $chunks[] = ResourceLoader::makeInlineScript(
+                                       Xml::encodeJsCall( 'mw.loader.using', [
+                                               [ 'user', 'site' ],
+                                               new XmlJsCode(
+                                                       'function () {'
+                                                               . Xml::encodeJsCall( '$.globalEval', [
+                                                                       $this->getRequest()->getText( 'wpTextbox1' )
+                                                               ] )
+                                                               . '}'
+                                               )
+                                       ] )
+                               );
+                               // FIXME: If the user is previewing, say, ./vector.js, his ./common.js will be loaded
+                               // asynchronously and may arrive *after* the inline script here. So the previewed code
+                               // may execute before ./common.js runs. Normally, ./common.js runs before ./vector.js.
+                               // Similarly, when previewing ./common.js and the user module does arrive first,
+                               // it will arrive without common.js and the inline script runs after.
+                               // Thus running common after the excluded subpage.
+                       } else {
+                               // Load normally
+                               $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED );
+                       }
                }
 
-               // "Scripts only" modules marked for top inclusion
-               $links[] = $this->makeResourceLoaderLink(
-                       $this->getModuleScripts( true, 'top' ),
-                       ResourceLoaderModule::TYPE_SCRIPTS
+               $chunks[] = ResourceLoader::makeInlineScript(
+                       ResourceLoader::makeConfigSetScript(
+                               [ 'wgPageParseReport' => $this->limitReportData ],
+                               true
+                       )
                );
 
-               return self::getHtmlFromLoaderLinks( $links );
-       }
-
-       /**
-        * JS stuff to put at the 'bottom', which goes at the bottom of the `<body>`.
-        * These are modules marked with position 'bottom', legacy scripts ($this->mScripts),
-        * site JS, and user JS.
-        *
-        * @param bool $unused Previously used to let this method change its output based
-        *  on whether it was called by getExternalHeadScripts() or getBottomScripts().
-        * @return string|WrappedStringList HTML
-        */
-       function getScriptsForBottomQueue( $unused = null ) {
-               // Scripts "only" requests marked for bottom inclusion
-               // If we're in the <head>, use load() calls rather than <script src="..."> tags
-               $links = [];
-
-               $links[] = $this->makeResourceLoaderLink( $this->getModuleScripts( true, 'bottom' ),
-                       ResourceLoaderModule::TYPE_SCRIPTS
-               );
-
-               // Modules requests - let the client calculate dependencies and batch requests as it likes
-               // Only load modules that have marked themselves for loading at the bottom
-               $modules = $this->getModules( true, 'bottom' );
-               if ( $modules ) {
-                       $links[] = ResourceLoader::makeInlineScript(
-                               Xml::encodeJsCall( 'mw.loader.load', [ $modules ] )
-                       );
-               }
-
-               // Legacy Scripts
-               $links[] = $this->mScripts;
-
-               // Add user JS if enabled
-               // This must use TYPE_COMBINED instead of only=scripts so that its request is handled by
-               // mw.loader.implement() which ensures that execution is scheduled after the "site" module.
-               if ( $this->getConfig()->get( 'AllowUserJs' )
-                       && $this->getUser()->isLoggedIn()
-                       && $this->getTitle()
-                       && $this->getTitle()->isJsSubpage()
-                       && $this->userCanPreview()
-               ) {
-                       // We're on a preview of a JS subpage. Exclude this page from the user module (T28283)
-                       // and include the draft contents as a raw script instead.
-                       $links[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED,
-                               [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
-                       );
-                       // Load the previewed JS
-                       $links[] = ResourceLoader::makeInlineScript(
-                               Xml::encodeJsCall( 'mw.loader.using', [
-                                       [ 'user', 'site' ],
-                                       new XmlJsCode(
-                                               'function () {'
-                                                       . Xml::encodeJsCall( '$.globalEval', [
-                                                               $this->getRequest()->getText( 'wpTextbox1' )
-                                                       ] )
-                                                       . '}'
-                                       )
-                               ] )
-                       );
-
-                       // FIXME: If the user is previewing, say, ./vector.js, his ./common.js will be loaded
-                       // asynchronously and may arrive *after* the inline script here. So the previewed code
-                       // may execute before ./common.js runs. Normally, ./common.js runs before ./vector.js.
-                       // Similarly, when previewing ./common.js and the user module does arrive first,
-                       // it will arrive without common.js and the inline script runs after.
-                       // Thus running common after the excluded subpage.
-               } else {
-                       // Include the user module normally, i.e., raw to avoid it being wrapped in a closure.
-                       $links[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED );
-               }
-
-               return self::getHtmlFromLoaderLinks( $links );
-       }
-
-       /**
-        * JS stuff to put at the bottom of the "<body>"
-        * @return string
-        */
-       function getBottomScripts() {
-               return $this->getScriptsForBottomQueue() .
-                       ResourceLoader::makeInlineScript(
-                               ResourceLoader::makeConfigSetScript(
-                                       [ 'wgPageParseReport' => $this->limitReportData ],
-                                       true
-                               )
-                       );
+               return self::combineWrappedStrings( $chunks );
        }
 
        /**
@@ -3616,79 +3392,76 @@ class OutputPage extends ContextSource {
        }
 
        /**
-        * Build a set of "<link>" elements for stylesheets specified in the $this->styles array.
+        * Build exempt modules and legacy non-ResourceLoader styles.
         *
         * @return string|WrappedStringList HTML
         */
-       public function buildCssLinks() {
+       protected function buildExemptModules() {
                global $wgContLang;
 
-               $this->getSkin()->setupSkinUserCss( $this );
-
-               // Add ResourceLoader styles
-               // Split the styles into these groups
-               $styles = [
-                       'other' => [],
-                       'user' => [],
-                       'site' => [],
-                       'private' => [],
-                       'noscript' => []
-               ];
-               $links = [];
-               $otherTags = []; // Tags to append after the normal <link> tags
                $resourceLoader = $this->getResourceLoader();
+               $chunks = [];
+               // Things that should be appended after the other link and style chunks
+               $append = [];
+               $moduleStyles = [
+                       'site.styles',
+                       'noscript'
+               ];
 
-               $moduleStyles = $this->getModuleStyles();
-
-               // Per-site custom styles
-               $moduleStyles[] = 'site.styles';
-               $moduleStyles[] = 'noscript';
-
-               // Per-user custom styles
+               // Exempt 'user' styles module.
+               // - May need excludepages for live preview.
+               // - Position after ResourceLoaderDynamicStyles marker
                if ( $this->getConfig()->get( 'AllowUserCss' ) && $this->getTitle()->isCssSubpage()
                        && $this->userCanPreview()
                ) {
-                       // We're on a preview of a CSS subpage
-                       // Exclude this page from the user module in case it's in there (bug 26283)
-                       $link = $this->makeResourceLoaderLink( 'user.styles', ResourceLoaderModule::TYPE_STYLES,
+                       $append[] = $this->makeResourceLoaderLink(
+                               'user.styles',
+                               ResourceLoaderModule::TYPE_STYLES,
                                [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
                        );
-                       $otherTags = array_merge( $otherTags, $link['html'] );
 
-                       // Load the previewed CSS
-                       // If needed, Janus it first. This is user-supplied CSS, so it's
-                       // assumed to be right for the content language directionality.
+                       // Load the previewed CSS. Janus it if needed.
+                       // User-supplied CSS is assumed to in the wiki's content language.
                        $previewedCSS = $this->getRequest()->getText( 'wpTextbox1' );
                        if ( $this->getLanguage()->getDir() !== $wgContLang->getDir() ) {
                                $previewedCSS = CSSJanus::transform( $previewedCSS, true, false );
                        }
-                       $otherTags[] = Html::inlineStyle( $previewedCSS );
+                       $append[] = Html::inlineStyle( $previewedCSS );
                } else {
-                       // Load the user styles normally
-                       $moduleStyles[] = 'user.styles';
+                       $module = $this->getResourceLoader()->getModule( 'user.styles' );
+                       if ( !$module->isKnownEmpty( $this->getRlClientContext() ) ) {
+                               // Load styles normally
+                               $moduleStyles[] = 'user.styles';
+                       }
                }
 
-               // Per-user preference styles
+               // Exempt 'user.cssprefs' module
+               // - Position after ResourceLoaderDynamicStyles marker
                $moduleStyles[] = 'user.cssprefs';
 
+               $groups = [
+                       'other' => [],
+                       'site' => [],
+                       'noscript' => [],
+                       'private' => [],
+                       'user' => [],
+               ];
                foreach ( $moduleStyles as $name ) {
                        $module = $resourceLoader->getModule( $name );
-                       if ( !$module ) {
+                       if ( !$module || $module->isKnownEmpty( $this->getRlClientContext() ) ) {
+                               // E.g. Don't output empty <styles> for user.cssprefs
                                continue;
                        }
                        if ( $name === 'site.styles' ) {
-                               // HACK: The site module shouldn't be fragmented with a cache group and
-                               // http request. But in order to ensure its styles are separated and after the
-                               // ResourceLoaderDynamicStyles marker, pretend it is in a group called 'site'.
-                               // The scripts remain ungrouped and rides the bottom queue.
-                               $styles['site'][] = $name;
+                               // HACK: Technically, the 'site.styles' module isn't in a separate request group.
+                               // But, in order to ensure its styles are in the right position after the marker,
+                               // pretend it's in a group called 'site'.
+                               $groups['site'][] = $name;
                                continue;
                        }
                        $group = $module->getGroup();
-                       // Modules in groups other than the ones needing special treatment
-                       // (see $styles assignment)
-                       // will be placed in the "other" style category.
-                       $styles[isset( $styles[$group] ) ? $group : 'other'][] = $name;
+                       // Use "other" in case. All exempt modules are in one of the known groups though.
+                       $groups[isset( $groups[$group] ) ? $group : 'other'][] = $name;
                }
 
                // We want site, private and user styles to override dynamically added
@@ -3696,33 +3469,23 @@ class OutputPage extends ContextSource {
                // statically added styles from other modules. So the order has to be
                // other, dynamic, site, private, user. Add statically added styles for
                // other modules
-               $links[] = $this->makeResourceLoaderLink(
-                       $styles['other'],
-                       ResourceLoaderModule::TYPE_STYLES
-               );
-               // Add normal styles added through addStyle()/addInlineStyle() here
-               $links[] = implode( '', $this->buildCssLinksArray() ) . $this->mInlineStyles;
-               // Add marker tag to mark the place where the client-side
-               // loader should inject dynamic styles
-               // We use a <meta> tag with a made-up name for this because that's valid HTML
-               $links[] = Html::element(
+
+               // Add legacy styles added through addStyle()/addInlineStyle() here
+               $chunks[] = implode( '', $this->buildCssLinksArray() ) . $this->mInlineStyles;
+
+               // Client-side mw.loader will inject dynamic styles before this marker.
+               $chunks[] = Html::element(
                        'meta',
                        [ 'name' => 'ResourceLoaderDynamicStyles', 'content' => '' ]
                );
 
-               // Add site-specific and user-specific styles
-               // 'private' at present only contains user.options, so put that before 'user'
-               // Any future private modules will likely have a similar user-specific character
-               foreach ( [ 'site', 'noscript', 'private', 'user' ] as $group ) {
-                       $links[] = $this->makeResourceLoaderLink( $styles[$group],
+               foreach ( [ 'other', 'site', 'noscript', 'private', 'user' ] as $group ) {
+                       $chunks[] = $this->makeResourceLoaderLink( $groups[$group],
                                ResourceLoaderModule::TYPE_STYLES
                        );
                }
 
-               // Add stuff in $otherTags (previewed user CSS if applicable)
-               $links[] = implode( '', $otherTags );
-
-               return self::getHtmlFromLoaderLinks( $links );
+               return self::combineWrappedStrings( array_merge( $chunks, $append ) );
        }
 
        /**
index 1db9ce5..418d17f 100644 (file)
@@ -121,7 +121,7 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext {
        }
 
        /**
-        * @param string $user
+        * @param string|null $user
         */
        public function setUser( $user ) {
                $this->user = $user;
index 1a93f6e..79eaf4b 100644 (file)
@@ -241,7 +241,7 @@ class ResourceLoader implements LoggerAwareInterface {
                $this->config = $config;
 
                // Add 'local' source first
-               $this->addSource( 'local', wfScript( 'load' ) );
+               $this->addSource( 'local', $config->get( 'LoadScript' ) );
 
                // Add other sources
                $this->addSource( $config->get( 'ResourceLoaderSources' ) );
diff --git a/includes/resourceloader/ResourceLoaderClientHtml.php b/includes/resourceloader/ResourceLoaderClientHtml.php
new file mode 100644 (file)
index 0000000..3093cde
--- /dev/null
@@ -0,0 +1,482 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use WrappedString\WrappedStringList;
+
+/**
+ * Bootstrap a ResourceLoader client on an HTML page.
+ *
+ * @since 1.28
+ */
+class ResourceLoaderClientHtml {
+
+       /** @var ResourceLoaderContext */
+       private $context;
+
+       /** @var ResourceLoader */
+       private $resourceLoader;
+
+       /** @var array */
+       private $config = [];
+
+       /** @var array */
+       private $modules = [];
+
+       /** @var array */
+       private $moduleStyles = [];
+
+       /** @var array */
+       private $moduleScripts = [];
+
+       /** @var array */
+       private $exemptStates = [];
+
+       /** @var array */
+       private $data;
+
+       /**
+        * @param ResourceLoaderContext $context
+        */
+       public function __construct( ResourceLoaderContext $context ) {
+               $this->context = $context;
+               $this->resourceLoader = $context->getResourceLoader();
+       }
+
+       /**
+        * Set mw.config variables.
+        *
+        * @param array $vars Array of key/value pairs
+        */
+       public function setConfig( array $vars ) {
+               foreach ( $vars as $key => $value ) {
+                       $this->config[$key] = $value;
+               }
+       }
+
+       /**
+        * Ensure one or more modules are loaded.
+        *
+        * @param array $modules Array of module names
+        */
+       public function setModules( array $modules ) {
+               $this->modules = $modules;
+       }
+
+       /**
+        * Ensure the styles of one or more modules are loaded.
+        *
+        * @deprecated since 1.28
+        * @param array $modules Array of module names
+        */
+       public function setModuleStyles( array $modules ) {
+               $this->moduleStyles = $modules;
+       }
+
+       /**
+        * Ensure the scripts of one or more modules are loaded.
+        *
+        * @deprecated since 1.28
+        * @param array $modules Array of module names
+        */
+       public function setModuleScripts( array $modules ) {
+               $this->moduleScripts = $modules;
+       }
+
+       /**
+        * Set state of special modules that are handled by the caller manually.
+        *
+        * See OutputPage::buildExemptModules() for use cases.
+        *
+        * @param array $modules Module state keyed by module name
+        */
+       public function setExemptStates( array $states ) {
+               $this->exemptStates = $states;
+       }
+
+       /**
+        * @return array
+        */
+       private function getData() {
+               if ( $this->data ) {
+                       // @codeCoverageIgnoreStart
+                       return $this->data;
+                       // @codeCoverageIgnoreEnd
+               }
+
+               $rl = $this->resourceLoader;
+               $data = [
+                       'states' => [
+                               // moduleName => state
+                       ],
+                       'general' => [
+                               // position => [ moduleName ]
+                               'top' => [],
+                               'bottom' => [],
+                       ],
+                       'styles' => [
+                               // moduleName
+                       ],
+                       'scripts' => [
+                               // position => [ moduleName ]
+                               'top' => [],
+                               'bottom' => [],
+                       ],
+                       // Embedding for private modules
+                       'embed' => [
+                               'styles' => [],
+                               'general' => [
+                                       'top' => [],
+                                       'bottom' => [],
+                               ],
+                       ],
+
+               ];
+
+               foreach ( $this->modules as $name ) {
+                       $module = $rl->getModule( $name );
+                       if ( !$module ) {
+                               continue;
+                       }
+
+                       $group = $module->getGroup();
+                       $position = $module->getPosition();
+
+                       if ( $group === 'private' ) {
+                               // Embed via mw.loader.implement per T36907.
+                               $data['embed']['general'][$position][] = $name;
+                               // Avoid duplicate request from mw.loader
+                               $data['states'][$name] = 'loading';
+                       } else {
+                               // Load via mw.loader.load()
+                               $data['general'][$position][] = $name;
+                       }
+               }
+
+               foreach ( $this->moduleStyles as $name ) {
+                       $module = $rl->getModule( $name );
+                       if ( !$module ) {
+                               continue;
+                       }
+
+                       if ( $module->getType() !== ResourceLoaderModule::LOAD_STYLES ) {
+                               $logger = $rl->getLogger();
+                               $logger->debug( 'Unexpected general module "{module}" in styles queue.', [
+                                       'module' => $name,
+                               ] );
+                       } else {
+                               // Stylesheet doesn't trigger mw.loader callback.
+                               // Set "ready" state to allow dependencies and avoid duplicate requests. (T87871)
+                               $data['states'][$name] = 'ready';
+                       }
+
+                       $group = $module->getGroup();
+                       $context = $this->getContext( $group, ResourceLoaderModule::TYPE_STYLES );
+                       if ( $module->isKnownEmpty( $context ) ) {
+                               // Avoid needless request for empty module
+                               $data['states'][$name] = 'ready';
+                       } else {
+                               if ( $group === 'private' ) {
+                                       // Embed via style element
+                                       $data['embed']['styles'][] = $name;
+                                       // Avoid duplicate request from mw.loader
+                                       $data['states'][$name] = 'ready';
+                               } else {
+                                       // Load from load.php?only=styles via <link rel=stylesheet>
+                                       $data['styles'][] = $name;
+                               }
+                       }
+               }
+
+               foreach ( $this->moduleScripts as $name ) {
+                       $module = $rl->getModule( $name );
+                       if ( !$module ) {
+                               continue;
+                       }
+
+                       $group = $module->getGroup();
+                       $position = $module->getPosition();
+                       $context = $this->getContext( $group, ResourceLoaderModule::TYPE_SCRIPTS );
+                       if ( $module->isKnownEmpty( $context ) ) {
+                               // Avoid needless request for empty module
+                               $data['states'][$name] = 'ready';
+                       } else {
+                               // Load from load.php?only=scripts via <script src></script>
+                               $data['scripts'][$position][] = $name;
+
+                               // Avoid duplicate request from mw.loader
+                               $data['states'][$name] = 'loading';
+                       }
+               }
+
+               return $data;
+       }
+
+       /**
+        * @return array Attribute key-value pairs for the HTML document element
+        */
+       public function getDocumentAttributes() {
+               return [ 'class' => 'client-nojs' ];
+       }
+
+       /**
+        * The order of elements in the head is as follows:
+        * - Inline scripts.
+        * - Stylesheets.
+        * - Async external script-src.
+        *
+        * Reasons:
+        * - Script execution may be blocked on preceeding stylesheets.
+        * - Async scripts are not blocked on stylesheets.
+        * - Inline scripts can't be asynchronous.
+        * - For styles, earlier is better.
+        *
+        * @return string|WrappedStringList HTML
+        */
+       public function getHeadHtml() {
+               $data = $this->getData();
+               $chunks = [];
+
+               // Change "client-nojs" class to client-js. This allows easy toggling of UI components.
+               // This happens synchronously on every page view to avoid flashes of wrong content.
+               // See also #getDocumentAttributes() and /resources/src/startup.js.
+               $chunks[] = Html::inlineScript(
+                       'document.documentElement.className = document.documentElement.className'
+                       . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );'
+               );
+
+               // Inline RLQ: Set page variables
+               if ( $this->config ) {
+                       $chunks[] = ResourceLoader::makeInlineScript(
+                               ResourceLoader::makeConfigSetScript( $this->config )
+                       );
+               }
+
+               // Inline RLQ: Initial module states
+               $states = array_merge( $this->exemptStates, $data['states'] );
+               if ( $states ) {
+                       $chunks[] = ResourceLoader::makeInlineScript(
+                               ResourceLoader::makeLoaderStateScript( $states )
+                       );
+               }
+
+               // Inline RLQ: Embedded modules
+               if ( $data['embed']['general']['top'] ) {
+                       $chunks[] = $this->getLoad(
+                               $data['embed']['general']['top'],
+                               ResourceLoaderModule::TYPE_COMBINED
+                       );
+               }
+
+               // Inline RLQ: Load general modules
+               if ( $data['general']['top'] ) {
+                       $chunks[] = ResourceLoader::makeInlineScript(
+                               Xml::encodeJsCall( 'mw.loader.load', [ $data['general']['top'] ] )
+                       );
+               }
+
+               // Inline RLQ: Load only=scripts
+               if ( $data['scripts']['top'] ) {
+                       $chunks[] = $this->getLoad(
+                               $data['scripts']['top'],
+                               ResourceLoaderModule::TYPE_SCRIPTS
+                       );
+               }
+
+               // External stylesheets
+               if ( $data['styles'] ) {
+                       $chunks[] = $this->getLoad(
+                               $data['styles'],
+                               ResourceLoaderModule::TYPE_STYLES
+                       );
+               }
+
+               // Inline stylesheets (embedded only=styles)
+               if ( $data['embed']['styles'] ) {
+                       $chunks[] = $this->getLoad(
+                               $data['embed']['styles'],
+                               ResourceLoaderModule::TYPE_STYLES
+                       );
+               }
+
+               // Async scripts. Once the startup is loaded, inline RLQ scripts will run.
+               $chunks[] = $this->getLoad( 'startup', ResourceLoaderModule::TYPE_SCRIPTS );
+
+               return WrappedStringList::join( "\n", $chunks );
+       }
+
+       /**
+        * @return string|WrappedStringList HTML
+        */
+       public function getBodyHtml() {
+               $data = $this->getData();
+               $chunks = [];
+
+               // Inline RLQ: Embedded modules
+               if ( $data['embed']['general']['bottom'] ) {
+                       $chunks[] = $this->getLoad(
+                               $data['embed']['general']['bottom'],
+                               ResourceLoaderModule::TYPE_COMBINED
+                       );
+               }
+
+               // Inline RLQ: Load only=scripts
+               if ( $data['scripts']['bottom'] ) {
+                       $chunks[] = $this->getLoad(
+                               $data['scripts']['bottom'],
+                               ResourceLoaderModule::TYPE_SCRIPTS
+                       );
+               }
+
+               // Inline RLQ: Load general modules
+               if ( $data['general']['bottom'] ) {
+                       $chunks[] = ResourceLoader::makeInlineScript(
+                               Xml::encodeJsCall( 'mw.loader.load', [ $data['general']['bottom'] ] )
+                       );
+               }
+
+               return WrappedStringList::join( "\n", $chunks );
+       }
+
+       private function getContext( $group, $type ) {
+               return self::makeContext( $this->context, $group, $type );
+       }
+
+       private function getLoad( $modules, $only ) {
+               return self::makeLoad( $this->context, (array)$modules, $only );
+       }
+
+       private static function makeContext( ResourceLoaderContext $mainContext, $group, $type,
+               array $extraQuery = []
+       ) {
+               // Create new ResourceLoaderContext so that $extraQuery may trigger isRaw().
+               $req = new FauxRequest( array_merge( $mainContext->getRequest()->getValues(), $extraQuery ) );
+               // Set 'only' if not combined
+               $req->setVal( 'only', $type === ResourceLoaderModule::TYPE_COMBINED ? null : $type );
+               // Remove user parameter in most cases
+               if ( $group !== 'user' && $group !== 'private' ) {
+                       $req->setVal( 'user', null );
+               }
+               $context = new ResourceLoaderContext( $mainContext->getResourceLoader(), $req );
+               // Allow caller to setVersion() and setModules()
+               return new DerivativeResourceLoaderContext( $context );
+       }
+
+       /**
+        * Explicily load or embed modules on a page.
+        *
+        * @param ResourceLoaderContext $mainContext
+        * @param array $modules One or more module names
+        * @param string $only ResourceLoaderModule TYPE_ class constant
+        * @param array $extraQuery [optional] Array with extra query parameters for the request
+        * @return string|WrappedStringList HTML
+        */
+       public static function makeLoad( ResourceLoaderContext $mainContext, array $modules, $only,
+               array $extraQuery = []
+       ) {
+               $rl = $mainContext->getResourceLoader();
+               $chunks = [];
+
+               if ( $mainContext->getDebug() && count( $modules ) > 1 ) {
+                       $chunks = [];
+                       // Recursively call us for every item
+                       foreach ( $modules as $name ) {
+                               $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery );
+                       }
+                       return new WrappedStringList( "\n", $chunks );
+               }
+
+               // Sort module names so requests are more uniform
+               sort( $modules );
+               // Create keyed-by-source and then keyed-by-group list of module objects from modules list
+               $sortedModules = [];
+               foreach ( $modules as $name ) {
+                       $module = $rl->getModule( $name );
+                       if ( !$module ) {
+                               $rl->getLogger()->warning( 'Unknown module "{module}"', [ 'module' => $name ] );
+                               continue;
+                       }
+                       $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module;
+               }
+
+               foreach ( $sortedModules as $source => $groups ) {
+                       foreach ( $groups as $group => $grpModules ) {
+                               $context = self::makeContext( $mainContext, $group, $only, $extraQuery );
+
+                               if ( $group === 'private' ) {
+                                       // Decide whether to use style or script element
+                                       if ( $only == ResourceLoaderModule::TYPE_STYLES ) {
+                                               $chunks[] = Html::inlineStyle(
+                                                       $rl->makeModuleResponse( $context, $grpModules )
+                                               );
+                                       } else {
+                                               $chunks[] = ResourceLoader::makeInlineScript(
+                                                       $rl->makeModuleResponse( $context, $grpModules )
+                                               );
+                                       }
+                                       continue;
+                               }
+
+                               // See if we have one or more raw modules
+                               $isRaw = false;
+                               foreach ( $grpModules as $key => $module ) {
+                                       $isRaw |= $module->isRaw();
+                               }
+
+                               // Special handling for the user group; because users might change their stuff
+                               // on-wiki like user pages, or user preferences; we need to find the highest
+                               // timestamp of these user-changeable modules so we can ensure cache misses on change
+                               // This should NOT be done for the site group (bug 27564) because anons get that too
+                               // and we shouldn't be putting timestamps in CDN-cached HTML
+                               if ( $group === 'user' ) {
+                                       $version = $rl->getCombinedVersion( $context, array_keys( $grpModules ) );
+                                       $context->setVersion( $version );
+                               }
+
+                               $context->setModules( array_keys( $grpModules ) );
+                               $url = $rl->createLoaderURL( $source, $context, $extraQuery );
+
+                               // Decide whether to use 'style' or 'script' element
+                               if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
+                                       $chunk = Html::linkedStyle( $url );
+                               } else {
+                                       if ( $context->getRaw() || $isRaw ) {
+                                               $chunk = Html::element( 'script', [
+                                                       // In SpecialJavaScriptTest, QUnit must load synchronous
+                                                       'async' => !isset( $extraQuery['sync'] ),
+                                                       'src' => $url
+                                               ] );
+                                       } else {
+                                               $chunk = ResourceLoader::makeInlineScript(
+                                                       Xml::encodeJsCall( 'mw.loader.load', [ $url ] )
+                                               );
+                                       }
+                               }
+
+                               if ( $group == 'noscript' ) {
+                                       $chunks[] = Html::rawElement( 'noscript', [], $chunk );
+                               } else {
+                                       $chunks[] = $chunk;
+                               }
+                       }
+               }
+
+               return new WrappedStringList( "\n", $chunks );
+       }
+}
index 85fc53d..8fa0411 100644 (file)
@@ -260,7 +260,7 @@ class ResourceLoaderContext {
 
        /**
         * @see ResourceLoaderModule::getVersionHash
-        * @see OutputPage::makeResourceLoaderLink
+        * @see ResourceLoaderClientHtml::makeLoad
         * @return string|null
         */
        public function getVersion() {
index b3b3f16..c1b47bf 100644 (file)
@@ -64,6 +64,13 @@ class ResourceLoaderUserOptionsModule extends ResourceLoaderModule {
                return false;
        }
 
+       /**
+        * @return string
+        */
+       public function getPosition() {
+               return 'top';
+       }
+
        /**
         * @return string
         */
index cea1f39..c8a0ff1 100644 (file)
@@ -74,6 +74,13 @@ class ResourceLoaderUserTokensModule extends ResourceLoaderModule {
                return false;
        }
 
+       /**
+        * @return string
+        */
+       public function getPosition() {
+               return 'top';
+       }
+
        /**
         * @return string
         */
index 5d36a3c..0e2e7db 100644 (file)
@@ -118,7 +118,7 @@ class SpecialJavaScriptTest extends SpecialPage {
                        . 'window.__karma__.loaded = function () {};'
                        . '}';
 
-               // The below is essentially a pure-javascript version of OutputPage::getHeadScripts.
+               // The below is essentially a pure-javascript version of OutputPage::headElement().
                $startup = $rl->makeModuleResponse( $startupContext, [
                        'startup' => $rl->getModule( 'startup' ),
                ] );
@@ -166,7 +166,7 @@ class SpecialJavaScriptTest extends SpecialPage {
                        [ 'raw' => true, 'sync' => true ]
                );
 
-               $head = implode( "\n", array_merge( $styles['html'], $scripts['html'] ) );
+               $head = implode( "\n", [ $styles, $scripts ] );
                $summary = $this->getSummaryHtml();
                $html = <<<HTML
 <!DOCTYPE html>
index 62ee94e..d026cb0 100644 (file)
@@ -77,7 +77,7 @@ function isCompatible( str ) {
        var NORLQ, script;
        if ( !isCompatible() ) {
                // Undo class swapping in case of an unsupported browser.
-               // See OutputPage::getHeadScripts().
+               // See ResourceLoaderClientHtml::getDocumentAttributes().
                document.documentElement.className = document.documentElement.className
                        .replace( /(^|\s)client-js(\s|$)/, '$1client-nojs$2' );
 
index 85bf954..84bf2fd 100644 (file)
@@ -64,10 +64,13 @@ class ResourceLoaderTestModule extends ResourceLoaderModule {
        protected $dependencies = [];
        protected $group = null;
        protected $source = 'local';
+       protected $position = 'bottom';
        protected $script = '';
        protected $styles = '';
        protected $skipFunction = null;
        protected $isRaw = false;
+       protected $isKnownEmpty = false;
+       protected $type = ResourceLoaderModule::LOAD_GENERAL;
        protected $targets = [ 'phpunit' ];
 
        public function __construct( $options = [] ) {
@@ -99,6 +102,13 @@ class ResourceLoaderTestModule extends ResourceLoaderModule {
        public function getSource() {
                return $this->source;
        }
+       public function getPosition() {
+               return $this->position;
+       }
+
+       public function getType() {
+               return $this->type;
+       }
 
        public function getSkipFunction() {
                return $this->skipFunction;
@@ -107,6 +117,9 @@ class ResourceLoaderTestModule extends ResourceLoaderModule {
        public function isRaw() {
                return $this->isRaw;
        }
+       public function isKnownEmpty( ResourceLoaderContext $context ) {
+               return $this->isKnownEmpty;
+       }
 
        public function enableModuleContentVersion() {
                return true;
index 9934749..c637d34 100644 (file)
@@ -139,72 +139,35 @@ class OutputPageTest extends MediaWikiTestCase {
        public static function provideMakeResourceLoaderLink() {
                // @codingStandardsIgnoreStart Generic.Files.LineLength
                return [
-                       // Load module script only
+                       // Single only=scripts load
                        [
                                [ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ],
                                "<script>(window.RLQ=window.RLQ||[]).push(function(){"
                                        . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.foo\u0026only=scripts\u0026skin=fallback");'
                                        . "});</script>"
                        ],
-                       [
-                               // Don't condition wrap raw modules (like the startup module)
-                               [ 'test.raw', ResourceLoaderModule::TYPE_SCRIPTS ],
-                               '<script async="" src="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.raw&amp;only=scripts&amp;skin=fallback"></script>'
-                       ],
-                       // Load module styles only
-                       // This also tests the order the modules are put into the url
+                       // Multiple only=styles load
                        [
                                [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ],
 
                                '<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles&amp;skin=fallback"/>'
                        ],
-                       // Load private module (only=scripts)
+                       // Private embed (only=scripts)
                        [
                                [ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ],
                                "<script>(window.RLQ=window.RLQ||[]).push(function(){"
                                        . "mw.test.baz({token:123});mw.loader.state({\"test.quux\":\"ready\"});"
                                        . "});</script>"
                        ],
-                       // Load private module (combined)
-                       [
-                               [ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ],
-                               "<script>(window.RLQ=window.RLQ||[]).push(function(){"
-                                       . "mw.loader.implement(\"test.quux\",function($,jQuery,require,module){"
-                                       . "mw.test.baz({token:123});},{\"css\":[\".mw-icon{transition:none}"
-                                       . "\"]});});</script>"
-                       ],
-                       // Load no modules
-                       [
-                               [ [], ResourceLoaderModule::TYPE_COMBINED ],
-                               '',
-                       ],
-                       // noscript group
-                       [
-                               [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ],
-                               '<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&amp;lang=en&amp;modules=test.noscript&amp;only=styles&amp;skin=fallback"/></noscript>'
-                       ],
-                       // Load two modules in separate groups
-                       [
-                               [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ],
-                               "<script>(window.RLQ=window.RLQ||[]).push(function(){"
-                                       . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.group.bar\u0026skin=fallback");'
-                                       . "});</script>\n"
-                                       . "<script>(window.RLQ=window.RLQ||[]).push(function(){"
-                                       . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.group.foo\u0026skin=fallback");'
-                                       . "});</script>"
-                       ],
                ];
                // @codingStandardsIgnoreEnd
        }
 
        /**
+        * See ResourceLoaderClientHtmlTest for full coverage.
+        *
         * @dataProvider provideMakeResourceLoaderLink
         * @covers OutputPage::makeResourceLoaderLink
-        * @covers ResourceLoader::makeLoaderImplementScript
-        * @covers ResourceLoader::makeModuleResponse
-        * @covers ResourceLoader::makeInlineScript
-        * @covers ResourceLoader::makeLoaderStateScript
-        * @covers ResourceLoader::createLoaderURL
         */
        public function testMakeResourceLoaderLink( $args, $expectedHtml ) {
                $this->setMwGlobals( [
@@ -238,25 +201,9 @@ class OutputPageTest extends MediaWikiTestCase {
                                'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }',
                                'group' => 'private',
                        ] ),
-                       'test.raw' => new ResourceLoaderTestModule( [
-                               'script' => 'mw.test.baz( { token: 123 } );',
-                               'isRaw' => true,
-                       ] ),
-                       'test.noscript' => new ResourceLoaderTestModule( [
-                               'styles' => '.mw-test-noscript { content: "style"; }',
-                               'group' => 'noscript',
-                       ] ),
-                       'test.group.bar' => new ResourceLoaderTestModule( [
-                               'styles' => '.mw-group-bar { content: "style"; }',
-                               'group' => 'bar',
-                       ] ),
-                       'test.group.foo' => new ResourceLoaderTestModule( [
-                               'styles' => '.mw-group-foo { content: "style"; }',
-                               'group' => 'foo',
-                       ] ),
                ] );
                $links = $method->invokeArgs( $out, $args );
-               $actualHtml = implode( "\n", $links['html'] );
+               $actualHtml = strval( $links );
                $this->assertEquals( $expectedHtml, $actualHtml );
        }
 
diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
new file mode 100644 (file)
index 0000000..0965b9f
--- /dev/null
@@ -0,0 +1,278 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderClientHtmlTest extends PHPUnit_Framework_TestCase {
+
+       protected static function makeContext( $extraQuery = [] ) {
+               $conf = new HashConfig( [
+                       'ResourceLoaderSources' => [],
+                       'ResourceModuleSkinStyles' => [],
+                       'ResourceModules' => [],
+                       'EnableJavaScriptTest' => false,
+                       'ResourceLoaderDebug' => false,
+                       'LoadScript' => '/w/load.php',
+               ] );
+               return new ResourceLoaderContext(
+                       new ResourceLoader( $conf ),
+                       new FauxRequest( array_merge( [
+                               'lang' => 'nl',
+                               'skin' => 'fallback',
+                               'user' => 'Example',
+                               'target' => 'phpunit',
+                       ], $extraQuery ) )
+               );
+       }
+
+       protected static function makeModule( array $options = [] ) {
+               return new ResourceLoaderTestModule( $options );
+       }
+
+       protected static function makeSampleModules() {
+               $modules = [
+                       'test' => [],
+                       'test.top' => [ 'position' => 'top' ],
+                       'test.private.top' => [ 'group' => 'private', 'position' => 'top' ],
+                       'test.private.bottom' => [ 'group' => 'private', 'position' => 'bottom' ],
+
+                       'test.styles.pure' => [ 'type' => ResourceLoaderModule::LOAD_STYLES ],
+                       'test.styles.mixed' => [],
+                       'test.styles.noscript' => [ 'group' => 'noscript', 'type' => ResourceLoaderModule::LOAD_STYLES ],
+                       'test.styles.mixed.user' => [ 'group' => 'user' ],
+                       'test.styles.mixed.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ],
+                       'test.styles.private' => [ 'group' => 'private', 'styles' => '.private{}' ],
+
+                       'test.scripts' => [],
+                       'test.scripts.top' => [ 'position' => 'top' ],
+                       'test.scripts.mixed.user' => [ 'group' => 'user' ],
+                       'test.scripts.mixed.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ],
+                       'test.scripts.raw' => [ 'isRaw' => true ],
+               ];
+               return array_map( function ( $options ) {
+                       return self::makeModule( $options );
+               }, $modules );
+       }
+
+       /**
+        * @covers ResourceLoaderClientHtml::getDocumentAttributes
+        */
+       public function testGetDocumentAttributes() {
+               $client = new ResourceLoaderClientHtml( self::makeContext() );
+               $this->assertInternalType( 'array', $client->getDocumentAttributes() );
+       }
+
+       /**
+        * @covers ResourceLoaderClientHtml::__construct
+        * @covers ResourceLoaderClientHtml::setModules
+        * @covers ResourceLoaderClientHtml::setModuleStyles
+        * @covers ResourceLoaderClientHtml::setModuleScripts
+        * @covers ResourceLoaderClientHtml::getData
+        * @covers ResourceLoaderClientHtml::getContext
+        */
+       public function testGetData() {
+               $context = self::makeContext();
+               $context->getResourceLoader()->register( self::makeSampleModules() );
+
+               $client = new ResourceLoaderClientHtml( $context );
+               $client->setModules( [
+                       'test',
+                       'test.private.bottom',
+                       'test.private.top',
+                       'test.top',
+                       'test.unregistered',
+               ] );
+               $client->setModuleStyles( [
+                       'test.styles.mixed',
+                       'test.styles.mixed.user.empty',
+                       'test.styles.private',
+                       'test.styles.pure',
+                       'test.unregistered.styles',
+               ] );
+               $client->setModuleScripts( [
+                       'test.scripts',
+                       'test.scripts.mixed.user.empty',
+                       'test.scripts.top',
+                       'test.unregistered.scripts',
+               ] );
+
+               $expected = [
+                       'states' => [
+                               'test.private.top' => 'loading',
+                               'test.private.bottom' => 'loading',
+                               'test.styles.pure' => 'ready',
+                               'test.styles.mixed.user.empty' => 'ready',
+                               'test.styles.private' => 'ready',
+                               'test.scripts' => 'loading',
+                               'test.scripts.top' => 'loading',
+                               'test.scripts.mixed.user.empty' => 'ready',
+                       ],
+                       'general' => [
+                               'top' => [ 'test.top' ],
+                               'bottom' => [ 'test' ],
+                       ],
+                       'styles' => [
+                               'test.styles.mixed',
+                               'test.styles.pure',
+                       ],
+                       'scripts' => [
+                               'top' => [ 'test.scripts.top' ],
+                               'bottom' => [ 'test.scripts' ],
+                       ],
+                       'embed' => [
+                               'styles' => [ 'test.styles.private' ],
+                               'general' => [
+                                       'top' => [ 'test.private.top' ],
+                                       'bottom' => [ 'test.private.bottom' ],
+                               ],
+                       ],
+               ];
+
+               $access = TestingAccessWrapper::newFromObject( $client );
+               $this->assertEquals( $expected, $access->getData() );
+       }
+
+       /**
+        * @covers ResourceLoaderClientHtml::setConfig
+        * @covers ResourceLoaderClientHtml::setExemptStates
+        * @covers ResourceLoaderClientHtml::getHeadHtml
+        * @covers ResourceLoaderClientHtml::getLoad
+        * @covers ResourceLoader::makeLoaderStateScript
+        */
+       public function testGetHeadHtml() {
+               $context = self::makeContext();
+               $context->getResourceLoader()->register( self::makeSampleModules() );
+
+               $client = new ResourceLoaderClientHtml( $context );
+               $client->setConfig( [ 'key' => 'value' ] );
+               $client->setModules( [
+                       'test.top',
+                       'test.private.top',
+               ] );
+               $client->setModuleStyles( [
+                       'test.styles.pure',
+                       'test.styles.private',
+               ] );
+               $client->setModuleScripts( [
+                       'test.scripts.top',
+               ] );
+               $client->setExemptStates( [
+                       'test.exempt' => 'ready',
+               ] );
+
+               // @codingStandardsIgnoreStart Generic.Files.LineLength
+               $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+                       . '<script>(window.RLQ=window.RLQ||[]).push(function(){'
+                       . 'mw.config.set({"key":"value"});'
+                       . 'mw.loader.state({"test.exempt":"ready","test.private.top":"loading","test.styles.pure":"ready","test.styles.private":"ready","test.scripts.top":"loading"});'
+                       . 'mw.loader.implement("test.private.top",function($,jQuery,require,module){},{"css":[]});'
+                       . 'mw.loader.load(["test.top"]);'
+                       . 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts.top\u0026only=scripts\u0026skin=fallback");'
+                       . '});</script>' . "\n"
+                       . '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>' . "\n"
+                       . '<style>.private{}</style>' . "\n"
+                       . '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback"></script>';
+               // @codingStandardsIgnoreEnd
+
+               $this->assertEquals( $expected, $client->getHeadHtml() );
+       }
+
+       /**
+        * @covers ResourceLoaderClientHtml::getBodyHtml
+        * @covers ResourceLoaderClientHtml::getLoad
+        */
+       public function testGetBodyHtml() {
+               $context = self::makeContext();
+               $context->getResourceLoader()->register( self::makeSampleModules() );
+
+               $client = new ResourceLoaderClientHtml( $context );
+               $client->setConfig( [ 'key' => 'value' ] );
+               $client->setModules( [
+                       'test',
+                       'test.private.bottom',
+               ] );
+               $client->setModuleScripts( [
+                       'test.scripts',
+               ] );
+
+               // @codingStandardsIgnoreStart Generic.Files.LineLength
+               $expected = '<script>(window.RLQ=window.RLQ||[]).push(function(){'
+                       . 'mw.loader.implement("test.private.bottom",function($,jQuery,require,module){},{"css":[]});'
+                       . 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts\u0026only=scripts\u0026skin=fallback");'
+                       . 'mw.loader.load(["test"]);'
+                       . '});</script>';
+               // @codingStandardsIgnoreEnd
+
+               $this->assertEquals( $expected, $client->getBodyHtml() );
+       }
+
+       public static function provideMakeLoad() {
+               return [
+                       // @codingStandardsIgnoreStart Generic.Files.LineLength
+                       [
+                               'context' => [],
+                               'modules' => [ 'test.unknown' ],
+                               'only' => ResourceLoaderModule::TYPE_STYLES,
+                               'output' => '',
+                       ],
+                       [
+                               'context' => [],
+                               'modules' => [ 'test.styles.private' ],
+                               'only' => ResourceLoaderModule::TYPE_STYLES,
+                               'output' => '<style>.private{}</style>',
+                       ],
+                       [
+                               'context' => [],
+                               'modules' => [ 'test.private.top' ],
+                               'only' => ResourceLoaderModule::TYPE_COMBINED,
+                               'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.private.top",function($,jQuery,require,module){},{"css":[]});});</script>',
+                       ],
+                       [
+                               'context' => [],
+                               // Eg. startup module
+                               'modules' => [ 'test.scripts.raw' ],
+                               'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+                               'output' => '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.scripts.raw&amp;only=scripts&amp;skin=fallback"></script>',
+                       ],
+                       [
+                               'context' => [],
+                               'modules' => [ 'test.scripts.mixed.user' ],
+                               'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+                               'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts.mixed.user\u0026only=scripts\u0026skin=fallback\u0026user=Example\u0026version=0a56zyi");});</script>',
+                       ],
+                       [
+                               'context' => [ 'debug' => true ],
+                               'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
+                               'only' => ResourceLoaderModule::TYPE_STYLES,
+                               'output' => '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>' . "\n"
+                                       . '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.mixed&amp;only=styles&amp;skin=fallback"/>',
+                       ],
+                       [
+                               'context' => [],
+                               'modules' => [ 'test.styles.noscript' ],
+                               'only' => ResourceLoaderModule::TYPE_STYLES,
+                               'output' => '<noscript><link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.noscript&amp;only=styles&amp;skin=fallback"/></noscript>',
+                       ],
+                       // @codingStandardsIgnoreEnd
+               ];
+       }
+
+       /**
+        * @dataProvider provideMakeLoad
+        * @covers ResourceLoaderClientHtml::makeLoad
+        * @covers ResourceLoaderClientHtml::makeContext
+        * @covers ResourceLoader::makeModuleResponse
+        * @covers ResourceLoaderModule::getModuleContent
+        * @covers ResourceLoader::getCombinedVersion
+        * @covers ResourceLoader::createLoaderURL
+        * @covers ResourceLoader::createLoaderQuery
+        * @covers ResourceLoader::makeLoaderQuery
+        * @covers ResourceLoader::makeInlineScript
+        */
+       public function testMakeLoad( array $extraQuery, array $modules, $type, $expected ) {
+               $context = self::makeContext( $extraQuery );
+               $context->getResourceLoader()->register( self::makeSampleModules() );
+               $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type );
+               $this->assertEquals( $expected, (string)$actual );
+       }
+}