From a130897f02f0e768b5235be1b43f0ec6c3edf2b6 Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Thu, 19 Mar 2020 11:37:34 +0000
Subject: [PATCH] Issue #474684 by bnjmnm, dawehner, tedbow, pfrenssen,
 JohnAlbin, ademarco, kalpaitch, vdacosta@voidtek.com, rensingh99, markcarver,
 jungle, jhedstrom, RobLoach, almaudoh, kevineinarsson, shaal, dpagini,
 thedavidmeister, sreynen, Snugug, Miguel.kode, kamkejj, alexpott, Pol, sun,
 Wim Leers, lauriii, tim.plunkett, eaton: Allow themes to declare dependencies
 on modules

---
 core/core.services.yml                        |  10 +-
 .../Drupal/Core/Extension/ModuleInstaller.php |  23 +-
 ...duleRequiredByThemesUninstallValidator.php |  84 +++++
 .../Core/Extension/ThemeExtensionList.php     |  17 +
 .../Drupal/Core/Extension/ThemeInstaller.php  |  60 +++-
 .../Extension/ThemeInstallerInterface.php     |   3 +
 ...duleRequiredByThemesUninstallValidator.php |  88 +++++
 .../src/Controller/SystemController.php       |  57 +++-
 .../system/src/Controller/ThemeController.php |   4 +
 .../system/src/Form/ModulesListForm.php       |  41 +--
 .../src/ModuleDependencyMessageTrait.php      |  55 +++
 core/modules/system/system.admin.inc          |  35 +-
 core/modules/system/system.install            |   6 -
 .../templates/system-themes-page.html.twig    |   6 +
 .../Form/ModulesListFormWebTest.php           |  25 +-
 .../src/Functional/Theme/ThemeUiTest.php      | 321 ++++++++++++++++++
 .../UpdateSystem/UpdateScriptTest.php         |  39 ++-
 ...test_module_compatible_constraint.info.yml |   4 +
 ...st_module_incompatible_constraint.info.yml |   4 +
 ..._depending_on_constrained_modules.info.yml |   6 +
 ..._another_module_required_by_theme.info.yml |   4 +
 .../test_module_required_by_theme.info.yml    |   4 +
 .../test_module_required_by_theme.module      |  20 ++
 .../test_theme_depending_on_modules.info.yml  |   6 +
 ...e_depending_on_nonexisting_module.info.yml |   6 +
 ...t_theme_mixed_module_dependencies.info.yml |   5 +
 ...a_base_theme_depending_on_modules.info.yml |   3 +
 .../Core/Theme/ThemeInstallerTest.php         |  75 ++++
 ...RequiredByThemesUninstallValidatorTest.php | 161 +++++++++
 .../templates/system-themes-page.html.twig    |   6 +
 .../admin/system-themes-page.html.twig        |   6 +
 31 files changed, 1118 insertions(+), 66 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php
 create mode 100644 core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php
 create mode 100644 core/modules/system/src/ModuleDependencyMessageTrait.php
 create mode 100644 core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php
 create mode 100644 core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_compatible_constraint/test_module_compatible_constraint.info.yml
 create mode 100644 core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_incompatible_constraint/test_module_incompatible_constraint.info.yml
 create mode 100644 core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_theme_depending_on_constrained_modules.info.yml
 create mode 100644 core/modules/system/tests/themes/test_theme_depending_on_modules/test_another_module_required_by_theme/test_another_module_required_by_theme.info.yml
 create mode 100644 core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.info.yml
 create mode 100644 core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.module
 create mode 100644 core/modules/system/tests/themes/test_theme_depending_on_modules/test_theme_depending_on_modules.info.yml
 create mode 100644 core/modules/system/tests/themes/test_theme_depending_on_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml
 create mode 100644 core/modules/system/tests/themes/test_theme_mixed_module_dependencies/test_theme_mixed_module_dependencies.info.yml
 create mode 100644 core/modules/system/tests/themes/test_theme_with_a_base_theme_depending_on_modules/test_theme_with_a_base_theme_depending_on_modules.info.yml
 create mode 100644 core/tests/Drupal/Tests/Core/Extension/ModuleRequiredByThemesUninstallValidatorTest.php

diff --git a/core/core.services.yml b/core/core.services.yml
index f6bdc1d18688..4a4c0bfeee0d 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -522,7 +522,7 @@ services:
     class: Drupal\Core\Extension\ModuleInstaller
     tags:
       - { name: service_collector, tag: 'module_install.uninstall_validator', call: addUninstallValidator }
-    arguments: ['%app.root%', '@module_handler', '@kernel']
+    arguments: ['%app.root%', '@module_handler', '@kernel', '@extension.list.theme']
     lazy: true
   extension.list.module:
     class: Drupal\Core\Extension\ModuleExtensionList
@@ -548,12 +548,18 @@ services:
       - { name: module_install.uninstall_validator }
     arguments: ['@string_translation', '@extension.list.module']
     lazy: true
+  module_required_by_themes_uninstall_validator:
+    class: Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator
+    tags:
+      - { name: module_install.uninstall_validator }
+    arguments: ['@string_translation', '@extension.list.module', '@extension.list.theme']
+    lazy: true
   theme_handler:
     class: Drupal\Core\Extension\ThemeHandler
     arguments: ['%app.root%', '@config.factory', '@extension.list.theme']
   theme_installer:
     class: Drupal\Core\Extension\ThemeInstaller
-    arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state']
+    arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state', '@extension.list.module']
   entity.memory_cache:
     class: Drupal\Core\Cache\MemoryCache\MemoryCache
   entity_type.manager:
diff --git a/core/lib/Drupal/Core/Extension/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php
index 17bb70030623..287a72c7dcda 100644
--- a/core/lib/Drupal/Core/Extension/ModuleInstaller.php
+++ b/core/lib/Drupal/Core/Extension/ModuleInstaller.php
@@ -50,6 +50,13 @@ class ModuleInstaller implements ModuleInstallerInterface {
    */
   protected $uninstallValidators;
 
+  /**
+   * The theme extension list.
+   *
+   * @var \Drupal\Core\Extension\ThemeExtensionList
+   */
+  protected $themeExtensionList;
+
   /**
    * Constructs a new ModuleInstaller instance.
    *
@@ -59,14 +66,21 @@ class ModuleInstaller implements ModuleInstallerInterface {
    *   The module handler.
    * @param \Drupal\Core\DrupalKernelInterface $kernel
    *   The drupal kernel.
+   * @param \Drupal\Core\Extension\ThemeExtensionList $extension_list_theme
+   *   The theme extension list.
    *
    * @see \Drupal\Core\DrupalKernel
    * @see \Drupal\Core\CoreServiceProvider
    */
-  public function __construct($root, ModuleHandlerInterface $module_handler, DrupalKernelInterface $kernel) {
+  public function __construct($root, ModuleHandlerInterface $module_handler, DrupalKernelInterface $kernel, ThemeExtensionList $extension_list_theme = NULL) {
     $this->root = $root;
     $this->moduleHandler = $module_handler;
     $this->kernel = $kernel;
+    if (is_null($extension_list_theme)) {
+      @trigger_error('The extension.list.theme service must be passed to ' . __NAMESPACE__ . '\ModuleInstaller::__construct(). It was added in drupal:8.9.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED);
+      $extension_list_theme = \Drupal::service('extension.list.theme');
+    }
+    $this->themeExtensionList = $extension_list_theme;
   }
 
   /**
@@ -372,12 +386,14 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) {
     }
 
     if ($uninstall_dependents) {
+      $theme_list = $this->themeExtensionList->getList();
+
       // Add dependent modules to the list. The new modules will be processed as
       // the foreach loop continues.
       foreach ($module_list as $module => $value) {
         foreach (array_keys($module_data[$module]->required_by) as $dependent) {
-          if (!isset($module_data[$dependent])) {
-            // The dependent module does not exist.
+          if (!isset($module_data[$dependent]) && !isset($theme_list[$dependent])) {
+            // The dependent module or theme does not exist.
             return FALSE;
           }
 
@@ -578,6 +594,7 @@ protected function updateKernel($module_filenames) {
     // After rebuilding the container we need to update the injected
     // dependencies.
     $container = $this->kernel->getContainer();
+    $this->themeExtensionList = $container->get('extension.list.theme');
     $this->moduleHandler = $container->get('module_handler');
   }
 
diff --git a/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php b/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php
new file mode 100644
index 000000000000..86499f55cad5
--- /dev/null
+++ b/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\Core\Extension;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+
+/**
+ * Ensures modules cannot be uninstalled if enabled themes depend on them.
+ */
+class ModuleRequiredByThemesUninstallValidator implements ModuleUninstallValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The module extension list.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
+  /**
+   * The theme extension list.
+   *
+   * @var \Drupal\Core\Extension\ThemeExtensionList
+   */
+  protected $themeExtensionList;
+
+  /**
+   * Constructs a new ModuleRequiredByThemesUninstallValidator.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $extension_list_module
+   *   The module extension list.
+   * @param \Drupal\Core\Extension\ThemeExtensionList $extension_list_theme
+   *   The theme extension list.
+   */
+  public function __construct(TranslationInterface $string_translation, ModuleExtensionList $extension_list_module, ThemeExtensionList $extension_list_theme) {
+    $this->stringTranslation = $string_translation;
+    $this->moduleExtensionList = $extension_list_module;
+    $this->themeExtensionList = $extension_list_theme;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($module) {
+    $reasons = [];
+
+    $themes_depending_on_module = $this->getThemesDependingOnModule($module);
+    if (!empty($themes_depending_on_module)) {
+      $module_name = $this->moduleExtensionList->get($module)->info['name'];
+      $theme_names = implode(', ', $themes_depending_on_module);
+      $reasons[] = $this->formatPlural(count($themes_depending_on_module),
+        'Required by the theme: @theme_names',
+        'Required by the themes: @theme_names',
+        ['@module_name' => $module_name, '@theme_names' => $theme_names]);
+    }
+
+    return $reasons;
+  }
+
+  /**
+   * Returns themes that depend on a module.
+   *
+   * @param string $module
+   *   The module machine name.
+   *
+   * @return string[]
+   *   An array of the names of themes that depend on $module.
+   */
+  protected function getThemesDependingOnModule($module) {
+    $installed_themes = $this->themeExtensionList->getAllInstalledInfo();
+    $themes_depending_on_module = array_map(function ($theme) use ($module) {
+      if (in_array($module, $theme['dependencies'])) {
+        return $theme['name'];
+      }
+    }, $installed_themes);
+
+    return array_filter($themes_depending_on_module);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Extension/ThemeExtensionList.php b/core/lib/Drupal/Core/Extension/ThemeExtensionList.php
index 8da8084578f4..e062793b81bc 100644
--- a/core/lib/Drupal/Core/Extension/ThemeExtensionList.php
+++ b/core/lib/Drupal/Core/Extension/ThemeExtensionList.php
@@ -51,6 +51,7 @@ class ThemeExtensionList extends ExtensionList {
     'libraries' => [],
     'libraries_extend' => [],
     'libraries_override' => [],
+    'dependencies' => [],
   ];
 
   /**
@@ -140,6 +141,22 @@ protected function doList() {
     // sub-themes.
     $this->fillInSubThemeData($themes, $sub_themes);
 
+    foreach ($themes as $key => $theme) {
+      // After $theme is processed by buildModuleDependencies(), there can be a
+      // `$theme->requires` array containing both module and base theme
+      // dependencies. The module dependencies are copied to their own property
+      // so they are available to operations specific to module dependencies.
+      if (isset($theme->requires)) {
+        $theme->module_dependencies = array_diff_key($theme->requires, $themes);
+      }
+      else {
+        // Even if no requirements are specified, the theme installation process
+        // expects the presence of the `requires` and `module_dependencies`
+        // properties, so they should be initialized here as empty arrays.
+        $theme->requires = [];
+        $theme->module_dependencies = [];
+      }
+    }
     return $themes;
   }
 
diff --git a/core/lib/Drupal/Core/Extension/ThemeInstaller.php b/core/lib/Drupal/Core/Extension/ThemeInstaller.php
index e1149e7b668d..fdcb6ed931ce 100644
--- a/core/lib/Drupal/Core/Extension/ThemeInstaller.php
+++ b/core/lib/Drupal/Core/Extension/ThemeInstaller.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Core\Extension;
 
+use Drupal\Component\Utility\Html;
 use Drupal\Core\Asset\AssetCollectionOptimizerInterface;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Config\ConfigFactoryInterface;
@@ -10,6 +11,8 @@
 use Drupal\Core\Extension\Exception\UnknownExtensionException;
 use Drupal\Core\Routing\RouteBuilderInterface;
 use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\system\ModuleDependencyMessageTrait;
 use Psr\Log\LoggerInterface;
 
 /**
@@ -17,6 +20,9 @@
  */
 class ThemeInstaller implements ThemeInstallerInterface {
 
+  use ModuleDependencyMessageTrait;
+  use StringTranslationTrait;
+
   /**
    * @var \Drupal\Core\Extension\ThemeHandlerInterface
    */
@@ -62,6 +68,13 @@ class ThemeInstaller implements ThemeInstallerInterface {
    */
   protected $logger;
 
+  /**
+   * The module extension list.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
   /**
    * Constructs a new ThemeInstaller.
    *
@@ -86,8 +99,10 @@ class ThemeInstaller implements ThemeInstallerInterface {
    *   A logger instance.
    * @param \Drupal\Core\State\StateInterface $state
    *   The state store.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
+   *   The module extension list.
    */
-  public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, ConfigInstallerInterface $config_installer, ModuleHandlerInterface $module_handler, ConfigManagerInterface $config_manager, AssetCollectionOptimizerInterface $css_collection_optimizer, RouteBuilderInterface $route_builder, LoggerInterface $logger, StateInterface $state) {
+  public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, ConfigInstallerInterface $config_installer, ModuleHandlerInterface $module_handler, ConfigManagerInterface $config_manager, AssetCollectionOptimizerInterface $css_collection_optimizer, RouteBuilderInterface $route_builder, LoggerInterface $logger, StateInterface $state, ModuleExtensionList $module_extension_list = NULL) {
     $this->themeHandler = $theme_handler;
     $this->configFactory = $config_factory;
     $this->configInstaller = $config_installer;
@@ -97,6 +112,11 @@ public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryI
     $this->routeBuilder = $route_builder;
     $this->logger = $logger;
     $this->state = $state;
+    if ($module_extension_list === NULL) {
+      @trigger_error('The extension.list.module service must be passed to ' . __NAMESPACE__ . '\ThemeInstaller::__construct(). It was added in drupal:8.9.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED);
+      $module_extension_list = \Drupal::service('extension.list.module');
+    }
+    $this->moduleExtensionList = $module_extension_list;
   }
 
   /**
@@ -106,6 +126,8 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
     $extension_config = $this->configFactory->getEditable('core.extension');
 
     $theme_data = $this->themeHandler->rebuildThemeData();
+    $installed_themes = $extension_config->get('theme') ?: [];
+    $installed_modules = $extension_config->get('module') ?: [];
 
     if ($install_dependencies) {
       $theme_list = array_combine($theme_list, $theme_list);
@@ -116,16 +138,41 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
       }
 
       // Only process themes that are not installed currently.
-      $installed_themes = $extension_config->get('theme') ?: [];
       if (!$theme_list = array_diff_key($theme_list, $installed_themes)) {
         // Nothing to do. All themes already installed.
         return TRUE;
       }
 
+      $module_list = $this->moduleExtensionList->getList();
       foreach ($theme_list as $theme => $value) {
-        // Add dependencies to the list. The new themes will be processed as
-        // the parent foreach loop continues.
-        foreach (array_keys($theme_data[$theme]->requires) as $dependency) {
+        $module_dependencies = $theme_data[$theme]->module_dependencies;
+        // $theme_data[$theme]->requires contains both theme and module
+        // dependencies keyed by the extension machine names and
+        // $theme_data[$theme]->module_dependencies contains only modules keyed
+        // by the module extension machine name. Therefore we can find the theme
+        // dependencies by finding array keys for 'requires' that are not in
+        // $module_dependencies.
+        $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $module_dependencies);
+        // We can find the unmet module dependencies by finding the module
+        // machine names keys that are not in $installed_modules keys.
+        $unmet_module_dependencies = array_diff_key($module_dependencies, $installed_modules);
+
+        // Prevent themes with unmet module dependencies from being installed.
+        if (!empty($unmet_module_dependencies)) {
+          $unmet_module_dependencies_list = implode(', ', array_keys($unmet_module_dependencies));
+          throw new MissingDependencyException("Unable to install theme: '$theme' due to unmet module dependencies: '$unmet_module_dependencies_list'.");
+        }
+
+        foreach ($module_dependencies as $dependency => $dependency_object) {
+          if ($incompatible = $this->checkDependencyMessage($module_list, $dependency, $dependency_object)) {
+            $sanitized_message = Html::decodeEntities(strip_tags($incompatible));
+            throw new MissingDependencyException("Unable to install theme: $sanitized_message");
+          }
+        }
+
+        // Add dependencies to the list of themes to install. The new themes
+        // will be processed as the parent foreach loop continues.
+        foreach (array_keys($theme_dependencies) as $dependency) {
           if (!isset($theme_data[$dependency])) {
             // The dependency does not exist.
             return FALSE;
@@ -147,9 +194,6 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
       arsort($theme_list);
       $theme_list = array_keys($theme_list);
     }
-    else {
-      $installed_themes = $extension_config->get('theme') ?: [];
-    }
 
     $themes_installed = [];
     foreach ($theme_list as $key) {
diff --git a/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php b/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php
index ae79b505ea18..6f18248cf912 100644
--- a/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php
+++ b/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php
@@ -25,6 +25,9 @@ interface ThemeInstallerInterface {
    *
    * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException
    *   Thrown when the theme does not exist.
+   *
+   * @throws \Drupal\Core\Extension\MissingDependencyException
+   *   Thrown when a requested dependency can't be found.
    */
   public function install(array $theme_list, $install_dependencies = TRUE);
 
diff --git a/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php b/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php
new file mode 100644
index 000000000000..601f482e4d30
--- /dev/null
+++ b/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php
@@ -0,0 +1,88 @@
+<?php
+// @codingStandardsIgnoreFile
+
+/**
+ * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator' "core/lib/Drupal/Core".
+ */
+
+namespace Drupal\Core\ProxyClass\Extension {
+
+    /**
+     * Provides a proxy class for \Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator.
+     *
+     * @see \Drupal\Component\ProxyBuilder
+     */
+    class ModuleRequiredByThemesUninstallValidator implements \Drupal\Core\Extension\ModuleUninstallValidatorInterface
+    {
+
+        use \Drupal\Core\DependencyInjection\DependencySerializationTrait;
+
+        /**
+         * The id of the original proxied service.
+         *
+         * @var string
+         */
+        protected $drupalProxyOriginalServiceId;
+
+        /**
+         * The real proxied service, after it was lazy loaded.
+         *
+         * @var \Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator
+         */
+        protected $service;
+
+        /**
+         * The service container.
+         *
+         * @var \Symfony\Component\DependencyInjection\ContainerInterface
+         */
+        protected $container;
+
+        /**
+         * Constructs a ProxyClass Drupal proxy object.
+         *
+         * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+         *   The container.
+         * @param string $drupal_proxy_original_service_id
+         *   The service ID of the original service.
+         */
+        public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id)
+        {
+            $this->container = $container;
+            $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id;
+        }
+
+        /**
+         * Lazy loads the real service from the container.
+         *
+         * @return object
+         *   Returns the constructed real service.
+         */
+        protected function lazyLoadItself()
+        {
+            if (!isset($this->service)) {
+                $this->service = $this->container->get($this->drupalProxyOriginalServiceId);
+            }
+
+            return $this->service;
+        }
+
+        /**
+         * {@inheritdoc}
+         */
+        public function validate($module)
+        {
+            return $this->lazyLoadItself()->validate($module);
+        }
+
+        /**
+         * {@inheritdoc}
+         */
+        public function setStringTranslation(\Drupal\Core\StringTranslation\TranslationInterface $translation)
+        {
+            return $this->lazyLoadItself()->setStringTranslation($translation);
+        }
+
+    }
+
+}
diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php
index f27cc01eb111..5942ff3ce0fd 100644
--- a/core/modules/system/src/Controller/SystemController.php
+++ b/core/modules/system/src/Controller/SystemController.php
@@ -4,12 +4,14 @@
 
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\Core\Extension\ThemeHandlerInterface;
 use Drupal\Core\Form\FormBuilderInterface;
 use Drupal\Core\Menu\MenuLinkTreeInterface;
 use Drupal\Core\Menu\MenuTreeParameters;
 use Drupal\Core\Theme\ThemeAccessCheck;
 use Drupal\Core\Url;
+use Drupal\system\ModuleDependencyMessageTrait;
 use Drupal\system\SystemManager;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -18,6 +20,8 @@
  */
 class SystemController extends ControllerBase {
 
+  use ModuleDependencyMessageTrait;
+
   /**
    * System Manager Service.
    *
@@ -53,6 +57,13 @@ class SystemController extends ControllerBase {
    */
   protected $menuLinkTree;
 
+  /**
+   * The module extension list.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
   /**
    * Constructs a new SystemController.
    *
@@ -66,13 +77,20 @@ class SystemController extends ControllerBase {
    *   The theme handler.
    * @param \Drupal\Core\Menu\MenuLinkTreeInterface $menu_link_tree
    *   The menu link tree service.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
+   *   The module extension list.
    */
-  public function __construct(SystemManager $systemManager, ThemeAccessCheck $theme_access, FormBuilderInterface $form_builder, ThemeHandlerInterface $theme_handler, MenuLinkTreeInterface $menu_link_tree) {
+  public function __construct(SystemManager $systemManager, ThemeAccessCheck $theme_access, FormBuilderInterface $form_builder, ThemeHandlerInterface $theme_handler, MenuLinkTreeInterface $menu_link_tree, ModuleExtensionList $module_extension_list = NULL) {
     $this->systemManager = $systemManager;
     $this->themeAccess = $theme_access;
     $this->formBuilder = $form_builder;
     $this->themeHandler = $theme_handler;
     $this->menuLinkTree = $menu_link_tree;
+    if ($module_extension_list === NULL) {
+      @trigger_error('The extension.list.module service must be passed to ' . __NAMESPACE__ . '\SystemController::__construct. It was added in Drupal 8.9.0 and will be required before Drupal 10.0.0.', E_USER_DEPRECATED);
+      $module_extension_list = \Drupal::service('extension.list.module');
+    }
+    $this->moduleExtensionList = $module_extension_list;
   }
 
   /**
@@ -84,7 +102,8 @@ public static function create(ContainerInterface $container) {
       $container->get('access_check.theme'),
       $container->get('form_builder'),
       $container->get('theme_handler'),
-      $container->get('menu.link_tree')
+      $container->get('menu.link_tree'),
+      $container->get('extension.list.module')
     );
   }
 
@@ -231,9 +250,41 @@ public function themesPage() {
         $theme->incompatible_base = (isset($theme->info['base theme']) && !($theme->base_themes === array_filter($theme->base_themes)));
         // Confirm that the theme engine is available.
         $theme->incompatible_engine = isset($theme->info['engine']) && !isset($theme->owner);
+        // Confirm that module dependencies are available.
+        $theme->incompatible_module = FALSE;
+        // Confirm that the user has permission to enable modules.
+        $theme->insufficient_module_permissions = FALSE;
       }
+
+      // Check module dependencies.
+      if ($theme->module_dependencies) {
+        $modules = $this->moduleExtensionList->getList();
+        foreach ($theme->module_dependencies as $dependency => $dependency_object) {
+          if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $dependency_object)) {
+            $theme->module_dependencies_list[$dependency] = $incompatible;
+            $theme->incompatible_module = TRUE;
+            continue;
+          }
+
+          // @todo Add logic for not displaying hidden modules in
+          //   https://drupal.org/node/3117829.
+          $module_name = $modules[$dependency]->info['name'];
+          $theme->module_dependencies_list[$dependency] = $modules[$dependency]->status ? $this->t('@module_name', ['@module_name' => $module_name]) : $this->t('@module_name (<span class="admin-disabled">disabled</span>)', ['@module_name' => $module_name]);
+
+          // Create an additional property that contains only disabled module
+          // dependencies. This will determine if it is possible to install the
+          // theme, or if modules must first be enabled.
+          if (!$modules[$dependency]->status) {
+            $theme->module_dependencies_disabled[$dependency] = $module_name;
+            if (!$this->currentUser()->hasPermission('administer modules')) {
+              $theme->insufficient_module_permissions = TRUE;
+            }
+          }
+        }
+      }
+
       $theme->operations = [];
-      if (!empty($theme->status) || !$theme->info['core_incompatible'] && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine) {
+      if (!empty($theme->status) || !$theme->info['core_incompatible'] && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine && !$theme->incompatible_module && empty($theme->module_dependencies_disabled)) {
         // Create the operations links.
         $query['theme'] = $theme->getName();
         if ($this->themeAccess->checkAccess($theme->getName())) {
diff --git a/core/modules/system/src/Controller/ThemeController.php b/core/modules/system/src/Controller/ThemeController.php
index 0a8ad31cf81b..1f3e61865cb8 100644
--- a/core/modules/system/src/Controller/ThemeController.php
+++ b/core/modules/system/src/Controller/ThemeController.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Config\PreExistingConfigException;
 use Drupal\Core\Config\UnmetDependenciesException;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Extension\MissingDependencyException;
 use Drupal\Core\Extension\ThemeExtensionList;
 use Drupal\Core\Extension\ThemeHandlerInterface;
 use Drupal\Core\Extension\ThemeInstallerInterface;
@@ -161,6 +162,9 @@ public function install(Request $request) {
       catch (UnmetDependenciesException $e) {
         $this->messenger()->addError($e->getTranslatedMessage($this->getStringTranslation(), $theme));
       }
+      catch (MissingDependencyException $e) {
+        $this->messenger()->addError($this->t('Unable to install @theme due to missing module dependencies.', ['@theme' => $theme]));
+      }
 
       return $this->redirect('system.themes_page');
     }
diff --git a/core/modules/system/src/Form/ModulesListForm.php b/core/modules/system/src/Form/ModulesListForm.php
index f32dad0909c0..d0c0a6fbb2a6 100644
--- a/core/modules/system/src/Form/ModulesListForm.php
+++ b/core/modules/system/src/Form/ModulesListForm.php
@@ -17,6 +17,7 @@
 use Drupal\Core\Session\AccountInterface;
 use Drupal\user\PermissionHandlerInterface;
 use Drupal\Core\Url;
+use Drupal\system\ModuleDependencyMessageTrait;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -31,6 +32,8 @@
  */
 class ModulesListForm extends FormBase {
 
+  use ModuleDependencyMessageTrait;
+
   /**
    * The current user.
    *
@@ -326,38 +329,16 @@ protected function buildRow(array $modules, Extension $module, $distribution) {
     // If this module requires other modules, add them to the array.
     /** @var \Drupal\Core\Extension\Dependency $dependency_object */
     foreach ($module->requires as $dependency => $dependency_object) {
-      if (!isset($modules[$dependency])) {
-        $row['#requires'][$dependency] = $this->t('@module (<span class="admin-missing">missing</span>)', ['@module' => $dependency]);
+      // @todo Add logic for not displaying hidden modules in
+      //   https://drupal.org/node/3117829.
+      if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $dependency_object)) {
+        $row['#requires'][$dependency] = $incompatible;
         $row['enable']['#disabled'] = TRUE;
+        continue;
       }
-      // Only display visible modules.
-      elseif (empty($modules[$dependency]->hidden)) {
-        $name = $modules[$dependency]->info['name'];
-        // Disable the module's checkbox if it is incompatible with the
-        // dependency's version.
-        if (!$dependency_object->isCompatible(str_replace(\Drupal::CORE_COMPATIBILITY . '-', '', $modules[$dependency]->info['version']))) {
-          $row['#requires'][$dependency] = $this->t('@module (@constraint) (<span class="admin-missing">incompatible with</span> version @version)', [
-            '@module' => $name,
-            '@constraint' => $dependency_object->getConstraintString(),
-            '@version' => $modules[$dependency]->info['version'],
-          ]);
-          $row['enable']['#disabled'] = TRUE;
-        }
-        // Disable the checkbox if the dependency is incompatible with this
-        // version of Drupal core.
-        elseif ($modules[$dependency]->info['core_incompatible']) {
-          $row['#requires'][$dependency] = $this->t('@module (<span class="admin-missing">incompatible with</span> this version of Drupal core)', [
-            '@module' => $name,
-          ]);
-          $row['enable']['#disabled'] = TRUE;
-        }
-        elseif ($modules[$dependency]->status) {
-          $row['#requires'][$dependency] = $this->t('@module', ['@module' => $name]);
-        }
-        else {
-          $row['#requires'][$dependency] = $this->t('@module (<span class="admin-disabled">disabled</span>)', ['@module' => $name]);
-        }
-      }
+
+      $name = $modules[$dependency]->info['name'];
+      $row['#requires'][$dependency] = $modules[$dependency]->status ? $this->t('@module', ['@module' => $name]) : $this->t('@module (<span class="admin-disabled">disabled</span>)', ['@module' => $name]);
     }
 
     // If this module is required by other modules, list those, and then make it
diff --git a/core/modules/system/src/ModuleDependencyMessageTrait.php b/core/modules/system/src/ModuleDependencyMessageTrait.php
new file mode 100644
index 000000000000..febfbfa43fd2
--- /dev/null
+++ b/core/modules/system/src/ModuleDependencyMessageTrait.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Drupal\system;
+
+use Drupal\Core\Extension\Dependency;
+
+/**
+ * Messages for missing or incompatible dependencies on modules.
+ *
+ * @internal The trait simply helps core classes that display user messages
+ *   regarding missing or incompatible module dependencies share exact same
+ *   wording and markup.
+ */
+trait ModuleDependencyMessageTrait {
+
+  /**
+   * Provides messages for missing modules or incompatible dependencies.
+   *
+   * @param array $modules
+   *   The list of existing modules.
+   * @param string $dependency
+   *   The module dependency to check.
+   * @param \Drupal\Core\Extension\Dependency $dependency_object
+   *   Dependency object used for comparing version requirement data.
+   *
+   * @return string|null
+   *   NULL if compatible, otherwise a string describing the incompatibility.
+   */
+  public function checkDependencyMessage(array $modules, $dependency, Dependency $dependency_object) {
+    if (!isset($modules[$dependency])) {
+      return $this->t('@module_name (<span class="admin-missing">missing</span>)', ['@module_name' => $dependency]);
+    }
+    else {
+      $module_name = $modules[$dependency]->info['name'];
+
+      // Check if the module is compatible with the installed version of core.
+      if ($modules[$dependency]->info['core_incompatible']) {
+        return $this->t('@module_name (<span class="admin-missing">incompatible with</span> this version of Drupal core)', [
+          '@module_name' => $module_name,
+        ]);
+      }
+
+      // Check if the module is incompatible with the dependency constraints.
+      $version = str_replace(\Drupal::CORE_COMPATIBILITY . '-', '', $modules[$dependency]->info['version']);
+      if (!$dependency_object->isCompatible($version)) {
+        $constraint_string = $dependency_object->getConstraintString();
+        return $this->t('@module_name (<span class="admin-missing">incompatible with</span> version @version)', [
+          '@module_name' => "$module_name ($constraint_string)",
+          '@version' => $modules[$dependency]->info['version'],
+        ]);
+      }
+    }
+  }
+
+}
diff --git a/core/modules/system/system.admin.inc b/core/modules/system/system.admin.inc
index 661677b51a82..5ae24c07fbf0 100644
--- a/core/modules/system/system.admin.inc
+++ b/core/modules/system/system.admin.inc
@@ -9,6 +9,7 @@
 use Drupal\Core\Link;
 use Drupal\Core\Render\Element;
 use Drupal\Core\Template\Attribute;
+use Drupal\Core\Url;
 
 /**
  * Prepares variables for administrative content block templates.
@@ -123,7 +124,7 @@ function template_preprocess_system_admin_index(&$variables) {
  *     - version: The version of the module.
  *     - links: Administration links provided by the module.
  *     - #requires: A list of modules that the project requires.
- *     - #required_by: A list of modules that require the project.
+ *     - #required_by: A list of modules and themes that require the project.
  *     - #attributes: A list of attributes for the module wrapper.
  *
  * @see \Drupal\system\Form\ModulesListForm
@@ -131,6 +132,18 @@ function template_preprocess_system_admin_index(&$variables) {
 function template_preprocess_system_modules_details(&$variables) {
   $form = $variables['form'];
 
+  // Identify modules that are depended on by themes.
+  // Added here instead of ModuleHandler to avoid recursion.
+  $themes = \Drupal::service('extension.list.theme')->getList();
+  foreach ($themes as $theme) {
+    foreach ($theme->info['dependencies'] as $dependency) {
+      if (isset($form[$dependency])) {
+        // Add themes to the module's required by list.
+        $form[$dependency]['#required_by'][] = $theme->status ? t('@theme', ['@theme (theme)' => $theme->info['name']]) : t('@theme (theme) (<span class="admin-disabled">disabled</span>)', ['@theme' => $theme->info['name']]);
+      }
+    }
+  }
+
   $variables['modules'] = [];
   // Iterate through all the modules, which are children of this element.
   foreach (Element::children($form) as $key) {
@@ -291,6 +304,12 @@ function template_preprocess_system_themes_page(&$variables) {
       $current_theme['is_default'] = $theme->is_default;
       $current_theme['is_admin'] = $theme->is_admin;
 
+      $current_theme['module_dependencies'] = !empty($theme->module_dependencies_list) ? [
+        '#theme' => 'item_list',
+        '#items' => $theme->module_dependencies_list,
+        '#context' => ['list_style' => 'comma-list'],
+      ] : [];
+
       // Make sure to provide feedback on compatibility.
       $current_theme['incompatible'] = '';
       if (!empty($theme->info['core_incompatible'])) {
@@ -311,6 +330,20 @@ function template_preprocess_system_themes_page(&$variables) {
       elseif (!empty($theme->incompatible_engine)) {
         $current_theme['incompatible'] = t('This theme requires the theme engine @theme_engine to operate correctly.', ['@theme_engine' => $theme->info['engine']]);
       }
+      elseif (!empty($theme->incompatible_module)) {
+        $current_theme['incompatible'] = t('This theme requires the listed modules to operate correctly.');
+      }
+      elseif (!empty($theme->module_dependencies_disabled)) {
+        if (!empty($theme->insufficient_module_permissions)) {
+          $current_theme['incompatible'] = t('This theme requires the listed modules to operate correctly. They must first be enabled by a user with permissions to do so.');
+        }
+        else {
+          $modules_url = (string) Url::fromRoute('system.modules_list')->toString();
+          $current_theme['incompatible'] = t('This theme requires the listed modules to operate correctly. They must first be enabled via the <a href=":modules_url">Extend page</a>.', [
+            ':modules_url' => $modules_url,
+          ]);
+        }
+      }
 
       // Build operation links.
       $current_theme['operations'] = [
diff --git a/core/modules/system/system.install b/core/modules/system/system.install
index 0ea442bf6139..08f50c5ed7d7 100644
--- a/core/modules/system/system.install
+++ b/core/modules/system/system.install
@@ -889,12 +889,6 @@ function system_requirements($phase) {
         $php_incompatible_extensions[$file->info['type']][] = $name;
       }
 
-      // @todo Remove this 'if' block to allow checking requirements of themes
-      //   https://www.drupal.org/project/drupal/issues/474684.
-      if ($file->info['type'] !== 'module') {
-        continue;
-      }
-
       // Check the module's required modules.
       /** @var \Drupal\Core\Extension\Dependency $requirement */
       foreach ($file->requires as $requirement) {
diff --git a/core/modules/system/templates/system-themes-page.html.twig b/core/modules/system/templates/system-themes-page.html.twig
index 6e65d7641b7f..aad558707626 100644
--- a/core/modules/system/templates/system-themes-page.html.twig
+++ b/core/modules/system/templates/system-themes-page.html.twig
@@ -22,6 +22,7 @@
  *     - notes: Identifies what context this theme is being used in, e.g.,
  *       default theme, admin theme.
  *     - incompatible: Text describing any compatibility issues.
+ *     - module_dependencies: A list of modules that this theme requires.
  *     - operations: A list of operation links, e.g., Settings, Enable, Disable,
  *       etc. these links should only be displayed if the theme is compatible.
  *
@@ -62,6 +63,11 @@
               {%- endif -%}
             </h3>
             <div class="theme-info__description">{{ theme.description }}</div>
+            {% if theme.module_dependencies %}
+              <div class="theme-info__requires">
+                {{ 'Requires: @module_dependencies'|t({ '@module_dependencies': theme.module_dependencies|render }) }}
+              </div>
+            {% endif %}
             {# Display operation links if the theme is compatible. #}
             {% if theme.incompatible %}
               <div class="incompatible">{{ theme.incompatible }}</div>
diff --git a/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php b/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php
index 8262372ebddc..3657f96911e0 100644
--- a/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php
+++ b/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php
@@ -27,19 +27,14 @@ class ModulesListFormWebTest extends BrowserTestBase {
   protected function setUp() {
     parent::setUp();
     \Drupal::state()->set('system_test.module_hidden', FALSE);
+    $this->drupalLogin($this->drupalCreateUser(['administer modules', 'administer permissions']));
   }
 
   /**
    * Tests the module list form.
    */
   public function testModuleListForm() {
-    $this->drupalLogin(
-      $this->drupalCreateUser(
-        ['administer modules', 'administer permissions']
-      )
-    );
     $this->drupalGet('admin/modules');
-    $this->assertResponse('200');
 
     // Check that system_test's configure link was rendered correctly.
     $this->assertFieldByXPath("//a[contains(@href, '/system-test/configure/bar') and text()='Configure ']/span[contains(@class, 'visually-hidden') and text()='the System test module']");
@@ -92,11 +87,6 @@ public function testModulesListFormWithInvalidInfoFile() {
         'expected_error' => "'core: 9.x' is not supported. Use 'core_version_requirement' to specify core compatibility. Only 'core: 8.x' is supported to provide backwards compatibility for Drupal 8 when needed in $file_path",
       ],
     ];
-    $this->drupalLogin(
-      $this->drupalCreateUser(
-        ['administer modules', 'administer permissions']
-      )
-    );
 
     foreach ($broken_infos as $broken_info) {
       file_put_contents($file_path, $broken_info['yml']);
@@ -120,4 +110,17 @@ public function testModulesListFormWithInvalidInfoFile() {
     }
   }
 
+  /**
+   * Confirm that module 'Required By' descriptions include dependent themes.
+   */
+  public function testRequiredByThemeMessage() {
+    $this->drupalGet('admin/modules');
+    $module_theme_depends_on_description = $this->getSession()->getPage()->findAll('css', '#edit-modules-test-module-required-by-theme-enable-description .admin-requirements li:contains("Test Theme Depending on Modules (theme) (disabled)")');
+    // Confirm that that 'Test Theme Depending on Modules' is listed as being
+    // required by the module 'Test Module Required by Theme'.
+    $this->assertCount(1, $module_theme_depends_on_description);
+    // Confirm that the required by message does not appear anywhere else.
+    $this->assertSession()->pageTextContains('Test Theme Depending on Modules (Theme) (Disabled)');
+  }
+
 }
diff --git a/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php b/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php
new file mode 100644
index 000000000000..e2a98252768e
--- /dev/null
+++ b/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php
@@ -0,0 +1,321 @@
+<?php
+
+namespace Drupal\Tests\system\Functional\Theme;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests the theme UI.
+ *
+ * @group Theme
+ */
+class ThemeUiTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * Modules used for testing.
+   *
+   * @var array
+   */
+  protected $testModules = [
+    'help' => 'Help',
+    'test_module_required_by_theme' => 'Test Module Required by Theme',
+    'test_another_module_required_by_theme' => 'Test Another Module Required by Theme',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalLogin($this->drupalCreateUser([
+      'administer themes',
+      'administer modules',
+    ]));
+  }
+
+  /**
+   * Tests permissions for enabling themes depending on disabled modules.
+   */
+  public function testModulePermissions() {
+    // Log in as a user without permission to enable modules.
+    $this->drupalLogin($this->drupalCreateUser([
+      'administer themes',
+    ]));
+    $this->drupalGet('admin/appearance');
+
+    // The links to install a theme that would enable modules should be replaced
+    // by this message.
+    $this->assertSession()->pageTextContains('This theme requires the listed modules to operate correctly. They must first be enabled by a user with permissions to do so.');
+
+    // The install page should not be reachable.
+    $this->drupalGet('admin/appearance/install?theme=test_theme_depending_on_modules');
+    $this->assertSession()->statusCodeEquals(404);
+
+    $this->drupalLogin($this->drupalCreateUser([
+      'administer themes',
+      'administer modules',
+    ]));
+    $this->drupalGet('admin/appearance');
+    $this->assertSession()->pageTextNotContains('This theme requires the listed modules to operate correctly. They must first be enabled by a user with permissions to do so.');
+  }
+
+  /**
+   * Tests installing a theme with module dependencies.
+   *
+   * @param string $theme_name
+   *   The name of the theme being tested.
+   * @param string[] $first_modules
+   *   Machine names of first modules to enable.
+   * @param string[] $second_modules
+   *   Machine names of second modules to enable.
+   * @param string[] $required_by_messages
+   *   Expected messages when attempting to uninstall $module_names.
+   * @param string $base_theme_to_uninstall
+   *   The name of the theme $theme_name has set as a base theme.
+   * @param string[] $base_theme_module_names
+   *   Machine names of the modules required by $base_theme_to_uninstall.
+   *
+   * @dataProvider providerTestThemeInstallWithModuleDependencies
+   */
+  public function testThemeInstallWithModuleDependencies($theme_name, array $first_modules, array $second_modules, array $required_by_messages, $base_theme_to_uninstall, array $base_theme_module_names) {
+    $assert_session = $this->assertSession();
+    $page = $this->getSession()->getPage();
+    $all_dependent_modules = array_merge($first_modules, $second_modules);
+    $this->drupalGet('admin/appearance');
+    $assert_module_enabled_message = function ($enabled_modules) {
+      $count = count($enabled_modules);
+      $module_enabled_text = $count === 1 ? "{$this->testModules[$enabled_modules[0]]} has been enabled." : $count . " modules have been enabled:";
+      $this->assertSession()->pageTextContains($module_enabled_text);
+    };
+    // All the modules should be listed as disabled.
+    foreach ($all_dependent_modules as $module) {
+      $expected_required_list_items[$module] = $this->testModules[$module] . " (disabled)";
+    }
+    $this->assertUninstallableTheme($expected_required_list_items, $theme_name);
+
+    // Enable the first group of dependee modules.
+    $first_module_form_post = [];
+    foreach ($first_modules as $module) {
+      $first_module_form_post["modules[$module][enable]"] = 1;
+    }
+    $this->drupalPostForm('admin/modules', $first_module_form_post, 'Install');
+    $assert_module_enabled_message($first_modules);
+
+    $this->drupalGet('admin/appearance');
+
+    // Confirm the theme is still uninstallable due to a remaining module
+    // dependency.
+    // The modules that have already been enabled will no longer be listed as
+    // disabled.
+    foreach ($first_modules as $module) {
+      $expected_required_list_items[$module] = $this->testModules[$module];
+    }
+    $this->assertUninstallableTheme($expected_required_list_items, $theme_name);
+
+    // Enable the second group of dependee modules.
+    $second_module_form_post = [];
+    foreach ($second_modules as $module) {
+      $second_module_form_post["modules[$module][enable]"] = 1;
+    }
+    $this->drupalPostForm('admin/modules', $second_module_form_post, 'Install');
+    $assert_module_enabled_message($second_modules);
+
+    // The theme should now be installable, so install it.
+    $this->drupalGet('admin/appearance');
+    $page->clickLink("Install $theme_name theme");
+    $assert_session->addressEquals('admin/appearance');
+    $assert_session->pageTextContains("The $theme_name theme has been installed");
+
+    // Confirm that the dependee modules can't be uninstalled because an enabled
+    // theme depends on them.
+    $this->drupalGet('admin/modules/uninstall');
+    foreach ($all_dependent_modules as $attribute) {
+      $assert_session->elementExists('css', "[name=\"uninstall[$attribute]\"][disabled]");
+    }
+    foreach ($required_by_messages as $selector => $message) {
+      $assert_session->elementTextContains('css', $selector, $message);
+    }
+
+    // Uninstall the theme that depends on the modules, and confirm the modules
+    // can now be uninstalled.
+    $this->uninstallTheme($theme_name);
+    $this->drupalGet('admin/modules/uninstall');
+
+    // Only attempt to uninstall modules not required by the base theme.
+    $modules_to_uninstall = array_diff($all_dependent_modules, $base_theme_module_names);
+    $this->uninstallModules($modules_to_uninstall);
+
+    if (!empty($base_theme_to_uninstall)) {
+      $this->uninstallTheme($base_theme_to_uninstall);
+      $this->drupalGet('admin/modules/uninstall');
+      $this->uninstallModules($base_theme_module_names);
+    }
+  }
+
+  /**
+   * Uninstalls modules via the admin UI.
+   *
+   * @param string[] $module_names
+   *   An array of module machine names.
+   */
+  protected function uninstallModules(array $module_names) {
+    $assert_session = $this->assertSession();
+    $this->drupalGet('admin/modules/uninstall');
+    foreach ($module_names as $attribute) {
+      $assert_session->elementExists('css', "[name=\"uninstall[$attribute]\"]:not([disabled])");
+    }
+    $to_uninstall = [];
+    foreach ($module_names as $attribute) {
+      $to_uninstall["uninstall[$attribute]"] = 1;
+    }
+    if (!empty($to_uninstall)) {
+      $this->drupalPostForm('admin/modules/uninstall', $to_uninstall, 'Uninstall');
+      $assert_session->pageTextContains('The following modules will be completely uninstalled from your site, and all data from these modules will be lost!');
+      $assert_session->pageTextContains('Would you like to continue with uninstalling the above?');
+      foreach ($module_names as $module_name) {
+        $assert_session->pageTextContains($this->testModules[$module_name]);
+      }
+      $this->getSession()->getPage()->pressButton('Uninstall');
+      $assert_session->pageTextContains('The selected modules have been uninstalled.');
+    }
+  }
+
+  /**
+   * Uninstalls a theme via the admin UI.
+   *
+   * @param string $theme_name
+   *   The theme name.
+   */
+  protected function uninstallTheme($theme_name) {
+    $this->drupalGet('admin/appearance');
+    $this->clickLink("Uninstall $theme_name theme");
+    $this->assertSession()->pageTextContains("The $theme_name theme has been uninstalled.");
+  }
+
+  /**
+   * Data provider for testThemeInstallWithModuleDependencies().
+   *
+   * @return array
+   *   An array of arrays. Details on the specific elements can be found in the
+   *   function body.
+   */
+  public function providerTestThemeInstallWithModuleDependencies() {
+    // Data provider values with the following keys:
+    // -'theme_name': The name of the theme being tested.
+    // -'first_modules': Array of module machine names to enable first.
+    // -'second_modules': Array of module machine names to enable second.
+    // -'required_by_messages': Array for checking the messages explaining why a
+    // module can't be uninstalled. The array key is the selector where the
+    // message should appear, the array value is the expected message.
+    // -'base_theme_to_uninstall': The name of a base theme that needs to be
+    // uninstalled before modules it depends on can be uninstalled.
+    // -'base_theme_module_names': Array of machine names of the modules
+    // required by base_theme_to_uninstall.
+    return [
+      'test theme with a module dependency and base theme with a different module dependency' => [
+        'theme_name' => 'Test Theme with a Module Dependency and Base Theme with a Different Module Dependency',
+        'first_modules' => [
+          'test_module_required_by_theme',
+          'test_another_module_required_by_theme',
+        ],
+        'second_modules' => [
+          'help',
+        ],
+        'required_by_messages' => [
+          '[data-drupal-selector="edit-test-another-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+          '[data-drupal-selector="edit-test-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+          '[data-drupal-selector="edit-help"] .item-list' => 'Required by the theme: Test Theme with a Module Dependency and Base Theme with a Different Module Dependency',
+        ],
+        'base_theme_to_uninstall' => 'Test Theme Depending on Modules',
+        'base_theme_module_names' => [
+          'test_module_required_by_theme',
+          'test_another_module_required_by_theme',
+        ],
+      ],
+      'Test Theme Depending on Modules' => [
+        'theme_name' => 'Test Theme Depending on Modules',
+        'first_modules' => [
+          'test_module_required_by_theme',
+        ],
+        'second_modules' => [
+          'test_another_module_required_by_theme',
+        ],
+        'required_by_messages' => [
+          '[data-drupal-selector="edit-test-another-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+          '[data-drupal-selector="edit-test-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+        ],
+        'base_theme_to_uninstall' => '',
+        'base_theme_module_names' => [],
+      ],
+      'test theme with a base theme depending on modules' => [
+        'theme_name' => 'Test Theme with a Base Theme Depending on Modules',
+        'first_modules' => [
+          'test_module_required_by_theme',
+        ],
+        'second_modules' => [
+          'test_another_module_required_by_theme',
+        ],
+        'required_by_messages' => [
+          '[data-drupal-selector="edit-test-another-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+          '[data-drupal-selector="edit-test-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+        ],
+        'base_theme_to_uninstall' => 'Test Theme Depending on Modules',
+        'base_theme_module_names' => [
+          'test_module_required_by_theme',
+          'test_another_module_required_by_theme',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Checks related to uninstallable themes due to module dependencies.
+   *
+   * @param string[] $expected_requires_list_items
+   *   The modules listed as being required to install the theme.
+   * @param string $theme_name
+   *   The name of the theme.
+   */
+  protected function assertUninstallableTheme(array $expected_requires_list_items, $theme_name) {
+    $theme_container = $this->getSession()->getPage()->find('css', "h3:contains(\"$theme_name\")")->getParent();
+    $requires_list_items = $theme_container->findAll('css', '.theme-info__requires li');
+    $this->assertCount(count($expected_requires_list_items), $requires_list_items);
+
+    foreach ($requires_list_items as $key => $item) {
+      $this->assertTrue(in_array($item->getText(), $expected_requires_list_items));
+    }
+
+    $incompatible = $theme_container->find('css', '.incompatible');
+    $expected_incompatible_text = 'This theme requires the listed modules to operate correctly. They must first be enabled via the Extend page.';
+    $this->assertSame($expected_incompatible_text, $incompatible->getText());
+    $this->assertFalse($theme_container->hasLink('Install Test Theme Depending on Modules theme'));
+  }
+
+  /**
+   * Tests installing a theme with missing module dependencies.
+   */
+  public function testInstallModuleWithMissingDependencies() {
+    $this->drupalGet('admin/appearance');
+    $theme_container = $this->getSession()->getPage()->find('css', 'h3:contains("Test Theme Depending on Nonexisting Module")')->getParent();
+    $this->assertContains('Requires: test_module_non_existing (missing)', $theme_container->getText());
+    $this->assertContains('This theme requires the listed modules to operate correctly.', $theme_container->getText());
+  }
+
+  /**
+   * Tests installing a theme with incompatible module dependencies.
+   */
+  public function testInstallModuleWithIncompatibleDependencies() {
+    $this->container->get('module_installer')->install(['test_module_compatible_constraint', 'test_module_incompatible_constraint']);
+    $this->drupalGet('admin/appearance');
+    $theme_container = $this->getSession()->getPage()->find('css', 'h3:contains("Test Theme Depending on Version Constrained Modules")')->getParent();
+    $this->assertContains('Requires: Test Module Theme Depends on with Compatible ConstraintTest Module Theme Depends on with Incompatible Constraint (>=8.x-2.x) (incompatible with version 8.x-1.8)', $theme_container->getText());
+    $this->assertContains('This theme requires the listed modules to operate correctly.', $theme_container->getText());
+  }
+
+}
diff --git a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php
index d5c3b966ec0f..31dc9bc907a9 100644
--- a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php
+++ b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php
@@ -24,7 +24,13 @@ class UpdateScriptTest extends BrowserTestBase {
    *
    * @var array
    */
-  public static $modules = ['update_script_test', 'dblog', 'language'];
+  protected static $modules = [
+    'update_script_test',
+    'dblog',
+    'language',
+    'test_module_required_by_theme',
+    'test_another_module_required_by_theme',
+  ];
 
   /**
    * {@inheritdoc}
@@ -61,7 +67,11 @@ protected function setUp() {
     parent::setUp();
     $this->updateUrl = Url::fromRoute('system.db_update');
     $this->statusReportUrl = Url::fromRoute('system.status');
-    $this->updateUser = $this->drupalCreateUser(['administer software updates', 'access site in maintenance mode']);
+    $this->updateUser = $this->drupalCreateUser([
+      'administer software updates',
+      'access site in maintenance mode',
+      'administer themes',
+    ]);
   }
 
   /**
@@ -175,6 +185,31 @@ public function testRequirements() {
     $this->drupalGet($this->updateUrl, ['external' => TRUE]);
     $this->assertSession()->assertEscaped('Node (Version <7.x-0.0-dev required)');
     $this->assertSession()->responseContains('Update script test requires this module and version. Currently using Node version ' . \Drupal::VERSION);
+
+    // Test that issues with modules that themes depend on are properly
+    // displayed.
+    $this->assertSession()->responseNotContains('Test Module Required by Theme');
+    $this->drupalGet('admin/appearance');
+    $this->getSession()->getPage()->clickLink('Install Test Theme Depending on Modules theme');
+    $this->assertSession()->addressEquals('admin/appearance');
+    $this->assertSession()->pageTextContains('The Test Theme Depending on Modules theme has been installed');
+
+    // Ensure that when a theme depends on a module and that module's
+    // requirements change, errors are displayed in the same manner as modules
+    // depending on other modules.
+    \Drupal::state()->set('test_theme_depending_on_modules.system_info_alter', ['dependencies' => ['test_module_required_by_theme (<7.x-0.0-dev)']]);
+    $this->drupalGet($this->updateUrl, ['external' => TRUE]);
+    $this->assertSession()->assertEscaped('Test Module Required by Theme (Version <7.x-0.0-dev required)');
+    $this->assertSession()->responseContains('Test Theme Depending on Modules requires this module and version. Currently using Test Module Required by Theme version ' . \Drupal::VERSION);
+
+    // Ensure that when a theme is updated to depend on an unavailable module,
+    // errors are displayed in the same manner as modules depending on other
+    // modules.
+    \Drupal::state()->set('test_theme_depending_on_modules.system_info_alter', ['dependencies' => ['a_module_theme_needs_that_does_not_exist']]);
+    $this->drupalGet($this->updateUrl, ['external' => TRUE]);
+    $this->assertSession()->responseContains('a_module_theme_needs_that_does_not_exist (Missing)');
+    $this->assertSession()->responseContains('Test Theme Depending on Modules requires this module.');
+
   }
 
   /**
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_compatible_constraint/test_module_compatible_constraint.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_compatible_constraint/test_module_compatible_constraint.info.yml
new file mode 100644
index 000000000000..ed5cba029db4
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_compatible_constraint/test_module_compatible_constraint.info.yml
@@ -0,0 +1,4 @@
+name: Test Module Theme Depends on with Compatible Constraint
+type: module
+package: Testing
+version: '8.x-1.2'
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_incompatible_constraint/test_module_incompatible_constraint.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_incompatible_constraint/test_module_incompatible_constraint.info.yml
new file mode 100644
index 000000000000..11b0b62523f0
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_incompatible_constraint/test_module_incompatible_constraint.info.yml
@@ -0,0 +1,4 @@
+name: Test Module Theme Depends on with Incompatible Constraint
+type: module
+package: Testing
+version: '8.x-1.8'
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_theme_depending_on_constrained_modules.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_theme_depending_on_constrained_modules.info.yml
new file mode 100644
index 000000000000..a0f34359bff2
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_theme_depending_on_constrained_modules.info.yml
@@ -0,0 +1,6 @@
+name: Test Theme Depending on Version Constrained Modules
+type: theme
+base theme: stark
+dependencies:
+  - test_module_compatible_constraint (>=8.x-1.x)
+  - test_module_incompatible_constraint (>=8.x-2.x)
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_modules/test_another_module_required_by_theme/test_another_module_required_by_theme.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_another_module_required_by_theme/test_another_module_required_by_theme.info.yml
new file mode 100644
index 000000000000..29e2202c288d
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_another_module_required_by_theme/test_another_module_required_by_theme.info.yml
@@ -0,0 +1,4 @@
+name: Test Another Module Required by Theme
+type: module
+package: Testing
+version: VERSION
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.info.yml
new file mode 100644
index 000000000000..81a1c2e880ee
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.info.yml
@@ -0,0 +1,4 @@
+name: Test Module Required by Theme
+type: module
+package: Testing
+version: VERSION
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.module b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.module
new file mode 100644
index 000000000000..191a0cf297fe
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.module
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * @file
+ * This file provides testing functionality for update.php.
+ */
+
+use Drupal\Core\Extension\Extension;
+
+/**
+ * Implements hook_system_info_alter().
+ */
+function test_module_required_by_theme_system_info_alter(array &$info, Extension $file, $type) {
+  if ($file->getName() == 'test_theme_depending_on_modules') {
+    $new_info = \Drupal::state()->get('test_theme_depending_on_modules.system_info_alter');
+    if ($new_info) {
+      $info = $new_info + $info;
+    }
+  }
+}
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_modules/test_theme_depending_on_modules.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_theme_depending_on_modules.info.yml
new file mode 100644
index 000000000000..48a6af4a4924
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_theme_depending_on_modules.info.yml
@@ -0,0 +1,6 @@
+name: Test Theme Depending on Modules
+type: theme
+base theme: stark
+dependencies:
+  - test_module_required_by_theme
+  - test_another_module_required_by_theme
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml
new file mode 100644
index 000000000000..da54e8aaca37
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml
@@ -0,0 +1,6 @@
+name: Test Theme Depending on Nonexisting Module
+type: theme
+base theme: stark
+version: VERSION
+dependencies:
+  - test_module_non_existing
diff --git a/core/modules/system/tests/themes/test_theme_mixed_module_dependencies/test_theme_mixed_module_dependencies.info.yml b/core/modules/system/tests/themes/test_theme_mixed_module_dependencies/test_theme_mixed_module_dependencies.info.yml
new file mode 100644
index 000000000000..5146e0d483de
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_mixed_module_dependencies/test_theme_mixed_module_dependencies.info.yml
@@ -0,0 +1,5 @@
+name: Test Theme with a Module Dependency and Base Theme with a Different Module Dependency
+type: theme
+base theme: test_theme_depending_on_modules
+dependencies:
+  - help
diff --git a/core/modules/system/tests/themes/test_theme_with_a_base_theme_depending_on_modules/test_theme_with_a_base_theme_depending_on_modules.info.yml b/core/modules/system/tests/themes/test_theme_with_a_base_theme_depending_on_modules/test_theme_with_a_base_theme_depending_on_modules.info.yml
new file mode 100644
index 000000000000..cde5b21ccaf3
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_with_a_base_theme_depending_on_modules/test_theme_with_a_base_theme_depending_on_modules.info.yml
@@ -0,0 +1,3 @@
+name: Test Theme with a Base Theme Depending on Modules
+type: theme
+base theme: test_theme_depending_on_modules
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php b/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php
index c8dd0337a80c..13c7aa282656 100644
--- a/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php
@@ -4,6 +4,8 @@
 
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\Extension\ExtensionNameLengthException;
+use Drupal\Core\Extension\MissingDependencyException;
+use Drupal\Core\Extension\ModuleUninstallValidatorException;
 use Drupal\Core\Extension\Exception\UnknownExtensionException;
 use Drupal\KernelTests\KernelTestBase;
 
@@ -137,6 +139,79 @@ public function testInstallNameTooLong() {
     }
   }
 
+  /**
+   * Tests installing a theme with unmet module dependencies.
+   *
+   * @dataProvider providerTestInstallThemeWithUnmetModuleDependencies
+   */
+  public function testInstallThemeWithUnmetModuleDependencies($theme_name, $installed_modules, $message) {
+    $this->container->get('module_installer')->install($installed_modules);
+    $themes = $this->themeHandler()->listInfo();
+    $this->assertEmpty($themes);
+    $this->expectException(MissingDependencyException::class);
+    $this->expectExceptionMessage($message);
+    $this->themeInstaller()->install([$theme_name]);
+  }
+
+  /**
+   * Data provider for testInstallThemeWithUnmetModuleDependencies().
+   */
+  public function providerTestInstallThemeWithUnmetModuleDependencies() {
+    return [
+      'theme with uninstalled module dependencies' => [
+        'test_theme_depending_on_modules',
+        [],
+        "Unable to install theme: 'test_theme_depending_on_modules' due to unmet module dependencies: 'test_module_required_by_theme, test_another_module_required_by_theme'.",
+      ],
+      'theme with a base theme with uninstalled module dependencies' => [
+        'test_theme_with_a_base_theme_depending_on_modules',
+        [],
+        "Unable to install theme: 'test_theme_with_a_base_theme_depending_on_modules' due to unmet module dependencies: 'test_module_required_by_theme, test_another_module_required_by_theme'.",
+      ],
+      'theme and base theme have uninstalled module dependencies' => [
+        'test_theme_mixed_module_dependencies',
+        [],
+        "Unable to install theme: 'test_theme_mixed_module_dependencies' due to unmet module dependencies: 'help, test_module_required_by_theme, test_another_module_required_by_theme'.",
+      ],
+      'theme with already installed module dependencies, base theme module dependencies are not installed' => [
+        'test_theme_mixed_module_dependencies',
+        ['help'],
+        "Unable to install theme: 'test_theme_mixed_module_dependencies' due to unmet module dependencies: 'test_module_required_by_theme, test_another_module_required_by_theme'.",
+      ],
+      'theme with module dependencies not installed, base theme module dependencies are already installed, ' => [
+        'test_theme_mixed_module_dependencies',
+        ['test_module_required_by_theme', 'test_another_module_required_by_theme'],
+        "Unable to install theme: 'test_theme_mixed_module_dependencies' due to unmet module dependencies: 'help'.",
+      ],
+      'theme depending on a module that does not exist' => [
+        'test_theme_depending_on_nonexisting_module',
+        [],
+        "Unable to install theme: 'test_theme_depending_on_nonexisting_module' due to unmet module dependencies: 'test_module_non_existing",
+      ],
+      'theme depending on an installed but incompatible module' => [
+        'test_theme_depending_on_constrained_modules',
+        ['test_module_compatible_constraint', 'test_module_incompatible_constraint'],
+        "Unable to install theme: Test Module Theme Depends on with Incompatible Constraint (>=8.x-2.x) (incompatible with version 8.x-1.8)",
+      ],
+    ];
+  }
+
+  /**
+   * Tests installing a theme with module dependencies that are met.
+   */
+  public function testInstallThemeWithMetModuleDependencies() {
+    $name = 'test_theme_depending_on_modules';
+    $themes = $this->themeHandler()->listInfo();
+    $this->assertArrayNotHasKey($name, $themes);
+    $this->container->get('module_installer')->install(['test_module_required_by_theme', 'test_another_module_required_by_theme']);
+    $this->themeInstaller()->install([$name]);
+    $themes = $this->themeHandler()->listInfo();
+    $this->assertArrayHasKey($name, $themes);
+    $this->expectException(ModuleUninstallValidatorException::class);
+    $this->expectExceptionMessage('The following reasons prevent the modules from being uninstalled: Required by the theme: Test Theme Depending on Modules');
+    $this->container->get('module_installer')->uninstall(['test_module_required_by_theme']);
+  }
+
   /**
    * Tests uninstalling the default theme.
    */
diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleRequiredByThemesUninstallValidatorTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleRequiredByThemesUninstallValidatorTest.php
new file mode 100644
index 000000000000..25735204f5ab
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleRequiredByThemesUninstallValidatorTest.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Drupal\Tests\Core\Extension;
+
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Tests\AssertHelperTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator
+ * @group Extension
+ */
+class ModuleRequiredByThemesUninstallValidatorTest extends UnitTestCase {
+
+  use AssertHelperTrait;
+
+  /**
+   * Instance of ModuleRequiredByThemesUninstallValidator.
+   *
+   * @var \Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator
+   */
+  protected $moduleRequiredByThemeUninstallValidator;
+
+  /**
+   * Mock of ModuleExtensionList.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
+  /**
+   * Mock of ThemeExtensionList.
+   *
+   * @var \Drupal\Core\Extension\ThemeExtensionList
+   */
+  protected $themeExtensionList;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->moduleExtensionList = $this->prophesize(ModuleExtensionList::class);
+    $this->themeExtensionList = $this->prophesize(ThemeExtensionList::class);
+    $this->moduleRequiredByThemeUninstallValidator = new ModuleRequiredByThemesUninstallValidator($this->getStringTranslationStub(), $this->moduleExtensionList->reveal(), $this->themeExtensionList->reveal());
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNoThemeDependency() {
+    $this->themeExtensionList->getAllInstalledInfo()->willReturn([
+      'stable' => [
+        'name' => 'Stable',
+        'dependencies' => [],
+      ],
+      'claro' => [
+        'name' => 'Claro',
+        'dependencies' => [],
+      ],
+    ]);
+
+    $module = $this->randomMachineName();
+    $expected = [];
+    $reasons = $this->moduleRequiredByThemeUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateOneThemeDependency() {
+    $module = 'single_module';
+    $module_name = 'Single Module';
+    $theme = 'one_theme';
+    $theme_name = 'One Theme';
+    $this->themeExtensionList->getAllInstalledInfo()->willReturn([
+      'stable' => [
+        'name' => 'Stable',
+        'dependencies' => [],
+      ],
+      'claro' => [
+        'name' => 'Claro',
+        'dependencies' => [],
+      ],
+      $theme => [
+        'name' => $theme_name,
+        'dependencies' => [
+          $module,
+        ],
+      ],
+    ]);
+
+    $this->moduleExtensionList->get($module)->willReturn((object) [
+      'info' => [
+        'name' => $module_name,
+      ],
+    ]);
+
+    $expected = [
+      "Required by the theme: $theme_name",
+    ];
+
+    $reasons = $this->moduleRequiredByThemeUninstallValidator->validate($module);
+    $this->assertSame($expected, $this->castSafeStrings($reasons));
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateTwoThemeDependencies() {
+    $module = 'popular_module';
+    $module_name = 'Popular Module';
+    $theme1 = 'first_theme';
+    $theme2 = 'second_theme';
+    $theme_name_1 = 'First Theme';
+    $theme_name_2 = 'Second Theme';
+    $this->themeExtensionList->getAllInstalledInfo()->willReturn([
+      'stable' => [
+        'name' => 'Stable',
+        'dependencies' => [],
+      ],
+      'claro' => [
+        'name' => 'Claro',
+        'dependencies' => [],
+      ],
+      $theme1 => [
+        'name' => $theme_name_1,
+        'dependencies' => [
+          $module,
+        ],
+      ],
+      $theme2 => [
+        'name' => $theme_name_2,
+        'dependencies' => [
+          $module,
+        ],
+      ],
+    ]);
+
+    $this->moduleExtensionList->get($module)->willReturn((object) [
+      'info' => [
+        'name' => $module_name,
+      ],
+    ]);
+
+    $expected = [
+      "Required by the themes: $theme_name_1, $theme_name_2",
+    ];
+
+    $reasons = $this->moduleRequiredByThemeUninstallValidator->validate($module);
+    $this->assertSame($expected, $this->castSafeStrings($reasons));
+  }
+
+}
+
+if (!defined('DRUPAL_MINIMUM_PHP')) {
+  define('DRUPAL_MINIMUM_PHP', '7.3.0');
+}
diff --git a/core/themes/claro/templates/system-themes-page.html.twig b/core/themes/claro/templates/system-themes-page.html.twig
index 1108c7f2f6f3..36d958dd3655 100644
--- a/core/themes/claro/templates/system-themes-page.html.twig
+++ b/core/themes/claro/templates/system-themes-page.html.twig
@@ -22,6 +22,7 @@
  *     - notes: Identifies what context this theme is being used in, e.g.,
  *       default theme, admin theme.
  *     - incompatible: Text describing any compatibility issues.
+ *     - module_dependencies: A list of modules that this theme requires.
  *     - operations: A list of operation links, e.g., Settings, Enable, Disable,
  *       etc. these links should only be displayed if the theme is compatible.
  *     - title_id: The unique id of the theme label.
@@ -97,6 +98,11 @@
               </div>
 
               <div class="card__footer">
+                {% if theme.module_dependencies %}
+                  <div class="theme-info__requires">
+                    {{ 'Requires: @module_dependencies'|t({ '@module_dependencies': theme.module_dependencies|render }) }}
+                  </div>
+                {% endif %}
                 {# Display operation links only if the theme is compatible. #}
                 {% if theme.incompatible %}
                   <small class="incompatible">{{ theme.incompatible }}</small>
diff --git a/core/themes/stable/templates/admin/system-themes-page.html.twig b/core/themes/stable/templates/admin/system-themes-page.html.twig
index 5a23f1a14c54..5d157a1caebb 100644
--- a/core/themes/stable/templates/admin/system-themes-page.html.twig
+++ b/core/themes/stable/templates/admin/system-themes-page.html.twig
@@ -22,6 +22,7 @@
  *     - notes: Identifies what context this theme is being used in, e.g.,
  *       default theme, admin theme.
  *     - incompatible: Text describing any compatibility issues.
+ *     - module_dependencies: A list of modules that this theme requires.
  *     - operations: A list of operation links, e.g., Settings, Enable, Disable,
  *       etc. these links should only be displayed if the theme is compatible.
  *
@@ -60,6 +61,11 @@
               {%- endif -%}
             </h3>
             <div class="theme-info__description">{{ theme.description }}</div>
+            {% if theme.module_dependencies %}
+              <div class="theme-info__requires">
+                {{ 'Requires: @module_dependencies'|t({ '@module_dependencies': theme.module_dependencies|render }) }}
+              </div>
+            {% endif %}
             {# Display operation links if the theme is compatible. #}
             {% if theme.incompatible %}
               <div class="incompatible">{{ theme.incompatible }}</div>
-- 
GitLab