 core/modules/contextual/contextual.module          |    3 -
 core/modules/contextual/js/contextual.js           |   73 ++++++++++++++------
 core/modules/edit/js/edit.js                       |   48 ++++++++++---
 .../user/lib/Drupal/user/RoleStorageController.php |   10 +++
 .../lib/Drupal/user/Tests/UserPermissionsTest.php  |   11 +++
 core/modules/user/user.module                      |   49 ++++++++++++-
 6 files changed, 161 insertions(+), 33 deletions(-)

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/edit/js/edit.js b/core/modules/edit/js/edit.js
index dab4522..d869509 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";
 
@@ -103,17 +103,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 +274,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 +495,4 @@ function deleteContainedModelsAndQueues($context) {
   });
 }
 
-})(jQuery, _, Backbone, Drupal, drupalSettings);
+})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);
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));
 }
 
 /**
