jenkins-bot has submitted this change and it was merged.

Change subject: resourceloader: Move queue formatting out of OutputPage
......................................................................


resourceloader: Move queue formatting out of OutputPage

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
---
M autoload.php
M docs/kss/Makefile
M includes/OutputPage.php
M includes/resourceloader/DerivativeResourceLoaderContext.php
M includes/resourceloader/ResourceLoader.php
A includes/resourceloader/ResourceLoaderClientHtml.php
M includes/resourceloader/ResourceLoaderContext.php
M includes/resourceloader/ResourceLoaderUserOptionsModule.php
M includes/resourceloader/ResourceLoaderUserTokensModule.php
M includes/specials/SpecialJavaScriptTest.php
M resources/src/startup.js
M tests/phpunit/ResourceLoaderTestCase.php
M tests/phpunit/includes/OutputPageTest.php
A tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
14 files changed, 1,018 insertions(+), 520 deletions(-)

Approvals:
  Gilles: Looks good to me, approved
  Jforrester: Looks good to me, but someone else must approve
  jenkins-bot: Verified



diff --git a/autoload.php b/autoload.php
index 2587add..7d5b9fb 100644
--- a/autoload.php
+++ b/autoload.php
@@ -1146,6 +1146,7 @@
        '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',
diff --git a/docs/kss/Makefile b/docs/kss/Makefile
index dadfb47..392ad1a 100644
--- a/docs/kss/Makefile
+++ b/docs/kss/Makefile
@@ -5,7 +5,7 @@
 # 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)
diff --git a/includes/OutputPage.php b/includes/OutputPage.php
index 753c3b7..53eb64c 100644
--- a/includes/OutputPage.php
+++ b/includes/OutputPage.php
@@ -154,6 +154,15 @@
        /** @var ResourceLoader */
        protected $mResourceLoader;
 
+       /** @var ResourceLoaderClientHtml */
+       private $rlClient;
+
+       /** @var ResourceLoaderContext */
+       private $rlClientContext;
+
+       /** @var string */
+       private $rlUserModuleState;
+
        /** @var array */
        protected $mJsConfigVars = [];
 
@@ -501,7 +510,7 @@
         * 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 @@
         * @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 @@
         *
         * @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 @@
         *
         * @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 @@
                        // 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 @@
                        // 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 @@
                $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,15 +2691,14 @@
                $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' 
) ) ) {
@@ -2637,22 +2714,11 @@
                }
 
                $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 @@
 
                $pieces[] = Html::openElement( 'body', $bodyAttrs );
 
-               return WrappedStringList::join( "\n", $pieces );
+               return self::combineWrappedStrings( $pieces );
        }
 
        /**
@@ -2706,388 +2772,98 @@
        }
 
        /**
-        * 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 );
-       }
-
-       /**
-        * Inline "<script>" tags to put in "<head>".
-        *
-        * @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 
] )
-                       );
-               }
-
-               // "Scripts only" modules marked for top inclusion
-               $links[] = $this->makeResourceLoaderLink(
-                       $this->getModuleScripts( true, 'top' ),
-                       ResourceLoaderModule::TYPE_SCRIPTS
-               );
-
-               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' )
+       /** @return bool */
+       private function isUserModulePreview() {
+               return $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 );
+                       && $this->userCanPreview();
        }
 
        /**
-        * JS stuff to put at the bottom of the "<body>"
-        * @return string
+        * 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 getBottomScripts() {
-               return $this->getScriptsForBottomQueue() .
-                       ResourceLoader::makeInlineScript(
-                               ResourceLoader::makeConfigSetScript(
-                                       [ 'wgPageParseReport' => 
$this->limitReportData ],
-                                       true
-                               )
-                       );
+       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 );
+                       }
+               }
+
+               $chunks[] = ResourceLoader::makeInlineScript(
+                       ResourceLoader::makeConfigSetScript(
+                               [ 'wgPageParseReport' => $this->limitReportData 
],
+                               true
+                       )
+               );
+
+               return self::combineWrappedStrings( $chunks );
        }
 
        /**
@@ -3616,79 +3392,76 @@
        }
 
        /**
-        * 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 @@
                // 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 ) );
        }
 
        /**
diff --git a/includes/resourceloader/DerivativeResourceLoaderContext.php 
b/includes/resourceloader/DerivativeResourceLoaderContext.php
index 1db9ce5..418d17f 100644
--- a/includes/resourceloader/DerivativeResourceLoaderContext.php
+++ b/includes/resourceloader/DerivativeResourceLoaderContext.php
@@ -121,7 +121,7 @@
        }
 
        /**
-        * @param string $user
+        * @param string|null $user
         */
        public function setUser( $user ) {
                $this->user = $user;
diff --git a/includes/resourceloader/ResourceLoader.php 
b/includes/resourceloader/ResourceLoader.php
index 1a93f6e..79eaf4b 100644
--- a/includes/resourceloader/ResourceLoader.php
+++ b/includes/resourceloader/ResourceLoader.php
@@ -241,7 +241,7 @@
                $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
index 0000000..3093cde
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderClientHtml.php
@@ -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 );
+       }
+}
diff --git a/includes/resourceloader/ResourceLoaderContext.php 
b/includes/resourceloader/ResourceLoaderContext.php
index 85fc53d..8fa0411 100644
--- a/includes/resourceloader/ResourceLoaderContext.php
+++ b/includes/resourceloader/ResourceLoaderContext.php
@@ -260,7 +260,7 @@
 
        /**
         * @see ResourceLoaderModule::getVersionHash
-        * @see OutputPage::makeResourceLoaderLink
+        * @see ResourceLoaderClientHtml::makeLoad
         * @return string|null
         */
        public function getVersion() {
diff --git a/includes/resourceloader/ResourceLoaderUserOptionsModule.php 
b/includes/resourceloader/ResourceLoaderUserOptionsModule.php
index b3b3f16..c1b47bf 100644
--- a/includes/resourceloader/ResourceLoaderUserOptionsModule.php
+++ b/includes/resourceloader/ResourceLoaderUserOptionsModule.php
@@ -67,6 +67,13 @@
        /**
         * @return string
         */
+       public function getPosition() {
+               return 'top';
+       }
+
+       /**
+        * @return string
+        */
        public function getGroup() {
                return 'private';
        }
diff --git a/includes/resourceloader/ResourceLoaderUserTokensModule.php 
b/includes/resourceloader/ResourceLoaderUserTokensModule.php
index cea1f39..c8a0ff1 100644
--- a/includes/resourceloader/ResourceLoaderUserTokensModule.php
+++ b/includes/resourceloader/ResourceLoaderUserTokensModule.php
@@ -77,6 +77,13 @@
        /**
         * @return string
         */
+       public function getPosition() {
+               return 'top';
+       }
+
+       /**
+        * @return string
+        */
        public function getGroup() {
                return 'private';
        }
diff --git a/includes/specials/SpecialJavaScriptTest.php 
b/includes/specials/SpecialJavaScriptTest.php
index 5d36a3c..0e2e7db 100644
--- a/includes/specials/SpecialJavaScriptTest.php
+++ b/includes/specials/SpecialJavaScriptTest.php
@@ -118,7 +118,7 @@
                        . '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 @@
                        [ '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>
diff --git a/resources/src/startup.js b/resources/src/startup.js
index 62ee94e..d026cb0 100644
--- a/resources/src/startup.js
+++ b/resources/src/startup.js
@@ -77,7 +77,7 @@
        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' );
 
diff --git a/tests/phpunit/ResourceLoaderTestCase.php 
b/tests/phpunit/ResourceLoaderTestCase.php
index 85bf954..84bf2fd 100644
--- a/tests/phpunit/ResourceLoaderTestCase.php
+++ b/tests/phpunit/ResourceLoaderTestCase.php
@@ -64,10 +64,13 @@
        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 @@
        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 @@
        public function isRaw() {
                return $this->isRaw;
        }
+       public function isKnownEmpty( ResourceLoaderContext $context ) {
+               return $this->isKnownEmpty;
+       }
 
        public function enableModuleContentVersion() {
                return true;
diff --git a/tests/phpunit/includes/OutputPageTest.php 
b/tests/phpunit/includes/OutputPageTest.php
index 9934749..c637d34 100644
--- a/tests/phpunit/includes/OutputPageTest.php
+++ b/tests/phpunit/includes/OutputPageTest.php
@@ -139,58 +139,24 @@
        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>"
                        ],
                ];
@@ -198,13 +164,10 @@
        }
 
        /**
+        * 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 @@
                                '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
index 0000000..0965b9f
--- /dev/null
+++ b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
@@ -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 );
+       }
+}

-- 
To view, visit https://gerrit.wikimedia.org/r/299272
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I605b8cd1e1fc009b4662a0edbc54d09dd65ee1df
Gerrit-PatchSet: 35
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: Krinkle <krinklem...@gmail.com>
Gerrit-Reviewer: Catrope <roan.katt...@gmail.com>
Gerrit-Reviewer: Gilles <gdu...@wikimedia.org>
Gerrit-Reviewer: Jforrester <jforres...@wikimedia.org>
Gerrit-Reviewer: Krinkle <krinklem...@gmail.com>
Gerrit-Reviewer: Legoktm <legoktm.wikipe...@gmail.com>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to