Merge "RCFilters: Display specific error if query times out"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 24 Oct 2017 08:41:10 +0000 (08:41 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 24 Oct 2017 08:41:10 +0000 (08:41 +0000)
1  2 
includes/specialpage/ChangesListSpecialPage.php
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.less

@@@ -21,6 -21,7 +21,7 @@@
   * @ingroup SpecialPage
   */
  use MediaWiki\Logger\LoggerFactory;
+ use Wikimedia\Rdbms\DBQueryTimeoutError;
  use Wikimedia\Rdbms\ResultWrapper;
  use Wikimedia\Rdbms\FakeResultWrapper;
  use Wikimedia\Rdbms\IDatabase;
@@@ -542,45 -543,57 +543,57 @@@ abstract class ChangesListSpecialPage e
  
                $this->considerActionsForDefaultSavedQuery();
  
-               $rows = $this->getRows();
                $opts = $this->getOptions();
-               if ( $rows === false ) {
-                       $rows = new FakeResultWrapper( [] );
-               }
+               try {
+                       $rows = $this->getRows();
+                       if ( $rows === false ) {
+                               $rows = new FakeResultWrapper( [] );
+                       }
  
-               // Used by Structured UI app to get results without MW chrome
-               if ( $this->getRequest()->getVal( 'action' ) === 'render' ) {
-                       $this->getOutput()->setArticleBodyOnly( true );
-               }
+                       // Used by Structured UI app to get results without MW chrome
+                       if ( $this->getRequest()->getVal( 'action' ) === 'render' ) {
+                               $this->getOutput()->setArticleBodyOnly( true );
+                       }
  
-               // Used by "live update" and "view newest" to check
-               // if there's new changes with minimal data transfer
-               if ( $this->getRequest()->getBool( 'peek' ) ) {
+                       // Used by "live update" and "view newest" to check
+                       // if there's new changes with minimal data transfer
+                       if ( $this->getRequest()->getBool( 'peek' ) ) {
                        $code = $rows->numRows() > 0 ? 200 : 204;
-                       $this->getOutput()->setStatusCode( $code );
-                       return;
-               }
+                               $this->getOutput()->setStatusCode( $code );
+                               return;
+                       }
  
-               $batch = new LinkBatch;
-               foreach ( $rows as $row ) {
-                       $batch->add( NS_USER, $row->rc_user_text );
-                       $batch->add( NS_USER_TALK, $row->rc_user_text );
-                       $batch->add( $row->rc_namespace, $row->rc_title );
-                       if ( $row->rc_source === RecentChange::SRC_LOG ) {
-                               $formatter = LogFormatter::newFromRow( $row );
-                               foreach ( $formatter->getPreloadTitles() as $title ) {
-                                       $batch->addObj( $title );
+                       $batch = new LinkBatch;
+                       foreach ( $rows as $row ) {
+                               $batch->add( NS_USER, $row->rc_user_text );
+                               $batch->add( NS_USER_TALK, $row->rc_user_text );
+                               $batch->add( $row->rc_namespace, $row->rc_title );
+                               if ( $row->rc_source === RecentChange::SRC_LOG ) {
+                                       $formatter = LogFormatter::newFromRow( $row );
+                                       foreach ( $formatter->getPreloadTitles() as $title ) {
+                                               $batch->addObj( $title );
+                                       }
                                }
                        }
-               }
-               $batch->execute();
+                       $batch->execute();
+                       $this->setHeaders();
+                       $this->outputHeader();
+                       $this->addModules();
+                       $this->webOutput( $rows, $opts );
  
-               $this->setHeaders();
-               $this->outputHeader();
-               $this->addModules();
-               $this->webOutput( $rows, $opts );
+                       $rows->free();
+               } catch ( DBQueryTimeoutError $timeoutException ) {
+                       MWExceptionHandler::logException( $timeoutException );
  
-               $rows->free();
+                       $this->setHeaders();
+                       $this->outputHeader();
+                       $this->addModules();
+                       $this->getOutput()->setStatusCode( 500 );
+                       $this->webOutputHeader( 0, $opts );
+                       $this->outputTimeout();
+               }
  
                if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) {
                        // Clean up any bad page entries for titles showing up in RC
                        if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
                                // Only load queries that are 'version' 2, since those
                                // have parameter representation
 -                              if ( $savedQueries[ 'version' ] === '2' ) {
 +                              if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
                                        $savedQueryDefaultID = $savedQueries[ 'default' ];
                                        $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
  
                );
        }
  
+       /**
+        * Add the "timeout" message to the output
+        */
+       protected function outputTimeout() {
+               $this->getOutput()->addHTML(
+                       '<div class="mw-changeslist-timeout">' .
+                       $this->msg( 'recentchanges-timeout' )->parse() .
+                       '</div>'
+               );
+       }
        /**
         * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
         *
                return $unstructuredGroupDefinition;
        }
  
 +      /**
 +       * @return array The legacy show/hide toggle filters
 +       */
 +      protected function getLegacyShowHideFilters() {
 +              $filters = [];
 +              foreach ( $this->filterGroups as $group ) {
 +                      if ( $group instanceof  ChangesListBooleanFilterGroup ) {
 +                              foreach ( $group->getFilters() as $key => $filter ) {
 +                                      if ( $filter->displaysOnUnstructuredUi( $this ) ) {
 +                                              $filters[ $key ] = $filter;
 +                                      }
 +                              }
 +                      }
 +              }
 +              return $filters;
 +      }
 +
        /**
         * Register all the filters, including legacy hook-driven ones.
         * Then create a FormOptions object with options as specified by the user
                // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
                $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
  
 -              // Add all filters
                /** @var ChangesListFilterGroup $filterGroup */
                foreach ( $this->filterGroups as $filterGroup ) {
 -                      // URL parameters can be per-group, like 'userExpLevel',
 -                      // or per-filter, like 'hideminor'.
 -                      if ( $filterGroup->isPerGroupRequestParameter() ) {
 -                              $opts->add( $filterGroup->getName(), $useDefaults ? $filterGroup->getDefault() : '' );
 -                      } else {
 -                              /** @var ChangesListBooleanFilter $filter */
 -                              foreach ( $filterGroup->getFilters() as $filter ) {
 -                                      $opts->add( $filter->getName(), $useDefaults ? $filter->getDefault( $structuredUI ) : false );
 -                              }
 -                      }
 +                      $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
                }
  
                $opts->add( 'namespace', '', FormOptions::STRING );
                // or per-filter, like 'hideminor'.
  
                foreach ( $this->filterGroups as $filterGroup ) {
 -                      if ( $filterGroup->isPerGroupRequestParameter() ) {
 +                      if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
                                $stringParameterNameSet[$filterGroup->getName()] = true;
 -                      } elseif ( $filterGroup->getType() === ChangesListBooleanFilterGroup::TYPE ) {
 +                      } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
                                foreach ( $filterGroup->getFilters() as $filter ) {
                                        $hideParameterNameSet[$filter->getName()] = true;
                                }
         * @param FormOptions $opts
         */
        public function validateOptions( FormOptions $opts ) {
 -              if ( $this->fixContradictoryOptions( $opts ) ) {
 +              $isContradictory = $this->fixContradictoryOptions( $opts );
 +              $isReplaced = $this->replaceOldOptions( $opts );
 +
 +              if ( $isContradictory || $isReplaced ) {
                        $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
                        $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
                }
                return false;
        }
  
 +      /**
 +       * Replace old options 'hideanons' or 'hideliu' with structured UI equivalent
 +       *
 +       * @param FormOptions $opts
 +       * @return bool True if the change was made
 +       */
 +      public function replaceOldOptions( FormOptions $opts ) {
 +              if ( !$this->isStructuredFilterUiEnabled() ) {
 +                      return false;
 +              }
 +
 +              // At this point 'hideanons' and 'hideliu' cannot be both true,
 +              // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
 +              if ( $opts[ 'hideanons' ] ) {
 +                      $opts->reset( 'hideanons' );
 +                      $opts[ 'userExpLevel' ] = 'registered';
 +                      return true;
 +              }
 +
 +              if ( $opts[ 'hideliu' ] ) {
 +                      $opts->reset( 'hideliu' );
 +                      $opts[ 'userExpLevel' ] = 'unregistered';
 +                      return true;
 +              }
 +
 +              return false;
 +      }
 +
        /**
         * Convert parameters values from true/false to 1/0
         * so they are not omitted by wfArrayToCgi()
                $dbr = $this->getDB();
                $isStructuredUI = $this->isStructuredFilterUiEnabled();
  
 +              /** @var ChangesListFilterGroup $filterGroup */
                foreach ( $this->filterGroups as $filterGroup ) {
 -                      // URL parameters can be per-group, like 'userExpLevel',
 -                      // or per-filter, like 'hideminor'.
 -                      if ( $filterGroup->isPerGroupRequestParameter() ) {
 -                              $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
 -                                      $query_options, $join_conds, $opts[$filterGroup->getName()] );
 -                      } else {
 -                              foreach ( $filterGroup->getFilters() as $filter ) {
 -                                      if ( $filter->isActive( $opts, $isStructuredUI ) ) {
 -                                              $filter->modifyQuery( $dbr, $this, $tables, $fields, $conds,
 -                                                      $query_options, $join_conds );
 -                                      }
 -                              }
 -                      }
 +                      $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
 +                              $query_options, $join_conds, $opts, $isStructuredUI );
                }
  
                // Namespace filtering
        }
  
        /**
-        * Send output to the OutputPage object, only called if not used feeds
+        * Send header output to the OutputPage object, only called if not using feeds
         *
-        * @param ResultWrapper $rows Database rows
+        * @param int $rowCount Number of database rows
         * @param FormOptions $opts
         */
-       public function webOutput( $rows, $opts ) {
+       private function webOutputHeader( $rowCount, $opts ) {
                if ( !$this->including() ) {
                        $this->outputFeedLinks();
-                       $this->doHeader( $opts, $rows->numRows() );
+                       $this->doHeader( $opts, $rowCount );
                }
+       }
+       /**
+        * Send output to the OutputPage object, only called if not used feeds
+        *
+        * @param ResultWrapper $rows Database rows
+        * @param FormOptions $opts
+        */
+       public function webOutput( $rows, $opts ) {
+               $this->webOutputHeader( $rows->numRows(), $opts );
  
                $this->outputChangesList( $rows, $opts );
        }
diff --combined languages/i18n/en.json
        "anonpreviewwarning": "<em>You are not logged in. Saving will record your IP address in this page's edit history.</em>",
        "missingsummary": "<strong>Reminder:</strong> You have not provided an edit summary.\nIf you click \"$1\" again, your edit will be saved without one.",
        "selfredirect": "<strong>Warning:</strong> You are redirecting this page to itself.\nYou may have specified the wrong target for the redirect, or you may be editing the wrong page.\nIf you click \"$1\" again, the redirect will be created anyway.",
 -      "missingcommenttext": "Please enter a comment below.",
 +      "missingcommenttext": "Please enter a comment.",
        "missingcommentheader": "<strong>Reminder:</strong> You have not provided a subject for this comment.\nIf you click \"$1\" again, your edit will be saved without one.",
        "summary-preview": "Preview of edit summary:",
        "subject-preview": "Preview of subject:",
        "timezoneregion-europe": "Europe",
        "timezoneregion-indian": "Indian Ocean",
        "timezoneregion-pacific": "Pacific Ocean",
 -      "allowemail": "Enable email from other users",
 -      "email-blacklist-label": "Prohibit these users from sending emails to me:",
 +      "allowemail": "Allow other users to email me",
 +      "email-blacklist-label": "Prohibit these users from emailing me:",
        "prefs-searchoptions": "Search",
        "prefs-namespaces": "Namespaces",
        "default": "default",
        "recentchanges-summary": "Track the most recent changes to the wiki on this page.",
        "recentchangestext": "-",
        "recentchanges-noresult": "No changes during the given period match these criteria.",
+       "recentchanges-timeout": "This search has timed out. You may wish to try different search parameters.",
        "recentchanges-feed-description": "Track the most recent changes to the wiki in this feed.",
        "recentchanges-label-newpage": "This edit created a new page",
        "recentchanges-label-minor": "This is a minor edit",
        "recentchanges-submit": "Show",
        "rcfilters-tag-remove": "Remove '$1'",
        "rcfilters-legend-heading": "<strong>List of abbreviations:</strong>",
 -      "rcfilters-other-review-tools": "<strong>Other review tools</strong>",
 +      "rcfilters-other-review-tools": "Other review tools",
        "rcfilters-group-results-by-page": "Group results by page",
        "rcfilters-grouping-title": "Grouping",
        "rcfilters-activefilters": "Active filters",
        "rcfilters-days-show-hours": "$1 {{PLURAL:$1|hour|hours}}",
        "rcfilters-highlighted-filters-list": "Highlighted: $1",
        "rcfilters-quickfilters": "Saved filters",
 -      "rcfilters-quickfilters-placeholder-title": "No links saved yet",
 +      "rcfilters-quickfilters-placeholder-title": "No filters saved yet",
        "rcfilters-quickfilters-placeholder-description": "To save your filter settings and reuse them later, click the bookmark icon in the Active Filter area, below.",
        "rcfilters-savedqueries-defaultlabel": "Saved filters",
        "rcfilters-savedqueries-rename": "Rename",
        "rcfilters-filter-user-experience-level-unregistered-label": "Unregistered",
        "rcfilters-filter-user-experience-level-unregistered-description": "Editors who aren't logged-in.",
        "rcfilters-filter-user-experience-level-newcomer-label": "Newcomers",
 -      "rcfilters-filter-user-experience-level-newcomer-description": "Registered editors with fewer than 10 edits and 4 days of activity.",
 +      "rcfilters-filter-user-experience-level-newcomer-description": "Registered editors who have fewer than 10 edits or 4 days of activity.",
        "rcfilters-filter-user-experience-level-learner-label": "Learners",
        "rcfilters-filter-user-experience-level-learner-description": "Registered editors whose experience falls between \"Newcomers\" and \"Experienced users.\"",
        "rcfilters-filter-user-experience-level-experienced-label": "Experienced users",
        "rcfilters-view-namespaces-tooltip": "Filter results by namespace",
        "rcfilters-view-tags-tooltip": "Filter results using edit tags",
        "rcfilters-view-return-to-default-tooltip": "Return to main filter menu",
 -      "rcfilters-view-tags-help-icon-tooltip": "Learn more about Tagged Edits",
 +      "rcfilters-view-tags-help-icon-tooltip": "Learn more about Tagged edits",
        "rcfilters-liveupdates-button": "Live updates",
        "rcfilters-liveupdates-button-title-on": "Turn off live updates",
        "rcfilters-liveupdates-button-title-off": "Display new changes as they happen",
        "uploaded-script-svg": "Found scriptable element \"$1\" in the uploaded SVG file.",
        "uploaded-hostile-svg": "Found unsafe CSS in the style element of uploaded SVG file.",
        "uploaded-event-handler-on-svg": "Setting event-handler attributes <code>$1=\"$2\"</code> is not allowed in SVG files.",
 -      "uploaded-href-attribute-svg": "href attributes in SVG files are only allowed to link to http:// or https:// targets, found <code>&lt;$1 $2=\"$3\"&gt;</code>.",
 +      "uploaded-href-attribute-svg": "<a> elements can only link (href) to data: (embedded file), http:// or https://, or fragment (#, same-document) targets.  For other elements, such as <image>, only data: and fragment are allowed.  Try embedding images when exporting your SVG.  Found <code>&lt;$1 $2=\"$3\"&gt;</code>.",
        "uploaded-href-unsafe-target-svg": "Found href to unsafe data: URI target <code>&lt;$1 $2=\"$3\"&gt;</code> in the uploaded SVG file.",
        "uploaded-animate-svg": "Found \"animate\" tag that might be changing href, using the \"from\" attribute <code>&lt;$1 $2=\"$3\"&gt;</code> in the uploaded SVG file.",
        "uploaded-setting-event-handler-svg": "Setting event-handler attributes is blocked, found <code>&lt;$1 $2=\"$3\"&gt;</code> in the uploaded SVG file.",
diff --combined languages/i18n/qqq.json
        "period-pm": "Text indicating the second period of the day when using a 12-hour calendar.",
        "pagecategories": "Used in the categories section of pages.\n\nFollowed by a colon and a list of categories.\n\nParameters:\n* $1 - number of categories\n{{Identical|Category}}",
        "pagecategorieslink": "{{notranslate}}",
 -      "category_header": "In category description page. Parameters:\n* $1 - category name\nSee also:\n* {{msg-mw|Category-media-header}}",
 +      "category_header": "In category description page, be aware that this is not about biology, but about bibliography. Parameters:\n* $1 - category name\nSee also:\n* {{msg-mw|Category-media-header}}",
        "subcategories": "Used as a header on category pages that have subcategories.\n{{Identical|Subcategory}}",
        "category-media-header": "In category description page. Parameters:\n* $1 - category name\nSee also:\n* {{msg-mw|Category header}}",
        "category-empty": "The text displayed in category page when that category is empty",
        "recentchanges-summary": "Summary of [[Special:RecentChanges]].",
        "recentchangestext": "Text in [[Special:RecentChanges]]",
        "recentchanges-noresult": "Used in [[Special:RecentChanges]], [[Special:RecentChangesLinked]], and [[Special:Watchlist]] when there are no changes to be shown.",
+       "recentchanges-timeout": "Used in [[Special:RecentChanges]], [[Special:RecentChangesLinked]], and [[Special:Watchlist]] when a query times out.",
        "recentchanges-feed-description": "Used in feed of RecentChanges. See example [{{canonicalurl:Special:RecentChanges|feed=atom}} feed].",
        "recentchanges-label-newpage": "# Used as tooltip for {{msg-mw|Newpageletter}}.\n# Also used as legend. Preceded by {{msg-mw|Newpageletter}} and followed by {{msg-mw|Recentchanges-legend-newpage}}.",
        "recentchanges-label-minor": "# Used as tooltip for {{msg-mw|Minoreditletter}}\n# Also used as legend. Preceded by {{msg-mw|Minoreditletter}}",
        "recentchanges-legend-unpatrolled": "Used as legend on [[Special:RecentChanges]] and [[Special:Watchlist]].\n\nRefers to {{msg-mw|Recentchanges-label-unpatrolled}}.",
        "recentchanges-legend-plusminus": "{{optional}}\nA plus/minus sign with a number for the legend.",
        "recentchanges-submit": "Label for submit button in [[Special:RecentChanges]]\n{{Identical|Show}}",
 -      "rcfilters-tag-remove": "A tooltip for the button that removes a filter from the active filters area in [[Special:RecentChanges]] and [[Special:Watchlist]] when RCFilters are enabled. \n\nParameters: $1 - Tag label",
 +      "rcfilters-tag-remove": "A tooltip for the button that removes a filter from the active filters area in [[Special:RecentChanges]] and [[Special:Watchlist]] when RCFilters are enabled. \n\nParameters: $1 - Tag label\n{{Identical|Remove}}",
        "rcfilters-legend-heading": "Used as a heading for legend box on [[Special:RecentChanges]] and [[Special:Watchlist]] when RCFilters are enabled.",
        "rcfilters-other-review-tools": "Used as a heading for the community collection of other links on [[Special:RecentChanges]] when RCFilters are enabled.",
        "rcfilters-group-results-by-page": "A label for the checkbox describing whether the results in [[Special:RecentChanges]] are grouped by page when RCFilters are enabled.",
        "rcfilters-view-namespaces-tooltip": "Tooltip for the button that loads the namespace view in [[Special:RecentChanges]]",
        "rcfilters-view-tags-tooltip": "Tooltip for the button that loads the tags view in [[Special:RecentChanges]]",
        "rcfilters-view-return-to-default-tooltip": "Tooltip for the button that returns to the default filter view in [[Special:RecentChanges]]",
 -      "rcfilters-view-tags-help-icon-tooltip": "Tooltip for the help button that leads user to [[mw:Special:MyLanguage/Help:New_filters_for_edit_review/Advanced_filters#tags|Help page]] for Tagged Edits",
 +      "rcfilters-view-tags-help-icon-tooltip": "Tooltip for the help button that leads user to Special:Tags page",
        "rcfilters-liveupdates-button": "Label for the button to enable or disable live updates on [[Special:RecentChanges]]",
        "rcfilters-liveupdates-button-title-on": "Title for the button to enable or disable live updates on [[Special:RecentChanges]] when the feature is ON.",
        "rcfilters-liveupdates-button-title-off": "Title for the button to enable or disable live updates on [[Special:RecentChanges]] when the feature is OFF.",
diff --combined resources/Resources.php
@@@ -132,10 -132,14 +132,10 @@@ return 
        /* jQuery */
  
        'jquery' => [
 -              'scripts' => ( $GLOBALS['wgUsejQueryThree'] ?
 -                      [
 -                              'resources/lib/jquery/jquery3.js',
 -                              'resources/lib/jquery/jquery.migrate.js',
 -                      ] : [
 -                              'resources/lib/jquery/jquery.js',
 -                      ]
 -              ),
 +              'scripts' => [
 +                      'resources/lib/jquery/jquery3.js',
 +                      'resources/lib/jquery/jquery.migrate.js',
 +              ],
                'raw' => true,
                'targets' => [ 'desktop', 'mobile' ],
        ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
        'jquery.badge' => [
 +              'deprecated' => 'Please use Notifications instead.',
                'scripts' => 'resources/src/jquery/jquery.badge.js',
                'styles' => 'resources/src/jquery/jquery.badge.css',
                'dependencies' => 'mediawiki.language',
        'mediawiki.page.watch.ajax' => [
                'scripts' => 'resources/src/mediawiki/page/watch.js',
                'dependencies' => [
 -                      'mediawiki.page.startup',
                        'mediawiki.api.watch',
                        'mediawiki.notify',
                        'mediawiki.util',
        ],
        'mediawiki.rcfilters.filters.ui' => [
                'scripts' => [
 +                      'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js',
                        'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js',
                        'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js',
                        'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js',
                        'namespaces',
                        'invert',
                        'recentchanges-noresult',
+                       'recentchanges-timeout',
                        'quotation-marks',
                ],
                'dependencies' => [
                        'jquery.makeCollapsible',
                        'mediawiki.language',
                        'mediawiki.user',
 +                      'mediawiki.util',
                        'mediawiki.rcfilters.filters.dm',
                        'oojs-ui.styles.icons-content',
                        'oojs-ui.styles.icons-moderation',
         * @param {Object} [tagList] Tag definition
         */
        mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList ) {
-               var parsedSavedQueries,
+               var parsedSavedQueries, pieces,
                        displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
                        defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
                        controller = this,
                        views = {},
                        items = [],
-                       uri = new mw.Uri(),
-                       $changesList = $( '.mw-changeslist' ).first().contents();
+                       uri = new mw.Uri();
  
                // Prepare views
                if ( namespaceStructure ) {
                                        separator: ';',
                                        fullCoverage: true,
                                        filters: items
 +                              },
 +                              {
 +                                      name: 'invertGroup',
 +                                      type: 'boolean',
 +                                      hidden: true,
 +                                      filters: [ {
 +                                              name: 'invert',
 +                                              'default': '0'
 +                                      } ]
                                } ]
                        };
                }
                        // again
                        this.updateStateFromUrl( false );
  
+                       pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
                        // Update the changes list with the existing data
                        // so it gets processed
                        this.changesListModel.update(
-                               $changesList.length ? $changesList : 'NO_RESULTS',
-                               $( 'fieldset.cloptions' ).first(),
+                               pieces.changes,
+                               pieces.fieldset,
+                               pieces.noResultsDetails === 'NO_RESULTS_TIMEOUT',
                                true // We're using existing DOM elements
                        );
                }
                }
        };
  
+       /**
+        * Extracts information from the changes list DOM
+        *
+        * @param {jQuery} $root Root DOM to find children from
+        * @return {Object} Information about changes list
+        * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
+        *   (either normally or as an error)
+        * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
+        *   'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
+        * @return {jQuery} return.fieldset Fieldset
+        */
+       mw.rcfilters.Controller.prototype._extractChangesListInfo = function ( $root ) {
+               var info, isTimeout,
+                       $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
+                       areResults = !!$changesListContents.length;
+               info = {
+                       changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
+                       fieldset: $root.find( 'fieldset.cloptions' ).first()
+               };
+               if ( !areResults ) {
+                       isTimeout = !!$root.find( '.mw-changeslist-timeout' ).length;
+                       info.noResultsDetails = isTimeout ? 'NO_RESULTS_TIMEOUT' : 'NO_RESULTS_NORMAL';
+               }
+               return info;
+       };
        /**
         * Create filter data from a number, for the filters that are numerical value
         *
         * Reset to default filters
         */
        mw.rcfilters.Controller.prototype.resetToDefaults = function () {
 -              this.uriProcessor.updateModelBasedOnQuery( this._getDefaultParams() );
 +              this.filtersModel.updateStateFromParams( this._getDefaultParams() );
  
                this.updateChangesList();
        };
         * @return {boolean} Defaults are all false
         */
        mw.rcfilters.Controller.prototype.areDefaultsEmpty = function () {
 -              var defaultParams = this._getDefaultParams(),
 -                      defaultFilters = this.filtersModel.getFiltersFromParameters( defaultParams );
 -
 -              this._deleteExcludedValuesFromFilterState( defaultFilters );
 -
 -              if ( Object.keys( defaultParams ).some( function ( paramName ) {
 -                      return paramName.endsWith( '_color' ) && defaultParams[ paramName ] !== null;
 -              } ) ) {
 -                      // There are highlights in the defaults, they're definitely
 -                      // not empty
 -                      return false;
 -              }
 -
 -              // Defaults can change in a session, so we need to do this every time
 -              return Object.keys( defaultFilters ).every( function ( filterName ) {
 -                      return !defaultFilters[ filterName ];
 -              } );
 +              return $.isEmptyObject( this._getDefaultParams( true ) );
        };
  
        /**
                        .getHighlightedItems()
                        .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
  
 -              this.filtersModel.emptyAllFilters();
 -              this.filtersModel.clearAllHighlightColors();
 -              // Check all filter interactions
 -              this.filtersModel.reassessFilterInteractions();
 +              this.filtersModel.updateStateFromParams( {} );
  
                this.updateChangesList();
  
         */
        mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) {
                var filterItem = this.filtersModel.getItemByName( filterName ),
 -                      isHighlighted = filterItem.isHighlighted();
 +                      isHighlighted = filterItem.isHighlighted(),
 +                      isSelected = filterItem.isSelected();
  
 -              if ( filterItem.isSelected() || isHighlighted ) {
 +              if ( isSelected || isHighlighted ) {
                        this.filtersModel.clearHighlightColor( filterName );
                        this.filtersModel.toggleFilterSelected( filterName, false );
 -                      this.updateChangesList();
 +
 +                      if ( isSelected ) {
 +                              // Only update the changes list if the filter changed
 +                              // its selection state. If it only changed its highlight
 +                              // then don't reload
 +                              this.updateChangesList();
 +                      }
 +
                        this.filtersModel.reassessFilterInteractions( filterItem );
  
                        // Log filter grouping
         */
        mw.rcfilters.Controller.prototype.toggleHighlight = function () {
                this.filtersModel.toggleHighlight();
 -              this._updateURL();
 +              this.uriProcessor.updateURL();
  
                if ( this.filtersModel.isHighlightEnabled() ) {
                        mw.hook( 'RcFilters.highlight.enable' ).fire();
         */
        mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
                this.filtersModel.setHighlightColor( filterName, color );
 -              this._updateURL();
 +              this.uriProcessor.updateURL();
                this._trackHighlight( 'set', { name: filterName, color: color } );
        };
  
         */
        mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
                this.filtersModel.clearHighlightColor( filterName );
 -              this._updateURL();
 +              this.uriProcessor.updateURL();
                this._trackHighlight( 'clear', filterName );
        };
  
         * @param {boolean} [setAsDefault=false] This query should be set as the default
         */
        mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
 -              var highlightedItems = {},
 -                      highlightEnabled = this.filtersModel.isHighlightEnabled(),
 -                      selectedState = this.filtersModel.getSelectedState();
 -
 -              // Prepare highlights
 -              this.filtersModel.getHighlightedItems().forEach( function ( item ) {
 -                      highlightedItems[ item.getName() + '_color' ] = highlightEnabled ?
 -                              item.getHighlightColor() : null;
 -              } );
 -
 -              // Delete all excluded filters
 -              this._deleteExcludedValuesFromFilterState( selectedState );
 -
                // Add item
                this.savedQueriesModel.addNewQuery(
                        label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
 -                      {
 -                              params: $.extend(
 -                                      true,
 -                                      {
 -                                              invert: String( Number( this.filtersModel.areNamespacesInverted() ) ),
 -                                              highlight: String( Number( this.filtersModel.isHighlightEnabled() ) )
 -                                      },
 -                                      this.filtersModel.getParametersFromFilters( selectedState )
 -                              ),
 -                              highlights: highlightedItems
 -                      },
 +                      this.filtersModel.getCurrentParameterState( true ),
                        setAsDefault
                );
  
         * @param {string} queryID Query id
         */
        mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
 -              var highlights,
 -                      queryItem = this.savedQueriesModel.getItemByID( queryID ),
 -                      data = this.savedQueriesModel.getItemFullData( queryID ),
 -                      currentMatchingQuery = this.findQueryMatchingCurrentState();
 +              var currentMatchingQuery,
 +                      params = this.savedQueriesModel.getItemParams( queryID );
 +
 +              currentMatchingQuery = this.findQueryMatchingCurrentState();
  
                if (
 -                      queryItem &&
 -                      (
 -                              // If there's already a query, don't reload it
 -                              // if it's the same as the one that already exists
 -                              !currentMatchingQuery ||
 -                              currentMatchingQuery.getID() !== queryItem.getID()
 -                      )
 +                      currentMatchingQuery &&
 +                      currentMatchingQuery.getID() === queryID
                ) {
 -                      highlights = data.highlights;
 -
 -                      // Update model state from filters
 -                      this.filtersModel.toggleFiltersSelected(
 -                              // Merge filters with excluded values
 -                              $.extend(
 -                                      true,
 -                                      {},
 -                                      this.filtersModel.getFiltersFromParameters( data.params ),
 -                                      this.filtersModel.getExcludedFiltersState()
 -                              )
 -                      );
 -
 -                      // Update namespace inverted property
 -                      this.filtersModel.toggleInvertedNamespaces( !!Number( data.params.invert ) );
 -
 -                      // Update highlight state
 -                      this.filtersModel.toggleHighlight( !!Number( data.params.highlight ) );
 -                      this.filtersModel.getItems().forEach( function ( filterItem ) {
 -                              var color = highlights[ filterItem.getName() + '_color' ];
 -                              if ( color ) {
 -                                      filterItem.setHighlightColor( color );
 -                              } else {
 -                                      filterItem.clearHighlightColor();
 -                              }
 -                      } );
 +                      // If the query we want to load is the one that is already
 +                      // loaded, don't reload it
 +                      return;
 +              }
  
 -                      // Check all filter interactions
 -                      this.filtersModel.reassessFilterInteractions();
 +              // Apply parameters to model
 +              this.filtersModel.updateStateFromParams( params );
  
 -                      this.updateChangesList();
 +              this.updateChangesList();
  
 -                      // Log filter grouping
 -                      this.trackFilterGroupings( 'savedfilters' );
 -              }
 +              // Log filter grouping
 +              this.trackFilterGroupings( 'savedfilters' );
        };
  
        /**
         * Check whether the current filter and highlight state exists
         * in the saved queries model.
         *
 -       * @return {boolean} Query exists
 +       * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
         */
        mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
 -              var highlightedItems = {},
 -                      selectedState = this.filtersModel.getSelectedState();
 -
 -              // Prepare highlights of the current query
 -              this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
 -                      highlightedItems[ item.getName() + '_color' ] = item.getHighlightColor();
 -              } );
 -
 -              // Remove anything that should be excluded from the saved query
 -              // this includes sticky filters and filters marked with 'excludedFromSavedQueries'
 -              this._deleteExcludedValuesFromFilterState( selectedState );
 -
                return this.savedQueriesModel.findMatchingQuery(
 -                      {
 -                              params: $.extend(
 -                                      true,
 -                                      {
 -                                              highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ),
 -                                              invert: String( Number( this.filtersModel.areNamespacesInverted() ) )
 -                                      },
 -                                      this.filtersModel.getParametersFromFilters( selectedState )
 -                              ),
 -                              highlights: highlightedItems
 -                      }
 +                      this.filtersModel.getCurrentParameterState( true )
                );
        };
  
 -      /**
 -       * Delete sticky filters from given object
 -       *
 -       * @param {Object} filterState Filter state
 -       */
 -      mw.rcfilters.Controller.prototype._deleteExcludedValuesFromFilterState = function ( filterState ) {
 -              // Remove excluded filters
 -              $.each( this.filtersModel.getExcludedFiltersState(), function ( filterName ) {
 -                      delete filterState[ filterName ];
 -              } );
 -      };
 -
        /**
         * Save the current state of the saved queries model with all
         * query item representation in the user settings.
         * without adding an history entry.
         */
        mw.rcfilters.Controller.prototype.replaceUrl = function () {
 -              mw.rcfilters.UriProcessor.static.replaceState( this._getUpdatedUri() );
 +              this.uriProcessor.replaceUpdatedUri();
        };
  
        /**
                updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
  
                if ( updateMode === this.FILTER_CHANGE ) {
 -                      this._updateURL( params );
 +                      this.uriProcessor.updateURL( params );
                }
                if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
                        this.changesListModel.invalidate();
                                        this.changesListModel.update(
                                                $changesListContent,
                                                $fieldset,
+                                               pieces.noResultsDetails === 'NO_RESULTS_TIMEOUT',
                                                false,
                                                // separator between old and new changes
                                                updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
         * Get an object representing the default parameter state, whether
         * it is from the model defaults or from the saved queries.
         *
 +       * @param {boolean} [excludeHiddenParams] Exclude hidden and sticky params
         * @return {Object} Default parameters
         */
 -      mw.rcfilters.Controller.prototype._getDefaultParams = function () {
 -              var savedFilters,
 -                      data = ( !mw.user.isAnon() && this.savedQueriesModel.getItemFullData( this.savedQueriesModel.getDefault() ) ) || {};
 -
 -              if ( !$.isEmptyObject( data ) ) {
 -                      // Merge saved filter state with sticky filter values
 -                      savedFilters = $.extend(
 -                              true, {},
 -                              this.filtersModel.getFiltersFromParameters( data.params ),
 -                              this.filtersModel.getStickyFiltersState()
 -                      );
 -
 -                      // Return parameter representation
 -                      return $.extend( true, {},
 -                              this.filtersModel.getParametersFromFilters( savedFilters ),
 -                              data.highlights,
 -                              { highlight: data.params.highlight, invert: data.params.invert }
 -                      );
 -              }
 -              return this.filtersModel.getDefaultParams();
 -      };
 -
 -      /**
 -       * Update the URL of the page to reflect current filters
 -       *
 -       * This should not be called directly from outside the controller.
 -       * If an action requires changing the URL, it should either use the
 -       * highlighting actions below, or call #updateChangesList which does
 -       * the uri corrections already.
 -       *
 -       * @param {Object} [params] Extra parameters to add to the API call
 -       */
 -      mw.rcfilters.Controller.prototype._updateURL = function ( params ) {
 -              var currentUri = new mw.Uri(),
 -                      updatedUri = this._getUpdatedUri();
 -
 -              updatedUri.extend( params || {} );
 -
 -              if (
 -                      this.uriProcessor.getVersion( currentUri.query ) !== 2 ||
 -                      this.uriProcessor.isNewState( currentUri.query, updatedUri.query )
 -              ) {
 -                      mw.rcfilters.UriProcessor.static.replaceState( updatedUri );
 +      mw.rcfilters.Controller.prototype._getDefaultParams = function ( excludeHiddenParams ) {
 +              if ( this.savedQueriesModel.getDefault() ) {
 +                      return this.savedQueriesModel.getDefaultParams( excludeHiddenParams );
 +              } else {
 +                      return this.filtersModel.getDefaultParams( excludeHiddenParams );
                }
        };
  
 -      /**
 -       * Get an updated mw.Uri object based on the model state
 -       *
 -       * @return {mw.Uri} Updated Uri
 -       */
 -      mw.rcfilters.Controller.prototype._getUpdatedUri = function () {
 -              var uri = new mw.Uri();
 -
 -              // Minimize url
 -              uri.query = this.uriProcessor.minimizeQuery(
 -                      $.extend(
 -                              true,
 -                              {},
 -                              // We want to retain unrecognized params
 -                              // The uri params from model will override
 -                              // any recognized value in the current uri
 -                              // query, retain unrecognized params, and
 -                              // the result will then be minimized
 -                              uri.query,
 -                              this.uriProcessor.getUriParametersFromModel(),
 -                              { urlversion: '2' }
 -                      )
 -              );
 -
 -              return uri;
 -      };
 -
        /**
         * Query the list of changes from the server for the current filters
         *
         * @return {jQuery.Promise} Promise object resolved with { content, status }
         */
        mw.rcfilters.Controller.prototype._queryChangesList = function ( counterId, params ) {
 -              var uri = this._getUpdatedUri(),
 -                      stickyParams = this.filtersModel.getStickyParams(),
 +              var uri = this.uriProcessor.getUpdatedUri(),
 +                      stickyParams = this.filtersModel.getStickyParamsValues(),
                        requestId,
                        latestRequest;
  
                return this._queryChangesList( 'updateChangesList' )
                        .then(
                                function ( data ) {
-                                       var $parsed = $( '<div>' ).append( $( $.parseHTML( data.content ) ) ),
-                                               pieces = {
-                                                       // Changes list
-                                                       changes: $parsed.find( '.mw-changeslist' ).first().contents(),
-                                                       // Fieldset
-                                                       fieldset: $parsed.find( 'fieldset.cloptions' ).first()
-                                               };
-                                       if ( pieces.changes.length === 0 ) {
-                                               pieces.changes = 'NO_RESULTS';
-                                       }
+                                       var $parsed = $( '<div>' ).append( $( $.parseHTML( data.content ) ) );
  
-                                       return pieces;
-                               }
+                                       return this._extractChangesListInfo( $parsed );
+                               }.bind( this )
                        );
        };
  
@@@ -1,5 -1,4 +1,5 @@@
  @import 'mediawiki.mixins.animation';
 +@import 'mediawiki.ui/variables';
  @import 'mw.rcfilters.mixins';
  
  @rcfilters-spinner-width: 70px;
                min-height: @rcfilters-wl-head-min-height;
        }
  
 -      body:not( .mw-rcfilters-ui-initialized ) {
 -              .mw-recentchanges-toplinks-content.mw-rcfilters-toplinks-collapsed {
 -                      display: none;
 +      .mw-recentchanges-toplinks {
 +              margin-bottom: 0.5em;
 +              padding: 0 0.5em 0.5em 0.5em;
 +              border: 1px solid transparent;
 +
 +              &:not( .mw-recentchanges-toplinks-collapsed ) {
 +                      // Same as the legend
 +                      border: 1px solid @colorGray12;
                }
 +      }
  
 -              .mw-recentchanges-toplinks-title.mw-rcfilters-toplinks-collapsed {
 -                      // Hide, but keep the placement so we don't jump
 -                      visibility: hidden;
 +      body:not( .mw-rcfilters-ui-initialized ) {
 +              .mw-recentchanges-toplinks.mw-recentchanges-toplinks-collapsed {
 +                      // Similar to the watchlist-details hack, we are going to make this float left
 +                      // while loading to prevent jumpiness in the min-height calculation
 +                      float: left;
 +
 +                      .mw-recentchanges-toplinks-content {
 +                              display: none;
 +                      }
                }
  
                .rcfilters-head {
        }
  
        .mw-changeslist {
-               &-empty {
-                       // Hide the 'empty' message when we load rcfilters
-                       // since we replace it anyways with a specific
-                       // message of our own
-                       display: none;
-               }
                // Reserve space for the highlight circles
                ul,
                table.mw-enhanced-rc {
                }
        }
  
+       // Temporarily hide any 'empty' or 'timeout' message while we
+       // load rcfilters.
+       .mw-changeslist-empty,
+       .mw-changeslist-timeout {
+               display: none;
+       }
        body.mw-rcfilters-ui-loading .mw-changeslist {
                opacity: 0.5;
        }
@@@ -97,7 -84,7 +97,7 @@@
                        display: inline-block;
                        width: 12px;
                        height: 12px;
 -                      background-color: #c8ccd1;
 +                      background-color: @colorGray12;
                        border-radius: 100%;
                        .animation( rcfiltersBouncedelay 1.5s ease-in-out -0.16s infinite both );
                }
                transform: scale( 0.7 );
        }
        40% {
 -              background-color: #a2a9b1;
 +              background-color: @colorGray10;
                -webkit-transform: scale( 1 );
                transform: scale( 1 );
        }
                transform: scale( 0.7 );
        }
        40% {
 -              background-color: #a2a9b1;
 +              background-color: @colorGray10;
                -moz-transform: scale( 0.7 );
                transform: scale( 1 );
        }
                transform: scale( 0.7 );
        }
        40% {
 -              background-color: #a2a9b1;
 +              background-color: @colorGray10;
                transform: scale( 1 );
        }
  }