diff --git a/core/lib/Drupal/Core/Entity/Controller/EntityRevisionViewController.php b/core/lib/Drupal/Core/Entity/Controller/EntityRevisionViewController.php
new file mode 100644
index 0000000000000000000000000000000000000000..25b2c3761813a03c62cbb0c60a61f5f7655e2e5e
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Controller/EntityRevisionViewController.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\Core\Entity\Controller;
+
+use Drupal\Core\Datetime\DateFormatterInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\RevisionableInterface;
+use Drupal\Core\Entity\RevisionLogInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a controller to view an entity revision.
+ */
+class EntityRevisionViewController implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Creates a new EntityRevisionViewController.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entityRepository
+   *   The entity repository.
+   * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
+   *   The date formatter.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The string translation manager.
+   */
+  public function __construct(
+    protected EntityTypeManagerInterface $entityTypeManager,
+    protected EntityRepositoryInterface $entityRepository,
+    protected DateFormatterInterface $dateFormatter,
+    TranslationInterface $translation,
+  ) {
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('entity.repository'),
+      $container->get('date.formatter'),
+      $container->get('string_translation'),
+    );
+  }
+
+  /**
+   * Provides a page to render a single entity revision.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $_entity_revision
+   *   The Entity to be rendered. Note this variable is named $_entity_revision
+   *   rather than $entity to prevent collisions with other named placeholders
+   *   in the route.
+   * @param string $view_mode
+   *   (optional) The view mode that should be used to display the entity.
+   *   Defaults to 'full'.
+   *
+   * @return array
+   *   A render array.
+   */
+  public function __invoke(RevisionableInterface $_entity_revision, string $view_mode = 'full'): array {
+    $entityTypeId = $_entity_revision->getEntityTypeId();
+
+    $page = $this->entityTypeManager
+      ->getViewBuilder($entityTypeId)
+      ->view($_entity_revision, $view_mode);
+
+    $page['#entity_type'] = $entityTypeId;
+    $page['#' . $entityTypeId] = $_entity_revision;
+    return $page;
+  }
+
+  /**
+   * Provides a title callback for a revision of an entity.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $_entity_revision
+   *   The revisionable entity, passed in directly from request attributes.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+   *   The title for the entity revision view page.
+   */
+  public function title(RevisionableInterface $_entity_revision): TranslatableMarkup {
+    $revision = $this->entityRepository->getTranslationFromContext($_entity_revision);
+    $titleArgs = ['%title' => $revision->label()];
+    if (!$revision instanceof RevisionLogInterface) {
+      return $this->t('Revision of %title', $titleArgs);
+    }
+
+    $titleArgs['%date'] = $this->dateFormatter->format($revision->getRevisionCreationTime());
+    return $this->t('Revision of %title from %date', $titleArgs);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php b/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php
index 979721832e0ae9db8afce6158301e584470332ef..cf660ab0cbc9981fb7e36aa3c9739696f1a224ea 100644
--- a/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php
+++ b/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php
@@ -184,8 +184,14 @@ public static function trustedCallbacks() {
    *
    * @return array
    *   A render array.
+   *
+   * @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use
+   *   \Drupal\Core\Entity\Controller\EntityRevisionViewController instead.
+   *
+   * @see https://www.drupal.org/node/3314346
    */
   public function viewRevision(EntityInterface $_entity_revision, $view_mode = 'full') {
+    @trigger_error(__METHOD__ . ' is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use \Drupal\Core\Entity\Controller\EntityRevisionViewController instead. See https://www.drupal.org/node/3314346.', E_USER_DEPRECATED);
     return $this->view($_entity_revision, $view_mode);
   }
 
diff --git a/core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php b/core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php
new file mode 100644
index 0000000000000000000000000000000000000000..def6fe1cde7bb9213930e0feb72a81b574be2387
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php
@@ -0,0 +1,311 @@
+<?php
+
+namespace Drupal\Core\Entity\Controller;
+
+use Drupal\Component\Utility\Xss;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Datetime\DateFormatterInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\RevisionableInterface;
+use Drupal\Core\Entity\RevisionableStorageInterface;
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Entity\RevisionLogInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a controller showing revision history for an entity.
+ *
+ * This controller is agnostic to any entity type by using
+ * \Drupal\Core\Entity\RevisionLogInterface.
+ */
+class VersionHistoryController extends ControllerBase {
+
+  /**
+   * Constructs a new VersionHistoryController.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   The entity type manager.
+   * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager
+   *   The language manager.
+   * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
+   *   The date formatter service.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   */
+  public function __construct(
+    EntityTypeManagerInterface $entityTypeManager,
+    LanguageManagerInterface $languageManager,
+    protected DateFormatterInterface $dateFormatter,
+    protected RendererInterface $renderer,
+  ) {
+    $this->entityTypeManager = $entityTypeManager;
+    $this->languageManager = $languageManager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('language_manager'),
+      $container->get('date.formatter'),
+      $container->get('renderer'),
+    );
+  }
+
+  /**
+   * Generates an overview table of revisions for an entity.
+   *
+   * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch
+   *   The route match.
+   *
+   * @return array
+   *   A render array.
+   */
+  public function __invoke(RouteMatchInterface $routeMatch): array {
+    $entityTypeId = $routeMatch->getRouteObject()->getOption('entity_type_id');
+    $entity = $routeMatch->getParameter($entityTypeId);
+    return $this->revisionOverview($entity);
+  }
+
+  /**
+   * Builds a link to revert an entity revision.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $revision
+   *   The entity to build a revert revision link for.
+   *
+   * @return array|null
+   *   A link to revert an entity revision, or NULL if the entity type does not
+   *   have an a route to revert an entity revision.
+   */
+  protected function buildRevertRevisionLink(RevisionableInterface $revision): ?array {
+    if (!$revision->hasLinkTemplate('revision-revert-form')) {
+      return NULL;
+    }
+
+    $url = $revision->toUrl('revision-revert-form');
+    // @todo Merge in cacheability after
+    // https://www.drupal.org/project/drupal/issues/2473873.
+    if (!$url->access()) {
+      return NULL;
+    }
+
+    return [
+      'title' => $this->t('Revert'),
+      'url' => $url,
+    ];
+  }
+
+  /**
+   * Builds a link to delete an entity revision.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $revision
+   *   The entity to build a delete revision link for.
+   *
+   * @return array|null
+   *   A link render array.
+   */
+  protected function buildDeleteRevisionLink(RevisionableInterface $revision): ?array {
+    if (!$revision->hasLinkTemplate('revision-delete-form')) {
+      return NULL;
+    }
+
+    $url = $revision->toUrl('revision-delete-form');
+    // @todo Merge in cacheability after
+    // https://www.drupal.org/project/drupal/issues/2473873.
+    if (!$url->access()) {
+      return NULL;
+    }
+
+    return [
+      'title' => $this->t('Delete'),
+      'url' => $url,
+    ];
+  }
+
+  /**
+   * Get a description of the revision.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $revision
+   *   The entity revision.
+   *
+   * @return array
+   *   A render array describing the revision.
+   */
+  protected function getRevisionDescription(RevisionableInterface $revision): array {
+    $context = [];
+    if ($revision instanceof RevisionLogInterface) {
+      // Use revision link to link to revisions that are not active.
+      ['type' => $dateFormatType, 'format' => $dateFormatFormat] = $this->getRevisionDescriptionDateFormat($revision);
+      $linkText = $this->dateFormatter->format($revision->getRevisionCreationTime(), $dateFormatType, $dateFormatFormat);
+
+      // @todo Simplify this when https://www.drupal.org/node/2334319 lands.
+      $username = [
+        '#theme' => 'username',
+        '#account' => $revision->getRevisionUser(),
+      ];
+      $context['username'] = $this->renderer->render($username);
+    }
+    else {
+      $linkText = $revision->access('view label') ? $revision->label() : $this->t('- Restricted access -');
+    }
+
+    $revisionViewLink = $revision->toLink($linkText, 'revision');
+    $context['revision'] = $revisionViewLink->getUrl()->access()
+      ? $revisionViewLink->toString()
+      : (string) $revisionViewLink->getText();
+    $context['message'] = $revision instanceof RevisionLogInterface ? [
+      '#markup' => $revision->getRevisionLogMessage(),
+      '#allowed_tags' => Xss::getHtmlTagList(),
+    ] : '';
+
+    return [
+      'data' => [
+        '#type' => 'inline_template',
+        '#template' => isset($context['username'])
+          ? '{% trans %} {{ revision }} by {{ username }}{% endtrans %}{% if message %}<p class="revision-log">{{ message }}</p>{% endif %}'
+          : '{% trans %} {{ revision }} {% endtrans %}{% if message %}<p class="revision-log">{{ message }}</p>{% endif %}',
+        '#context' => $context,
+      ],
+    ];
+  }
+
+  /**
+   * Date format to use for revision description dates.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $revision
+   *   The revision in context.
+   *
+   * @return array
+   *   An array with keys 'type' and optionally 'format' suitable for passing
+   *   to date formatter service.
+   */
+  protected function getRevisionDescriptionDateFormat(RevisionableInterface $revision): array {
+    return [
+      'type' => 'short',
+      'format' => '',
+    ];
+  }
+
+  /**
+   * Generates revisions of an entity relevant to the current language.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $entity
+   *   The entity.
+   *
+   * @return \Generator|\Drupal\Core\Entity\RevisionableInterface
+   *   Generates revisions.
+   */
+  protected function loadRevisions(RevisionableInterface $entity) {
+    $entityType = $entity->getEntityType();
+    $translatable = $entityType->isTranslatable();
+    $entityStorage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
+    assert($entityStorage instanceof RevisionableStorageInterface);
+
+    $result = $entityStorage->getQuery()
+      ->accessCheck(FALSE)
+      ->allRevisions()
+      ->condition($entityType->getKey('id'), $entity->id())
+      ->sort($entityType->getKey('revision'), 'DESC')
+      ->execute();
+
+    $currentLangcode = $this->languageManager
+      ->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)
+      ->getId();
+    foreach ($entityStorage->loadMultipleRevisions(array_keys($result)) as $revision) {
+      // Only show revisions that are affected by the language that is being
+      // displayed.
+      if (!$translatable || ($revision->hasTranslation($currentLangcode) && $revision->getTranslation($currentLangcode)->isRevisionTranslationAffected())) {
+        yield $revision;
+      }
+    }
+  }
+
+  /**
+   * Generates an overview table of revisions of an entity.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $entity
+   *   A revisionable entity.
+   *
+   * @return array
+   *   A render array.
+   */
+  protected function revisionOverview(RevisionableInterface $entity): array {
+    $build['entity_revisions_table'] = [
+      '#theme' => 'table',
+      '#header' => [
+        'revision' => ['data' => $this->t('Revision')],
+        'operations' => ['data' => $this->t('Operations')],
+      ],
+    ];
+
+    foreach ($this->loadRevisions($entity) as $revision) {
+      $build['entity_revisions_table']['#rows'][$revision->getRevisionId()] = $this->buildRow($revision);
+    }
+
+    (new CacheableMetadata())
+      // Only dealing with this entity and no external dependencies.
+      ->addCacheableDependency($entity)
+      ->addCacheContexts(['languages:language_content'])
+      ->applyTo($build);
+
+    return $build;
+  }
+
+  /**
+   * Builds a table row for a revision.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $revision
+   *   An entity revision.
+   *
+   * @return array
+   *   A table row.
+   */
+  protected function buildRow(RevisionableInterface $revision): array {
+    $row = [];
+    $rowAttributes = [];
+
+    $row['revision']['data'] = $this->getRevisionDescription($revision);
+    $row['operations']['data'] = [];
+
+    // Revision status.
+    if ($revision->isDefaultRevision()) {
+      $rowAttributes['class'][] = 'revision-current';
+      $row['operations']['data']['status']['#markup'] = $this->t('<em>Current revision</em>');
+    }
+
+    // Operation links.
+    $links = $this->getOperationLinks($revision);
+    if (count($links) > 0) {
+      $row['operations']['data']['operations'] = [
+        '#type' => 'operations',
+        '#links' => $links,
+      ];
+    }
+
+    return ['data' => $row] + $rowAttributes;
+  }
+
+  /**
+   * Get operations for an entity revision.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $revision
+   *   The entity to build revision links for.
+   *
+   * @return array
+   *   An array of operation links.
+   */
+  protected function getOperationLinks(RevisionableInterface $revision): array {
+    // Removes links which are inaccessible or not rendered.
+    return array_filter([
+      $this->buildRevertRevisionLink($revision),
+      $this->buildDeleteRevisionLink($revision),
+    ]);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/Form/RevisionDeleteForm.php b/core/lib/Drupal/Core/Entity/Form/RevisionDeleteForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..7c3fdd177a49558ea527a07824051a2ea2b576ea
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Form/RevisionDeleteForm.php
@@ -0,0 +1,283 @@
+<?php
+
+namespace Drupal\Core\Entity\Form;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Datetime\DateFormatterInterface;
+use Drupal\Core\Entity\EntityFormInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\RevisionableInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Form\ConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Entity\RevisionLogInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a form for deleting an entity revision.
+ *
+ * @internal
+ */
+class RevisionDeleteForm extends ConfirmFormBase implements EntityFormInterface {
+
+  /**
+   * The entity operation.
+   *
+   * @var string
+   */
+  protected string $operation;
+
+  /**
+   * The entity revision.
+   *
+   * @var \Drupal\Core\Entity\RevisionableInterface
+   */
+  protected RevisionableInterface $revision;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected ModuleHandlerInterface $moduleHandler;
+
+  /**
+   * Creates a new RevisionDeleteForm instance.
+   *
+   * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
+   *   The date formatter.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   Entity type manager.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundleInformation
+   *   The bundle information.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   * @param \Drupal\Core\Session\AccountInterface $currentUser
+   *   The current user.
+   */
+  public function __construct(
+    protected DateFormatterInterface $dateFormatter,
+    protected EntityTypeManagerInterface $entityTypeManager,
+    protected EntityTypeBundleInfoInterface $bundleInformation,
+    MessengerInterface $messenger,
+    protected TimeInterface $time,
+    protected AccountInterface $currentUser,
+  ) {
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('date.formatter'),
+      $container->get('entity_type.manager'),
+      $container->get('entity_type.bundle.info'),
+      $container->get('messenger'),
+      $container->get('datetime.time'),
+      $container->get('current_user')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getBaseFormId() {
+    return $this->revision->getEntityTypeId() . '_revision_delete';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return $this->revision->getEntityTypeId() . '_revision_delete';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return ($this->getEntity() instanceof RevisionLogInterface)
+      ? $this->t('Are you sure you want to delete the revision from %revision-date?', [
+        '%revision-date' => $this->dateFormatter->format($this->getEntity()->getRevisionCreationTime()),
+      ])
+      : $this->t('Are you sure you want to delete the revision?');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl() {
+    return $this->getEntity()->getEntityType()->hasLinkTemplate('version-history') && $this->getEntity()->toUrl('version-history')->access($this->currentUser)
+      ? $this->getEntity()->toUrl('version-history')
+      : $this->getEntity()->toUrl();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText() {
+    return $this->t('Delete');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return '';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $entityTypeId = $this->revision->getEntityTypeId();
+    $entityStorage = $this->entityTypeManager->getStorage($entityTypeId);
+    $entityStorage->deleteRevision($this->revision->getRevisionId());
+
+    $bundleLabel = $this->getBundleLabel($this->revision);
+    $messengerArgs = [
+      '@type' => $bundleLabel ?? $this->revision->getEntityType()->getLabel(),
+      '%title' => $this->revision->label(),
+    ];
+    if ($this->revision instanceof RevisionLogInterface) {
+      $messengerArgs['%revision-date'] = $this->dateFormatter->format($this->revision->getRevisionCreationTime());
+      $this->messenger->addStatus($this->t('Revision from %revision-date of @type %title has been deleted.', $messengerArgs));
+    }
+    else {
+      $this->messenger->addStatus($this->t('Revision of @type %title has been deleted.', $messengerArgs));
+    }
+
+    $this->logger($this->revision->getEntityType()->getProvider())->notice('@type: deleted %title revision %revision.', [
+      '@type' => $this->revision->bundle(),
+      '%title' => $this->revision->label(),
+      '%revision' => $this->revision->getRevisionId(),
+    ]);
+
+    // When there is one remaining revision or more, redirect to the version
+    // history page.
+    if ($this->revision->hasLinkTemplate('version-history')) {
+      $query = $this->entityTypeManager->getStorage($entityTypeId)->getQuery();
+      $remainingRevisions = $query
+        ->accessCheck(FALSE)
+        ->allRevisions()
+        ->condition($this->revision->getEntityType()->getKey('id'), $this->revision->id())
+        ->count()
+        ->execute();
+      $versionHistoryUrl = $this->revision->toUrl('version-history');
+      if ($remainingRevisions && $versionHistoryUrl->access($this->currentUser())) {
+        $form_state->setRedirectUrl($versionHistoryUrl);
+      }
+    }
+
+    if (!$form_state->getRedirect()) {
+      $canonicalUrl = $this->revision->toUrl();
+      if ($canonicalUrl->access($this->currentUser())) {
+        $form_state->setRedirectUrl($canonicalUrl);
+      }
+    }
+  }
+
+  /**
+   * Returns the bundle label of an entity.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $entity
+   *   The entity.
+   *
+   * @return string|null
+   *   The bundle label.
+   */
+  protected function getBundleLabel(RevisionableInterface $entity): ?string {
+    $bundleInfo = $this->bundleInformation->getBundleInfo($entity->getEntityTypeId());
+    return isset($bundleInfo[$entity->bundle()]['label']) ? (string) $bundleInfo[$entity->bundle()]['label'] : NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOperation($operation) {
+    $this->operation = $operation;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOperation() {
+    return $this->operation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getEntity() {
+    return $this->revision;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setEntity(EntityInterface $entity) {
+    assert($entity instanceof RevisionableInterface);
+    $this->revision = $entity;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getEntityFromRouteMatch(RouteMatchInterface $route_match, $entity_type_id) {
+    return $route_match->getParameter($entity_type_id . '_revision');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildEntity(array $form, FormStateInterface $form_state) {
+    return $this->revision;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * The save() method is not used in RevisionDeleteForm. This
+   * overrides the default implementation that saves the entity.
+   *
+   * Confirmation forms should override submitForm() instead for their logic.
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    throw new \LogicException('The save() method is not used in RevisionDeleteForm');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setModuleHandler(ModuleHandlerInterface $module_handler) {
+    $this->moduleHandler = $module_handler;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function currentUser() {
+    return $this->currentUser;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/Form/RevisionRevertForm.php b/core/lib/Drupal/Core/Entity/Form/RevisionRevertForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..23f4b4d8361463cefab9784b180aba0d04bcedf1
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Form/RevisionRevertForm.php
@@ -0,0 +1,327 @@
+<?php
+
+namespace Drupal\Core\Entity\Form;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Datetime\DateFormatterInterface;
+use Drupal\Core\Entity\EntityChangedInterface;
+use Drupal\Core\Entity\EntityFormInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\RevisionableInterface;
+use Drupal\Core\Entity\RevisionableStorageInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Form\ConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Entity\RevisionLogInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a form for reverting an entity revision.
+ *
+ * @internal
+ */
+class RevisionRevertForm extends ConfirmFormBase implements EntityFormInterface {
+
+  /**
+   * The entity operation.
+   *
+   * @var string
+   */
+  protected string $operation;
+
+  /**
+   * The entity revision.
+   *
+   * @var \Drupal\Core\Entity\RevisionableInterface
+   */
+  protected RevisionableInterface $revision;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected ModuleHandlerInterface $moduleHandler;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected EntityTypeManagerInterface $entityTypeManager;
+
+  /**
+   * Creates a new RevisionRevertForm instance.
+   *
+   * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
+   *   The date formatter.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundleInformation
+   *   The bundle information.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   * @param \Drupal\Core\Session\AccountInterface $currentUser
+   *   The current user.
+   */
+  public function __construct(
+    protected DateFormatterInterface $dateFormatter,
+    protected EntityTypeBundleInfoInterface $bundleInformation,
+    MessengerInterface $messenger,
+    protected TimeInterface $time,
+    protected AccountInterface $currentUser,
+  ) {
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('date.formatter'),
+      $container->get('entity_type.bundle.info'),
+      $container->get('messenger'),
+      $container->get('datetime.time'),
+      $container->get('current_user'),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getBaseFormId() {
+    return $this->revision->getEntityTypeId() . '_revision_revert';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return $this->revision->getEntityTypeId() . '_revision_revert';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return ($this->getEntity() instanceof RevisionLogInterface)
+      ? $this->t('Are you sure you want to revert to the revision from %revision-date?', [
+        '%revision-date' => $this->dateFormatter->format($this->getEntity()->getRevisionCreationTime()),
+      ])
+      : $this->t('Are you sure you want to revert the revision?');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl() {
+    return $this->getEntity()->getEntityType()->hasLinkTemplate('version-history') && $this->getEntity()->toUrl('version-history')->access($this->currentUser)
+      ? $this->getEntity()->toUrl('version-history')
+      : $this->getEntity()->toUrl();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText() {
+    return $this->t('Revert');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return '';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildForm($form, $form_state);
+    $form['actions']['submit']['#submit'] = [
+      '::submitForm',
+      '::save',
+    ];
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $revisionId = $this->revision->getRevisionId();
+    $revisionLabel = $this->revision->label();
+    $bundleLabel = $this->getBundleLabel($this->revision);
+    if ($this->revision instanceof RevisionLogInterface) {
+      $originalRevisionTimestamp = $this->revision->getRevisionCreationTime();
+    }
+
+    $this->revision = $this->prepareRevision($this->revision, $form_state);
+
+    if ($this->revision instanceof RevisionLogInterface) {
+      $date = $this->dateFormatter->format($originalRevisionTimestamp);
+      $this->messenger->addMessage($this->t('@type %title has been reverted to the revision from %revision-date.', [
+        '@type' => $bundleLabel,
+        '%title' => $revisionLabel,
+        '%revision-date' => $date,
+      ]));
+    }
+    else {
+      $this->messenger->addMessage($this->t('@type %title has been reverted.', [
+        '@type' => $bundleLabel,
+        '%title' => $revisionLabel,
+      ]));
+    }
+
+    $this->logger($this->revision->getEntityType()->getProvider())->notice('@type: reverted %title revision %revision.', [
+      '@type' => $this->revision->bundle(),
+      '%title' => $revisionLabel,
+      '%revision' => $revisionId,
+    ]);
+
+    $versionHistoryUrl = $this->revision->toUrl('version-history');
+    if ($versionHistoryUrl->access($this->currentUser())) {
+      $form_state->setRedirectUrl($versionHistoryUrl);
+    }
+
+    if (!$form_state->getRedirect()) {
+      $canonicalUrl = $this->revision->toUrl();
+      if ($canonicalUrl->access($this->currentUser())) {
+        $form_state->setRedirectUrl($canonicalUrl);
+      }
+    }
+  }
+
+  /**
+   * Prepares a revision to be reverted.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $revision
+   *   The revision to be reverted.
+   * @param \Drupal\Core\Form\FormStateInterface $formState
+   *   The current state of the form.
+   *
+   * @return \Drupal\Core\Entity\RevisionableInterface
+   *   The new revision, the same type as passed to $revision.
+   */
+  protected function prepareRevision(RevisionableInterface $revision, FormStateInterface $formState): RevisionableInterface {
+    $storage = $this->entityTypeManager->getStorage($revision->getEntityTypeId());
+    if (!$storage instanceof RevisionableStorageInterface) {
+      throw new \LogicException('Revisionable entities are expected to implement RevisionableStorageInterface');
+    }
+
+    $revision = $storage->createRevision($revision);
+
+    $time = $this->time->getRequestTime();
+    if ($revision instanceof EntityChangedInterface) {
+      $revision->setChangedTime($time);
+    }
+
+    if ($revision instanceof RevisionLogInterface) {
+      $originalRevisionTimestamp = $revision->getRevisionCreationTime();
+      $date = $this->dateFormatter->format($originalRevisionTimestamp);
+      $revision
+        ->setRevisionLogMessage($this->t('Copy of the revision from %date.', ['%date' => $date]))
+        ->setRevisionCreationTime($time)
+        ->setRevisionUserId($this->currentUser()->id());
+    }
+
+    return $revision;
+  }
+
+  /**
+   * Returns the bundle label of an entity.
+   *
+   * @param \Drupal\Core\Entity\RevisionableInterface $entity
+   *   The entity.
+   *
+   * @return string|null
+   *   The bundle label.
+   */
+  protected function getBundleLabel(RevisionableInterface $entity): ?string {
+    $bundleInfo = $this->bundleInformation->getBundleInfo($entity->getEntityTypeId());
+    return isset($bundleInfo[$entity->bundle()]['label']) ? (string) $bundleInfo[$entity->bundle()]['label'] : NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOperation($operation) {
+    $this->operation = $operation;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOperation() {
+    return $this->operation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getEntity() {
+    return $this->revision;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setEntity(EntityInterface $entity) {
+    assert($entity instanceof RevisionableInterface);
+    $this->revision = $entity;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getEntityFromRouteMatch(RouteMatchInterface $route_match, $entity_type_id) {
+    return $route_match->getParameter($entity_type_id . '_revision');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildEntity(array $form, FormStateInterface $form_state) {
+    return $this->revision;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    return $this->revision->save();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setModuleHandler(ModuleHandlerInterface $module_handler) {
+    $this->moduleHandler = $module_handler;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function currentUser() {
+    return $this->currentUser;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/Plugin/Derivative/VersionHistoryLocalTasks.php b/core/lib/Drupal/Core/Entity/Plugin/Derivative/VersionHistoryLocalTasks.php
new file mode 100644
index 0000000000000000000000000000000000000000..4d0fd5259f7f35c1c3d7db0bca7acaf876e9d758
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Plugin/Derivative/VersionHistoryLocalTasks.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\Core\Entity\Plugin\Derivative;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides version history local tasks for revisionable entities.
+ */
+class VersionHistoryLocalTasks extends DeriverBase implements ContainerDeriverInterface {
+
+  /**
+   * Creates a new VersionHistoryLocalTasks instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   The entity type manager.
+   */
+  public function __construct(
+    protected EntityTypeManagerInterface $entityTypeManager,
+  ) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    $this->derivatives = [];
+    foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
+      if (!$entity_type->hasLinkTemplate('version-history')) {
+        continue;
+      }
+
+      $this->derivatives["$entity_type_id.version_history"] = [
+        'route_name' => "entity.$entity_type_id.version_history",
+        'base_route' => "entity.$entity_type_id.canonical",
+      ] + $base_plugin_definition;
+    }
+
+    return parent::getDerivativeDefinitions($base_plugin_definition);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/Routing/RevisionHtmlRouteProvider.php b/core/lib/Drupal/Core/Entity/Routing/RevisionHtmlRouteProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..f2f1cd90896f98a2764d28fa528f190d281a6964
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Routing/RevisionHtmlRouteProvider.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Drupal\Core\Entity\Routing;
+
+use Drupal\Core\Entity\Controller\EntityRevisionViewController;
+use Drupal\Core\Entity\Controller\VersionHistoryController;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Provides entity revision routes.
+ */
+class RevisionHtmlRouteProvider implements EntityRouteProviderInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRoutes(EntityTypeInterface $entity_type) {
+    $collection = new RouteCollection();
+    $entityTypeId = $entity_type->id();
+
+    if ($version_history_route = $this->getVersionHistoryRoute($entity_type)) {
+      $collection->add("entity.$entityTypeId.version_history", $version_history_route);
+    }
+
+    if ($revision_view_route = $this->getRevisionViewRoute($entity_type)) {
+      $collection->add("entity.$entityTypeId.revision", $revision_view_route);
+    }
+
+    if ($revision_revert_route = $this->getRevisionRevertRoute($entity_type)) {
+      $collection->add("entity.$entityTypeId.revision_revert_form", $revision_revert_route);
+    }
+
+    if ($revision_delete_route = $this->getRevisionDeleteRoute($entity_type)) {
+      $collection->add("entity.$entityTypeId.revision_delete_form", $revision_delete_route);
+    }
+
+    return $collection;
+  }
+
+  /**
+   * Gets the entity revision history route.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entityType
+   *   The entity type.
+   *
+   * @return \Symfony\Component\Routing\Route|null
+   *   The entity revision revert route, or NULL if the entity type does not
+   *   support viewing version history.
+   */
+  protected function getVersionHistoryRoute(EntityTypeInterface $entityType): ?Route {
+    if (!$entityType->hasLinkTemplate('version-history')) {
+      return NULL;
+    }
+
+    $entityTypeId = $entityType->id();
+    return (new Route($entityType->getLinkTemplate('version-history')))
+      ->addDefaults([
+        '_controller' => VersionHistoryController::class,
+        '_title' => 'Revisions',
+      ])
+      ->setRequirement('_entity_access', $entityTypeId . '.view all revisions')
+      ->setOption('entity_type_id', $entityTypeId)
+      ->setOption('_admin_route', TRUE)
+      ->setOption('parameters', [
+        $entityTypeId => [
+          'type' => 'entity:' . $entityTypeId,
+        ],
+      ]);
+  }
+
+  /**
+   * Gets the entity revision view route.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entityType
+   *   The entity type.
+   *
+   * @return \Symfony\Component\Routing\Route|null
+   *   The entity revision view route, or NULL if the entity type does not
+   *   support viewing revisions.
+   */
+  protected function getRevisionViewRoute(EntityTypeInterface $entityType): ?Route {
+    if (!$entityType->hasLinkTemplate('revision')) {
+      return NULL;
+    }
+
+    $entityTypeId = $entityType->id();
+    $revisionParameterName = $entityTypeId . '_revision';
+    return (new Route($entityType->getLinkTemplate('revision')))
+      ->addDefaults([
+        '_controller' => EntityRevisionViewController::class,
+        '_title_callback' => EntityRevisionViewController::class . '::title',
+      ])
+      ->setRequirement('_entity_access', $revisionParameterName . '.view revision')
+      ->setOption('parameters', [
+        $entityTypeId => [
+          'type' => 'entity:' . $entityTypeId,
+        ],
+        $revisionParameterName => [
+          'type' => 'entity_revision:' . $entityTypeId,
+        ],
+      ]);
+  }
+
+  /**
+   * Gets the entity revision revert route.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entityType
+   *   The entity type.
+   *
+   * @return \Symfony\Component\Routing\Route|null
+   *   The entity revision revert route, or NULL if the entity type does not
+   *   support reverting revisions.
+   */
+  protected function getRevisionRevertRoute(EntityTypeInterface $entityType): ?Route {
+    if (!$entityType->hasLinkTemplate('revision-revert-form')) {
+      return NULL;
+    }
+
+    $entityTypeId = $entityType->id();
+    $revisionParameterName = $entityTypeId . '_revision';
+    return (new Route($entityType->getLinkTemplate('revision-revert-form')))
+      ->addDefaults([
+        '_entity_form' => $entityTypeId . '.revision-revert',
+        '_title' => 'Revert revision',
+      ])
+      ->setRequirement('_entity_access', $revisionParameterName . '.revert')
+      ->setOption('_admin_route', TRUE)
+      ->setOption('parameters', [
+        $entityTypeId => [
+          'type' => 'entity:' . $entityTypeId,
+        ],
+        $revisionParameterName => [
+          'type' => 'entity_revision:' . $entityTypeId,
+        ],
+      ]);
+  }
+
+  /**
+   * Gets the entity revision delete route.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entityType
+   *   The entity type.
+   *
+   * @return \Symfony\Component\Routing\Route|null
+   *   The entity revision delete route, or NULL if the entity type does not
+   *   support deleting revisions.
+   */
+  protected function getRevisionDeleteRoute(EntityTypeInterface $entityType): ?Route {
+    if (!$entityType->hasLinkTemplate('revision-delete-form')) {
+      return NULL;
+    }
+
+    $entityTypeId = $entityType->id();
+    $revisionParameterName = $entityTypeId . '_revision';
+    return (new Route($entityType->getLinkTemplate('revision-delete-form')))
+      ->addDefaults([
+        '_entity_form' => $entityTypeId . '.revision-delete',
+        '_title' => 'Delete revision',
+      ])
+      ->setRequirement('_entity_access', $revisionParameterName . '.delete revision')
+      ->setOption('_admin_route', TRUE)
+      ->setOption('parameters', [
+        $entityTypeId => [
+          'type' => 'entity:' . $entityTypeId,
+        ],
+        $revisionParameterName => [
+          'type' => 'entity_revision:' . $entityTypeId,
+        ],
+      ]);
+  }
+
+}
diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml
index 5dc97324d7fe44ee2e20d3be7f24c6c1b8b4c215..03a5c5afe86121ce4f56564612a7598637a1a9a1 100644
--- a/core/modules/media/media.routing.yml
+++ b/core/modules/media/media.routing.yml
@@ -1,7 +1,7 @@
 entity.media.revision:
   path: '/media/{media}/revisions/{media_revision}/view'
   defaults:
-    _controller: '\Drupal\Core\Entity\Controller\EntityViewController::viewRevision'
+    _controller: '\Drupal\Core\Entity\Controller\EntityRevisionViewController'
     _title_callback: '\Drupal\Core\Entity\Controller\EntityController::title'
   options:
     parameters:
diff --git a/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php b/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php
index 43d9ebda0b13878d0e3d19c5044a1c27e05817d9..b2ad9d4e35abae31b830e94071e25e10273b3513 100644
--- a/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php
+++ b/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php
@@ -86,12 +86,12 @@ public function test1() {
           ],
           [
             'id' => '2',
-            'name' => $this->randomString(32),
+            'name' => $this->randomString(64),
             'user_id' => '1',
           ],
           [
             'id' => '3',
-            'name' => $this->randomString(32),
+            'name' => $this->randomString(64),
             'user_id' => '2',
           ],
         ],
@@ -110,7 +110,7 @@ public function test1() {
       ],
     ]);
 
-    $this->assertSame('1: [entity_test: 1]: name.0.value=<em class="placeholder">Name</em>: may not be longer than 32 characters.||user_id.0.target_id=The referenced entity (<em class="placeholder">user</em>: <em class="placeholder">1</em>) does not exist.', $this->messages[0], 'First message should have 2 validation errors.');
+    $this->assertSame('1: [entity_test: 1]: name.0.value=<em class="placeholder">Name</em>: may not be longer than 64 characters.||user_id.0.target_id=The referenced entity (<em class="placeholder">user</em>: <em class="placeholder">1</em>) does not exist.', $this->messages[0], 'First message should have 2 validation errors.');
     $this->assertSame('2: [entity_test: 2]: user_id.0.target_id=The referenced entity (<em class="placeholder">user</em>: <em class="placeholder">1</em>) does not exist.', $this->messages[1], 'Second message should have 1 validation error.');
     $this->assertArrayNotHasKey(2, $this->messages, 'Third message should not exist.');
   }
diff --git a/core/modules/node/node.module b/core/modules/node/node.module
index eb3050e82ff91c6f385dd18e152c61d6101d4312..e42f548422869b07fec24a47381e317bf9d82752 100644
--- a/core/modules/node/node.module
+++ b/core/modules/node/node.module
@@ -177,6 +177,17 @@ function node_title_list(StatementInterface $result, $title = NULL) {
   return $num_rows ? ['#theme' => 'item_list__node', '#items' => $items, '#title' => $title, '#cache' => ['tags' => Cache::mergeTags(['node_list'], Cache::buildTags('node', $nids))]] : FALSE;
 }
 
+/**
+ * Implements hook_local_tasks_alter().
+ */
+function node_local_tasks_alter(&$local_tasks): void {
+  // Removes 'Revisions' local task added by deriver. Local task
+  // 'entity.node.version_history' will be replaced by
+  // 'entity.version_history:node.version_history' after
+  // https://www.drupal.org/project/drupal/issues/3153559.
+  unset($local_tasks['entity.version_history:node.version_history']);
+}
+
 /**
  * Determines the type of marker to be displayed for a given node.
  *
diff --git a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php
index 833cef792df1c07ac291367a780725b6c3dcf17c..a19e0f0426730d43ff4ecf8507e837526cfa6f54 100644
--- a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php
+++ b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php
@@ -19,6 +19,11 @@ class NodeRevisionsUiTest extends NodeTestBase {
    */
   protected $defaultTheme = 'stark';
 
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['block'];
+
   /**
    * @var \Drupal\user\Entity\User
    */
@@ -187,4 +192,27 @@ public function testNodeRevisionsTabWithDefaultRevision() {
     $this->assertSession()->linkByHrefNotExists('/node/' . $node_id . '/revisions/5/revert');
   }
 
+  /**
+   * Checks the Revisions tab.
+   *
+   * Tests two 'Revisions' local tasks are not added by both Node and
+   * VersionHistoryLocalTasks.
+   *
+   * This can be removed after 'entity.node.version_history' local task is
+   * removed by https://www.drupal.org/project/drupal/issues/3153559.
+   *
+   * @covers node_local_tasks_alter()
+   */
+  public function testNodeDuplicateRevisionsTab(): void {
+    $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalLogin($this->editor);
+
+    $node = $this->drupalCreateNode();
+    $this->drupalGet($node->toUrl('edit-form'));
+
+    // There must be exactly one 'Revisions' local task.
+    $xpath = $this->assertSession()->buildXPathQuery('//a[contains(@href, :href)]', [':href' => $node->toUrl('version-history')->toString()]);
+    $this->assertSession()->elementsCount('xpath', $xpath, 1);
+  }
+
 }
diff --git a/core/modules/system/system.links.task.yml b/core/modules/system/system.links.task.yml
index db8f8564d822f91e931fa8e03fe821aadfb14727..ed66b0595e20b1316f4a42a1123014cb8ea75122 100644
--- a/core/modules/system/system.links.task.yml
+++ b/core/modules/system/system.links.task.yml
@@ -64,6 +64,11 @@ entity.date_format.edit_form:
   route_name: entity.date_format.edit_form
   base_route: entity.date_format.edit_form
 
+entity.version_history:
+  title: 'Revisions'
+  weight: 20
+  deriver: 'Drupal\Core\Entity\Plugin\Derivative\VersionHistoryLocalTasks'
+
 system.admin_content:
   title: Content
   route_name: system.admin_content
diff --git a/core/modules/system/tests/modules/entity_test/entity_test.routing.yml b/core/modules/system/tests/modules/entity_test/entity_test.routing.yml
index f08c0902d8ec87560c62b66e1a1b752d910a112f..52278e4d7806c7b42c3103828ca49d379b0fb0e0 100644
--- a/core/modules/system/tests/modules/entity_test/entity_test.routing.yml
+++ b/core/modules/system/tests/modules/entity_test/entity_test.routing.yml
@@ -55,23 +55,10 @@ entity.entity_test.collection:
   requirements:
     _access: 'TRUE'
 
-entity.entity_test_rev.revision:
-  path: '/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/view'
-  defaults:
-    _controller: '\Drupal\Core\Entity\Controller\EntityViewController::viewRevision'
-  options:
-    parameters:
-      entity_test_rev:
-        type: entity:entity_test_rev
-      entity_test_rev_revision:
-        type: entity_revision:entity_test_rev
-  requirements:
-    _access: 'TRUE'
-
 entity.entity_test_mulrev.revision:
   path: '/entity_test_mulrev/{entity_test_mulrev}/revision/{entity_test_mulrev_revision}/view'
   defaults:
-    _controller: '\Drupal\Core\Entity\Controller\EntityViewController::viewRevision'
+    _controller: '\Drupal\Core\Entity\Controller\EntityRevisionViewController'
   options:
     parameters:
       entity_test_mulrev:
diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTest.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTest.php
index ecf193b9d06e339fdcf1de53bc9902143bb106b6..cdff2c884c085775419f874816eeb0b24162ad4f 100644
--- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTest.php
+++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTest.php
@@ -73,7 +73,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
       ->setLabel(t('Name'))
       ->setDescription(t('The name of the test entity.'))
       ->setTranslatable(TRUE)
-      ->setSetting('max_length', 32)
+      ->setSetting('max_length', 64)
       ->setDisplayOptions('view', [
         'label' => 'hidden',
         'type' => 'string',
diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRev.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRev.php
index e47e9362767123a3acd8ea56ec26db8cb084b753..0c1326b66244d2379da2ea62ab1ca5a05af7b3e0 100644
--- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRev.php
+++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRev.php
@@ -17,11 +17,14 @@
  *     "form" = {
  *       "default" = "Drupal\entity_test\EntityTestForm",
  *       "delete" = "Drupal\entity_test\EntityTestDeleteForm",
- *       "delete-multiple-confirm" = "Drupal\Core\Entity\Form\DeleteMultipleForm"
+ *       "delete-multiple-confirm" = \Drupal\Core\Entity\Form\DeleteMultipleForm::class,
+ *       "revision-delete" = \Drupal\Core\Entity\Form\RevisionDeleteForm::class,
+ *       "revision-revert" = \Drupal\Core\Entity\Form\RevisionRevertForm::class,
  *     },
  *     "views_data" = "Drupal\views\EntityViewsData",
  *     "route_provider" = {
  *       "html" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
+ *       "revision" = \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider::class,
  *     },
  *   },
  *   base_table = "entity_test_rev",
@@ -43,6 +46,9 @@
  *     "delete-multiple-form" = "/entity_test_rev/delete_multiple",
  *     "edit-form" = "/entity_test_rev/manage/{entity_test_rev}/edit",
  *     "revision" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/view",
+ *     "revision-delete-form" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/delete",
+ *     "revision-revert-form" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/revert",
+ *     "version-history" = "/entity_test_rev/{entity_test_rev}/revisions",
  *   }
  * )
  */
diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRevPub.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRevPub.php
index 3c3a201d589d25d9b64c2bcb21b6b07bfab77d9c..8b961601355ec9be24b4499d2e76401a2703cd1d 100644
--- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRevPub.php
+++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRevPub.php
@@ -18,7 +18,13 @@
  *     "form" = {
  *       "default" = "Drupal\entity_test\EntityTestForm",
  *       "delete" = "Drupal\entity_test\EntityTestDeleteForm",
- *       "delete-multiple-confirm" = "Drupal\Core\Entity\Form\DeleteMultipleForm"
+ *       "delete-multiple-confirm" = \Drupal\Core\Entity\Form\DeleteMultipleForm::class,
+ *       "revision-delete" = \Drupal\Core\Entity\Form\RevisionDeleteForm::class,
+ *       "revision-revert" = \Drupal\Core\Entity\Form\RevisionRevertForm::class,
+ *     },
+ *     "route_provider" = {
+ *       "html" = \Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider::class,
+ *       "revision" = \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider::class,
  *     },
  *   },
  *   base_table = "entity_test_revpub",
@@ -35,12 +41,15 @@
  *     "published" = "status",
  *   },
  *   links = {
- *     "add-form" = "/entity_test_rev/add",
- *     "canonical" = "/entity_test_rev/manage/{entity_test_rev}",
- *     "delete-form" = "/entity_test/delete/entity_test_rev/{entity_test_rev}",
- *     "delete-multiple-form" = "/entity_test_rev/delete_multiple",
- *     "edit-form" = "/entity_test_rev/manage/{entity_test_rev}/edit",
- *     "revision" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/view",
+ *     "add-form" = "/entity_test_revpub/add",
+ *     "canonical" = "/entity_test_revpub/manage/{entity_test_revpub}",
+ *     "delete-form" = "/entity_test/delete/entity_test_revpub/{entity_test_revpub}",
+ *     "delete-multiple-form" = "/entity_test_revpub/delete_multiple",
+ *     "edit-form" = "/entity_test_revpub/manage/{entity_test_revpub}/edit",
+ *     "revision" = "/entity_test_revpub/{entity_test_revpub}/revision/{entity_test_revpub_revision}/view",
+ *     "revision-delete-form" = "/entity_test_revpub/{entity_test_revpub}/revision/{entity_test_revpub_revision}/delete",
+ *     "revision-revert-form" = "/entity_test_revpub/{entity_test_revpub}/revision/{entity_test_revpub_revision}/revert",
+ *     "version-history" = "/entity_test_revpub/{entity_test_revpub}/revisions",
  *   }
  * )
  */
diff --git a/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php b/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php
index 4e7b4dc2a0475f2c61e1466371fd918c056f4d82..287be829cadaae8c8cd8f85994c9f4eb957a7873 100644
--- a/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php
+++ b/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php
@@ -65,9 +65,27 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter
       return $access;
     }
 
+    // Access to revisions is based on labels, so access can vary by individual
+    // revisions, since the 'name' field can vary by revision.
+    $labels = explode(',', $entity->label());
+    $labels = array_map('trim', $labels);
+    if (in_array($operation, [
+      'view all revisions',
+      'view revision',
+    ], TRUE)) {
+      return AccessResult::allowedIf(in_array($operation, $labels, TRUE));
+    }
+    elseif ($operation === 'revert') {
+      // Disallow reverting to latest.
+      return AccessResult::allowedIf(!$entity->isDefaultRevision() && !$entity->isLatestRevision() && in_array('revert', $labels, TRUE));
+    }
+    elseif ($operation === 'delete revision') {
+      // Disallow deleting latest and current revision.
+      return AccessResult::allowedIf(!$entity->isLatestRevision() && in_array('delete revision', $labels, TRUE));
+    }
+
     // No opinion.
     return AccessResult::neutral();
-
   }
 
   /**
diff --git a/core/modules/system/tests/modules/entity_test_revlog/src/Entity/EntityTestWithRevisionLog.php b/core/modules/system/tests/modules/entity_test_revlog/src/Entity/EntityTestWithRevisionLog.php
index b03415ff58adcc7d96df8446ac620dc27852d091..c5430498b766ba47cc2218db143b78581b2f70da 100644
--- a/core/modules/system/tests/modules/entity_test_revlog/src/Entity/EntityTestWithRevisionLog.php
+++ b/core/modules/system/tests/modules/entity_test_revlog/src/Entity/EntityTestWithRevisionLog.php
@@ -12,21 +12,44 @@
  * @ContentEntityType(
  *   id = "entity_test_revlog",
  *   label = @Translation("Test entity - revisions log"),
+ *   handlers = {
+ *     "access" = "Drupal\entity_test_revlog\EntityTestRevlogAccessControlHandler",
+ *     "form" = {
+ *       "default" = \Drupal\Core\Entity\ContentEntityForm::class,
+ *       "revision-delete" = \Drupal\Core\Entity\Form\RevisionDeleteForm::class,
+ *       "revision-revert" = \Drupal\Core\Entity\Form\RevisionRevertForm::class,
+ *     },
+ *     "route_provider" = {
+ *       "html" = \Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider::class,
+ *       "revision" = \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider::class,
+ *     },
+ *   },
  *   base_table = "entity_test_revlog",
  *   revision_table = "entity_test_revlog_revision",
+ *   translatable = FALSE,
  *   entity_keys = {
  *     "id" = "id",
  *     "uuid" = "uuid",
  *     "revision" = "revision_id",
  *     "bundle" = "type",
  *     "label" = "name",
- *     "langcode" = "langcode",
  *   },
  *   revision_metadata_keys = {
  *     "revision_user" = "revision_user",
  *     "revision_created" = "revision_created",
  *     "revision_log_message" = "revision_log_message"
  *   },
+ *   links = {
+ *     "add-form" = "/entity_test_revlog/add",
+ *     "canonical" = "/entity_test_revlog/manage/{entity_test_revlog}",
+ *     "delete-form" = "/entity_test/delete/entity_test_revlog/{entity_test_revlog}",
+ *     "delete-multiple-form" = "/entity_test_revlog/delete_multiple",
+ *     "edit-form" = "/entity_test_revlog/manage/{entity_test_revlog}/edit",
+ *     "revision" = "/entity_test_revlog/{entity_test_revlog}/revision/{entity_test_revlog_revision}/view",
+ *     "revision-delete-form" = "/entity_test_revlog/{entity_test_revlog}/revision/{entity_test_revlog_revision}/delete",
+ *     "revision-revert-form" = "/entity_test_revlog/{entity_test_revlog}/revision/{entity_test_revlog_revision}/revert",
+ *     "version-history" = "/entity_test_revlog/{entity_test_revlog}/revisions",
+ *   }
  * )
  */
 class EntityTestWithRevisionLog extends RevisionableContentEntityBase {
@@ -40,9 +63,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
     $fields['name'] = BaseFieldDefinition::create('string')
       ->setLabel(t('Name'))
       ->setDescription(t('The name of the test entity.'))
-      ->setTranslatable(TRUE)
       ->setRevisionable(TRUE)
-      ->setSetting('max_length', 32)
+      ->setSetting('max_length', 64)
       ->setDisplayOptions('view', [
         'label' => 'hidden',
         'type' => 'string',
@@ -56,4 +78,17 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
     return $fields;
   }
 
+  /**
+   * Sets the name.
+   *
+   * @param string $name
+   *   Name of the entity.
+   *
+   * @return $this
+   */
+  public function setName(string $name) {
+    $this->set('name', $name);
+    return $this;
+  }
+
 }
diff --git a/core/modules/system/tests/modules/entity_test_revlog/src/EntityTestRevlogAccessControlHandler.php b/core/modules/system/tests/modules/entity_test_revlog/src/EntityTestRevlogAccessControlHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..a5275600bc6170d877208780d68381f1ad6d42de
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test_revlog/src/EntityTestRevlogAccessControlHandler.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Drupal\entity_test_revlog;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityAccessControlHandler;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog;
+
+/**
+ * Defines the access control handler for test entity types.
+ */
+class EntityTestRevlogAccessControlHandler extends EntityAccessControlHandler {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    assert($entity instanceof EntityTestWithRevisionLog);
+
+    // Access to revisions is based on labels, so access can vary by individual
+    // revisions, since the 'name' field can vary by revision.
+    $labels = explode(',', $entity->label());
+    $labels = array_map('trim', $labels);
+    if (in_array($operation, [
+      'view',
+      'view label',
+      'view all revisions',
+      'view revision',
+    ], TRUE)) {
+      return AccessResult::allowedIf(in_array($operation, $labels, TRUE));
+    }
+    elseif ($operation === 'revert') {
+      return AccessResult::allowedIf(
+        // Allow revert even if latest.
+        in_array('force allow revert', $labels, TRUE) ||
+        // Disallow reverting to latest.
+        (!$entity->isDefaultRevision() && !$entity->isLatestRevision() && in_array('revert', $labels, TRUE))
+      );
+    }
+    elseif ($operation === 'delete revision') {
+      return AccessResult::allowedIf(
+        // Allow revision deletion even if latest.
+        in_array('force allow delete revision', $labels, TRUE) ||
+        // Disallow deleting latest and current revision.
+        (!$entity->isLatestRevision() && in_array('delete revision', $labels, TRUE))
+      );
+    }
+
+    // No opinion.
+    return AccessResult::neutral();
+  }
+
+}
diff --git a/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php b/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php
index 6c0f802f6e4018ac5a800df5a539d50348d88aae..3ce683ec9287d02c2be1d4a9c33f89ac3c5b7b56 100644
--- a/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php
+++ b/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php
@@ -38,7 +38,7 @@ protected function setUp(): void {
     parent::setUp();
     // Create some dummy entity_test entities.
     for ($i = 0; $i < 2; $i++) {
-      $entity_test = $this->createTestEntity('entity_test');
+      $entity_test = $this->createTestEntity('entity_test', 'view revision');
       $entity_test->save();
       $this->entities[] = $entity_test;
     }
@@ -78,7 +78,7 @@ public function testEntityViewController() {
     $entity_test_rev->setNewRevision(TRUE);
     $entity_test_rev->isDefaultRevision(TRUE);
     $entity_test_rev->save();
-    $this->drupalGet('entity_test_rev/' . $entity_test_rev->id() . '/revision/' . $entity_test_rev->revision_id->value . '/view');
+    $this->drupalGet($entity_test_rev->toUrl('revision'));
     $this->assertSession()->pageTextContains($entity_test_rev->label());
     $this->assertSession()->responseContains($get_label_markup($entity_test_rev->label()));
 
@@ -125,14 +125,16 @@ public function testEntityViewControllerViewBuilder() {
    *
    * @param string $entity_type
    *   The entity type.
+   * @param string|null $name
+   *   The entity name, or NULL to generate random name.
    *
    * @return \Drupal\Core\Entity\EntityInterface
    *   The created entity.
    */
-  protected function createTestEntity($entity_type) {
+  protected function createTestEntity($entity_type, $name = NULL) {
     $data = [
       'bundle' => $entity_type,
-      'name' => $this->randomMachineName(),
+      'name' => $name ?? $this->randomMachineName(),
     ];
     return $this->container->get('entity_type.manager')->getStorage($entity_type)->create($data);
   }
diff --git a/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php b/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php
index 55d79ec9fba02f3959ea9620ffdd4b92fb52752f..ab0ff1186543233069ec73d17498d2dc6e6a7d83 100644
--- a/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php
+++ b/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\Tests\system\Unit\Menu;
 
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Extension\Extension;
 use Drupal\Tests\Core\Menu\LocalTaskIntegrationTestBase;
 
@@ -15,7 +17,7 @@ class SystemLocalTasksTest extends LocalTaskIntegrationTestBase {
   /**
    * The mocked theme handler.
    *
-   * @var \Drupal\Core\Extension\ThemeHandlerInterface|\PHPUnit\Framework\MockObject\MockObject
+   * @var \Drupal\Core\Extension\ThemeHandlerInterface
    */
   protected $themeHandler;
 
@@ -44,6 +46,20 @@ protected function setUp(): void {
       ->with('olivero')
       ->willReturn(TRUE);
     $this->container->set('theme_handler', $this->themeHandler);
+
+    $fooEntityDefinition = $this->createMock(EntityTypeInterface::class);
+    $fooEntityDefinition
+      ->expects($this->once())
+      ->method('hasLinkTemplate')
+      ->with('version-history')
+      ->will($this->returnValue(TRUE));
+    $entityTypeManager = $this->createMock(EntityTypeManagerInterface::class);
+    $entityTypeManager->expects($this->any())
+      ->method('getDefinitions')
+      ->willReturn([
+        'foo' => $fooEntityDefinition,
+      ]);
+    $this->container->set('entity_type.manager', $entityTypeManager);
   }
 
   /**
@@ -68,6 +84,12 @@ public function getSystemAdminRoutes() {
           ['system.theme_settings_global', 'system.theme_settings_theme:olivero'],
         ],
       ],
+      [
+        'entity.foo.version_history',
+        [
+          ['entity.version_history:foo.version_history'],
+        ],
+      ],
     ];
   }
 
diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..17372ca8270e68502746dff2465936fe22acf8bb
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php
@@ -0,0 +1,308 @@
+<?php
+
+namespace Drupal\FunctionalTests\Entity;
+
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Entity\RevisionLogInterface;
+use Drupal\entity_test\Entity\EntityTestRev;
+use Drupal\entity_test\Entity\EntityTestRevPub;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests deleting a revision with revision delete form.
+ *
+ * @group Entity
+ * @coversDefaultClass \Drupal\Core\Entity\Form\RevisionDeleteForm
+ */
+class RevisionDeleteFormTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'block',
+    'entity_test',
+    'entity_test_revlog',
+    'dblog',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
+  /**
+   * Tests title by whether entity supports revision creation dates.
+   *
+   * @param string $entityTypeId
+   *   The entity type to test.
+   * @param string $expectedQuestion
+   *   The expected question/page title.
+   *
+   * @covers ::getQuestion
+   * @dataProvider providerPageTitle
+   */
+  public function testPageTitle(string $entityTypeId, string $expectedQuestion): void {
+    $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
+
+    $entity = $storage->create([
+      'type' => $entityTypeId,
+      'name' => 'delete revision',
+    ]);
+    if ($entity instanceof RevisionLogInterface) {
+      $date = new \DateTime('11 January 2009 4:00:00pm');
+      $entity->setRevisionCreationTime($date->getTimestamp());
+    }
+    $entity->setNewRevision();
+    $entity->save();
+    $revisionId = $entity->getRevisionId();
+
+    // Create a new latest revision.
+    if ($entity instanceof RevisionLogInterface) {
+      $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
+    }
+    $entity->setNewRevision();
+    $entity->save();
+
+    // Reload the entity.
+    $revision = $storage->loadRevision($revisionId);
+    $this->drupalGet($revision->toUrl('revision-delete-form'));
+    $this->assertSession()->pageTextContains($expectedQuestion);
+    $this->assertSession()->buttonExists('Delete');
+    $this->assertSession()->linkExists('Cancel');
+  }
+
+  /**
+   * Data provider for testPageTitle.
+   */
+  public function providerPageTitle(): array {
+    return [
+      ['entity_test_rev', 'Are you sure you want to delete the revision?'],
+      ['entity_test_revlog', 'Are you sure you want to delete the revision from Sun, 01/11/2009 - 16:00?'],
+    ];
+  }
+
+  /**
+   * Test cannot delete latest revision.
+   *
+   * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess
+   */
+  public function testAccessDeleteLatest(): void {
+    /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
+    $entity = EntityTestRev::create();
+    $entity->setName('delete revision');
+    $entity->save();
+
+    $entity->setNewRevision();
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('revision-delete-form'));
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Test cannot delete default revision.
+   *
+   * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess
+   */
+  public function testAccessDeleteDefault(): void {
+    /** @var \Drupal\entity_test\Entity\EntityTestRevPub $entity */
+    $entity = EntityTestRevPub::create();
+    $entity->setName('delete revision');
+    $entity->save();
+
+    $entity->isDefaultRevision(TRUE);
+    $entity->setPublished();
+    $entity->setNewRevision();
+    $entity->save();
+    $revisionId = $entity->getRevisionId();
+
+    $entity->isDefaultRevision(FALSE);
+    $entity->setUnpublished();
+    $entity->setNewRevision();
+    $entity->save();
+
+    // Reload the entity.
+    /** @var \Drupal\entity_test\Entity\EntityTestRevPub $revision */
+    $revision = \Drupal::entityTypeManager()->getStorage('entity_test_revpub')
+      ->loadRevision($revisionId);
+    // Check default but not latest.
+    $this->assertTrue($revision->isDefaultRevision());
+    $this->assertFalse($revision->isLatestRevision());
+    $this->drupalGet($entity->toUrl('revision-delete-form'));
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Test can delete non-latest revision.
+   *
+   * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess
+   */
+  public function testAccessDeleteNonLatest(): void {
+    /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
+    $entity = EntityTestRev::create();
+    $entity->setName('delete revision');
+    $entity->save();
+    $entity->isDefaultRevision();
+    $revisionId = $entity->getRevisionId();
+
+    $entity->setNewRevision();
+    $entity->save();
+
+    // Reload the entity.
+    $revision = \Drupal::entityTypeManager()->getStorage('entity_test_rev')
+      ->loadRevision($revisionId);
+    $this->drupalGet($revision->toUrl('revision-delete-form'));
+    $this->assertSession()->statusCodeEquals(200);
+  }
+
+  /**
+   * Tests revision deletion, and expected response after deletion.
+   *
+   * @param array $permissions
+   *   If not empty, a user will be created and logged in with these
+   *   permissions.
+   * @param string $entityTypeId
+   *   The entity type to test.
+   * @param string $entityLabel
+   *   The entity label, which corresponds to access grants.
+   * @param int $totalRevisions
+   *   Total number of revisions to create.
+   * @param string $expectedLog
+   *   Expected log.
+   * @param string $expectedMessage
+   *   Expected messenger message.
+   * @param string|int $expectedDestination
+   *   Expected destination after deletion.
+   *
+   * @covers ::submitForm
+   * @dataProvider providerSubmitForm
+   */
+  public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, string $expectedLog, string $expectedMessage, $expectedDestination): void {
+    if (count($permissions) > 0) {
+      $this->drupalLogin($this->createUser($permissions));
+    }
+    $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
+
+    $entity = $storage->create([
+      'type' => $entityTypeId,
+      'name' => $entityLabel,
+    ]);
+    if ($entity instanceof RevisionLogInterface) {
+      $date = new \DateTime('11 January 2009 4:00:00pm');
+      $entity->setRevisionCreationTime($date->getTimestamp());
+    }
+    $entity->save();
+    $revisionId = $entity->getRevisionId();
+
+    $otherRevisionIds = [];
+    for ($i = 0; $i < $totalRevisions - 1; $i++) {
+      if ($entity instanceof RevisionLogInterface) {
+        $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
+      }
+      $entity->setNewRevision();
+      $entity->save();
+      $otherRevisionIds[] = $entity->getRevisionId();
+    }
+
+    $revision = $storage->loadRevision($revisionId);
+    $this->drupalGet($revision->toUrl('revision-delete-form'));
+    $this->submitForm([], 'Delete');
+
+    // The revision was deleted.
+    $this->assertNull($storage->loadRevision($revisionId));
+    // Make sure the other revisions were not deleted.
+    foreach ($otherRevisionIds as $otherRevisionId) {
+      $this->assertNotNull($storage->loadRevision($otherRevisionId));
+    }
+
+    // Destination.
+    if ($expectedDestination === 404) {
+      $this->assertSession()->statusCodeEquals(404);
+    }
+    else {
+      $this->assertSession()->statusCodeEquals(200);
+      $this->assertSession()->addressEquals($expectedDestination);
+    }
+
+    // Logger log.
+    $logs = $this->getLogs($entity->getEntityType()->getProvider());
+    $this->assertEquals([0 => $expectedLog], $logs);
+    // Messenger message.
+    $this->assertSession()->pageTextContains($expectedMessage);
+  }
+
+  /**
+   * Data provider for testSubmitForm.
+   */
+  public function providerSubmitForm(): array {
+    $data = [];
+
+    $data['not supporting revision log, one revision remaining after delete, no view access'] = [
+      [],
+      'entity_test_rev',
+      'view all revisions, delete revision',
+      2,
+      'entity_test_rev: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.',
+      'Revision of Entity Test Bundle view all revisions, delete revision has been deleted.',
+      '/entity_test_rev/1/revisions',
+    ];
+
+    $data['not supporting revision log, one revision remaining after delete, view access'] = [
+      ['view test entity'],
+      'entity_test_rev',
+      'view, view all revisions, delete revision',
+      2,
+      'entity_test_rev: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">1</em>.',
+      'Revision of Entity Test Bundle view, view all revisions, delete revision has been deleted.',
+      '/entity_test_rev/1/revisions',
+    ];
+
+    $data['supporting revision log, one revision remaining after delete, no view access'] = [
+      [],
+      'entity_test_revlog',
+      'view all revisions, delete revision',
+      2,
+      'entity_test_revlog: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.',
+      'Revision from Sun, 01/11/2009 - 16:00 of Test entity - revisions log view all revisions, delete revision has been deleted.',
+      '/entity_test_revlog/1/revisions',
+    ];
+
+    $data['supporting revision log, one revision remaining after delete, view access'] = [
+      [],
+      'entity_test_revlog',
+      'view, view all revisions, delete revision',
+      2,
+      'entity_test_revlog: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">1</em>.',
+      'Revision from Sun, 01/11/2009 - 16:00 of Test entity - revisions log view, view all revisions, delete revision has been deleted.',
+      '/entity_test_revlog/1/revisions',
+    ];
+
+    return $data;
+  }
+
+  /**
+   * Loads watchdog entries by channel.
+   *
+   * @param string $channel
+   *   The logger channel.
+   *
+   * @return string[]
+   *   Watchdog entries.
+   */
+  protected function getLogs(string $channel): array {
+    $logs = \Drupal::database()->query("SELECT * FROM {watchdog} WHERE type = :type", [':type' => $channel])->fetchAll();
+    return array_map(function (object $log) {
+      return (string) new FormattableMarkup($log->message, unserialize($log->variables));
+    }, $logs);
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..82d0d06d8c2b13f979d8bfe0d407556d87c08bf5
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php
@@ -0,0 +1,330 @@
+<?php
+
+namespace Drupal\FunctionalTests\Entity;
+
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Entity\RevisionLogInterface;
+use Drupal\entity_test\Entity\EntityTestRev;
+use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests reverting a revision with revision revert form.
+ *
+ * @group Entity
+ * @coversDefaultClass \Drupal\Core\Entity\Form\RevisionRevertForm
+ */
+class RevisionRevertFormTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'block',
+    'entity_test',
+    'entity_test_revlog',
+    'dblog',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
+  /**
+   * Tests title by whether entity supports revision creation dates.
+   *
+   * @param string $entityTypeId
+   *   The entity type to test.
+   * @param string $expectedQuestion
+   *   The expected question/page title.
+   *
+   * @covers ::getQuestion
+   * @dataProvider providerPageTitle
+   */
+  public function testPageTitle(string $entityTypeId, string $expectedQuestion): void {
+    $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
+
+    $entity = $storage->create([
+      'type' => $entityTypeId,
+      'name' => 'revert',
+    ]);
+    if ($entity instanceof RevisionLogInterface) {
+      $date = new \DateTime('11 January 2009 4:00:00pm');
+      $entity->setRevisionCreationTime($date->getTimestamp());
+    }
+    $entity->setNewRevision();
+    $entity->save();
+    $revisionId = $entity->getRevisionId();
+
+    // Create a new latest revision.
+    if ($entity instanceof RevisionLogInterface) {
+      $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
+    }
+    $entity->setNewRevision();
+    $entity->save();
+
+    // Reload the entity.
+    $revision = $storage->loadRevision($revisionId);
+    $this->drupalGet($revision->toUrl('revision-revert-form'));
+    $this->assertSession()->pageTextContains($expectedQuestion);
+    $this->assertSession()->buttonExists('Revert');
+    $this->assertSession()->linkExists('Cancel');
+  }
+
+  /**
+   * Data provider for testPageTitle.
+   */
+  public function providerPageTitle(): array {
+    return [
+      ['entity_test_rev', 'Are you sure you want to revert the revision?'],
+      ['entity_test_revlog', 'Are you sure you want to revert to the revision from Sun, 01/11/2009 - 16:00?'],
+    ];
+  }
+
+  /**
+   * Test cannot revert latest revision.
+   *
+   * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess
+   */
+  public function testAccessRevertLatest(): void {
+    /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
+    $entity = EntityTestRev::create();
+    $entity->setName('revert');
+    $entity->save();
+
+    $entity->setNewRevision();
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('revision-revert-form'));
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Test can revert non-latest revision.
+   *
+   * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess
+   */
+  public function testAccessRevertNonLatest(): void {
+    /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
+    $entity = EntityTestRev::create();
+    $entity->setName('revert');
+    $entity->save();
+    $revisionId = $entity->getRevisionId();
+
+    $entity->setNewRevision();
+    $entity->save();
+
+    // Reload the entity.
+    $revision = \Drupal::entityTypeManager()->getStorage('entity_test_rev')
+      ->loadRevision($revisionId);
+    $this->drupalGet($revision->toUrl('revision-revert-form'));
+    $this->assertSession()->statusCodeEquals(200);
+  }
+
+  /**
+   * Tests revision revert, and expected response after revert.
+   *
+   * @param array $permissions
+   *   If not empty, a user will be created and logged in with these
+   *   permissions.
+   * @param string $entityTypeId
+   *   The entity type to test.
+   * @param string $entityLabel
+   *   The entity label, which corresponds to access grants.
+   * @param string $expectedLog
+   *   Expected log.
+   * @param string $expectedMessage
+   *   Expected messenger message.
+   * @param string $expectedDestination
+   *   Expected destination after deletion.
+   *
+   * @covers ::submitForm
+   * @dataProvider providerSubmitForm
+   */
+  public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, string $expectedLog, string $expectedMessage, string $expectedDestination): void {
+    if (count($permissions) > 0) {
+      $this->drupalLogin($this->createUser($permissions));
+    }
+    $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
+
+    $entity = $storage->create([
+      'type' => $entityTypeId,
+      'name' => $entityLabel,
+    ]);
+    if ($entity instanceof RevisionLogInterface) {
+      $date = new \DateTime('11 January 2009 4:00:00pm');
+      $entity->setRevisionCreationTime($date->getTimestamp());
+    }
+    $entity->save();
+    $revisionId = $entity->getRevisionId();
+
+    if ($entity instanceof RevisionLogInterface) {
+      $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
+    }
+    $entity->setNewRevision();
+    $entity->save();
+
+    $revision = $storage->loadRevision($revisionId);
+    $this->drupalGet($revision->toUrl('revision-revert-form'));
+
+    $count = $this->countRevisions($entityTypeId);
+    $this->submitForm([], 'Revert');
+
+    // A new revision was created.
+    $this->assertEquals($count + 1, $this->countRevisions($entityTypeId));
+
+    // Destination.
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertSession()->addressEquals($expectedDestination);
+
+    // Logger log.
+    $logs = $this->getLogs($entity->getEntityType()->getProvider());
+    $this->assertEquals([0 => $expectedLog], $logs);
+    // Messenger message.
+    $this->assertSession()->pageTextContains($expectedMessage);
+  }
+
+  /**
+   * Data provider for testSubmitForm.
+   */
+  public function providerSubmitForm(): array {
+    $data = [];
+
+    $data['not supporting revision log, no version history access'] = [
+      ['view test entity'],
+      'entity_test_rev',
+      'view, revert',
+      'entity_test_rev: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.',
+      'Entity Test Bundle view, revert has been reverted.',
+      '/entity_test_rev/manage/1',
+    ];
+
+    $data['not supporting revision log, version history access'] = [
+      ['view test entity'],
+      'entity_test_rev',
+      'view, view all revisions, revert',
+      'entity_test_rev: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.',
+      'Entity Test Bundle view, view all revisions, revert has been reverted.',
+      '/entity_test_rev/1/revisions',
+    ];
+
+    $data['supporting revision log, no version history access'] = [
+      [],
+      'entity_test_revlog',
+      'view, revert',
+      'entity_test_revlog: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.',
+      'Test entity - revisions log view, revert has been reverted to the revision from Sun, 01/11/2009 - 16:00.',
+      '/entity_test_revlog/manage/1',
+    ];
+
+    $data['supporting revision log, version history access'] = [
+      [],
+      'entity_test_revlog',
+      'view, view all revisions, revert',
+      'entity_test_revlog: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.',
+      'Test entity - revisions log view, view all revisions, revert has been reverted to the revision from Sun, 01/11/2009 - 16:00.',
+      '/entity_test_revlog/1/revisions',
+    ];
+
+    return $data;
+  }
+
+  /**
+   * Tests the revert process.
+   *
+   * @covers ::prepareRevision
+   */
+  public function testPrepareRevision(): void {
+    $user = $this->createUser();
+    $this->drupalLogin($user);
+
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
+    $entity = EntityTestWithRevisionLog::create([
+      'type' => 'entity_test_revlog',
+      'name' => 'revert',
+    ]);
+
+    $date = new \DateTime('11 January 2009 4:00:00pm');
+    $entity->setRevisionCreationTime($date->getTimestamp());
+    $entity->isDefaultRevision(TRUE);
+    $entity->setNewRevision();
+    $entity->save();
+
+    $revisionCreationTime = $date->modify('+1 hour')->getTimestamp();
+    $entity->setRevisionCreationTime($revisionCreationTime);
+    $entity->setRevisionUserId(0);
+    $entity->isDefaultRevision(FALSE);
+    $entity->setNewRevision();
+    $entity->save();
+    $targetRevertRevisionId = $entity->getRevisionId();
+
+    // Create a another revision so the previous revision can be reverted to.
+    $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
+    $entity->isDefaultRevision(FALSE);
+    $entity->setNewRevision();
+    $entity->save();
+
+    $count = $this->countRevisions($entity->getEntityTypeId());
+
+    // Load the revision to be copied.
+    $storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId());
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $targetRevision */
+    $targetRevision = $storage->loadRevision($targetRevertRevisionId);
+
+    $this->drupalGet($targetRevision->toUrl('revision-revert-form'));
+    $this->submitForm([], 'Revert');
+
+    // Load the new latest revision.
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $latestRevision */
+    $latestRevision = $storage->loadUnchanged($entity->id());
+    $this->assertEquals($count + 1, $this->countRevisions($entity->getEntityTypeId()));
+    $this->assertEquals('Copy of the revision from <em class="placeholder">Sun, 01/11/2009 - 17:00</em>.', $latestRevision->getRevisionLogMessage());
+    $this->assertGreaterThan($revisionCreationTime, $latestRevision->getRevisionCreationTime());
+    $this->assertEquals($user->id(), $latestRevision->getRevisionUserId());
+    $this->assertTrue($latestRevision->isDefaultRevision());
+  }
+
+  /**
+   * Loads watchdog entries by channel.
+   *
+   * @param string $channel
+   *   The logger channel.
+   *
+   * @return string[]
+   *   Watchdog entries.
+   */
+  protected function getLogs(string $channel): array {
+    $logs = \Drupal::database()->query("SELECT * FROM {watchdog} WHERE type = :type", [':type' => $channel])->fetchAll();
+    return array_map(function (object $log) {
+      return (string) new FormattableMarkup($log->message, unserialize($log->variables));
+    }, $logs);
+  }
+
+  /**
+   * Count number of revisions for an entity type.
+   *
+   * @param string $entityTypeId
+   *   The entity type.
+   *
+   * @return int
+   *   Number of revisions for an entity type.
+   */
+  protected function countRevisions(string $entityTypeId): int {
+    return (int) \Drupal::entityTypeManager()->getStorage($entityTypeId)
+      ->getQuery()
+      ->accessCheck(FALSE)
+      ->allRevisions()
+      ->count()
+      ->execute();
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionRouteProviderTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionRouteProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..56062dade42ec941ac8bb72e9b35e8b03ecf8b7c
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionRouteProviderTest.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\FunctionalTests\Entity;
+
+use Drupal\entity_test\Entity\EntityTestRev;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests revision route provider.
+ *
+ * @group Entity
+ * @coversDefaultClass \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider
+ */
+class RevisionRouteProviderTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'block',
+    'entity_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
+  /**
+   * Tests title is from revision in context.
+   */
+  public function testRevisionTitle(): void {
+    $entity = EntityTestRev::create();
+    $entity
+      ->setName('first revision, view revision')
+      ->setNewRevision();
+    $entity->save();
+    $revisionId = $entity->getRevisionId();
+
+    // A default revision is created to ensure it is not pulled from the
+    // non-revision entity parameter.
+    $entity
+      ->setName('second revision, view revision')
+      ->setNewRevision();
+    $entity->isDefaultRevision(TRUE);
+    $entity->save();
+
+    // Reload the object.
+    $revision = \Drupal::entityTypeManager()->getStorage('entity_test_rev')->loadRevision($revisionId);
+    $this->drupalGet($revision->toUrl('revision'));
+    $this->assertSession()->responseContains('first revision');
+    $this->assertSession()->responseNotContains('second revision');
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionVersionHistoryTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionVersionHistoryTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..98a77808fdc44245ac9ff92191a20f204da838f0
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionVersionHistoryTest.php
@@ -0,0 +1,307 @@
+<?php
+
+namespace Drupal\FunctionalTests\Entity;
+
+use Drupal\entity_test\Entity\EntityTestRev;
+use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests version history page.
+ *
+ * @group Entity
+ * @coversDefaultClass \Drupal\Core\Entity\Controller\VersionHistoryController
+ */
+class RevisionVersionHistoryTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'entity_test',
+    'entity_test_revlog',
+    'user',
+  ];
+
+  /**
+   * Test all revisions appear, in order of revision creation.
+   */
+  public function testOrder(): void {
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
+    $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
+    // Need label to be able to assert order.
+    $entity->setName('view all revisions');
+    $user = $this->drupalCreateUser([], 'first revision');
+    $entity->setRevisionUser($user);
+    $entity->setNewRevision();
+    $entity->save();
+
+    $entity->setNewRevision();
+    $user = $this->drupalCreateUser([], 'second revision');
+    $entity->setRevisionUser($user);
+    $entity->save();
+
+    $entity->setNewRevision();
+    $user = $this->drupalCreateUser([], 'third revision');
+    $entity->setRevisionUser($user);
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementsCount('css', 'table tbody tr', 3);
+    // Order is newest to oldest revision by creation order.
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'third revision');
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(2)', 'second revision');
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(3)', 'first revision');
+  }
+
+  /**
+   * Test current revision is indicated.
+   *
+   * @covers \Drupal\Core\Entity\Controller\RevisionControllerTrait::revisionOverview
+   */
+  public function testCurrentRevision(): void {
+    /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
+    $entity = EntityTestRev::create(['type' => 'entity_test_rev']);
+    // Need label to be able to assert order.
+    $entity->setName('view all revisions');
+    $entity->setNewRevision();
+    $entity->save();
+
+    $entity->setNewRevision();
+    $entity->save();
+
+    $entity->setNewRevision();
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementsCount('css', 'table tbody tr', 3);
+    // Current revision text is found on the latest revision row.
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
+    $this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(2)', 'Current revision');
+    $this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(3)', 'Current revision');
+    // Current revision row has 'revision-current' class.
+    $this->assertSession()->elementAttributeContains('css', 'table tbody tr:nth-child(1)', 'class', 'revision-current');
+  }
+
+  /**
+   * Test description with entity implementing revision log.
+   *
+   * @covers ::getRevisionDescription
+   */
+  public function testDescriptionRevLog(): void {
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
+    $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
+    $entity->setName('view all revisions');
+    $user = $this->drupalCreateUser([], $this->randomMachineName());
+    $entity->setRevisionUser($user);
+    $entity->setRevisionCreationTime((new \DateTime('2 February 2013 4:00:00pm'))->getTimestamp());
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', '02/02/2013 - 16:00');
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', $user->getAccountName());
+  }
+
+  /**
+   * Test description with entity, without revision log, no label access.
+   *
+   * @covers ::getRevisionDescription
+   */
+  public function testDescriptionNoRevLogNoLabelAccess(): void {
+    /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
+    $entity = EntityTestRev::create(['type' => 'entity_test_rev']);
+    $entity->setName('view all revisions');
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', '- Restricted access -');
+    $this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(1)', $entity->getName());
+  }
+
+  /**
+   * Test description with entity, without revision log, with label access.
+   *
+   * @covers ::getRevisionDescription
+   */
+  public function testDescriptionNoRevLogWithLabelAccess(): void {
+    // Permission grants 'view label' access.
+    $this->drupalLogin($this->createUser(['view test entity']));
+
+    /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
+    $entity = EntityTestRev::create(['type' => 'entity_test_rev']);
+    $entity->setName('view all revisions');
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', $entity->getName());
+    $this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(1)', '- Restricted access -');
+  }
+
+  /**
+   * Test revision link, without access to revision page.
+   *
+   * @covers ::getRevisionDescription
+   */
+  public function testDescriptionLinkNoAccess(): void {
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
+    $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
+    $entity->setName('view all revisions');
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementsCount('css', 'table tbody tr', 1);
+    $this->assertSession()->elementsCount('css', 'table tbody tr a', 0);
+  }
+
+  /**
+   * Test revision link, with access to revision page.
+   *
+   * Test two revisions. Usually the latest revision only checks canonical
+   * route access, whereas all others will check individual revision access.
+   *
+   * @covers ::getRevisionDescription
+   */
+  public function testDescriptionLinkWithAccess(): void {
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
+    $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
+    // Revision has access to individual revision.
+    $entity->setName('view all revisions, view revision');
+    $entity->save();
+    $firstRevisionId = $entity->getRevisionId();
+
+    // Revision has access to canonical route.
+    $entity->setName('view all revisions, view');
+    $entity->setNewRevision();
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $row1Link = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1) a');
+    $this->assertEquals($entity->toUrl()->toString(), $row1Link->getAttribute('href'));
+    // Reload revision so object has the properties to build a revision link.
+    $firstRevision = \Drupal::entityTypeManager()->getStorage('entity_test_revlog')
+      ->loadRevision($firstRevisionId);
+    $row2Link = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2) a');
+    $this->assertEquals($firstRevision->toUrl('revision')->toString(), $row2Link->getAttribute('href'));
+  }
+
+  /**
+   * Test revision log message if supported, and HTML tags are stripped.
+   *
+   * @covers ::getRevisionDescription
+   */
+  public function testDescriptionRevisionLogMessage(): void {
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
+    $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
+    $entity->setName('view all revisions');
+    $entity->setRevisionLogMessage('<em>Hello</em> <script>world</script> <strong>123</strong>');
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    // Script tags are stripped, while admin-safe tags are retained.
+    $this->assertSession()->elementContains('css', 'table tbody tr:nth-child(1)', '<em>Hello</em> world <strong>123</strong>');
+  }
+
+  /**
+   * Test revert operation.
+   *
+   * @covers ::buildRevertRevisionLink
+   */
+  public function testOperationRevertRevision(): void {
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
+    $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
+    $entity->setName('view all revisions');
+    $entity->save();
+
+    $entity->setName('view all revisions, revert');
+    $entity->setNewRevision();
+    $entity->save();
+
+    $entity->setName('view all revisions, revert');
+    $entity->setNewRevision();
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementsCount('css', 'table tbody tr', 3);
+
+    // Latest revision does not have revert revision operation: reverting latest
+    // revision is not permitted.
+    $row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)');
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
+    $this->assertSession()->elementNotExists('named', ['link', 'Revert'], $row1);
+
+    // Revision 2 has revert revision operation: granted access.
+    $row2 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2)');
+    $this->assertSession()->elementExists('named', ['link', 'Revert'], $row2);
+
+    // Revision 3 does not have revert revision operation: no access.
+    $row3 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(3)');
+    $this->assertSession()->elementNotExists('named', ['link', 'Revert'], $row3);
+
+    // Reverting latest is allowed if entity access permits it.
+    $entity->setName('view all revisions, revert, force allow revert');
+    $entity->setNewRevision();
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementsCount('css', 'table tbody tr', 4);
+
+    $row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)');
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
+    $this->assertSession()->elementExists('named', ['link', 'Revert'], $row1);
+  }
+
+  /**
+   * Test delete operation.
+   *
+   * @covers ::buildDeleteRevisionLink
+   */
+  public function testOperationDeleteRevision(): void {
+    /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
+    $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
+    $entity->setName('view all revisions');
+    $entity->save();
+
+    $entity->setName('view all revisions, delete revision');
+    $entity->setNewRevision();
+    $entity->save();
+
+    $entity->setName('view all revisions, delete revision');
+    $entity->setNewRevision();
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementsCount('css', 'table tbody tr', 3);
+
+    // Latest revision does not have delete revision operation: deleting latest
+    // revision is not permitted.
+    $row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)');
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
+    $this->assertSession()->elementNotExists('named', ['link', 'Delete'], $row1);
+
+    // Revision 2 has delete revision operation: granted access.
+    $row2 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2)');
+    $this->assertSession()->elementExists('named', ['link', 'Delete'], $row2);
+
+    // Revision 3 does not have delete revision operation: no access.
+    $row3 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(3)');
+    $this->assertSession()->elementNotExists('named', ['link', 'Delete'], $row3);
+
+    // Deleting latest is allowed if entity access permits it.
+    $entity->setName('view all revisions, delete revision, force allow delete revision');
+    $entity->setNewRevision();
+    $entity->save();
+
+    $this->drupalGet($entity->toUrl('version-history'));
+    $this->assertSession()->elementsCount('css', 'table tbody tr', 4);
+
+    $row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)');
+    $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
+    $this->assertSession()->elementExists('named', ['link', 'Delete'], $row1);
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionViewTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionViewTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..52b34cdacb3bb3d0fff4c1ddb173105566420ed3
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionViewTest.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Drupal\FunctionalTests\Entity;
+
+use Drupal\Core\Entity\RevisionLogInterface;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests revision view page.
+ *
+ * @group Entity
+ * @coversDefaultClass \Drupal\Core\Entity\Controller\EntityRevisionViewController
+ */
+class RevisionViewTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'block',
+    'entity_test',
+    'entity_test_revlog',
+    'field',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
+  /**
+   * Tests revision page.
+   *
+   * @param string $entityTypeId
+   *   Entity type to test.
+   * @param string $expectedPageTitle
+   *   Expected page title.
+   *
+   * @covers ::__invoke
+   *
+   * @dataProvider providerRevisionPage
+   */
+  public function testRevisionPage(string $entityTypeId, string $expectedPageTitle): void {
+    $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
+
+    // Add a field to test revision page output.
+    $fieldStorage = FieldStorageConfig::create([
+      'entity_type' => $entityTypeId,
+      'field_name' => 'foo',
+      'type' => 'string',
+    ]);
+    $fieldStorage->save();
+    FieldConfig::create([
+      'field_storage' => $fieldStorage,
+      'bundle' => $entityTypeId,
+    ])->save();
+
+    /** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $displayRepository */
+    $displayRepository = \Drupal::service('entity_display.repository');
+    $displayRepository->getViewDisplay($entityTypeId, $entityTypeId)
+      ->setComponent('foo', [
+        'type' => 'string',
+      ])
+      ->save();
+
+    $entity = $storage->create(['type' => $entityTypeId]);
+    $entity->setName('revision 1, view revision');
+    $revision1Body = $this->randomMachineName();
+    $entity->foo = $revision1Body;
+    $entity->setNewRevision();
+    if ($entity instanceof RevisionLogInterface) {
+      $date = new \DateTime('11 January 2009 4:00:00pm');
+      $entity->setRevisionCreationTime($date->getTimestamp());
+    }
+    $entity->save();
+    $revisionId = $entity->getRevisionId();
+
+    $entity->setName('revision 2, view revision');
+    $revision2Body = $this->randomMachineName();
+    $entity->foo = $revision2Body;
+    if ($entity instanceof RevisionLogInterface) {
+      $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
+    }
+    $entity->setNewRevision();
+    $entity->save();
+
+    $revision = $storage->loadRevision($revisionId);
+    $this->drupalGet($revision->toUrl('revision'));
+
+    $this->assertSession()->pageTextContains($expectedPageTitle);
+    $this->assertSession()->pageTextContains($revision1Body);
+    $this->assertSession()->pageTextNotContains($revision2Body);
+  }
+
+  /**
+   * Data provider for testRevisionPage.
+   */
+  public function providerRevisionPage(): array {
+    return [
+      ['entity_test_rev', 'Revision of revision 1, view revision'],
+      ['entity_test_revlog', 'Revision of revision 1, view revision from Sun, 01/11/2009 - 16:00'],
+    ];
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
index a394660485d0285118a4fed2d3b21e5e30c38eb4..7218e99135c5a99adbe077ebfc64ae1eab4749d3 100644
--- a/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
@@ -164,10 +164,10 @@ protected function checkValidation($entity_type) {
     $this->assertEquals('This value should not be null.', $violations[0]->getMessage());
 
     $test_entity = clone $entity;
-    $test_entity->name->value = $this->randomString(33);
+    $test_entity->name->value = $this->randomString(65);
     $violations = $test_entity->validate();
     $this->assertEquals(1, $violations->count(), 'Validation failed.');
-    $this->assertEquals(t('%name: may not be longer than @max characters.', ['%name' => 'Name', '@max' => 32]), $violations[0]->getMessage());
+    $this->assertEquals(t('%name: may not be longer than @max characters.', ['%name' => 'Name', '@max' => 64]), $violations[0]->getMessage());
 
     // Make sure the information provided by a violation is correct.
     $violation = $violations[0];
diff --git a/core/tests/Drupal/KernelTests/Core/Entity/RevisionRouteProviderTest.php b/core/tests/Drupal/KernelTests/Core/Entity/RevisionRouteProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..bdde7ca46adb2072d834cbfaa46ed5a695c888ff
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Entity/RevisionRouteProviderTest.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Drupal\KernelTests\Core\Entity;
+
+use Drupal\entity_test\Entity\EntityTestRev;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * Tests revision route provider.
+ *
+ * @coversDefaultClass \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider
+ * @group Entity
+ */
+class RevisionRouteProviderTest extends KernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['entity_test', 'user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installEntitySchema('entity_test_rev');
+    $this->installEntitySchema('user');
+    $this->setUpCurrentUser(['uid' => 1]);
+  }
+
+  /**
+   * Tests revision access for revision overview.
+   *
+   * Tests routes which do not need a specific revision parameter.
+   */
+  public function testOperationAccessOverview(): void {
+    $entity = EntityTestRev::create()
+      ->setName('first revision');
+    $entity->save();
+    $this->assertFalse($entity->toUrl('version-history')->access());
+
+    $entity
+      ->setName('view all revisions')
+      ->setNewRevision();
+    $entity->save();
+    $this->assertTrue($entity->toUrl('version-history')->access());
+  }
+
+  /**
+   * Tests revision access is granted by entity operations.
+   *
+   * Ensures entity is sourced from revision parameter, not entity parameter or
+   * default revision.
+   * E.g 'entity_test_rev_revision'
+   * in '/{entity_test_rev}/revision/{entity_test_rev_revision}/view'.
+   *
+   * @param string $linkTemplate
+   *   The link template to test.
+   * @param string $entityLabel
+   *   Access is granted via specially named entity label passed to
+   *   EntityTestAccessControlHandler.
+   *
+   * @dataProvider providerOperationAccessRevisionRoutes
+   */
+  public function testOperationAccessRevisionRoutes(string $linkTemplate, string $entityLabel): void {
+    $entityStorage = \Drupal::entityTypeManager()->getStorage('entity_test_rev');
+
+    $entity = EntityTestRev::create()
+      ->setName('first revision');
+    $entity->save();
+    $noAccessRevisionId = $entity->getRevisionId();
+
+    $entity
+      ->setName($entityLabel)
+      ->setNewRevision();
+    $entity->save();
+    $hasAccessRevisionId = $entity->getRevisionId();
+
+    $this->assertNotEquals($noAccessRevisionId, $hasAccessRevisionId);
+
+    // Create an additional default revision to ensure access isn't being pulled
+    // from default revision.
+    $entity
+      ->setName('default')
+      ->setNewRevision();
+    $entity->isDefaultRevision(TRUE);
+    $entity->save();
+
+    // Reload entity so default revision flags are accurate.
+    $originalRevision = $entityStorage->loadRevision($noAccessRevisionId);
+    $viewableRevision = $entityStorage->loadRevision($hasAccessRevisionId);
+
+    $this->assertFalse($originalRevision->toUrl($linkTemplate)->access());
+    $this->assertTrue($viewableRevision->toUrl($linkTemplate)->access());
+  }
+
+  /**
+   * Data provider for testOperationAccessRevisionRoutes.
+   *
+   * @return array
+   *   Data for testing.
+   */
+  public function providerOperationAccessRevisionRoutes(): array {
+    $data = [];
+
+    $data['view revision'] = [
+      'revision',
+      'view revision',
+    ];
+
+    $data['revert revision'] = [
+      'revision-revert-form',
+      'revert',
+    ];
+
+    $data['delete revision'] = [
+      'revision-delete-form',
+      'delete revision',
+    ];
+
+    return $data;
+  }
+
+}