 core/core.services.yml                             |    5 +
 core/includes/common.inc                           |  174 +++-
 core/includes/menu.inc                             |  255 ++---
 core/includes/theme.inc                            |   40 +-
 core/lib/Drupal/Core/Access/AccessManager.php      |   26 +-
 core/lib/Drupal/Core/Access/CsrfAccessCheck.php    |   18 +-
 core/lib/Drupal/Core/Access/CustomAccessCheck.php  |   21 +-
 core/lib/Drupal/Core/Access/DefaultAccessCheck.php |   20 +-
 .../Core/Cache/CacheabilityAffectorInterface.php   |   52 +
 core/lib/Drupal/Core/Cache/DomainCacheContext.php  |   53 +
 core/lib/Drupal/Core/Entity/EntityAccessCheck.php  |   21 +-
 .../Drupal/Core/Entity/EntityCreateAccessCheck.php |   18 +-
 .../Core/Page/DefaultHtmlFragmentRenderer.php      |   32 +-
 core/lib/Drupal/Core/Routing/Access/AccessBase.php |   52 +
 .../Drupal/Core/Routing/Access/AccessInterface.php |    7 +-
 core/lib/Drupal/Core/Theme/ThemeAccessCheck.php    |   20 +-
 .../src/Access/BookNodeIsRemovableAccessCheck.php  |   21 +-
 core/modules/book/src/BookManager.php              |    1 -
 core/modules/comment/comment.services.yml          |    4 +
 .../modules/comment/src/CommentPostRenderCache.php |   84 ++
 .../FieldFormatter/CommentDefaultFormatter.php     |   29 +-
 .../src/Access/ConfigTranslationFormAccess.php     |   17 +
 .../src/Access/ConfigTranslationOverviewAccess.php |   21 +-
 .../contact/src/Access/ContactPageAccess.php       |   21 +-
 .../Access/ContentTranslationManageAccessCheck.php |   21 +-
 .../Access/ContentTranslationOverviewAccess.php    |   22 +-
 core/modules/editor/editor.module                  |   84 +-
 core/modules/editor/editor.services.yml            |    3 +
 core/modules/editor/src/Element.php                |  120 +++
 .../field_ui/src/Access/FormModeAccessCheck.php    |   18 +-
 .../field_ui/src/Access/ViewModeAccessCheck.php    |   22 +-
 core/modules/menu_link/menu_link.api.php           |    2 +-
 core/modules/menu_link/menu_link.services.yml      |   13 +-
 .../CacheableAccessCheckMenuTreeManipulator.php    |  641 ++++++++++++
 .../menu_link/src/DefaultMenuTreeManipulators.php  |  326 ++++++
 core/modules/menu_link/src/MenuActiveTrail.php     |  109 ++
 .../menu_link/src/MenuActiveTrailInterface.php     |   68 ++
 core/modules/menu_link/src/MenuLinkForm.php        |   25 +-
 core/modules/menu_link/src/MenuLinkStorage.php     |   23 -
 .../menu_link/src/MenuLinkStorageInterface.php     |    8 -
 core/modules/menu_link/src/MenuTree.php            |  731 +++++--------
 core/modules/menu_link/src/MenuTreeInterface.php   |  223 ++--
 core/modules/menu_link/src/MenuTreeItem.php        |   59 ++
 core/modules/menu_link/src/MenuTreeParameters.php  |  196 ++++
 ...CacheableAccessCheckMenuTreeManipulatorTest.php | 1095 ++++++++++++++++++++
 .../tests/src/DefaultMenuTreeManipulatorsTest.php  |  436 ++++++++
 .../menu_link/tests/src/MenuActiveTrailTest.php    |  175 ++++
 .../menu_link/tests/src/MenuTreeItemTest.php       |   79 ++
 .../menu_link/tests/src/MenuTreeParametersTest.php |  174 ++++
 core/modules/menu_link/tests/src/MenuTreeTest.php  |  733 ++++++++-----
 core/modules/menu_ui/menu_ui.module                |   27 +-
 core/modules/menu_ui/src/MenuForm.php              |   49 +-
 .../modules/node/src/Access/NodeAddAccessCheck.php |   18 +-
 .../node/src/Access/NodeRevisionAccessCheck.php    |   21 +-
 .../quickedit/src/Access/EditEntityAccessCheck.php |   21 +-
 .../src/Access/EditEntityFieldAccessCheck.php      |   21 +-
 core/modules/rest/src/Access/CSRFAccessCheck.php   |   19 +-
 .../src/Access/ShortcutSetSwitchAccessCheck.php    |   18 +-
 core/modules/system/src/Access/CronAccessCheck.php |   22 +-
 .../system/src/Controller/SystemController.php     |   75 +-
 .../system/src/Plugin/Block/SystemMenuBlock.php    |   91 +-
 core/modules/system/src/SystemManager.php          |   43 +-
 core/modules/system/src/Tests/System/AdminTest.php |   38 +-
 core/modules/system/system.module                  |   37 +-
 core/modules/system/templates/menu-tree.html.twig  |   40 +
 .../src/Access/DefinedTestAccessCheck.php          |   20 +-
 .../src/Access/TestAccessCheck.php                 |   21 +-
 core/modules/toolbar/toolbar.module                |  120 ++-
 .../src/Access/ViewOwnTrackerAccessCheck.php       |   19 +-
 .../update/src/Access/UpdateManagerAccessCheck.php |   21 +-
 core/modules/user/src/Access/LoginStatusCheck.php  |   18 +-
 .../user/src/Access/PermissionAccessCheck.php      |   19 +-
 .../user/src/Access/RegisterAccessCheck.php        |   19 +-
 core/modules/user/src/Access/RoleAccessCheck.php   |   18 +-
 .../user/src/Tests/UserAccountLinksTests.php       |   12 +-
 core/modules/views/src/ViewsAccessCheck.php        |   17 +-
 core/themes/bartik/bartik.theme                    |   18 +-
 77 files changed, 5765 insertions(+), 1515 deletions(-)

diff --git a/core/core.services.yml b/core/core.services.yml
index 250be9e..94aa73a 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -7,6 +7,11 @@ services:
   cache_contexts:
     class: Drupal\Core\Cache\CacheContexts
     arguments: ['@service_container', '%cache_contexts%' ]
+  cache_context.domain:
+    class: Drupal\Core\Cache\DomainCacheContext
+    arguments: ['@request']
+    tags:
+      - { name: cache.context}
   cache_context.url:
     class: Drupal\Core\Cache\UrlCacheContext
     arguments: ['@request_stack']
diff --git a/core/includes/common.inc b/core/includes/common.inc
index 3f0fd1d..f6d607e 100644
--- a/core/includes/common.inc
+++ b/core/includes/common.inc
@@ -3084,18 +3084,91 @@ function drupal_prepare_page($page) {
   // 'sidebar_first', 'footer', etc.
   \Drupal::moduleHandler()->alter('page', $page);
 
+  // @todo Remove these once https://drupal.org/node/1869476 lands; this can
+  //       easily be converted into blocks.
   // The "main" and "secondary" menus are never part of the page-level render
   // array and therefore their cache tags will never bubble up into the page
   // cache, even though they should be. This happens because they're rendered
   // directly by the theme system.
-  // @todo Remove this once https://drupal.org/node/1869476 lands.
-  if (theme_get_setting('features.main_menu') && count(menu_main_menu())) {
-    $main_links_source = _menu_get_links_source('main_links', 'main');
-    $page['page_top']['#cache']['tags']['menu'][$main_links_source] = $main_links_source;
+  $menu_tree = \Drupal::service('menu_link.tree');
+  $cacheable_access_check = \Drupal::service('menu_link.cacheable_access_check_tree_manipulator');
+  $account = \Drupal::currentUser();
+  $main_links_source = _menu_get_links_source('main_links', 'main');
+  $main_tree_parameters = menu_navigation_links($main_links_source);
+  if (!$cacheable_access_check->willBeEmptyForAccount($main_links_source, $account, $main_tree_parameters)) {
+    $page['_main_menu'] = array(
+      '#cache' => array(
+        'keys' => array_merge(
+          array(
+            'main_menu',
+            // This is cached per domain, language and theme. Since this is a
+            // single level, we don't care about the active trail at all;
+            // marking links as active is handled by the drupal.active-link
+            // library. Hence we don't need to vary by active trail.
+            'cache_context.domain',
+            'cache_context.language',
+            'cache_context.theme',
+          ),
+          // Also cache per cache context by which menu tree access checks are
+          // being cached.
+          $cacheable_access_check->getAccessCachingContexts()
+        ),
+      ),
+      '#pre_render' => array(
+        '_prerender_main_menu',
+      ),
+      '#attributes' => array(
+        'id' => 'links__system_main_menu',
+        'class' => array('links'),
+      ),
+      '#heading' => array(
+        'text' => t('Main menu'),
+        'class' => array('visually-hidden'),
+      ),
+    );
   }
-  if (theme_get_setting('features.secondary_menu') && count(menu_secondary_menu())) {
-    $secondary_links_source = _menu_get_links_source('secondary_links', 'account');
-    $page['page_top']['#cache']['tags']['menu'][$secondary_links_source] = $secondary_links_source;
+  $secondary_links_source = _menu_get_links_source('secondary_links', 'account');
+  // If the secondary menu source is set as the primary menu, we display the
+  // second level of the primary menu, depending on the active trail.
+  $secondary_menu_level = ($main_links_source == $secondary_links_source) ? 1 : 0;
+  $secondary_tree_parameters = menu_navigation_links($secondary_links_source, $secondary_menu_level);
+  if (!$cacheable_access_check->willBeEmptyForAccount($secondary_links_source, $account, $secondary_tree_parameters)) {
+    $page['_secondary_menu'] = array(
+      '#cache' => array(
+        'keys' => array_merge(
+          array(
+            'secondary_menu',
+            // This is cached per domain, language and theme. Since this is a
+            // single level, we don't care about the active trail at all;
+            // marking links as active is handled by the drupal.active-link
+            // library. Hence we don't need to vary by active trail.
+            'cache_context.domain',
+            'cache_context.language',
+            'cache_context.theme',
+          ),
+          // Also cache per cache context by which menu tree access checks are
+          // being cached.
+          $cacheable_access_check->getAccessCachingContexts()
+        ),
+      ),
+      '#pre_render' => array(
+        '_prerender_secondary_menu',
+      ),
+      '#attributes' => array(
+        'id' => 'links__system_secondary_menu',
+        'class' => array('links'),
+      ),
+      '#heading' => array(
+        'text' => t('Secondary menu'),
+        'class' => array('visually-hidden'),
+      ),
+    );
+  }
+  // If the secondary menu source is set as the primary menu, we display the
+  // second level of the primary menu, depending on the active trail. In that
+  // case, we must vary by active trail after all.
+  if ($main_links_source == $secondary_links_source) {
+    $page['_secondary_menu']['#cache']['keys'][] = $menu_tree->getActiveTrailCacheKey($secondary_links_source);
   }
 
   // If no module has taken care of the main content, add it to the page now.
@@ -3113,6 +3186,60 @@ function drupal_prepare_page($page) {
   return $page;
 }
 
+// @todo Remove this once https://drupal.org/node/1869476 lands; this can
+//       easily be converted into a method on a block plugin.
+function _prerender_main_menu($element) {
+  if (theme_get_setting('features.main_menu')) {
+    $original = $element;
+
+    $main_links_source = _menu_get_links_source('main_links', 'main');
+    $parameters = menu_navigation_links($main_links_source);
+
+    /** @var \Drupal\menu_link\MenuTreeInterface $menu_tree */
+    $menu_tree = \Drupal::service('menu_link.tree');
+    $tree = $menu_tree->build($main_links_source, $parameters);
+    $element = $menu_tree->render($tree);
+
+    // Copy properties of the original element.
+    $element['#cache']['keys'] = $original['#cache']['keys'];
+    $element['#attributes'] = $original['#attributes'];
+    $element['#heading'] = $original['#heading'];
+  }
+  $element['#cache']['tags']['theme'] = TRUE;
+  $element['#cache']['tags']['theme_global_settings'] = TRUE;
+
+  return $element;
+}
+
+// @todo Remove this once https://drupal.org/node/1869476 lands; this can
+//       easily be converted into a method on a block plugin.
+function _prerender_secondary_menu($element) {
+  if (theme_get_setting('features.secondary_menu')) {
+    $original = $element;
+
+    $main_links_source = _menu_get_links_source('main_links', 'main');
+    $secondary_links_source = _menu_get_links_source('secondary_links', 'account');
+    // If the secondary menu source is set as the primary menu, we display the
+    // second level of the primary menu, depending on the active trail.
+    $secondary_menu_level = ($main_links_source == $secondary_links_source) ? 1 : 0;
+    $parameters = menu_navigation_links($secondary_links_source, $secondary_menu_level);
+
+    /** @var \Drupal\menu_link\MenuTreeInterface $menu_tree */
+    $menu_tree = \Drupal::service('menu_link.tree');
+    $tree = $menu_tree->build($secondary_links_source, $parameters);
+    $element = $menu_tree->render($tree);
+
+    // Copy properties of the original element.
+    $element['#cache']['keys'] = $original['#cache']['keys'];
+    $element['#attributes'] = $original['#attributes'];
+    $element['#heading'] = $original['#heading'];
+  }
+  $element['#cache']['tags']['theme'] = TRUE;
+  $element['#cache']['tags']['theme_global_settings'] = TRUE;
+
+  return $element;
+}
+
 /**
  * Renders the page, including all theming.
  *
@@ -3305,8 +3432,16 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
   // Make any final changes to the element before it is rendered. This means
   // that the $element or the children can be altered or corrected before the
   // element is rendered into the final text.
+  /** @var \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver */
+  $controller_resolver = \Drupal::service('controller_resolver');
   if (isset($elements['#pre_render'])) {
     foreach ($elements['#pre_render'] as $callable) {
+      if (is_string($callable) && strpos($callable, '::') === FALSE) {
+        $callable = $controller_resolver->getControllerFromDefinition($callable);
+      }
+      else {
+        $callable = $callable;
+      }
       $elements = call_user_func($callable, $elements);
     }
   }
@@ -3407,6 +3542,12 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
   // which allows the output'ed text to be filtered.
   if (isset($elements['#post_render'])) {
     foreach ($elements['#post_render'] as $callable) {
+      if (is_string($callable) && strpos($callable, '::') === FALSE) {
+        $callable = $controller_resolver->getControllerFromDefinition($callable);
+      }
+      else {
+        $callable = $callable;
+      }
       $elements['#children'] = call_user_func($callable, $elements['#children'], $elements);
     }
   }
@@ -3477,7 +3618,7 @@ function drupal_render_children(&$element, $children_keys = NULL) {
   $output = '';
   foreach ($children_keys as $key) {
     if (!empty($element[$key])) {
-      $output .= drupal_render($element[$key]);
+      $output .= drupal_render($element[$key], TRUE);
     }
   }
   return $output;
@@ -3672,9 +3813,15 @@ function drupal_render_cache_set(&$markup, array $elements) {
  * @return string
  *   The generated placeholder HTML.
  *
+ * @throws \Exception
+ *
  * @see drupal_render_cache_get()
  */
 function drupal_render_cache_generate_placeholder($callback, array &$context) {
+  if (!is_callable($callback) && strpos($callback, ':') === FALSE) {
+    throw new Exception(t('#callback must be a callable function or of the form service_id:method.'));
+  }
+
   // Generate a unique token if one is not already provided.
   $context += array(
     'token' => \Drupal\Component\Utility\Crypt::randomBytesBase64(55),
@@ -3702,10 +3849,19 @@ function drupal_render_cache_generate_placeholder($callback, array &$context) {
  */
 function _drupal_render_process_post_render_cache(array &$elements) {
   if (isset($elements['#post_render_cache'])) {
+    /** @var \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver */
+    $controller_resolver = \Drupal::service('controller_resolver');
+
     // Call all #post_render_cache callbacks, passing the provided context.
     foreach (array_keys($elements['#post_render_cache']) as $callback) {
+      if (strpos($callback, '::') === FALSE) {
+        $callable = $controller_resolver->getControllerFromDefinition($callback);
+      }
+      else {
+        $callable = $callback;
+      }
       foreach ($elements['#post_render_cache'][$callback] as $context) {
-        $elements = call_user_func_array($callback, array($elements, $context));
+        $elements = call_user_func_array($callable, array($elements, $context));
       }
     }
     // Make sure that any attachments added in #post_render_cache callbacks are
diff --git a/core/includes/menu.inc b/core/includes/menu.inc
index 5457ff8..34e15c7 100644
--- a/core/includes/menu.inc
+++ b/core/includes/menu.inc
@@ -9,6 +9,7 @@
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Render\Element;
 use Drupal\Core\Template\Attribute;
+use Drupal\menu_link\MenuTreeParameters;
 use Symfony\Cmf\Component\Routing\RouteObjectInterface;
 
 /**
@@ -99,6 +100,22 @@
  * menu_links table holds the visible menu links. By default these are
  * derived from the same hook_menu definitions, however you are free to
  * add more with menu_link_save().
+ *
+ * @section Rendering menus
+ * Once you have created menus (that contain menu links), you want to render
+ * them. Drupal provides a block (Drupal\system\Plugin\Block\SystemMenuBlock) to
+ * do so.
+ *
+ * However, perhaps you have more advanced needs and you're not satisfied with
+ * what the menu blocks offer you. If that's the case, you'll want to:
+ * - Instantiate \Drupal\menu_link\MenuTreeParameters, and set its values to
+ *   match your needs.
+ * - Potentially write a custom menu tree manipulator, see
+ *   \Drupal\menu_link\DefaultMenuTreeManipulators for examples.
+ * - Call \Drupal\menu_link\MenuTree::build() with your menu tree parameters,
+ *   this will return a menu tree.
+ * - Pass the menu tree to \Drupal\menu_link\MenuTree::render(), this will
+ *   return a renderable array.
  */
 
 /**
@@ -135,113 +152,41 @@
 const MENU_PREFERRED_LINK = '1cf698d64d1aa4b83907cf6ed55db3a7f8e92c91';
 
 /**
- * Localizes a menu link title using t() if possible.
- *
- * Translate the title and description to allow storage of English title
- * strings in the database, yet display of them in the language required
- * by the current user.
- *
- * @param $item
- *   A menu link entity.
- */
-function _menu_item_localize(&$item) {
-  // Allow default menu links to be translated.
-  $item['localized_options'] = $item['options'];
-  // All 'class' attributes are assumed to be an array during rendering, but
-  // links stored in the database may use an old string value.
-  // @todo In order to remove this code we need to implement a database update
-  //   including unserializing all existing link options and running this code
-  //   on them, as well as adding validation to menu_link_save().
-  if (isset($item['options']['attributes']['class']) && is_string($item['options']['attributes']['class'])) {
-    $item['localized_options']['attributes']['class'] = explode(' ', $item['options']['attributes']['class']);
-  }
-  // If the menu link is defined in code and not customized, we can use t().
-  if (!empty($item['machine_name']) && !$item['customized']) {
-    // @todo Figure out a proper way to support translations of menu links, see
-    //   https://drupal.org/node/2193777.
-    $item['title'] = t($item['link_title']);
-  }
-  else {
-    $item['title'] = $item['link_title'];
-  }
-}
-
-/**
- * Provides menu link unserializing, access control, and argument handling.
- *
- * @param array $item
- *   The passed in item has the following keys:
- *   - access: (optional) Becomes TRUE if the item is accessible, FALSE
- *     otherwise. If the key is not set, the access manager is used to
- *     determine the access.
- *   - options: (required) Is unserialized and copied to $item['localized_options'].
- *   - link_title: (required) The title of the menu link.
- *   - route_name: (required) The route name of the menu link.
- *   - route_parameters: (required) The unserialized route parameters of the menu link.
- *   The passed in item is changed by the following keys:
- *   - href: The actual path to the link. This path is generated from the
- *     link_path of the menu link entity.
- *   - title: The title of the link. This title is generated from the
- *     link_title of the menu link entity.
+ * Implements template_preprocess_HOOK() for theme_menu_tree().
  */
-function _menu_link_translate(&$item) {
-  if (!is_array($item['options'])) {
-    $item['options'] = (array) unserialize($item['options']);
-  }
-  $item['localized_options'] = $item['options'];
-  $item['title'] = $item['link_title'];
-  if ($item['external'] || empty($item['route_name'])) {
-    $item['access'] = 1;
-    $item['href'] = $item['link_path'];
-    $item['route_parameters'] = array();
-    // Set to NULL so that drupal_pre_render_link() is certain to skip it.
-    $item['route_name'] = NULL;
-  }
-  else {
-    $item['href'] = NULL;
-    if (!is_array($item['route_parameters'])) {
-      $item['route_parameters'] = (array) unserialize($item['route_parameters']);
-    }
-    // menu_tree_check_access() may set this ahead of time for links to nodes.
-    if (!isset($item['access'])) {
-      $item['access'] = \Drupal::getContainer()->get('access_manager')->checkNamedRoute($item['route_name'], $item['route_parameters'], \Drupal::currentUser());
+function template_preprocess_menu_tree(&$variables) {
+  if (isset($variables['tree']['#heading'])) {
+    $variables['heading'] = $variables['tree']['#heading'];
+    $heading = &$variables['heading'];
+    // Convert a string heading into an array, using a H2 tag by default.
+    if (is_string($heading)) {
+      $heading = array('text' => $heading);
     }
-    // For performance, don't localize a link the user can't access.
-    if ($item['access']) {
-      _menu_item_localize($item);
+    // Merge in default array properties into $heading.
+    $heading += array(
+      'level' => 'h2',
+      'attributes' => array(),
+    );
+    // @todo Remove backwards compatibility for $heading['class'].
+    if (isset($heading['class'])) {
+      $heading['attributes']['class'] = $heading['class'];
     }
+    // Convert the attributes array into an Attribute object.
+    $heading['attributes'] = new Attribute($heading['attributes']);
+    $heading['text'] = String::checkPlain($heading['text']);
   }
 
-  // Allow other customizations - e.g. adding a page-specific query string to the
-  // options array. For performance reasons we only invoke this hook if the link
-  // has the 'alter' flag set in the options array.
-  if (!empty($item['options']['alter'])) {
-    \Drupal::moduleHandler()->alter('translated_menu_link', $item, $map);
+  if (isset($variables['tree']['#attributes'])) {
+    $variables['attributes'] = new Attribute($variables['tree']['#attributes']);
   }
-}
-
-/**
- * Implements template_preprocess_HOOK() for theme_menu_tree().
- */
-function template_preprocess_menu_tree(&$variables) {
+  else {
+    $variables['attributes'] = new Attribute();
+  }
+  $variables['attributes']['class'] = array('menu');
   $variables['tree'] = $variables['tree']['#children'];
 }
 
 /**
- * Returns HTML for a wrapper for a menu sub-tree.
- *
- * @param $variables
- *   An associative array containing:
- *   - tree: An HTML string containing the tree's items.
- *
- * @see template_preprocess_menu_tree()
- * @ingroup themeable
- */
-function theme_menu_tree($variables) {
-  return '<ul class="menu">' . $variables['tree'] . '</ul>';
-}
-
-/**
  * Returns HTML for a menu link and submenu.
  *
  * @param $variables
@@ -357,31 +302,6 @@ function menu_list_system_menus() {
 }
 
 /**
- * Returns an array of links to be rendered as the Main menu.
- */
-function menu_main_menu() {
-  $main_links_source = _menu_get_links_source('main_links', 'main');
-  return menu_navigation_links($main_links_source);
-}
-
-/**
- * Returns an array of links to be rendered as the Secondary links.
- */
-function menu_secondary_menu() {
-  $main_links_source = _menu_get_links_source('main_links', 'main');
-  $secondary_links_source = _menu_get_links_source('secondary_links', 'account');
-
-  // If the secondary menu source is set as the primary menu, we display the
-  // second level of the primary menu.
-  if ($secondary_links_source == $main_links_source) {
-    return menu_navigation_links($main_links_source, 1);
-  }
-  else {
-    return menu_navigation_links($secondary_links_source, 0);
-  }
-}
-
-/**
  * Returns the source of links of a menu.
  *
  * @param string $name
@@ -398,56 +318,34 @@ function _menu_get_links_source($name, $default) {
 }
 
 /**
- * Returns an array of links for a navigation menu.
+ * Builds an array of menu tree parameters for a navigation menu.
  *
- * @param $menu_name
+ * @param string $menu_name
  *   The name of the menu.
- * @param $level
+ * @param int $level
  *   Optional, the depth of the menu to be returned.
  *
- * @return
- *   An array of links of the specified menu and level.
+ * @return \Drupal\menu_link\MenuTreeParameters
+ *   An array of menu tree parameters.
  */
+// @todo Remove this once https://drupal.org/node/1869476 lands; this can
+//       easily be converted into a method on a block plugin.
 function menu_navigation_links($menu_name, $level = 0) {
-  // Don't even bother querying the menu table if no menu is specified.
-  if (empty($menu_name)) {
-    return array();
-  }
-
-  // Get the menu hierarchy for the current page.
-  /** @var \Drupal\menu_link\MenuTreeInterface $menu_tree */
-  $menu_tree = \Drupal::service('menu_link.tree');
-  $tree = $menu_tree->buildPageData($menu_name, $level + 1);
-
-  // Go down the active trail until the right level is reached.
-  while ($level-- > 0 && $tree) {
-    // Loop through the current level's items until we find one that is in trail.
-    while ($item = array_shift($tree)) {
-      if ($item['link']['in_active_trail']) {
-        // If the item is in the active trail, we continue in the subtree.
-        $tree = empty($item['below']) ? array() : $item['below'];
-        break;
-      }
-    }
-  }
-
-  // Create a single level of links.
-  $links = array();
-  foreach ($tree as $item) {
-    if (!$item['link']['hidden']) {
-      $class = '';
-      $l = $item['link']['localized_options'];
-      $l['href'] = $item['link']['link_path'];
-      $l['title'] = $item['link']['title'];
-      if ($item['link']['in_active_trail']) {
-        $class = ' active-trail';
-        $l['attributes']['class'][] = 'active-trail';
-      }
-      // Keyed with the unique mlid to generate classes in links.html.twig.
-      $links['menu-' . $item['link']['mlid'] . $class] = $l;
-    }
-  }
-  return $links;
+  /** @var \Drupal\menu_link\MenuActiveTrailInterface $menu_active_trail */
+  $menu_active_trail = \Drupal::service('menu_link.active_trail');
+
+  $parameters = new MenuTreeParameters();
+  $parameters->setMaxDepth($level + 1);
+  if ($level > 0) {
+    $parameters->expandAlongActiveTrail($menu_name, $menu_active_trail->getActiveIds($menu_name), \Drupal::service('entity.query'))
+      ->appendManipulator('menu_link.default_tree_manipulators:extractSubtreeOfActiveTrail', array($level));
+  }
+  $parameters->appendManipulator('menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess')
+    ->appendManipulator('menu_link.default_tree_manipulators:translate')
+    ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+    ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
+    ->appendManipulator('menu_link.default_tree_manipulators:setTreeItemClass');
+  return $parameters;
 }
 
 /**
@@ -637,6 +535,8 @@ function menu_get_active_menu_names() {
  *   found. The most specific menu link ('node/5' preferred over 'node/%') in
  *   the most preferred menu (as defined by menu_get_active_menu_names()) is
  *   returned.
+ *
+ * @todo apply caching here also; this needs >=3 DB queries right now.
  */
 function menu_link_get_preferred($path = NULL, $selected_menu = NULL) {
   $preferred_links = &drupal_static(__FUNCTION__);
@@ -687,7 +587,6 @@ function menu_link_get_preferred($path = NULL, $selected_menu = NULL) {
             $candidate_item = $candidates[$link_path][$menu_name];
             $candidate_item['access'] = \Drupal::service('access_manager')->checkNamedRoute($candidate_item['route_name'], $candidate_item['route_parameters'], \Drupal::currentUser());
             if ($candidate_item['access']) {
-              _menu_item_localize($candidate_item);
               $preferred_links[$path][$menu_name] = $candidate_item;
               if (empty($preferred_links[$path][MENU_PREFERRED_LINK])) {
                 // Store the most specific link.
@@ -914,36 +813,14 @@ function _menu_clear_page_cache() {
   //  the end of the page load when there are multiple links saved or deleted.
   if ($cache_cleared == 0) {
     Cache::invalidateTags(array('content' => TRUE));
-    // Keep track of which menus have expanded items.
-    _menu_set_expanded_menus();
     $cache_cleared = 1;
   }
   elseif ($cache_cleared == 1) {
     drupal_register_shutdown_function('Drupal\Core\Cache\Cache::invalidateTags', array('content' => TRUE));
-    // Keep track of which menus have expanded items.
-    drupal_register_shutdown_function('_menu_set_expanded_menus');
     $cache_cleared = 2;
   }
 }
 
 /**
- * Updates a list of menus with expanded items.
- */
-function _menu_set_expanded_menus() {
-  $names = array();
-  $result = Drupal::entityQueryAggregate('menu_link')
-    ->condition('expanded', 0, '<>')
-    ->groupBy('menu_name')
-    ->execute();
-
-  // Flatten the resulting array.
-  foreach($result as $k => $v) {
-    $names[$k] = $v['menu_name'];
-  }
-
-  \Drupal::state()->set('menu_expanded', $names);
-}
-
-/**
  * @} End of "defgroup menu".
  */
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 03d0df0..d3effff 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -2081,8 +2081,17 @@ function template_preprocess_page(&$variables) {
   $variables['site_slogan']       = (theme_get_setting('features.slogan') ? Xss::filterAdmin($site_config->get('slogan')) : '');
 
   if (!defined('MAINTENANCE_MODE')) {
-    $variables['main_menu']      = theme_get_setting('features.main_menu') ? menu_main_menu() : array();
-    $variables['secondary_menu'] = theme_get_setting('features.secondary_menu') ? menu_secondary_menu() : array();
+    // Pass the main menu and secondary menu to the template as render arrays.
+    // @todo Remove these once https://drupal.org/node/1869476 lands; this can
+    //       easily be converted into blocks.
+    if (isset($variables['page']['_main_menu'])) {
+      $variables['main_menu'] = $variables['page']['_main_menu'];
+      unset($variables['page']['_main_menu']);
+    }
+    if (isset($variables['page']['_secondary_menu'])) {
+      $variables['secondary_menu'] = $variables['page']['_secondary_menu'];
+      unset($variables['page']['_secondary_menu']);
+    }
     $variables['action_links']   = menu_get_local_actions();
     $variables['tabs']           = menu_local_tabs();
 
@@ -2107,32 +2116,6 @@ function template_preprocess_page(&$variables) {
     $variables['feed_icons']     = '';
   }
 
-  // Pass the main menu and secondary menu to the template as render arrays.
-  if (!empty($variables['main_menu'])) {
-    $variables['main_menu'] = array(
-      '#theme' =>'links__system_main_menu',
-      '#links' => $variables['main_menu'],
-      '#heading' => array(
-        'text' => t('Main menu'),
-        'class' => array('visually-hidden'),
-        'attributes' => array('id' => 'links__system_main_menu'),
-      ),
-      '#set_active_class' => TRUE,
-    );
-  }
-  if (!empty($variables['secondary_menu'])) {
-    $variables['secondary_menu'] = array(
-      '#theme' =>'links__system_secondary_menu',
-      '#links' => $variables['secondary_menu'],
-      '#heading' => array(
-        'text' => t('Secondary menu'),
-        'class' => array('visually-hidden'),
-        'attributes' => array('id' => 'links__system_secondary_menu'),
-      ),
-      '#set_active_class' => TRUE,
-    );
-  }
-
   if ($node = \Drupal::request()->attributes->get('node')) {
     $variables['node'] = $node;
   }
@@ -2599,6 +2582,7 @@ function drupal_common_theme() {
     ),
     'menu_tree' => array(
       'render element' => 'tree',
+      'template' => 'menu-tree',
     ),
     'menu_local_task' => array(
       'render element' => 'element',
diff --git a/core/lib/Drupal/Core/Access/AccessManager.php b/core/lib/Drupal/Core/Access/AccessManager.php
index b40905f..74c87b8 100644
--- a/core/lib/Drupal/Core/Access/AccessManager.php
+++ b/core/lib/Drupal/Core/Access/AccessManager.php
@@ -40,7 +40,7 @@ class AccessManager implements ContainerAwareInterface {
   /**
    * Array of access check objects keyed by service id.
    *
-   * @var array
+   * @var \Drupal\Core\Routing\Access\AccessInterface[]
    */
   protected $checks;
 
@@ -237,6 +237,30 @@ public function checkNamedRoute($route_name, array $parameters = array(), Accoun
   }
 
   /**
+   * Returns all available access checks that are cacheable.
+   *
+   * @return array
+   *   An array with the keys being the cacheable access check service IDs and
+   *   the values being the corresponding cache contexts that they must be
+   *   varied by.
+   */
+  public function getCacheableAccessChecks() {
+    $cacheable_checks = array();
+
+    foreach ($this->checkIds as $service_id) {
+      if (empty($this->checks[$service_id])) {
+        $this->loadCheck($service_id);
+      }
+
+      if ($this->checks[$service_id]->isCacheable()) {
+        $cacheable_checks[$service_id] = $this->checks[$service_id]->getCacheContexts();
+      }
+    }
+
+    return $cacheable_checks;
+  }
+
+  /**
    * Checks a route against applicable access check services.
    *
    * Determines whether the route is accessible or not.
diff --git a/core/lib/Drupal/Core/Access/CsrfAccessCheck.php b/core/lib/Drupal/Core/Access/CsrfAccessCheck.php
index 1e8847d..0b8e13f 100644
--- a/core/lib/Drupal/Core/Access/CsrfAccessCheck.php
+++ b/core/lib/Drupal/Core/Access/CsrfAccessCheck.php
@@ -7,7 +7,7 @@
 
 namespace Drupal\Core\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface as RoutingAccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Symfony\Component\Routing\Route;
 use Symfony\Component\HttpFoundation\Request;
 
@@ -18,7 +18,7 @@
  * a token generated by \Drupal::csrfToken()->get() using the same value as the
  * "_csrf_token" parameter in the route.
  */
-class CsrfAccessCheck implements RoutingAccessInterface {
+class CsrfAccessCheck extends AccessBase {
 
   /**
    * The CSRF token generator.
@@ -68,4 +68,18 @@ public function access(Route $route, Request $request) {
     }
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Access/CustomAccessCheck.php b/core/lib/Drupal/Core/Access/CustomAccessCheck.php
index c128a5b..55542f7 100644
--- a/core/lib/Drupal/Core/Access/CustomAccessCheck.php
+++ b/core/lib/Drupal/Core/Access/CustomAccessCheck.php
@@ -8,7 +8,7 @@
 namespace Drupal\Core\Access;
 
 use Drupal\Core\Controller\ControllerResolverInterface;
-use Drupal\Core\Routing\Access\AccessInterface as RoutingAccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\Route;
@@ -23,7 +23,7 @@
  * cannot reuse any stored property of your actual controller instance used
  * to generate the output.
  */
-class CustomAccessCheck implements RoutingAccessInterface {
+class CustomAccessCheck extends AccessBase {
 
   /**
    * The controller resolver.
@@ -71,4 +71,21 @@ public function access(Route $route, Request $request, AccountInterface $account
     return call_user_func_array($callable, $arguments);
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo consider removing this access check, because it's impossible to
+   *       collect cache tags and contexts for.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Access/DefaultAccessCheck.php b/core/lib/Drupal/Core/Access/DefaultAccessCheck.php
index 485dad8..25e045f 100644
--- a/core/lib/Drupal/Core/Access/DefaultAccessCheck.php
+++ b/core/lib/Drupal/Core/Access/DefaultAccessCheck.php
@@ -7,13 +7,13 @@
 
 namespace Drupal\Core\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface as RoutingAccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Symfony\Component\Routing\Route;
 
 /**
  * Allows access to routes to be controlled by an '_access' boolean parameter.
  */
-class DefaultAccessCheck implements RoutingAccessInterface {
+class DefaultAccessCheck extends AccessBase {
 
   /**
    * Checks access to the route based on the _access parameter.
@@ -36,4 +36,20 @@ public function access(Route $route) {
     }
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * This is globally cacheable.
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Cache/CacheabilityAffectorInterface.php b/core/lib/Drupal/Core/Cache/CacheabilityAffectorInterface.php
new file mode 100644
index 0000000..093d278
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/CacheabilityAffectorInterface.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * @file
+ * Contains \Drupal\Core\CacheabilityAffectorInterface.
+ */
+
+namespace Drupal\Core\Cache;
+
+/**
+ * Defines an interface for an object affecting the cacheability of its parents.
+ *
+ * @ingroup cache
+ */
+interface CacheabilityAffectorInterface {
+
+  /**
+   * Returns the cache tags that this and any parent should be associated with.
+   *
+   * @return array
+   *  An array of cache tags.
+   */
+  public function getCacheTags();
+
+  /**
+   * Indicates whether this object is cacheable.
+   *
+   * @return bool
+   *   Returns TRUE if the object is cacheable, FALSE otherwise.
+   */
+  public function isCacheable();
+
+  /**
+   * Returns the cache contexts that this and any parent should be varied by.
+   *
+   * Only relevant when this is object is cacheable.
+   *
+   * @return array
+   *   An array of cache context IDs.
+   */
+  public function getCacheContexts();
+
+  /**
+   * Returns the maximum age that this and any parent may be cached.
+   *
+   * Only relevant when this is object is cacheable.
+   *
+   * @return int
+   *   The maximum time in seconds that this object may be cached.
+   */
+  public function getCacheMaxAge();
+
+}
diff --git a/core/lib/Drupal/Core/Cache/DomainCacheContext.php b/core/lib/Drupal/Core/Cache/DomainCacheContext.php
new file mode 100644
index 0000000..f6ffd56
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/DomainCacheContext.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Cache\DomainCacheContext.
+ */
+
+namespace Drupal\Core\Cache;
+
+use Symfony\Component\HttpFoundation\Request;
+use Drupal\Core\Theme\ThemeNegotiatorInterface;
+
+/**
+ * Defines the DomainCacheContext service, for "per domain" caching.
+ *
+ * A "domain" is defined as the combination of URI scheme, domain name and port.
+ *
+ * @see Symfony\Component\HttpFoundation::getSchemeAndHttpHost()
+ */
+class DomainCacheContext implements CacheContextInterface {
+
+  /**
+   * The current request.
+   *
+   * @var \Symfony\Component\HttpFoundation\Request
+   */
+  protected $request;
+
+  /**
+   * Constructs a new ThemeCacheContext service.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request object.
+   */
+  public function __construct(Request $request) {
+    $this->request = $request;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getLabel() {
+    return t('Domain');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getContext() {
+    return $this->request->getSchemeAndHttpHost();
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/EntityAccessCheck.php b/core/lib/Drupal/Core/Entity/EntityAccessCheck.php
index 951abb0..4ecaceb 100644
--- a/core/lib/Drupal/Core/Entity/EntityAccessCheck.php
+++ b/core/lib/Drupal/Core/Entity/EntityAccessCheck.php
@@ -7,7 +7,7 @@
 
 namespace Drupal\Core\Entity;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\Routing\Route;
 use Symfony\Component\HttpFoundation\Request;
@@ -15,7 +15,7 @@
 /**
  * Provides a generic access checker for entities.
  */
-class EntityAccessCheck implements AccessInterface {
+class EntityAccessCheck extends AccessBase {
 
   /**
    * Checks access to the entity operation on the given route.
@@ -56,4 +56,21 @@ public function access(Route $route, Request $request, AccountInterface $account
     return static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the entity being accessed.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Entity/EntityCreateAccessCheck.php b/core/lib/Drupal/Core/Entity/EntityCreateAccessCheck.php
index bf7073d..fabb013 100644
--- a/core/lib/Drupal/Core/Entity/EntityCreateAccessCheck.php
+++ b/core/lib/Drupal/Core/Entity/EntityCreateAccessCheck.php
@@ -7,7 +7,7 @@
 
 namespace Drupal\Core\Entity;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\Route;
@@ -15,7 +15,7 @@
 /**
  * Defines an access checker for entity creation.
  */
-class EntityCreateAccessCheck implements AccessInterface {
+class EntityCreateAccessCheck extends AccessBase {
 
   /**
    * The entity manager.
@@ -72,4 +72,18 @@ public function access(Route $route, Request $request, AccountInterface $account
     return $this->entityManager->getAccessController($entity_type)->createAccess($bundle, $account) ? static::ALLOW : static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user');
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Page/DefaultHtmlFragmentRenderer.php b/core/lib/Drupal/Core/Page/DefaultHtmlFragmentRenderer.php
index f2429d9..31768ae 100644
--- a/core/lib/Drupal/Core/Page/DefaultHtmlFragmentRenderer.php
+++ b/core/lib/Drupal/Core/Page/DefaultHtmlFragmentRenderer.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Core\Page;
 
+use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Cache\CacheableInterface;
 use Drupal\Core\Language\LanguageManager;
 
@@ -54,19 +55,32 @@ public function render(HtmlFragmentInterface $fragment, $status_code = 200) {
     // Build the HtmlPage object.
     $page = new HtmlPage('', array(), $fragment->getTitle());
     $page = $this->preparePage($page, $page_array);
-    $page->setBodyTop(drupal_render($page_array['page_top']));
-    $page->setBodyBottom(drupal_render($page_array['page_bottom']));
+    // The 'page_top' and 'page_bottom' regions contain the "top-content" and
+    // "bottom-content" of this page, respectively. We render them independently
+    // of the main content, because HtmlPage requires that.
+    if (isset($page_array['page_top'])) {
+      $page_top = $page_array['page_top'];
+      unset($page_array['page_top']);
+      $page->setBodyTop(drupal_render($page_top));
+    }
+    if (isset($page_array['page_bottom'])) {
+      $page_bottom = $page_array['page_bottom'];
+      unset($page_array['page_bottom']);
+      $page->setBodyBottom(drupal_render($page_bottom));
+    }
     $page->setContent(drupal_render($page_array));
-    $page->setStatusCode($status_code);
 
-    if ($fragment instanceof CacheableInterface) {
-      // Collect cache tags for all the content in all the regions on the page.
-      $tags = $page_array['#cache']['tags'];
+    // Collect cache tags for all three parts of the page: top, bottom and main.
+    $page_cache_tags = NestedArray::mergeDeep(
+      isset($page_top) ? $page_top['#cache']['tags'] : array(),
+      isset($page_bottom) ? $page_bottom['#cache']['tags'] : array(),
+      $page_array['#cache']['tags'],
       // Enforce the generic "content" cache tag on all pages.
       // @todo Remove the "content" cache tag. @see https://drupal.org/node/2124957
-      $tags['content'] = TRUE;
-      $page->setCacheTags($tags);
-    }
+      array('content' => TRUE)
+    );
+    $page->setCacheTags($page_cache_tags);
+    $page->setStatusCode($status_code);
 
     return $page;
   }
diff --git a/core/lib/Drupal/Core/Routing/Access/AccessBase.php b/core/lib/Drupal/Core/Routing/Access/AccessBase.php
new file mode 100644
index 0000000..1dc3e30
--- /dev/null
+++ b/core/lib/Drupal/Core/Routing/Access/AccessBase.php
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Routing\Access\AccessBase.
+ */
+
+namespace Drupal\Core\Routing\Access;
+
+use Drupal\Core\Cache\Cache;
+
+/**
+ * Defines a base implementation of a route access checker.
+ *
+ *
+ *
+ * This implements sane defaults for the CacheabilityAffectorInterface. Every
+ * access check still has to implement @code isCacheable() @endcode and
+ * @code getCacheContexts() @endcode, to ensure developers make a conscious
+ * choice there.
+ *
+ * @see Drupal\Core\CacheabilityAffectorInterface
+ */
+abstract class AccessBase implements AccessInterface {
+
+  /**
+   * {@inheritdoc}
+   *
+   * Access check objects don't have references to the data needed to perform
+   * the access check; that data is only passed to ::access() when invoked. Thus
+   * it's impossible for access checks to generate meaningful cache tags.
+   * Fortunately, we can approximate the necessary cache tags by getting the
+   * cache tags from the associated route's parameters.
+   * @todo Consider either instantiating access check objects for every check
+   *       that needs to be performed, or alternatively, to have a setArgs()
+   *       method.
+   */
+  public function getCacheTags() {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * Typically, access check results remain permanently valid, until one of the
+   * associated cache tags automatically invalidate the result.
+   */
+  public function getCacheMaxAge() {
+    return Cache::PERMANENT;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Routing/Access/AccessInterface.php b/core/lib/Drupal/Core/Routing/Access/AccessInterface.php
index 24c943d..2516fcd 100644
--- a/core/lib/Drupal/Core/Routing/Access/AccessInterface.php
+++ b/core/lib/Drupal/Core/Routing/Access/AccessInterface.php
@@ -8,11 +8,16 @@
 namespace Drupal\Core\Routing\Access;
 
 use Drupal\Core\Access\AccessInterface as GenericAccessInterface;
+use Drupal\Core\Cache\CacheabilityAffectorInterface;
 
 /**
  * An access check service determines access rules for particular routes.
+ *
+ * This extends the @code CacheabilityAffectorInterface @endcode to ensure that
+ * access checks provide sufficient information to menus that are render cached,
+ * to ensure both effective caching and correct cache invalidation.
  */
-interface AccessInterface extends GenericAccessInterface {
+interface AccessInterface extends GenericAccessInterface, CacheabilityAffectorInterface {
 
   // @todo Remove this interface since it no longer defines any methods?
   // @see https://drupal.org/node/2266817.
diff --git a/core/lib/Drupal/Core/Theme/ThemeAccessCheck.php b/core/lib/Drupal/Core/Theme/ThemeAccessCheck.php
index 2898034..9bc5fd3 100644
--- a/core/lib/Drupal/Core/Theme/ThemeAccessCheck.php
+++ b/core/lib/Drupal/Core/Theme/ThemeAccessCheck.php
@@ -7,12 +7,12 @@
 
 namespace Drupal\Core\Theme;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 
 /**
  * Provides access checking for themes for routing and theme negotiation.
  */
-class ThemeAccessCheck implements AccessInterface {
+class ThemeAccessCheck extends AccessBase {
 
   /**
    * Checks access to the theme for routing.
@@ -41,4 +41,20 @@ public function checkAccess($theme) {
     return !empty($themes[$theme]->status);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * This is globally cacheable.
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/book/src/Access/BookNodeIsRemovableAccessCheck.php b/core/modules/book/src/Access/BookNodeIsRemovableAccessCheck.php
index 861c252..1f8e73d 100644
--- a/core/modules/book/src/Access/BookNodeIsRemovableAccessCheck.php
+++ b/core/modules/book/src/Access/BookNodeIsRemovableAccessCheck.php
@@ -8,13 +8,13 @@
 namespace Drupal\book\Access;
 
 use Drupal\book\BookManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\node\NodeInterface;
 
 /**
  * Determines whether the requested node can be removed from its book.
  */
-class BookNodeIsRemovableAccessCheck implements AccessInterface {
+class BookNodeIsRemovableAccessCheck extends AccessBase {
 
   /**
    * Book Manager Service.
@@ -46,4 +46,21 @@ public function access(NodeInterface $node) {
     return $this->bookManager->checkNodeIsRemovable($node) ? static::ALLOW : static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable globally  if we could associate the cache tag
+   *       of the book node being checked.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/book/src/BookManager.php b/core/modules/book/src/BookManager.php
index c491cf4..7b4e012 100644
--- a/core/modules/book/src/BookManager.php
+++ b/core/modules/book/src/BookManager.php
@@ -553,7 +553,6 @@ public function bookTreeOutput(array $tree) {
       $element['#href'] = $node->url();
       $element['#localized_options'] = !empty($data['link']['localized_options']) ? $data['link']['localized_options'] : array();
       $element['#below'] = $data['below'] ? $this->bookTreeOutput($data['below']) : $data['below'];
-      $element['#original_link'] = $data['link'];
       // Index using the link's unique nid.
       $build[$data['link']['nid']] = $element;
     }
diff --git a/core/modules/comment/comment.services.yml b/core/modules/comment/comment.services.yml
index b0fc2ad..5873634 100644
--- a/core/modules/comment/comment.services.yml
+++ b/core/modules/comment/comment.services.yml
@@ -12,3 +12,7 @@ services:
   comment.statistics:
     class: Drupal\comment\CommentStatistics
     arguments: ['@database', '@current_user', '@entity.manager', '@state']
+
+  comment.post_render_cache:
+    class: Drupal\comment\CommentPostRenderCache
+    arguments: ['@entity.manager', '@entity.form_builder']
diff --git a/core/modules/comment/src/CommentPostRenderCache.php b/core/modules/comment/src/CommentPostRenderCache.php
new file mode 100644
index 0000000..15831e0
--- /dev/null
+++ b/core/modules/comment/src/CommentPostRenderCache.php
@@ -0,0 +1,84 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\comment\CommentPostRenderCache.
+ */
+
+namespace Drupal\comment;
+
+use Drupal\Core\Entity\EntityFormBuilderInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\field\Entity\FieldConfig;
+
+/**
+ * Defines a service for comment post render cache callbacks.
+ */
+class CommentPostRenderCache {
+
+  /**
+   * The entity manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityManagerInterface
+   */
+  protected $entityManager;
+
+  /**
+   * The entity form builder service.
+   *
+   * @var \Drupal\Core\Entity\EntityFormBuilderInterface
+   */
+  protected $entityFormBuilder;
+
+  /**
+   * Constructs a new CommentPostRenderCache object.
+   *
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager service.
+   * @param \Drupal\Core\Entity\EntityFormBuilderInterface $entity_form_builder
+   *   The entity form builder service.
+   */
+  public function __construct(EntityManagerInterface $entity_manager, EntityFormBuilderInterface $entity_form_builder) {
+    $this->entityManager = $entity_manager;
+    $this->entityFormBuilder = $entity_form_builder;
+  }
+
+  /**
+   * #post_render_cache callback; replaces placeholder with comment form.
+   *
+   * @param array $element
+   *   The renderable array that contains the to be replaced placeholder.
+   * @param array $context
+   *   An array with the following keys:
+   *   - entity_type: an entity type
+   *   - entity_id: an entity ID
+   *   - field_name: a comment field name
+   *
+   * @return array
+   *   A renderable array containing the comment form.
+   */
+  public function renderForm(array $element, array $context) {
+    $field_name = $context['field_name'];
+    $entity = $this->entityManager->getStorage($context['entity_type'])->load($context['entity_id']);
+    $field = Fieldconfig::loadByName($entity->getEntityTypeId(), $field_name);
+    $values = array(
+      'entity_type' => $entity->getEntityTypeId(),
+      'entity_id' => $entity->id(),
+      'field_name' => $field_name,
+      'comment_type' => $field->getSetting('bundle'),
+      'pid' => NULL,
+    );
+    $comment = $this->entityManager->getStorage('comment')->create($values);
+    $form = $this->entityFormBuilder->getForm($comment);
+    // @todo: This only works as long as assets are still tracked in a global
+    //   static variable, see https://drupal.org/node/2238835
+    $markup = drupal_render($form, TRUE);
+
+    $callback = 'comment.post_render_cache:renderForm';
+    $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
+    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
+
+    return $element;
+  }
+
+}
diff --git a/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
index 290a607..bb17d42 100644
--- a/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
+++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
@@ -176,7 +176,7 @@ public function viewElements(FieldItemListInterface $items) {
           // All other users need a user-specific form, which would break the
           // render cache: hence use a #post_render_cache callback.
           else {
-            $callback = '\Drupal\comment\Plugin\Field\FieldFormatter\CommentDefaultFormatter::renderForm';
+            $callback = 'comment.post_render_cache:renderForm';
             $context = array(
               'entity_type' => $entity->getEntityTypeId(),
               'entity_id' => $entity->id(),
@@ -208,33 +208,6 @@ public function viewElements(FieldItemListInterface $items) {
   }
 
   /**
-   * #post_render_cache callback; replaces placeholder with comment form.
-   *
-   * @param array $element
-   *   The renderable array that contains the to be replaced placeholder.
-   * @param array $context
-   *   An array with the following keys:
-   *   - entity_type: an entity type
-   *   - entity_id: an entity ID
-   *   - field_name: a comment field name
-   *
-   * @return array
-   *   A renderable array containing the comment form.
-   */
-  public static function renderForm(array $element, array $context) {
-    $callback = '\Drupal\comment\Plugin\Field\FieldFormatter\CommentDefaultFormatter::renderForm';
-    $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
-    $entity = entity_load($context['entity_type'], $context['entity_id']);
-    $form = comment_add($entity, $context['field_name']);
-    // @todo: This only works as long as assets are still tracked in a global
-    //   static variable, see https://drupal.org/node/2238835
-    $markup = drupal_render($form, TRUE);
-    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
-
-    return $element;
-  }
-
-  /**
    * {@inheritdoc}
    */
   public function settingsForm(array $form, array &$form_state) {
diff --git a/core/modules/config_translation/src/Access/ConfigTranslationFormAccess.php b/core/modules/config_translation/src/Access/ConfigTranslationFormAccess.php
index 10c5f25..5cf9c91 100644
--- a/core/modules/config_translation/src/Access/ConfigTranslationFormAccess.php
+++ b/core/modules/config_translation/src/Access/ConfigTranslationFormAccess.php
@@ -40,4 +40,21 @@ public function access(Route $route, Request $request, AccountInterface $account
     return static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per role if we could associate the cache tag
+   *       of the target language.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/config_translation/src/Access/ConfigTranslationOverviewAccess.php b/core/modules/config_translation/src/Access/ConfigTranslationOverviewAccess.php
index 529bc4a..700f9d8 100644
--- a/core/modules/config_translation/src/Access/ConfigTranslationOverviewAccess.php
+++ b/core/modules/config_translation/src/Access/ConfigTranslationOverviewAccess.php
@@ -8,7 +8,7 @@
 namespace Drupal\config_translation\Access;
 
 use Drupal\config_translation\ConfigMapperManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\Route;
@@ -16,7 +16,7 @@
 /**
  * Checks access for displaying the configuration translation overview.
  */
-class ConfigTranslationOverviewAccess implements AccessInterface {
+class ConfigTranslationOverviewAccess extends AccessBase {
 
   /**
    * The mapper plugin discovery service.
@@ -74,4 +74,21 @@ public function access(Route $route, Request $request, AccountInterface $account
     return $access ? static::ALLOW : static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per role if we could associate the cache tag
+   *       of the config mapper's and that of its source language.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/contact/src/Access/ContactPageAccess.php b/core/modules/contact/src/Access/ContactPageAccess.php
index 97a7b54..b890d9d 100644
--- a/core/modules/contact/src/Access/ContactPageAccess.php
+++ b/core/modules/contact/src/Access/ContactPageAccess.php
@@ -8,7 +8,7 @@
 namespace Drupal\contact\Access;
 
 use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\user\UserDataInterface;
 use Drupal\user\UserInterface;
@@ -16,7 +16,7 @@
 /**
  * Access check for contact_personal_page route.
  */
-class ContactPageAccess implements AccessInterface {
+class ContactPageAccess extends AccessBase {
 
   /**
    * The contact settings config object.
@@ -94,4 +94,21 @@ public function access(UserInterface $user, AccountInterface $account) {
     return $account->hasPermission('access user contact forms') ? static::ALLOW : static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the user being contacted.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/content_translation/src/Access/ContentTranslationManageAccessCheck.php b/core/modules/content_translation/src/Access/ContentTranslationManageAccessCheck.php
index 137b52e..9fee9cd 100644
--- a/core/modules/content_translation/src/Access/ContentTranslationManageAccessCheck.php
+++ b/core/modules/content_translation/src/Access/ContentTranslationManageAccessCheck.php
@@ -9,7 +9,7 @@
 
 use Drupal\Core\Entity\EntityManagerInterface;
 use Drupal\Core\Language\LanguageInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\Routing\Route;
 use Symfony\Component\HttpFoundation\Request;
@@ -17,7 +17,7 @@
 /**
  * Access check for entity translation CRUD operation.
  */
-class ContentTranslationManageAccessCheck implements AccessInterface {
+class ContentTranslationManageAccessCheck extends AccessBase {
 
   /**
    * The entity type manager.
@@ -91,4 +91,21 @@ public function access(Route $route, Request $request, AccountInterface $account
     return static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the entity being translated.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/content_translation/src/Access/ContentTranslationOverviewAccess.php b/core/modules/content_translation/src/Access/ContentTranslationOverviewAccess.php
index fabd61b..0a835c6 100644
--- a/core/modules/content_translation/src/Access/ContentTranslationOverviewAccess.php
+++ b/core/modules/content_translation/src/Access/ContentTranslationOverviewAccess.php
@@ -8,14 +8,14 @@
 namespace Drupal\content_translation\Access;
 
 use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Access check for entity translation overview.
  */
-class ContentTranslationOverviewAccess implements AccessInterface {
+class ContentTranslationOverviewAccess extends AccessBase {
 
   /**
    * The entity type manager.
@@ -71,4 +71,22 @@ public function access(Request $request, AccountInterface $account) {
 
     return static::DENY;
   }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the entity being translated.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/editor/editor.module b/core/modules/editor/editor.module
index 00ab94c..8ff97c3 100644
--- a/core/modules/editor/editor.module
+++ b/core/modules/editor/editor.module
@@ -61,7 +61,7 @@ function editor_menu_link_defaults_alter(array &$links) {
  */
 function editor_element_info() {
   $type['text_format'] = array(
-    '#pre_render' => array('editor_pre_render_format'),
+    '#pre_render' => array('element.editor:preRenderTextFormat'),
   );
   return $type;
 }
@@ -247,88 +247,6 @@ function editor_load($format_id) {
 }
 
 /**
- * Additional #pre_render callback for 'text_format' elements.
- */
-function editor_pre_render_format($element) {
-  // Allow modules to programmatically enforce no client-side editor by setting
-  // the #editor property to FALSE.
-  if (isset($element['#editor']) && !$element['#editor']) {
-    return $element;
-  }
-
-  // filter_process_format() copies properties to the expanded 'value' child
-  // element, including the #pre_render property. Skip this text format widget,
-  // if it contains no 'format'.
-  if (!isset($element['format'])) {
-    return $element;
-  }
-  $format_ids = array_keys($element['format']['format']['#options']);
-
-  // Early-return if no text editor is associated with any of the text formats.
-  $editors = entity_load_multiple('editor', $format_ids);
-  if (count($editors) === 0) {
-    return $element;
-  }
-
-  // Use a hidden element for a single text format.
-  $field_id = $element['value']['#id'];
-  if (!$element['format']['format']['#access']) {
-    // Use the first (and only) available text format.
-    $format_id = $format_ids[0];
-    $element['format']['editor'] = array(
-      '#type' => 'hidden',
-      '#name' => $element['format']['format']['#name'],
-      '#value' => $format_id,
-      '#attributes' => array(
-        'class' => array('editor'),
-        'data-editor-for' => $field_id,
-      ),
-    );
-  }
-  // Otherwise, attach to text format selector.
-  else {
-    $element['format']['format']['#attributes']['class'][] = 'editor';
-    $element['format']['format']['#attributes']['data-editor-for'] = $field_id;
-  }
-
-  // Hide the text format's filters' guidelines of those text formats that have
-  // a text editor associated: they're rather useless when using a text editor.
-  foreach ($editors as $format_id => $editor) {
-    $element['format']['guidelines'][$format_id]['#access'] = FALSE;
-  }
-
-  // Attach Text Editor module's (this module) library.
-  $element['#attached']['library'][] = 'editor/drupal.editor';
-
-  // Attach attachments for all available editors.
-  $manager = \Drupal::service('plugin.manager.editor');
-  $element['#attached'] = NestedArray::mergeDeep($element['#attached'], $manager->getAttachments($format_ids));
-
-  // Apply XSS filters when editing content if necessary. Some types of text
-  // editors cannot guarantee that the end user won't become a victim of XSS.
-  if (!empty($element['value']['#value'])) {
-    $original = $element['value']['#value'];
-    $format = entity_load('filter_format', $element['format']['format']['#value']);
-
-    // Ensure XSS-safety for the current text format/editor.
-    $filtered = editor_filter_xss($original, $format);
-    if ($filtered !== FALSE) {
-      $element['value']['#value'] = $filtered;
-    }
-
-    // Only when the user has access to multiple text formats, we must add data-
-    // attributes for the original value and change tracking, because they are
-    // only necessary when the end user can switch between text formats/editors.
-    if ($element['format']['format']['#access']) {
-      $element['value']['#attributes']['data-editor-value-is-changed'] = 'false';
-      $element['value']['#attributes']['data-editor-value-original'] = $original;
-    }
-  }
-
-  return $element;
-}
-
-/**
  * Applies text editor XSS filtering.
  *
  * @param string $html
diff --git a/core/modules/editor/editor.services.yml b/core/modules/editor/editor.services.yml
index b7acc7d..731215c 100644
--- a/core/modules/editor/editor.services.yml
+++ b/core/modules/editor/editor.services.yml
@@ -2,3 +2,6 @@ services:
   plugin.manager.editor:
     class: Drupal\editor\Plugin\EditorManager
     parent: default_plugin_manager
+  element.editor:
+    class: Drupal\editor\Element
+    arguments: ['@plugin.manager.editor']
diff --git a/core/modules/editor/src/Element.php b/core/modules/editor/src/Element.php
new file mode 100644
index 0000000..9942a54
--- /dev/null
+++ b/core/modules/editor/src/Element.php
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\editor\Element.
+ */
+
+namespace Drupal\editor;
+
+use Drupal\Core\Entity\EntityFormBuilderInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+
+use Drupal\editor\Entity\Editor;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\Component\Plugin\PluginManagerInterface;
+
+/**
+ * Defines a service for Text Editor's render elements.
+ */
+class Element {
+
+  /**
+   * The Text Editor plugin manager manager service.
+   *
+   * @var \Drupal\Component\Plugin\PluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * Constructs a new Element object.
+   *
+   * @param \Drupal\Component\Plugin\PluginManagerInterface $plugin_manager
+   *   The Text Editor plugin manager service.
+   */
+  public function __construct(PluginManagerInterface $plugin_manager) {
+    $this->pluginManager = $plugin_manager;
+  }
+
+  /**
+   * Additional #pre_render callback for 'text_format' elements.
+   */
+  function preRenderTextFormat(array $element) {
+    // Allow modules to programmatically enforce no client-side editor by
+    // setting the #editor property to FALSE.
+    if (isset($element['#editor']) && !$element['#editor']) {
+      return $element;
+    }
+
+    // filter_process_format() copies properties to the expanded 'value' child
+    // element, including the #pre_render property. Skip this text format
+    // widget, if it contains no 'format'.
+    if (!isset($element['format'])) {
+      return $element;
+    }
+    $format_ids = array_keys($element['format']['format']['#options']);
+
+    // Early-return if no text editor is associated with any of the text formats.
+    $editors = Editor::loadMultiple($format_ids);
+    if (count($editors) === 0) {
+      return $element;
+    }
+
+    // Use a hidden element for a single text format.
+    $field_id = $element['value']['#id'];
+    if (!$element['format']['format']['#access']) {
+      // Use the first (and only) available text format.
+      $format_id = $format_ids[0];
+      $element['format']['editor'] = array(
+        '#type' => 'hidden',
+        '#name' => $element['format']['format']['#name'],
+        '#value' => $format_id,
+        '#attributes' => array(
+          'class' => array('editor'),
+          'data-editor-for' => $field_id,
+        ),
+      );
+    }
+    // Otherwise, attach to text format selector.
+    else {
+      $element['format']['format']['#attributes']['class'][] = 'editor';
+      $element['format']['format']['#attributes']['data-editor-for'] = $field_id;
+    }
+
+    // Hide the text format's filters' guidelines of those text formats that have
+    // a text editor associated: they're rather useless when using a text editor.
+    foreach ($editors as $format_id => $editor) {
+      $element['format']['guidelines'][$format_id]['#access'] = FALSE;
+    }
+
+    // Attach Text Editor module's (this module) library.
+    $element['#attached']['library'][] = 'editor/drupal.editor';
+
+    // Attach attachments for all available editors.
+    $element['#attached'] = drupal_merge_attached($element['#attached'], $this->pluginManager->getAttachments($format_ids));
+
+    // Apply XSS filters when editing content if necessary. Some types of text
+    // editors cannot guarantee that the end user won't become a victim of XSS.
+    if (!empty($element['value']['#value'])) {
+      $original = $element['value']['#value'];
+      $format = FilterFormat::load($element['format']['format']['#value']);
+
+      // Ensure XSS-safety for the current text format/editor.
+      $filtered = editor_filter_xss($original, $format);
+      if ($filtered !== FALSE) {
+        $element['value']['#value'] = $filtered;
+      }
+
+      // Only when the user has access to multiple text formats, we must add data-
+      // attributes for the original value and change tracking, because they are
+      // only necessary when the end user can switch between text formats/editors.
+      if ($element['format']['format']['#access']) {
+        $element['value']['#attributes']['data-editor-value-is-changed'] = 'false';
+        $element['value']['#attributes']['data-editor-value-original'] = $original;
+      }
+    }
+
+    return $element;
+  }
+
+}
diff --git a/core/modules/field_ui/src/Access/FormModeAccessCheck.php b/core/modules/field_ui/src/Access/FormModeAccessCheck.php
index d35dd15..0d1a79a 100644
--- a/core/modules/field_ui/src/Access/FormModeAccessCheck.php
+++ b/core/modules/field_ui/src/Access/FormModeAccessCheck.php
@@ -8,7 +8,7 @@
 namespace Drupal\field_ui\Access;
 
 use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\Routing\Route;
 use Symfony\Component\HttpFoundation\Request;
@@ -18,7 +18,7 @@
  *
  * @see \Drupal\entity\Entity\EntityFormMode
  */
-class FormModeAccessCheck implements AccessInterface {
+class FormModeAccessCheck extends AccessBase {
 
   /**
    * The entity manager.
@@ -84,4 +84,18 @@ public function access(Route $route, Request $request, AccountInterface $account
     return static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user.roles');
+  }
+
 }
diff --git a/core/modules/field_ui/src/Access/ViewModeAccessCheck.php b/core/modules/field_ui/src/Access/ViewModeAccessCheck.php
index 8e93040..301350d 100644
--- a/core/modules/field_ui/src/Access/ViewModeAccessCheck.php
+++ b/core/modules/field_ui/src/Access/ViewModeAccessCheck.php
@@ -8,7 +8,7 @@
 namespace Drupal\field_ui\Access;
 
 use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\Routing\Route;
 use Symfony\Component\HttpFoundation\Request;
@@ -18,7 +18,7 @@
  *
  * @see \Drupal\entity\Entity\EntityViewMode
  */
-class ViewModeAccessCheck implements AccessInterface {
+class ViewModeAccessCheck extends AccessBase {
 
   /**
    * The entity manager.
@@ -84,4 +84,22 @@ public function access(Route $route, Request $request, AccountInterface $account
     return static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the entity being translated.
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user.roles');
+  }
+
+
 }
diff --git a/core/modules/menu_link/menu_link.api.php b/core/modules/menu_link/menu_link.api.php
index b4e8892..19fd3a3 100644
--- a/core/modules/menu_link/menu_link.api.php
+++ b/core/modules/menu_link/menu_link.api.php
@@ -31,7 +31,7 @@
  *
  * @see hook_menu_link_alter()
  */
-function hook_translated_menu_link_alter(\Drupal\menu_link\Entity\MenuLink &$menu_link, $map) {
+function hook_translated_menu_link_alter(\Drupal\menu_link\Entity\MenuLink &$menu_link) {
   if ($menu_link->href == 'devel/cache/clear') {
     $menu_link->localized_options['query'] = drupal_get_destination();
   }
diff --git a/core/modules/menu_link/menu_link.services.yml b/core/modules/menu_link/menu_link.services.yml
index 88f5037..3bb37e1 100644
--- a/core/modules/menu_link/menu_link.services.yml
+++ b/core/modules/menu_link/menu_link.services.yml
@@ -1,7 +1,18 @@
 services:
   menu_link.tree:
     class: Drupal\menu_link\MenuTree
-    arguments: ['@database', '@cache.data', '@language_manager', '@request_stack', '@entity.manager', '@entity.query', '@state']
+    arguments: ['@entity.manager', '@entity.query', '@current_user', '@controller_resolver', '@path.alias_manager']
+  menu_link.active_trail:
+    class: Drupal\menu_link\MenuActiveTrail
+    arguments: ['@request_stack']
+  menu_link.default_tree_manipulators:
+    class: Drupal\menu_link\DefaultMenuTreeManipulators
+    arguments: ['@access_manager', '@current_user', '@module_handler']
+  menu_link.cacheable_access_check_tree_manipulator:
+    class: Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator
+    arguments: ['@menu_link.tree', '@cache_contexts', '@cache.data', '@entity.manager', '@current_user', '@access_manager', '@router.route_provider', '@paramconverter_manager', '@request_stack']
+    calls:
+    - [setAccessCachingContexts, [['cache_context.user.roles']]]
   menu_link.static:
     class: Drupal\menu_link\StaticMenuLinks
     arguments: ['@module_handler']
diff --git a/core/modules/menu_link/src/CacheableAccessCheckMenuTreeManipulator.php b/core/modules/menu_link/src/CacheableAccessCheckMenuTreeManipulator.php
new file mode 100644
index 0000000..f9a7637
--- /dev/null
+++ b/core/modules/menu_link/src/CacheableAccessCheckMenuTreeManipulator.php
@@ -0,0 +1,641 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator.
+ */
+
+namespace Drupal\menu_link;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Utility\String;
+use Drupal\Core\Access\AccessManager;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Cache\CacheContexts;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\ParamConverter\ParamConverterManagerInterface;
+use Drupal\Core\Routing\RequestHelper;
+use Drupal\Core\Routing\RouteProviderInterface;
+use Drupal\Core\Session\AccountInterface;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\Routing\Exception\RouteNotFoundException;
+
+/**
+ * Provides a very advanced tree manipulator for optimal menu render caching.
+ *
+ * By default, this menu tree manipulator is configured to cache menu links
+ * access (::setAccessCachingContexts()) per user role.
+ *
+ * We can classify access checks as non-dynamic (hence cacheable, per role by
+ * default) or dynamic (needing to run on every page load, hence non-cacheable,
+ * because not cacheable per role — again, by default). This service exploits
+ * that property.
+ *
+ * We can then analyze the cacheability of the access checks in a given menu
+ * tree (::analyzeAccessCacheability()), and if there aren't any menu links with
+ * dynamic access checks, then the rendered menu is fully cacheable.
+ * In the other case, we calculate and cache the dynamic access check results
+ * per user (::getAccountDynamicAccess()). Those cached results are tagged with
+ * the @link cache cache tags @endlink associated with those dynamic access
+ * checks, to ensure that when the (cached) result might change, we run the
+ * dynamic access checks again.
+ * Now that we have deep insight in the cacheability of a given menu, the entire
+ * menu tree can be render cached.
+ *
+ * Using this access checking menu tree manipulator instead of the simpler yet
+ * uncacheable \Drupal\menu_link\DefaultMenuTreeManipulators::checkAccess(),
+ * the entire menu tree can be rendered in a way that performs well
+ * (\Drupal\menu_link\MenuTreeInterface::render()). Those menu links that have
+ * >=1 dynamic access checks are rendered as render cache placeholders.
+ * Then, on a page load with a warm cache, the rendered menu can be retrieved
+ * from the render cache, the dynamic access check results for the current user
+ * are retrieved from the cache once, and a #post_render_cache callback gets
+ * applied (\Drupal\menu_link\MenuTreeInterface::renderItemPlaceholder()) to
+ * each placeholder. That callback then has all the necessary information to
+ * decide whether to render, and to do the rendering itself, so it can occur
+ * very efficiently.
+ */
+class CacheableAccessCheckMenuTreeManipulator {
+
+  /**
+   * The menu tree service.
+   *
+   * @var \Drupal\menu_link\MenuTreeInterface
+   */
+  protected $menuTree;
+
+  /**
+   * The cache contexts service.
+   *
+   * @var \Drupal\Core\Cache\CacheContexts
+   */
+  protected $cacheContexts;
+
+  /**
+   * The cache backend.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $cache;
+
+  /**
+   * The menu entity storage.
+   *
+   * @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface
+   */
+  protected $menuStorage;
+
+  /**
+   * The user entity storage.
+   *
+   * @var \Drupal\user\UserStorageInterface
+   */
+  protected $userStorage;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The access manager.
+   *
+   * @var \Drupal\Core\Access\AccessManager
+   */
+  protected $accessManager;
+
+  /**
+   * The route provider.
+   *
+   * @var \Drupal\Core\Routing\RouteProviderInterface
+   */
+  protected $routeProvider;
+
+  /**
+   * The paramconverter.
+   *
+   * @var \Drupal\Core\ParamConverter\ParamConverterManagerInterface
+   */
+  protected $paramConverter;
+
+  /**
+   * The request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * Indicates access caching per which cache context is enabled.
+   *
+   * The empty array implies only globally cacheable access checks will be
+   * cached.
+   *
+   * @var string[]
+   */
+  protected $accessCachingContexts = array();
+
+  /**
+   * Constructs a new CacheableAccessCheckMenuTreeManipulator.
+   *
+   * @param \Drupal\menu_link\MenuTreeInterface $menu_tree
+   *   The menu tree service.
+   * @param \Drupal\Core\Cache\CacheContexts $cache_contexts
+   *   The cache contexts service.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   The cache backend.
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager.
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\Access\AccessManager $access_manager
+   *   The access check manager.
+   * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
+   *   The route provider.
+   * @param \Drupal\Core\ParamConverter\ParamConverterManagerInterface $paramconverter_manager
+   *   The param converter manager.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   *
+   * @codeCoverageIgnore
+   */
+  public function __construct(MenuTreeInterface $menu_tree, CacheContexts $cache_contexts, CacheBackendInterface $cache_backend, EntityManagerInterface $entity_manager, AccountInterface $current_user, AccessManager $access_manager, RouteProviderInterface $route_provider, ParamConverterManagerInterface $paramconverter_manager, RequestStack $request_stack) {
+    $this->menuTree = $menu_tree;
+    $this->cacheContexts = $cache_contexts;
+    $this->cache = $cache_backend;
+    $this->menuStorage = $entity_manager->getStorage('menu');
+    $this->userStorage = $entity_manager->getStorage('user');
+    $this->currentUser = $current_user;
+    $this->accessManager = $access_manager;
+    $this->routeProvider = $route_provider;
+    $this->paramConverter = $paramconverter_manager;
+    $this->requestStack = $request_stack;
+  }
+
+  /**
+   * Sets the cache contexts by which access check results should be cached.
+   *
+   * Many access check results are applicable to large sets of users. Each
+   * access check has metadata that indicates whether its results are cacheable,
+   * and if it's cacheable, that metadata also indicates which context it
+   * depends upon (e.g. user role, language, etc.).
+   *
+   * A rendered menu tree is highly dependent on access check results, because
+   * menu links whose target URL is not accessible to the user are omitted. To
+   * perform the required access checking on every page load is very slow and
+   * wasteful.
+   *
+   * Hence we want to partially cache rendered menu trees. When rendering a menu
+   * tree:
+   * - every menu link in the tree that has no uncacheable access checks can be
+   *   access-checked and rendered
+   * - every menu link in the tree that has at least one uncacheable access
+   *   check can be rendered into a placeholder
+   * The resulting rendered menu tree can be cached, and whenever it is
+   * displayed, we will only need to run the access checks for the menu links
+   * rendered into placeholders (i.e. those with an uncacheable access check).
+   *
+   * However, some access checks are cacheable by high-cardinality cache
+   * contexts, for example: cacheable per user. For sites with few users that
+   * might be okay, but for many sites that will be suboptimal.
+   * In other words: it depends on the characteristics of a given website which
+   * cache contexts access checks are cached by yields optimal results. Sites
+   * with few users may want to cache per user. Sites with many users may want
+   * to cache per user role. Complex sites may want to go further still: they
+   * may want to cache per user role and per user group membership (if some menu
+   * links are only accessible if the user is member of a certain group).
+   *
+   * This is why this method exists: it allows sites to specify by which cache
+   * contexts access checks of rendered menu trees should be cached.
+   *
+   * @param string[] $cache_contexts
+   *   The IDs of the cache context by which to vary cached access check results.
+   *
+   * @codeCoverageIgnore
+   */
+  public function setAccessCachingContexts(array $cache_contexts) {
+    $this->accessCachingContexts = $cache_contexts;
+  }
+
+  /**
+   * Gets the caching contexts this instance is caching access check results by.
+   *
+   * @return \string[]
+   *
+   * @codeCoverageIgnore
+   */
+  public function getAccessCachingContexts() {
+    return $this->accessCachingContexts;
+  }
+
+  /**
+   * Checks whether the account has access to the given menu link.
+   *
+   * @param string $menu_name
+   *   A menu name.
+   * @param string $link_path
+   *   The path of the menu link whose access check results to get.
+   * @param AccountInterface $account
+   *   An account.
+   *
+   * @return mixed
+   *   Whether the account has access to the menu link.
+   */
+  public function accountHasAccess($menu_name, $link_path, $account) {
+    $account_menu_access = $this->getAccountDynamicAccess($menu_name, $account);
+    return $account_menu_access[$link_path];
+  }
+
+  /**
+   * Menu tree manipulator that performs non-dynamic access checks.
+   *
+   * Those menu links that need a dynamic access check are marked as such, with
+   * a 'needs_dynamic_access_check' property.
+
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   */
+  public function checkNonDynamicAccess(array $tree) {
+    // Find all access checks whose results are cacheable per user role
+    // (or globally cacheable, because that means the access check's result
+    // doesn't depend on anything external to the access check itself).
+    static $cacheable_access_checks;
+    if (!isset($cacheable_access_checks)) {
+      $cacheable_access_checks = $this->accessManager->getCacheableAccessChecks();
+      $is_cacheable = function ($cache_contexts) {
+        // If the cache contexts for a given access check are a subset of the
+        // configured set of cache contexts we should cache access check results
+        // by, then this access check is cacheable.
+        $uncached_contexts = array_diff($cache_contexts, $this->accessCachingContexts);
+        return empty($uncached_contexts);
+      };
+      $cacheable_access_checks = array_keys(array_filter($cacheable_access_checks, $is_cacheable));
+    }
+
+    foreach ($tree as $key => $v) {
+      $item = &$tree[$key]->link;
+      if ($item['external'] || empty($item['route_name'])) {
+        $item['access'] = TRUE;
+      }
+      else {
+        if (!is_array($item['route_parameters'])) {
+          $item['route_parameters'] = (array) unserialize($item['route_parameters']);
+        }
+        try {
+          $route = $this->routeProvider->getRouteByName($item['route_name'], $item['route_parameters']);
+          $checks = $route->getOption('_access_checks') ?: array();
+          // If this route has any access checks whose results cannot be cached
+          // per role, then don't perform access checking here.
+          if (count(array_diff($checks, $cacheable_access_checks))) {
+            $item['needs_dynamic_access_check'] = 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess';
+          }
+          else {
+            $item['access'] = $this->accessManager->checkNamedRoute($item['route_name'], $item['route_parameters'], $this->currentUser);
+          }
+        }
+        catch (RouteNotFoundException $e) {
+          $item['access'] = FALSE;
+        }
+
+        // Remove menu items whose links are inaccessible.
+        if (array_key_exists('access', $item) && !$item['access']) {
+          unset($tree[$key]);
+          continue;
+        }
+      }
+
+      if ($tree[$key]->children) {
+        $tree[$key]->children = $this->checkNonDynamicAccess($tree[$key]->children);
+      }
+    }
+    return $tree;
+  }
+
+  /**
+   * Analyzes the links in a menu for cacheability of their access checks.
+   *
+   * Remember, a menu tree is determined by the menu tree parameters used to
+   * build it, and therefore the tree parameters also determine which links
+   * exist in a menu.
+   *
+   * Should have cached results, because this will be called on every page load.
+   *
+   * @param $menu_name
+   *   A menu name.
+   * @param MenuTreeParameters|null $parameters
+   *   (optional) Menu tree build parameters. By default, the entire menu tree
+   *   will be analyzed. When provided (i.e. when rendering some specialized
+   *   variant of the menu tree), they must contain the non-dynamic access
+   *   checking tree manipulator ('menu_link.default_tree_manipulators:checkNonDynamicAccess'),
+   *   since that's how it can determine the access check cacheability of a menu
+   *   tree.
+   *   When rendering e.g. only the first 2 levels of a 5-level menu tree, it is
+   *   essential to pass in the corresponding $parameters of that 2-level
+   *   rendition of the menu tree, otherwise the access cacheability analysis is
+   *   inapplicable.
+   *
+   * @return array|FALSE
+   *   If $parameters is provided but lacks the non-dynamic access checking tree
+   *   manipulator, FALSE will be returned.
+   *   In all other cases, an analysis will be performed, and an array
+   *   containing two values is returned.
+   *   - The first value contains the menu links needing dynamic access checks.
+   *     If this is the empty array, then zero menu links in the given menu need
+   *     dynamic access checks. In other words: this menu can easily be cached.
+   *     If it's a non-empty array, we do have menu links needing dynamic access
+   *     checks, with each key being a menu link path and the value being a tuple
+   *     containing the route name and route parameters.
+   *   - The second value is simply a boolean, indicating whether there are >0
+   *     links that don't need dynamic access checks. If there are, that
+   *     indicates this menu tree, when rendered, will definitely not be empty.
+   *
+   * @throws \LogicException
+   *   Thrown if the 'menu_link.default_tree_manipulators:checkNonDynamicAccess'
+   *   menu tree manipulator is not included in $parameters.
+   */
+  public function analyzeAccessCacheability($menu_name, MenuTreeParameters $parameters = NULL) {
+    // Build the cache ID.
+    $keys = array('menu', $menu_name, 'access_cacheability_analysis');
+    $keys = array_merge($keys, $this->accessCachingContexts);
+    $keys = $this->cacheContexts->convertTokensToKeys($keys);
+    $cid = implode(':', $keys);
+
+    // Validate the provided tree parameters. The non-dynamic access checking
+    // tree manipulator is required to perform the analysis. Return FALSE if it
+    // is missing.
+    if (!is_null($parameters)) {
+      $nondynamic_access_check_missing = TRUE;
+      foreach ($parameters->manipulators as $manipulator) {
+        if ($manipulator['callable'] === 'menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess') {
+          $nondynamic_access_check_missing = FALSE;
+          break;
+        }
+      }
+      if ($nondynamic_access_check_missing) {
+        throw new \LogicException(String::format('Cannot analyze cacheability of the @menu_name menu with custom menu tree parameters if the menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess tree manipulator is not included.', array('@menu_name' => $menu_name)));
+      }
+      $cid .= ':' . hash('sha256', serialize($parameters));
+    }
+    else {
+      // When no tree parameters are passed, we assume we must analyze the
+      // access cacheability of the entire menu tree, so we add but one tree
+      // manipulator: the non-dynamic access check one!
+      $parameters = new MenuTreeParameters();
+      $parameters->appendManipulator('menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess');
+    }
+
+    $cache = $this->cache->get($cid);
+    if ($cache) {
+      $access_cacheability_analysis = $cache->data;
+    }
+    else {
+      // Build the menu tree that must be analyzed.
+      $tree = $this->menuTree->build($menu_name, $parameters);
+
+      //
+      $emptiness_determining_dynamic_access_links = array();
+      $all_dynamic_access_links = array();
+      $has_visible_nondynamic_access_links = FALSE;
+      self::recursiveAnalyzer($tree, $emptiness_determining_dynamic_access_links, $all_dynamic_access_links, $has_visible_nondynamic_access_links);
+      $access_cacheability_analysis = array(
+        $emptiness_determining_dynamic_access_links,
+        $all_dynamic_access_links,
+        $has_visible_nondynamic_access_links,
+      );
+
+      // Cache the result. Since this applied to all non-hidden menu links, we
+      // can safely cache this for each menu, without needing to take other
+      // cache contexts into account. Whenever the menu changes (i.e. also
+      // whenever any of its links change), the cached result is invalidated,
+      // because the set of permissions may have changed as well.
+      $cache_tags = $this->menuStorage->load($menu_name)->getCacheTag() ;
+      $this->cache->set($cid, $access_cacheability_analysis, Cache::PERMANENT, $cache_tags);
+    }
+
+    return $access_cacheability_analysis;
+  }
+
+  /**
+   * Helper function for ::analyzeAccessCacheability().
+   */
+  protected static function recursiveAnalyzer($tree, &$emptiness_determining_dynamic_access_links, &$all_dynamic_access_links, &$has_visible_nondynamic_access_links, $ancestor_needs_dynamic_access_check = FALSE) {
+    foreach ($tree as $item) {
+      // No access checks needed on hidden items.
+      $link = $item->link;
+      if ($link->hidden) {
+        continue;
+      }
+
+      $link_is_accessible = isset($link->access) && $link->access;
+      $link_may_be_accessible = isset($link->needs_dynamic_access_check);
+
+      // We can only know for certain that a link will be visible if its parent
+      // is unconditionally visible.
+      if ($link_is_accessible && !$ancestor_needs_dynamic_access_check) {
+        $has_visible_nondynamic_access_links = TRUE;
+      }
+      elseif ($link_may_be_accessible) {
+        $all_dynamic_access_links[$link->link_path] = array($link->route_name, $link->route_parameters);
+        // To determine the emptiness of a menu tree for a given user, we don't
+        // need to look at dynamic access menu links that are descendants of
+        // other dynamic access menu links: if an ancestor is visible, we
+        // already know that the menu tree will be non-empty.
+        // On the other hand, this distinction is vital, because if a dynamic
+        // access menu link would be accessible for a user but an ancestor
+        // dynamic access menu link wouldn't be, we would incorrectly conclude
+        // that the menu will be non-empty — children of inaccessible parent
+        // links are never rendered!
+        if (!$ancestor_needs_dynamic_access_check) {
+          $emptiness_determining_dynamic_access_links[] = $link->link_path;
+        }
+      }
+
+      // Recurse for analyzing the children, but only if their parent link is or
+      // may be accessible.
+      if ($item->children && ($link_is_accessible || $link_may_be_accessible)) {
+        $ancestor_needs_dynamic_access_check = $ancestor_needs_dynamic_access_check || isset($link->needs_dynamic_access_check);
+        self::recursiveAnalyzer($item->children, $emptiness_determining_dynamic_access_links, $all_dynamic_access_links, $has_visible_nondynamic_access_links, $ancestor_needs_dynamic_access_check);
+      }
+    }
+  }
+
+  /**
+   * Gets the dynamic access check result for the given menu link for the current user.
+   *
+   * Should have statically cached results, because if there are N links with
+   * dynamic access checks in the given menu, this will be called N+0 or N+1
+   * times. It will be called N times by #post_render_cache callbacks and
+   * potentially once more to determine the visibility of this menu for the
+   * given account.
+   *
+   * Uses the results of ::analyzeAccessCacheability($menu_name) to calculate
+   * (and cache) the dynamic access check results for this user for *all* menu
+   * links in the given menu. It's okay to have a superset of dynamic access
+   * check results — that's still usable when rendering a subset of a menu tree.
+   *
+   * It associates the cache tags of any route parameters that after enhancing
+   * happen to be entities — so that when those entities are modified, the
+   * dynamic access checks are run again, to ensure correctness.
+   *
+   * @param string $menu_name
+   *   A menu name.
+   * @param AccountInterface $account
+   *   An account.
+   *
+   * @return array
+   *   An array of dynamic access check results for the given account, for all
+   *   dynamic menu links in the given menu with dynamic access checks, keyed by
+   *   menu link paths, and the corresponding values being tuples of the
+   *   following form:
+   *   1. Whether the account has access to the menu link, to prevent running
+   *      its dynamic access check on every page load.
+   *   2. The source path for the menu link, to prevent a path alias lookup
+   *      during the rendering of this menu link on every page load.
+   *
+   * @see ::analyzeAccessCacheability()
+   */
+  public function getAccountDynamicAccess($menu_name, AccountInterface $account) {
+    static $account_access = array();
+    $key = $menu_name . $account->id();
+    if (!isset($account_access[$key])) {
+      $cid = implode(':', array('menu', $menu_name, 'account_access_results', $account->id()));
+      $cache = $this->cache->get($cid);
+      if ($cache) {
+        $account_access[$key] = $cache->data;
+      }
+      else {
+        $account_access[$key] = $this->doGetAccountDynamicAccess($menu_name, $account);
+      }
+    }
+    return $account_access[$key];
+  }
+
+  /**
+   * Helper for ::getAccountDynamicAccess().
+   *
+   * @param string $menu_name
+   *   A menu name.
+   * @param AccountInterface $account
+   *   An account.
+   *
+   * @return array
+   *   An array of dynamic access check results for the given account, for all
+   *   dynamic menu links in the given menu with dynamic access checks, keyed by
+   *   menu link paths, and the corresponding values indicating whether the
+   *   account has access to the menu link, to prevent running its dynamic
+   *   access check on every page load.
+   *
+   * @see ::getAccountDynamicAccess()
+   */
+  protected function doGetAccountDynamicAccess($menu_name, AccountInterface $account) {
+    $analysis = $this->analyzeAccessCacheability($menu_name);
+    $all_dynamic_access_links = $analysis[1];
+
+    $all_cache_tags = array(
+      $this->menuStorage->load($menu_name)->getCacheTag(),
+      $account->isAnonymous() ? array() : $this->userStorage->load($account->id())->getCacheTag(),
+    );
+    foreach ($all_dynamic_access_links as $link_path => $link) {
+      list($route_name, $route_parameters) = $link;
+      try {
+        $route = $this->routeProvider->getRouteByName($route_name, $route_parameters);
+
+        // Create a request and copy the account from the current request.
+        $defaults = $route_parameters + $route->getDefaults();
+        $defaults[RouteObjectInterface::ROUTE_OBJECT] = $route;
+        $route_request = RequestHelper::duplicate($this->requestStack->getCurrentRequest(), $link_path);
+        $enhanced_parameters = $this->paramConverter->convert($defaults, $route_request);
+        $route_request->attributes->add($enhanced_parameters);
+
+        // Determine access check result for the given user.
+        $user_access[$link_path] = $this->accessManager->check($route, $route_request, $account);;
+
+        // Collect cache tags that the access check result depends upon.
+        foreach ($enhanced_parameters as $parameter) {
+          if (is_object($parameter) && $parameter instanceof EntityInterface) {
+            $all_cache_tags[] = $parameter->getCacheTag();
+          }
+        }
+      }
+      catch (RouteNotFoundException $e) {
+        // We can't generate cache tags for route parameters if the route
+        // doesn't actually exist! But if the route doesn't exist, the menu
+        // link should not be shown, so indicate the user has no access.
+        $user_access[$link_path] = FALSE;
+      }
+    }
+
+    $user_access_cid = implode(':', array('menu', $menu_name, 'account_access_results', $account->id()));
+    $this->cache->set($user_access_cid, $user_access, Cache::PERMANENT, NestedArray::mergeDeepArray($all_cache_tags));
+
+    return $user_access;
+  }
+
+  /**
+   * Determines whether the menu will be empty for the given account.
+   *
+   * This is crucial for efficient rendering of menus: we don't ever want to
+   * show an empty menu to the user (in that case we want to hide it), but
+   * whether the menu will be empty or not for the user, may depend on the
+   * results of dynamic access checks. Without this function, we'd know too late
+   * whether the menu will be empty or not.
+   *
+   * Remember, a menu tree is determined by the menu tree parameters used to
+   * build it, and therefore the tree parameters also determine which links
+   * exist in a menu. So we must take the menu tree parameters into account.
+   *
+   * @param string $menu_name
+   *   A menu name.
+   * @param AccountInterface $account
+   *   An account.
+   * @param MenuTreeParameters|null $parameters
+   *   Passed to ::analyzeAccessCacheability().
+   * @return bool
+   *   Boolean indicating whether the given menu tree will be empty for the
+   *   given account.
+   *
+   * @see ::analyzeAccessCacheability()
+   * @see ::getAccountDynamicAccess()
+   */
+  public function willBeEmptyForAccount($menu_name, AccountInterface $account, MenuTreeParameters $parameters = NULL) {
+    $analysis = $this->analyzeAccessCacheability($menu_name, $parameters);
+    $emptiness_determining_dynamic_access_links = $analysis[0];
+    $has_visible_nondynamic_access_links = $analysis[2];
+
+    // There are >0 links visible based on non-dynamic access checks alone.
+    if ($has_visible_nondynamic_access_links) {
+      return FALSE;
+    }
+    // Each link either has non-dynamic access checks or dynamic access checks.
+    // In case there are 0 visible non-dynamic access check links (i.e. the if-
+    // statement above evaluated to FALSE), and there are 0 dynamic access check
+    // links, then the menu will be empty for the given account.
+    else if (empty($emptiness_determining_dynamic_access_links)) {
+      return TRUE;
+    }
+
+    // If >0 links that have >0 dynamic access checks allows the given account
+    // access, then this rendered menu tree will not be empty. Thanks to
+    // ::getAccountDynamicAccess(), we have cached dynamic access check results
+    // for the given account, hence we can calculate this very efficiently.
+    $user_access = $this->getAccountDynamicAccess($menu_name, $account);
+    foreach ($emptiness_determining_dynamic_access_links as $link_path) {
+      if ($user_access[$link_path]) {
+        return FALSE;
+      }
+    }
+
+    // 0 links that have >0 dynamic access checks allow the given account access
+    // then this rendered menu tree will be empty.
+    return TRUE;
+  }
+
+}
diff --git a/core/modules/menu_link/src/DefaultMenuTreeManipulators.php b/core/modules/menu_link/src/DefaultMenuTreeManipulators.php
new file mode 100644
index 0000000..12f2d7f
--- /dev/null
+++ b/core/modules/menu_link/src/DefaultMenuTreeManipulators.php
@@ -0,0 +1,326 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\DefaultMenuTreeManipulators.
+ */
+
+namespace Drupal\menu_link;
+
+use Drupal\Core\Access\AccessManager;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Provides default menu tree manipulators.
+ */
+class DefaultMenuTreeManipulators {
+  use StringTranslationTrait;
+
+  /**
+   * The access manager.
+   *
+   * @var \Drupal\Core\Access\AccessManager
+   */
+  protected $accessManager;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * Constructs a new DefaultMenuTreeManipulators object.
+   *
+   * @param \Drupal\Core\Access\AccessManager $access_manager
+   *   The access check manager.
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   *
+   * @codeCoverageIgnore
+   */
+  public function __construct(AccessManager $access_manager, AccountInterface $current_user, ModuleHandlerInterface $module_handler) {
+    $this->accessManager = $access_manager;
+    $this->currentUser = $current_user;
+    $this->moduleHandler = $module_handler;
+  }
+
+  /**
+   * Tree manipulator that translates menu link titles.
+   *
+   * Sets the 'title' property, based on the existing 'link_title' property.
+   *
+   * Translate the title and description to allow storage of English title
+   * strings in the database, yet display of them in the language required by
+   * the current user.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   */
+  public function translate(array $tree) {
+    foreach ($tree as $key => $v) {
+      $item = &$tree[$key]->link;
+
+      $item['title'] = $item['link_title'];
+      if (!is_array($item['options'])) {
+        $item['options'] = (array) unserialize($item['options']);
+      }
+
+      // Allow default menu links to be translated.
+      $item['localized_options'] = $item['options'];
+
+      if (!$item['external'] && !empty($item['route_name'])) {
+        // All 'class' attributes are assumed to be an array during rendering, but
+        // links stored in the database may use an old string value.
+        // @todo In order to remove this code we need to implement a database update
+        //   including unserializing all existing link options and running this code
+        //   on them, as well as adding validation to menu_link_save().
+        if (isset($item['options']['attributes']['class']) && is_string($item['options']['attributes']['class'])) {
+          $item['localized_options']['attributes']['class'] = explode(' ', $item['options']['attributes']['class']);
+        }
+        // If the menu link is defined in code and not customized, we can use t().
+        if (!empty($item['machine_name']) && !$item['customized']) {
+          // @todo Figure out a proper way to support translations of menu links, see
+          //   https://drupal.org/node/2193777.
+          $item['title'] = $this->t($item['link_title']);
+        }
+        else {
+          $item['title'] = $item['link_title'];
+        }
+
+        // Allow other customizations - e.g. adding a page-specific query string to the
+        // options array. For performance reasons we only invoke this hook if the link
+        // has the 'alter' flag set in the options array.
+        if (!empty($item['options']['alter'])) {
+          $this->moduleHandler->alter('translated_menu_link', $item);
+        }
+      }
+
+      if ($tree[$key]->children) {
+        $tree[$key]->children = $this->translate($tree[$key]->children);
+      }
+    }
+    return $tree;
+  }
+
+  /**
+   * Tree manipulator that generates a unique index, and sorts by it.
+   *
+   * Note that this requires the ::translate() tree manipulator to have run
+   * before it.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   */
+  public function generateIndexAndSort(array $tree) {
+    $new_tree = array();
+    foreach ($tree as $key => $v) {
+      $item = $tree[$key]->link;
+      if ($tree[$key]->children) {
+        $tree[$key]->children = $this->generateIndexAndSort($tree[$key]->children);
+      }
+      // The weights are made a uniform 5 digits by adding 50000 as an offset.
+      // After ::translate(), $item['title'] has the localized link title.
+      // Adding the mlid to the end of the index insures that it is unique.
+      $new_tree[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['mlid']] = $tree[$key];
+    }
+    // Sort siblings in the tree based on the weights and localized titles.
+    ksort($new_tree);
+    return $new_tree;
+  }
+
+  /**
+   * Tree manipulator that sets a 'href' property on external URL menu links.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   *
+   * @todo remove the empty($item['route_name']) check, because everything has
+   *   been converted to the routing system?
+   */
+  public function setHrefPropertyIfExternal(array $tree) {
+    foreach ($tree as $key => $v) {
+      $item = &$tree[$key]->link;
+
+      if ($item['external'] || empty($item['route_name'])) {
+        $item['href'] = $item['link_path'];
+        $item['route_parameters'] = array();
+        // Set to NULL so that drupal_pre_render_link() is certain to skip it.
+        $item['route_name'] = NULL;
+      }
+      else {
+        $item['href'] = NULL;
+      }
+
+      if ($tree[$key]->children) {
+        $tree[$key]->children = $this->setHrefPropertyIfExternal($tree[$key]->children);
+      }
+    }
+
+    return $tree;
+  }
+
+  /**
+   * Tree manipulator that performs access checks.
+   *
+   * Removes menu items from the given menu tree whose links are inaccessible
+   * for the current user, sets the 'access' property to TRUE on menu links that
+   * are accessible for the current user.
+   *
+   * Makes the resulting menu tree impossible to render cache, unless render
+   * caching per user is acceptable.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   */
+  public function checkAccess(array $tree) {
+    foreach ($tree as $key => $v) {
+      $item = &$tree[$key]->link;
+      if ($item['external'] || empty($item['route_name'])) {
+        $item['access'] = TRUE;
+      }
+      else {
+        if (!is_array($item['route_parameters'])) {
+          $item['route_parameters'] = (array) unserialize($item['route_parameters']);
+        }
+        // Other menu tree manipulators may already have calculated access, do
+        // not overwrite the existing value in that case.
+        if (!isset($item['access'])) {
+          $item['access'] = $this->accessManager->checkNamedRoute($item['route_name'], $item['route_parameters'], $this->currentUser);
+        }
+        // Remove menu items whose links are inaccessible.
+        if (!$item['access']) {
+          unset($tree[$key]);
+          continue;
+        }
+      }
+      if ($tree[$key]->children) {
+        $tree[$key]->children = $this->checkAccess($tree[$key]->children);
+      }
+    }
+    return $tree;
+  }
+
+  /**
+   * Tree manipulator that extracts the sets a class on each tree item's LI element.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   * @param int $level
+   *   The level in the active trail to extract.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   */
+  public function extractSubtreeOfActiveTrail($tree, $level) {
+    // Go down the active trail until the right level is reached.
+    while ($level-- > 0 && $tree) {
+      // Loop through the current level's items until we find one that is in the
+      // active trail.
+      while ($item = array_shift($tree)) {
+        if ($item->link['in_active_trail']) {
+          // If the item is in the active trail, we continue in the subtree.
+          $tree = empty($item->children) ? array() : $item->children;
+          break;
+        }
+      }
+    }
+    return $tree;
+  }
+
+  /**
+   * Tree manipulator that sets a class on each tree item's LI element.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   */
+  public function setTreeItemClass($tree) {
+    // Generate a unique identifier for each LI attribute in the menu tree.
+    foreach ($tree as $key => $item) {
+      $li_class = 'menu-' . $item->link['mlid'];
+      if ($item->link['in_active_trail']) {
+        $li_class .= '-active-trail';
+      }
+      $item->link['li_attributes']['class'][] = $li_class;
+      $tree[$key] = $item;
+      if ($tree[$key]->children) {
+        $tree[$key]->children = $this->setTreeItemClass($tree[$key]->children);
+      }
+    }
+    return $tree;
+  }
+
+  /**
+   * Tree manipulator that flattens the tree to a single level.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   */
+  public function flatten($tree) {
+    foreach ($tree as $key => $item) {
+      if ($tree[$key]->children) {
+        $tree[$key]->children = $this->flatten($tree[$key]->children);
+        foreach ($tree[$key]->children as $child_key => $child_item) {
+          $tree[$child_key] = $child_item;
+        }
+      }
+    }
+    return $tree;
+  }
+
+  /**
+   * Moves links' localized 'title' attribute to a 'description' property.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem[] $tree
+   *   The menu tree to manipulate.
+   *
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   The manipulated menu tree.
+   */
+  public function setLinkTitleAsDescription($tree) {
+    foreach ($tree as $key => $item) {
+      $link = &$tree[$key]->link;
+
+      if (!empty($link['localized_options']['attributes']['title'])) {
+        $link['description'] = $link['localized_options']['attributes']['title'];
+        unset($link['localized_options']['attributes']['title']);
+      }
+
+      if ($tree[$key]->children) {
+        $tree[$key]->children = $this->setLinkTitleAsDescription($tree[$key]->children);
+      }
+    }
+    return $tree;
+  }
+
+}
diff --git a/core/modules/menu_link/src/MenuActiveTrail.php b/core/modules/menu_link/src/MenuActiveTrail.php
new file mode 100644
index 0000000..4a863ff
--- /dev/null
+++ b/core/modules/menu_link/src/MenuActiveTrail.php
@@ -0,0 +1,109 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\MenuTree.
+ */
+
+namespace Drupal\menu_link;
+
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Provides the default implementation of a menu tree.
+ */
+class MenuActiveTrail implements MenuActiveTrailInterface {
+
+  /**
+   * The request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * A list of active trail paths keyed by $menu_name.
+   *
+   * @var array
+   */
+  protected $trailPaths;
+
+  /**
+   * Constructs a new MenuActiveTrail.
+   *
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   *
+   * @codeCoverageIgnore
+   */
+  public function __construct(RequestStack $request_stack) {
+    $this->requestStack = $request_stack;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getActiveTrailIds($menu_name) {
+    // Parent mlids; used both as key and value to ensure uniqueness.
+    // We always want all the top-level links with plid == 0.
+    $active_trail = array(0 => 0);
+
+    $request = $this->requestStack->getCurrentRequest();
+
+    if ($route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME)) {
+      // @todo https://drupal.org/node/2068471 is adding support so we can tell
+      // if this is called on a 404/403 page.
+      // Check if the active trail has been overridden for this menu tree.
+      $active_path = $this->getPath($menu_name);
+      // Find a menu link corresponding to the current path. If
+      // $active_path is NULL, let menu_link_get_preferred() determine
+      // the path.
+      if ($active_link = $this->menuLinkGetPreferred($menu_name, $active_path)) {
+        if ($active_link['menu_name'] == $menu_name) {
+          // Use all the coordinates, except the last one because
+          // there can be no child beyond the last column.
+          for ($i = 1; $i < MENU_MAX_DEPTH; $i++) {
+            if ($active_link['p' . $i]) {
+              $active_trail[$active_link['p' . $i]] = $active_link['p' . $i];
+            }
+          }
+        }
+      }
+    }
+    return $active_trail;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getActiveTrailCacheKey($menu_name) {
+    return 'menu_trail.' . implode('|', $this->getActiveTrailIds($menu_name));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setPath($menu_name, $path = NULL) {
+    if (isset($path)) {
+      $this->trailPaths[$menu_name] = $path;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPath($menu_name) {
+    return isset($this->trailPaths[$menu_name]) ? $this->trailPaths[$menu_name] : NULL;
+  }
+
+  /**
+   * Wraps menu_link_get_preferred().
+   *
+   * @codeCoverageIgnore
+   */
+  protected function menuLinkGetPreferred($menu_name, $active_path) {
+    return menu_link_get_preferred($active_path, $menu_name);
+  }
+
+}
diff --git a/core/modules/menu_link/src/MenuActiveTrailInterface.php b/core/modules/menu_link/src/MenuActiveTrailInterface.php
new file mode 100644
index 0000000..f8089b9
--- /dev/null
+++ b/core/modules/menu_link/src/MenuActiveTrailInterface.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\MenuActiveTrailInterface.
+ */
+
+namespace Drupal\menu_link;
+
+/**
+ * Defines an interface for the active menu trail service.
+ *
+ * The active trail of a given menu is the trail from the current page to the
+ * root of that menu's tree.
+ */
+interface MenuActiveTrailInterface {
+
+  /**
+   * Sets the path for determining the active trail of the specified menu tree.
+   *
+   * This path will also affect the breadcrumbs under some circumstances.
+   * Breadcrumbs are built using the preferred link returned by
+   * menu_link_get_preferred(). If the preferred link is inside one of the menus
+   * specified in calls to static::setPath(), the preferred link will be
+   * overridden by the corresponding path returned by static::getPath().
+   *
+   * @param string $menu_name
+   *   The name of the affected menu tree.
+   * @param string $path
+   *   The path to use when finding the active trail.
+   */
+  public function setPath($menu_name, $path = NULL);
+
+  /**
+   * Gets the path for determining the active trail of the specified menu tree.
+   *
+   * @param string $menu_name
+   *   The menu name of the requested tree.
+   *
+   * @return string
+   *   A string containing the path. If no path has been specified with
+   *   static::setPath(), NULL is returned.
+   */
+  public function getPath($menu_name);
+
+  /**
+   * Gets the active trail IDs of the specified menu tree.
+   *
+   * @param string $menu_name
+   *   The menu name of the requested tree.
+   *
+   * @return int[]
+   *   An array containing the active trail: a list of mlids.
+   */
+  public function getActiveTrailIds($menu_name);
+
+  /**
+   * Gets the active trail cache key of the specified menu tree.
+   *
+   * @param string $menu_name
+   *   The menu name of the requested tree.
+   *
+   * @return string
+   *   The cache key that uniquely identifies the active trail of the menu tree.
+   */
+  public function getActiveTrailCacheKey($menu_name);
+
+}
diff --git a/core/modules/menu_link/src/MenuLinkForm.php b/core/modules/menu_link/src/MenuLinkForm.php
index 6eee313..ac08ff1 100644
--- a/core/modules/menu_link/src/MenuLinkForm.php
+++ b/core/modules/menu_link/src/MenuLinkForm.php
@@ -41,6 +41,13 @@ class MenuLinkForm extends EntityForm {
   protected $urlGenerator;
 
   /**
+   * The default menu tree manipulators service.
+   *
+   * @var \Drupal\menu_link\DefaultMenuTreeManipulators
+   */
+  protected $defaultMenuTreeManipulators;
+
+  /**
    * Constructs a new MenuLinkForm object.
    *
    * @param \Drupal\menu_link\MenuLinkStorageInterface $menu_link_storage
@@ -49,11 +56,13 @@ class MenuLinkForm extends EntityForm {
    *   The path alias manager.
    * @param \Drupal\Core\Routing\UrlGenerator $url_generator
    *   The URL generator.
+   * @param \Drupal\menu_link\DefaultMenuTreeManipulators $default_manipulators
    */
-  public function __construct(MenuLinkStorageInterface $menu_link_storage, AliasManagerInterface $path_alias_manager, UrlGenerator $url_generator) {
+  public function __construct(MenuLinkStorageInterface $menu_link_storage, AliasManagerInterface $path_alias_manager, UrlGenerator $url_generator, DefaultMenuTreeManipulators $default_manipulators) {
     $this->menuLinkStorage = $menu_link_storage;
     $this->pathAliasManager = $path_alias_manager;
     $this->urlGenerator = $url_generator;
+    $this->defaultMenuTreeManipulators = $default_manipulators;
   }
 
   /**
@@ -63,7 +72,8 @@ public static function create(ContainerInterface $container) {
     return new static(
       $container->get('entity.manager')->getStorage('menu_link'),
       $container->get('path.alias_manager'),
-      $container->get('url_generator')
+      $container->get('url_generator'),
+      $container->get('menu_link.default_tree_manipulators')
     );
   }
 
@@ -73,8 +83,15 @@ public static function create(ContainerInterface $container) {
   public function form(array $form, array &$form_state) {
     $menu_link = $this->entity;
     // Since menu_link_load() no longer returns a translated and access checked
-    // item, do it here instead.
-    _menu_link_translate($menu_link);
+    // item, do it here instead. Since translation and access checking are only
+    // necessary for rendering, we must pretend to render a tree of menu links,
+    // so we can apply the translation and access checking tree manipulators.
+    $item = new MenuTreeItem($menu_link, array());
+    $tree = array($item);
+    $tree = $this->defaultMenuTreeManipulators->translate($tree);
+    $tree = $this->defaultMenuTreeManipulators->setHrefPropertyIfExternal($tree);
+    $tree = $this->defaultMenuTreeManipulators->checkAccess($tree);
+    $menu_link = $tree[0]->link;
 
     $form['link_title'] = array(
       '#type' => 'textfield',
diff --git a/core/modules/menu_link/src/MenuLinkStorage.php b/core/modules/menu_link/src/MenuLinkStorage.php
index 60871d0..7d0b8b8 100644
--- a/core/modules/menu_link/src/MenuLinkStorage.php
+++ b/core/modules/menu_link/src/MenuLinkStorage.php
@@ -149,29 +149,6 @@ public function loadUpdatedCustomized(array $router_paths) {
   /**
    * {@inheritdoc}
    */
-  public function loadModuleAdminTasks() {
-    // @todo - this code will move out of the menu link entity, so we are doing
-    //   a straight SQL query for expediency.
-    $result = $this->database->select('menu_links');
-    $result->condition('machine_name', 'system.admin');
-    $result->addField('menu_links', 'mlid');
-    $plid = $result->execute()->fetchField();
-
-    $query = $this->database->select('menu_links', 'base', array('fetch' => \PDO::FETCH_ASSOC));
-    $query->fields('base');
-    $query
-      ->condition('base.hidden', 0, '>=')
-      ->condition('base.module', '', '>')
-      ->condition('base.machine_name', '', '>')
-      ->condition('base.p1', $plid);
-    $entities = $query->execute()->fetchAll();
-
-    return $entities;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
   public function updateParentalStatus(EntityInterface $entity, $exclude = FALSE) {
     // If plid == 0, there is nothing to update.
     if ($entity->plid) {
diff --git a/core/modules/menu_link/src/MenuLinkStorageInterface.php b/core/modules/menu_link/src/MenuLinkStorageInterface.php
index 3d3fd5d..f160fe1 100644
--- a/core/modules/menu_link/src/MenuLinkStorageInterface.php
+++ b/core/modules/menu_link/src/MenuLinkStorageInterface.php
@@ -34,14 +34,6 @@ public function setPreventReparenting($value = FALSE);
   public function getPreventReparenting();
 
   /**
-   * Loads system menu link as needed by system_get_module_admin_tasks().
-   *
-   * @return array
-   *   An array of menu link entities indexed by their IDs.
-   */
-  public function loadModuleAdminTasks();
-
-  /**
    * Checks and updates the 'has_children' property for the parent of a link.
    *
    * @param \Drupal\Core\Entity\EntityInterface $entity
diff --git a/core/modules/menu_link/src/MenuTree.php b/core/modules/menu_link/src/MenuTree.php
index adbf85d..491c307 100644
--- a/core/modules/menu_link/src/MenuTree.php
+++ b/core/modules/menu_link/src/MenuTree.php
@@ -7,15 +7,11 @@
 
 namespace Drupal\menu_link;
 
-use Drupal\Core\Cache\Cache;
-use Drupal\Core\Cache\CacheBackendInterface;
-use Drupal\Core\Database\Connection;
+use Drupal\Core\Controller\ControllerResolverInterface;
 use Drupal\Core\Entity\EntityManagerInterface;
 use Drupal\Core\Entity\Query\QueryFactory;
-use Drupal\Core\State\StateInterface;
-use Drupal\Core\Language\LanguageManagerInterface;
-use Symfony\Cmf\Component\Routing\RouteObjectInterface;
-use Symfony\Component\HttpFoundation\RequestStack;
+use Drupal\Core\Path\AliasManagerInterface;
+use Drupal\Core\Session\AccountInterface;
 
 /**
  * Provides the default implementation of a menu tree.
@@ -23,36 +19,7 @@
 class MenuTree implements MenuTreeInterface {
 
   /**
-   * The database connection.
-   *
-   * @var \Drupal\Core\Database\Connection
-   *   The database connection.
-   */
-  protected $database;
-
-  /**
-   * The cache backend.
-   *
-   * @var \Drupal\Core\Cache\CacheBackendInterface
-   */
-  protected $cache;
-
-  /**
-   * The language manager.
-   *
-   * @var \Drupal\Core\Language\LanguageManagerInterface
-   */
-  protected $languageManager;
-
-  /**
-   * The request stack.
-   *
-   * @var \Symfony\Component\HttpFoundation\RequestStack
-   */
-  protected $requestStack;
-
-  /**
-   * The menu link storage.
+   * The menu link entity storage.
    *
    * @var \Drupal\menu_link\MenuLinkStorageInterface
    */
@@ -66,492 +33,346 @@ class MenuTree implements MenuTreeInterface {
   protected $queryFactory;
 
   /**
-   * The state.
-   *
-   * @var \Drupal\Core\State\StateInterface
-   */
-  protected $state;
-
-  /**
-   * A list of active trail paths keyed by $menu_name.
+   * The current user.
    *
-   * @var array
+   * @var \Drupal\Core\Session\AccountInterface
    */
-  protected $trailPaths;
+  protected $currentUser;
 
   /**
-   * Stores the rendered menu output keyed by $menu_name.
+   * The controller resolver.
    *
-   * @var array
+   * @var \Drupal\Core\Controller\ControllerResolverInterface
    */
-  protected $menuOutput;
+  protected $controllerResolver;
 
   /**
-   * Stores the menu tree used by the doBuildTree method, keyed by a cache ID.
+   * An alias manager for looking up the system path.
    *
-   * This cache ID is built using the $menu_name, the current language and
-   * some parameters passed into an entity query.
+   * @var \Drupal\Core\Path\AliasManagerInterface
    */
-  protected $menuTree;
-
-  /**
-   * Stores the full menu tree data keyed by a cache ID.
-   *
-   * This variable distinct from static::$menuTree by having also items without
-   * access by the current user.
-   *
-   * This cache ID is built with the menu name, a passed in root link ID, the
-   * current language as well as the maximum depth.
-   *
-   * @var array
-   */
-  protected $menuFullTrees;
-
-  /**
-   * Stores the menu tree data on the current page keyed by a cache ID.
-   *
-   * This contains less information than a tree built with buildAllData.
-   *
-   * @var array
-   */
-  protected $menuPageTrees;
+  protected $aliasManager;
 
   /**
    * Constructs a new MenuTree.
    *
-   * @param \Drupal\Core\Database\Connection $database
-   *   The database connection.
-   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
-   *   The cache backend.
-   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
-   *   The language manager.
-   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
-   *   The request stack.
    * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
    *   The entity manager.
    * @param \Drupal\Core\Entity\Query\QueryFactory $entity_query_factory
    *   The entity query factory.
-   * @param \Drupal\Core\State\StateInterface $state
-   *   The state.
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver
+   *   The controller resolver.
+   * @param \Drupal\Core\Path\AliasManagerInterface $alias_manager
+   *   An alias manager for looking up the system path.
+   *
+   * @codeCoverageIgnore
    */
-  public function __construct(Connection $database, CacheBackendInterface $cache_backend, LanguageManagerInterface $language_manager, RequestStack $request_stack, EntityManagerInterface $entity_manager, QueryFactory $entity_query_factory, StateInterface $state) {
-    $this->database = $database;
-    $this->cache = $cache_backend;
-    $this->languageManager = $language_manager;
-    $this->requestStack = $request_stack;
+  public function __construct(EntityManagerInterface $entity_manager, QueryFactory $entity_query_factory, AccountInterface $current_user, ControllerResolverInterface $controller_resolver, AliasManagerInterface $alias_manager) {
     $this->menuLinkStorage = $entity_manager->getStorage('menu_link');
     $this->queryFactory = $entity_query_factory;
-    $this->state = $state;
+    $this->currentUser = $current_user;
+    $this->controllerResolver = $controller_resolver;
+    $this->aliasManager = $alias_manager;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function buildAllData($menu_name, $link = NULL, $max_depth = NULL) {
-    $language_interface = $this->languageManager->getCurrentLanguage();
-
-    // Use $mlid as a flag for whether the data being loaded is for the whole
-    // tree.
-    $mlid = isset($link['mlid']) ? $link['mlid'] : 0;
-    // Generate a cache ID (cid) specific for this $menu_name, $link, $language,
-    // and depth.
-    $cid = 'links:' . $menu_name . ':all:' . $mlid . ':' . $language_interface->id . ':' . (int) $max_depth;
-
-    if (!isset($this->menuFullTrees[$cid])) {
-      // If the static variable doesn't have the data, check {cache_menu}.
-      $cache = $this->cache->get($cid);
-      if ($cache && $cache->data) {
-        // If the cache entry exists, it contains the parameters for
-        // menu_build_tree().
-        $tree_parameters = $cache->data;
-      }
-      // If the tree data was not in the cache, build $tree_parameters.
-      if (!isset($tree_parameters)) {
-        $tree_parameters = array(
-          'min_depth' => 1,
-          'max_depth' => $max_depth,
-        );
-        if ($mlid) {
-          // The tree is for a single item, so we need to match the values in
-          // its p columns and 0 (the top level) with the plid values of other
-          // links.
-          $parents = array(0);
-          for ($i = 1; $i < MENU_MAX_DEPTH; $i++) {
-            if (!empty($link["p$i"])) {
-              $parents[] = $link["p$i"];
-            }
-          }
-          $tree_parameters['expanded'] = $parents;
-          $tree_parameters['active_trail'] = $parents;
-          $tree_parameters['active_trail'][] = $mlid;
-        }
+  public function render(array $tree) {
+    $build = array();
 
-        // Cache the tree building parameters using the page-specific cid.
-        $this->cache->set($cid, $tree_parameters, Cache::PERMANENT, array('menu' => $menu_name));
+    foreach ($tree as $item) {
+      // Don't render a menu link (nor its descendants) if it's hidden.
+      if ($item->link['hidden']) {
+        continue;
       }
 
-      // Build the tree using the parameters; the resulting tree will be cached
-      // by $this->doBuildTree()).
-      $this->menuFullTrees[$cid] = $this->buildTree($menu_name, $tree_parameters);
-    }
-
-    return $this->menuFullTrees[$cid];
-  }
+      // If >0 of the access checks associated with this menu link are dynamic,
+      // then render a render cache placeholder instead, with an associated
+      // #post_render_cache callback. Thanks to this approach, we can render
+      // cache the entire menu tree.
+      if ($item->link['needs_dynamic_access_check']) {
+        // Pre-calculate the source path, to avoid doing this every page load.
+        $source_path = $this->aliasManager->getPathByAlias($item->link['link_path']);
+        $item->link['localized_options']['attributes']['data-drupal-link-system-path'] = $source_path;
 
-  /**
-   * {@inheritdoc}
-   */
-  public function buildPageData($menu_name, $max_depth = NULL, $only_active_trail = FALSE) {
-    $language_interface = $this->languageManager->getCurrentLanguage();
-
-    // Load the request corresponding to the current page.
-    $request = $this->requestStack->getCurrentRequest();
-    $system_path = NULL;
-    if ($route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME)) {
-      // @todo https://drupal.org/node/2068471 is adding support so we can tell
-      // if this is called on a 404/403 page.
-      $system_path = $request->attributes->get('_system_path');
-      $page_not_403 = 1;
-    }
-    if (isset($system_path)) {
-      if (isset($max_depth)) {
-        $max_depth = min($max_depth, MENU_MAX_DEPTH);
+        $element = $this->renderItemAsPlaceholder($item);
       }
-      // Generate a cache ID (cid) specific for this page.
-      $cid = 'links:' . $menu_name . ':page:' . $system_path . ':' . $language_interface->id . ':' . $page_not_403 . ':' . (int) $max_depth;
-      // If we are asked for the active trail only, and $menu_name has not been
-      // built and cached for this page yet, then this likely means that it
-      // won't be built anymore, as this function is invoked from
-      // template_preprocess_page(). So in order to not build a giant menu tree
-      // that needs to be checked for access on all levels, we simply check
-      // whether we have the menu already in cache, or otherwise, build a
-      // minimum tree containing the active trail only.
-      if (!isset($this->menuPageTrees[$cid]) && $only_active_trail) {
-        $cid .= ':trail';
-      }
-
-      if (!isset($this->menuPageTrees[$cid])) {
-        // If the static variable doesn't have the data, check {cache_menu}.
-        $cache = $this->cache->get($cid);
-        if ($cache && $cache->data) {
-          // If the cache entry exists, it contains the parameters for
-          // menu_build_tree().
-          $tree_parameters = $cache->data;
+      else {
+        if ($item->link['access']) {
+          $element = $this->renderItem($item);
         }
-        // If the tree data was not in the cache, build $tree_parameters.
-        if (!isset($tree_parameters)) {
-          $tree_parameters = array(
-            'min_depth' => 1,
-            'max_depth' => $max_depth,
-          );
-          $active_trail = $this->getActiveTrailIds($menu_name);
-
-          // If this page is accessible to the current user, build the tree
-          // parameters accordingly.
-          if ($page_not_403) {
-            // The active trail contains more than only array(0 => 0).
-            if (count($active_trail) > 1) {
-              // If we are asked to build links for the active trail only,skip
-              // the entire 'expanded' handling.
-              if ($only_active_trail) {
-                $tree_parameters['only_active_trail'] = TRUE;
-              }
-            }
-            $parents = $active_trail;
-
-            $expanded = $this->state->get('menu_expanded');
-            // Check whether the current menu has any links set to be expanded.
-            if (!$only_active_trail && $expanded && in_array($menu_name, $expanded)) {
-              // Collect all the links set to be expanded, and then add all of
-              // their children to the list as well.
-              do {
-                $query = $this->queryFactory->get('menu_link')
-                  ->condition('menu_name', $menu_name)
-                  ->condition('expanded', 1)
-                  ->condition('has_children', 1)
-                  ->condition('plid', $parents, 'IN')
-                  ->condition('mlid', $parents, 'NOT IN');
-                $result = $query->execute();
-                $parents += $result;
-              } while (!empty($result));
-            }
-            $tree_parameters['expanded'] = $parents;
-            $tree_parameters['active_trail'] = $active_trail;
-          }
-          // If access is denied, we only show top-level links in menus.
-          else {
-            $tree_parameters['expanded'] = $active_trail;
-            $tree_parameters['active_trail'] = $active_trail;
-          }
-          // Cache the tree building parameters using the page-specific cid.
-          $this->cache->set($cid, $tree_parameters, Cache::PERMANENT, array('menu' => $menu_name));
+        else {
+          continue;
         }
-
-        // Build the tree using the parameters; the resulting tree will be
-        // cached by $tihs->buildTree().
-        $this->menuPageTrees[$cid] = $this->buildTree($menu_name, $tree_parameters);
       }
-      return $this->menuPageTrees[$cid];
-    }
 
-    return array();
-  }
+      // Key by the menu link's ID.
+      $build[$item->link['mlid']] = $element;
+    }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function getActiveTrailIds($menu_name) {
-    // Parent mlids; used both as key and value to ensure uniqueness.
-    // We always want all the top-level links with plid == 0.
-    $active_trail = array(0 => 0);
-
-    $request = $this->requestStack->getCurrentRequest();
-
-    if ($route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME)) {
-      // @todo https://drupal.org/node/2068471 is adding support so we can tell
-      // if this is called on a 404/403 page.
-      // Check if the active trail has been overridden for this menu tree.
-      $active_path = $this->getPath($menu_name);
-      // Find a menu link corresponding to the current path. If
-      // $active_path is NULL, let menu_link_get_preferred() determine
-      // the path.
-      if ($active_link = $this->menuLinkGetPreferred($menu_name, $active_path)) {
-        if ($active_link['menu_name'] == $menu_name) {
-          // Use all the coordinates, except the last one because
-          // there can be no child beyond the last column.
-          for ($i = 1; $i < MENU_MAX_DEPTH; $i++) {
-            if ($active_link['p' . $i]) {
-              $active_trail[$active_link['p' . $i]] = $active_link['p' . $i];
-            }
-          }
-        }
-      }
+    if ($build) {
+      $menu_name = $tree ? end($tree)->link['menu_name'] : '';
+      // Make sure drupal_render() does not re-order the links.
+      $build['#sorted'] = TRUE;
+      // Add the theme wrapper for outer markup.
+      // Allow menu-specific theme overrides.
+      $build['#theme_wrappers'][] = 'menu_tree__' . strtr($menu_name, '-', '_');
+      // Set cache tag.
+      $build['#cache']['tags']['menu'][$menu_name] = $menu_name;
     }
-    return $active_trail;
+
+    return $build;
   }
 
   /**
-   * {@inheritdoc}
+   * Renders a single item in a menu tree.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem $item
+   *   An item in a menu tree to be rendered.
+   *
+   * @return array
+   *   The renderable array for the given menu tree item.
    */
-  public function setPath($menu_name, $path = NULL) {
-    if (isset($path)) {
-      $this->trailPaths[$menu_name] = $path;
-    }
+  protected function renderItem(MenuTreeItem $item) {
+    // Build the menu tree item's render array (a single menu link).
+    $element = $this->renderMenuLink($item);
+    // Render the children (i.e. a subtree), if any.
+    $element['#below'] = $item->children ? $this->render($item->children) : $item->children;
+    return $element;
   }
 
   /**
-   * {@inheritdoc}
+   * Renders a single item in a menu tree as a render cache placeholder.
+   *
+   * @param \Drupal\menu_link\MenuTreeItem $item
+   *   An item in a menu tree to be rendered.
+   *
+   * @return array
+   *   The renderable array for the given menu tree item.
    */
-  public function getPath($menu_name) {
-    return isset($this->trailPaths[$menu_name]) ? $this->trailPaths[$menu_name] : NULL;
+  protected function renderItemAsPlaceholder(MenuTreeItem $item) {
+    $callback = 'menu_link.tree:renderItemPlaceholder';
+    $context = array(
+      'menu_tree_item' => $item,
+    );
+    $element = array(
+      '#markup' => $this->drupalRenderCacheGeneratePlaceholder($callback, $context),
+      '#post_render_cache' => array(
+        $callback => array(
+          $context,
+        ),
+      ),
+    );
+    return $element;
   }
 
   /**
-   * {@inheritdoc}
+   * #post_render_cache callback; replaces placeholder with rendered tree item.
+   *
+   * (A menu tree item contains at least a menu link, but it potentially also
+   * contains a subtree.)
+   *
+   * A placeholder is typically used for a menu tree item whose link needs >=1
+   * dynamic access checks. This #post_render_cache callback is capable of
+   * rendering that into
+   *
+   * Used for links that need dynamic access checks.
+   *
+   * Uses ::getAccountDynamicAccess() to efficiently apply dynamic access checks
+   * to the given menu link.
+   *
+   * @param array $element
+   *   The renderable array that contains the to be replaced placeholder.
+   * @param array $context
+   *   An array with the following keys:
+   *   - menu_tree_item: the menu tree item
+   *   - token: the unique token for this placeholder
+   *
+   * @return array
+   *   A renderable array containing the comment form.
+   *
+   * @see ::render()
+   * @see ::getAccountDynamicAccess()
    */
-  public function renderMenu($menu_name) {
-
-    if (!isset($this->menuOutput[$menu_name])) {
-      $tree = $this->buildPageData($menu_name);
-      $this->menuOutput[$menu_name] = $this->renderTree($tree);
+  public function renderItemPlaceholder(array $element, array $context) {
+    $item = $context['menu_tree_item'];
+
+    // Get the dynamic access check results for this menu link, for the given
+    // user.
+    $menu_name = $item->link['menu_name'];
+    $link_path = $item->link['link_path'];
+    $callable = $item->link['needs_dynamic_access_check'];
+    if (strpos($callable, '::') === FALSE) {
+      $callable = $this->controllerResolver->getControllerFromDefinition($callable);
+    }
+    $account_has_access = call_user_func($callable, $menu_name, $link_path, $this->currentUser);
+
+    // Apply the necessary rendering: none if the user doesn't have access to
+    // this link, otherwise render this link and its children.
+    $markup = '';
+    if ($account_has_access) {
+      $build = $this->renderItem($item);
+      $markup = $this->drupalRender($build, TRUE);
     }
-    return $this->menuOutput[$menu_name];
+
+    $callback = 'menu_link.tree:renderItemPlaceholder';
+    $placeholder = $this->drupalRenderCacheGeneratePlaceholder($callback, $context);
+    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
+
+    return $element;
   }
 
   /**
-   * {@inheritdoc}
+   * Renders a menu link, i.e. a single item in the tree, excluding its subtree.
+   *
+   * @param MenuTreeItem $data
+   *   The data representing a single menu link.
+   *
+   * @return array
+   *   The renderable array for a menu link inside a menu tree.
    */
-  public function renderTree($tree) {
-    $build = array();
-    $items = array();
-    $menu_name = $tree ? end($tree)['link']['menu_name'] : '';
-
-    // Pull out just the menu links we are going to render so that we
-    // get an accurate count for the first/last classes.
-    foreach ($tree as $data) {
-      if ($data['link']['access'] && !$data['link']['hidden']) {
-        $items[] = $data;
-      }
+  protected static function renderMenuLink(MenuTreeItem $data) {
+    $class = array();
+    // Set a class for the <li>-tag. Since $data->children may contain local
+    // tasks, only set 'expanded' class if the link also has children within
+    // the current menu.
+    if ($data->link['has_children'] && $data->children) {
+      $class[] = 'expanded';
     }
-
-    foreach ($items as $data) {
-      $class = array();
-      // Set a class for the <li>-tag. Since $data['below'] may contain local
-      // tasks, only set 'expanded' class if the link also has children within
-      // the current menu.
-      if ($data['link']['has_children'] && $data['below']) {
-        $class[] = 'expanded';
-      }
-      elseif ($data['link']['has_children']) {
-        $class[] = 'collapsed';
-      }
-      else {
-        $class[] = 'leaf';
-      }
-      // Set a class if the link is in the active trail.
-      if ($data['link']['in_active_trail']) {
-        $class[] = 'active-trail';
-        $data['link']['localized_options']['attributes']['class'][] = 'active-trail';
-      }
-
-      // Allow menu-specific theme overrides.
-      $element['#theme'] = 'menu_link__' . strtr($data['link']['menu_name'], '-', '_');
-      $element['#attributes']['class'] = $class;
-      $element['#title'] = $data['link']['title'];
-      // @todo Use route name and parameters to generate the link path, unless
-      //    it is external.
-      $element['#href'] = $data['link']['link_path'];
-      $element['#localized_options'] = !empty($data['link']['localized_options']) ? $data['link']['localized_options'] : array();
-      $element['#below'] = $data['below'] ? $this->renderTree($data['below']) : $data['below'];
-      $element['#original_link'] = $data['link'];
-      // Index using the link's unique mlid.
-      $build[$data['link']['mlid']] = $element;
+    elseif ($data->link['has_children']) {
+      $class[] = 'collapsed';
     }
-    if ($build) {
-      // Make sure drupal_render() does not re-order the links.
-      $build['#sorted'] = TRUE;
-      // Add the theme wrapper for outer markup.
-      // Allow menu-specific theme overrides.
-      $build['#theme_wrappers'][] = 'menu_tree__' . strtr($menu_name, '-', '_');
-      // Set cache tag.
-      $menu_name = $data['link']['menu_name'];
-      $build['#cache']['tags']['menu'][$menu_name] = $menu_name;
+    else {
+      $class[] = 'leaf';
+    }
+    // Set a class if the link is in the active trail.
+    if ($data->link['in_active_trail']) {
+      $class[] = 'active-trail';
+      $data->link['localized_options']['attributes']['class'][] = 'active-trail';
     }
 
-    return $build;
+    // Allow menu-specific theme overrides.
+    $element['#theme'] = 'menu_link__' . strtr($data->link['menu_name'], '-', '_');
+    $element['#attributes'] = isset($data->link['li_attributes']) ? $data->link['li_attributes'] : array();
+    if (!isset($element['#attributes']['class'])) {
+      $element['#attributes']['class'] = array();
+    }
+    $element['#attributes']['class'] = array_merge($element['#attributes']['class'], $class);
+    $element['#title'] = $data->link['title'];
+    // @todo Use route name and parameters to generate the link path, unless
+    //    it is external.
+    $element['#href'] = $data->link['link_path'];
+    $element['#localized_options'] = !empty($data->link['localized_options']) ? $data->link['localized_options'] : array();
+
+    return $element;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function buildTree($menu_name, array $parameters = array()) {
-    // Build the menu tree.
-    $tree = $this->doBuildTree($menu_name, $parameters);
-    // Check access for the current user to each item in the tree.
-    $this->checkAccess($tree);
+  public function build($menu_name, MenuTreeParameters $parameters) {
+    $links = $this->loadLinks($menu_name, $parameters);
+    $tree = $this->convertLinksToTree($links, $parameters->active_trail, $parameters->min_depth, $parameters->manipulators);
     return $tree;
   }
 
   /**
-   * Builds a menu tree.
-   *
-   * This function may be used build the data for a menu tree only, for example
-   * to further massage the data manually before further processing happens.
-   * MenuTree::checkAccess() needs to be invoked afterwards.
+   * Loads links in the given menu, according to the given tree parameters.
    *
    * @param string $menu_name
-   *   The name of the menu.
-   * @param array $parameters
-   *   The parameters passed into static::buildTree()
-   *
-   * @see static::buildTree()
+   *   A menu name.
+   * @param MenuTreeParameters $parameters
+   *   Parameters for building the menu tree
+   * @return array
+   *   A flat array of menu links that are part of the menu. Each array element
+   *   is an associative array of information about the menu link, containing
+   *   the fields from the {menu_links} table, and optionally additional
+   *   information from the {menu_router} table, if the menu item appears in
+   *   both tables. This array must be ordered depth-first.
    */
-  protected function doBuildTree($menu_name, array $parameters = array()) {
-    $language_interface = $this->languageManager->getCurrentLanguage();
-
-    // Build the cache id; sort parents to prevent duplicate storage and remove
-    // default parameter values.
-    if (isset($parameters['expanded'])) {
-      sort($parameters['expanded']);
+  protected function loadLinks($menu_name, MenuTreeParameters $parameters) {
+    $query = $this->queryFactory->get('menu_link');
+    for ($i = 1; $i <= MENU_MAX_DEPTH; $i++) {
+      $query->sort('p' . $i, 'ASC');
     }
-    $tree_cid = 'links:' . $menu_name . ':tree-data:' . $language_interface->id . ':' . hash('sha256', serialize($parameters));
-
-    // If we do not have this tree in the static cache, check {cache_menu}.
-    if (!isset($this->menuTree[$tree_cid])) {
-      $cache = $this->cache->get($tree_cid);
-      if ($cache && $cache->data) {
-        $this->menuTree[$tree_cid] = $cache->data;
-      }
+    $query->condition('menu_name', $menu_name);
+    if (!empty($parameters->expanded)) {
+      // Sort parents to prevent duplicate storage.
+      sort($parameters->expanded);
+      $query->condition('plid', $parameters->expanded, 'IN');
     }
-
-    if (!isset($this->menuTree[$tree_cid])) {
-      $query = $this->queryFactory->get('menu_link');
-      for ($i = 1; $i <= MENU_MAX_DEPTH; $i++) {
-        $query->sort('p' . $i, 'ASC');
-      }
-      $query->condition('menu_name', $menu_name);
-      if (!empty($parameters['expanded'])) {
-        $query->condition('plid', $parameters['expanded'], 'IN');
-      }
-      elseif (!empty($parameters['only_active_trail'])) {
-        $query->condition('mlid', $parameters['active_trail'], 'IN');
-      }
-      $min_depth = (isset($parameters['min_depth']) ? $parameters['min_depth'] : 1);
-      if ($min_depth != 1) {
-        $query->condition('depth', $min_depth, '>=');
-      }
-      if (isset($parameters['max_depth'])) {
-        $query->condition('depth', $parameters['max_depth'], '<=');
-      }
-      // Add custom query conditions, if any were passed.
-      if (isset($parameters['conditions'])) {
-        foreach ($parameters['conditions'] as $column => $value) {
-          $query->condition($column, $value);
-        }
+    elseif ($parameters->only_active_trail) {
+      $query->condition('mlid', $parameters->active_trail, 'IN');
+    }
+    if ($parameters->min_depth != 1) {
+      $query->condition('depth', $parameters->min_depth, '>=');
+    }
+    if (isset($parameters->max_depth)) {
+      $query->condition('depth', $parameters->max_depth, '<=');
+    }
+    // Add custom query conditions, if any were passed.
+    foreach ($parameters->conditions as $column => $value) {
+      if (!is_array($value)) {
+        $query->condition($column, $value);
       }
-
-      // Build an ordered array of links using the query result object.
-      $links = array();
-      if ($result = $query->execute()) {
-        $links = $this->menuLinkStorage->loadMultiple($result);
+      else {
+        $operator = $value[1];
+        $value = $value[0];
+        $query->condition($column, $value, $operator);
       }
-      $active_trail = (isset($parameters['active_trail']) ? $parameters['active_trail'] : array());
-      $tree = $this->doBuildTreeData($links, $active_trail, $min_depth);
-
-      // Cache the data, if it is not already in the cache.
-      $this->cache->set($tree_cid, $tree, Cache::PERMANENT, array('menu' => $menu_name));
-      $this->menuTree[$tree_cid] = $tree;
     }
 
-    return $this->menuTree[$tree_cid];
+    // Build an ordered array of links using the query result object.
+    $links = array();
+    if ($result = $query->execute()) {
+      $links = $this->menuLinkStorage->loadMultiple($result);
+    }
+    return $links;
   }
 
   /**
-   * Sorts the menu tree and recursively checks access for each item.
+   * Converts menu links to a tree structure.
+   *
+   * @param array $links
+   *   A flat array of menu links that are part of the menu.
+   *   See \Drupal\menu_link\MenuTree::loadLinks().
+   * @param array $active_trail
+   *   An array of the menu link ID values that are in the active trail.
+   * @param int $min_depth
+   *   The minimum depth to include in the resulting menu tree.
+   * @param array $tree_manipulators
+   *   Tree manipulators to be applied. See \Drupal\menu_link\MenuTreeParameters.
    *
-   * @param array $tree
-   *   The menu tree you wish to operate on.
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   A menu tree.
    */
-  protected function checkAccess(&$tree) {
-    $new_tree = array();
-    foreach ($tree as $key => $v) {
-      $item = &$tree[$key]['link'];
-      $this->menuLinkTranslate($item);
-      if ($item['access'] || ($item['in_active_trail'] && strpos($item['href'], '%') !== FALSE)) {
-        if ($tree[$key]['below']) {
-          $this->checkAccess($tree[$key]['below']);
-        }
-        // The weights are made a uniform 5 digits by adding 50000 as an offset.
-        // After _menu_link_translate(), $item['title'] has the localized link
-        // title. Adding the mlid to the end of the index insures that it is
-        // unique.
-        $new_tree[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['mlid']] = $tree[$key];
+  protected function convertLinksToTree(array $links, array $active_trail = array(), $min_depth = 1, array $tree_manipulators = array()) {
+    // Reverse the array so we can use the more efficient array_pop() function.
+    $links = array_reverse($links);
+    $tree = $this->treeDataRecursive($links, $active_trail, $min_depth);
+
+    // Apply menu tree manipulators.
+    foreach ($tree_manipulators as $manipulator) {
+      // Prepare the arguments for the menu tree manipulator callable; the first
+      // argument is always the menu tree.
+      $args = isset($manipulator['args']) ? $manipulator['args'] : array();
+      array_unshift($args, $tree);
+
+      $callable = $manipulator['callable'];
+      if (strpos($callable, '::') === FALSE) {
+        $callable = $this->controllerResolver->getControllerFromDefinition($callable);
       }
+
+      $tree = call_user_func_array($callable, $args);
     }
-    // Sort siblings in the tree based on the weights and localized titles.
-    ksort($new_tree);
-    $tree = $new_tree;
-  }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function buildTreeData(array $links, array $parents = array(), $depth = 1) {
-    $tree = $this->doBuildTreeData($links, $parents, $depth);
-    $this->checkAccess($tree);
     return $tree;
   }
 
   /**
-   * Prepares the data for calling $this->treeDataRecursive().
-   */
-  protected function doBuildTreeData(array $links, array $parents = array(), $depth = 1) {
-    // Reverse the array so we can use the more efficient array_pop() function.
-    $links = array_reverse($links);
-    return $this->treeDataRecursive($links, $parents, $depth);
-  }
-
-  /**
    * Builds the data representing a menu tree.
    *
    * The function is a bit complex because the rendering of a link depends on
@@ -563,39 +384,37 @@ protected function doBuildTreeData(array $links, array $parents = array(), $dept
    *   the fields from the {menu_links} table, and optionally additional
    *   information from the {menu_router} table, if the menu item appears in
    *   both tables. This array must be ordered depth-first.
-   *   See _menu_build_tree() for a sample query.
-   * @param array $parents
+   *   See ::loadLinks() for a sample query.
+   * @param array $active_trail
    *   An array of the menu link ID values that are in the path from the current
    *   page to the root of the menu tree.
-   * @param int $depth
+   * @param int $min_depth
    *   The minimum depth to include in the returned menu tree.
    *
    * @return array
    */
-  protected function treeDataRecursive(&$links, $parents, $depth) {
+  protected function treeDataRecursive(&$links, $active_trail, $min_depth) {
     $tree = array();
     while ($item = array_pop($links)) {
+      $mlid = $item['mlid'];
       // We need to determine if we're on the path to root so we can later build
       // the correct active trail.
-      $item['in_active_trail'] = in_array($item['mlid'], $parents);
+      $item['in_active_trail'] = in_array($mlid, $active_trail);
       // Add the current link to the tree.
-      $tree[$item['mlid']] = array(
-        'link' => $item,
-        'below' => array(),
-      );
+      $tree[$mlid] = new MenuTreeItem($item, array());
       // Look ahead to the next link, but leave it on the array so it's
       // available to other recursive function calls if we return or build a
       // sub-tree.
       $next = end($links);
       // Check whether the next link is the first in a new sub-tree.
-      if ($next && $next['depth'] > $depth) {
-        // Recursively call doBuildTreeData to build the sub-tree.
-        $tree[$item['mlid']]['below'] = $this->treeDataRecursive($links, $parents, $next['depth']);
+      if ($next && $next['depth'] > $min_depth) {
+        // Recur to build the sub-tree.
+        $tree[$mlid]->children = $this->treeDataRecursive($links, $active_trail, $next['depth']);
         // Fetch next link after filling the sub-tree.
         $next = end($links);
       }
       // Determine if we should exit the loop and return.
-      if (!$next || $next['depth'] < $depth) {
+      if (!$next || $next['depth'] < $min_depth) {
         break;
       }
     }
@@ -603,17 +422,21 @@ protected function treeDataRecursive(&$links, $parents, $depth) {
   }
 
   /**
-   * Wraps menu_link_get_preferred().
+   * Wraps drupal_render_cache_generate_placeholder().
+   *
+   * @codeCoverageIgnore
    */
-  protected function menuLinkGetPreferred($menu_name, $active_path) {
-    return menu_link_get_preferred($active_path, $menu_name);
+  protected function drupalRenderCacheGeneratePlaceholder($callback, &$context) {
+    return drupal_render_cache_generate_placeholder($callback, $context);
   }
 
   /**
-   * Wraps _menu_link_translate().
+   * Wraps drupal_render().
+   *
+   * @codeCoverageIgnore
    */
-  protected function menuLinkTranslate(&$item) {
-    _menu_link_translate($item);
+  protected function drupalRender(&$elements, $is_recursive_call = FALSE) {
+    return drupal_render($elements, $is_recursive_call);
   }
 
 }
diff --git a/core/modules/menu_link/src/MenuTreeInterface.php b/core/modules/menu_link/src/MenuTreeInterface.php
index 418f602..881701b 100644
--- a/core/modules/menu_link/src/MenuTreeInterface.php
+++ b/core/modules/menu_link/src/MenuTreeInterface.php
@@ -8,175 +8,90 @@
 namespace Drupal\menu_link;
 
 /**
- * Defines an interface for trees out of menu links.
+ * Defines an interface for building and rendering trees of menu links.
+ *
+ * The main goal of this service is to, given a menu name, build (::build()) the
+ * corresponding tree of menu links.
+ * Building the menu consists of two phases. First: loading the menu links in
+ * the given menu. Second: transforming this list of menu links into a tree (by
+ * looking at their tree metadata) and applying tree manipulators. Tree
+ * manipulators can perform simple tasks like translating the title of all menu
+ * links in a tree, or more complex tasks like access checking (which will
+ * remove inaccessible menu links), or even very complex tasks like extracting a
+ * subtree from the tree according depending on the active trail.
+ * Which links are loaded and which tree manipulators are applied can be
+ * specified in the MenuTreeParameters you pass to ::build().
+ *
+ * If desired, that tree of menu links can then be rendered (::render()).
+ *
+ *
+ * @section rendered_menu_tree_cacheability Rendered menu tree cacheability
+ *
+ * Unfortunately, because Drupal only wants to show menu links to a user that
+ * are accessible for that user, we have a performance problem. A menu consists
+ * of menu links arranged in a tree. Each menu link points to a route (or an
+ * external URL). Each route can have any number of access checks defined. And,
+ * finally, those access checks can be of arbitrary complexity, and therefore of
+ * arbitrary cacheability. Some may be globally cacheable, others per role, yet
+ * others per user, per page, per language, and whatnot. Some may not even be
+ * cacheable at all. Caching per user, for example, is usually not an option:
+ * the cache hit ratio would be too low on a typical Drupal site.
+ *
+ * Access checking is performed by menu tree manipulators. The simplest possible
+ * manipulator will just apply access checks for the given user, and end up with
+ * the correct result. Unfortunately, the resulting tree will only be cacheable
+ * per user. To allow for more cacheable access checking, ::render() allows each
+ * link to have a 'needs_dynamic_access_check' property. Those menu links will
+ * be rendered into render cache placeholders. See ::render() for details.
+ *
+ * @see \Drupal\menu_link\DefaultMenuTreeManipulators
+ * @see \Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator
  */
 interface MenuTreeInterface {
 
   /**
-   * Returns a rendered menu tree.
+   * Builds a menu tree based on the given tree parameters.
    *
-   * The menu item's LI element is given one of the following classes:
-   * - expanded: The menu item is showing its submenu.
-   * - collapsed: The menu item has a submenu which is not shown.
-   * - leaf: The menu item has no submenu.
-   *
-   * @param array $tree
-   *   A data structure representing the tree as returned from menu_tree_data.
-   *
-   * @return array
-   *   A structured array to be rendered by drupal_render().
-   */
-  public function renderTree($tree);
-
-  /**
-   * Sets the path for determining the active trail of the specified menu tree.
-   *
-   * This path will also affect the breadcrumbs under some circumstances.
-   * Breadcrumbs are built using the preferred link returned by
-   * menu_link_get_preferred(). If the preferred link is inside one of the menus
-   * specified in calls to static::setPath(), the preferred link will be
-   * overridden by the corresponding path returned by static::getPath().
-   *
-   * @param string $menu_name
-   *   The name of the affected menu tree.
-   * @param string $path
-   *   The path to use when finding the active trail.
-   */
-  public function setPath($menu_name, $path = NULL);
-
-  /**
-   * Gets the path for determining the active trail of the specified menu tree.
-   *
-   * @param string $menu_name
-   *   The menu name of the requested tree.
-   *
-   * @return string
-   *   A string containing the path. If no path has been specified with
-   *   static::setPath(), NULL is returned.
-   */
-  public function getPath($menu_name);
-
-  /**
-   * Gets the active trail IDs of the specified menu tree.
-   *
-   * @param string $menu_name
-   *   The menu name of the requested tree.
-   *
-   * @return array
-   *   An array containing the active trail: a list of mlids.
-   */
-  public function getActiveTrailIds($menu_name);
-
-  /**
-   * Sorts and returns the built data representing a menu tree.
-   *
-   * @param array $links
-   *   A flat array of menu links that are part of the menu. Each array element
-   *   is an associative array of information about the menu link, containing
-   *   the fields from the {menu_links} table, and optionally additional
-   *   information from the {menu_router} table, if the menu item appears in
-   *   both tables. This array must be ordered depth-first.
-   *   See _menu_build_tree() for a sample query.
-   * @param array $parents
-   *   An array of the menu link ID values that are in the path from the current
-   *   page to the root of the menu tree.
-   * @param int $depth
-   *   The minimum depth to include in the returned menu tree.
-   *
-   * @return array
-   *   An array of menu links in the form of a tree. Each item in the tree is an
-   *   associative array containing:
-   *   - link: The menu link item from $links, with additional element
-   *     'in_active_trail' (TRUE if the link ID was in $parents).
-   *   - below: An array containing the sub-tree of this item, where each
-   *     element is a tree item array with 'link' and 'below' elements. This
-   *     array will be empty if the menu item has no items in its sub-tree
-   *     having a depth greater than or equal to $depth.
-   */
-  public function buildTreeData(array $links, array $parents = array(), $depth = 1);
-
-  /**
-   * Gets the data structure for a named menu tree, based on the current page.
-   *
-   * The tree order is maintained by storing each parent in an individual
-   * field, see http://drupal.org/node/141866 for more.
+   * Loads menu links in the given menu corresponding to the given tree
+   * parameters, then converts into a tree structure consisting of @endcode
+   * \Drupal\menu_link\MenuTreeItem @endcode items, and finally applies any
+   * tree manipulators specified in the tree parameters.
    *
    * @param string $menu_name
-   *   The named menu links to return.
-   * @param int $max_depth
-   *   (optional) The maximum depth of links to retrieve.
-   * @param bool $only_active_trail
-   *   (optional) Whether to only return the links in the active trail (TRUE)
-   *   instead of all links on every level of the menu link tree (FALSE).
-   *   Defaults to FALSE.
-   *
-   * @return array
-   *   An array of menu links, in the order they should be rendered. The array
-   *   is a list of associative arrays -- these have two keys, link and below.
-   *   link is a menu item, ready for theming as a link. Below represents the
-   *   submenu below the link if there is one, and it is a subtree that has the
-   *   same structure described for the top-level array.
-   */
-  public function buildPageData($menu_name, $max_depth = NULL, $only_active_trail = FALSE);
-
-  /**
-   * Gets the data structure representing a named menu tree.
+   *   A menu name.
+   * @param \Drupal\menu_link\MenuTreeParameters|null $parameters
+   *   Parameters for building the menu tree.
    *
-   * Since this can be the full tree including hidden items, the data returned
-   * may be used for generating an an admin interface or a select.
+   * @return \Drupal\menu_link\MenuTreeItem[]
+   *   A menu tree.
    *
-   * @param string $menu_name
-   *   The named menu links to return
-   * @param array $link
-   *   A fully loaded menu link, or NULL. If a link is supplied, only the
-   *   path to root will be included in the returned tree - as if this link
-   *   represented the current page in a visible menu.
-   * @param int $max_depth
-   *   Optional maximum depth of links to retrieve. Typically useful if only one
-   *   or two levels of a sub tree are needed in conjunction with a non-NULL
-   *   $link, in which case $max_depth should be greater than $link['depth'].
-   *
-   * @return array
-   *   An tree of menu links in an array, in the order they should be rendered.
-   */
-  public function buildAllData($menu_name, $link = NULL, $max_depth = NULL);
-
-  /**
-   * Renders a menu tree based on the current path.
-   *
-   * @param string $menu_name
-   *   The name of the menu.
-   *
-   * @return array
-   *   A structured array representing the specified menu on the current page,
-   *   to be rendered by drupal_render().
+   * @see \Drupal\menu_link\MenuTreeParameters
    */
-  public function renderMenu($menu_name);
+  public function build($menu_name, MenuTreeParameters $parameters);
 
   /**
-   * Builds a menu tree, translates links, and checks access.
+   * Renders the given menu tree into a renderable array.
+   *
+   * Each menu tree item's LI element is given one of the following classes:
+   * - expanded: The menu item is showing its children.
+   * - collapsed: The menu item has a children which are not shown.
+   * - leaf: The menu item has no children.
+   *
+   * Menu tree items whose link has a 'needs_dynamic_access_check' property set
+   * will be rendered into render cache placeholders. That means that that menu
+   * link (and its children) don't get rendered just yet. Other menu links are
+   * rendered normally.
+   * The #post_render_cache callback associated with a render cache placeholder
+   * will call the callable defined in the value of the menu link's
+   * 'needs_dynamic_access_check' property to determine whether the current user
+   * has access, and hence whether it should be rendered.
    *
-   * @param string $menu_name
-   *   The name of the menu.
-   * @param array $parameters
-   *   (optional) An associative array of build parameters. Possible keys:
-   *   - expanded: An array of parent link ids to return only menu links that
-   *     are children of one of the plids in this list. If empty, the whole menu
-   *     tree is built, unless 'only_active_trail' is TRUE.
-   *   - active_trail: An array of mlids, representing the coordinates of the
-   *     currently active menu link.
-   *   - only_active_trail: Whether to only return links that are in the active
-   *     trail. This option is ignored, if 'expanded' is non-empty.
-   *   - min_depth: The minimum depth of menu links in the resulting tree.
-   *     Defaults to 1, which is the default to build a whole tree for a menu
-   *     (excluding menu container itself).
-   *   - max_depth: The maximum depth of menu links in the resulting tree.
-   *   - conditions: An associative array of custom database select query
-   *     condition key/value pairs; see _menu_build_tree() for the actual query.
+   * @param array $tree
+   *   A menu tree, as returned by ::build().
    *
    * @return array
-   *   A fully built menu tree.
+   *   The renderable array for the given menu tree.
    */
-  public function buildTree($menu_name, array $parameters = array());
+  public function render(array $tree);
 
 }
diff --git a/core/modules/menu_link/src/MenuTreeItem.php b/core/modules/menu_link/src/MenuTreeItem.php
new file mode 100644
index 0000000..2ccc3ab
--- /dev/null
+++ b/core/modules/menu_link/src/MenuTreeItem.php
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\MenuTreeItem.
+ */
+
+namespace Drupal\menu_link;
+
+/**
+ * Provides a value object to model an item in a menu tree.
+ *
+ * Because it also points to the children of the item, it can also be said to
+ * model a subtree of the menu tree.
+ */
+class MenuTreeItem {
+
+  /**
+   * The menu link for this item in a menu tree.
+   *
+   * @var \Drupal\menu_link\MenuLinkInterface
+   */
+  public $link;
+
+  /**
+   * The children of this item in the menu tree. Represents the subtree.
+   *
+   * @var \Drupal\Core\Menu\MenuTreeItem[]
+   */
+  public $children;
+
+  /**
+   * Constructs a new MenuTreeItem.
+   *
+   * @param \Drupal\menu_link\MenuLinkInterface $link
+   *   The menu link for this item.
+   * @param \Drupal\menu_link\MenuLinkInterface[] $children
+   *   The children of this item in the menu tree.
+   */
+  public function __construct(MenuLinkInterface $link, array $children) {
+    $this->link = $link;
+    $this->children = $children;
+  }
+
+  /**
+   * Counts all menu links in the current subtree.
+   *
+   * @return int
+   *   The number of menu links in this subtree (one plus the number of menu
+   *   links in all descendants).
+   */
+  public function count() {
+    $sum = function ($carry, MenuTreeItem $item) {
+      return $carry + $item->count();
+    };
+    return 1 + array_reduce($this->children, $sum);
+  }
+
+}
diff --git a/core/modules/menu_link/src/MenuTreeParameters.php b/core/modules/menu_link/src/MenuTreeParameters.php
new file mode 100644
index 0000000..c050743
--- /dev/null
+++ b/core/modules/menu_link/src/MenuTreeParameters.php
@@ -0,0 +1,196 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\MenuTreeParameters.
+ */
+
+namespace Drupal\menu_link;
+
+use Drupal\Core\Entity\Query\QueryFactory;
+
+/**
+ * Provides a value object to model menu tree parameters.
+ *
+ * Menu tree parameters are used to convert a set of menu links into a tree of
+ * menu links; these parameters affect:
+ * - the shape of the menu tree (e.g. which links should be expanded)
+ * - which menu links are omitted from the tree (e.g. minimum and maximum depth)
+ * - how items in that tree are manipulated (translation, access checking, or
+ *   even extracting a subset of the tree — *any* kind of manipulation is
+ *   possible) — @see \Drupal\menu_link\MenuTreeItem
+ *
+ * Examples of tree manipulators:
+ * - @see \Drupal\menu_link\MenuTree::translate
+ * - @see \Drupal\menu_link\MenuTree::generateIndexAndSort()
+ * - @see \Drupal\menu_link\MenuTree::checkAccess()
+ * - @see \Drupal\menu_link\CacheableMenuTreeAccessCheck::checkNonDynamicAccess()
+ */
+class MenuTreeParameters {
+
+  /**
+   * An array of parent link ids to return only menu links that are children of
+   * one of the plids in this list. If empty, the whole menu tree is built,
+   * unless 'only_active_trail' is TRUE.
+   *
+   * Defaults to the empty array.
+   *
+   * @var int[]
+   */
+  public $expanded = array();
+
+  /**
+   * An array of mlids, representing the coordinates of the currently active
+   * menu link.
+   *
+   * Defaults to the empty array.
+   *
+   * @var int[]
+   */
+  public $active_trail = array();
+
+  /**
+   * Whether to only return links that are in the active trail. This option is
+   * ignored, if 'expanded' is non-empty.
+   *
+   * Defaults to FALSE.
+   *
+   * @var bool
+   */
+  public $only_active_trail = FALSE;
+
+  /**
+   * The minimum depth of menu links in the resulting tree.
+   *
+   * Defaults to 1, which is the default to build a whole tree for a menu
+   * (excluding menu container itself).
+   *
+   * @var int
+   */
+  public $min_depth = 1;
+
+  /**
+   * The maximum depth of menu links in the resulting tree.
+   *
+   * @var int|null
+   */
+  public $max_depth = NULL;
+
+  /**
+   * An associative array of custom database select query condition key/value
+   * pairs; see _menu_build_tree() for the actual query.
+   *
+   * Defaults to the empty array.
+   *
+   * @var array
+   */
+  public $conditions = array();
+
+  /**
+   * An array of subarrays, each of those subarrays containing the 'callable'
+   * key, and optionally an 'args' key. When the tree is built, each of these
+   * callables are invoked one-by-one, each receiving the tree and also
+   * returning it (after applying some manipulation). The first argument is
+   * always the tree. If the 'args' key's associated value is an array, then
+   * that array's values are passed as additional arguments to the callable.
+   *
+   * @see \Drupal\menu_link\MenuTreeItem
+   *
+   * Defaults to the empty array.
+   *
+   * @var array
+   */
+  public $manipulators = array();
+
+  /**
+   * Sets a minimum depth; causes a menu tree to show from the given level.
+   *
+   * @param int $min_depth
+   *   The minimum depth to apply.
+   *
+   * @return $this
+   */
+  public function setMinDepth($min_depth) {
+    $this->min_depth = min(max($min_depth, 1), MENU_MAX_DEPTH);
+    return $this;
+  }
+
+  /**
+   * Sets a maximum depth; causes a menu tree to show up to the given level.
+   *
+   * @param int $max_depth
+   *   The maximum depth to apply.
+   *
+   * @return $this
+   */
+  public function setMaxDepth($max_depth) {
+    $this->max_depth = max(min($max_depth, MENU_MAX_DEPTH), 1);
+    return $this;
+  }
+
+  /**
+   * Adds menu links to be expanded (whose children to show).
+   *
+   * @param int[] $expanded
+   *   An array containing the links to be expanded: a list of mlids.
+   *
+   * @return $this
+   */
+  public function addExpanded(array $expanded) {
+    $this->expanded = array_merge($this->expanded, $expanded);
+    $this->expanded = array_unique($this->expanded);
+    return $this;
+  }
+
+  /**
+   * Adds the tree parameters for reacting to the active menu trail.
+   *
+   * @param string $menu_name
+   *   The menu name whose active trail to expand.
+   * @param int[] $active_trail
+   *   An array containing the active trail: a list of mlids.
+   * @param \Drupal\Core\Entity\Query\QueryFactory $entity_query_factory
+   *   The entity query factory.
+   *
+   * @return $this
+   *
+   * @see \Drupal\menu_link\MenuTree::getActiveTrailIds()
+   */
+  public function expandAlongActiveTrail($menu_name, array $active_trail, QueryFactory $entity_query_factory) {
+    // Store the active trail.
+    $this->active_trail = $active_trail;
+
+    // Collect all the links set to be expanded, and then add all of
+    // their children to the list as well.
+    $expanded_parents = $active_trail;
+    do {
+      $query = $entity_query_factory->get('menu_link')
+        ->condition('menu_name', $menu_name)
+        ->condition('expanded', 1)
+        ->condition('has_children', 1)
+        ->condition('plid', $expanded_parents, 'IN')
+        ->condition('mlid', $expanded_parents, 'NOT IN');
+      $result = $query->execute();
+      $expanded_parents = array_unique(array_merge($expanded_parents, $result));
+    } while (!empty($result));
+    $this->addExpanded($expanded_parents);
+
+    return $this;
+  }
+
+  /**
+   * Adds a tree manipulator to be applied.
+   *
+   * Note: the order matters!
+   *
+   * @param callable $callable
+   * @param array $args
+   *
+   * @return $this
+   */
+  public function appendManipulator($callable, array $args = array()) {
+    $this->manipulators[] = array('callable' => $callable, 'args' => $args);
+    return $this;
+  }
+
+}
diff --git a/core/modules/menu_link/tests/src/CacheableAccessCheckMenuTreeManipulatorTest.php b/core/modules/menu_link/tests/src/CacheableAccessCheckMenuTreeManipulatorTest.php
new file mode 100644
index 0000000..47b960a
--- /dev/null
+++ b/core/modules/menu_link/tests/src/CacheableAccessCheckMenuTreeManipulatorTest.php
@@ -0,0 +1,1095 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\Tests\CacheableAccessCheckMenuTreeManipulatorTest.
+ */
+
+namespace Drupal\menu_link\Tests {
+
+  use Drupal\Core\Cache\Cache;
+  use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
+  use Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator;
+  use Drupal\menu_link\MenuTreeItem;
+  use Drupal\menu_link\Entity\MenuLink;
+  use Drupal\menu_link\MenuTreeParameters;
+  use Drupal\user\UserStorageInterface;
+  use Drupal\Tests\UnitTestCase;
+  use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+  use Symfony\Component\HttpFoundation\Request;
+  use Symfony\Component\HttpFoundation\RequestStack;
+  use Symfony\Component\Routing\Exception\RouteNotFoundException;
+
+  /**
+   * Tests the CacheableAccessCheckMenuTreeManipulator service.
+   *
+   * @group Drupal
+   * @group menu_link
+   *
+   * @coversDefaultClass \Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator
+   */
+  class CacheableAccessCheckMenuTreeManipulatorTest extends UnitTestCase {
+
+    /**
+     * The tested CacheableAccessCheckMenuTreeManipulator service.
+     *
+     * @var \Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator
+     */
+    protected $cacheableAccessCheck;
+
+    /**
+     * The mocked menu tree.
+     *
+     * @var \Drupal\menu_link\MenuTreeInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $menuTree;
+
+    /**
+     * The mocked cache backend.
+     *
+     * @var \Drupal\Core\Cache\CacheBackendInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $cacheBackend;
+
+    /**
+     * The mocked cache contexts service.
+     *
+     * @var \Drupal\Core\Cache\CacheContexts|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $cacheContexts;
+
+    /**
+     * The mocked entity manager.
+     *
+     * @var \Drupal\Core\Entity\EntityManagerInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $entityManager;
+
+    /**
+     * The mocked entity query factor.y
+     *
+     * @var  \Drupal\Core\Entity\Query\QueryFactory|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $entityQueryFactory;
+
+    /**
+     * The mocked state.
+     *
+     * @var \Drupal\Core\State\StateInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $state;
+
+    /**
+     * The mocked current user.
+     *
+     * @var \Drupal\Core\Session\AccountInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $currentUser;
+
+    /**
+     * The mocked controller resolver.
+     *
+     * @var \Drupal\Core\Controller\ControllerResolverInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $controllerResolver;
+
+    /**
+     * The mocked access manager.
+     *
+     * @var \Drupal\Core\Access\AccessManager|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $accessManager;
+
+    /**
+     * The mocked route provider.
+     *
+     * @var \Drupal\Core\Routing\RouteProviderInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $routeProvider;
+
+    /**
+     * The mocked paramconverter.
+     *
+     * @var \Drupal\Core\ParamConverter\ParamConverterManagerInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $paramConverter;
+
+    /**
+     * A mocked alias manager for looking up the system path.
+     *
+     * @var \Drupal\Core\Path\AliasManagerInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $aliasManager;
+
+    /**
+     * The test request stack.
+     *
+     * @var \Symfony\Component\HttpFoundation\RequestStack.
+     */
+    protected $requestStack;
+
+    /**
+     * The mocked default menu tree manipulators.
+     *
+     * @var \Drupal\menu_link\DefaultMenuTreeManipulators|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $defaultMenuTreeManipulators;
+
+    /**
+     * Stores some default values for a menu link.
+     *
+     * @var array
+     */
+    protected $defaultMenuLink = array(
+      'menu_name' => 'main-menu',
+      'mlid' => 1,
+      'title' => 'Example 1',
+      'route_name' => 'example1',
+      'link_path' => 'example1',
+      'access' => 1,
+      'hidden' => FALSE,
+      'has_children' => FALSE,
+      'in_active_trail' => TRUE,
+      'localized_options' => array('attributes' => array('title' => '')),
+      'weight' => 0,
+    );
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function getInfo() {
+      return array(
+        'name' => 'Tests \Drupal\menu_link\MenuTree',
+        'description' => '',
+        'group' => 'Menu',
+      );
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function setUp() {
+      $this->cacheBackend = $this->getMock('Drupal\Core\Cache\CacheBackendInterface');
+      $this->cacheContexts = $this->getMockBuilder('Drupal\Core\Cache\CacheContexts')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $this->cacheContexts->expects($this->any())
+        ->method('convertTokensToKeys')
+        ->with($this->isType('array'))
+        ->will($this->returnCallback(function($keys) {
+          foreach ($keys as $index => $key) {
+            if ($key === 'cache_context.user.roles') {
+              $keys[$index] = 'r.authenticated';
+            }
+          }
+          return $keys;
+        }));
+      $this->entityManager = $this->getMock('Drupal\Core\Entity\EntityManagerInterface');
+      $this->currentUser = $this->getMock('Drupal\Core\Session\AccountInterface');
+      $this->currentUser->expects($this->any())
+        ->method('id')
+        ->will($this->returnValue(mt_rand(1, 100)));
+      $this->accessManager = $this->getMockBuilder('Drupal\Core\Access\AccessManager')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $this->routeProvider = $this->getMock('Drupal\Core\Routing\RouteProviderInterface');
+      $this->paramConverter = $this->getMock('Drupal\Core\ParamConverter\ParamConverterManagerInterface');
+      $this->requestStack = new RequestStack();
+
+      // MenuTree::build() with no $parameters argument will call the default menu
+      // tree manipulators service, mock as needed.
+      $this->defaultMenuTreeManipulators = $this->getMockBuilder('\Drupal\menu_link\DefaultMenuTreeManipulators')
+        ->disableOriginalConstructor()
+        ->setMethods(array('translate', 'setHrefPropertyIfExternal', 'checkNonDynamicAccess'))
+        ->getMock();
+      $this->defaultMenuTreeManipulators->expects($this->any())
+        ->method('translate')
+        ->will($this->returnArgument(0));
+      $this->defaultMenuTreeManipulators->expects($this->any())
+        ->method('setHrefPropertyIfExternal')
+        ->will($this->returnArgument(0));
+      $this->defaultMenuTreeManipulators->expects($this->any())
+        ->method('checkNonDynamicAccess')
+        ->will($this->returnArgument(0));
+
+      $this->menuTree = $this->getMockBuilder('\Drupal\menu_link\MenuTreeInterface')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $this->cacheableAccessCheck = new TestCacheableAccessCheckMenuTreeManipulator($this->menuTree, $this->cacheContexts, $this->cacheBackend, $this->entityManager, $this->currentUser, $this->accessManager, $this->routeProvider, $this->paramConverter, $this->requestStack);
+      $this->cacheableAccessCheck->setAccessCachingContexts(array('cache_context.user.roles'));
+    }
+
+    /**
+     * Tests accountHasAccess().
+     *
+     * @covers ::accountHasAccess()
+     */
+    public function testAccountHasAccess() {
+      // Mock a session for the current user.
+      $session = $this->getMockBuilder('Drupal\Core\Session\UserSession')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $session->expects($this->exactly(2))
+        ->method('id')
+        ->will($this->returnValue($this->currentUser->id()));
+
+      // Mock the cached results for ::getAccountDynamicAccess().
+      $cid = 'menu:test_menu:account_access_results:' . $this->currentUser->id();
+      $cache_item = (object) array(
+        'data' => array(
+          'some_link_path' => TRUE,
+        ),
+      );
+      $this->cacheBackend->expects($this->once())
+        ->method('get')
+        ->with($cid)
+        ->will($this->returnValue($cache_item));
+
+      $account_has_access = $this->cacheableAccessCheck->accountHasAccess('test_menu', 'some_link_path', $session);
+      $this->assertTrue($account_has_access );
+    }
+
+    /**
+     * This mocks a tree with the following structure:
+     * - 1
+     * - 2
+     *   - 3
+     *     - 4
+     * - 5
+     *   - 7
+     * - 6
+     * - 8
+     *
+     * With link 6 being the only external link.
+     */
+    protected function mockTree() {
+      $this->links = array(
+        1 => new MenuLink(array('mlid' => 1, 'depth' => 1, 'route_name' => 'example1', 'weight' => 0, 'link_title' => 'foo', 'machine_name' => 'test.example1'), 'menu_link'),
+        2 => new MenuLink(array('mlid' => 2, 'depth' => 1, 'route_name' => 'example2', 'weight' => 0, 'link_title' => 'bar', 'machine_name' => 'test.example2'), 'menu_link'),
+        3 => new MenuLink(array('mlid' => 3, 'depth' => 2, 'route_name' => 'example3', 'weight' => 0, 'link_title' => 'baz', 'machine_name' => 'test.example3'), 'menu_link'),
+        4 => new MenuLink(array('mlid' => 4, 'depth' => 3, 'route_name' => 'example4', 'weight' => 0, 'link_title' => 'qux', 'machine_name' => 'test.example4'), 'menu_link'),
+        5 => new MenuLink(array('mlid' => 5, 'depth' => 1, 'route_name' => 'example5', 'weight' => 0, 'link_title' => 'foofoo'), 'menu_link'),
+        6 => new MenuLink(array('mlid' => 6, 'depth' => 1, 'external' => 'TRUE', 'link_path' => 'https://drupal.org/', 'weight' => 0, 'link_title' => 'barbar'), 'menu_link'),
+        7 => new MenuLink(array('mlid' => 7, 'depth' => 1, 'route_name' => 'example7', 'weight' => 0, 'link_title' => 'bazbaz'), 'menu_link'),
+        8 => new MenuLink(array('mlid' => 8, 'depth' => 1, 'route_name' => 'example8', 'weight' => 0, 'link_title' => 'quxqux'), 'menu_link'),
+      );
+      $this->originalTree = array();
+      $this->originalTree[1] = new MenuTreeItem($this->links[1], array());
+      $this->originalTree[2] = new MenuTreeItem($this->links[2], array(
+        3 => new MenuTreeItem($this->links[3], array(
+            4 => new MenuTreeItem($this->links[4], array()),
+          )),
+      ));
+      $this->originalTree[5] = new MenuTreeItem($this->links[5], array(
+        7 => new MenuTreeItem($this->links[7], array()),
+      ));
+      $this->originalTree[6] = new MenuTreeItem($this->links[6], array());
+      $this->originalTree[8] = new MenuTreeItem($this->links[8], array());
+    }
+
+    /**
+     * Tests the checkNonDynamicAccess() tree manipulator.
+     *
+     * @covers ::checkNonDynamicAccess
+     */
+    public function testCheckNonDynamicAccess() {
+      // We let the mocked AccessManager return a subset of the actual results
+      // for AccessManager::getCacheableAccessChecks().
+      $cacheable_access_checks = array(
+        // Globally cacheable.
+        'access_check.default' => array(),
+        // Cacheable per user.
+        'access_check.entity_create' => array('cache_context.user'),
+        // Cacheable per user role.
+        'access_check.permission' => array('cache_context.user.roles',)
+      );
+      $this->accessManager->expects($this->once())
+        ->method('getCacheableAccessChecks')
+        ->will($this->returnValue($cacheable_access_checks));
+
+      // For each non-external menu link, ::checkNonDynamicAccess() will=
+      // determine the access checks for its route. Mock all scenarios.
+      $mocked_route = $this->getMockBuilder('\Symfony\Component\Routing\Route')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $route_1 = clone $mocked_route;
+      $route_1->expects($this->once())
+        ->method('getOption')
+        ->with('_access_checks')
+        ->will($this->returnValue(array('access_check.default')));
+      $route_2 = clone $mocked_route;
+      $route_2->expects($this->once())
+        ->method('getOption')
+        ->with('_access_checks')
+        ->will($this->returnValue(array('access_check.entity_create')));
+      $route_3 = clone $mocked_route;
+      $route_3->expects($this->once())
+        ->method('getOption')
+        ->with('_access_checks')
+        ->will($this->returnValue(array('access_check.permission')));
+      // The globally cacheable and per-role cacheable access checks.
+      $route_4 = clone $mocked_route;
+      $route_4->expects($this->once())
+        ->method('getOption')
+        ->with('_access_checks')
+        ->will($this->returnValue(array('access_check.default', 'access_check.permission')));
+      // The globally cacheable and per-user cacheable access checks.
+      $route_5 = clone $mocked_route;
+      $route_5->expects($this->once())
+        ->method('getOption')
+        ->with('_access_checks')
+        ->will($this->returnValue(array('access_check.default', 'access_check.entity_create')));
+      // An *uncacheable* access check.
+      $route_7 = clone $mocked_route;
+      $route_7->expects($this->once())
+        ->method('getOption')
+        ->with('_access_checks')
+        ->will($this->returnValue(array('access_check.uncacheable')));
+      // A non-existent route.
+      $route_8 = clone $mocked_route;
+      $route_8->expects($this->once())
+        ->method('getOption')
+        ->with('_access_checks')
+        ->will($this->throwException(new RouteNotFoundException));
+      $this->routeProvider->expects($this->exactly(7))
+        ->method('getRouteByName')
+        ->will($this->returnValueMap(array(
+          array('example1', array('foo' => 'bar'), $route_1),
+          array('example2', array(), $route_2),
+          array('example3', array('baz' => 'qux'), $route_3),
+          array('example4', array(), $route_4),
+          array('example5', array(), $route_5),
+          array('example7', array(), $route_7),
+          array('example8', array(), $route_8),
+        )));
+
+      // Those menu links that are non-external and whose routes only have non-
+      // dynamic access checks will have their access checks performed. We mock
+      // them, and pretend the current user has access to all of them but one.
+      $this->accessManager->expects($this->exactly(3))
+        ->method('checkNamedRoute')
+        ->will($this->returnValueMap(array(
+          array('example1', array('foo' => 'bar'), $this->currentUser, NULL, TRUE),
+          array('example3', array('baz' => 'qux'), $this->currentUser, NULL, TRUE),
+          array('example4', array(), $this->currentUser, NULL, FALSE),
+        )));
+
+      $this->mockTree();
+      $this->links[1]['route_parameters'] = array('foo' => 'bar');
+      $this->links[3]['route_parameters'] = serialize(array('baz' => 'qux'));
+      $tree = $this->cacheableAccessCheck->checkNonDynamicAccess($this->originalTree);
+
+      // Menu link 1: globally cacheable access check, hence 'access'.
+      $link = $tree[1]->link;
+      $this->assertFalse(isset($link['needs_dynamic_access_check']));
+      $this->assertTrue(isset($link['access']));
+      $this->assertTrue($link['access']);
+      // Menu link 2: per-user cacheable access check, hence 'needs_dynamic_access_check'.
+      $link = $tree[2]->link;
+      $this->assertTrue(isset($link['needs_dynamic_access_check']));
+      $this->assertFalse(isset($link['access']));
+      $this->assertSame('menu_link.cacheable_access_check_tree_manipulator:accountHasAccess', $link['needs_dynamic_access_check']);
+      // Menu link 3: per-role cacheable access check, hence 'access'.
+      $link = $tree[2]->children[3]->link;
+      $this->assertFalse(isset($link['needs_dynamic_access_check']));
+      $this->assertTrue(isset($link['access']));
+      $this->assertTrue($link['access']);
+      // Menu link 4: globally and per-role cacheable access checks, yet access
+      // forbidden, hence removed.
+      $this->assertFalse(array_key_exists(4, $tree[2]->children[3]->children));
+      // Menu link 5: globally and per-user cacheable access checks, hence 'needs_dynamic_access_check'..
+      $link = $tree[5]->link;
+      $this->assertTrue(isset($link['needs_dynamic_access_check']));
+      $this->assertFalse(isset($link['access']));
+      $this->assertSame('menu_link.cacheable_access_check_tree_manipulator:accountHasAccess', $link['needs_dynamic_access_check']);
+      // Menu link 6: external URL, hence 'access'.
+      $link = $tree[6]->link;
+      $this->assertFalse(isset($link['needs_dynamic_access_check']));
+      $this->assertTrue(isset($link['access']));
+      // Menu link 7: uncacheable access check, hence 'needs_dynamic_access_check'.
+      $link = $tree[5]->children[7]->link;
+      $this->assertTrue(isset($link['needs_dynamic_access_check']));
+      $this->assertFalse(isset($link['access']));
+      $this->assertSame('menu_link.cacheable_access_check_tree_manipulator:accountHasAccess', $link['needs_dynamic_access_check']);
+      // Menu link 8: non-existent route, hence equivalent to access forbidden,
+      // hence removed.
+      $this->assertFalse(array_key_exists(8, $tree));
+    }
+
+    /**
+     * Helper function to mock a test menu for unit testing purposes.
+     *
+     * @param \Drupal\menu_link\MenuLinkInterface[] $links
+     *   The menu link objects to be mocked.
+     */
+    protected function mockTestMenuFromMockedLinks(array $links) {
+      $ids = array_keys($links);
+
+      // Setup query and the query result.
+      $query = $this->getMock('Drupal\Core\Entity\Query\QueryInterface');
+      $this->entityQueryFactory->expects($this->once())
+        ->method('get')
+        ->with('menu_link')
+        ->will($this->returnValue($query));
+      $query->expects($this->once())
+        ->method('condition')
+        ->with('menu_name', 'test_menu');
+      $query->expects($this->once())
+        ->method('execute')
+        ->will($this->returnValue($ids));
+
+      $storage = $this->getMock('Drupal\menu_link\MenuLinkStorageInterface');
+      $storage->expects($this->once())
+        ->method('loadMultiple')
+        ->with($ids)
+        ->will($this->returnValue($links));
+      $this->menuTree->setMenuLinkStorage($storage);
+    }
+
+    /**
+     * Provides test data for testAnalyzeAccessCacheability() and testAnalyzeAccessCacheabilityWithParameters().
+     */
+    public function providerTestAnalyzeAccessCacheability() {
+      $data = array();
+
+      $menu_link_1_properties = array(
+        'mlid' => 1,
+        'depth' => 1,
+        'weight' => 0,
+        'title' => '',
+        'link_path' => 'example1',
+        'route_name' => 'example1_route',
+      );
+      $menu_link_2_properties = array(
+        'mlid' => 2,
+        'depth' => 1,
+        'weight' => 0,
+        'title' => '',
+        'link_path' => 'example2',
+        'route_name' => 'example2_route',
+      );
+      $menu_link_3_properties = array(
+        'mlid' => 3,
+        'depth' => 1,
+        'weight' => 0,
+        'title' => '',
+        'link_path' => 'example3',
+        'route_name' => 'example3_route',
+      );
+      $menu_link_4_properties = array(
+        'mlid' => 4,
+        'p1' => 3,
+        'p2' => 4,
+        'depth' => 2,
+        'weight' => 0,
+        'title' => '',
+        'link_path' => 'example4',
+        'route_name' => 'example4_route',
+      );
+      $menu_link_5_properties = array(
+        'mlid' => 5,
+        'p1' => 3,
+        'p2' => 4,
+        'p3' => 5,
+        'depth' => 3,
+        'weight' => 0,
+        'title' => '',
+        'link_path' => 'example5',
+        'route_name' => 'example5_route',
+      );
+
+      // Test case 1: a menu tree containing only non-dynamic access menu links
+      // that are all accessible.
+      $tree = array();
+      $tree[1] = new MenuTreeItem(new MenuLink($menu_link_1_properties + array('access' => TRUE), 'menu_link'), array());
+      $tree[2] = new MenuTreeItem(new MenuLink($menu_link_2_properties + array('access' => TRUE), 'menu_link'), array());
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('access' => TRUE), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array(),
+        array(),
+        TRUE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 2: a menu tree containing only non-dynamic access menu links
+      // that are all inaccessible.
+      $tree = array();
+      $tree[1] = new MenuTreeItem(new MenuLink($menu_link_1_properties + array('access' => FALSE), 'menu_link'), array());
+      $tree[2] = new MenuTreeItem(new MenuLink($menu_link_2_properties + array('access' => FALSE), 'menu_link'), array());
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('access' => FALSE), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array(),
+        array(),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 3: a menu tree whose menu links are all hidden.
+      $tree = array();
+      $tree[1] = new MenuTreeItem(new MenuLink($menu_link_1_properties + array('hidden' => TRUE), 'menu_link'), array());
+      $tree[2] = new MenuTreeItem(new MenuLink($menu_link_2_properties + array('hidden' => TRUE), 'menu_link'), array());
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('hidden' => TRUE), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array(),
+        array(),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 4: a menu tree with two hidden menu links and two non-dynamic
+      // access menu links of which only one is accessible.
+      $tree = array();
+      $tree[1] = new MenuTreeItem(new MenuLink($menu_link_1_properties + array('hidden' => TRUE), 'menu_link'), array());
+      $tree[2] = new MenuTreeItem(new MenuLink($menu_link_2_properties + array('access' => FALSE), 'menu_link'), array());
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('access' => TRUE), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array(),
+        array(),
+        TRUE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 5: a menu tree with a hidden menu link, one non-dynamic access
+      // menu link that is inaccessible and one dynamic access menu link.
+      $tree = array();
+      $tree[1] = new MenuTreeItem(new MenuLink($menu_link_1_properties + array('hidden' => TRUE), 'menu_link'), array());
+      $tree[2] = new MenuTreeItem(new MenuLink($menu_link_2_properties + array('access' => FALSE), 'menu_link'), array());
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array('example3'),
+        array(
+          'example3' => array('example3_route', array()),
+        ),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 6: a menu tree with a hidden menu link, one non-dynamic
+      // access menu link that is accessible and one dynamic access menu link.
+      $tree = array();
+      $tree[1] = new MenuTreeItem(new MenuLink($menu_link_1_properties + array('hidden' => TRUE), 'menu_link'), array());
+      $tree[2] = new MenuTreeItem(new MenuLink($menu_link_2_properties + array('access' => TRUE), 'menu_link'), array());
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array('example3'),
+        array(
+          'example3' => array('example3_route', array()),
+        ),
+        TRUE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 7: (recursion correctness test) a menu tree with one non-
+      // dynamic access menu link that is accessible with a child dynamic access
+      // menu link.
+      $tree = array();
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('access' => TRUE), 'menu_link'), array());
+      $tree[3]->children[4] = new MenuTreeItem(new MenuLink($menu_link_4_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array('example4'),
+        array(
+          'example4' => array('example4_route', array()),
+        ),
+        TRUE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 8: (recursion correctness test) a menu tree with one non-
+      // dynamic access menu link that is inaccessible with a child dynamic
+      // access menu link.
+      $tree = array();
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('access' => FALSE), 'menu_link'), array());
+      $tree[3]->children[4] = new MenuTreeItem(new MenuLink($menu_link_4_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array(),
+        array(),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 9: (recursion correctness test) a menu tree with one dynamic
+      // access menu link, with a child dynamic access menu link.
+      $tree = array();
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $tree[3]->children[4] = new MenuTreeItem(new MenuLink($menu_link_4_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array('example3'),
+        array(
+          'example3' => array('example3_route', array()),
+          'example4' => array('example4_route', array()),
+        ),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 10: (recursion correctness test) a menu tree with one dynamic
+      // access menu link, with a child non-dynamic access menu link that is
+      // accessible.
+      $tree = array();
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $tree[3]->children[4] = new MenuTreeItem(new MenuLink($menu_link_4_properties + array('access' => TRUE), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array('example3'),
+        array(
+          'example3' => array('example3_route', array()),
+        ),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 11: (recursion correctness test) a menu tree with one dynamic
+      // access menu link, with a child non-dynamic access menu link that is
+      // inaccessible.
+      $tree = array();
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $tree[3]->children[4] = new MenuTreeItem(new MenuLink($menu_link_4_properties + array('access' => FALSE), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array('example3'),
+        array(
+          'example3' => array('example3_route', array()),
+        ),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 12: (recursion correctness test) a menu tree with one dynamic
+      // access menu link, with a child non-dynamic access menu link that is
+      // accessible, with again a child dynamic access menu link.
+      $tree = array();
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $tree[3]->children[4] = new MenuTreeItem(new MenuLink($menu_link_4_properties + array('access' => TRUE), 'menu_link'), array());
+      $tree[3]->children[4]->children[5] = new MenuTreeItem(new MenuLink($menu_link_5_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array('example3'),
+        array(
+          'example3' => array('example3_route', array()),
+          'example5' => array('example5_route', array()),
+        ),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      // Test case 13: (recursion correctness test) a menu tree with one dynamic
+      // access menu link, with a child non-dynamic access menu link that is
+      // inaccessible, with again a child dynamic access menu link.
+      $tree = array();
+      $tree[3] = new MenuTreeItem(new MenuLink($menu_link_3_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $tree[3]->children[4] = new MenuTreeItem(new MenuLink($menu_link_4_properties + array('access' => FALSE), 'menu_link'), array());
+      $tree[3]->children[4]->children[5] = new MenuTreeItem(new MenuLink($menu_link_5_properties + array('needs_dynamic_access_check' => 'menu_link.cacheable_access_check_tree_manipulator:accountHasAccess'), 'menu_link'), array());
+      $expected_access_cacheability_analysis = array(
+        array('example3'),
+        array(
+          'example3' => array('example3_route', array()),
+        ),
+        FALSE,
+      );
+      $data[] = array($tree, $expected_access_cacheability_analysis);
+
+      return $data;
+    }
+
+    /**
+     * Tests analyzeAccessCacheability().
+     *
+     * @covers ::analyzeAccessCacheability()
+     * @covers ::recursiveAnalyzer()
+     * @dataProvider providerTestAnalyzeAccessCacheability
+     */
+    public function testAnalyzeAccessCacheability($tree, $expected_access_cacheability_analysis) {
+      // Mock missing cache item, and verify that it gets set later on.
+      $cid = 'menu:test_menu:access_cacheability_analysis:r.authenticated';
+      $this->cacheBackend->expects($this->at(0))
+        ->method('get')
+        ->with($cid)
+        ->will($this->returnValue(FALSE));
+      $expected_cache_tags = array(
+        'menu' => array('test_menu'),
+      );
+      $this->cacheBackend->expects($this->at(1))
+        ->method('set')
+        ->with($cid, $expected_access_cacheability_analysis, Cache::PERMANENT, $expected_cache_tags);
+
+      // Mock built tree.
+      $this->menuTree->expects($this->once())
+        ->method('build')
+        ->will($this->returnValue($tree));
+
+      // Mock menu entity storage, to allow this menu's cache tag to be retrieved.
+      $menu = $this->getMockBuilder('Drupal\system\Entity\Menu')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $menu->expects($this->once())
+        ->method('getCacheTag')
+        ->will($this->returnValue(array('menu' => array('test_menu'))));
+      $storage = $this->getMock('Drupal\Core\Config\Entity\ConfigEntityStorageInterface');
+      $storage->expects($this->exactly(1))
+        ->method('load')
+        ->with('test_menu')
+        ->will($this->returnValue($menu));
+      $this->cacheableAccessCheck->setMenuStorage($storage);
+      $this->assertEquals($expected_access_cacheability_analysis, $this->cacheableAccessCheck->analyzeAccessCacheability('test_menu'));
+    }
+
+    /**
+     * Tests analyzeAccessCacheability() with its optional $parameters argument.
+     *
+     * @covers ::analyzeAccessCacheability()
+     * @covers ::recursiveAnalyzer()
+     * @dataProvider providerTestAnalyzeAccessCacheability
+     */
+    public function testAnalyzeAccessCacheabilityWithParameters($tree, $expected_access_cacheability_analysis) {
+      // Use custom menu tree parameters; it doesn't matter which ones are used,
+      // all that matters is that custom menu tree parameters are passed at all.
+      $parameters = new MenuTreeParameters();
+      $parameters->appendManipulator('menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess');
+      $parameters->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort');
+
+      // Mock missing cache item, and verify that it gets set later on.
+      $cid = 'menu:test_menu:access_cacheability_analysis:r.authenticated:' . hash('sha256', serialize($parameters));
+      $this->cacheBackend->expects($this->at(0))
+        ->method('get')
+        ->with($cid)
+        ->will($this->returnValue(FALSE));
+      $expected_cache_tags = array(
+        'menu' => array('test_menu'),
+      );
+      $this->cacheBackend->expects($this->at(1))
+        ->method('set')
+        ->with($cid, $expected_access_cacheability_analysis, Cache::PERMANENT, $expected_cache_tags);
+
+      // Mock built tree.
+      $this->menuTree->expects($this->once())
+        ->method('build')
+        ->will($this->returnValue($tree));
+
+      // Mock menu entity storage, to allow this menu's cache tag to be retrieved.
+      $menu = $this->getMockBuilder('Drupal\system\Entity\Menu')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $menu->expects($this->once())
+        ->method('getCacheTag')
+        ->will($this->returnValue(array('menu' => array('test_menu'))));
+      $storage = $this->getMock('Drupal\Core\Config\Entity\ConfigEntityStorageInterface');
+      $storage->expects($this->exactly(1))
+        ->method('load')
+        ->with('test_menu')
+        ->will($this->returnValue($menu));
+      $this->cacheableAccessCheck->setMenuStorage($storage);
+      $this->assertEquals($expected_access_cacheability_analysis, $this->cacheableAccessCheck->analyzeAccessCacheability('test_menu', $parameters));
+    }
+
+    /**
+     * Tests analyzeAccessCacheability() with an invalid $parameters argument.
+     *
+     * @covers ::analyzeAccessCacheability()
+     * @expectedException \LogicException
+     * @expectedExceptionMessage Cannot analyze cacheability of the test_menu menu with custom menu tree parameters if the menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess tree manipulator is not included.
+     */
+    public function testAnalyzeAccessCacheabilityWithInvalidParameters() {
+      // Use custom menu tree parameters; but exclude the required manipulator
+      // 'menu_link.default_tree_manipulators:checkNonDynamicAccess'.
+      $parameters = new MenuTreeParameters();
+      $parameters->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort');
+      $this->cacheableAccessCheck->analyzeAccessCacheability('test_menu', $parameters);
+    }
+
+    /**
+     * Tests analyzeAccessCacheability() with a cached result.
+     *
+     * @covers ::analyzeAccessCacheability()
+     */
+    public function testAnalyzeAccessCacheabilityCached() {
+      $expected_result = array(
+        array(),
+        TRUE,
+      );
+
+      // Mock available cache item.
+      $cid = 'menu:test_menu:access_cacheability_analysis:r.authenticated';
+      $this->cacheBackend->expects($this->at(0))
+        ->method('get')
+        ->with($cid)
+        ->will($this->returnValue((object) array('data' => $expected_result)));
+
+      $this->assertSame($expected_result, $this->cacheableAccessCheck->analyzeAccessCacheability('test_menu'));
+    }
+
+    /**
+     * Tests getAccountDynamicAccess().
+     *
+     * @covers ::getAccountDynamicAccess()
+     * @covers ::doGetAccountDynamicAccess()
+     */
+    public function testGetAccountDynamicAccess() {
+      // Mock a session for the current user.
+      $session = $this->getMockBuilder('Drupal\Core\Session\UserSession')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $session->expects($this->exactly(6))
+        ->method('id')
+        ->will($this->returnValue($this->currentUser->id()));
+      $session->expects($this->exactly(1))
+        ->method('isAnonymous')
+        ->will($this->returnValue(FALSE));
+
+      // Tests menu A, whose results are cached.
+      $cid = 'menu:menu_a:account_access_results:' . $this->currentUser->id();
+      $cache_item = (object) array(
+        'data' => array(
+          'a' => TRUE,
+        ),
+      );
+      $this->cacheBackend->expects($this->at(0))
+        ->method('get')
+        ->with($cid)
+        ->will($this->returnValue($cache_item));
+      $account_access = $this->cacheableAccessCheck->getAccountDynamicAccess('menu_a', $session);
+      $this->assertEquals(array('a' => TRUE), $account_access);
+
+      // Test menu B, which will test the full flow.
+      // With uncached results.
+      $cid = 'menu:menu_b:account_access_results:' . $this->currentUser->id();
+      $this->cacheBackend->expects($this->at(0))
+        ->method('get')
+        ->with($cid)
+        ->will($this->returnValue(FALSE));
+      // But with cached results for ::analyzeAccessCacheability().
+      $cid = 'menu:menu_b:access_cacheability_analysis:r.authenticated';
+      $this->cacheBackend->expects($this->at(1))
+        ->method('get')
+        ->with($cid)
+        ->will($this->returnValue((object) array(
+          'data' => array(
+            // The menu links needing dynamic access checks that determine the
+            // emptiness of the rendered menu tree: irrelevant to this test.
+            array(),
+            // The menu links needing dynamic access checks: three.
+            // @see ::analyzeAccessCacheability()
+            array(
+              // An existing route, to test one code path of
+              // ::doGetAccountDynamicAccess().
+              'b' => array(
+                'testing_route_b',
+                array(),
+              ),
+              // A non-existing route, to test another code path of
+              // ::doGetAccountDynamicAccess().
+              'b2' => array(
+                'non_existing_route_b2',
+                array(),
+              ),
+            ),
+            // Whether there are >0 links that don't need dynamic access checks.
+            TRUE,
+          ),
+        )));
+      // Mock menu entity storage, to allow this menu's cache tag to be retrieved.
+      $menu = $this->getMockBuilder('Drupal\system\Entity\Menu')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $menu->expects($this->once())
+        ->method('getCacheTag')
+        ->will($this->returnValue(array('menu' => array('menu_b'))));
+      $storage = $this->getMock('Drupal\Core\Config\Entity\ConfigEntityStorageInterface');
+      $storage->expects($this->exactly(1))
+        ->method('load')
+        ->with('menu_b')
+        ->will($this->returnValue($menu));
+      $this->cacheableAccessCheck->setMenuStorage($storage);
+      // Mock user entity storage, to allow the current user's cache tag to be
+      // retrieved.
+      $user = $this->getMockBuilder('Drupal\user\Entity\User')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $user->expects($this->once())
+        ->method('getCacheTag')
+        ->will($this->returnValue(array('user' => array($this->currentUser->id()))));
+      $storage = $this->getMockBuilder('Drupal\user\UserStorage')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $storage->expects($this->exactly(1))
+        ->method('load')
+        ->with($this->currentUser->id())
+        ->will($this->returnValue($user));
+      $this->cacheableAccessCheck->setUserStorage($storage);
+      // Mock route provider, to allow the route to be looked up.
+      $route = $this->getMockBuilder('Symfony\Component\Routing\Route')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $route_defaults = array(
+        '_title' => 'Testing Route B',
+        //  This route has a default 'animal' entity route parameter, with
+        // animal entity ID 'llama'. We expect this to be upcasted to the
+        // actual entity by the parameter converter.
+        'animal' => 'llama',
+      );
+      $route->expects($this->once())
+        ->method('getDefaults')
+        ->will($this->returnValue($route_defaults));
+      $this->routeProvider->expects($this->at(0))
+        ->method('getRouteByName')
+        ->with('testing_route_b', array())
+        ->will($this->returnValue($route));
+      $this->routeProvider->expects($this->at(1))
+        ->method('getRouteByName')
+        ->with('non_existing_route_b2', array())
+        ->will($this->throwException(new RouteNotFoundException()));
+      // Mock the request stack; it doesn't matter what the pretended current
+      // route is, it's only going to be duplicated anyway.
+      $request = (new Request());
+      $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'random');
+      $this->requestStack->push($request);
+      // Mock the parameter converter; let it enhance the 'animal' parameter into
+      // an actual entity.
+      $expected_defaults = $route_defaults;
+      $expected_defaults[RouteObjectInterface::ROUTE_OBJECT] = $route;
+      $animal_entity = $this->getMockBuilder('Drupal\Core\Entity\Entity')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $animal_entity->expects($this->once())
+        ->method('getCacheTag')
+        ->will($this->returnValue(array('animal' => array('llama'))));
+      $this->paramConverter->expects($this->once())
+        ->method('convert')
+        ->with($expected_defaults, $this->isInstanceOf('Symfony\Component\HttpFoundation\Request'))
+        ->will($this->returnCallback(function ($defaults) use ($animal_entity) {
+          $defaults['animal'] = $animal_entity;
+          return $defaults;
+        }));
+      // Mock the access manager to indicate that the route is accessible.
+      $this->accessManager->expects($this->once())
+        ->method('check')
+        ->with($route, $this->isInstanceOf('Symfony\Component\HttpFoundation\Request'), $session)
+        ->will($this->returnValue(TRUE));
+      // Assert that the correct result is written to cache backend. Also assert
+      // that the correct cache tags are associated; this is *crucial* for main-
+      // taining correctness as the entities in Drupal change.
+      $cid = 'menu:menu_b:account_access_results:' . $this->currentUser->id();
+      $expected_result = array(
+        // The given account indeed does have access to this link path.
+        'b' => TRUE,
+        'b2' => FALSE,
+      );
+      $expected_cache_tags = array(
+        'menu' => array('menu_b'),
+        'user' => array($this->currentUser->id()),
+        'animal' => array('llama'),
+      );
+      $this->cacheBackend->expects($this->once())
+        ->method('set')
+        ->with($cid, $expected_result, Cache::PERMANENT, $expected_cache_tags);
+      $account_access = $this->cacheableAccessCheck->getAccountDynamicAccess('menu_b', $session);
+      $this->assertEquals($expected_result, $account_access);
+    }
+
+    /**
+     * Provides test data for testWillBeEmptyForAccount().
+     */
+    public function providerTestWillBeEmptyForAccount() {
+      $data = array();
+
+      // ::analyzeAccessCacheability() returns a triplet of values. Their
+      // respective potential cases are:
+      // - first value (link paths):
+      //   1. empty array (0 emptiness determining dynamic access check menu
+      //      links)
+      //   2. non-empty array (>0 emptiness determining dynamic access check
+      //      menu links)
+      //     a. 0 of those are accessible to the (mocked) current user
+      //     b. >0 of those are accessible to the (mocked) current user
+      // - second value: not used by ::willBeEmptyForAccount()
+      // - third value (boolean):
+      //   1. FALSE (no non-dynamic access check menu links are visible for the
+      //      current user's role)
+      //   2. TRUE (>0 non-dynamic access check menu links are visible for the
+      //      current user's role)
+      $first_value_cases = array(
+        0 => array(),
+        1 => array('inaccessible'),
+        2 => array('accessible', 'inaccessible'),
+      );
+      $third_value_cases = array(
+        0 => FALSE,
+        1 => TRUE,
+      );
+
+      // Build all possible scenarios from this complete list of cases.
+      $data[] = array($first_value_cases[0], $third_value_cases[0], TRUE);
+      $data[] = array($first_value_cases[0], $third_value_cases[1], FALSE);
+      $data[] = array($first_value_cases[1], $third_value_cases[0], TRUE);
+      $data[] = array($first_value_cases[1], $third_value_cases[1], FALSE);
+      $data[] = array($first_value_cases[2], $third_value_cases[0], FALSE);
+      $data[] = array($first_value_cases[2], $third_value_cases[1], FALSE);
+
+      return $data;
+    }
+
+    /**
+     * Tests willBeEmptyForAccount().
+     *
+     * @param mixed $first_value
+     *   The first value of an ::analyzeAccessCacheability() return value pair.
+     * @param mixed $third_value
+     *   The third value of an ::analyzeAccessCacheability() return value pair.
+     * @param bool $expected
+     *   The expected return value of ::willBeEmptyForAccount().
+     *
+     * @covers ::willBeEmptyForAccount()
+     * @dataProvider providerTestWillBeEmptyForAccount
+     */
+    public function testWillBeEmptyForAccount($first_value, $third_value, $expected) {
+      // Mock a session for the current user.
+      $session = $this->getMockBuilder('Drupal\Core\Session\UserSession')
+        ->disableOriginalConstructor()
+        ->getMock();
+      $session->expects($this->any())
+        ->method('id')
+        ->will($this->returnValue($this->currentUser->id()));
+
+      // Mock the access cacheability analysis results.
+      $this->cacheBackend->expects($this->any())
+        ->method('get')
+        ->will($this->returnValueMap(array(
+          array('menu:test_menu:access_cacheability_analysis:r.authenticated', FALSE, (object) array('data' => array($first_value, array(), $third_value))),
+          array(
+            'menu:test_menu:account_access_results:' . $this->currentUser->id(),
+            FALSE,
+            (object) array(
+              'data' => array(
+                'inaccessible' => FALSE,
+                'accessible' => TRUE,
+              ),
+            ),
+          ),
+        )));
+
+      $this->assertSame($expected, $this->cacheableAccessCheck->willBeEmptyForAccount('test_menu', $session));
+    }
+
+  }
+
+  class TestCacheableAccessCheckMenuTreeManipulator extends CacheableAccessCheckMenuTreeManipulator {
+
+    /**
+     * Set the menu entity storage.
+     *
+     * @param \Drupal\Core\Config\Entity\ConfigEntityStorageInterface $storage
+     *   The menu entity storage.
+     */
+    public function setMenuStorage(ConfigEntityStorageInterface $storage) {
+      $this->menuStorage = $storage;
+    }
+
+    /**
+     * Set the user entity storage.
+     *
+     * @param \Drupal\user\UserStorageInterface $storage
+     *   The menu entity storage.
+     */
+    public function setUserStorage(UserStorageInterface $storage) {
+      $this->userStorage = $storage;
+    }
+
+  }
+
+}
diff --git a/core/modules/menu_link/tests/src/DefaultMenuTreeManipulatorsTest.php b/core/modules/menu_link/tests/src/DefaultMenuTreeManipulatorsTest.php
new file mode 100644
index 0000000..41552e7
--- /dev/null
+++ b/core/modules/menu_link/tests/src/DefaultMenuTreeManipulatorsTest.php
@@ -0,0 +1,436 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\Tests\DefaultMenuTreeManipulatorsTest.
+ */
+
+namespace Drupal\menu_link\Tests {
+
+  use Drupal\menu_link\MenuTreeItem;
+  use Drupal\menu_link\DefaultMenuTreeManipulators;
+  use Drupal\menu_link\Entity\MenuLink;
+  use Drupal\Tests\UnitTestCase;
+
+  /**
+   * Tests the default menu tree manipulators.
+   *
+   * @group Drupal
+   * @group menu_link
+   * @group kak
+   *
+   * @coversDefaultClass \Drupal\menu_link\DefaultMenuTreeManipulators
+   */
+  class DefaultMenuTreeManipulatorsTest extends UnitTestCase {
+
+    /**
+     * The mocked access manager.
+     *
+     * @var \Drupal\Core\Access\AccessManager|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $accessManager;
+
+    /**
+     * The mocked current user.
+     *
+     * @var \Drupal\Core\Session\AccountInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $currentUser;
+
+    /**
+     * The mocked module handler.
+     *
+     * @var \Drupal\Core\Extension\ModuleHandlerInterface|\PHPUnit_Framework_MockObject_MockObject
+     */
+    protected $moduleHandler;
+
+    /**
+     * The default menu tree manipulators.
+     *
+     * @var \Drupal\menu_link\DefaultMenuTreeManipulators
+     */
+    protected $defaultMenuTreeManipulators;
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function getInfo() {
+      return array(
+        'name' => 'Tests \Drupal\menu_link\DefaultMenuTreeManipulators',
+        'description' => '',
+        'group' => 'Menu',
+      );
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function setUp() {
+      $this->accessManager = $this->getMockBuilder('\Drupal\Core\Access\AccessManager')
+        ->disableOriginalConstructor()->getMock();
+      $this->currentUser = $this->getMock('Drupal\Core\Session\AccountInterface');
+      $this->moduleHandler = $this->getMock('Drupal\Core\Extension\ModuleHandlerInterface');
+
+      // Stub translation manager that just returns the passed string, prefixed
+      // with "translated:".
+      $translation = $this->getMock('Drupal\Core\StringTranslation\TranslationInterface');
+      $translation->expects($this->any())
+        ->method('translate')
+        ->will($this->returnCallback(function ($string, $args, $options) {
+          return \Drupal\Component\Utility\String::format('translated:' . $string, $args, $options);
+        }));
+
+      $this->defaultMenuTreeManipulators = new DefaultMenuTreeManipulators($this->accessManager, $this->currentUser, $this->moduleHandler);
+      $this->defaultMenuTreeManipulators->setStringTranslation($translation);
+    }
+
+    /**
+     * This mocks a tree with the following structure:
+     * - 1
+     * - 2
+     *   - 3
+     *     - 4
+     * - 5
+     *   - 7
+     * - 6
+     * - 8
+     *
+     * With link 6 being the only external link.
+     */
+    protected function mockTree() {
+      $this->links = array(
+        1 => new MenuLink(array('mlid' => 1, 'depth' => 1, 'route_name' => 'example1', 'weight' => 0, 'link_title' => 'foo', 'machine_name' => 'test.example1'), 'menu_link'),
+        2 => new MenuLink(array('mlid' => 2, 'depth' => 1, 'route_name' => 'example2', 'weight' => 0, 'link_title' => 'bar', 'machine_name' => 'test.example2'), 'menu_link'),
+        3 => new MenuLink(array('mlid' => 3, 'depth' => 2, 'route_name' => 'example3', 'weight' => 0, 'link_title' => 'baz', 'machine_name' => 'test.example3'), 'menu_link'),
+        4 => new MenuLink(array('mlid' => 4, 'depth' => 3, 'route_name' => 'example4', 'weight' => 0, 'link_title' => 'qux', 'machine_name' => 'test.example4'), 'menu_link'),
+        5 => new MenuLink(array('mlid' => 5, 'depth' => 1, 'route_name' => 'example5', 'weight' => 0, 'link_title' => 'foofoo'), 'menu_link'),
+        6 => new MenuLink(array('mlid' => 6, 'depth' => 1, 'external' => 'TRUE', 'link_path' => 'https://drupal.org/', 'weight' => 0, 'link_title' => 'barbar'), 'menu_link'),
+        7 => new MenuLink(array('mlid' => 7, 'depth' => 1, 'route_name' => 'example7', 'weight' => 0, 'link_title' => 'bazbaz'), 'menu_link'),
+        8 => new MenuLink(array('mlid' => 8, 'depth' => 1, 'route_name' => 'example8', 'weight' => 0, 'link_title' => 'quxqux'), 'menu_link'),
+      );
+      $this->originalTree = array();
+      $this->originalTree[1] = new MenuTreeItem($this->links[1], array());
+      $this->originalTree[2] = new MenuTreeItem($this->links[2], array(
+        3 => new MenuTreeItem($this->links[3], array(
+            4 => new MenuTreeItem($this->links[4], array()),
+          )),
+      ));
+      $this->originalTree[5] = new MenuTreeItem($this->links[5], array(
+        7 => new MenuTreeItem($this->links[7], array()),
+      ));
+      $this->originalTree[6] = new MenuTreeItem($this->links[6], array());
+      $this->originalTree[8] = new MenuTreeItem($this->links[8], array());
+    }
+
+    /**
+     * Tests the translate() tree manipulator.
+     *
+     * @covers ::translate
+     */
+    public function testTranslate() {
+      $this->mockTree();
+      $this->links[3]['options']['alter'] = TRUE;
+      $this->moduleHandler->expects($this->once())
+        ->method('alter')
+        ->with('translated_menu_link', $this->links[3]);
+      $this->links[4]['customized'] = TRUE;
+      $this->links[8]['options'] = serialize(array('attributes' => array('class' => 'some classes')));
+      $tree = $this->defaultMenuTreeManipulators->translate($this->originalTree);
+
+      // Menu links 1, 2, 3 and 4 are defined in code, but the 4th one is
+      // customized and therefore not translatable.
+      $this->assertEquals('translated:' . $this->links[1]['link_title'], $tree[1]->link['title']);
+      $this->assertEquals('translated:' . $this->links[2]['link_title'], $tree[2]->link['title']);
+      $this->assertEquals('translated:' . $this->links[3]['link_title'], $tree[2]->children[3]->link['title']);
+      $this->assertEquals($this->links[4]['link_title'], $tree[2]->children[3]->children[4]->link['title']);
+
+      // Links 5, 7 and 8 are not defined in code, and link 6 is an external URL
+      // hence none of them are translated.
+      $this->assertEquals($this->links[5]['link_title'], $tree[5]->link['title']);
+      $this->assertEquals($this->links[6]['link_title'], $tree[6]->link['title']);
+      $this->assertEquals($this->links[7]['link_title'], $tree[5]->children[7]->link['title']);
+      $this->assertEquals($this->links[8]['link_title'], $tree[8]->link['title']);
+
+      // Also verify that serialized options are unserialized and a string
+      // "class" attribute is moved over to the localized options.
+      $this->assertEquals(array('some', 'classes'), $tree[8]->link['localized_options']['attributes']['class']);
+    }
+
+    /**
+     * Tests the generateIndexAndSort() tree manipulator.
+     *
+     * @covers ::generateIndexAndSort
+     */
+    public function testGenerateIndexAndSort() {
+      // Closure for simulating ::translate(), without any actual translation,
+      // but with the creation of the 'title' property.
+      $simulated_translate = function (array $tree) use (&$simulated_translate) {
+        foreach ($tree as $key => $item) {
+          $tree[$key]->link['title'] = $tree[$key]->link['link_title'];
+          if ($tree[$key]->children) {
+            $tree[$key]->children = $simulated_translate($tree[$key]->children);
+          }
+        }
+        return $tree;
+      };
+
+      $this->mockTree();
+      $tree = $this->originalTree;
+      $tree = $simulated_translate($tree);
+      $tree = $this->defaultMenuTreeManipulators->generateIndexAndSort($tree);
+
+      // Validate that parent items #1, #2, #5 and #6 exist on the root level.
+      $this->assertEquals($this->links[1]['mlid'], $tree['50000 foo 1']->link['mlid']);
+      $this->assertEquals($this->links[2]['mlid'], $tree['50000 bar 2']->link['mlid']);
+      $this->assertEquals($this->links[5]['mlid'], $tree['50000 foofoo 5']->link['mlid']);
+      $this->assertEquals($this->links[6]['mlid'], $tree['50000 barbar 6']->link['mlid']);
+      $this->assertEquals($this->links[8]['mlid'], $tree['50000 quxqux 8']->link['mlid']);
+
+      // Validate that child item #4 exists at the correct location in the hierarchy.
+      $this->assertEquals($this->links[4]['mlid'], $tree['50000 bar 2']->children['50000 baz 3']->children['50000 qux 4']->link['mlid']);
+      // Validate that child item #7 exists at the correct location in the hierarchy.
+      $this->assertEquals($this->links[7]['mlid'], $tree['50000 foofoo 5']->children['50000 bazbaz 7']->link['mlid']);
+    }
+
+    /**
+     * Tests the setHrefPropertyIfExternal() tree manipulator.
+     *
+     * @covers ::setHrefPropertyIfExternal
+     */
+    public function testSetHrefPropertyIfExternal() {
+      $this->mockTree();
+      $this->links[5]['route_name'] = NULL;
+      $this->links[5]['link_path'] = $this->randomName();
+      $tree = $this->defaultMenuTreeManipulators->setHrefPropertyIfExternal($this->originalTree);
+
+      // Menu link 1, 2, 3, 4, 7 and 8 are not external, nor do they miss a
+      $link = $tree[1]->link;
+      $this->assertTrue(array_key_exists('href', $link));
+      $this->assertNull($link['href']);
+      $link = $tree[2]->link;
+      $this->assertTrue(array_key_exists('href', $link));
+      $this->assertNull($link['href']);
+      $link = $tree[2]->children[3]->link;
+      $this->assertTrue(array_key_exists('href', $link));
+      $this->assertNull($link['href']);
+      $link = $tree[2]->children[3]->children[4]->link;
+      $this->assertTrue(array_key_exists('href', $link));
+      $this->assertNull($link['href']);
+      $link = $tree[5]->children[7]->link;
+      $this->assertTrue(array_key_exists('href', $link));
+      $this->assertNull($link['href']);
+      $link = $tree[8]->link;
+      $this->assertTrue(array_key_exists('href', $link));
+      $this->assertNull($link['href']);
+
+      // Menu link 5 has no route name and is hence treated as an external URL.
+      $link = $tree[5]->link;
+      $this->assertTrue(array_key_exists('href', $link));
+      $this->assertSame($link['href'], $this->links[5]['link_path']);
+      // Menu link 6 is an external URL.
+      $link = $tree[6]->link;
+      $this->assertTrue(array_key_exists('href', $link));
+      $this->assertSame($link['href'], 'https://drupal.org/');
+    }
+
+    /**
+     * Tests the checkAccess() tree manipulator.
+     *
+     * @covers ::checkAccess
+     */
+    public function testCheckAccess() {
+      // Those menu links that are non-external will have their access checks
+      // performed. 8 routes, but 1 is external, 1 has no route name and 2
+      // already have their 'access' property set, so only 4 calls will be made.
+      $this->accessManager->expects($this->exactly(4))
+        ->method('checkNamedRoute')
+        ->will($this->returnValueMap(array(
+          array('example1', array(), $this->currentUser, NULL, TRUE),
+          array('example2', array('foo' => 'bar'), $this->currentUser, NULL, TRUE),
+          array('example3', array('baz' => 'qux'), $this->currentUser, NULL, TRUE),
+          array('example4', array(), $this->currentUser, NULL, FALSE),
+        )));
+
+      $this->mockTree();
+      $this->links[2]['route_parameters'] = array('foo' => 'bar');
+      $this->links[3]['route_parameters'] = serialize(array('baz' => 'qux'));
+      $this->links[5]['route_name'] = NULL;
+      $this->links[7]['access'] = TRUE;
+      $this->links[8]['access'] = FALSE;
+      $tree = $this->defaultMenuTreeManipulators->checkAccess($this->originalTree);
+
+      // Menu link 1: route with parameters, access granted.
+      $link = $tree[1]->link;
+      $this->assertTrue(array_key_exists('access', $link));
+      $this->assertTrue($link['access']);
+      // Menu link 2: route with unserialized parameters, access granted.
+      $link = $tree[2]->link;
+      $this->assertTrue(array_key_exists('access', $link));
+      $this->assertTrue($link['access']);
+      // Menu link 3: route with serialized parameters, access forbidden.
+      $link = $tree[2]->children[3]->link;
+      $this->assertTrue(array_key_exists('access', $link));
+      $this->assertTrue($link['access']);
+      // Menu link 4: route without parameters, access forbidden, hence removed.
+      $this->assertFalse(array_key_exists(4, $tree[2]->children[3]->children));
+      // Menu link 5: no route name, treated as external, hence access granted.
+      $link = $tree[5]->link;
+      $this->assertTrue(array_key_exists('access', $link));
+      $this->assertTrue($link['access']);
+      // Menu link 6: external URL, hence access granted.
+      $link = $tree[6]->link;
+      $this->assertTrue(array_key_exists('access', $link));
+      $this->assertTrue($link['access']);
+      // Menu link 7: 'access' already set.
+      $link = $tree[5]->children[7]->link;
+      $this->assertTrue(array_key_exists('access', $link));
+      $this->assertTrue($link['access']);
+      // Menu link 8: 'access' already set, to FALSE, hence removed.
+      $this->assertFalse(array_key_exists(8, $tree));
+    }
+
+    /**
+     * Tests the extractSubtreeOfActiveTrail() tree manipulator.
+     *
+     * @covers ::extractSubtreeOfActiveTrail
+     */
+    public function testExtractSubtreeOfActiveTrail() {
+      // No link in the active trail.
+      $this->mockTree();
+      // Get level 0.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 0);
+      $this->assertEquals(array(1, 2, 5, 6, 8), array_keys($tree));
+      // Get level 1.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 1);
+      $this->assertEquals(array(), array_keys($tree));
+      // Get level 2.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 1);
+      $this->assertEquals(array(), array_keys($tree));
+
+      // Link 5 in the active trail.
+      $this->mockTree();
+      $this->links[5]['in_active_trail'] = TRUE;
+      // Get level 0.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 0);
+      $this->assertEquals(array(1, 2, 5, 6, 8), array_keys($tree));
+      // Get level 1.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 1);
+      $this->assertEquals(array(7), array_keys($tree));
+      // Get level 2.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 2);
+      $this->assertEquals(array(), array_keys($tree));
+
+      // Link 2 in the active trail.
+      $this->mockTree();
+      $this->links[2]['in_active_trail'] = TRUE;
+      // Get level 0.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 0);
+      $this->assertEquals(array(1, 2, 5, 6, 8), array_keys($tree));
+      // Get level 1.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 1);
+      $this->assertEquals(array(3), array_keys($tree));
+      // Get level 2.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 2);
+      $this->assertEquals(array(), array_keys($tree));
+
+      // Links 2 and 3 in the active trail.
+      $this->mockTree();
+      $this->links[2]['in_active_trail'] = TRUE;
+      $this->links[3]['in_active_trail'] = TRUE;
+      // Get level 0.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 0);
+      $this->assertEquals(array(1, 2, 5, 6, 8), array_keys($tree));
+      // Get level 1.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 1);
+      $this->assertEquals(array(3), array_keys($tree));
+      // Get level 2.
+      $tree = $this->defaultMenuTreeManipulators->extractSubtreeOfActiveTrail($this->originalTree, 2);
+      $this->assertEquals(array(4), array_keys($tree));
+    }
+
+    /**
+     * Tests the setTreeItemClass() tree manipulator.
+     *
+     * @covers ::setTreeItemClass
+     */
+    public function testSetTreeItemClass() {
+      // A single link in the active trail.
+      $this->mockTree();
+      $this->links[1]['in_active_trail'] = TRUE;
+      $tree = $this->defaultMenuTreeManipulators->setTreeItemClass($this->originalTree);
+      $this->assertEquals(array('menu-1-active-trail'), $tree[1]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-2'), $tree[2]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-3'), $tree[2]->children[3]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-4'), $tree[2]->children[3]->children[4]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-5'), $tree[5]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-6'), $tree[6]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-7'), $tree[5]->children[7]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-8'), $tree[8]->link['li_attributes']['class']);
+
+      // Multiple links in the active trail.
+      $this->mockTree();
+      $this->links[2]['in_active_trail'] = TRUE;
+      $this->links[3]['in_active_trail'] = TRUE;
+      $this->links[4]['in_active_trail'] = TRUE;
+      $tree = $this->defaultMenuTreeManipulators->setTreeItemClass($this->originalTree);
+      $this->assertEquals(array('menu-1'), $tree[1]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-2-active-trail'), $tree[2]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-3-active-trail'), $tree[2]->children[3]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-4-active-trail'), $tree[2]->children[3]->children[4]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-5'), $tree[5]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-6'), $tree[6]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-7'), $tree[5]->children[7]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-8'), $tree[8]->link['li_attributes']['class']);
+
+      // An external link in the active trail (which is nonsensical, but
+      // ::setTreeItemClass()) shouldn't care.
+      $this->mockTree();
+      $this->links[6]['in_active_trail'] = TRUE;
+      $tree = $this->defaultMenuTreeManipulators->setTreeItemClass($this->originalTree);
+      $this->assertEquals(array('menu-1'), $tree[1]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-2'), $tree[2]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-3'), $tree[2]->children[3]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-4'), $tree[2]->children[3]->children[4]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-5'), $tree[5]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-6-active-trail'), $tree[6]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-7'), $tree[5]->children[7]->link['li_attributes']['class']);
+      $this->assertEquals(array('menu-8'), $tree[8]->link['li_attributes']['class']);
+    }
+
+    /**
+     * Tests the flatten() tree manipulator.
+     *
+     * @covers ::flatten
+     */
+    public function testFlatten() {
+      $this->mockTree();
+      $tree = $this->defaultMenuTreeManipulators->flatten($this->originalTree);
+      $this->assertEquals(array(1, 2, 5, 6, 8), array_keys($this->originalTree));
+      $this->assertEquals(array(1, 2, 5, 6, 8, 3, 4, 7), array_keys($tree));
+    }
+
+    /**
+     * Tests the setLinkTitleAsDescription() tree manipulator.
+     *
+     * @covers ::setLinkTitleAsDescription
+     */
+    public function testSetLinkTitleAsDescription() {
+      $this->mockTree();
+      $random_title = $this->randomName();
+      $this->links[1]['localized_options']['attributes']['title'] = $random_title;
+      $tree = $this->defaultMenuTreeManipulators->setLinkTitleAsDescription($this->originalTree);
+
+      // Verifies that those menu links that have a localized 'title' attribute
+      // now have a 'description' property on the menu link instead (with that
+      // attribute now removed).
+      $this->assertFalse(isset($tree[1]->link['localized_options']['attributes']['title']));
+      $this->assertTrue(isset($tree[1]->link['description']));
+      $this->assertEquals($random_title, $tree[1]->link['description']);
+
+      // Verifies that other menu links are unaffected.
+      $this->assertFalse(isset($tree[2]->link['description']));
+    }
+
+  }
+
+}
diff --git a/core/modules/menu_link/tests/src/MenuActiveTrailTest.php b/core/modules/menu_link/tests/src/MenuActiveTrailTest.php
new file mode 100644
index 0000000..701ce9e
--- /dev/null
+++ b/core/modules/menu_link/tests/src/MenuActiveTrailTest.php
@@ -0,0 +1,175 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\Tests\MenuActiveTrailTest.
+ */
+
+namespace Drupal\menu_link\Tests {
+
+  use Drupal\menu_link\MenuActiveTrail;
+  use Drupal\Tests\UnitTestCase;
+  use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+  use Symfony\Component\HttpFoundation\Request;
+  use Symfony\Component\HttpFoundation\RequestStack;
+
+  if (!defined('MENU_MAX_DEPTH')) {
+    define('MENU_MAX_DEPTH', 9);
+  }
+
+  /**
+   * Tests the active menu trail service.
+   *
+   * @group Drupal
+   * @group menu_link
+   *
+   * @coversDefaultClass \Drupal\menu_link\MenuActiveTrail
+   */
+  class MenuActiveTrailTest extends UnitTestCase {
+
+    /**
+     * The tested active menu trail service.
+     *
+     * @var \Drupal\menu_link\MenuActiveTrail|\Drupal\menu_link\Tests\TestMenuTree
+     */
+    protected $menuActiveTrail;
+
+    /**
+     * The test request stack.
+     *
+     * @var \Symfony\Component\HttpFoundation\RequestStack.
+     */
+    protected $requestStack;
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function getInfo() {
+      return array(
+        'name' => 'Tests \Drupal\menu_link\MenuActiveTrail',
+        'description' => '',
+        'group' => 'Menu',
+      );
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function setUp() {
+      $this->requestStack = new RequestStack();
+
+      $this->menuActiveTrail = new TestMenuActiveTrail($this->requestStack);
+    }
+
+    /**
+     * Tests active paths.
+     *
+     * @covers ::setPath
+     * @covers ::getPath
+     */
+    public function testActivePaths() {
+      $this->assertNull($this->menuActiveTrail->getPath('test_menu1'));
+
+      $this->menuActiveTrail->setPath('test_menu1', 'example_path1');
+      $this->assertEquals('example_path1', $this->menuActiveTrail->getPath('test_menu1'));
+      $this->assertNull($this->menuActiveTrail->getPath('test_menu2'));
+
+      $this->menuActiveTrail->setPath('test_menu2', 'example_path2');
+      $this->assertEquals('example_path1', $this->menuActiveTrail->getPath('test_menu1'));
+      $this->assertEquals('example_path2', $this->menuActiveTrail->getPath('test_menu2'));
+    }
+
+    /**
+     * Tests getActiveTrailIds().
+     *
+     * @covers ::getActiveTrailIds()
+     * @covers ::getActiveTrailCacheKey()
+     */
+    public function testGetActiveTrailIds() {
+      $menu_link = array(
+        'mlid' => 10,
+        'route_name' => 'example1',
+        'p1' => 3,
+        'p2' => 2,
+        'p3' => 1,
+        'p4' => 4,
+        'p5' => 9,
+        'p6' => 5,
+        'p7' => 6,
+        'p8' => 7,
+        'p9' => 8,
+        'menu_name' => 'test_menu'
+      );
+      $this->menuActiveTrail->setPreferredMenuLink('test_menu', 'test/path', $menu_link);
+      $request = (new Request());
+      $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'test_route');
+      $this->requestStack->push($request);
+      $this->menuActiveTrail->setPath('test_menu', 'test/path');
+
+      $trail = $this->menuActiveTrail->getActiveTrailIds('test_menu');
+      $this->assertEquals(array(0 => 0, 3 => 3, 2 => 2, 1 => 1, 4 => 4, 9 => 9, 5 => 5, 6 => 6, 7 => 7), $trail);
+
+      $cache_key = $this->menuActiveTrail->getActiveTrailCacheKey('test_menu');
+      $this->assertSame('menu_trail.0|3|2|1|4|9|5|6|7', $cache_key);
+    }
+
+    /**
+     * Tests getActiveTrailIds() without preferred link.
+     *
+     * @covers ::getActiveTrailIds()
+     * @covers ::getActiveTrailCacheKey()
+     */
+    public function testGetActiveTrailIdsWithoutPreferredLink() {
+      $request = (new Request());
+      $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'test_route');
+      $this->requestStack->push($request);
+      $this->menuActiveTrail->setPath('test_menu', 'test/path');
+
+      $trail = $this->menuActiveTrail->getActiveTrailIds('test_menu');
+      $this->assertEquals(array(0 => 0), $trail);
+
+      $cache_key = $this->menuActiveTrail->getActiveTrailCacheKey('test_menu');
+      $this->assertSame('menu_trail.0', $cache_key);
+    }
+
+  }
+
+  class TestMenuActiveTrail extends MenuActiveTrail {
+
+    /**
+     * Stores the preferred menu link per menu and path.
+     *
+     * @var array
+     */
+    protected $preferredMenuLink;
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function menuLinkGetPreferred($menu_name, $active_path) {
+      return isset($this->preferredMenuLink[$menu_name][$active_path]) ? $this->preferredMenuLink[$menu_name][$active_path] : NULL;
+    }
+
+    /**
+     * Sets the preferred menu link.
+     *
+     * @param string $menu_name
+     *   The menu name.
+     * @param string $active_path
+     *   The active path.
+     * @param array $menu_link
+     *   The preferred menu link.
+     */
+    public function setPreferredMenuLink($menu_name, $active_path, $menu_link) {
+      $this->preferredMenuLink[$menu_name][$active_path] = $menu_link;
+    }
+
+  }
+
+}
+
+namespace {
+  if (!defined('MENU_MAX_DEPTH')) {
+    define('MENU_MAX_DEPTH', 9);
+  }
+}
diff --git a/core/modules/menu_link/tests/src/MenuTreeItemTest.php b/core/modules/menu_link/tests/src/MenuTreeItemTest.php
new file mode 100644
index 0000000..779cb1a
--- /dev/null
+++ b/core/modules/menu_link/tests/src/MenuTreeItemTest.php
@@ -0,0 +1,79 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\Tests\MenuTreeItemTest.
+ */
+
+namespace Drupal\menu_link\Tests;
+
+use Drupal\menu_link\MenuTreeItem;
+use Drupal\menu_link\Entity\MenuLink;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests the menu tree item value object.
+ *
+ * @group Drupal
+ * @group menu_link
+ *
+ * @coversDefaultClass \Drupal\menu_link\MenuTreeItem
+ */
+class MenuTreeItemTest extends UnitTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getInfo() {
+    return array(
+      'name' => 'Tests \Drupal\menu_link\MenuTreeItem',
+      'description' => '',
+      'group' => 'Menu',
+    );
+  }
+
+  /**
+   * Tests construction.
+   *
+   * @covers ::__construct
+   */
+  public function testConstruction() {
+    $link = new MenuLink(array(
+      'mlid' => 1,
+      'depth' => 1,
+      'weight' => 0,
+      'title' => '',
+      'route_name' => 'example1',
+    ), 'menu_link');
+    $item = new MenuTreeItem($link, array());
+    $this->assertSame($link, $item->link);
+    $this->assertSame(array(), $item->children);
+  }
+
+  /**
+   * Tests count().
+   *
+   * @covers ::count
+   */
+  public function testCount() {
+    $link_1 = new MenuLink(array(
+      'mlid' => 1,
+      'depth' => 1,
+      'weight' => 0,
+      'title' => '',
+      'route_name' => 'example1',
+    ), 'menu_link');
+    $link_2 = new MenuLink(array(
+      'mlid' => 2,
+      'depth' => 2,
+      'weight' => 0,
+      'title' => '',
+      'route_name' => 'example2',
+    ), 'menu_link');
+    $child_item = new MenuTreeItem($link_2, array());
+    $parent_item = new MenuTreeItem($link_1, array($child_item));
+    $this->assertSame(1, $child_item->count());
+    $this->assertSame(2, $parent_item->count());
+  }
+
+}
diff --git a/core/modules/menu_link/tests/src/MenuTreeParametersTest.php b/core/modules/menu_link/tests/src/MenuTreeParametersTest.php
new file mode 100644
index 0000000..f442d7c
--- /dev/null
+++ b/core/modules/menu_link/tests/src/MenuTreeParametersTest.php
@@ -0,0 +1,174 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\menu_link\Tests\MenuTreeParametersTest.
+ */
+
+namespace Drupal\menu_link\Tests;
+
+use Drupal\menu_link\MenuTreeParameters;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests the menu tree parameters value object.
+ *
+ * @group Drupal
+ * @group menu_link
+ *
+ * @coversDefaultClass \Drupal\menu_link\MenuTreeParameters
+ */
+class MenuTreeParametersTest extends UnitTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getInfo() {
+    return array(
+      'name' => 'Tests \Drupal\menu_link\MenuTreeParameters',
+      'description' => '',
+      'group' => 'Menu',
+    );
+  }
+
+  /**
+   * Provides test data for testSetMinDepth() and testSetMaxDepth.
+   */
+  public function providerTestSetMinOrMaxDepth() {
+    $data = array();
+
+    // Valid values at the extremes and in the middle.
+    $data[] = array(1, 1);
+    $data[] = array(2, 2);
+    $data[] = array(9, 9);
+
+    // Invalid values are mapped to the closest valid value.
+    $data[] = array(-10000, 1);
+    $data[] = array(0, 1);
+    $data[] = array(10, 9);
+    $data[] = array(100000, 9);
+
+    return $data;
+  }
+
+  /**
+   * Tests setMinDepth().
+   *
+   * @covers ::setMinDepth
+   * @dataProvider providerTestSetMinOrMaxDepth
+   */
+  public function testSetMinDepth($min_depth, $expected) {
+    $parameters = new MenuTreeParameters();
+    $parameters->setMinDepth($min_depth);
+    $this->assertEquals($expected, $parameters->min_depth);
+  }
+
+  /**
+   * Tests setMaxDepth().
+   *
+   * @covers ::setMaxDepth
+   * @dataProvider providerTestSetMinOrMaxDepth
+   */
+  public function testSetMaxDepth($min_depth, $expected) {
+    $parameters = new MenuTreeParameters();
+    $parameters->setMaxDepth($min_depth);
+    $this->assertEquals($expected, $parameters->max_depth);
+  }
+
+  /**
+   * Tests addExpanded().
+   *
+   * @covers ::addExpanded
+   */
+  public function testAddExpanded() {
+    $parameters = new MenuTreeParameters();
+
+    // Verify default value.
+    $this->assertEquals(array(), $parameters->expanded);
+
+    // Add actual mlids to be expanded.
+    $parameters->addExpanded(array(4, 5, 6));
+    $this->assertEquals(array(4, 5, 6), $parameters->expanded);
+
+    // Add additional mlids; they should be merged, not replacing the old ones.
+    $parameters->addExpanded(array(8, 9));
+    $this->assertEquals(array(4, 5, 6, 8 , 9), $parameters->expanded);
+
+    // Add pre-existing mlids; they should not be added again; this is a set.
+    $parameters->addExpanded(array(4, 8));
+    $this->assertEquals(array(4, 5, 6, 8 , 9), $parameters->expanded);
+  }
+
+  /**
+   * Tests expandAlongActiveTrail().
+   *
+   * @covers ::expandAlongActiveTrail
+   */
+  public function testExpandAlongActiveTrail() {
+    $parameters = new MenuTreeParameters();
+
+    // Verify default value.
+    $this->assertEquals(array(), $parameters->active_trail);
+    $this->assertEquals(array(), $parameters->expanded);
+
+    // Verify.
+    $entity_query_factory = $this->getMockBuilder('Drupal\Core\Entity\Query\QueryFactory')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $query_1 = $this->getMock('Drupal\Core\Entity\Query\QueryInterface');
+    $query_1->expects($this->any())
+      ->method('condition')
+      ->will($this->returnSelf());
+    $query_1->expects($this->once())
+      ->method('execute')
+      ->will($this->returnValue(array(1295 => 1295)));
+    $entity_query_factory->expects($this->at(0))
+      ->method('get')
+      ->with('menu_link')
+      ->will($this->returnValue($query_1));
+    $query_2 = $this->getMock('Drupal\Core\Entity\Query\QueryInterface');
+    $query_2->expects($this->any())
+      ->method('condition')
+      ->will($this->returnSelf());
+    $query_2->expects($this->once())
+      ->method('execute')
+      ->will($this->returnValue(array()));
+    $entity_query_factory->expects($this->at(1))
+      ->method('get')
+      ->with('menu_link')
+      ->will($this->returnValue($query_2));
+    $parameters->expandAlongActiveTrail('test_menu', array(42, 1337), $entity_query_factory);
+    $this->assertEquals(array(42, 1337), $parameters->active_trail);
+    $this->assertEquals(array(42, 1337, 1295), $parameters->expanded);
+  }
+
+  /**
+   * Tests appendManipulator().
+   *
+   * @covers ::appendManipulator
+   */
+  public function testAppendManipulator() {
+    $parameters = new MenuTreeParameters();
+
+    // Verify default value.
+    $expected_manipulators = array();
+    $this->assertEquals($expected_manipulators, $parameters->manipulators);
+
+    // Append a manipulator without arguments.
+    $parameters->appendManipulator('\My\Callable::manipulator1', array());
+    $expected_manipulators[0] = array('callable' => '\My\Callable::manipulator1', 'args' => array());
+    $this->assertEquals($expected_manipulators, $parameters->manipulators);
+
+    // Append a manipulator with arguments.
+    $parameters->appendManipulator('\My\Callable::manipulator2', array('foo' => 'bar'));
+    $expected_manipulators[1] = array('callable' => '\My\Callable::manipulator2', 'args' => array('foo' => 'bar'));
+    $this->assertEquals($expected_manipulators, $parameters->manipulators);
+
+    // Add pre-existing manipulators; they should be added again; this is an
+    // ordered list.
+    $parameters->appendManipulator('\My\Callable::manipulator2', array('foo' => 'bar'));
+    $expected_manipulators[2] = array('callable' => '\My\Callable::manipulator2', 'args' => array('foo' => 'bar'));
+    $this->assertEquals($expected_manipulators, $parameters->manipulators);
+  }
+
+}
diff --git a/core/modules/menu_link/tests/src/MenuTreeTest.php b/core/modules/menu_link/tests/src/MenuTreeTest.php
index bd77a45..c1747f3 100644
--- a/core/modules/menu_link/tests/src/MenuTreeTest.php
+++ b/core/modules/menu_link/tests/src/MenuTreeTest.php
@@ -7,21 +7,19 @@
 
 namespace Drupal\menu_link\Tests {
 
-use Drupal\Core\Cache\Cache;
-use Drupal\Core\Entity\EntityStorageInterface;
-use Drupal\Core\Language\Language;
+use Drupal\menu_link\MenuLinkStorageInterface;
 use Drupal\menu_link\MenuTree;
+use Drupal\menu_link\MenuTreeItem;
+use Drupal\menu_link\MenuTreeParameters;
+use Drupal\menu_link\Entity\MenuLink;
 use Drupal\Tests\UnitTestCase;
-use Symfony\Cmf\Component\Routing\RouteObjectInterface;
-use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpFoundation\RequestStack;
 
 if (!defined('MENU_MAX_DEPTH')) {
   define('MENU_MAX_DEPTH', 9);
 }
 
 /**
- * Tests the menu tree.
+ * Tests the menu tree service.
  *
  * @group Drupal
  * @group menu_link
@@ -38,53 +36,53 @@ class MenuTreeTest extends UnitTestCase {
   protected $menuTree;
 
   /**
-   * The mocked database connection.
+   * The mocked entity manager.
    *
-   * @var \Drupal\Core\DatabaseConnection|\PHPUnit_Framework_MockObject_MockObject
+   * @var \Drupal\Core\Entity\EntityManagerInterface|\PHPUnit_Framework_MockObject_MockObject
    */
-  protected $connection;
+  protected $entityManager;
 
   /**
-   * The mocked cache backend.
+   * The mocked entity query factor.y
    *
-   * @var \Drupal\Core\Cache\CacheBackendInterface|\PHPUnit_Framework_MockObject_MockObject
+   * @var  \Drupal\Core\Entity\Query\QueryFactory|\PHPUnit_Framework_MockObject_MockObject
    */
-  protected $cacheBackend;
+  protected $entityQueryFactory;
 
   /**
-   * The mocked language manager.
+   * The mocked state.
    *
-   * @var \Drupal\Core\Language\LanguageManagerInterface|\PHPUnit_Framework_MockObject_MockObject
+   * @var \Drupal\Core\State\StateInterface|\PHPUnit_Framework_MockObject_MockObject
    */
-  protected $languageManager;
+  protected $state;
 
   /**
-   * The test request stack.
+   * The mocked current user.
    *
-   * @var \Symfony\Component\HttpFoundation\RequestStack.
+   * @var \Drupal\Core\Session\AccountInterface|\PHPUnit_Framework_MockObject_MockObject
    */
-  protected $requestStack;
+  protected $currentUser;
 
   /**
-   * The mocked entity manager.
+   * The mocked controller resolver.
    *
-   * @var \Drupal\Core\Entity\EntityManagerInterface|\PHPUnit_Framework_MockObject_MockObject
+   * @var \Drupal\Core\Controller\ControllerResolverInterface|\PHPUnit_Framework_MockObject_MockObject
    */
-  protected $entityManager;
+  protected $controllerResolver;
 
   /**
-   * The mocked entity query factor.y
+   * A mocked alias manager for looking up the system path.
    *
-   * @var  \Drupal\Core\Entity\Query\QueryFactory|\PHPUnit_Framework_MockObject_MockObject
+   * @var \Drupal\Core\Path\AliasManagerInterface|\PHPUnit_Framework_MockObject_MockObject
    */
-  protected $entityQueryFactory;
+  protected $aliasManager;
 
   /**
-   * The mocked state.
+   * The mocked default menu tree manipulators.
    *
-   * @var \Drupal\Core\State\StateInterface|\PHPUnit_Framework_MockObject_MockObject
+   * @var \Drupal\menu_link\DefaultMenuTreeManipulators|\PHPUnit_Framework_MockObject_MockObject
    */
-  protected $state;
+  protected $defaultMenuTreeManipulators;
 
   /**
    * Stores some default values for a menu link.
@@ -120,303 +118,365 @@ public static function getInfo() {
    * {@inheritdoc}
    */
   protected function setUp() {
-    $this->connection = $this->getMockBuilder('Drupal\Core\Database\Connection')
-      ->disableOriginalConstructor()
-      ->getMock();
-    $this->cacheBackend = $this->getMock('Drupal\Core\Cache\CacheBackendInterface');
-    $this->languageManager = $this->getMock('Drupal\Core\Language\LanguageManagerInterface');
-    $this->requestStack = new RequestStack();
     $this->entityManager = $this->getMock('Drupal\Core\Entity\EntityManagerInterface');
     $this->entityQueryFactory = $this->getMockBuilder('Drupal\Core\Entity\Query\QueryFactory')
       ->disableOriginalConstructor()
       ->getMock();
-    $this->state = $this->getMock('Drupal\Core\State\StateInterface');
-
-    $this->menuTree = new TestMenuTree($this->connection, $this->cacheBackend, $this->languageManager, $this->requestStack, $this->entityManager, $this->entityQueryFactory, $this->state);
+    $this->currentUser = $this->getMock('Drupal\Core\Session\AccountInterface');
+    $this->currentUser->expects($this->any())
+      ->method('id')
+      ->will($this->returnValue(mt_rand(1, 100)));
+    $this->controllerResolver = $this->getMock('Drupal\Core\Controller\ControllerResolverInterface');
+    $this->aliasManager = $this->getMock('Drupal\Core\Path\AliasManagerInterface');
+
+    // MenuTree::build() with no $parameters argument will call the default menu
+    // tree manipulators service, mock as needed.
+    $this->defaultMenuTreeManipulators = $this->getMockBuilder('\Drupal\menu_link\DefaultMenuTreeManipulators')
+      ->disableOriginalConstructor()
+      ->setMethods(array('translate', 'setHrefPropertyIfExternal', 'checkNonDynamicAccess'))
+      ->getMock();
+    $this->defaultMenuTreeManipulators->expects($this->any())
+      ->method('translate')
+      ->will($this->returnArgument(0));
+    $this->defaultMenuTreeManipulators->expects($this->any())
+      ->method('setHrefPropertyIfExternal')
+      ->will($this->returnArgument(0));
+    $this->defaultMenuTreeManipulators->expects($this->any())
+      ->method('checkNonDynamicAccess')
+      ->will($this->returnArgument(0));
+
+    $this->menuTree = new TestMenuTree($this->entityManager, $this->entityQueryFactory, $this->currentUser, $this->controllerResolver, $this->aliasManager);
+
+    $this->controllerResolver->expects($this->any())
+      ->method('getControllerFromDefinition')
+      ->will($this->returnValueMap(array(
+        array('menu_link.default_tree_manipulators:translate', array($this->defaultMenuTreeManipulators, 'translate')),
+        array('menu_link.default_tree_manipulators:generateIndexAndSort', array($this->defaultMenuTreeManipulators, 'generateIndexAndSort')),
+        array('menu_link.default_tree_manipulators:setHrefPropertyIfExternal', array($this->defaultMenuTreeManipulators, 'setHrefPropertyIfExternal')),
+        array('menu_link.default_tree_manipulators:checkNonDynamicAccess', array($this->defaultMenuTreeManipulators, 'checkNonDynamicAccess')),
+        array('some_service:dynamicAccessCheck', '\Drupal\menu_link\Tests\MenuTreeTest::dynamicAccessCheckResult'),
+      )));
   }
 
   /**
-   * Tests active paths.
+   * Helper function to mock a test menu for unit testing purposes.
    *
-   * @covers ::setPath
-   * @covers ::getPath
+   * @param \Drupal\menu_link\MenuLinkInterface[] $links
+   *   The menu link objects to be mocked.
    */
-  public function testActivePaths() {
-    $this->assertNull($this->menuTree->getPath('test_menu1'));
+  protected function mockTestMenuFromMockedLinks(array $links) {
+    $ids = array_keys($links);
 
-    $this->menuTree->setPath('test_menu1', 'example_path1');
-    $this->assertEquals('example_path1', $this->menuTree->getPath('test_menu1'));
-    $this->assertNull($this->menuTree->getPath('test_menu2'));
+    // Setup query and the query result.
+    $query = $this->getMock('Drupal\Core\Entity\Query\QueryInterface');
+    $this->entityQueryFactory->expects($this->once())
+      ->method('get')
+      ->with('menu_link')
+      ->will($this->returnValue($query));
+    $query->expects($this->once())
+      ->method('condition')
+      ->with('menu_name', 'test_menu');
+    $query->expects($this->once())
+      ->method('execute')
+      ->will($this->returnValue($ids));
 
-    $this->menuTree->setPath('test_menu2', 'example_path2');
-    $this->assertEquals('example_path1', $this->menuTree->getPath('test_menu1'));
-    $this->assertEquals('example_path2', $this->menuTree->getPath('test_menu2'));
+    $storage = $this->getMock('Drupal\menu_link\MenuLinkStorageInterface');
+    $storage->expects($this->once())
+      ->method('loadMultiple')
+      ->with($ids)
+      ->will($this->returnValue($links));
+    $this->menuTree->setMenuLinkStorage($storage);
   }
 
   /**
-   * Tests buildTreeData with a single level.
+   * Tests ::build() with a single level.
    *
-   * @covers ::buildTreeData
-   * @covers ::doBuildTreeData
+   * @covers ::build
+   * @covers ::convertLinksToTree
+   * @covers ::treeDataRecursive
    */
-  public function testBuildTreeDataWithSingleLevel() {
-    $items = array();
-    $items[] = array(
+  public function testBuildWithSingleLevel() {
+    $links = array();
+    $links[1] = new MenuLink(array(
       'mlid' => 1,
       'depth' => 1,
       'weight' => 0,
       'title' => '',
       'route_name' => 'example1',
-      'access' => TRUE,
-    );
-    $items[] = array(
+    ), 'menu_link');
+    $links[2] = new MenuLink(array(
       'mlid' => 2,
       'depth' => 1,
       'weight' => 0,
       'title' => '',
       'route_name' => 'example2',
-      'access' => TRUE,
-    );
-
-    $result = $this->menuTree->buildTreeData($items, array(), 1);
+    ), 'menu_link');
+    $this->mockTestMenuFromMockedLinks($links);
+    $result = $this->menuTree->build('test_menu', new MenuTreeParameters());
 
     $this->assertCount(2, $result);
     $result1 = array_shift($result);
-    $this->assertEquals($items[0] + array('in_active_trail' => FALSE), $result1['link']);
+    $this->assertFalse($result1->link['in_active_trail']);
     $result2 = array_shift($result);
-    $this->assertEquals($items[1] + array('in_active_trail' => FALSE), $result2['link']);
+    $this->assertFalse($result2->link['in_active_trail']);
   }
 
   /**
-   * Tests buildTreeData with a single level and one item being active.
+   * Tests ::build() with a single level and one item being active.
    *
-   * @covers ::buildTreeData
-   * @covers ::doBuildTreeData
+   * @covers ::build
+   * @covers ::convertLinksToTree
+   * @covers ::treeDataRecursive
    */
-  public function testBuildTreeDataWithSingleLevelAndActiveItem() {
-    $items = array();
-    $items[] = array(
+  public function testBuildWithSingleLevelAndActiveItem() {
+    $links = array();
+    $links[1] = new MenuLink(array(
       'mlid' => 1,
       'depth' => 1,
       'weight' => 0,
       'title' => '',
       'route_name' => 'example1',
       'access' => TRUE,
-    );
-    $items[] = array(
+    ), 'menu_link');
+    $links[2] = new MenuLink(array(
       'mlid' => 2,
       'depth' => 1,
       'weight' => 0,
       'title' => '',
       'route_name' => 'example2',
       'access' => TRUE,
-    );
+    ), 'menu_link');
+    $this->mockTestMenuFromMockedLinks($links);
 
-    $result = $this->menuTree->buildTreeData($items, array(1), 1);
+    $parameters = new MenuTreeParameters();
+    $parameters->active_trail = array(1);
+    $result = $this->menuTree->build('test_menu', $parameters);
 
     $this->assertCount(2, $result);
     $result1 = array_shift($result);
-    $this->assertEquals($items[0] + array('in_active_trail' => TRUE), $result1['link']);
+    $this->assertTrue($result1->link['in_active_trail']);
     $result2 = array_shift($result);
-    $this->assertEquals($items[1] + array('in_active_trail' => FALSE), $result2['link']);
+    $this->assertFalse($result2->link['in_active_trail']);
   }
 
   /**
-   * Tests buildTreeData with a single level and none item being active.
+   * Tests build() with a single level and none item being active.
    *
-   * @covers ::buildTreeData
-   * @covers ::doBuildTreeData
+   * @covers ::build
+   * @covers ::convertLinksToTree
+   * @covers ::treeDataRecursive
    */
-  public function testBuildTreeDataWithSingleLevelAndNoActiveItem() {
-    $items = array();
-    $items[] = array(
+  public function testBuildWithSingleLevelAndNoActiveItem() {
+    $links = array();
+    $links[1] = new MenuLink(array(
       'mlid' => 1,
       'depth' => 1,
       'weight' => 0,
       'title' => '',
       'route_name' => 'example1',
-      'access' => TRUE,
-    );
-    $items[] = array(
+    ), 'menu_link');
+    $links[2] = new MenuLink(array(
       'mlid' => 2,
       'depth' => 1,
       'weight' => 0,
       'title' => '',
       'route_name' => 'example2',
-      'access' => TRUE,
-    );
+    ), 'menu_link');
+    $this->mockTestMenuFromMockedLinks($links);
 
-    $result = $this->menuTree->buildTreeData($items, array(3), 1);
+    $parameters = new MenuTreeParameters();
+    $parameters->active_trail = array(3);
+    $result = $this->menuTree->build('test_menu', $parameters);
 
     $this->assertCount(2, $result);
     $result1 = array_shift($result);
-    $this->assertEquals($items[0] + array('in_active_trail' => FALSE), $result1['link']);
+    $this->assertFalse($result1->link['in_active_trail']);
     $result2 = array_shift($result);
-    $this->assertEquals($items[1] + array('in_active_trail' => FALSE), $result2['link']);
+    $this->assertFalse($result2->link['in_active_trail']);
   }
 
   /**
-   * Tests buildTreeData with a more complex example.
+   * Tests build() with multiple levels.
    *
-   * @covers ::buildTreeData
-   * @covers ::doBuildTreeData
+   * @covers ::build
+   * @covers ::loadLinks
+   * @covers ::treeDataRecursive
    */
-  public function testBuildTreeWithComplexData() {
-    $items = array(
-      1 => array('mlid' => 1, 'depth' => 1, 'route_name' => 'example1', 'access' => TRUE, 'weight' => 0, 'title' => ''),
-      2 => array('mlid' => 2, 'depth' => 1, 'route_name' => 'example2', 'access' => TRUE, 'weight' => 0, 'title' => ''),
-      3 => array('mlid' => 3, 'depth' => 2, 'route_name' => 'example3', 'access' => TRUE, 'weight' => 0, 'title' => ''),
-      4 => array('mlid' => 4, 'depth' => 3, 'route_name' => 'example4', 'access' => TRUE, 'weight' => 0, 'title' => ''),
-      5 => array('mlid' => 5, 'depth' => 1, 'route_name' => 'example5', 'access' => TRUE, 'weight' => 0, 'title' => ''),
+  public function testBuildMultipleLevels() {
+    $links = array();
+    $base = array(
+      'weight' => 0,
+      'title' => 'title',
     );
-
-    $tree = $this->menuTree->buildTreeData($items);
-
-    // Validate that parent items #1, #2, and #5 exist on the root level.
-    $this->assertEquals($items[1]['mlid'], $tree['50000  1']['link']['mlid']);
-    $this->assertEquals($items[2]['mlid'], $tree['50000  2']['link']['mlid']);
-    $this->assertEquals($items[5]['mlid'], $tree['50000  5']['link']['mlid']);
-
-    // Validate that child item #4 exists at the correct location in the hierarchy.
-    $this->assertEquals($items[4]['mlid'], $tree['50000  2']['below']['50000  3']['below']['50000  4']['link']['mlid']);
+    $links[1] = new MenuLink($base + array(
+      'mlid' => 1,
+      'p1' => 1,
+      'depth' => 1,
+    ), 'menu_link');
+    $links[2] = new MenuLink(array(
+      'mlid' => 2,
+      'p1' => 1,
+      'p2' => 2,
+      'depth' => 2,
+    ), 'menu_link');
+    $links[3] = new MenuLink(array(
+      'mlid' => 3,
+      'p1' => 1,
+      'p2' => 2,
+      'p3' => 3,
+      'depth' => 3,
+    ), 'menu_link');
+    $this->mockTestMenuFromMockedLinks($links);
+
+    $tree = $this->menuTree->build('test_menu', new MenuTreeParameters());
+    $this->assertTrue(array_key_exists(1, $tree));
+    $this->assertTrue(array_key_exists(2, $tree[1]->children));
+    $this->assertTrue(array_key_exists(3, $tree[1]->children[2]->children));
   }
 
   /**
-   * Tests getActiveTrailIds().
+   * Tests build() with parameters, to test loadLinks().
    *
-   * @covers ::getActiveTrailIds()
+   * @covers ::build
+   * @covers ::loadLinks
+   * @covers ::convertLinksToTree
+   * @covers ::treeDataRecursive
    */
-  public function testGetActiveTrailIds() {
-    $menu_link = array(
-      'mlid' => 10,
-      'route_name' => 'example1',
+  public function testBuildWithParameters() {
+    $links = array();
+    $base = array(
+      'weight' => 0,
+      'title' => 'title',
+    );
+    $links[1] = new MenuLink($base + array(
+      'mlid' => 1,
       'p1' => 3,
       'p2' => 2,
       'p3' => 1,
-      'p4' => 4,
-      'p5' => 9,
-      'p6' => 5,
-      'p7' => 6,
-      'p8' => 7,
-      'p9' => 8,
-      'menu_name' => 'test_menu'
-    );
-    $this->menuTree->setPreferredMenuLink('test_menu', 'test/path', $menu_link);
-    $request = (new Request());
-    $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'test_route');
-    $this->requestStack->push($request);
-    $this->menuTree->setPath('test_menu', 'test/path');
-
-    $trail = $this->menuTree->getActiveTrailIds('test_menu');
-    $this->assertEquals(array(0 => 0, 3 => 3, 2 => 2, 1 => 1, 4 => 4, 9 => 9, 5 => 5, 6 => 6, 7 => 7), $trail);
-  }
-
-  /**
-   * Tests getActiveTrailIds() without preferred link.
-   *
-   * @covers ::getActiveTrailIds()
-   */
-  public function testGetActiveTrailIdsWithoutPreferredLink() {
-    $request = (new Request());
-    $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'test_route');
-    $this->requestStack->push($request);
-    $this->menuTree->setPath('test_menu', 'test/path');
-
-    $trail = $this->menuTree->getActiveTrailIds('test_menu');
-    $this->assertEquals(array(0 => 0), $trail);
-  }
-
-
-  /**
-   * Tests buildTree with simple menu_name and no parameters.
-   */
-  public function testBuildTreeWithoutParameters() {
-    $language = new Language(array('id' => 'en'));
-    $this->languageManager->expects($this->any())
-      ->method('getCurrentLanguage')
-      ->will($this->returnValue($language));
-
-    // Setup query and the query result.
+    ), 'menu_link');
+
+    $ids = array_keys($links);
+
+    // Set up parameters that should trigger most code paths in ::loadLinks().
+    $parameters = new MenuTreeParameters();
+    $parameters->expanded = array(42, 1337);
+    $parameters->min_depth = 2;
+    $parameters->max_depth = 5;
+    $parameters->conditions['module'] = 'system';
+    $parameters->conditions['hidden'] = array(0, '>=');
+    $parameters->appendManipulator('menu_link.default_tree_manipulators:translate')
+      ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+      ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal');
+
+    // Mock the query and the query result.
     $query = $this->getMock('Drupal\Core\Entity\Query\QueryInterface');
     $this->entityQueryFactory->expects($this->once())
       ->method('get')
       ->with('menu_link')
       ->will($this->returnValue($query));
-    $query->expects($this->once())
+    $query->expects($this->at(9))
       ->method('condition')
       ->with('menu_name', 'test_menu');
-    $query->expects($this->once())
+    $query->expects($this->at(10))
+      ->method('condition')
+      ->with('plid', array(42, 1337), 'IN');
+    $query->expects($this->at(11))
+      ->method('condition')
+      ->with('depth', 2, '>=');
+    $query->expects($this->at(12))
+      ->method('condition')
+      ->with('depth', 5, '<=');
+    $query->expects($this->at(13))
+      ->method('condition')
+      ->with('module', 'system');
+    $query->expects($this->at(14))
+      ->method('condition')
+      ->with('hidden', 0, '>=');
+    $query->expects($this->at(15))
       ->method('execute')
-      ->will($this->returnValue(array(1, 2, 3)));
+      ->will($this->returnValue($ids));
+
+    // Mock menu link entity storage.
+    $storage = $this->getMock('Drupal\menu_link\MenuLinkStorageInterface');
+    $storage->expects($this->once())
+      ->method('loadMultiple')
+      ->with($ids)
+      ->will($this->returnValue($links));
+    $this->menuTree->setMenuLinkStorage($storage);
 
-    $storage = $this->getMock('Drupal\Core\Entity\EntityStorageInterface');
+    $this->menuTree->build('test_menu', $parameters);
+  }
+
+  /**
+   * Tests build() with other parameters, to test loadLinks().
+   *
+   * @covers ::build
+   * @covers ::loadLinks
+   */
+  public function testBuildWithParametersOnlyActiveTrail() {
+    $links = array();
     $base = array(
-      'access' => TRUE,
       'weight' => 0,
       'title' => 'title',
     );
-    $menu_link = $base + array(
+    $links[1] = new MenuLink($base + array(
       'mlid' => 1,
       'p1' => 3,
       'p2' => 2,
       'p3' => 1,
-    );
-    $links[1] = $menu_link;
-    $menu_link = $base + array(
-      'mlid' => 3,
-      'p1' => 3,
-      'depth' => 1,
-    );
-    $links[3] = $menu_link;
-    $menu_link = $base + array(
-      'mlid' => 2,
-      'p1' => 3,
-      'p2' => 2,
-      'depth' => 2,
-    );
-    $links[2] = $menu_link;
+    ), 'menu_link');
+    $ids = array_keys($links);
+
+    // Set up parameters that should trigger the sole code path in ::loadLinks()
+    // untested by ::testBuildWithParameters(): the one for "only active trail".
+    $parameters = new MenuTreeParameters();
+    $parameters->only_active_trail = TRUE;
+    $parameters->active_trail= array(6, 7, 8);
+    $parameters->min_depth = 1;
+    $parameters->max_depth = 7;
+
+    // Mock the query and the query result.
+    $query_2 = $this->getMock('Drupal\Core\Entity\Query\QueryInterface');
+    $this->entityQueryFactory->expects($this->once())
+      ->method('get')
+      ->with('menu_link')
+      ->will($this->returnValue($query_2));
+    $query_2->expects($this->at(9))
+      ->method('condition')
+      ->with('menu_name', 'test_menu');
+    $query_2->expects($this->at(10))
+      ->method('condition')
+      ->with('mlid', array(6, 7, 8), 'IN');
+    $query_2->expects($this->at(11))
+      ->method('condition')
+      ->with('depth', 7, '<=');
+    $query_2->expects($this->at(12))
+      ->method('execute')
+      ->will($this->returnValue($ids));
+
+    // Mock menu link entity storage.
+    $storage = $this->getMock('Drupal\menu_link\MenuLinkStorageInterface');
     $storage->expects($this->once())
       ->method('loadMultiple')
-      ->with(array(1, 2, 3))
-      ->will($this->returnValue($links));
-    $this->menuTree->setStorage($storage);
+      ->with($ids)
+      ->will($this->returnValue(array()));
+    $this->menuTree->setMenuLinkStorage($storage);
 
-    // Ensure that static/non static caching works.
-    // First setup no working caching.
-    $this->cacheBackend->expects($this->at(0))
-      ->method('get')
-      ->with('links:test_menu:tree-data:en:35786c7117b4e38d0f169239752ce71158266ae2f6e4aa230fbbb87bd699c0e3')
-      ->will($this->returnValue(FALSE));
-    $this->cacheBackend->expects($this->at(1))
-      ->method('set')
-      ->with('links:test_menu:tree-data:en:35786c7117b4e38d0f169239752ce71158266ae2f6e4aa230fbbb87bd699c0e3', $this->anything(), Cache::PERMANENT, array('menu' => 'test_menu'));
-
-    // Ensure that the static caching triggered.
-    $this->cacheBackend->expects($this->exactly(1))
-      ->method('get');
-
-    $this->menuTree->buildTree('test_menu');
-    $this->menuTree->buildTree('test_menu');
+    $this->menuTree->build('test_menu', $parameters);
   }
 
   /**
-   * Tests the output with a single level.
+   * Tests render() with a single level.
    *
-   * @covers ::renderTree
+   * @covers ::render
    */
-  public function testOutputWithSingleLevel() {
+  public function testRenderWithSingleLevel() {
     $tree = array(
-      '1' => array(
-        'link' => array('mlid' => 1) + $this->defaultMenuLink,
-        'below' => array(),
-      ),
-      '2' => array(
-        'link' => array('mlid' => 2) + $this->defaultMenuLink,
-        'below' => array(),
-      ),
+      '1' => new MenuTreeItem(new MenuLink(array('mlid' => 1) + $this->defaultMenuLink, 'menu_link'), array()),
+      '2' => new MenuTreeItem(new MenuLink(array('mlid' => 2) + $this->defaultMenuLink, 'menu_link'), array()),
     );
 
-    $output = $this->menuTree->renderTree($tree);
+    $output = $this->menuTree->render($tree);
 
     // Validate that the - in main-menu is changed into an underscore
     $this->assertEquals($output['1']['#theme'], 'menu_link__main_menu', 'Hyphen is changed to an underscore on menu_link');
@@ -425,34 +485,36 @@ public function testOutputWithSingleLevel() {
   }
 
   /**
-   * Tests the output method with a complex example.
+   * Tests render() with a complex menu tree without dynamic access checks.
    *
-   * @covers ::renderTree
+   * @covers ::render()
+   * @covers ::renderItem()
+   * @covers ::renderMenuLink()
    */
-  public function testOutputWithComplexData() {
+  public function testRenderWithComplexData() {
     $tree = array(
-      '1'=> array(
-        'link' => array('mlid' => 1, 'has_children' => 1, 'title' => 'Item 1', 'link_path' => 'a') + $this->defaultMenuLink,
-        'below' => array(
-          '2' => array('link' => array('mlid' => 2, 'title' => 'Item 2', 'link_path' => 'a/b') + $this->defaultMenuLink,
-            'below' => array(
-              '3' => array('link' => array('mlid' => 3, 'title' => 'Item 3', 'in_active_trail' => 0, 'link_path' => 'a/b/c') + $this->defaultMenuLink,
-                'below' => array()),
-              '4' => array('link' => array('mlid' => 4, 'title' => 'Item 4', 'in_active_trail' => 0, 'link_path' => 'a/b/d') + $this->defaultMenuLink,
-                'below' => array())
+      '1'=> new MenuTreeItem(
+        new MenuLink(array('mlid' => 1, 'has_children' => 1, 'title' => 'Item 1', 'link_path' => 'a') + $this->defaultMenuLink, 'menu_link'),
+        array(
+          '2' => new MenuTreeItem(
+            new MenuLink(array('mlid' => 2, 'has_children' => 1, 'title' => 'Item 2', 'link_path' => 'a/b') + $this->defaultMenuLink, 'menu_link'),
+            array(
+              '3' => new MenuTreeItem(new MenuLink(array('mlid' => 3, 'title' => 'Item 3', 'in_active_trail' => 0, 'link_path' => 'a/b/c') + $this->defaultMenuLink, 'menu_link'), array()),
+              '4' => new MenuTreeItem(new MenuLink(array('mlid' => 4, 'title' => 'Item 4', 'in_active_trail' => 0, 'link_path' => 'a/b/d') + $this->defaultMenuLink, 'menu_link'), array()),
             )
           )
         )
       ),
-      '5' => array('link' => array('mlid' => 5, 'hidden' => 1, 'title' => 'Item 5', 'link_path' => 'e') + $this->defaultMenuLink, 'below' => array()),
-      '6' => array('link' => array('mlid' => 6, 'title' => 'Item 6', 'in_active_trail' => 0, 'access' => 0, 'link_path' => 'f') + $this->defaultMenuLink, 'below' => array()),
-      '7' => array('link' => array('mlid' => 7, 'title' => 'Item 7', 'in_active_trail' => 0, 'link_path' => 'g') + $this->defaultMenuLink, 'below' => array())
+      '5' => new MenuTreeItem(new MenuLink(array('mlid' => 5, 'hidden' => 1, 'title' => 'Item 5', 'link_path' => 'e') + $this->defaultMenuLink, 'menu_link'), array()),
+      '6' => new MenuTreeItem(new MenuLink(array('mlid' => 6, 'title' => 'Item 6', 'in_active_trail' => 0, 'access' => 0, 'link_path' => 'f') + $this->defaultMenuLink, 'menu_link'), array()),
+      '7' => new MenuTreeItem(new MenuLink(array('mlid' => 7, 'title' => 'Item 7', 'in_active_trail' => 0, 'link_path' => 'g') + $this->defaultMenuLink, 'menu_link'), array()),
+      '8' => new MenuTreeItem(new MenuLink(array('mlid' => 8, 'title' => 'Item 8', 'in_active_trail' => 0, 'link_path' => 'g', 'has_children' => TRUE) + $this->defaultMenuLink, 'menu_link'), array()),
     );
 
-    $output = $this->menuTree->renderTree($tree);
+    $output = $this->menuTree->render($tree);
 
     // Looking for child items in the data
-    $this->assertEquals( $output['1']['#below']['2']['#href'], 'a/b', 'Checking the href on a child item');
+    $this->assertEquals($output['1']['#below']['2']['#href'], 'a/b', 'Checking the href on a child item');
     $this->assertTrue(in_array('active-trail', $output['1']['#below']['2']['#attributes']['class']), 'Checking the active trail class');
     // Validate that the hidden and no access items are missing
     $this->assertFalse(isset($output['5']), 'Hidden item should be missing');
@@ -460,30 +522,157 @@ public function testOutputWithComplexData() {
     // Item 7 is after a couple hidden items. Just to make sure that 5 and 6 are
     // skipped and 7 still included.
     $this->assertTrue(isset($output['7']), 'Item after hidden items is present');
+
+    // Verify that items 1 and 2 have the 'expanded' class because they have
+    // rendered children.
+    $this->assertTrue(in_array('expanded', $output[1]['#attributes']['class']));
+    $this->assertTrue(in_array('expanded', $output[1]['#below'][2]['#attributes']['class']));
+    $this->assertTrue(!in_array('expanded', $output[1]['#below'][2]['#below'][3]['#attributes']['class']));
+    $this->assertTrue(!in_array('expanded', $output[1]['#below'][2]['#below'][4]['#attributes']['class']));
+    $this->assertTrue(!in_array('expanded', $output[7]['#attributes']['class']));
+    $this->assertTrue(!in_array('expanded', $output[8]['#attributes']['class']));
+
+    // Verify that only item 8 has the 'collapsed' class because it has children
+    // which aren't rendered.
+    $this->assertTrue(!in_array('collapsed', $output[1]['#attributes']['class']));
+    $this->assertTrue(!in_array('collapsed', $output[1]['#below'][2]['#attributes']['class']));
+    $this->assertTrue(!in_array('collapsed', $output[1]['#below'][2]['#below'][3]['#attributes']['class']));
+    $this->assertTrue(!in_array('collapsed', $output[1]['#below'][2]['#below'][4]['#attributes']['class']));
+    $this->assertTrue(!in_array('collapsed', $output[7]['#attributes']['class']));
+    $this->assertTrue(in_array('collapsed', $output[8]['#attributes']['class']));
+
+    // Verify that only visible leafs in the tree have the 'leaf' class set.
+    $this->assertTrue(!in_array('leaf', $output[1]['#attributes']['class']));
+    $this->assertTrue(!in_array('leaf', $output[1]['#below'][2]['#attributes']['class']));
+    $this->assertTrue(in_array('leaf', $output[1]['#below'][2]['#below'][3]['#attributes']['class']));
+    $this->assertTrue(in_array('leaf', $output[1]['#below'][2]['#below'][4]['#attributes']['class']));
+    $this->assertTrue(in_array('leaf', $output[7]['#attributes']['class']));
+    $this->assertTrue(!in_array('leaf', $output[8]['#attributes']['class']));
+  }
+
+  /**
+   * Mocked 'needs_dynamic_access_check' callback.
+   *
+   * @see ::testRenderWithComplexDataAndDynamicAccessChecks()
+   */
+  public static function dynamicAccessCheckResult() {
+    return TRUE;
   }
 
   /**
-   * Tests menu tree access check with a single level.
+   * Tests render() with a complex menu tree with dynamic access checks.
    *
-   * @covers ::checkAccess
+   * @covers ::render()
+   * @covers ::renderItem()
+   * @covers ::renderItemAsPlaceholder()
+   * @covers ::renderMenuLink()
+   * @covers ::renderItemPlaceholder()
    */
-  public function testCheckAccessWithSingleLevel() {
-    $items = array(
-      array('mlid' => 1, 'route_name' => 'menu_test_1', 'depth' => 1, 'link_path' => 'menu_test/test_1', 'in_active_trail' => FALSE) + $this->defaultMenuLink,
-      array('mlid' => 2, 'route_name' => 'menu_test_2', 'depth' => 1, 'link_path' => 'menu_test/test_2', 'in_active_trail' => FALSE) + $this->defaultMenuLink,
+  public function testRenderWithComplexDataAndDynamicAccessChecks() {
+    // Menu tree items 2 and 7 need dynamic access checks.
+    $tree = array(
+      '1'=> new MenuTreeItem(
+          new MenuLink(array('mlid' => 1, 'has_children' => 1, 'title' => 'Item 1', 'link_path' => 'a') + $this->defaultMenuLink, 'menu_link'),
+          array(
+            '2' => new MenuTreeItem(
+                new MenuLink(array('mlid' => 2, 'has_children' => 1, 'title' => 'Item 2', 'link_path' => 'a/b', 'needs_dynamic_access_check' => 'some_service:dynamicAccessCheck') + $this->defaultMenuLink, 'menu_link'),
+                array(
+                  '3' => new MenuTreeItem(new MenuLink(array('mlid' => 3, 'title' => 'Item 3', 'in_active_trail' => 0, 'link_path' => 'a/b/c') + $this->defaultMenuLink, 'menu_link'), array()),
+                  '4' => new MenuTreeItem(new MenuLink(array('mlid' => 4, 'title' => 'Item 4', 'in_active_trail' => 0, 'link_path' => 'a/b/d') + $this->defaultMenuLink, 'menu_link'), array()),
+                )
+              )
+          )
+        ),
+      '5' => new MenuTreeItem(new MenuLink(array('mlid' => 5, 'hidden' => 1, 'title' => 'Item 5', 'link_path' => 'e') + $this->defaultMenuLink, 'menu_link'), array()),
+      '6' => new MenuTreeItem(new MenuLink(array('mlid' => 6, 'title' => 'Item 6', 'in_active_trail' => 0, 'access' => 0, 'link_path' => 'f') + $this->defaultMenuLink, 'menu_link'), array()),
+      '7' => new MenuTreeItem(new MenuLink(array('mlid' => 7, 'title' => 'Item 7', 'in_active_trail' => 0, 'link_path' => 'g', 'needs_dynamic_access_check' => 'some_service:dynamicAccessCheck') + $this->defaultMenuLink, 'menu_link'), array()),
+      '8' => new MenuTreeItem(new MenuLink(array('mlid' => 8, 'title' => 'Item 8', 'in_active_trail' => 0, 'link_path' => 'h', 'has_children' => TRUE) + $this->defaultMenuLink, 'menu_link'), array()),
     );
 
-    // Register a menuLinkTranslate to mock the access.
-    $this->menuTree->menuLinkTranslateCallable = function(&$item) {
-      $item['access'] = $item['mlid'] == 1;
-    };
+    // Mock aliasManager for the menu links that will need dynamic access checks
+    // but whose source paths will be looked up already.
+    $this->aliasManager->expects($this->exactly(2))
+      ->method('getPathByAlias')
+      ->will($this->returnValueMap(array(
+        array('a/b', NULL, 'a/b'),
+        array('g', NULL, 'system.g'),
+      )));
+
+    $output = $this->menuTree->render($tree);
+
+    // Verify that items 2 and 7 are rendered into render cache placeholders. In
+    // the subsequent assertions, items 2 and 7 and their children (3 and 4)
+    // will therefore be missing. (Until the #post_render_cache callback is
+    // applied).
+    $this->assertEquals('<drupal:render-cache-placeholder callback="menu_link.tree:renderItemPlaceholder" token="NON_RANDOM_TOKEN" />', $output[1]['#below'][2]['#markup']);
+    $expected_context_2 = array(
+      'menu_tree_item' => $tree[1]->children[2],
+      'token' => 'NON_RANDOM_TOKEN',
+    );
+    $this->assertEquals(array('menu_link.tree:renderItemPlaceholder' => array($expected_context_2)), $output[1]['#below'][2]['#post_render_cache']);
+    $this->assertEquals('<drupal:render-cache-placeholder callback="menu_link.tree:renderItemPlaceholder" token="NON_RANDOM_TOKEN" />', $output[7]['#markup']);
+    $expected_context_7 = array(
+      'menu_tree_item' => $tree[7],
+      'token' => 'NON_RANDOM_TOKEN',
+    );
+    $this->assertEquals(array('menu_link.tree:renderItemPlaceholder' => array($expected_context_7)), $output[7]['#post_render_cache']);
+    $this->assertEquals('<drupal:render-cache-placeholder callback="menu_link.tree:renderItemPlaceholder" token="NON_RANDOM_TOKEN" />', $output[7]['#markup']);
 
-    // Build the menu tree and check access for all of the items.
-    $tree = $this->menuTree->buildTreeData($items);
+    // Validate that the hidden and no access items are missing
+    $this->assertFalse(isset($output['5']), 'Hidden item should be missing');
+    $this->assertFalse(isset($output['6']), 'False access should be missing');
+    // Item 7 is after a couple hidden items. Just to make sure that 5 and 6 are
+    // skipped and 7 still included.
+    $this->assertTrue(isset($output['7']), 'Item after hidden items is present');
 
-    $this->assertCount(1, $tree);
-    $item = reset($tree);
-    $this->assertEquals($items[0], $item['link']);
+    // Verify that items 1 has the 'expanded' class because they have
+    // rendered children.
+    $this->assertTrue(in_array('expanded', $output[1]['#attributes']['class']));
+    $this->assertTrue(!in_array('expanded', $output[8]['#attributes']['class']));
+
+    // Verify that only item 8 has the 'collapsed' class because it has children
+    // which aren't rendered.
+    $this->assertTrue(!in_array('collapsed', $output[1]['#attributes']['class']));
+    $this->assertTrue(in_array('collapsed', $output[8]['#attributes']['class']));
+
+    // Verify that only visible leafs in the tree have the 'leaf' class set.
+    $this->assertTrue(!in_array('leaf', $output[1]['#attributes']['class']));
+    $this->assertTrue(!in_array('leaf', $output[8]['#attributes']['class']));
+
+
+    // Now run ::renderItemPlaceholder() on both menu items that were rendered
+    // into render cache placeholders.
+    // Verifying item 2 and its children. Also verify the presence of the pre-
+    // calculated 'data-drupal-link-system-path attribute, which allows a path
+    // source lookup to be avoided.
+    $placeholder_element_item_2 = $output[1]['#below'][2];
+    $updated_element = $this->menuTree->renderItemPlaceholder($placeholder_element_item_2, $expected_context_2);
+    $item_2_build = unserialize($updated_element['#markup']);
+    $this->assertEquals('a/b', $item_2_build['#href'], 'Checking the href on a child item');
+    $this->assertTrue(in_array('active-trail', $item_2_build['#attributes']['class']), 'Checking the active trail class');
+    $this->assertTrue(in_array('expanded', $item_2_build['#attributes']['class']));
+    $this->assertTrue(!in_array('expanded', $item_2_build['#below'][3]['#attributes']['class']));
+    $this->assertTrue(!in_array('expanded', $item_2_build['#below'][4]['#attributes']['class']));
+    $this->assertTrue(!in_array('collapsed', $item_2_build['#attributes']['class']));
+    $this->assertTrue(!in_array('collapsed', $item_2_build['#below'][3]['#attributes']['class']));
+    $this->assertTrue(!in_array('collapsed', $item_2_build['#below'][4]['#attributes']['class']));
+    $this->assertTrue(!in_array('leaf', $item_2_build['#attributes']['class']));
+    $this->assertTrue(in_array('leaf', $item_2_build['#below'][3]['#attributes']['class']));
+    $this->assertTrue(in_array('leaf', $item_2_build['#below'][4]['#attributes']['class']));
+    $this->assertEquals('a/b', $item_2_build['#localized_options']['attributes']['data-drupal-link-system-path']);
+
+    // Verifying item 7 and its children. Also verify the presence of the pre-
+    // calculated 'data-drupal-link-system-path attribute, which allows a path
+    // source lookup to be avoided.
+    $placeholder_element_item_7 = $output[7];
+    $updated_element = $this->menuTree->renderItemPlaceholder($placeholder_element_item_7, $expected_context_7);
+    $item_7_build = unserialize($updated_element['#markup']);
+    $this->assertEquals('g', $item_7_build['#href'], 'Checking the href on a child item');
+    $this->assertTrue(!in_array('active-trail', $item_7_build['#attributes']['class']), 'Checking the active trail class');
+    $this->assertTrue(!in_array('expanded', $item_7_build['#attributes']['class']));
+    $this->assertTrue(!in_array('collapsed', $item_7_build['#attributes']['class']));
+    $this->assertTrue(in_array('leaf', $item_7_build['#attributes']['class']));
+    $this->assertEquals('system.g', $item_7_build['#localized_options']['attributes']['data-drupal-link-system-path']);
   }
 
 }
@@ -491,56 +680,28 @@ public function testCheckAccessWithSingleLevel() {
 class TestMenuTree extends MenuTree {
 
   /**
-   * An alternative callable used for menuLinkTranslate.
-   * @var callable
-   */
-  public $menuLinkTranslateCallable;
-
-  /**
-   * Stores the preferred menu link per menu and path.
+   * Set the menu link entity storage.
    *
-   * @var array
-   */
-  protected $preferredMenuLink;
-
-  /**
-   * {@inheritdoc}
+   * @param \Drupal\menu_link\MenuLinkStorageInterface $storage
+   *   The menu link entity storage.
    */
-  protected function menuLinkTranslate(&$item) {
-    if (isset($this->menuLinkTranslateCallable)) {
-      call_user_func_array($this->menuLinkTranslateCallable, array(&$item));
-    }
+  public function setMenuLinkStorage(MenuLinkStorageInterface $storage) {
+    $this->menuLinkStorage = $storage;
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  protected function menuLinkGetPreferred($menu_name, $active_path) {
-    return isset($this->preferredMenuLink[$menu_name][$active_path]) ? $this->preferredMenuLink[$menu_name][$active_path] : NULL;
-  }
+  protected function drupalRenderCacheGeneratePlaceholder($callback, &$context) {
+    // Generate a unique token if one is not already provided.
+    $context += array(
+      'token' => 'NON_RANDOM_TOKEN',
+    );
 
-  /**
-   * Set the storage.
-   *
-   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
-   *   The menu link storage.
-   */
-  public function setStorage(EntityStorageInterface $storage) {
-    $this->menuLinkStorage = $storage;
+    return '<drupal:render-cache-placeholder callback="' . $callback . '" token="' . $context['token'] . '" />';
   }
 
-  /**
-   * Sets the preferred menu link.
-   *
-   * @param string $menu_name
-   *   The menu name.
-   * @param string $active_path
-   *   The active path.
-   * @param array $menu_link
-   *   The preferred menu link.
-   */
-  public function setPreferredMenuLink($menu_name, $active_path, $menu_link) {
-    $this->preferredMenuLink[$menu_name][$active_path] = $menu_link;
+  // Returns the serialized version of the renderable array, to allow verifying
+  // the renderable array, without needing drupal_render().
+  protected function drupalRender(&$elements, $is_recursive_call = FALSE) {
+    return serialize($elements);
   }
 
 }
diff --git a/core/modules/menu_ui/menu_ui.module b/core/modules/menu_ui/menu_ui.module
index 3639d26..96f814b 100644
--- a/core/modules/menu_ui/menu_ui.module
+++ b/core/modules/menu_ui/menu_ui.module
@@ -18,6 +18,7 @@
 use Drupal\system\Entity\Menu;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Drupal\menu_link\Entity\MenuLink;
+use Drupal\menu_link\MenuTreeParameters;
 use Drupal\menu_link\MenuLinkStorage;
 use Drupal\node\NodeInterface;
 use Symfony\Component\HttpFoundation\Request;
@@ -249,10 +250,17 @@ function _menu_ui_get_options($menus, $available_menus, $item) {
   /** @var \Drupal\menu_link\MenuTreeInterface $menu_tree */
   $menu_tree = \Drupal::service('menu_link.tree');
 
+  // We want to list the entire menu tree, translated and access-checked.
+  $parameters = new MenuTreeParameters();
+  $parameters->appendManipulator('menu_link.default_tree_manipulators:checkAccess')
+    ->appendManipulator('menu_link.default_tree_manipulators:translate')
+    ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+    ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal');
+
   $options = array();
   foreach ($menus as $menu_name => $title) {
     if (isset($available_menus[$menu_name])) {
-      $tree = $menu_tree->buildAllData($menu_name, NULL);
+      $tree = $menu_tree->build($menu_name, $parameters);
       $options[$menu_name . ':0'] = '<' . $title . '>';
       _menu_ui_parents_recurse($tree, $menu_name, '--', $options, $item['mlid'], $limit);
     }
@@ -260,23 +268,26 @@ function _menu_ui_get_options($menus, $available_menus, $item) {
   return $options;
 }
 
+function _menu_ui_options_tree_parameters($menu_name) {
+}
+
 /**
  * Recursive helper function for menu_ui_parent_options().
  */
 function _menu_ui_parents_recurse($tree, $menu_name, $indent, &$options, $exclude, $depth_limit) {
   foreach ($tree as $data) {
-    if ($data['link']['depth'] > $depth_limit) {
+    if ($data->link['depth'] > $depth_limit) {
       // Don't iterate through any links on this level.
       break;
     }
-    if ($data['link']['mlid'] != $exclude && $data['link']['hidden'] >= 0) {
-      $title = $indent . ' ' . truncate_utf8($data['link']['title'], 30, TRUE, FALSE);
-      if ($data['link']['hidden']) {
+    if ($data->link['mlid'] != $exclude && $data->link['hidden'] >= 0) {
+      $title = $indent . ' ' . truncate_utf8($data->link['title'], 30, TRUE, FALSE);
+      if ($data->link['hidden']) {
         $title .= ' (' . t('disabled') . ')';
       }
-      $options[$menu_name . ':' . $data['link']['mlid']] = $title;
-      if ($data['below']) {
-        _menu_ui_parents_recurse($data['below'], $menu_name, $indent . '--', $options, $exclude, $depth_limit);
+      $options[$menu_name . ':' . $data->link['mlid']] = $title;
+      if ($data->children) {
+        _menu_ui_parents_recurse($data->children, $menu_name, $indent . '--', $options, $exclude, $depth_limit);
       }
     }
   }
diff --git a/core/modules/menu_ui/src/MenuForm.php b/core/modules/menu_ui/src/MenuForm.php
index 4d2eac8..1a5ee38 100644
--- a/core/modules/menu_ui/src/MenuForm.php
+++ b/core/modules/menu_ui/src/MenuForm.php
@@ -12,8 +12,9 @@
 use Drupal\Core\Entity\Query\QueryFactory;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\Core\Render\Element;
-use Drupal\menu_link\MenuLinkStorageInterface;
+use Drupal\menu_link\MenuTreeItem;
 use Drupal\menu_link\MenuTreeInterface;
+use Drupal\menu_link\MenuTreeParameters;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -29,13 +30,6 @@ class MenuForm extends EntityForm {
   protected $entityQueryFactory;
 
   /**
-   * The menu link storage.
-   *
-   * @var \Drupal\menu_link\MenuLinkStorageInterface
-   */
-  protected $menuLinkStorage;
-
-  /**
    * The menu tree service.
    *
    * @var \Drupal\menu_link\MenuTreeInterface
@@ -54,14 +48,11 @@ class MenuForm extends EntityForm {
    *
    * @param \Drupal\Core\Entity\Query\QueryFactory $entity_query_factory
    *   The factory for entity queries.
-   * @param \Drupal\menu_link\MenuLinkStorageInterface $menu_link_storage
-   *   The menu link storage.
    * @param \Drupal\menu_link\MenuTreeInterface $menu_tree
    *   The menu tree service.
    */
-  public function __construct(QueryFactory $entity_query_factory, MenuLinkStorageInterface $menu_link_storage, MenuTreeInterface $menu_tree) {
+  public function __construct(QueryFactory $entity_query_factory, MenuTreeInterface $menu_tree) {
     $this->entityQueryFactory = $entity_query_factory;
-    $this->menuLinkStorage = $menu_link_storage;
     $this->menuTree = $menu_tree;
   }
 
@@ -71,7 +62,6 @@ public function __construct(QueryFactory $entity_query_factory, MenuLinkStorageI
   public static function create(ContainerInterface $container) {
     return new static(
       $container->get('entity.query'),
-      $container->get('entity.manager')->getStorage('menu_link'),
       $container->get('menu_link.tree')
     );
   }
@@ -253,24 +243,25 @@ protected function buildOverviewForm(array &$form, array &$form_state) {
 
     $form['#attached']['css'] = array(drupal_get_path('module', 'menu') . '/css/menu.admin.css');
 
-    $links = array();
-    $query = $this->entityQueryFactory->get('menu_link')
-      ->condition('menu_name', $this->entity->id());
-    for ($i = 1; $i <= MENU_MAX_DEPTH; $i++) {
-      $query->sort('p' . $i, 'ASC');
-    }
-    $result = $query->execute();
-
-    if (!empty($result)) {
-      $links = $this->menuLinkStorage->loadMultiple($result);
-    }
+    // We want to list the entire menu tree, translated and access-checked.
+    $parameters = new MenuTreeParameters();
+    $parameters->appendManipulator('menu_link.default_tree_manipulators:checkAccess')
+      ->appendManipulator('menu_link.default_tree_manipulators:translate')
+      ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+      ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal');
 
-    $delta = max(count($links), 50);
     // We indicate that a menu administrator is running the menu access check.
     $this->getRequest()->attributes->set('_menu_admin', TRUE);
-    $tree = $this->menuTree->buildTreeData($links);
+    $tree = $this->menuTree->build($this->entity->id(), $parameters);
     $this->getRequest()->attributes->set('_menu_admin', FALSE);
 
+    // Determine the delta; the number of weights to be made available.
+    $sum = function ($carry, MenuTreeItem $item) {
+      return $carry + $item->count();
+    };
+    $total = array_reduce($tree, $sum);
+    $delta = max($total, 50);
+
     $form = array_merge($form, $this->buildOverviewTreeForm($tree, $delta));
     $form['#empty_text'] = t('There are no menu links yet. <a href="@link">Add link</a>.', array('@link' => url('admin/structure/menu/manage/' . $this->entity->id() .'/add')));
 
@@ -291,7 +282,7 @@ protected function buildOverviewForm(array &$form, array &$form_state) {
   protected function buildOverviewTreeForm($tree, $delta) {
     $form = &$this->overviewTreeForm;
     foreach ($tree as $data) {
-      $item = $data['link'];
+      $item = $data->link;
       // Don't show callbacks; these have $item['hidden'] < 0.
       if ($item && $item['hidden'] >= 0) {
         $mlid = 'mlid:' . $item['mlid'];
@@ -352,8 +343,8 @@ protected function buildOverviewTreeForm($tree, $delta) {
         );
       }
 
-      if ($data['below']) {
-        $this->buildOverviewTreeForm($data['below'], $delta);
+      if ($data->children) {
+        $this->buildOverviewTreeForm($data->children, $delta);
       }
     }
     return $form;
diff --git a/core/modules/node/src/Access/NodeAddAccessCheck.php b/core/modules/node/src/Access/NodeAddAccessCheck.php
index c7bb0e7..90ec739 100644
--- a/core/modules/node/src/Access/NodeAddAccessCheck.php
+++ b/core/modules/node/src/Access/NodeAddAccessCheck.php
@@ -8,14 +8,14 @@
 namespace Drupal\node\Access;
 
 use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\node\NodeTypeInterface;
 
 /**
  * Determines access to for node add pages.
  */
-class NodeAddAccessCheck implements AccessInterface {
+class NodeAddAccessCheck extends AccessBase {
 
   /**
    * The entity manager.
@@ -61,4 +61,18 @@ public function access(AccountInterface $account, NodeTypeInterface $node_type =
     return static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user');
+  }
+
 }
diff --git a/core/modules/node/src/Access/NodeRevisionAccessCheck.php b/core/modules/node/src/Access/NodeRevisionAccessCheck.php
index 66dc7e2..08aa973 100644
--- a/core/modules/node/src/Access/NodeRevisionAccessCheck.php
+++ b/core/modules/node/src/Access/NodeRevisionAccessCheck.php
@@ -9,7 +9,7 @@
 
 use Drupal\Core\Database\Connection;
 use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\node\NodeInterface;
 use Symfony\Component\Routing\Route;
@@ -17,7 +17,7 @@
 /**
  * Provides an access checker for node revisions.
  */
-class NodeRevisionAccessCheck implements AccessInterface {
+class NodeRevisionAccessCheck extends AccessBase {
 
   /**
    * The node storage.
@@ -162,4 +162,21 @@ public function checkAccess(NodeInterface $node, AccountInterface $account, $op
     return $this->access[$cid];
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the entity being translated.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/quickedit/src/Access/EditEntityAccessCheck.php b/core/modules/quickedit/src/Access/EditEntityAccessCheck.php
index a80aa2e..b083201 100644
--- a/core/modules/quickedit/src/Access/EditEntityAccessCheck.php
+++ b/core/modules/quickedit/src/Access/EditEntityAccessCheck.php
@@ -8,7 +8,7 @@
 namespace Drupal\quickedit\Access;
 
 use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Drupal\Core\Entity\EntityInterface;
@@ -16,7 +16,7 @@
 /**
  * Access check for editing entities with QuickEdit.
  */
-class EditEntityAccessCheck implements AccessInterface {
+class EditEntityAccessCheck extends AccessBase {
 
   /**
    * The entity manager.
@@ -87,4 +87,21 @@ protected function validateAndUpcastRequestAttributes(Request $request) {
     return TRUE;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the entity being edited.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/quickedit/src/Access/EditEntityFieldAccessCheck.php b/core/modules/quickedit/src/Access/EditEntityFieldAccessCheck.php
index 6eaba8a..a17c5ba 100644
--- a/core/modules/quickedit/src/Access/EditEntityFieldAccessCheck.php
+++ b/core/modules/quickedit/src/Access/EditEntityFieldAccessCheck.php
@@ -8,7 +8,7 @@
 namespace Drupal\quickedit\Access;
 
 use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Drupal\Core\Entity\EntityInterface;
@@ -16,7 +16,7 @@
 /**
  * Access check for editing entity fields.
  */
-class EditEntityFieldAccessCheck implements AccessInterface, EditEntityFieldAccessCheckInterface {
+class EditEntityFieldAccessCheck extends AccessBase implements EditEntityFieldAccessCheckInterface {
 
   /**
    * The entity manager.
@@ -101,4 +101,21 @@ protected function validateAndUpcastRequestAttributes(Request $request) {
     return TRUE;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the entity being edited.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/rest/src/Access/CSRFAccessCheck.php b/core/modules/rest/src/Access/CSRFAccessCheck.php
index 6d50ff0..37260ee 100644
--- a/core/modules/rest/src/Access/CSRFAccessCheck.php
+++ b/core/modules/rest/src/Access/CSRFAccessCheck.php
@@ -7,7 +7,7 @@
 
 namespace Drupal\rest\Access;
 
-use Drupal\Core\Access\AccessCheckInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\Routing\Route;
 use Symfony\Component\HttpFoundation\Request;
@@ -15,7 +15,7 @@
 /**
  * Access protection against CSRF attacks.
  */
-class CSRFAccessCheck implements AccessCheckInterface {
+class CSRFAccessCheck extends AccessBase {
 
   /**
    * Implements AccessCheckInterface::applies().
@@ -71,4 +71,19 @@ public function access(Request $request, AccountInterface $account) {
     // Let other access checkers decide if the request is legit.
     return static::ALLOW;
   }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/shortcut/src/Access/ShortcutSetSwitchAccessCheck.php b/core/modules/shortcut/src/Access/ShortcutSetSwitchAccessCheck.php
index dece42f..1d6656a 100644
--- a/core/modules/shortcut/src/Access/ShortcutSetSwitchAccessCheck.php
+++ b/core/modules/shortcut/src/Access/ShortcutSetSwitchAccessCheck.php
@@ -7,14 +7,14 @@
 
 namespace Drupal\shortcut\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\user\UserInterface;
 
 /**
  * Checks access to switch a user's shortcut set.
  */
-class ShortcutSetSwitchAccessCheck implements AccessInterface {
+class ShortcutSetSwitchAccessCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -51,4 +51,18 @@ public function access(UserInterface $user, AccountInterface $account) {
     return static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user');
+  }
+
 }
diff --git a/core/modules/system/src/Access/CronAccessCheck.php b/core/modules/system/src/Access/CronAccessCheck.php
index 7aa9a9d..423ee45 100644
--- a/core/modules/system/src/Access/CronAccessCheck.php
+++ b/core/modules/system/src/Access/CronAccessCheck.php
@@ -7,12 +7,12 @@
 
 namespace Drupal\system\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 
 /**
  * Access check for cron routes.
  */
-class CronAccessCheck implements AccessInterface {
+class CronAccessCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -34,4 +34,22 @@ public function access($key) {
     }
     return static::ALLOW;
   }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable per user if we could associate the cache tag
+   *       of the entity being translated.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php
index b960968..0d5e6a0 100644
--- a/core/modules/system/src/Controller/SystemController.php
+++ b/core/modules/system/src/Controller/SystemController.php
@@ -13,6 +13,9 @@
 use Drupal\Core\Extension\ThemeHandlerInterface;
 use Drupal\Core\Form\FormBuilderInterface;
 use Drupal\Core\Theme\ThemeAccessCheck;
+use Drupal\menu_link\Entity\MenuLink;
+use Drupal\menu_link\MenuTreeInterface;
+use Drupal\menu_link\MenuTreeParameters;
 use Drupal\system\SystemManager;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -57,6 +60,13 @@ class SystemController extends ControllerBase {
   protected $themeHandler;
 
   /**
+   * The menu tree service.
+   *
+   * @var \Drupal\menu_link\MenuTreeInterface
+   */
+  protected $menuTree;
+
+  /**
    * Constructs a new SystemController.
    *
    * @param \Drupal\system\SystemManager $systemManager
@@ -69,13 +79,16 @@ class SystemController extends ControllerBase {
    *   The form builder.
    * @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler
    *   The theme handler.
+   * @param \Drupal\menu_link\MenuTreeInterface $menu_tree
+   *   The menu tree service.
    */
-  public function __construct(SystemManager $systemManager, QueryFactory $queryFactory, ThemeAccessCheck $theme_access, FormBuilderInterface $form_builder, ThemeHandlerInterface $theme_handler) {
+  public function __construct(SystemManager $systemManager, QueryFactory $queryFactory, ThemeAccessCheck $theme_access, FormBuilderInterface $form_builder, ThemeHandlerInterface $theme_handler, MenuTreeInterface $menu_tree) {
     $this->systemManager = $systemManager;
     $this->queryFactory = $queryFactory;
     $this->themeAccess = $theme_access;
     $this->formBuilder = $form_builder;
     $this->themeHandler = $theme_handler;
+    $this->menuTree = $menu_tree;
   }
 
   /**
@@ -87,7 +100,8 @@ public static function create(ContainerInterface $container) {
       $container->get('entity.query'),
       $container->get('access_check.theme'),
       $container->get('form_builder'),
-      $container->get('theme_handler')
+      $container->get('theme_handler'),
+      $container->get('menu_link.tree')
     );
   }
 
@@ -111,40 +125,29 @@ public function overview($path) {
       ->condition('link_path', $path)
       ->condition('module', 'system');
     $result = $query->execute();
-    $menu_link_storage = $this->entityManager()->getStorage('menu_link');
-    if ($system_link = $menu_link_storage->loadMultiple($result)) {
-      $system_link = reset($system_link);
-      $query = $this->queryFactory->get('menu_link')
-        ->condition('link_path', 'admin/help', '<>')
-        ->condition('menu_name', $system_link->menu_name)
-        ->condition('plid', $system_link->id())
-        ->condition('hidden', 0);
-      $result = $query->execute();
-      if (!empty($result)) {
-        $menu_links = $menu_link_storage->loadMultiple($result);
-        foreach ($menu_links as $item) {
-          _menu_link_translate($item);
-          if (!$item['access']) {
-            continue;
-          }
-          // The link description, either derived from 'description' in hook_menu()
-          // or customized via Menu UI module is used as title attribute.
-          if (!empty($item['localized_options']['attributes']['title'])) {
-            $item['description'] = $item['localized_options']['attributes']['title'];
-            unset($item['localized_options']['attributes']['title']);
-          }
-          $block = $item;
-          $block['content'] = array(
-            '#theme' => 'admin_block_content',
-            '#content' => $this->systemManager->getAdminBlock($item),
-          );
-
-          if (!empty($block['content']['#content'])) {
-            // Prepare for sorting as in function _menu_tree_check_access().
-            // The weight is offset so it is always positive, with a uniform 5-digits.
-            $blocks[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['mlid']] = $block;
-          }
-        }
+    $system_link = MenuLink::load(reset($result));
+    if ($system_link) {
+      // Get the list of child menu links, for admin/config, which is just a
+      // particularly shaped tree.
+      $parameters = new MenuTreeParameters();
+      $parameters->conditions['plid'] = $system_link->id();
+      $parameters->conditions['hidden'] = 0;
+      $parameters->appendManipulator('menu_link.default_tree_manipulators:flatten')
+        ->appendManipulator('menu_link.default_tree_manipulators:checkAccess')
+        ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
+        ->appendManipulator('menu_link.default_tree_manipulators:translate')
+        ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+        ->appendManipulator('menu_link.default_tree_manipulators:setLinkTitleAsDescription');
+      $tree = $this->menuTree->build($system_link->menu_name, $parameters);
+
+      // Transform the tree to the desired format.
+      foreach ($tree as $key => $item) {
+        $link = $item->link;
+        $blocks[$key] = $link;
+        $blocks[$key]['content'] = array(
+          '#theme' => 'admin_block_content',
+          '#content' => $this->systemManager->getAdminBlock($link),
+        );
       }
     }
     if ($blocks) {
diff --git a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php
index a698e1f..2c92830 100644
--- a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php
+++ b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php
@@ -10,10 +10,13 @@
 use Drupal\Component\Utility\NestedArray;
 use Drupal\block\BlockBase;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator;
+use Drupal\menu_link\MenuActiveTrailInterface;
 use Drupal\menu_link\MenuTreeInterface;
+use Drupal\menu_link\MenuTreeParameters;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
-
 /**
  * Provides a generic Menu block.
  *
@@ -27,13 +30,27 @@
 class SystemMenuBlock extends BlockBase implements ContainerFactoryPluginInterface {
 
   /**
-   * The menu tree.
+   * The menu tree service.
    *
    * @var \Drupal\menu_link\MenuTreeInterface
    */
   protected $menuTree;
 
   /**
+   * The active menu trail service.
+   *
+   * @var \Drupal\menu_link\MenuActiveTrailInterface
+   */
+  protected $menuActiveTrail;
+
+  /**
+   * The cacheable access check menu tree manipulator service.
+   *
+   * @var \Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator
+   */
+  protected $menuCacheableAccessCheck;
+
+  /**
    * Constructs a new SystemMenuBlock.
    *
    * @param array $configuration
@@ -43,11 +60,17 @@ class SystemMenuBlock extends BlockBase implements ContainerFactoryPluginInterfa
    * @param array $plugin_definition
    *   The plugin implementation definition.
    * @param \Drupal\menu_link\MenuTreeInterface $menu_tree
-   *   The menu tree.
+   *   The menu tree service.
+   * @param \Drupal\menu_link\MenuActiveTrailInterface $menu_active_trail
+   *   The active menu trail service.
+   * @param \Drupal\menu_link\CacheableAccessCheckMenuTreeManipulator
+   *   The cacheable access check menu tree manipulator service.
    */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, MenuTreeInterface $menu_tree) {
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, MenuTreeInterface $menu_tree, MenuActiveTrailInterface $menu_active_trail, CacheableAccessCheckMenuTreeManipulator $cacheable_access_check) {
     parent::__construct($configuration, $plugin_id, $plugin_definition);
     $this->menuTree = $menu_tree;
+    $this->menuActiveTrail = $menu_active_trail;
+    $this->menuCacheableAccessCheck = $cacheable_access_check;
   }
 
   /**
@@ -58,7 +81,9 @@ public static function create(ContainerInterface $container, array $configuratio
       $configuration,
       $plugin_id,
       $plugin_definition,
-      $container->get('menu_link.tree')
+      $container->get('menu_link.tree'),
+      $container->get('menu_link.active_trail'),
+      $container->get('menu_link.cacheable_access_check_tree_manipulator')
     );
   }
 
@@ -67,7 +92,17 @@ public static function create(ContainerInterface $container, array $configuratio
    */
   public function build() {
     $menu = $this->getDerivativeId();
-    return $this->menuTree->renderMenu($menu);
+
+    $parameters = new MenuTreeParameters();
+    $parameters->expandAlongActiveTrail($menu, $this->menuActiveTrail->getActiveTrailIds($menu), \Drupal::service('entity.query'))
+      ->appendManipulator('menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess')
+      ->appendManipulator('menu_link.default_tree_manipulators:translate')
+      ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+      ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal');
+
+    $tree = $this->menuTree->build($menu, $parameters);
+
+    return $this->menuTree->render($tree);
   }
 
   /**
@@ -91,9 +126,7 @@ public function defaultConfiguration() {
   public function getCacheKeys() {
     // Add a key for the active menu trail.
     $menu = $this->getDerivativeId();
-    $active_trail = $this->menuTree->getActiveTrailIds($menu);
-    $active_trail_key = 'trail.' . implode('|', $active_trail);
-    return array_merge(parent::getCacheKeys(), array($active_trail_key));
+    return array_merge(parent::getCacheKeys(), array($this->menuActiveTrail->getActiveTrailCacheKey($menu)));
   }
 
   /**
@@ -112,9 +145,43 @@ public function getCacheTags() {
    * {@inheritdoc}
    */
   protected function getRequiredCacheContexts() {
-    // Menu blocks must be cached per role: different roles may have access to
-    // different menu links.
-    return array('cache_context.user.roles');
+    return array_merge(array(
+      'cache_context.domain',
+      'cache_context.language',
+    ), $this->menuCacheableAccessCheck->getAccessCachingContexts());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function access(AccountInterface $account) {
+    // Deny access to menu blocks if we know the rendered menu tree will be
+    // empty for the user.
+    $menu_name = $this->getDerivativeId();
+    return !$this->menuCacheableAccessCheck->willBeEmptyForAccount($menu_name, $account);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, array &$form_state) {
+    $form = parent::buildConfigurationForm($form, $form_state);
+
+    list($uncacheable_access_links) = $this->menuCacheableAccessCheck->analyzeAccessCacheability($this->getDerivativeId());
+    if (count($uncacheable_access_links)) {
+      // @todo Improve UI.
+      // @todo Also show at admin/structure/menu/manage/%menu
+      $form['cache']['warn_about_uncacheable_menu_access'] = array(
+        '#weight' => -100,
+        '#type' => 'item',
+        '#markup' => '<strong>' . $this->t('This menu cannot be cached efficiently. Access checks need to happen on <em>every</em> page load for the following links:', array('!count' => count($uncacheable_access_links)))  . '</strong>',
+      );
+      $form['cache']['warn_about_uncacheable_menu_access']['list'] = array(
+        '#theme' => 'item_list',
+        '#items' => array_keys($uncacheable_access_links),
+      );
+    }
+    return $form;
   }
 
 }
diff --git a/core/modules/system/src/SystemManager.php b/core/modules/system/src/SystemManager.php
index 99b928b..43bd517 100644
--- a/core/modules/system/src/SystemManager.php
+++ b/core/modules/system/src/SystemManager.php
@@ -8,6 +8,8 @@
 
 use Drupal\Component\Utility\Unicode;
 use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\menu_link\MenuTreeItem;
+use Drupal\menu_link\MenuTreeParameters;
 use Symfony\Cmf\Component\Routing\RouteObjectInterface;
 use Drupal\Core\Database\Connection;
 use Drupal\Core\Extension\ModuleHandlerInterface;
@@ -211,29 +213,34 @@ public function getAdminBlock($item) {
       }
     }
 
-    if (isset($this->menuItems[$item['mlid']])) {
-      return $this->menuItems[$item['mlid']];
+    $cache_key = $item['mlid'];
+    if (isset($this->menuItems[$cache_key])) {
+      return $this->menuItems[$cache_key];
     }
 
+    // Get the list of menu links, which is just a particularly shaped tree.
+    /** @var \Drupal\menu_link\MenuTree $menu_tree */
+    $menu_tree = \Drupal::service('menu_link.tree');
+    $parameters = new MenuTreeParameters();
+    $parameters->conditions['plid'] = $item['mlid'];
+    $parameters->conditions['hidden'] = 0;
+    $parameters->appendManipulator('menu_link.default_tree_manipulators:flatten')
+      ->appendManipulator('menu_link.default_tree_manipulators:checkAccess')
+      ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
+      ->appendManipulator('menu_link.default_tree_manipulators:translate')
+      ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+      ->appendManipulator('menu_link.default_tree_manipulators:setLinkTitleAsDescription');
+    $tree = $menu_tree->build($item['menu_name'], $parameters);
+
+    // Transform the tree to the format expected by theme_admin_block_content().
     $content = array();
-    $menu_links = $this->menuLinkStorage->loadByProperties(array('plid' => $item['mlid'], 'menu_name' => $item['menu_name'], 'hidden' => 0));
-    foreach ($menu_links as $link) {
-      _menu_link_translate($link);
-      if ($link['access']) {
-        // The link description, either derived from 'description' in
-        // hook_menu() or customized via Menu UI module is used as title attribute.
-        if (!empty($link['localized_options']['attributes']['title'])) {
-          $link['description'] = $link['localized_options']['attributes']['title'];
-          unset($link['localized_options']['attributes']['title']);
-        }
-        // Prepare for sorting as in function _menu_tree_check_access().
-        // The weight is offset so it is always positive, with a uniform 5-digits.
-        $key = (50000 + $link['weight']) . ' ' . Unicode::strtolower($link['title']) . ' ' . $link['mlid'];
-        $content[$key] = $link;
+    foreach ($tree as $key => $item) {
+      if ($item->link['access']) {
+        $content[$key] = $item->link;
       }
     }
-    ksort($content);
-    $this->menuItems[$item['mlid']] = $content;
+
+    $this->menuItems[$cache_key] = $content;
     return $content;
   }
 
diff --git a/core/modules/system/src/Tests/System/AdminTest.php b/core/modules/system/src/Tests/System/AdminTest.php
index 63b96f7..5f2747c 100644
--- a/core/modules/system/src/Tests/System/AdminTest.php
+++ b/core/modules/system/src/Tests/System/AdminTest.php
@@ -8,6 +8,8 @@
 namespace Drupal\system\Tests\System;
 
 use Drupal\simpletest\WebTestBase;
+use Drupal\menu_link\Entity\MenuLink;
+use Drupal\menu_link\MenuTreeParameters;
 
 /**
  * Tests administrative overview pages.
@@ -114,23 +116,29 @@ function testAdminPages() {
    * @return \Drupal\menu_link\MenuLinkInterface[]
    */
   protected function getTopLevelMenuLinks() {
-    $route_provider = \Drupal::service('router.route_provider');
-    $routes = array();
-    foreach ($route_provider->getAllRoutes() as $key => $value) {
-      $path = $value->getPath();
-      if (strpos($path, '/admin/') === 0 && count(explode('/', $path)) == 3) {
-        $routes[$key] = $key;
-      }
+    // Load the /admin menu link, to determine its mlid and menu name.
+    $query = \Drupal::entityQuery('menu_link')->condition('link_path', 'admin')
+      ->condition('module', 'system');
+    $result = $query->execute();
+    $admin_link = MenuLink::load(reset($result));
+
+    // Get the list of menu links, which is just a particularly shaped tree.
+    /** @var \Drupal\menu_link\MenuTree $menu_tree */
+    $menu_tree = \Drupal::service('menu_link.tree');
+    $parameters = new MenuTreeParameters();
+    $parameters->conditions['plid'] = $admin_link->mlid;
+    $parameters->conditions['hidden'] = 0;
+    $parameters->appendManipulator('menu_link.default_tree_manipulators:flatten');
+    $parameters->appendManipulator('menu_link.default_tree_manipulators:translate');
+    $tree = $menu_tree->build($admin_link->menu_name, $parameters);
+
+    // Transform the tree to a list of menu links.
+    $menu_links = array();
+    foreach ($tree as $item) {
+      $menu_links[] = $item->link;
     }
-    $menu_link_ids = \Drupal::entityQuery('menu_link')
-      ->condition('route_name', $routes)
-      ->execute();
 
-    $menu_items = \Drupal::entityManager()->getStorage('menu_link')->loadMultiple($menu_link_ids);
-    foreach ($menu_items as &$menu_item) {
-      _menu_link_translate($menu_item);
-    }
-    return $menu_items;
+    return $menu_links;
   }
 
   /**
diff --git a/core/modules/system/system.module b/core/modules/system/system.module
index ac44ab5..4bd7f30 100644
--- a/core/modules/system/system.module
+++ b/core/modules/system/system.module
@@ -11,6 +11,8 @@
 use Drupal\Core\StringTranslation\TranslationWrapper;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\block\BlockPluginInterface;
+use Drupal\menu_link\Entity\MenuLink;
+use Drupal\menu_link\MenuTreeParameters;
 use Drupal\user\UserInterface;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use GuzzleHttp\Exception\RequestException;
@@ -1455,12 +1457,33 @@ function system_get_module_admin_tasks($module, array $info) {
   $links = &drupal_static(__FUNCTION__);
 
   if (!isset($links)) {
+    // Load the /admin menu link, to determine its mlid.
+    $query = \Drupal::entityQuery('menu_link')->condition('machine_name', 'system.admin');
+    $result = $query->execute();
+    $admin_link = MenuLink::load(reset($result));
+
+    // Get the list of child menu links, for /admin, which is just a
+    // particularly shaped tree.
+    /** @var \Drupal\menu_link\MenuTree $menu_tree */
+    $menu_tree = \Drupal::service('menu_link.tree');
+    $parameters = new MenuTreeParameters();
+    $parameters->conditions['hidden'] = array(0, '>=');
+    $parameters->conditions['module'] = array('', '>');
+    $parameters->conditions['machine_name'] = array('', '>');
+    $parameters->conditions['p1'] = array('', '>');
+    $parameters->appendManipulator('menu_link.default_tree_manipulators:flatten')
+      ->appendManipulator('menu_link.default_tree_manipulators:checkAccess')
+      ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
+      ->appendManipulator('menu_link.default_tree_manipulators:translate')
+      ->appendManipulator('menu_link.default_tree_manipulators:setLinkTitleAsDescription');
+    $tree = $menu_tree->build($admin_link->menu_name, $parameters);
+
+    // Transform the tree to a list of menu links, minus the inaccessible ones.
     $links = array();
-    $menu_links = entity_get_controller('menu_link')->loadModuleAdminTasks();
-    foreach ($menu_links as $link) {
-      _menu_link_translate($link);
+    foreach ($tree as $item) {
+      $link = $item->link;
       if ($link['access']) {
-        $links[$link['machine_name']] = $link;
+        $links[$link['machine_name']] = (array) $link;
       }
     }
   }
@@ -1474,12 +1497,6 @@ function system_get_module_admin_tasks($module, array $info) {
     $machine_name = $item['machine_name'];
     if (isset($links[$machine_name])) {
       $task = $links[$machine_name];
-      // The link description, either derived from 'description' in the default
-      // menu link or customized via Menu UI module is used as title attribute.
-      if (!empty($task['localized_options']['attributes']['title'])) {
-        $task['description'] = $task['localized_options']['attributes']['title'];
-        unset($task['localized_options']['attributes']['title']);
-      }
 
       // Check the admin tasks for duplicate names. If one is found,
       // append the parent menu item's title to differentiate.
diff --git a/core/modules/system/templates/menu-tree.html.twig b/core/modules/system/templates/menu-tree.html.twig
new file mode 100644
index 0000000..1d47cd9
--- /dev/null
+++ b/core/modules/system/templates/menu-tree.html.twig
@@ -0,0 +1,40 @@
+{#
+/**
+ * @file
+ * Default theme implementation for a menu tree.
+ *
+ * Available variables:
+ * - attributes: Attributes for the UL containing the tree of links.
+ * - tree: Menu tree to be output.
+ * - heading: (optional) A heading to precede the links.
+ *   - text: The heading text.
+ *   - level: The heading level (e.g. 'h2', 'h3').
+ *   - attributes: (optional) A keyed list of attributes for the heading.
+ *   If the heading is a string, it will be used as the text of the heading and
+ *   the level will default to 'h2'.
+ *
+ *   Headings should be used on navigation menus and any list of links that
+ *   consistently appears on multiple pages. To make the heading invisible use
+ *   the 'visually-hidden' CSS class. Do not use 'display:none', which
+ *   removes it from screen-readers and assistive technology. Headings allow
+ *   screen-reader and keyboard only users to navigate to or skip the links.
+ *   See http://juicystudio.com/article/screen-readers-display-none.php and
+ *   http://www.w3.org/TR/WCAG-TECHS/H42.html for more information.
+ *
+ * @see template_preprocess_menu_tree()
+ *
+ * @ingroup themeable
+ */
+#}
+{% if tree -%}
+    {%- if heading -%}
+        {%- if heading.level -%}
+            <{{ heading.level }}{{ heading.attributes }}>{{ heading.text }}</{{ heading.level }}>
+        {%- else -%}
+            <h2{{ heading.attributes }}>{{ heading.text }}</h2>
+        {%- endif -%}
+    {%- endif -%}
+    <ul{{ attributes }}>
+        {{ tree  }}
+    </ul>
+{%- endif %}
diff --git a/core/modules/system/tests/modules/router_test_directory/src/Access/DefinedTestAccessCheck.php b/core/modules/system/tests/modules/router_test_directory/src/Access/DefinedTestAccessCheck.php
index 1469d20..dd66276 100644
--- a/core/modules/system/tests/modules/router_test_directory/src/Access/DefinedTestAccessCheck.php
+++ b/core/modules/system/tests/modules/router_test_directory/src/Access/DefinedTestAccessCheck.php
@@ -7,13 +7,13 @@
 
 namespace Drupal\router_test\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Symfony\Component\Routing\Route;
 
 /**
  * Defines an access checker similar to DefaultAccessCheck
  */
-class DefinedTestAccessCheck implements AccessInterface {
+class DefinedTestAccessCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -36,4 +36,20 @@ public function access(Route $route) {
     }
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * This is globally cacheable.
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/system/tests/modules/router_test_directory/src/Access/TestAccessCheck.php b/core/modules/system/tests/modules/router_test_directory/src/Access/TestAccessCheck.php
index d8af2c6..73be18c 100644
--- a/core/modules/system/tests/modules/router_test_directory/src/Access/TestAccessCheck.php
+++ b/core/modules/system/tests/modules/router_test_directory/src/Access/TestAccessCheck.php
@@ -7,12 +7,12 @@
 
 namespace Drupal\router_test\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 
 /**
  * Access check for test routes.
  */
-class TestAccessCheck implements AccessInterface {
+class TestAccessCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -25,4 +25,21 @@ public function access() {
     // allowed or not.
     return static::DENY;
   }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * This is globally cacheable.
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/toolbar/toolbar.module b/core/modules/toolbar/toolbar.module
index 131a1a5..dc8e7bd 100644
--- a/core/modules/toolbar/toolbar.module
+++ b/core/modules/toolbar/toolbar.module
@@ -9,10 +9,12 @@
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Render\Element;
 use Drupal\Core\Template\Attribute;
+use Drupal\Component\Utility\NestedArray;
 use Drupal\Component\Utility\Crypt;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Drupal\menu_link\MenuLinkInterface;
+use Drupal\menu_link\MenuTreeParameters;
 use Drupal\user\RoleInterface;
 use Drupal\user\UserInterface;
 
@@ -350,33 +352,17 @@ function toolbar_toolbar() {
     '#weight' => -20,
   );
 
-  // Retrieve the administration menu from the database.
-  $tree = toolbar_get_menu_tree();
-
-  // Add attributes to the links before rendering.
-  toolbar_menu_navigation_links($tree);
-
-  /** @var \Drupal\menu_link\MenuTreeInterface $menu_tree */
-  $menu_tree = \Drupal::service('menu_link.tree');
-
-  $menu = array(
-    '#heading' => t('Administration menu'),
-    'toolbar_administration' => array(
-      '#type' => 'container',
-      '#attributes' => array(
-        'class' => array('toolbar-menu-administration'),
-      ),
-      'administration_menu' => $menu_tree->renderTree($tree),
-    ),
-  );
-
   // To conserve bandwidth, we only include the top-level links in the HTML.
   // The subtrees are fetched through a JSONP script that is generated at the
   // toolbar_subtrees route. We provide the JavaScript requesting that JSONP
   // script here with the hash parameter that is needed for that route.
   // @see toolbar_subtrees_jsonp()
+  // @todo Optimize this; _toolbar_get_subtrees_hash() causes two DB queries! B
+  //       default, a Drupal 8 site's admin menu tree is completely cacheable
+  //       per role, and with MenuTree::analyzeAccessCacheability(), we now have
+  //       a tool to verify whether that is the case or not.
   $langcode = \Drupal::languageManager()->getCurrentLanguage()->id;
-  $menu['toolbar_administration']['#attached']['js'][] = array(
+  $subtrees_attached['js'][] = array(
     'type' => 'setting',
     'data' => array('toolbar' => array(
       'subtreesHash' => _toolbar_get_subtrees_hash($langcode),
@@ -403,13 +389,50 @@ function toolbar_toolbar() {
         'data-drupal-subtrees' => '',
       ),
     ),
-    'tray' => $menu,
+    'tray' => array(
+      '#heading' => t('Administration menu'),
+      '#attached' => $subtrees_attached,
+      'toolbar_administration' => array(
+        '#cache' => array(
+          'keys' => array(
+            'toolbar',
+            'admin_menu_tray',
+            // This is cached per role, domain, language and theme. Since this
+            // is a single level, we don't care about the active trail at all;
+            // marking links as active is handled by the drupal.active-link
+            // library. Hence we don't need to vary by active trail.
+            'cache_context.user.role',
+            'cache_context.domain',
+            'cache_context.language',
+            'cache_context.theme',
+          ),
+        ),
+        '#pre_render' => array(
+          'toolbar_prerender_toolbar_administration_tray',
+        ),
+        '#type' => 'container',
+        '#attributes' => array(
+          'class' => array('toolbar-menu-administration'),
+        ),
+      ),
+    ),
     '#weight' => -15,
   );
 
   return $items;
 }
 
+// Add attributes to the links before rendering.
+function toolbar_prerender_toolbar_administration_tray(array $element) {
+  /** @var \Drupal\menu_link\MenuTreeInterface $menu_tree */
+  $menu_tree = \Drupal::service('menu_link.tree');
+
+  $tree = toolbar_get_menu_tree();
+  $element['administration_menu'] = $menu_tree->render($tree);
+
+  return $element;
+}
+
 /**
  * Gets only the top level items below the 'admin' path.
  *
@@ -427,11 +450,16 @@ function toolbar_get_menu_tree() {
   $result = $query->execute();
   if (!empty($result)) {
     $admin_link = menu_link_load(reset($result));
-    $tree = $menu_tree->buildTree('admin', array(
-      'expanded' => array($admin_link['mlid']),
-      'min_depth' => $admin_link['depth'] + 1,
-      'max_depth' => $admin_link['depth'] + 1,
-    ));
+    $parameters = new MenuTreeParameters();
+    $parameters->setMinDepth($admin_link['depth'] + 1)
+      ->setMaxDepth($admin_link['depth'] + 1)
+      ->addExpanded(array($admin_link['mlid']))
+      ->appendManipulator('menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess')
+      ->appendManipulator('menu_link.default_tree_manipulators:translate')
+      ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+      ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
+      ->appendManipulator('toolbar_menu_navigation_links');
+    $tree = $menu_tree->build('admin', $parameters);
   }
 
   return $tree;
@@ -446,35 +474,39 @@ function toolbar_get_menu_tree() {
  * @return
  *   An array of links as defined above.
  */
-function toolbar_menu_navigation_links(&$tree) {
+function toolbar_menu_navigation_links($tree) {
   foreach ($tree as $key => $item) {
     // Configure sub-items.
-    if (!empty($item['below'])) {
-      toolbar_menu_navigation_links($tree[$key]['below']);
+    if (!empty($item->children)) {
+      toolbar_menu_navigation_links($tree[$key]->children);
     }
     // Make sure we have a path specific ID in place, so we can attach icons
     // and behaviors to the items.
-    $tree[$key]['link']['localized_options']['attributes'] = array(
-      'id' => 'toolbar-link-' . str_replace(array('/', '<', '>'), array('-', '', ''), $item['link']['link_path']),
+    $tree[$key]->link['localized_options']['attributes'] = array(
+      'id' => 'toolbar-link-' . str_replace(array('/', '<', '>'), array('-', '', ''), $item->link['link_path']),
       'class' => array(
         'toolbar-icon',
-        'toolbar-icon-' . strtolower(str_replace(' ', '-', $item['link']['link_title'])),
+        'toolbar-icon-' . strtolower(str_replace(' ', '-', $item->link['link_title'])),
       ),
-      'title' => String::checkPlain($item['link']['description']),
+      'title' => String::checkPlain($item->link['description']),
     );
   }
+  return $tree;
 }
 
 /**
  * Returns the rendered subtree of each top-level toolbar link.
+ *
+ * TODO cache per role instead of per user; there are ZERO non-role access check links in the admin menu by default!
  */
 function toolbar_get_rendered_subtrees() {
   $subtrees = array();
   /** @var \Drupal\menu_link\MenuTreeInterface $menu_tree */
   $menu_tree = \Drupal::service('menu_link.tree');
+  // @todo toolbar_get_menu_tree() does NOT check access per user, even though this expects that; all of this is only working by accident!
   $tree = toolbar_get_menu_tree();
   foreach ($tree as $tree_item) {
-    $item = $tree_item['link'];
+    $item = $tree_item->link;
     if (!$item['hidden'] && $item['access']) {
       if ($item['has_children']) {
         $query = \Drupal::entityQuery('menu_link')
@@ -483,15 +515,25 @@ function toolbar_get_rendered_subtrees() {
           $query->condition('p' . $i, $item['p' . $i]);
         }
         $parents = $query->execute();
-        $subtree = $menu_tree->buildTree($item['menu_name'], array('expanded' => $parents, 'min_depth' => $item['depth']+1));
-        toolbar_menu_navigation_links($subtree);
-        $subtree = $menu_tree->renderTree($subtree);
+
+        $parameters = new MenuTreeParameters();
+        $parameters->setMinDepth($item['depth'] + 1)
+          ->addExpanded($parents)
+          // @todo Switch to menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess
+          // so we can remove per-user toolbar tree caching.
+          ->appendManipulator('menu_link.default_tree_manipulators:checkAccess')
+          ->appendManipulator('menu_link.default_tree_manipulators:translate')
+          ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+          ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
+          ->appendManipulator('toolbar_menu_navigation_links');
+
+        $subtree = $menu_tree->build($item['menu_name'], $parameters);
+        $subtree = $menu_tree->render($subtree);
         $subtree = drupal_render($subtree);
       }
       else {
         $subtree = '';
       }
-
       $id = str_replace(array('/', '<', '>'), array('-', '', ''), $item['link_path']);
       $subtrees[$id] = $subtree;
     }
diff --git a/core/modules/tracker/src/Access/ViewOwnTrackerAccessCheck.php b/core/modules/tracker/src/Access/ViewOwnTrackerAccessCheck.php
index 4421b77..68cdebb 100644
--- a/core/modules/tracker/src/Access/ViewOwnTrackerAccessCheck.php
+++ b/core/modules/tracker/src/Access/ViewOwnTrackerAccessCheck.php
@@ -7,14 +7,14 @@
 
 namespace Drupal\tracker\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\user\UserInterface;
 
 /**
  * Access check for user tracker routes.
  */
-class ViewOwnTrackerAccessCheck implements AccessInterface {
+class ViewOwnTrackerAccessCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -30,4 +30,19 @@ class ViewOwnTrackerAccessCheck implements AccessInterface {
   public function access(AccountInterface $account, UserInterface $user) {
     return ($user && $account->isAuthenticated() && ($user->id() == $account->id())) ? static::ALLOW : static::DENY;
   }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user');
+  }
+
 }
diff --git a/core/modules/update/src/Access/UpdateManagerAccessCheck.php b/core/modules/update/src/Access/UpdateManagerAccessCheck.php
index 5fc20b0..9967bd3 100644
--- a/core/modules/update/src/Access/UpdateManagerAccessCheck.php
+++ b/core/modules/update/src/Access/UpdateManagerAccessCheck.php
@@ -7,13 +7,13 @@
 
 namespace Drupal\update\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Site\Settings;
 
 /**
  * Determines whether allow authorized operations is set.
  */
-class UpdateManagerAccessCheck implements AccessInterface {
+class UpdateManagerAccessCheck extends AccessBase {
 
   /**
    * Settings Service.
@@ -42,4 +42,21 @@ public function access() {
     return $this->settings->get('allow_authorize_operations', TRUE) ? static::ALLOW : static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @todo This would be cacheable globally if we could associate the cache tag
+   *       of the Settings.
+   */
+  public function isCacheable() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array();
+  }
+
 }
diff --git a/core/modules/user/src/Access/LoginStatusCheck.php b/core/modules/user/src/Access/LoginStatusCheck.php
index fbceacd..577b846 100644
--- a/core/modules/user/src/Access/LoginStatusCheck.php
+++ b/core/modules/user/src/Access/LoginStatusCheck.php
@@ -7,14 +7,14 @@
 
 namespace Drupal\user\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Determines access to routes based on login status of current user.
  */
-class LoginStatusCheck implements AccessInterface {
+class LoginStatusCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -31,4 +31,18 @@ public function access(Request $request, AccountInterface $account) {
     return ($request->attributes->get('_menu_admin') || $account->isAuthenticated()) ? static::ALLOW : static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user.roles');
+  }
+
 }
diff --git a/core/modules/user/src/Access/PermissionAccessCheck.php b/core/modules/user/src/Access/PermissionAccessCheck.php
index 2672a4e..17f88b6 100644
--- a/core/modules/user/src/Access/PermissionAccessCheck.php
+++ b/core/modules/user/src/Access/PermissionAccessCheck.php
@@ -7,14 +7,14 @@
 
 namespace Drupal\user\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\Routing\Route;
 
 /**
  * Determines access to routes based on permissions defined via hook_permission().
  */
-class PermissionAccessCheck implements AccessInterface {
+class PermissionAccessCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -31,4 +31,19 @@ public function access(Route $route, AccountInterface $account) {
     $permission = $route->getRequirement('_permission');
     return $account->hasPermission($permission) ? static::ALLOW : static::DENY;
   }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user.roles');
+  }
+
 }
diff --git a/core/modules/user/src/Access/RegisterAccessCheck.php b/core/modules/user/src/Access/RegisterAccessCheck.php
index aec179b..f199a62 100644
--- a/core/modules/user/src/Access/RegisterAccessCheck.php
+++ b/core/modules/user/src/Access/RegisterAccessCheck.php
@@ -7,14 +7,14 @@
 
 namespace Drupal\user\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Access check for user registration routes.
  */
-class RegisterAccessCheck implements AccessInterface {
+class RegisterAccessCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -30,4 +30,19 @@ class RegisterAccessCheck implements AccessInterface {
   public function access(Request $request, AccountInterface $account) {
     return ($request->attributes->get('_menu_admin') || $account->isAnonymous()) && (\Drupal::config('user.settings')->get('register') != USER_REGISTER_ADMINISTRATORS_ONLY) ? static::ALLOW : static::DENY;
   }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user.roles');
+  }
+
 }
diff --git a/core/modules/user/src/Access/RoleAccessCheck.php b/core/modules/user/src/Access/RoleAccessCheck.php
index e8cfda4..25afe82 100644
--- a/core/modules/user/src/Access/RoleAccessCheck.php
+++ b/core/modules/user/src/Access/RoleAccessCheck.php
@@ -7,7 +7,7 @@
 
 namespace Drupal\user\Access;
 
-use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\Routing\Route;
 
@@ -18,7 +18,7 @@
  * single role, users with that role with have access. If you specify multiple
  * ones you can conjunct them with AND by using a "+" and with OR by using ",".
  */
-class RoleAccessCheck implements AccessInterface {
+class RoleAccessCheck extends AccessBase {
 
   /**
    * Checks access.
@@ -54,4 +54,18 @@ public function access(Route $route, AccountInterface $account) {
     return static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user.roles');
+  }
+
 }
diff --git a/core/modules/user/src/Tests/UserAccountLinksTests.php b/core/modules/user/src/Tests/UserAccountLinksTests.php
index ce936e4..bef8b0c 100644
--- a/core/modules/user/src/Tests/UserAccountLinksTests.php
+++ b/core/modules/user/src/Tests/UserAccountLinksTests.php
@@ -8,6 +8,7 @@
 namespace Drupal\user\Tests;
 
 use Drupal\simpletest\WebTestBase;
+use Drupal\menu_link\MenuTreeParameters;
 
 /**
  * Tests user links in the secondary menu.
@@ -69,10 +70,17 @@ function testSecondaryMenu() {
     // For a logged-out user, expect no secondary links.
     /** @var \Drupal\menu_link\MenuTreeInterface $menu_tree */
     $menu_tree = \Drupal::service('menu_link.tree');
-    $tree = $menu_tree->buildTree('account');
+    $parameters = new MenuTreeParameters();
+    $parameters->setMaxDepth(1)
+      ->appendManipulator('menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess')
+      ->appendManipulator('menu_link.default_tree_manipulators:translate')
+      ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
+      ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
+      ->appendManipulator('menu_link.default_tree_manipulators:setTreeItemClass');
+    $tree = $menu_tree->build('account', $parameters);
     $this->assertEqual(count($tree), 1, 'The secondary links menu contains only one menu link.');
     $link = reset($tree);
-    $link = $link['link'];
+    $link = $link->link;
     $this->assertTrue((bool) $link->hidden, 'The menu link is hidden.');
   }
 
diff --git a/core/modules/views/src/ViewsAccessCheck.php b/core/modules/views/src/ViewsAccessCheck.php
index 8a3b508..afc32c9 100644
--- a/core/modules/views/src/ViewsAccessCheck.php
+++ b/core/modules/views/src/ViewsAccessCheck.php
@@ -8,6 +8,7 @@
 namespace Drupal\views;
 
 use Drupal\Core\Access\AccessCheckInterface;
+use Drupal\Core\Routing\Access\AccessBase;
 use Drupal\Core\Session\AccountInterface;
 use Symfony\Component\Routing\Route;
 
@@ -16,7 +17,7 @@
  *
  * @todo We could leverage the permission one as well?
  */
-class ViewsAccessCheck implements AccessCheckInterface {
+class ViewsAccessCheck extends AccessBase implements AccessCheckInterface {
 
   /**
    * {@inheritdoc}
@@ -38,4 +39,18 @@ public function access(AccountInterface $account) {
     return $account->hasPermission('access all views') ? static::ALLOW : static::DENY;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function isCacheable() {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array('cache_context.user.roles');
+  }
+
 }
diff --git a/core/themes/bartik/bartik.theme b/core/themes/bartik/bartik.theme
index 5ccf24c..cd61853 100644
--- a/core/themes/bartik/bartik.theme
+++ b/core/themes/bartik/bartik.theme
@@ -59,15 +59,10 @@ function bartik_preprocess_page(&$variables) {
   // Pass the main menu and secondary menu to the template as render arrays.
   if (!empty($variables['main_menu'])) {
     $variables['main_menu']['#attributes']['id'] = 'main-menu-links';
-    $variables['main_menu']['#attributes']['class'] = array('links', 'clearfix');
   }
   if (!empty($variables['secondary_menu'])) {
     $variables['secondary_menu']['#attributes']['id'] = 'secondary-menu-links';
-    $variables['secondary_menu']['#attributes']['class'] = array(
-      'links',
-      'inline',
-      'clearfix',
-    );
+    $variables['secondary_menu']['#attributes']['class'][] = 'inline';
   }
 
   // Set the options that apply to both page and maintenance page.
@@ -139,10 +134,15 @@ function bartik_preprocess_block(&$variables) {
 }
 
 /**
- * Implements THEME_menu_tree().
+ * Implements hook_preprocess_HOOK() for menu-tree.html.twig.
+ *
+ * @see template_preprocess_menu_tree()
  */
-function bartik_menu_tree($variables) {
-  return '<ul class="menu clearfix">' . $variables['tree'] . '</ul>';
+function bartik_preprocess_menu_tree(&$variables) {
+  if (!isset($variables['attributes']['class'])) {
+    $variables['attributes']['class'] = array();
+  }
+  $variables['attributes']['class'][] = 'clearfix';
 }
 
 /**
