diff --git a/core/lib/Drupal/Core/Render/theme.api.php b/core/lib/Drupal/Core/Render/theme.api.php
index 44fcdd0..5d20ba9 100644
--- a/core/lib/Drupal/Core/Render/theme.api.php
+++ b/core/lib/Drupal/Core/Render/theme.api.php
@@ -1092,87 +1092,16 @@ function hook_page_bottom(array &$page_bottom) {
  *   The directory path of the theme or module, so that it doesn't need to be
  *   looked up.
  *
- * @return array
+ * @return \Drupal\Core\Theme\ThemeHook[]
  *   An associative array of information about theme implementations. The keys
  *   on the outer array are known as "theme hooks". For theme suggestions,
  *   instead of the array key being the base theme hook, the key is a theme
  *   suggestion name with the format 'base_hook_name__sub_hook_name'.
  *   For render elements, the key is the machine name of the render element.
- *   The array values are themselves arrays containing information about the
- *   theme hook and its implementation. Each information array must contain
+ *   The array values are value objects containing information about the
+ *   theme hook and its implementation. Each theme hook object must specify
  *   either a 'variables' element (for using a #theme element) or a
  *   'render element' element (for render elements), but not both.
- *   The following elements may be part of each information array:
- *   - variables: Only used for #theme in render array: an array of variables,
- *     where the array keys are the names of the variables, and the array
- *     values are the default values if they are not given in the render array.
- *     Template implementations receive each array key as a variable in the
- *     template file (so they must be legal PHP/Twig variable names). Function
- *     implementations are passed the variables in a single $variables function
- *     argument. If you are using these variables in a render array, prefix the
- *     variable names defined here with a #.
- *   - render element: Used for render element items only: the name of the
- *     renderable element or element tree to pass to the theme function. This
- *     name is used as the name of the variable that holds the renderable
- *     element or tree in preprocess and process functions.
- *   - file: The file the implementation resides in. This file will be included
- *     prior to the theme being rendered, to make sure that the function or
- *     preprocess function (as needed) is actually loaded.
- *   - path: Override the path of the file to be used. Ordinarily the module or
- *     theme path will be used, but if the file will not be in the default
- *     path, include it here. This path should be relative to the Drupal root
- *     directory.
- *   - template: If specified, the theme implementation is a template file, and
- *     this is the template name. Do not add 'html.twig' on the end of the
- *     template name. The extension will be added automatically by the default
- *     rendering engine (which is Twig.) If 'path' is specified, 'template'
- *     should also be specified. If neither 'template' nor 'function' are
- *     specified, a default template name will be assumed. For example, if a
- *     module registers the 'search_result' theme hook, 'search-result' will be
- *     assigned as its template name.
- *   - function: (deprecated in Drupal 8.0.x, will be removed in Drupal 9.0.x)
- *     If specified, this will be the function name to invoke for this
- *     implementation. If neither 'template' nor 'function' are specified, a
- *     default template name will be assumed. See above for more details.
- *   - base hook: Used for theme suggestions only: the base theme hook name.
- *     Instead of this suggestion's implementation being used directly, the base
- *     hook will be invoked with this implementation as its first suggestion.
- *     The base hook's files will be included and the base hook's preprocess
- *     functions will be called in addition to any suggestion's preprocess
- *     functions. If an implementation of hook_theme_suggestions_HOOK() (where
- *     HOOK is the base hook) changes the suggestion order, a different
- *     suggestion may be used in place of this suggestion. If after
- *     hook_theme_suggestions_HOOK() this suggestion remains the first
- *     suggestion, then this suggestion's function or template will be used to
- *     generate the rendered output.
- *   - pattern: A regular expression pattern to be used to allow this theme
- *     implementation to have a dynamic name. The convention is to use __ to
- *     differentiate the dynamic portion of the theme. For example, to allow
- *     forums to be themed individually, the pattern might be: 'forum__'. Then,
- *     when the forum is rendered, following render array can be used:
- *     @code
- *     $render_array = array(
- *       '#theme' => array('forum__' . $tid, 'forum'),
- *       '#forum' => $forum,
- *     );
- *     @endcode
- *   - preprocess functions: A list of functions used to preprocess this data.
- *     Ordinarily this won't be used; it's automatically filled in. By default,
- *     for a module this will be filled in as template_preprocess_HOOK. For
- *     a theme this will be filled in as twig_preprocess and
- *     twig_preprocess_HOOK as well as themename_preprocess and
- *     themename_preprocess_HOOK.
- *   - override preprocess functions: Set to TRUE when a theme does NOT want
- *     the standard preprocess functions to run. This can be used to give a
- *     theme FULL control over how variables are set. For example, if a theme
- *     wants total control over how certain variables in the page.html.twig are
- *     set, this can be set to true. Please keep in mind that when this is used
- *     by a theme, that theme becomes responsible for making sure necessary
- *     variables are set.
- *   - type: (automatically derived) Where the theme hook is defined:
- *     'module', 'theme_engine', or 'theme'.
- *   - theme path: (automatically derived) The directory path of the theme or
- *     module, so that it doesn't need to be looked up.
  *
  * @see themeable
  * @see hook_theme_registry_alter()
diff --git a/core/lib/Drupal/Core/Theme/Registry.php b/core/lib/Drupal/Core/Theme/Registry.php
index a0af702..226f1bd 100644
--- a/core/lib/Drupal/Core/Theme/Registry.php
+++ b/core/lib/Drupal/Core/Theme/Registry.php
@@ -215,7 +215,7 @@ protected function init($theme_name = NULL) {
   /**
    * Returns the complete theme registry from cache or rebuilds it.
    *
-   * @return array
+   * @return \Drupal\Core\Theme\ThemeHook[]
    *   The complete theme registry data array.
    *
    * @see Registry::$registry
@@ -343,6 +343,7 @@ protected function build() {
     // and preprocess functions comes first.
     foreach (array_reverse($this->theme->getBaseThemes()) as $base) {
       // If the base theme uses a theme engine, process its hooks.
+      /** @var \Drupal\Core\Theme\ActiveTheme $base */
       $base_path = $base->getPath();
       if ($this->theme->getEngine()) {
         $this->processExtension($cache, $this->theme->getEngine(), 'base_theme_engine', $base->getName(), $base_path);
@@ -366,12 +367,6 @@ protected function build() {
     $this->themeManager->alterForTheme($this->theme, 'theme_registry', $cache);
 
     // @todo Implement more reduction of the theme registry entry.
-    // Optimize the registry to not have empty arrays for functions.
-    foreach ($cache as $hook => $info) {
-      if (empty($info['preprocess functions'])) {
-        unset($cache[$hook]['preprocess functions']);
-      }
-    }
     $this->registry[$this->theme->getName()] = $cache;
 
     return $this->registry[$this->theme->getName()];
@@ -380,28 +375,10 @@ protected function build() {
   /**
    * Process a single implementation of hook_theme().
    *
-   * @param array $cache
+   * @param \Drupal\Core\Theme\ThemeHook[] $cache
    *   The theme registry that will eventually be cached; It is an associative
-   *   array keyed by theme hooks, whose values are associative arrays
-   *   describing the hook:
-   *   - 'type': The passed-in $type.
-   *   - 'theme path': The passed-in $path.
-   *   - 'function': The name of the function generating output for this theme
-   *     hook. Either defined explicitly in hook_theme() or, if neither
-   *     'function' nor 'template' is defined, then the default theme function
-   *     name is used. The default theme function name is the theme hook
-   *     prefixed by either 'theme_' for modules or '$name_' for everything
-   *     else. If 'function' is defined, 'template' is not used.
-   *   - 'template': The filename of the template generating output for this
-   *     theme hook. The template is in the directory defined by the 'path' key
-   *     of hook_theme() or defaults to "$path/templates".
-   *   - 'variables': The variables for this theme hook as defined in
-   *     hook_theme(). If there is more than one implementation and 'variables'
-   *     is not specified in a later one, then the previous definition is kept.
-   *   - 'render element': The renderable element for this theme hook as defined
-   *     in hook_theme(). If there is more than one implementation and
-   *     'render element' is not specified in a later one, then the previous
-   *     definition is kept.
+   *   array keyed by theme hooks, whose values are \Drupal\Core\Theme\ThemeHook
+   *   value objects.
    *   - See the @link themeable Theme system overview topic @endlink for
    *     detailed documentation.
    * @param string $name
@@ -430,13 +407,6 @@ protected function build() {
   protected function processExtension(array &$cache, $name, $type, $theme, $path) {
     $result = [];
 
-    $hook_defaults = [
-      'variables' => TRUE,
-      'render element' => TRUE,
-      'pattern' => TRUE,
-      'base hook' => TRUE,
-    ];
-
     $module_list = array_keys($this->moduleHandler->getModuleList());
 
     // Invoke the hook_theme() implementation, preprocess what is returned, and
@@ -445,53 +415,63 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
     if (function_exists($function)) {
       $result = $function($cache, $type, $theme, $path);
       foreach ($result as $hook => $info) {
+        // If there was no string $hook provided retrieve it from the ThemeHook.
+        if ($info instanceof ThemeHook && is_int($hook)) {
+          $hook = $info->getHook();
+        }
+
+        // @deprecated
+        if (is_array($info)) {
+          $info = ThemeHook::createFromLegacy($hook, $info);
+        }
+
         // When a theme or engine overrides a module's theme function
         // $result[$hook] will only contain key/value pairs for information being
         // overridden.  Pull the rest of the information from what was defined by
         // an earlier hook.
+        $cached_info = isset($cache[$hook]) ? $cache[$hook] : NULL;
 
         // Fill in the type and path of the module, theme, or engine that
         // implements this theme function.
-        $result[$hook]['type'] = $type;
-        $result[$hook]['theme path'] = $path;
+        $info->setType($type);
+        $info->setThemePath($path);
 
         // If a theme hook has a base hook, mark its preprocess functions always
         // incomplete in order to inherit the base hook's preprocess functions.
-        if (!empty($result[$hook]['base hook'])) {
-          $result[$hook]['incomplete preprocess functions'] = TRUE;
+        if ($info->getBaseHook()) {
+          $info->markIncomplete();
         }
 
-        if (isset($cache[$hook]['includes'])) {
-          $result[$hook]['includes'] = $cache[$hook]['includes'];
-        }
-
-        // Load the includes, as they may contain preprocess functions.
-        if (isset($info['includes'])) {
-          foreach ($info['includes'] as $include_file) {
-            include_once $this->root . '/' . $include_file;
-          }
+        if ($cached_info && $cached_info->getIncludes()) {
+          $info->setIncludes($cached_info->getIncludes());
         }
 
         // If the theme implementation defines a file, then also use the path
         // that it defined. Otherwise use the default path. This allows
         // system.module to declare theme functions on behalf of core .include
         // files.
-        if (isset($info['file'])) {
-          $include_file = isset($info['path']) ? $info['path'] : $path;
-          $include_file .= '/' . $info['file'];
-          include_once $this->root . '/' . $include_file;
-          $result[$hook]['includes'][] = $include_file;
+        if ($info->getFile()) {
+          $include_file = $info->getPath() ?: $info->getThemePath();
+          $include_file .= '/' . $info->getFile();
+          $info->addInclude($include_file);
+        }
+
+        // Load the includes, as they may contain preprocess functions.
+        if ($info->hasIncludes()) {
+          foreach ($info->getIncludes() as $include_file) {
+            include_once $this->root . '/' . $include_file;
+          }
         }
 
         // A template file is the default implementation for a theme hook, but
         // if the theme hook specifies a function callback instead, check to
         // ensure the function actually exists.
-        if (isset($info['function'])) {
-          if (!function_exists($info['function'])) {
+        if ($function = $info->getFunction()) {
+          if (!function_exists($function)) {
             throw new \BadFunctionCallException(sprintf(
               'Theme hook "%s" refers to a theme function callback that does not exist: "%s"',
               $hook,
-              $info['function']
+              $function
             ));
           }
         }
@@ -499,29 +479,27 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
         // hook used. If the template does not exist, the theme engine used
         // should throw an exception at runtime when attempting to include
         // the template file.
-        elseif (!isset($info['template'])) {
-          $info['template'] = strtr($hook, '_', '-');
-          $result[$hook]['template'] = $info['template'];
+        elseif (!$info->getTemplate()) {
+          $info->setTemplate(strtr($hook, '_', '-'));
         }
 
         // Prepend the current theming path when none is set. This is required
         // for the default theme engine to know where the template lives.
-        if (isset($result[$hook]['template']) && !isset($info['path'])) {
-          $result[$hook]['path'] = $path . '/templates';
+        if ($info->getTemplate() && !$info->getPath()) {
+          $info->setPath($info->getThemePath() . '/templates');
         }
 
         // If the default keys are not set, use the default values registered
         // by the module.
-        if (isset($cache[$hook])) {
-          $result[$hook] += array_intersect_key($cache[$hook], $hook_defaults);
+        if ($cached_info) {
+          $info->mergeDefaults($cached_info);
         }
 
         // Preprocess variables for all theming hooks, whether the hook is
         // implemented as a template or as a function. Ensure they are arrays.
-        if (!isset($info['preprocess functions']) || !is_array($info['preprocess functions'])) {
-          $info['preprocess functions'] = [];
+        if (!$info->getPreprocessFunctions()) {
           $prefixes = [];
-          if ($type == 'module') {
+          if ($info->getType() == 'module') {
             // Default variable preprocessor prefix.
             $prefixes[] = 'template';
             // Add all modules so they can intervene with their own variable
@@ -529,7 +507,7 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
             // even if they are not the owner of the current hook.
             $prefixes = array_merge($prefixes, $module_list);
           }
-          elseif ($type == 'theme_engine' || $type == 'base_theme_engine') {
+          elseif ($info->getType() == 'theme_engine' || $info->getType() == 'base_theme_engine') {
             // Theme engines get an extra set that come before the normally
             // named variable preprocessors.
             $prefixes[] = $name . '_engine';
@@ -546,29 +524,23 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
             // Only use non-hook-specific variable preprocessors for theming
             // hooks implemented as templates. See the @defgroup themeable
             // topic.
-            if (isset($info['template']) && function_exists($prefix . '_preprocess')) {
-              $info['preprocess functions'][] = $prefix . '_preprocess';
+            if ($info->getTemplate() && function_exists($prefix . '_preprocess')) {
+              $info->addPreprocessFunction($prefix . '_preprocess');
             }
             if (function_exists($prefix . '_preprocess_' . $hook)) {
-              $info['preprocess functions'][] = $prefix . '_preprocess_' . $hook;
+              $info->addPreprocessFunction($prefix . '_preprocess_' . $hook);
             }
           }
         }
-        // Check for the override flag and prevent the cached variable
-        // preprocessors from being used. This allows themes or theme engines
-        // to remove variable preprocessors set earlier in the registry build.
-        if (!empty($info['override preprocess functions'])) {
-          // Flag not needed inside the registry.
-          unset($result[$hook]['override preprocess functions']);
-        }
-        elseif (isset($cache[$hook]['preprocess functions']) && is_array($cache[$hook]['preprocess functions'])) {
-          $info['preprocess functions'] = array_merge($cache[$hook]['preprocess functions'], $info['preprocess functions']);
-        }
-        $result[$hook]['preprocess functions'] = $info['preprocess functions'];
-      }
 
-      // Merge the newly created theme hooks into the existing cache.
-      $cache = $result + $cache;
+        // Merge the newly created theme hooks into the existing cache.
+        if ($cached_info) {
+          $cache[$hook] = $info->merge($cache[$hook]);
+        }
+        else {
+          $cache[$hook] = $info;
+        }
+      }
     }
 
     // Let themes have variable preprocessors even if they didn't register a
@@ -577,17 +549,14 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
       foreach ($cache as $hook => $info) {
         // Check only if not registered by the theme or engine.
         if (empty($result[$hook])) {
-          if (!isset($info['preprocess functions'])) {
-            $cache[$hook]['preprocess functions'] = [];
-          }
           // Only use non-hook-specific variable preprocessors for theme hooks
           // implemented as templates. See the @defgroup themeable topic.
-          if (isset($info['template']) && function_exists($name . '_preprocess')) {
-            $cache[$hook]['preprocess functions'][] = $name . '_preprocess';
+          if ($info->getTemplate() && function_exists($name . '_preprocess')) {
+            $info->addPreprocessFunction($name . '_preprocess');
           }
           if (function_exists($name . '_preprocess_' . $hook)) {
-            $cache[$hook]['preprocess functions'][] = $name . '_preprocess_' . $hook;
-            $cache[$hook]['theme path'] = $path;
+            $info->addPreprocessFunction($name . '_preprocess_' . $hook);
+            $info->setThemePath($path);
           }
         }
       }
@@ -599,34 +568,37 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path)
    *
    * @param string $hook
    *   The name of the suggestion hook to complete.
-   * @param array $cache
+   * @param \Drupal\Core\Theme\ThemeHook[] $cache
    *   The theme registry, as documented in
    *   \Drupal\Core\Theme\Registry::processExtension().
    */
   protected function completeSuggestion($hook, array &$cache) {
     $previous_hook = $hook;
-    $incomplete_previous_hook = [];
+    $incomplete_previous_hook = NULL;
     // Continue looping if the candidate hook doesn't exist or if the candidate
     // hook has incomplete preprocess functions, and if the candidate hook is a
     // suggestion (has a double underscore).
-    while ((!isset($cache[$previous_hook]) || isset($cache[$previous_hook]['incomplete preprocess functions']))
+    while ((!isset($cache[$previous_hook]) || !$cache[$previous_hook]->isComplete())
       && $pos = strrpos($previous_hook, '__')) {
       // Find the first existing candidate hook that has incomplete preprocess
       // functions.
-      if (isset($cache[$previous_hook]) && !$incomplete_previous_hook && isset($cache[$previous_hook]['incomplete preprocess functions'])) {
-        $incomplete_previous_hook = $cache[$previous_hook];
-        unset($incomplete_previous_hook['incomplete preprocess functions']);
+      if (isset($cache[$previous_hook]) && !$incomplete_previous_hook && !$cache[$previous_hook]->isComplete()) {
+        $incomplete_previous_hook = clone $cache[$previous_hook];
+        $incomplete_previous_hook->markComplete();
       }
       $previous_hook = substr($previous_hook, 0, $pos);
-      $this->mergePreprocessFunctions($hook, $previous_hook, $incomplete_previous_hook, $cache);
+      $this->mergePreprocessFunctions($hook, $previous_hook, $cache, $incomplete_previous_hook);
     }
 
-    // In addition to processing suggestions, include base hooks.
-    if (isset($cache[$hook]['base hook'])) {
-      // In order to retain the additions from above, pass in the current hook
-      // as the parent hook, otherwise it will be overwritten.
-      $this->mergePreprocessFunctions($hook, $cache[$hook]['base hook'], $cache[$hook], $cache);
+    // If a theme hook specifies a base hook, and that base hook is its own
+    // theme hook and has a complete list of preprocess functions, merge it into
+    // the current hook.
+    $base_hook = $cache[$hook]->getBaseHook();
+    if ($base_hook && isset($cache[$base_hook]) && $cache[$base_hook]->isComplete()) {
+      $cache[$hook] = $cache[$hook]->merge($cache[$base_hook]);
     }
+
+    $cache[$hook]->markComplete();
   }
 
   /**
@@ -636,26 +608,32 @@ protected function completeSuggestion($hook, array &$cache) {
    *   The name of the hook to merge preprocess functions to.
    * @param string $source_hook_name
    *   The name of the hook to merge preprocess functions from.
-   * @param array $parent_hook
+   * @param \Drupal\Core\Theme\ThemeHook[] $cache
+   *   The theme registry, as documented in
+   *   \Drupal\Core\Theme\Registry::processExtension().
+   * @param \Drupal\Core\Theme\ThemeHook $parent_hook
    *   The parent hook if it exists. Either an incomplete hook from suggestions
    *   or a base hook.
-   * @param array $cache
-   *   The theme registry, as documented in
-   *   \Drupal\Core\Theme\Registry::processExtension().
    */
-  protected function mergePreprocessFunctions($destination_hook_name, $source_hook_name, $parent_hook, array &$cache) {
-    // If base hook exists clone of it for the preprocess function
-    // without a template.
-    // @see https://www.drupal.org/node/2457295
-    if (isset($cache[$source_hook_name]) && (!isset($cache[$source_hook_name]['incomplete preprocess functions']) || !isset($cache[$destination_hook_name]['incomplete preprocess functions']))) {
-      $cache[$destination_hook_name] = $parent_hook + $cache[$source_hook_name];
-      if (isset($parent_hook['preprocess functions'])) {
-        $diff = array_diff($parent_hook['preprocess functions'], $cache[$source_hook_name]['preprocess functions']);
-        $cache[$destination_hook_name]['preprocess functions'] = array_merge($cache[$source_hook_name]['preprocess functions'], $diff);
+  protected function mergePreprocessFunctions($destination_hook_name, $source_hook_name, array &$cache, ThemeHook $parent_hook = NULL) {
+    // If the source hook doesn't exist, do not continue.
+    if (!isset($cache[$source_hook_name])) {
+      return;
+    }
+
+    // If either of the source or destination hook have complete preprocess
+    // functions, or the destination hook does not exist yet, continue.
+    if ($cache[$source_hook_name]->isComplete() || (!isset($cache[$destination_hook_name]) || $cache[$destination_hook_name]->isComplete())) {
+      $to_be_merged = clone $cache[$source_hook_name];
+      // If a parent hook was provided, use it as the basis for a merged result.
+      if ($parent_hook) {
+        $to_be_merged = $parent_hook->merge($to_be_merged);
       }
+      $cache[$destination_hook_name] = ThemeHook::createFromExisting($destination_hook_name, $to_be_merged);
+
       // If a base hook isn't set, this is the actual base hook.
-      if (!isset($cache[$source_hook_name]['base hook'])) {
-        $cache[$destination_hook_name]['base hook'] = $source_hook_name;
+      if (!$cache[$destination_hook_name]->getBaseHook()) {
+        $cache[$destination_hook_name]->setBaseHook($source_hook_name);
       }
     }
   }
@@ -663,7 +641,7 @@ protected function mergePreprocessFunctions($destination_hook_name, $source_hook
   /**
    * Completes the theme registry adding discovered functions and hooks.
    *
-   * @param array $cache
+   * @param \Drupal\Core\Theme\ThemeHook[] $cache
    *   The theme registry as documented in
    *   \Drupal\Core\Theme\Registry::processExtension().
    * @param \Drupal\Core\Theme\ActiveTheme $theme
@@ -676,6 +654,7 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) {
     // expected naming conventions.
     $prefixes = array_keys((array) $this->moduleHandler->getModuleList());
     foreach (array_reverse($theme->getBaseThemes()) as $base) {
+      /** @var \Drupal\Core\Theme\ActiveTheme $base */
       $prefixes[] = $base->getName();
     }
     if ($theme->getEngine()) {
@@ -718,16 +697,16 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) {
     ksort($suggestion_level);
     foreach ($suggestion_level as $level => $item) {
       foreach ($item as $preprocessor => $hook) {
-        if (isset($cache[$hook]['preprocess functions']) && !in_array($hook, $cache[$hook]['preprocess functions'])) {
+        if (isset($cache[$hook]) && !$cache[$hook]->hasPreprocessFunction($hook)) {
           // Add missing preprocessor to existing hook.
-          $cache[$hook]['preprocess functions'][] = $preprocessor;
+          $cache[$hook]->addPreprocessFunction($preprocessor);
         }
         elseif (!isset($cache[$hook]) && strpos($hook, '__')) {
           // Process non-existing hook and register it.
           // Look for a previously defined hook that is either a less specific
           // suggestion hook or the base hook.
           $this->completeSuggestion($hook, $cache);
-          $cache[$hook]['preprocess functions'][] = $preprocessor;
+          $cache[$hook]->addPreprocessFunction($preprocessor);
         }
       }
     }
@@ -738,18 +717,8 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) {
       // The 'base hook' is only applied to derivative hooks already registered
       // from a pattern. This is typically set from
       // drupal_find_theme_functions() and drupal_find_theme_templates().
-      if (isset($info['incomplete preprocess functions'])) {
+      if (!$info->isComplete()) {
         $this->completeSuggestion($hook, $cache);
-        unset($cache[$hook]['incomplete preprocess functions']);
-      }
-
-      // Optimize the registry.
-      if (isset($cache[$hook]['preprocess functions']) && empty($cache[$hook]['preprocess functions'])) {
-        unset($cache[$hook]['preprocess functions']);
-      }
-      // Ensure uniqueness.
-      if (isset($cache[$hook]['preprocess functions'])) {
-        $cache[$hook]['preprocess functions'] = array_unique($cache[$hook]['preprocess functions']);
       }
     }
   }
diff --git a/core/lib/Drupal/Core/Theme/ThemeHook.php b/core/lib/Drupal/Core/Theme/ThemeHook.php
new file mode 100644
index 0000000..0007aae
--- /dev/null
+++ b/core/lib/Drupal/Core/Theme/ThemeHook.php
@@ -0,0 +1,677 @@
+<?php
+
+namespace Drupal\Core\Theme;
+
+/**
+ * @todo.
+ */
+class ThemeHook implements \ArrayAccess {
+
+  /**
+   * Where the theme hook is defined: 'module', 'theme_engine', or 'theme'.
+   *
+   * This is automatically derived and does not need to be specified.
+   *
+   * @var string
+   */
+  protected $type;
+
+  /**
+   * The directory path of the theme or module, so that it doesn't need to be looked up.
+   *
+   * This is automatically derived and does not need to be specified.
+   *
+   * @var string
+   */
+  protected $theme_path;
+
+  /**
+   * Only used for #theme in render array: an array of variables, where the
+   * array keys are the names of the variables, and the array values are the
+   * default values if they are not given in the render array. Template
+   * implementations receive each array key as a variable in the template file
+   * (so they must be legal PHP/Twig variable names). Function implementations
+   * are passed the variables in a single $variables function argument. If you
+   * are using these variables in a render array, prefix the variable names
+   * defined here with a #.
+   *
+   * @var mixed[]|null
+   */
+  protected $variables;
+
+  /**
+   * Used for render element items only: the name of the renderable element or
+   * element tree to pass to the theme function. This name is used as the name
+   * of the variable that holds the renderable element or tree in preprocess and
+   * process functions.
+   *
+   * @var string
+   */
+  protected $render_element;
+
+  /**
+   * A regular expression pattern to be used to allow this theme
+   * implementation to have a dynamic name. The convention is to use __ to
+   * differentiate the dynamic portion of the theme. For example, to allow
+   * forums to be themed individually, the pattern might be: 'forum__'. Then,
+   * when the forum is rendered, following render array can be used:
+   *   @code
+   *   $render_array = array(
+   *     '#theme' => array('forum__' . $tid, 'forum'),
+   *     '#forum' => $forum,
+   *   );
+   *   @endcode
+   *
+   * @var string
+   */
+  protected $pattern;
+
+  /**
+   * Used for theme suggestions only: the base theme hook name.
+   *
+   * Instead of this suggestion's implementation being used directly, the base
+   * hook will be invoked with this implementation as its first suggestion. The
+   * base hook's files will be included and the base hook's preprocess functions
+   * will be called in addition to any suggestion's preprocess functions. If an
+   * implementation of hook_theme_suggestions_HOOK() (where HOOK is the base
+   * hook) changes the suggestion order, a different suggestion may be used in
+   * place of this suggestion. If after hook_theme_suggestions_HOOK() this
+   * suggestion remains the first suggestion, then this suggestion's function or
+   * template will be used to generate the rendered output.
+   *
+   * @var string
+   */
+  protected $base_hook;
+
+  /**
+   * @var string[]
+   */
+  protected $includes = [];
+
+  /**
+   * The file the implementation resides in. This file will be included prior to
+   * the theme being rendered, to make sure that the function or preprocess
+   * function (as needed) is actually loaded.
+   *
+   * @var string
+   */
+  protected $file;
+
+  /**
+   * If specified, this will be the function name to invoke for this
+   * implementation. If neither 'template' nor 'function' are specified, a
+   * default template name will be assumed. See above for more details.
+   *
+   * @deprecated in Drupal 8.0.x, will be removed in Drupal 9.0.x.
+   *
+   * @var string
+   */
+  protected $function;
+
+  /**
+   * If specified, the theme implementation is a template file, and this is the
+   * template name. Do not add 'html.twig' on the end of the template name. The
+   * extension will be added automatically by the default rendering engine
+   * (which is Twig.) If 'path' is specified, 'template' should also be
+   * specified. If neither 'template' nor 'function' are specified, a default
+   * template name will be assumed. For example, if a module registers the
+   * 'search_result' theme hook, 'search-result' will be assigned as its
+   * template name.
+   *
+   * @var string
+   */
+  protected $template;
+
+  /**
+   * Override the path of the file to be used. Ordinarily the module or theme
+   * path will be used, but if the file will not be in the default path, include
+   * it here. This path should be relative to the Drupal root directory.
+   *
+   * @var string
+   */
+  protected $path;
+
+  /**
+   * A list of functions used to preprocess this data.
+   *
+   * Ordinarily this won't be used; it's automatically filled in. By default,
+   * for a module this will be filled in as template_preprocess_HOOK. For a
+   * theme this will be filled in as twig_preprocess and twig_preprocess_HOOK as
+   * well as themename_preprocess and themename_preprocess_HOOK.
+   *
+   * @var string[]
+   */
+  protected $preprocess_functions = [];
+
+  /**
+   * @var bool
+   */
+  protected $incomplete_preprocess_functions = FALSE;
+
+  /**
+   * Set to TRUE when a theme does NOT want the standard preprocess functions to run.
+   *
+   * This can be used to give a theme FULL control over how variables are set.
+   * For example, if a theme wants total control over how certain variables in
+   * the page.html.twig are set, this can be set to true. Please keep in mind
+   * that when this is used by a theme, that theme becomes responsible for
+   * making sure necessary variables are set.
+   *
+   * @var bool
+   */
+  protected $override_preprocess_functions = FALSE;
+
+  /**
+   * Stores extra data.
+   *
+   * @var mixed[]
+   */
+  protected $storage = [];
+
+  /**
+   * @var string
+   */
+  protected $hook;
+
+  /**
+   * Constructs a new ThemeHook.
+   *
+   * @param string $hook
+   */
+  protected function __construct($hook) {
+    $this->hook = $hook;
+  }
+
+  /**
+   * Creates a new ThemeHook instance.
+   *
+   * @param string $hook
+   *
+   * @return static
+   */
+  public static function create($hook) {
+    return new static($hook);
+  }
+
+  /**
+   * Creates a new ThemeHook instance based on an existing ThemeHook.
+   *
+   * @param string $hook
+   * @param \Drupal\Core\Theme\ThemeHook $other
+   *
+   * @return static
+   */
+  public static function createFromExisting($hook, ThemeHook $other) {
+    $instance = static::create($hook);
+    return $instance->merge($other);
+  }
+
+  /**
+   * @deprecated
+   */
+  public static function createFromLegacy($hook, array $values) {
+    $instance = static::create($hook);
+    foreach ($values as $name => $value) {
+      $instance->offsetSet($name, $value);
+    }
+    return $instance;
+  }
+
+  /**
+   * @param \Drupal\Core\Theme\ThemeHook $other
+   *
+   * @return static
+   */
+  public function merge(ThemeHook $other) {
+    $result = clone $this;
+    // If this object doesn't have a value, use the value from the other object.
+    if (!$result->getType()) {
+      $result->setType($other->getType());
+    }
+    if (!$result->getFile()) {
+      $result->setFile($other->getFile());
+    }
+    if (!$result->getThemePath()) {
+      $result->setThemePath($other->getThemePath());
+    }
+    if (!$result->getPath()) {
+      $result->setPath($other->getPath());
+    }
+    if (!$result->getOverriddenPreprocessFunctionStatus()) {
+      $result->setOverriddenPreprocessFunctionStatus($other->getOverriddenPreprocessFunctionStatus());
+    }
+
+    // @todo.
+    if (!$result->getFunction() && !$result->getTemplate()) {
+      $result->setTemplate($other->getTemplate());
+      $result->setFunction($other->getFunction());
+    }
+
+    // @todo.
+    if (!$other->isComplete()) {
+      $result->markIncomplete();
+    }
+
+    // @todo.
+    $result->setIncludes(array_merge($result->getIncludes(), $other->getIncludes()));
+
+    // @todo.
+    $result->mergeDefaults($other);
+
+    // Check for the override flag and prevent the cached variable preprocessors
+    // from being used. This allows themes or theme engines to remove variable
+    // preprocessors set earlier in the registry build.
+    if (!$result->getOverriddenPreprocessFunctionStatus()) {
+      $result->mergePreprocessFunctions($other);
+    }
+
+    return $result;
+  }
+
+  /**
+   * @param \Drupal\Core\Theme\ThemeHook $other
+   *
+   * @return static
+   */
+  public function mergeDefaults(ThemeHook $other) {
+    if ($other->getVariables() && !$this->getVariables()) {
+      $this->setVariables($other->getVariables());
+    }
+    if (!$this->getPattern()) {
+      $this->setPattern($other->getPattern());
+    }
+    if (!$this->getBaseHook()) {
+      $this->setBaseHook($other->getBaseHook());
+    }
+    if ($other->getRenderElement() && !$this->getRenderElement()) {
+      $this->setRenderElement($other->getRenderElement());
+    }
+  }
+  public function mergePreprocessFunctions(ThemeHook $other) {
+    $this->setPreprocessFunctions(array_merge($other->getPreprocessFunctions(), $this->getPreprocessFunctions()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function &offsetGet($name) {
+    $value = NULL;
+    $name = str_replace(' ', '_', $name);
+    if (property_exists($this, $name)) {
+      $value = &$this->{$name};
+    }
+    else {
+      if (isset($this->storage[$name])) {
+        $value = &$this->storage[$name];
+      }
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetSet($name, $value) {
+    $name = str_replace(' ', '_', $name);
+    if (property_exists($this, $name)) {
+      $this->{$name} = $value;
+    }
+    else {
+      $this->storage[$name] = $value;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetUnset($name) {
+    $name = str_replace(' ', '_', $name);
+    if (property_exists($this, $name)) {
+      $reflection = new \ReflectionClass($this);
+      $this->{$name} = $reflection->getDefaultProperties()[$name];
+    }
+    else {
+      unset($this->storage[$name]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetExists($name) {
+    $name = str_replace(' ', '_', $name);
+    if (property_exists($this, $name)) {
+      return isset($this->{$name});
+    }
+    else {
+      return isset($this->storage[$name]);
+    }
+  }
+
+  /**
+   * @return mixed
+   */
+  public function getThemePath() {
+    return $this->theme_path;
+  }
+
+  /**
+   * @param string $theme_path
+   *
+   * @return $this
+   */
+  public function setThemePath($theme_path) {
+    $this->theme_path = $theme_path;
+
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getType() {
+    return $this->type;
+  }
+
+  /**
+   * @param string $type
+   */
+  public function setType($type) {
+    $this->type = $type;
+
+    return $this;
+  }
+
+  /**
+   * @return mixed[]|null
+   */
+  public function getVariables() {
+    return $this->variables;
+  }
+
+  /**
+   * @param mixed[] $variables
+   *
+   * @return $this
+   */
+  public function setVariables(array $variables) {
+    $this->variables = $variables;
+
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getRenderElement() {
+    return $this->render_element;
+  }
+
+  /**
+   * @param string $render_element
+   *
+   * @return $this
+   */
+  public function setRenderElement($render_element) {
+    $this->render_element = $render_element;
+
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getPattern() {
+    return $this->pattern;
+  }
+
+  /**
+   * @param string $pattern
+   *
+   * @return $this
+   */
+  public function setPattern($pattern) {
+    $this->pattern = $pattern;
+
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getBaseHook() {
+    return $this->base_hook;
+  }
+
+  /**
+   * @param string $base_hook
+   *
+   * @return $this
+   */
+  public function setBaseHook($base_hook) {
+    $this->base_hook = $base_hook;
+
+    return $this;
+  }
+
+  /**
+   * @return string[]
+   */
+  public function getIncludes() {
+    return $this->includes;
+  }
+
+  /**
+   * @param string[] $includes
+   *
+   * @return $this
+   */
+  public function setIncludes(array $includes) {
+    $this->includes = $includes;
+
+    return $this;
+  }
+
+  /**
+   * @param string $include
+   *
+   * @return $this
+   */
+  public function addInclude($include) {
+    $includes = $this->getIncludes();
+    $includes[] = $include;
+    $this->setIncludes($includes);
+
+    return $this;
+  }
+
+  /**
+   * @return bool
+   */
+  public function hasIncludes() {
+    return !empty($this->includes);
+  }
+
+  /**
+   * @return string
+   */
+  public function getFile() {
+    return $this->file;
+  }
+
+  /**
+   * @param string $file
+   *
+   * @return $this
+   */
+  public function setFile($file) {
+    $this->file = $file;
+
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getFunction() {
+    return $this->function;
+  }
+
+  /**
+   * @param string $function
+   *
+   * @return $this
+   */
+  public function setFunction($function) {
+    $this->function = $function;
+
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getTemplate() {
+    return $this->template;
+  }
+
+  /**
+   * @param string $template
+   *
+   * @return $this
+   */
+  public function setTemplate($template) {
+    $this->template = $template;
+
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getPath() {
+    return $this->path;
+  }
+
+  /**
+   * @param string $path
+   *
+   * @return $this
+   */
+  public function setPath($path) {
+    $this->path = $path;
+
+    return $this;
+  }
+
+  /**
+   * @return string[]
+   */
+  public function getPreprocessFunctions() {
+    return $this->preprocess_functions;
+  }
+
+  /**
+   * @param string[] $preprocess_functions
+   *
+   * @return $this
+   */
+  public function setPreprocessFunctions(array $preprocess_functions) {
+    $this->preprocess_functions = array_values(array_unique($preprocess_functions));
+
+    return $this;
+  }
+
+  /**
+   * @param string $preprocess_function
+   *
+   * @return bool
+   */
+  public function hasPreprocessFunction($preprocess_function) {
+    return in_array($preprocess_function, $this->getPreprocessFunctions());
+  }
+
+  /**
+   * @param string $preprocess_function
+   *
+   * @return $this
+   */
+  public function addPreprocessFunction($preprocess_function) {
+    $preprocess_functions = $this->getPreprocessFunctions();
+    $preprocess_functions[] = $preprocess_function;
+    $this->setPreprocessFunctions($preprocess_functions);
+
+    return $this;
+  }
+
+  /**
+   * @return bool
+   */
+  public function getOverriddenPreprocessFunctionStatus() {
+    return $this->override_preprocess_functions;
+  }
+
+  /**
+   * @return $this
+   */
+  public function setOverriddenPreprocessFunctionStatus($status) {
+    $this->override_preprocess_functions = (bool) $status;
+
+    return $this;
+  }
+
+  /**
+   * @return bool
+   */
+  public function isComplete() {
+    return !$this->incomplete_preprocess_functions;
+  }
+
+  /**
+   * @return $this
+   */
+  public function markIncomplete() {
+    $this->incomplete_preprocess_functions = TRUE;
+
+    return $this;
+  }
+
+  /**
+   * @return $this
+   */
+  public function markComplete() {
+    $this->incomplete_preprocess_functions = FALSE;
+
+    return $this;
+  }
+
+  /**
+   * @param string $name
+   *
+   * @return $this
+   */
+  public function setFlag($name) {
+    $this->flag[$name] = TRUE;
+
+    return $this;
+  }
+
+  /**
+   * @param string $name
+   *
+   * @return $this
+   */
+  public function unsetFlag($name) {
+    unset($this->flag[$name]);
+
+    return $this;
+  }
+
+  /**
+   * @return mixed
+   */
+  public function getHook() {
+    return $this->hook;
+  }
+
+}
diff --git a/core/modules/layout_discovery/tests/src/Kernel/LayoutTest.php b/core/modules/layout_discovery/tests/src/Kernel/LayoutTest.php
index 9d4c446..c3785df 100644
--- a/core/modules/layout_discovery/tests/src/Kernel/LayoutTest.php
+++ b/core/modules/layout_discovery/tests/src/Kernel/LayoutTest.php
@@ -34,6 +34,17 @@ protected function setUp() {
   }
 
   /**
+   * Tests that a layout provided by a theme has the preprocess function set.
+   */
+  public function testThemeProvidedLayout() {
+    $this->container->get('theme_installer')->install(['test_layout_theme']);
+    $this->config('system.theme')->set('default', 'test_layout_theme')->save();
+
+    $theme_definitions = $this->container->get('theme.registry')->get();
+    $this->assertTrue(in_array('template_preprocess_layout', $theme_definitions['test_layout_theme']['preprocess functions']));
+  }
+
+  /**
    * Test rendering a layout.
    *
    * @dataProvider renderLayoutData
diff --git a/core/modules/layout_discovery/tests/themes/test_layout_theme/templates/test-layout-theme.html.twig b/core/modules/layout_discovery/tests/themes/test_layout_theme/templates/test-layout-theme.html.twig
new file mode 100644
index 0000000..67675f6
--- /dev/null
+++ b/core/modules/layout_discovery/tests/themes/test_layout_theme/templates/test-layout-theme.html.twig
@@ -0,0 +1 @@
+{{ content.content }}
diff --git a/core/modules/layout_discovery/tests/themes/test_layout_theme/test_layout_theme.info.yml b/core/modules/layout_discovery/tests/themes/test_layout_theme/test_layout_theme.info.yml
new file mode 100644
index 0000000..021d43f
--- /dev/null
+++ b/core/modules/layout_discovery/tests/themes/test_layout_theme/test_layout_theme.info.yml
@@ -0,0 +1,6 @@
+name: 'Test layout theme'
+type: theme
+description: 'Theme for testing a theme-provided layout'
+version: VERSION
+base theme: classy
+core: 8.x
diff --git a/core/modules/layout_discovery/tests/themes/test_layout_theme/test_layout_theme.layouts.yml b/core/modules/layout_discovery/tests/themes/test_layout_theme/test_layout_theme.layouts.yml
new file mode 100644
index 0000000..9da19dd
--- /dev/null
+++ b/core/modules/layout_discovery/tests/themes/test_layout_theme/test_layout_theme.layouts.yml
@@ -0,0 +1,7 @@
+test_layout_theme:
+  label: 'Test Layout - Theme'
+  category: 'Test Layout Theme'
+  template: templates/test-layout-theme
+  regions:
+    content:
+      label: Content
diff --git a/core/modules/locale/tests/modules/locale_test/locale_test.module b/core/modules/locale/tests/modules/locale_test/locale_test.module
index 89ba2ba..9059e9c 100644
--- a/core/modules/locale/tests/modules/locale_test/locale_test.module
+++ b/core/modules/locale/tests/modules/locale_test/locale_test.module
@@ -154,7 +154,7 @@ function locale_test_theme() {
   $return = [];
 
   $return['locale_test_tokenized'] = [
-    'variable' => ['content' => ''],
+    'variables' => ['content' => ''],
   ];
 
   return $return;
diff --git a/core/modules/system/tests/modules/theme_test/theme_test.module b/core/modules/system/tests/modules/theme_test/theme_test.module
index 2e6f164..674905b 100644
--- a/core/modules/system/tests/modules/theme_test/theme_test.module
+++ b/core/modules/system/tests/modules/theme_test/theme_test.module
@@ -6,6 +6,7 @@
  */
 
 use Drupal\Core\Extension\Extension;
+use Drupal\Core\Theme\ThemeHook;
 
 /**
  * Implements hook_theme().
@@ -66,6 +67,9 @@ function theme_test_theme($existing, $type, $theme, $path) {
       'bar' => '',
     ],
   ];
+  $items[] = ThemeHook::create('theme_test_registered_by_module')
+    ->setRenderElement('content')
+    ->setBaseHook('container');
   return $items;
 }
 
diff --git a/core/modules/system/tests/themes/test_theme/templates/theme-test-registered-by-module.html.twig b/core/modules/system/tests/themes/test_theme/templates/theme-test-registered-by-module.html.twig
new file mode 100644
index 0000000..3432e01
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme/templates/theme-test-registered-by-module.html.twig
@@ -0,0 +1,2 @@
+{# Output for Theme API test #}
+Template provided by theme is registered by module.
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php b/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
index a962fec..211f89e 100644
--- a/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php
@@ -142,6 +142,9 @@ public function testSuggestionPreprocessFunctions() {
     $this->assertIdentical($expected_preprocess_functions, $preprocess_functions, 'Suggestion implemented as a template correctly inherits preprocess functions.');
 
     $this->assertTrue(isset($registry_theme->get()['theme_test_preprocess_suggestions__kitten__meerkat__tarsier__moose']), 'Preprocess function with an unimplemented lower-level suggestion is added to the registry.');
+    foreach ($registry_theme->get() as $name => $info) {
+      $this->assertSame($name, $info->getHook());
+    }
   }
 
   /**
@@ -192,4 +195,22 @@ public function testThemeSuggestions() {
     ], $suggestions, 'Found expected page node suggestions.');
   }
 
+  /**
+   * Tests theme-provided templates that are registered by modules.
+   */
+  public function testThemeTemplatesRegisteredByModules() {
+    $theme_handler = \Drupal::service('theme_handler');
+    $theme_handler->install(['test_theme']);
+
+    $registry_theme = new Registry(\Drupal::root(), \Drupal::cache(), \Drupal::lock(), \Drupal::moduleHandler(), $theme_handler, \Drupal::service('theme.initialization'), 'test_theme');
+    $registry_theme->setThemeManager(\Drupal::theme());
+
+    $expected = [
+      'template_preprocess',
+      'template_preprocess_container',
+    ];
+    $registry = $registry_theme->get();
+    $this->assertEquals($expected, $registry['theme_test_registered_by_module']['preprocess functions']);
+  }
+
 }
diff --git a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php
index f2cd22e..aa0074e 100644
--- a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php
+++ b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php
@@ -9,6 +9,7 @@
 
 use Drupal\Core\Theme\ActiveTheme;
 use Drupal\Core\Theme\Registry;
+use Drupal\Core\Theme\ThemeHook;
 use Drupal\Tests\UnitTestCase;
 
 /**
@@ -191,6 +192,17 @@ public function testGetRegistryForModule() {
   public function testPostProcessExtension($defined_functions, $hooks, $expected) {
     static::$functions['user'] = $defined_functions;
 
+    foreach ($expected as $name => $hook) {
+      if (is_array($hook)) {
+        $expected[$name] = ThemeHook::createFromLegacy($name, $hook);
+      }
+    }
+    foreach ($hooks as $name => $hook) {
+      if (is_array($hook)) {
+        $hooks[$name] = ThemeHook::createFromLegacy($name, $hook);
+      }
+    }
+
     $theme = $this->prophesize(ActiveTheme::class);
     $theme->getBaseThemes()->willReturn([]);
     $theme->getName()->willReturn('test');
