 .../Drupal/block/Tests/Views/DisplayBlockTest.php  |    2 +-
 core/modules/contextual/contextual.module          |    3 -
 core/modules/contextual/js/contextual.js           |   73 ++++++++++++++------
 .../Tests/ContextualDynamicContextTest.php         |    4 +-
 core/modules/edit/js/edit.js                       |   63 ++++++++++++++---
 .../menu/lib/Drupal/menu/Tests/MenuTest.php        |    2 +-
 .../user/lib/Drupal/user/RoleStorageController.php |   10 +++
 .../lib/Drupal/user/Tests/UserPermissionsTest.php  |   11 +++
 core/modules/user/user.module                      |   49 ++++++++++++-
 9 files changed, 179 insertions(+), 38 deletions(-)

diff --git a/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php b/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php
index 9a4680c..f525393 100644
--- a/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php
+++ b/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php
@@ -240,7 +240,7 @@ public function testBlockContextualLinks() {
     $response = $this->drupalPost('contextual/render', 'application/json', $post, array('query' => array('destination' => 'test-page')));
     $this->assertResponse(200);
     $json = drupal_json_decode($response);
-    $this->assertIdentical($json[$id], '<ul class="contextual-links"><li class="block-configure odd first"><a href="' . base_path() . 'admin/structure/block/manage/' . $block->id() . '?destination=test-page">Configure block</a></li><li class="views-uiedit even last"><a href="' . base_path() . 'admin/structure/views/view/test_view_block/edit/block_1?destination=test-page">Edit view</a></li></ul>');
+    $this->assertIdentical($json[$id], '<ul class="contextual-links"><li class="block-configure odd first"><a href="' . base_path() . 'admin/structure/block/manage/' . $block->id() . '">Configure block</a></li><li class="views-uiedit even last"><a href="' . base_path() . 'admin/structure/views/view/test_view_block/edit/block_1">Edit view</a></li></ul>');
   }
 
 }
diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module
index e405f20..f13b639 100644
--- a/core/modules/contextual/contextual.module
+++ b/core/modules/contextual/contextual.module
@@ -283,9 +283,6 @@ function contextual_pre_render_links($element) {
       'route_name' => isset($item['route_name']) ? $item['route_name'] : '',
       'route_parameters' => isset($item['route_parameters']) ? $item['route_parameters'] : array(),
     );
-    $item['localized_options'] += array('query' => array());
-    $item['localized_options']['query'] += drupal_get_destination();
-    $links[$class] += $item['localized_options'];
   }
   $element['#links'] = $links;
 
diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js
index 0ea3815..38cbc49 100644
--- a/core/modules/contextual/js/contextual.js
+++ b/core/modules/contextual/js/contextual.js
@@ -3,7 +3,7 @@
  * Attaches behaviors for the Contextual module.
  */
 
-(function ($, Drupal, drupalSettings, Backbone, Modernizr) {
+(function ($, Drupal, drupalSettings, _, Backbone, Modernizr, JSON, storage) {
 
 "use strict";
 
@@ -17,24 +17,49 @@ var options = $.extend(drupalSettings.contextual,
   }
 );
 
+// Clear the cached contextual links whenever the current user's set of
+// permissions changes.
+var cachedPermissionsHash = storage.getItem('Drupal.contextual.permissionsHash');
+var permissionsHash = drupalSettings.user.permissionsHash;
+if (cachedPermissionsHash !== permissionsHash) {
+  if (typeof permissionsHash === 'string') {
+    _.chain(storage).keys().each(function (key) {
+      if (key.substring(0, 18) === 'Drupal.contextual.') {
+        storage.removeItem(key);
+      }
+    });
+  }
+  storage.setItem('Drupal.contextual.permissionsHash', permissionsHash);
+}
+
 /**
  * Initializes a contextual link: updates its DOM, sets up model and views
  *
  * @param jQuery $contextual
  *   A contextual links placeholder DOM element, containing the actual
  *   contextual links as rendered by the server.
+ * @param string html
+ *   The server-side rendered HTML for this contextual link.
  */
-function initContextual ($contextual) {
+function initContextual ($contextual, html) {
   var $region = $contextual.closest('.contextual-region');
   var contextual = Drupal.contextual;
 
   $contextual
+    // Update the placeholder to contain its rendered contextual links.
+    .html(html)
     // Use the placeholder as a wrapper with a specific class to provide
     // positioning and behavior attachment context.
     .addClass('contextual')
     // Ensure a trigger element exists before the actual contextual links.
     .prepend(Drupal.theme('contextualTrigger'));
 
+  // Set the destination parameter on each of the contextual links.
+  var destination = '?destination=' + Drupal.encodePath(drupalSettings.currentPath);
+  $contextual.find('.contextual-links a').each(function () {
+    this.setAttribute('href', this.getAttribute('href') + destination);
+  });
+
   // Create a model and the appropriate views.
   var model = new contextual.Model({
     title: $region.find('h2:first').text().trim()
@@ -128,26 +153,34 @@ Drupal.behaviors.contextual = {
       ids.push($(this).attr('data-contextual-id'));
     });
 
-    // Perform an AJAX request to let the server render the contextual links for
-    // each of the placeholders.
-    $.ajax({
-      url: Drupal.url('contextual/render') + '?destination=' + Drupal.encodePath(drupalSettings.currentPath),
-      type: 'POST',
-      data: { 'ids[]' : ids },
-      dataType: 'json',
-      success: function (results) {
-        for (var id in results) {
-          if (results.hasOwnProperty(id)) {
-            // Update the placeholder to contain its rendered contextual links.
-            var $placeholder = $context.find('[data-contextual-id="' + id + '"]')
-              .html(results[id]);
+    // Update all contextual links placeholders whose HTML is cached.
+    var uncachedIDs = _.filter(ids, function initIfCached (contextualID) {
+      var html = storage.getItem('Drupal.contextual.' + contextualID);
+      if (html !== null) {
+        initContextual($context.find('[data-contextual-id="' + contextualID + '"]'), html);
+        return false;
+      }
+      return true;
+    });
 
+    // Perform an AJAX request to let the server render the contextual links for
+    // each of the placeholders whose HTML is not yet cached.
+    if (uncachedIDs.length > 0) {
+      $.ajax({
+        url: Drupal.url('contextual/render'),
+        type: 'POST',
+        data: { 'ids[]' : uncachedIDs },
+        dataType: 'json',
+        success: function (results) {
+          _.each(results, function (html, contextualID) {
+            // Store the metadata.
+            storage.setItem('Drupal.contextual.' + contextualID, html);
             // Initialize the contextual link.
-            initContextual($placeholder);
-          }
+            initContextual($context.find('[data-contextual-id="' + contextualID + '"]'), html);
+          });
         }
-      }
-     });
+      });
+    }
   }
 };
 
@@ -417,4 +450,4 @@ Drupal.theme.contextualTrigger = function () {
   return '<button class="trigger visually-hidden focusable" type="button"></button>';
 };
 
-})(jQuery, Drupal, drupalSettings, Backbone, Modernizr);
+})(jQuery, Drupal, drupalSettings, _, Backbone, Modernizr, window.JSON, window.sessionStorage);
diff --git a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
index 4cbbca3..bdca035 100644
--- a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
+++ b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
@@ -77,9 +77,9 @@ function testDifferentPermissions() {
     $response = $this->renderContextualLinks($ids, 'node');
     $this->assertResponse(200);
     $json = drupal_json_decode($response);
-    $this->assertIdentical($json[$ids[0]], '<ul class="contextual-links"><li class="nodepage-edit odd first last"><a href="' . base_path() . 'node/1/edit?destination=node">Edit</a></li></ul>');
+    $this->assertIdentical($json[$ids[0]], '<ul class="contextual-links"><li class="nodepage-edit odd first last"><a href="' . base_path() . 'node/1/edit">Edit</a></li></ul>');
     $this->assertIdentical($json[$ids[1]], '');
-    $this->assertIdentical($json[$ids[2]], '<ul class="contextual-links"><li class="nodepage-edit odd first last"><a href="' . base_path() . 'node/3/edit?destination=node">Edit</a></li></ul>');
+    $this->assertIdentical($json[$ids[2]], '<ul class="contextual-links"><li class="nodepage-edit odd first last"><a href="' . base_path() . 'node/3/edit">Edit</a></li></ul>');
     $this->assertIdentical($json[$ids[3]], '');
 
     // Authenticated user: can access contextual links, cannot edit articles.
diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js
index dab4522..37eab08 100644
--- a/core/modules/edit/js/edit.js
+++ b/core/modules/edit/js/edit.js
@@ -16,7 +16,7 @@
  *   - contextualLinksQueue: queue of contextual links on entities for which it
  *     is not yet known whether the user has permission to edit at >=1 of them.
  */
-(function ($, _, Backbone, Drupal, drupalSettings) {
+(function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
 
 "use strict";
 
@@ -59,15 +59,28 @@ Drupal.behaviors.edit = {
     // Initialize the Edit app once per page load.
     $('body').once('edit-init', initEdit);
 
+    // Find all in-place editable fields, if any.
+    var $fields = $(context).find('[data-edit-id]').once('edit');
+    if ($fields.length === 0) {
+      return;
+    }
+
     // Process each field element: queue to be used or to fetch metadata.
     // When a field is being rerendered after editing, it will be processed
     // immediately. New fields will be unable to be processed immediately, but
     // will instead be queued to have their metadata fetched, which occurs below
     // in fetchMissingMetaData().
-    $(context).find('[data-edit-id]').once('edit').each(function (index, fieldElement) {
+    $fields.each(function (index, fieldElement) {
       processField(fieldElement);
     });
 
+    // Entities and fields on the page have been detected, try to set up the
+    // contextual links for those entities that already have the necessary meta-
+    // data in the client-side cache.
+    contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) {
+      return !initializeEntityContextualLink(contextualLink);
+    });
+
     // Fetch metadata for any fields that are queued to retrieve it.
     fetchMissingMetadata(function (fieldElementsWithFreshMetadata) {
       // Metadata has been fetched, reprocess fields whose metadata was missing.
@@ -103,17 +116,47 @@ Drupal.edit = {
   // Per-field metadata that indicates whether in-place editing is allowed,
   // which in-place editor should be used, etc.
   metadata: {
-    has: function (fieldID) { return _.has(this.data, fieldID); },
-    add: function (fieldID, metadata) { this.data[fieldID] = metadata; },
+    has: function (fieldID) {
+      return storage.getItem(this._prefixFieldID(fieldID)) !== null;
+    },
+    add: function (fieldID, metadata) {
+      storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
+    },
     get: function (fieldID, key) {
-      return (key === undefined) ? this.data[fieldID] : this.data[fieldID][key];
+      var metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
+      return (key === undefined) ? metadata : metadata[key];
+    },
+    _prefixFieldID: function (fieldID) {
+      return 'Drupal.edit.metadata.' + fieldID;
     },
-    intersection: function (fieldIDs) { return _.intersection(fieldIDs, _.keys(this.data)); },
-    // Contains the actual metadata, keyed by field ID.
-    data: {}
+    _unprefixFieldID: function (fieldID) {
+      // Strip "Drupal.edit.metadata.", which is 21 characters long.
+      return fieldID.substring(21);
+    },
+    intersection: function (fieldIDs) {
+      var prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
+      var intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage));
+      return _.map(intersection, this._unprefixFieldID);
+    }
   }
 };
 
+// Clear the Edit metadata cache whenever the current user's set of permissions
+// changes.
+var permissionsHashKey = Drupal.edit.metadata._prefixFieldID('permissionsHash');
+var permissionsHashValue = storage.getItem(permissionsHashKey);
+var permissionsHash = drupalSettings.user.permissionsHash;
+if (permissionsHashValue !== permissionsHash) {
+  if (typeof permissionsHash === 'string') {
+    _.chain(storage).keys().each(function (key) {
+      if (key.substring(0, 21) === 'Drupal.edit.metadata.') {
+        storage.removeItem(key);
+      }
+    });
+  }
+  storage.setItem(permissionsHashKey, permissionsHash);
+}
+
 /**
  * Detect contextual links on entities annotated by Edit; queue these to be
  * processed.
@@ -244,7 +287,7 @@ function fetchMissingMetadata (callback) {
     var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
     var entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
     // Ensure we only request entityIDs for which we don't have metadata yet.
-    entityIDs = _.difference(entityIDs, _.keys(Drupal.edit.metadata.data));
+    entityIDs = _.difference(entityIDs, Drupal.edit.metadata.intersection(entityIDs));
     fieldsMetadataQueue = [];
 
     $.ajax({
@@ -465,4 +508,4 @@ function deleteContainedModelsAndQueues($context) {
   });
 }
 
-})(jQuery, _, Backbone, Drupal, drupalSettings);
+})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);
diff --git a/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php b/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php
index f4f9e53..48baaa1 100644
--- a/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php
+++ b/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php
@@ -418,7 +418,7 @@ public function testBlockContextualLinks() {
     $response =  $this->drupalPost('contextual/render', 'application/json', $post, array('query' => array('destination' => 'test-page')));
     $this->assertResponse(200);
     $json = drupal_json_decode($response);
-    $this->assertIdentical($json[$id], '<ul class="contextual-links"><li class="block-configure odd first"><a href="' . base_path() . 'admin/structure/block/manage/' . $block->id() . '?destination=test-page">Configure block</a></li><li class="menu-edit even last"><a href="' . base_path() . 'admin/structure/menu/manage/tools?destination=test-page">Edit menu</a></li></ul>');
+    $this->assertIdentical($json[$id], '<ul class="contextual-links"><li class="block-configure odd first"><a href="' . base_path() . 'admin/structure/block/manage/' . $block->id() . '">Configure block</a></li><li class="menu-edit even last"><a href="' . base_path() . 'admin/structure/menu/manage/tools">Edit menu</a></li></ul>');
   }
 
   /**
diff --git a/core/modules/user/lib/Drupal/user/RoleStorageController.php b/core/modules/user/lib/Drupal/user/RoleStorageController.php
index 03937a6..dbf9af0 100644
--- a/core/modules/user/lib/Drupal/user/RoleStorageController.php
+++ b/core/modules/user/lib/Drupal/user/RoleStorageController.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\user;
 
+use Drupal\Core\Cache\Cache;
 use Drupal\Core\Config\Entity\ConfigStorageController;
 
 /**
@@ -17,6 +18,15 @@ class RoleStorageController extends ConfigStorageController implements RoleStora
   /**
    * {@inheritdoc}
    */
+  public function resetCache(array $ids = NULL) {
+    parent::resetCache($ids);
+    // Clear the user permissions cache.
+    Cache::invalidateTags(array('permissions' => $ids));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function deleteRoleReferences(array $rids) {
     // Remove the role from all users.
     db_delete('users_roles')
diff --git a/core/modules/user/lib/Drupal/user/Tests/UserPermissionsTest.php b/core/modules/user/lib/Drupal/user/Tests/UserPermissionsTest.php
index 48ce623..d2dc07c 100644
--- a/core/modules/user/lib/Drupal/user/Tests/UserPermissionsTest.php
+++ b/core/modules/user/lib/Drupal/user/Tests/UserPermissionsTest.php
@@ -40,6 +40,7 @@ function testUserPermissionChanges() {
     $this->drupalLogin($this->admin_user);
     $rid = $this->rid;
     $account = $this->admin_user;
+    $previous_permissions_hash = user_get_permissions_hash($account);
 
     // Add a permission.
     $this->assertFalse(user_access('administer nodes', $account), 'User does not have "administer nodes" permission.');
@@ -50,6 +51,9 @@ function testUserPermissionChanges() {
     $storage_controller = $this->container->get('entity.manager')->getStorageController('user_role');
     $storage_controller->resetCache();
     $this->assertTrue(user_access('administer nodes', $account), 'User now has "administer nodes" permission.');
+    $current_permissions_hash = user_get_permissions_hash($account);
+    $this->assertNotEqual($previous_permissions_hash, $current_permissions_hash, 'Permissions hash has changed.');
+    $previous_permissions_hash = $current_permissions_hash;
 
     // Remove a permission.
     $this->assertTrue(user_access('access user profiles', $account), 'User has "access user profiles" permission.');
@@ -59,6 +63,8 @@ function testUserPermissionChanges() {
     $this->assertText(t('The changes have been saved.'), 'Successful save message displayed.');
     $storage_controller->resetCache();
     $this->assertFalse(user_access('access user profiles', $account), 'User no longer has "access user profiles" permission.');
+    $current_permissions_hash = user_get_permissions_hash($account);
+    $this->assertNotEqual($previous_permissions_hash, $current_permissions_hash, 'Permissions hash has changed.');
   }
 
   /**
@@ -89,6 +95,7 @@ function testAdministratorRole() {
   function testUserRoleChangePermissions() {
     $rid = $this->rid;
     $account = $this->admin_user;
+    $previous_permissions_hash = user_get_permissions_hash($this->admin_user);
 
     // Verify current permissions.
     $this->assertFalse(user_access('administer nodes', $account), 'User does not have "administer nodes" permission.');
@@ -106,5 +113,9 @@ function testUserRoleChangePermissions() {
     $this->assertTrue(user_access('administer nodes', $account), 'User now has "administer nodes" permission.');
     $this->assertFalse(user_access('access user profiles', $account), 'User no longer has "access user profiles" permission.');
     $this->assertTrue(user_access('administer site configuration', $account), 'User still has "administer site configuration" permission.');
+
+    // Verify the permissions hash has changed.
+    $current_permissions_hash = user_get_permissions_hash($account);
+    $this->assertNotEqual($previous_permissions_hash, $current_permissions_hash, 'Permissions hash has changed.');
   }
 }
diff --git a/core/modules/user/user.module b/core/modules/user/user.module
index b55cee6..d81c683 100644
--- a/core/modules/user/user.module
+++ b/core/modules/user/user.module
@@ -1,6 +1,7 @@
 <?php
 
 use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\entity\Entity\EntityDisplay;
@@ -131,14 +132,58 @@ function user_js_alter(&$javascript) {
   // Provide the user ID in drupalSettings to allow JavaScript code to customize
   // the experience for the end user, rather than the server side, which would
   // break the render cache.
+  // Similarly, provide a permissions hash, so that permission-dependent data
+  // can be reliably cached on the client side.
+  $user = \Drupal::currentUser();
   $javascript['settings']['data'][] = array(
     'user' => array(
-      'uid' => \Drupal::currentUser()->id(),
+      'uid' => $user->id(),
+      'permissionsHash' => user_get_permissions_hash($user),
     ),
   );
 }
 
 /**
+ * Get a hash that uniquely identifies the user's permissions.
+ *
+ * Cached by role, invalidated whenever permissions change.
+ *
+ * @param \Drupal\Core\Session\AccountInterface $account
+ *   The account to get the permissions hash for.
+ *
+ * @return string
+ *   The permissions hash.
+ *
+ * @see user_role_change_permissions()
+ * @see user_role_grant_permissions()
+ * @see user_role_revoke_permissions()
+ */
+function user_get_permissions_hash(AccountInterface $account) {
+  // Early-return if cached in session.
+  if (function_exists('drupal_session_started') && drupal_session_started() && isset($_SESSION['user_permissions_hash'])) {
+    return $_SESSION['user_permissions_hash'];
+  }
+
+  $sorted_roles = $account->getRoles();
+  sort($sorted_roles);
+  $serialized_roles = implode(',', $sorted_roles);
+  if ($cache = cache()->get("user_permissions_hash:$serialized_roles")) {
+    $permissions_hash = $cache->data;
+  }
+  else {
+    $permissions_hash = hash('sha256', drupal_get_private_key() . drupal_get_hash_salt() . serialize(user_role_permissions($sorted_roles)));
+    cache()->set("user_permissions_hash:$serialized_roles", $permissions_hash, CacheBackendInterface::CACHE_PERMANENT, array('permissions' => $sorted_roles));
+  }
+
+  // Cache in session, if there is a session.
+  if (function_exists('drupal_session_started') && drupal_session_started()) {
+    $_SESSION['user_permissions_hash'] = $permissions_hash;
+  }
+
+  return $permissions_hash;
+}
+
+/**
  * Implements hook_entity_bundle_info().
  */
 function user_entity_bundle_info() {
@@ -1622,6 +1667,7 @@ function user_role_grant_permissions($rid, array $permissions = array()) {
     $role->grantPermission($permission);
   }
   $role->save();
+  cache_invalidate_tags(array('permissions' => $rid));
 }
 
 /**
@@ -1642,6 +1688,7 @@ function user_role_revoke_permissions($rid, array $permissions = array()) {
     $role->revokePermission($permission);
   }
   $role->save();
+  cache_invalidate_tags(array('permissions' => $rid));
 }
 
 /**
