Merge "Introduce ContentHandler::getSecondaryDataUpdates."
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Mon, 17 Sep 2018 14:06:19 +0000 (14:06 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Mon, 17 Sep 2018 14:06:19 +0000 (14:06 +0000)
1  2 
RELEASE-NOTES-1.32
autoload.php
docs/hooks.txt
includes/Revision/RenderedRevision.php
includes/Storage/DerivedPageDataUpdater.php
includes/content/ContentHandler.php
includes/page/Article.php

diff --combined RELEASE-NOTES-1.32
@@@ -36,10 -36,6 +36,10 @@@ production
    (e.g. MediaWiki:Common.js), CSS or JSON was separated from 'editinterface'
    and is available under 'editsitejs'/'editsitecss'/'editsitejson'. Having
    'editinterface' is still necessary to edit such pages.
 +* $wgMultiContentRevisionSchemaMigrationStage now defaults to writing both the
 +  old and the new schema, but reading the new schema, so Multi-Content Revisions
 +  (MCR) are now functional per default. The new default value of the setting is
 +  SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW.
  
  ==== Removed configuration ====
  * $wgEnableAPI and $wgEnableWriteAPI – These settings, deprecated in 1.31,
  === Bug fixes in 1.32 ===
  * SpecialPage::execute() will now only call checkLoginSecurityLevel() if
    getLoginSecurityLevel() returns non-false.
 +* (T43720, T46197) Improved page display title handling for category pages
  
  === Action API changes in 1.32 ===
  * Added templated parameters.
@@@ -294,23 -289,6 +294,23 @@@ because of Phabricator reports
    Define $wgProfiler via LocalSettings.php instead.
  * The mw.loader.addSource() is now considered a private method, and no longer
    supports the `id, url` signature. Use the `Object` parameter instead.
 +* The backwards-compatibility code in HTMLForm to add a drop-down control to an
 +  option that is not set to be a drop-down if the "mw-chosen" class is present,
 +  is now removed.
 +* Several collations were removed. They were workarounds for bugs in the ICU
 +  library and they are no longer needed (as of ICU 57.1):
 +  * 'uppercase-se' (NorthernSamiUppercaseCollation) - use 'uca-se' instead
 +  * 'xx-uca-et' (CollationEt) - use 'uca-et' instead
 +  * 'xx-uca-fa' (CollationFa) - use 'uca-fa' instead
 +* The hooks 'SpecialRecentChangesFilters' & 'SpecialWatchlistFilters' deprecated
 +  in 1.23 were removed. Instead, use 'ChangesListSpecialPageStructuredFilters'.
 +  The ChangesListSpecialPage code for these legacy hooks, and their use in
 +  SpecialRecentchanges.php and SpecialWatchlist, was also removed:
 +  * ChangesListSpecialPage->getCustomFilters()
 +  * ChangesListSpecialPage->getFilterGroupDefinitionFromLegacyCustomFilters()
 +  * ChangesListSpecialPage::customFilters
 +* The global function wfUseMW, deprecated since 1.26, has now been removed. Use
 +  the "requires" property of static extension registration instead.
  
  === Deprecations in 1.32 ===
  * HTMLForm::setSubmitProgressive() is deprecated. No need to call it. Submit
  * Overriding SearchEngine::{searchText,searchTitle,searchArchiveTitle}
    in extending classes is deprecated.  Extend related doSearch* methods
    instead.
 -* CollationFa has been removed completely as it's not needed anymore
  * The following 'mediawiki.api' plugin modules were merged into mediawiki.api
    and deprecated: mediawiki.api.category, mediawiki.api.edit,
    mediawiki.api.login, mediawiki.api.options, mediawiki.api.parse,
    'help', 'help-message', 'help-messages' instead.
  * (T197179) HTMLFormField::getNotices() is now deprecated.
  * The jquery.localize module is now deprecated. Use jquery.i18n instead.
+ * The SecondaryDataUpdates hook was deprecated in favor of RevisionDataUpdates,
+   or overriding ContentHandler::getSecondaryDataUpdates (T194038).
+ * The WikiPageDeletionUpdates hook was deprecated in favor of
+   PageDeletionDataUpdates, or overriding ContentHandler::getDeletionDataUpdates
+   (T194038).
+ * Content::getSecondaryDataUpdates has been deprecated in favor of
+   ContentHandler::getSecondaryDataUpdates() for overriding by extensions
+   (T194038).
+   Application logic should call WikiPage::doSecondaryDataUpdates() (T194037).
+ * Content::getDeletionUpdates has been deprecated in favor of
+   ContentHandler::getDeletionUpdates() for overriding by extensions (T194038).
+   Application logic should call WikiPage::doSecondaryDataUpdates() (T194037).
  
  === Other changes in 1.32 ===
  * (T198811) The following tables have had their UNIQUE indexes turned into
    `'help-inline' => false`.
  * The archive table's ar_rev_id field is now unique.
  * Special:BotPasswords now requires reauthentication.
 +* (T174023) Multi-Content Revision (MCR) capabilities were introduced into the
 +  storage layer and have basic support for display. No user interface exists
 +  yet for creating or managing content in slots beides the main slot. See
 +  <https://www.mediawiki.org/wiki/Multi-Content_Revisions> for more
 +  information.
  * …
  
  == Compatibility ==
diff --combined autoload.php
@@@ -285,6 -285,7 +285,6 @@@ $wgAutoloadLocalClasses = 
        'CodeContentHandler' => __DIR__ . '/includes/content/CodeContentHandler.php',
        'Collation' => __DIR__ . '/includes/collation/Collation.php',
        'CollationCkb' => __DIR__ . '/includes/collation/CollationCkb.php',
 -      'CollationEt' => __DIR__ . '/includes/collation/CollationEt.php',
        'CommandLineInc' => __DIR__ . '/maintenance/commandLine.inc',
        'CommandLineInstaller' => __DIR__ . '/maintenance/install.php',
        'CommentStore' => __DIR__ . '/includes/CommentStore.php',
        'MediaWiki\\ProcOpenError' => __DIR__ . '/includes/exception/ProcOpenError.php',
        'MediaWiki\\Revision\\RenderedRevision' => __DIR__ . '/includes/Revision/RenderedRevision.php',
        'MediaWiki\\Revision\\RevisionRenderer' => __DIR__ . '/includes/Revision/RevisionRenderer.php',
+       'MediaWiki\\Revision\\SlotRenderingProvider' => __DIR__ . '/includes/Revision/SlotRenderingProvider.php',
        'MediaWiki\\Search\\ParserOutputSearchDataExtractor' => __DIR__ . '/includes/search/ParserOutputSearchDataExtractor.php',
        'MediaWiki\\ShellDisabledError' => __DIR__ . '/includes/exception/ShellDisabledError.php',
        'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
diff --combined docs/hooks.txt
@@@ -624,8 -624,8 +624,8 @@@ a chance to hide their (unrelated) log 
    AND in the final query)
  $logTypes: Array of log types being queried
  
 -'ArticleAfterFetchContentObject': After fetching content of an article from the
 -database.
 +'ArticleAfterFetchContentObject': DEPRECATED since 1.32, use ArticleRevisionViewCustom
 +to control output. After fetching content of an article from the database.
  &$article: the article (object) being loaded from the database
  &$content: the content of the article, as a Content object
  
@@@ -640,21 -640,12 +640,21 @@@ this to change the content in this are
  $diffEngine: the DifferenceEngine
  $output: the OutputPage object
  
 -'ArticleContentViewCustom': Allows to output the text of the article in a
 -different format than wikitext. Note that it is preferable to implement proper
 -handing for a custom data type using the ContentHandler facility.
 +'ArticleRevisionViewCustom': Allows custom rendering of an article's content.
 +Note that it is preferable to implement proper handing for a custom data type using
 +the ContentHandler facility.
 +$revision: content of the page, as a RevisionRecord object, or null if the revision
 +  could not be loaded. May also be a fake that wraps content supplied by an extension.
 +$title: title of the page
 +$oldid: the requested revision id, or 0 for the currrent revision.
 +$output: a ParserOutput object
 +
 +'ArticleContentViewCustom': DEPRECATED since 1.32, use ArticleRevisionViewCustom instead,
 +or provide an appropriate ContentHandler. Allows to output the text of the article in a
 +different format than wikitext.
  $content: content of the page, as a Content object
  $title: title of the page
 -$output: reference to $wgOut
 +$output: a ParserOutput object
  
  'ArticleDelete': Before an article is deleted.
  &$wikiPage: the WikiPage (object) being deleted
@@@ -784,8 -775,8 +784,8 @@@ $article: the articl
  $article: Article object
  $patrolFooterShown: boolean whether patrol footer is shown
  
 -'ArticleViewHeader': Before the parser cache is about to be tried for article
 -viewing.
 +'ArticleViewHeader': Control article output. Called before the parser cache is about
 +to be tried for article viewing.
  &$article: the article
  &$pcache: whether to try the parser cache or not
  &$outputDone: whether the output for this page finished or not. Set to
@@@ -2546,6 -2537,12 +2546,12 @@@ $originalRevId: if the edit restores o
    (Used to be called $baseRevId.)
  $undidRevId: the rev ID (or 0) this edit undid
  
+ 'PageDeletionDataUpdates': Called when constructing a list of DeferrableUpdate to be
+ executed when a page is deleted.
+ $title The Title of the page being deleted.
+ $revision A RevisionRecord representing the page's current revision at the time of deletion.
+ &$updates A list of DeferrableUpdate that can be manipulated by the hook handler.
  'PageHistoryBeforeList': When a history page list is about to be constructed.
  &$article: the article that the history is loading for
  $context: RequestContext object
@@@ -2919,6 -2916,13 +2925,13 @@@ called after the addition of 'qunit' an
    added to any module.
  &$ResourceLoader: object
  
+ 'RevisionDataUpdates': Called when constructing a list of DeferrableUpdate to be
+ executed to record secondary data about a revision.
+ $title The Title of the page the revision  belongs to
+ $renderedRevision a RenderedRevision object representing the new revision and providing access
+   to the RevisionRecord as well as ParserOutput of that revision.
+ &$updates A list of DeferrableUpdate that can be manipulated by the hook handler.
  'RevisionRecordInserted': Called after a revision is inserted into the database.
  $revisionRecord: the RevisionRecord that has just been inserted.
  
@@@ -2978,9 -2982,9 +2991,9 @@@ result augmentors
  Note that lists should be in the format name => object and the names in both
    lists should be distinct.
  
- 'SecondaryDataUpdates': Allows modification of the list of DataUpdates to
- perform when page content is modified. Currently called by
- AbstractContent::getSecondaryDataUpdates.
+ 'SecondaryDataUpdates': DEPRECATED! Use RevisionDataUpdates or override
+ ContentHandler::getSecondaryDataUpdates instead.
+ Allows modification of the list of DataUpdates to perform when page content is modified.
  $title: Title of the page that is being edited.
  $oldContent: Content object representing the page's content before the edit.
  $recursive: bool indicating whether DataUpdates should trigger recursive
@@@ -3320,6 -3324,14 +3333,6 @@@ use this to change some selection crite
  &$title: If the hook returns false, a Title object to use instead of the
    result from the normal query
  
 -'SpecialRecentChangesFilters': DEPRECATED since 1.23! Use
 -ChangesListSpecialPageStructuredFilters instead.
 -Called after building form options at RecentChanges.
 -$special: the special page object
 -&$filters: associative array of filter definitions. The keys are the HTML
 -  name/URL parameters. Each key maps to an associative array with a 'msg'
 -  (message key) and a 'default' value.
 -
  'SpecialRecentChangesPanel': Called when building form options in
  SpecialRecentChanges.
  &$extraOpts: array of added items, to which can be added
@@@ -3434,6 -3446,14 +3447,6 @@@ Special:Upload
  $wgVersion: Current $wgVersion for you to use
  &$versionUrl: Raw url to link to (eg: release notes)
  
 -'SpecialWatchlistFilters': DEPRECATED since 1.23! Use
 -ChangesListSpecialPageStructuredFilters instead.
 -Called after building form options at Watchlist.
 -$special: the special page object
 -&$filters: associative array of filter definitions. The keys are the HTML
 -  name/URL parameters. Each key maps to an associative array with a 'msg'
 -  (message key) and a 'default' value.
 -
  'SpecialWatchlistGetNonRevisionTypes': Called when building sql query for
  SpecialWatchlist. Allows extensions to register custom values they have
  inserted to rc_type so they can be returned as part of the watchlist.
@@@ -4038,10 -4058,9 +4051,9 @@@ dumps. One, and only one hook should se
  &$opts: Options to use for the query
  &$join: Join conditions
  
- 'WikiPageDeletionUpdates': manipulate the list of DeferrableUpdates to be
- applied when a page is deleted. Called in WikiPage::getDeletionUpdates(). Note
- that updates specific to a content model should be provided by the respective
- Content's getDeletionUpdates() method.
+ 'WikiPageDeletionUpdates': DEPRECATED! Use PageDeletionDataUpdates or
+ override ContentHandler::getDeletionDataUpdates instead.
+ Manipulates the list of DeferrableUpdates to be applied when a page is deleted.
  $page: the WikiPage
  $content: the Content to generate updates for, or null in case the page revision
    could not be loaded. The delete will succeed despite this.
@@@ -42,7 -42,7 +42,7 @@@ use Wikimedia\Assert\Assert
   *
   * @since 1.32
   */
- class RenderedRevision {
+ class RenderedRevision implements SlotRenderingProvider {
  
        /**
         * @var Title
         * but should use a RevisionRenderer instead.
         *
         * @param Title $title
 -       * @param RevisionRecord $revision
 +       * @param RevisionRecord $revision The revision to render. The content for rendering will be
 +       *        taken from this RevisionRecord. However, if the RevisionRecord is not complete
 +       *        according isReadyForInsertion(), but a revision ID is known, the parser may load
 +       *        the revision from the database if it needs revision meta data to handle magic
 +       *        words like {{REVISIONUSER}}.
         * @param ParserOptions $options
         * @param callable $combineOutput Callback for combining slot output into revision output.
         *        Signature: function ( RenderedRevision $this ): ParserOutput.
        private function setRevisionInternal( RevisionRecord $revision ) {
                $this->revision = $revision;
  
 -              // Make sure the parser uses the correct Revision object
 -              $title = $this->title;
 -              $oldCallback = $this->options->getCurrentRevisionCallback();
 -              $this->options->setCurrentRevisionCallback(
 -                      function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
 -                              if ( $parserTitle->equals( $title ) ) {
 -                                      $legacyRevision = new Revision( $this->revision );
 -                                      return $legacyRevision;
 -                              } else {
 -                                      return call_user_func( $oldCallback, $parserTitle, $parser );
 +              // Force the parser to use  $this->revision to resolve magic words like {{REVISIONUSER}}
 +              // if the revision is either known to be complete, or it doesn't have a revision ID set.
 +              // If it's incomplete and we have a revision ID, the parser can do better by loading
 +              // the revision from the database if needed to handle a magic word.
 +              //
 +              // The following considerations inform the logic described above:
 +              //
 +              // 1) If we have a saved revision already loaded, we want the parser to use it, instead of
 +              // loading it again.
 +              //
 +              // 2) If the revision is a fake that wraps some kind of synthetic content, such as an
 +              // error message from Article, it should be used directly and things like {{REVISIONUSER}}
 +              // should not expected to work, since there may not even be an actual revision to
 +              // refer to.
 +              //
 +              // 3) If the revision is a fake constructed around a Title, a Content object, and
 +              // a revision ID, to provide backwards compatibility to code that has access to those
 +              // but not to a complete RevisionRecord for rendering, then we want the Parser to
 +              // load the actual revision from the database when it encounters a magic word like
 +              // {{REVISIONUSER}}, but we don't want to load that revision ahead of time just in case.
 +              //
 +              // 4) Previewing an edit to a template should use the submitted unsaved
 +              // MutableRevisionRecord for self-transclusions in the template's documentation (see T7278).
 +              // That revision would be complete except for the ID field.
 +              //
 +              // 5) Pre-save transform would provide a RevisionRecord that has all meta-data but is
 +              // incomplete due to not yet having content set. However, since it doesn't have a revision
 +              // ID either, the below code would still force it to be used, allowing
 +              // {{subst::REVISIONUSER}} to function as expected.
 +
 +              if ( $this->revision->isReadyForInsertion() || !$this->revision->getId() ) {
 +                      $title = $this->title;
 +                      $oldCallback = $this->options->getCurrentRevisionCallback();
 +                      $this->options->setCurrentRevisionCallback(
 +                              function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
 +                                      if ( $title->equals( $parserTitle ) ) {
 +                                              $legacyRevision = new Revision( $this->revision );
 +                                              return $legacyRevision;
 +                                      } else {
 +                                              return call_user_func( $oldCallback, $parserTitle, $parser );
 +                                      }
                                }
 -                      }
 -              );
 +                      );
 +              }
        }
  
        /**
@@@ -27,12 -27,14 +27,14 @@@ use CategoryMembershipChangeJob
  use Content;
  use ContentHandler;
  use DataUpdate;
+ use DeferrableUpdate;
  use DeferredUpdates;
  use Hooks;
  use IDBAccessObject;
  use InvalidArgumentException;
  use JobQueueGroup;
  use Language;
+ use LinksDeletionUpdate;
  use LinksUpdate;
  use LogicException;
  use MediaWiki\Edit\PreparedEdit;
@@@ -165,8 -167,7 +167,7 @@@ class DerivedPageDataUpdater implement
         *
         * Contains the following fields:
         * - oldRevision (RevisionRecord|null): the revision that was current before the change
-        *   associated with this update. Might not be set, use getOldRevision() instead of direct
-        *   access.
+        *   associated with this update. Might not be set, use getParentRevision().
         * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change
         *   was about creating a new page); null if not known (that should not happen).
         * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded,
         */
        private $slotsUpdate = null;
  
+       /**
+        * @var RevisionRecord|null
+        */
+       private $parentRevision = null;
        /**
         * @var RevisionRecord|null
         */
        }
  
        /**
-        * Returns the revision that was current before the edit. This would be null if the edit
-        * created the page, or the revision's parent for a regular edit, or the revision itself
-        * for a null-edit.
-        * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
+        * Returns the parent revision of the new revision wrapped by this update.
+        * If the update is a null-edit, this will return the parent of the current (and new) revision.
+        * This will return null if the revision wrapped by this update created the page.
+        * Only defined after calling prepareContent() or prepareUpdate()!
         *
-        * @return RevisionRecord|null the revision that was current before the edit, or null if
-        *         the edit created the page.
+        * @return RevisionRecord|null the parent revision of the new revision, or null if
+        *         the update created the page.
         */
-       private function getOldRevision() {
-               $this->assertHasPageState( __METHOD__ );
+       private function getParentRevision() {
+               $this->assertPrepared( __METHOD__ );
  
-               // If 'oldRevision' is not set, load it!
-               // Useful if $this->oldPageState is initialized by prepareUpdate.
-               if ( !array_key_exists( 'oldRevision', $this->pageState ) ) {
-                       /** @var int $oldId */
-                       $oldId = $this->pageState['oldId'];
-                       $flags = $this->useMaster() ? RevisionStore::READ_LATEST : 0;
-                       $this->pageState['oldRevision'] = $oldId
-                               ? $this->revisionStore->getRevisionById( $oldId, $flags )
-                               : null;
+               if ( $this->parentRevision ) {
+                       return $this->parentRevision;
                }
  
-               return $this->pageState['oldRevision'];
+               if ( !$this->pageState['oldId'] ) {
+                       // If there was no current revision, there is no parent revision,
+                       // since the page didn't exist.
+                       return null;
+               }
+               $oldId = $this->revision->getParentId();
+               $flags = $this->useMaster() ? RevisionStore::READ_LATEST : 0;
+               $this->parentRevision = $oldId
+                       ? $this->revisionStore->getRevisionById( $oldId, $flags )
+                       : null;
+               return $this->parentRevision;
        }
  
        /**
         * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
         * to avoid confusion, since the page's current revision is then the new revision after
         * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
-        * Use getOldRevision() instead to access the revision that used to be current before the
-        * edit.
+        * Use getParentRevision() instead to access the revision that is the parent of the
+        * new revision.
         *
         * @return RevisionRecord|null the page's current revision, or null if the page does not
         * yet exist.
  
                        // prepareUpdate() is redundant for null-edits
                        $this->doTransition( 'has-revision' );
+               } else {
+                       $this->parentRevision = $parentRevision;
                }
        }
  
                $this->assertPrepared( __METHOD__ );
  
                if ( !$this->slotsUpdate ) {
-                       $old = $this->getOldRevision();
+                       $old = $this->getParentRevision();
                        $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
                                $this->revision->getSlots(),
                                $old ? $old->getSlots() : null
                        } else {
                                throw new LogicException(
                                        'Trying to re-use DerivedPageDataUpdater with revision '
 -                                      .$revision->getId()
 +                                      . $revision->getId()
                                        . ', but it\'s already bound to revision '
                                        . $this->revision->getId()
                                );
                        if ( !$this->user->equals( $user ) ) {
                                throw new LogicException(
                                        'The Revision provided has a mismatching actor: expected '
 -                                      .$this->user->getName()
 +                                      . $this->user->getName()
                                        . ', got '
                                        . $user->getName()
                                );
        /**
         * @param bool $recursive
         *
-        * @return DataUpdate[]
+        * @return DeferrableUpdate[]
         */
        public function getSecondaryDataUpdates( $recursive = false ) {
-               // TODO: MCR: getSecondaryDataUpdates() needs a complete overhaul to avoid DataUpdates
-               // from different slots overwriting each other in the database. Plan:
-               // * replace direct calls to Content::getSecondaryDataUpdates() with calls to this method
-               // * Construct LinksUpdate here, on the combined ParserOutput, instead of in AbstractContent
-               //   for each slot.
-               // * Pass $slot into getSecondaryDataUpdates() - probably be introducing a new duplicate
-               //   version of this function in ContentHandler.
-               // * The new method gets the PreparedEdit, but no $recursive flag (that's for LinksUpdate)
-               // * Hack: call both the old and the new getSecondaryDataUpdates method here; Pass
-               //   the per-slot ParserOutput to the old method, for B/C.
-               // * Hack: If there is more than one slot, filter LinksUpdate from the DataUpdates
-               //   returned by getSecondaryDataUpdates, and use a LinksUpdated for the combined output
-               //   instead.
-               // * Call the SecondaryDataUpdates hook here (or kill it - its signature doesn't make sense)
-               $content = $this->getSlots()->getContent( 'main' );
-               // NOTE: $output is the combined output, to be shown in the default view.
+               if ( $this->isContentDeleted() ) {
+                       // This shouldn't happen, since the current content is always public,
+                       // and DataUpates are only needed for current content.
+                       return [];
+               }
                $output = $this->getCanonicalParserOutput();
  
-               $updates = $content->getSecondaryDataUpdates(
-                       $this->getTitle(), null, $recursive, $output
+               // Construct a LinksUpdate for the combined canonical output.
+               $linksUpdate = new LinksUpdate(
+                       $this->getTitle(),
+                       $output,
+                       $recursive
                );
  
-               return $updates;
+               $allUpdates = [ $linksUpdate ];
+               // NOTE: Run updates for all slots, not just the modified slots! Otherwise,
+               // info for an inherited slot may end up being removed. This is also needed
+               // to ensure that purges are effective.
+               $renderedRevision = $this->getRenderedRevision();
+               foreach ( $this->getSlots()->getSlotRoles() as $role ) {
+                       $slot = $this->getRawSlot( $role );
+                       $content = $slot->getContent();
+                       $handler = $content->getContentHandler();
+                       $updates = $handler->getSecondaryDataUpdates(
+                               $this->getTitle(),
+                               $content,
+                               $role,
+                               $renderedRevision
+                       );
+                       $allUpdates = array_merge( $allUpdates, $updates );
+                       // TODO: remove B/C hack in 1.32!
+                       // NOTE: we assume that the combined output contains all relevant meta-data for
+                       // all slots!
+                       $legacyUpdates = $content->getSecondaryDataUpdates(
+                               $this->getTitle(),
+                               null,
+                               $recursive,
+                               $output
+                       );
+                       // HACK: filter out redundant and incomplete LinksUpdates
+                       $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
+                               return !( $update instanceof LinksUpdate );
+                       } );
+                       $allUpdates = array_merge( $allUpdates, $legacyUpdates );
+               }
+               // XXX: if a slot was removed by an earlier edit, but deletion updates failed to run at
+               // that time, we don't know for which slots to run deletion updates when purging a page.
+               // We'd have to examine the entire history of the page to determine that. Perhaps there
+               // could be a "try extra hard" mode for that case that would run a DB query to find all
+               // roles/models ever used on the page. On the other hand, removing slots should be quite
+               // rare, so perhaps this isn't worth the trouble.
+               // TODO: consolidate with similar logic in WikiPage::getDeletionUpdates()
+               $wikiPage = $this->getWikiPage();
+               $parentRevision = $this->getParentRevision();
+               foreach ( $this->getRemovedSlotRoles() as $role ) {
+                       // HACK: we should get the content model of the removed slot from a SlotRoleHandler!
+                       // For now, find the slot in the parent revision - if the slot was removed, it should
+                       // always exist in the parent revision.
+                       $parentSlot = $parentRevision->getSlot( $role, RevisionRecord::RAW );
+                       $content = $parentSlot->getContent();
+                       $handler = $content->getContentHandler();
+                       $updates = $handler->getDeletionUpdates(
+                               $this->getTitle(),
+                               $role
+                       );
+                       $allUpdates = array_merge( $allUpdates, $updates );
+                       // TODO: remove B/C hack in 1.32!
+                       $legacyUpdates = $content->getDeletionUpdates( $wikiPage );
+                       // HACK: filter out redundant and incomplete LinksDeletionUpdate
+                       $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
+                               return !( $update instanceof LinksDeletionUpdate );
+                       } );
+                       $allUpdates = array_merge( $allUpdates, $legacyUpdates );
+               }
+               // TODO: hard deprecate SecondaryDataUpdates in favor of RevisionDataUpdates in 1.33!
+               Hooks::run(
+                       'RevisionDataUpdates',
+                       [ $this->getTitle(), $renderedRevision, &$allUpdates ]
+               );
+               return $allUpdates;
        }
  
        /**
                        WikiPage::onArticleEdit( $title, $legacyRevision, $this->getTouchedSlotRoles() );
                }
  
-               $oldRevision = $this->getOldRevision();
+               $oldRevision = $this->getParentRevision();
                $oldLegacyRevision = $oldRevision ? new Revision( $oldRevision ) : null;
  
                // TODO: In the wiring, register a listener for this on the new PageEventEmitter
                }
  
                foreach ( $updates as $update ) {
-                       $update->setCause( $causeAction, $causeAgent );
+                       if ( $update instanceof DataUpdate ) {
+                               $update->setCause( $causeAction, $causeAgent );
+                       }
                        if ( $update instanceof LinksUpdate ) {
                                $update->setRevision( $legacyRevision );
                                $update->setTriggeringUser( $triggeringUser );
@@@ -28,6 -28,7 +28,7 @@@
  use Wikimedia\Assert\Assert;
  use MediaWiki\Logger\LoggerFactory;
  use MediaWiki\MediaWikiServices;
+ use MediaWiki\Revision\SlotRenderingProvider;
  use MediaWiki\Search\ParserOutputSearchDataExtractor;
  
  /**
@@@ -1389,20 -1390,14 +1390,20 @@@ abstract class ContentHandler 
         * @return ParserOutput
         */
        public function getParserOutputForIndexing( WikiPage $page, ParserCache $cache = null ) {
 +              // TODO: MCR: ContentHandler should be called per slot, not for the whole page.
 +              // See T190066.
                $parserOptions = $page->makeParserOptions( 'canonical' );
 -              $revId = $page->getRevision()->getId();
                if ( $cache ) {
                        $parserOutput = $cache->get( $page, $parserOptions );
                }
 +
                if ( empty( $parserOutput ) ) {
 +                      $renderer = MediaWikiServices::getInstance()->getRevisionRenderer();
                        $parserOutput =
 -                              $page->getContent()->getParserOutput( $page->getTitle(), $revId, $parserOptions );
 +                              $renderer->getRenderedRevision(
 +                                      $page->getRevision()->getRevisionRecord(),
 +                                      $parserOptions
 +                              )->getRevisionParserOutput();
                        if ( $cache ) {
                                $cache->save( $parserOutput, $page, $parserOptions );
                        }
                return $parserOutput;
        }
  
+       /**
+        * Returns a list of DeferrableUpdate objects for recording information about the
+        * given Content in some secondary data store.
+        *
+        * Application logic should not call this method directly. Instead, it should call
+        * DerivedPageDataUpdater::getSecondaryDataUpdates().
+        *
+        * @note Implementations must not return a LinksUpdate instance. Instead, a LinksUpdate
+        * is created by the calling code in DerivedPageDataUpdater, on the combined ParserOutput
+        * of all slots, not for each slot individually. This is in contrast to the old
+        * getSecondaryDataUpdates method defined by AbstractContent, which returned a LinksUpdate.
+        *
+        * @note Implementations should not call $content->getParserOutput, they should call
+        * $slotOutput->getSlotRendering( $role, false ) instead if they need to access a ParserOutput
+        * of $content. This allows existing ParserOutput objects to be re-used, while avoiding
+        * creating a ParserOutput when none is needed.
+        *
+        * @param Title $title The title of the page to supply the updates for
+        * @param Content $content The content to generate data updates for.
+        * @param string $role The role (slot) in which the content is being used. Which updates
+        *        are performed should generally not depend on the role the content has, but the
+        *        DeferrableUpdates themselves may need to know the role, to track to which slot the
+        *        data refers, and to avoid overwriting data of the same kind from another slot.
+        * @param SlotRenderingProvider $slotOutput A provider that can be used to gain access to
+        *        a ParserOutput of $content by calling $slotOutput->getSlotParserOutput( $role, false ).
+        * @return DeferrableUpdate[] A list of DeferrableUpdate objects for putting information
+        *        about this content object somewhere. The default implementation returns an empty
+        *        array.
+        * @since 1.32
+        */
+       public function getSecondaryDataUpdates(
+               Title $title,
+               Content $content,
+               $role,
+               SlotRenderingProvider $slotOutput
+       ) {
+               return [];
+       }
+       /**
+        * Returns a list of DeferrableUpdate objects for removing information about content
+        * in some secondary data store. This is used when a page is deleted, and also when
+        * a slot is removed from a page.
+        *
+        * Application logic should not call this method directly. Instead, it should call
+        * WikiPage::getSecondaryDataUpdates().
+        *
+        * @note Implementations must not return a LinksDeletionUpdate instance. Instead, a
+        * LinksDeletionUpdate is created by the calling code in WikiPage.
+        * This is in contrast to the old getDeletionUpdates method defined by AbstractContent,
+        * which returned a LinksUpdate.
+        *
+        * @note Implementations should not rely on the page's current content, but rather the current
+        * state of the secondary data store.
+        *
+        * @param Title $title The title of the page to supply the updates for
+        * @param string $role The role (slot) in which the content is being used. Which updates
+        *        are performed should generally not depend on the role the content has, but the
+        *        DeferrableUpdates themselves may need to know the role, to track to which slot the
+        *        data refers, and to avoid overwriting data of the same kind from another slot.
+        *
+        * @return DeferrableUpdate[] A list of DeferrableUpdate objects for putting information
+        *        about this content object somewhere. The default implementation returns an empty
+        *        array.
+        *
+        * @since 1.32
+        */
+       public function getDeletionUpdates( Title $title, $role ) {
+               return [];
+       }
  }
@@@ -20,8 -20,6 +20,8 @@@
   * @file
   */
  use MediaWiki\MediaWikiServices;
 +use MediaWiki\Storage\MutableRevisionRecord;
 +use MediaWiki\Storage\RevisionRecord;
  
  /**
   * Class for viewing MediaWiki article and history.
  class Article implements Page {
        /**
         * @var IContextSource|null The context this Article is executed in.
 -       * If null, REquestContext::getMain() is used.
 +       * If null, RequestContext::getMain() is used.
         */
        protected $mContext;
  
 -      /** @var WikiPage The WikiPage object of this instance */
 +      /** @var WikiPage|null The WikiPage object of this instance */
        protected $mPage;
  
        /**
        public $mParserOptions;
  
        /**
 -       * @var string|null Text of the revision we are working on
 -       * @todo BC cruft
 -       */
 -      public $mContent;
 -
 -      /**
 -       * @var Content|null Content of the revision we are working on.
 -       * Initialized by fetchContentObject().
 +       * @var Content|null Content of the main slot of $this->mRevision.
 +       * @note This variable is read only, setting it has no effect.
 +       *       Extensions that wish to override the output of Article::view should use a hook.
 +       * @todo MCR: Remove in 1.33
 +       * @deprecated since 1.32
         * @since 1.21
         */
        public $mContentObject;
  
 -      /** @var bool Is the content ($mContent) already loaded? */
 +      /**
 +       * @var bool Is the target revision loaded? Set by fetchRevisionRecord().
 +       *
 +       * @deprecated since 1.32. Whether content has been loaded should not be relevant to
 +       * code outside this class.
 +       */
        public $mContentLoaded = false;
  
 -      /** @var int|null The oldid of the article that is to be shown, 0 for the current revision */
 +      /**
 +       * @var int|null The oldid of the article that was requested to be shown,
 +       * 0 for the current revision.
 +       * @see $mRevIdFetched
 +       */
        public $mOldId;
  
        /** @var Title|null Title from which we were redirected here, if any. */
        /** @var string|bool URL to redirect to or false if none */
        public $mRedirectUrl = false;
  
 -      /** @var int Revision ID of revision we are working on */
 +      /**
 +       * @var int Revision ID of revision that was loaded.
 +       * @see $mOldId
 +       * @deprecated since 1.32, use getRevIdFetched() instead.
 +       */
        public $mRevIdFetched = 0;
  
        /**
 -       * @var Revision|null Revision we are working on. Initialized by getOldIDFromRequest()
 -       * or fetchContentObject().
 +       * @var Status|null represents the outcome of fetchRevisionRecord().
 +       * $fetchResult->value is the RevisionRecord object, if the operation was successful.
 +       *
 +       * The information in $fetchResult is duplicated by the following deprecated public fields:
 +       * $mRevIdFetched, $mContentLoaded. $mRevision (and $mContentObject) also typically duplicate
 +       * information of the loaded revision, but may be overwritten by extensions or due to errors.
 +       */
 +      private $fetchResult = null;
 +
 +      /**
 +       * @var Revision|null Revision to be shown. Initialized by getOldIDFromRequest()
 +       * or fetchContentObject(). Normally loaded from the database, but may be replaced
 +       * by an extension, or be a fake representing an error message or some such.
 +       * While the output of Article::view is typically based on this revision,
 +       * it may be overwritten by error messages or replaced by extensions.
         */
        public $mRevision = null;
  
        /**
         * @var ParserOutput|null|false The ParserOutput generated for viewing the page,
         * initialized by view(). If no ParserOutput could be generated, this is set to false.
 +       * @deprecated since 1.32
         */
 -      public $mParserOutput;
 +      public $mParserOutput = null;
  
        /**
         * @var bool Whether render() was called. With the way subclasses work
         */
        public static function newFromTitle( $title, IContextSource $context ) {
                if ( NS_MEDIA == $title->getNamespace() ) {
 -                      // FIXME: where should this go?
 +                      // XXX: This should not be here, but where should it go?
                        $title = Title::makeTitle( NS_FILE, $title->getDBkey() );
                }
  
                $this->mRedirectedFrom = null; # Title object if set
                $this->mRevIdFetched = 0;
                $this->mRedirectUrl = false;
 +              $this->mRevision = null;
 +              $this->mContentObject = null;
 +              $this->fetchResult = null;
 +
 +              // TODO hard-deprecate direct access to public fields
  
                $this->mPage->clear();
        }
         * This function has side effects! Do not use this function if you
         * only want the real revision text if any.
         *
 -       * @return Content Return the content of this revision
 +       * @deprecated since 1.32, use getRevisionFetched() or fetchRevisionRecord() instead.
 +       *
 +       * @return Content
         *
         * @since 1.21
         */
        protected function getContentObject() {
                if ( $this->mPage->getId() === 0 ) {
 -                      # If this is a MediaWiki:x message, then load the messages
 -                      # and return the message value for x.
 -                      if ( $this->getTitle()->getNamespace() == NS_MEDIAWIKI ) {
 -                              $text = $this->getTitle()->getDefaultMessageText();
 -                              if ( $text === false ) {
 -                                      $text = '';
 -                              }
 -
 -                              $content = ContentHandler::makeContent( $text, $this->getTitle() );
 -                      } else {
 -                              $message = $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon';
 -                              $content = new MessageContent( $message, null, 'parsemag' );
 -                      }
 +                      $content = $this->getSubstituteContent();
                } else {
                        $this->fetchContentObject();
                        $content = $this->mContentObject;
        }
  
        /**
 -       * @return int The oldid of the article that is to be shown, 0 for the current revision
 +       * Returns Content object to use when the page does not exist.
 +       *
 +       * @return Content
 +       */
 +      private function getSubstituteContent() {
 +              # If this is a MediaWiki:x message, then load the messages
 +              # and return the message value for x.
 +              if ( $this->getTitle()->getNamespace() == NS_MEDIAWIKI ) {
 +                      $text = $this->getTitle()->getDefaultMessageText();
 +                      if ( $text === false ) {
 +                              $text = '';
 +                      }
 +
 +                      $content = ContentHandler::makeContent( $text, $this->getTitle() );
 +              } else {
 +                      $message = $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon';
 +                      $content = new MessageContent( $message, null, 'parsemag' );
 +              }
 +
 +              return $content;
 +      }
 +
 +      /**
 +       * Returns ParserOutput to use when a page does not exist. In some cases, we still want to show
 +       * "virtual" content, e.g. in the MediaWiki namespace, or in the File namespace for non-local
 +       * files.
 +       *
 +       * @param ParserOptions $options
 +       *
 +       * @return ParserOutput
 +       */
 +      protected function getEmptyPageParserOutput( ParserOptions $options ) {
 +              $content = $this->getSubstituteContent();
 +
 +              return $content->getParserOutput( $this->getTitle(), 0, $options );
 +      }
 +
 +      /**
 +       * @see getOldIDFromRequest()
 +       * @see getRevIdFetched()
 +       *
 +       * @return int The oldid of the article that is was requested in the constructor or via the
 +       *         context's WebRequest.
         */
        public function getOldID() {
                if ( is_null( $this->mOldId ) ) {
                                if ( $this->mRevision !== null ) {
                                        // Revision title doesn't match the page title given?
                                        if ( $this->mPage->getId() != $this->mRevision->getPage() ) {
 -                                              $function = get_class( $this->mPage ). '::newFromID';
 +                                              $function = get_class( $this->mPage ) . '::newFromID';
                                                $this->mPage = $function( $this->mRevision->getPage() );
                                        }
                                }
                        }
                }
  
 +              $this->mRevIdFetched = $this->mRevision ? $this->mRevision->getId() : 0;
 +
                return $oldid;
        }
  
         * Get text content object
         * Does *NOT* follow redirects.
         * @todo When is this null?
 +       * @deprecated since 1.32, use fetchRevisionRecord() instead.
         *
         * @note Code that wants to retrieve page content from the database should
         * use WikiPage::getContent().
         * @since 1.21
         */
        protected function fetchContentObject() {
 -              if ( $this->mContentLoaded ) {
 -                      return $this->mContentObject;
 +              if ( !$this->mContentLoaded ) {
 +                      $this->fetchRevisionRecord();
 +              }
 +
 +              return $this->mContentObject;
 +      }
 +
 +      /**
 +       * Fetches the revision to work on.
 +       * The revision is typically loaded from the database, but may also be a fake representing
 +       * an error message or content supplied by an extension. Refer to $this->fetchResult for
 +       * the revision actually loaded from the database, and any errors encountered while doing
 +       * that.
 +       *
 +       * @return RevisionRecord|null
 +       */
 +      protected function fetchRevisionRecord() {
 +              if ( $this->fetchResult ) {
 +                      return $this->mRevision ? $this->mRevision->getRevisionRecord() : null;
                }
  
                $this->mContentLoaded = true;
 -              $this->mContent = null;
 +              $this->mContentObject = null;
  
                $oldid = $this->getOldID();
  
 -              # Pre-fill content with error message so that if something
 -              # fails we'll have something telling us what we intended.
 -              // XXX: this isn't page content but a UI message. horrible.
 -              $this->mContentObject = new MessageContent( 'missing-revision', [ $oldid ] );
 +              // $this->mRevision might already be fetched by getOldIDFromRequest()
 +              if ( !$this->mRevision ) {
 +                      if ( !$oldid ) {
 +                              $this->mRevision = $this->mPage->getRevision();
 +
 +                              if ( !$this->mRevision ) {
 +                                      wfDebug( __METHOD__ . " failed to find page data for title " .
 +                                              $this->getTitle()->getPrefixedText() . "\n" );
  
 -              if ( $oldid ) {
 -                      # $this->mRevision might already be fetched by getOldIDFromRequest()
 -                      if ( !$this->mRevision ) {
 +                                      // Just for sanity, output for this case is done by showMissingArticle().
 +                                      $this->fetchResult = Status::newFatal( 'noarticletext' );
 +                                      $this->applyContentOverride( $this->makeFetchErrorContent() );
 +                                      return null;
 +                              }
 +                      } else {
                                $this->mRevision = Revision::newFromId( $oldid );
 +
                                if ( !$this->mRevision ) {
 -                                      wfDebug( __METHOD__ . " failed to retrieve specified revision, id $oldid\n" );
 -                                      return false;
 +                                      wfDebug( __METHOD__ . " failed to load revision, rev_id $oldid\n" );
 +
 +                                      $this->fetchResult = Status::newFatal( 'missing-revision', $oldid );
 +                                      $this->applyContentOverride( $this->makeFetchErrorContent() );
 +                                      return null;
                                }
                        }
 -              } else {
 -                      $oldid = $this->mPage->getLatest();
 -                      if ( !$oldid ) {
 -                              wfDebug( __METHOD__ . " failed to find page data for title " .
 -                                      $this->getTitle()->getPrefixedText() . "\n" );
 -                              return false;
 -                      }
 +              }
 +
 +              $this->mRevIdFetched = $this->mRevision->getId();
 +              $this->fetchResult = Status::newGood( $this->mRevision );
 +
 +              if ( !$this->mRevision->userCan( Revision::DELETED_TEXT, $this->getContext()->getUser() ) ) {
 +                      wfDebug( __METHOD__ . " failed to retrieve content of revision " .
 +                              $this->mRevision->getId() . "\n" );
 +
 +                      // Just for sanity, output for this case is done by showDeletedRevisionHeader().
 +                      $this->fetchResult = Status::newFatal( 'rev-deleted-text-permission' );
 +                      $this->applyContentOverride( $this->makeFetchErrorContent() );
 +                      return null;
 +              }
 +
 +              if ( Hooks::isRegistered( 'ArticleAfterFetchContentObject' ) ) {
 +                      $contentObject = $this->mRevision->getContent(
 +                              Revision::FOR_THIS_USER,
 +                              $this->getContext()->getUser()
 +                      );
  
 -                      # Update error message with correct oldid
 -                      $this->mContentObject = new MessageContent( 'missing-revision', [ $oldid ] );
 +                      $hookContentObject = $contentObject;
  
 -                      $this->mRevision = $this->mPage->getRevision();
 +                              // Avoid PHP 7.1 warning of passing $this by reference
 +                      $articlePage = $this;
 +
 +                      Hooks::run(
 +                              'ArticleAfterFetchContentObject',
 +                              [ &$articlePage, &$hookContentObject ],
 +                              '1.32'
 +                      );
  
 -                      if ( !$this->mRevision ) {
 -                              wfDebug( __METHOD__ . " failed to retrieve current page, rev_id $oldid\n" );
 -                              return false;
 +                      if ( $hookContentObject !== $contentObject ) {
 +                              // A hook handler is trying to override the content
 +                              $this->applyContentOverride( $hookContentObject );
                        }
                }
  
 -              // @todo FIXME: Horrible, horrible! This content-loading interface just plain sucks.
 -              // We should instead work with the Revision object when we need it...
 -              // Loads if user is allowed
 -              $content = $this->mRevision->getContent(
 +              // For B/C only
 +              $this->mContentObject = $this->mRevision->getContent(
                        Revision::FOR_THIS_USER,
                        $this->getContext()->getUser()
                );
  
 -              if ( !$content ) {
 -                      wfDebug( __METHOD__ . " failed to retrieve content of revision " .
 -                              $this->mRevision->getId() . "\n" );
 -                      return false;
 +              return $this->mRevision->getRevisionRecord();
 +      }
 +
 +      /**
 +       * Returns a Content object representing any error in $this->fetchContent, or null
 +       * if there is no such error.
 +       *
 +       * @return Content|null
 +       */
 +      private function makeFetchErrorContent() {
 +              if ( !$this->fetchResult || $this->fetchResult->isOK() ) {
 +                      return null;
                }
  
 -              $this->mContentObject = $content;
 -              $this->mRevIdFetched = $this->mRevision->getId();
 +              return new MessageContent( $this->fetchResult->getMessage() );
 +      }
  
 -              // Avoid PHP 7.1 warning of passing $this by reference
 -              $articlePage = $this;
 +      /**
 +       * Applies a content override by constructing a fake Revision object and assigning
 +       * it to mRevision. The fake revision will not have a user, timestamp or summary set.
 +       *
 +       * This mechanism exists mainly to accommodate extensions that use the
 +       * ArticleAfterFetchContentObject. Once that hook has been removed, there should no longer
 +       * be a need for a fake revision object. fetchRevisionRecord() presently also uses this mechanism
 +       * to report errors, but that could be changed to use $this->fetchResult instead.
 +       *
 +       * @param Content $override Content to be used instead of the actual page content,
 +       *        coming from an extension or representing an error message.
 +       */
 +      private function applyContentOverride( Content $override ) {
 +              // Construct a fake revision
 +              $rev = new MutableRevisionRecord( $this->getTitle() );
 +              $rev->setContent( 'main', $override );
  
 -              Hooks::run(
 -                      'ArticleAfterFetchContentObject',
 -                      [ &$articlePage, &$this->mContentObject ]
 -              );
 +              $this->mRevision = new Revision( $rev );
  
 -              return $this->mContentObject;
 +              // For B/C only
 +              $this->mContentObject = $override;
        }
  
        /**
  
        /**
         * Get the fetched Revision object depending on request parameters or null
 -       * on failure.
 +       * on failure. The revision returned may be a fake representing an error message or
 +       * wrapping content supplied by an extension. Refer to $this->fetchResult for the
 +       * revision actually loaded from the database.
         *
         * @since 1.19
         * @return Revision|null
         */
        public function getRevisionFetched() {
 -              $this->fetchContentObject();
 +              $this->fetchRevisionRecord();
  
 -              return $this->mRevision;
 +              if ( $this->fetchResult->isOK() ) {
 +                      return $this->mRevision;
 +              }
        }
  
        /**
         * Use this to fetch the rev ID used on page views
         *
 +       * Before fetchRevisionRecord was called, this returns the page's latest revision,
 +       * regardless of what getOldID() returns.
 +       *
         * @return int Revision ID of last article revision
         */
        public function getRevIdFetched() {
 -              if ( $this->mRevIdFetched ) {
 -                      return $this->mRevIdFetched;
 +              if ( $this->fetchResult && $this->fetchResult->isOK() ) {
 +                      return $this->fetchResult->value->getId();
                } else {
                        return $this->mPage->getLatest();
                }
                                        }
                                        break;
                                case 3:
 -                                      # This will set $this->mRevision if needed
 -                                      $this->fetchContentObject();
 -
                                        # Are we looking at an old revision
 -                                      if ( $oldid && $this->mRevision ) {
 +                                      $rev = $this->fetchRevisionRecord();
 +                                      if ( $oldid && $this->fetchResult->isOK() ) {
                                                $this->setOldSubtitle( $oldid );
  
                                                if ( !$this->showDeletedRevisionHeader() ) {
                                                        "<div id='mw-clearyourcache' lang='$lang' dir='$dir' class='mw-content-$dir'>\n$1\n</div>",
                                                        'clearyourcache'
                                                );
 +                                      } elseif ( !Hooks::run( 'ArticleRevisionViewCustom', [
 +                                                      $rev,
 +                                                      $this->getTitle(),
 +                                                      $oldid,
 +                                                      $outputPage,
 +                                              ] )
 +                                      ) {
 +                                              // NOTE: sync with hooks called in DifferenceEngine::renderNewRevision()
 +                                              // Allow extensions do their own custom view for certain pages
 +                                              $outputDone = true;
                                        } elseif ( !Hooks::run( 'ArticleContentViewCustom',
 -                                              [ $this->fetchContentObject(), $this->getTitle(), $outputPage ] )
 +                                              [ $this->fetchContentObject(), $this->getTitle(), $outputPage ], '1.32' )
                                        ) {
 -                                              # Allow extensions do their own custom view for certain pages
 +                                              // NOTE: sync with hooks called in DifferenceEngine::renderNewRevision()
 +                                              // Allow extensions do their own custom view for certain pages
                                                $outputDone = true;
                                        }
                                        break;
                                        # Run the parse, protected by a pool counter
                                        wfDebug( __METHOD__ . ": doing uncached parse\n" );
  
 -                                      $content = $this->getContentObject();
 -                                      $poolArticleView = new PoolWorkArticleView( $this->getPage(), $parserOptions,
 -                                              $this->getRevIdFetched(), $useParserCache, $content );
 +                                      $rev = $this->fetchRevisionRecord();
 +                                      $error = null;
  
 -                                      if ( !$poolArticleView->execute() ) {
 +                                      if ( $rev ) {
 +                                              $poolArticleView = new PoolWorkArticleView(
 +                                                      $this->getPage(),
 +                                                      $parserOptions,
 +                                                      $this->getRevIdFetched(),
 +                                                      $useParserCache,
 +                                                      $rev
 +                                              );
 +                                              $ok = $poolArticleView->execute();
                                                $error = $poolArticleView->getError();
 +                                              $this->mParserOutput = $poolArticleView->getParserOutput() ?: null;
 +
 +                                              # Don't cache a dirty ParserOutput object
 +                                              if ( $poolArticleView->getIsDirty() ) {
 +                                                      $outputPage->setCdnMaxage( 0 );
 +                                                      $outputPage->addHTML( "<!-- parser cache is expired, " .
 +                                                              "sending anyway due to pool overload-->\n" );
 +                                              }
 +                                      } else {
 +                                              $ok = false;
 +                                      }
 +
 +                                      if ( !$ok ) {
                                                if ( $error ) {
                                                        $outputPage->clearHTML(); // for release() errors
                                                        $outputPage->enableClientCache( false );
                                                return;
                                        }
  
 -                                      $this->mParserOutput = $poolArticleView->getParserOutput();
 -                                      $outputPage->addParserOutput( $this->mParserOutput, $poOptions );
 -                                      if ( $content->getRedirectTarget() ) {
 -                                              $outputPage->addSubtitle( "<span id=\"redirectsub\">" .
 -                                                      $this->getContext()->msg( 'redirectpagesub' )->parse() . "</span>" );
 +                                      if ( $this->mParserOutput ) {
 +                                              $outputPage->addParserOutput( $this->mParserOutput, $poOptions );
                                        }
  
 -                                      # Don't cache a dirty ParserOutput object
 -                                      if ( $poolArticleView->getIsDirty() ) {
 -                                              $outputPage->setCdnMaxage( 0 );
 -                                              $outputPage->addHTML( "<!-- parser cache is expired, " .
 -                                                      "sending anyway due to pool overload-->\n" );
 +                                      if ( $rev && $this->getRevisionRedirectTarget( $rev ) ) {
 +                                              $outputPage->addSubtitle( "<span id=\"redirectsub\">" .
 +                                                      $this->getContext()->msg( 'redirectpagesub' )->parse() . "</span>" );
                                        }
  
                                        $outputDone = true;
                        }
                }
  
 -              # Get the ParserOutput actually *displayed* here.
 -              # Note that $this->mParserOutput is the *current*/oldid version output.
 +              // Get the ParserOutput actually *displayed* here.
 +              // Note that $this->mParserOutput is the *current*/oldid version output.
 +              // Note that the ArticleViewHeader hook is allowed to set $outputDone to a
 +              // ParserOutput instance.
                $pOutput = ( $outputDone instanceof ParserOutput )
 +                      // phpcs:ignore MediaWiki.Usage.NestedInlineTernary.UnparenthesizedTernary -- FIXME T203805
                        ? $outputDone // object fetched by hook
                        : $this->mParserOutput ?: null; // ParserOutput or null, avoid false
  
                $outputPage->adaptCdnTTL( $this->mPage->getTimestamp(), IExpiringStore::TTL_DAY );
  
                # Check for any __NOINDEX__ tags on the page using $pOutput
 -              $policy = $this->getRobotPolicy( 'view', $pOutput );
 +              $policy = $this->getRobotPolicy( 'view', $pOutput ?: null );
                $outputPage->setIndexPolicy( $policy['index'] );
 -              $outputPage->setFollowPolicy( $policy['follow'] );
 +              $outputPage->setFollowPolicy( $policy['follow'] ); // FIXME: test this
  
                $this->showViewFooter();
 -              $this->mPage->doViewUpdates( $user, $oldid );
 +              $this->mPage->doViewUpdates( $user, $oldid ); // FIXME: test this
  
                # Load the postEdit module if the user just saved this revision
                # See also EditPage::setPostEditCookie
                        # Clear the cookie. This also prevents caching of the response.
                        $request->response()->clearCookie( $cookieKey );
                        $outputPage->addJsConfigVars( 'wgPostEdit', $postEdit );
 -                      $outputPage->addModules( 'mediawiki.action.view.postEdit' );
 +                      $outputPage->addModules( 'mediawiki.action.view.postEdit' ); // FIXME: test this
                }
        }
  
 +      /**
 +       * @param RevisionRecord $revision
 +       * @return null|Title
 +       */
 +      private function getRevisionRedirectTarget( RevisionRecord $revision ) {
 +              // TODO: find a *good* place for the code that determines the redirect target for
 +              // a given revision!
 +              // NOTE: Use main slot content. Compare code in DerivedPageDataUpdater::revisionIsRedirect.
 +              $content = $revision->getContent( 'main' );
 +              return $content ? $content->getRedirectTarget() : null;
 +      }
 +
        /**
         * Adjust title for pages with displaytitle, -{T|}- or language conversion
         * @param ParserOutput $pOutput
         */
        public function adjustDisplayTitle( ParserOutput $pOutput ) {
 +              $out = $this->getContext()->getOutput();
 +
                # Adjust the title if it was set by displaytitle, -{T|}- or language conversion
                $titleText = $pOutput->getTitleText();
                if ( strval( $titleText ) !== '' ) {
 -                      $this->getContext()->getOutput()->setPageTitle( $titleText );
 +                      $out->setPageTitle( $titleText );
 +                      $out->setDisplayTitle( $titleText );
                }
        }
  
                # Show error message
                $oldid = $this->getOldID();
                if ( !$oldid && $title->getNamespace() === NS_MEDIAWIKI && $title->hasSourceText() ) {
 -                      $outputPage->addParserOutput( $this->getContentObject()->getParserOutput( $title ) );
 +                      // use fake Content object for system message
 +                      $parserOptions = ParserOptions::newCanonical( 'canonical' );
 +                      $outputPage->addParserOutput( $this->getEmptyPageParserOutput( $parserOptions ) );
                } else {
                        if ( $oldid ) {
                                $text = wfMessage( 'missing-revision', $oldid )->plain();
                                __METHOD__
                        );
  
 -                      // @todo FIXME: i18n issue/patchwork message
 +                      // @todo i18n issue/patchwork message
                        $context->getOutput()->addHTML(
                                '<strong class="mw-delete-warning-revisions">' .
                                $context->msg( 'historywarning' )->numParams( $revisions )->parse() .
  
        /**
         * Output deletion confirmation dialog
 -       * @todo FIXME: Move to another file?
 +       * @todo Move to another file?
         * @param string $reason Prefilled reason
         */
        public function confirmDelete( $reason ) {
         * Call to WikiPage function for backwards compatibility.
         * @see WikiPage::doDeleteUpdates
         */
-       public function doDeleteUpdates( $id, Content $content = null ) {
-               return $this->mPage->doDeleteUpdates( $id, $content );
+       public function doDeleteUpdates(
+               $id,
+               Content $content = null,
+               $revision = null,
+               User $user = null
+       ) {
+               $this->mPage->doDeleteUpdates( $id, $content, $revision, $user );
        }
  
        /**