 core/modules/edit/js/edit.js                       |   63 +++++--
 core/modules/user/lib/Drupal/user/Entity/Role.php  |   14 +-
 .../user/lib/Drupal/user/PermissionsHash.php       |   83 +++++++++
 .../lib/Drupal/user/PermissionsHashInterface.php   |   28 ++++
 .../lib/Drupal/user/Tests/UserPermissionsTest.php  |   19 +++
 .../Drupal/user/Tests/PermissionsHashTest.php      |  176 ++++++++++++++++++++
 core/modules/user/user.module                      |    6 +-
 core/modules/user/user.services.yml                |    3 +
 8 files changed, 380 insertions(+), 12 deletions(-)

diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js
index 0712d65..d4f6f28 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-field-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-field-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({
@@ -466,4 +509,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/Entity/Role.php b/core/modules/user/lib/Drupal/user/Entity/Role.php
index b4e9a12..7ca885d 100644
--- a/core/modules/user/lib/Drupal/user/Entity/Role.php
+++ b/core/modules/user/lib/Drupal/user/Entity/Role.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\user\Entity;
 
+use Drupal\Core\Cache\Cache;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
 use Drupal\Core\Entity\EntityStorageControllerInterface;
 use Drupal\user\RoleInterface;
@@ -126,10 +127,21 @@ public function preSave(EntityStorageControllerInterface $storage_controller) {
   /**
    * {@inheritdoc}
    */
+  public function postSave(EntityStorageControllerInterface $storage_controller, $update = TRUE) {
+    parent::postSave($storage_controller, $update);
+
+    Cache::invalidateTags(array('role' => $this->id()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public static function postDelete(EntityStorageControllerInterface $storage_controller, array $entities) {
     parent::postDelete($storage_controller, $entities);
 
-    $storage_controller->deleteRoleReferences(array_keys($entities));
+    $ids = array_keys($entities);
+    $storage_controller->deleteRoleReferences($ids);
+    Cache::invalidateTags(array('role' => $ids));
   }
 
 }
diff --git a/core/modules/user/lib/Drupal/user/PermissionsHash.php b/core/modules/user/lib/Drupal/user/PermissionsHash.php
new file mode 100644
index 0000000..114abdd
--- /dev/null
+++ b/core/modules/user/lib/Drupal/user/PermissionsHash.php
@@ -0,0 +1,83 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\user\PermissionsHash.
+ */
+
+namespace Drupal\user;
+
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\PrivateKey;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\user\PermissionsHashInterface;
+
+/**
+ * Generates and caches the permissions hash for a user.
+ */
+class PermissionsHash implements PermissionsHashInterface {
+
+  /**
+   * The private key service.
+   *
+   * @var \Drupal\Core\PrivateKey
+   */
+  protected $privateKey;
+
+  /**
+   * The cache backend interface to use for the permission hash cache.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $cache;
+
+  /**
+   * Constructs a PermissionsHash object.
+   *
+   * @param \Drupal\Core\PrivateKey $private_key
+   *   The private key service.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
+   *   The cache backend interface to use for the permission hash cache.
+   */
+  public function __construct(PrivateKey $private_key, CacheBackendInterface $cache) {
+    $this->privateKey = $private_key;
+    $this->cache = $cache;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * Cached by role, invalidated whenever permissions change.
+   */
+  public function generate(AccountInterface $account) {
+    $sorted_roles = $account->getRoles();
+    sort($sorted_roles);
+    $role_list = implode(',', $sorted_roles);
+    if ($cache = $this->cache->get("user_permissions_hash:$role_list")) {
+      $permissions_hash = $cache->data;
+    }
+    else {
+      $permissions_hash = $this->doGenerate($sorted_roles);
+      $this->cache->set("user_permissions_hash:$role_list", $permissions_hash, CacheBackendInterface::CACHE_PERMANENT, array('role' => $sorted_roles));
+    }
+
+    return $permissions_hash;
+  }
+
+  /**
+   * Generates a hash that uniquely identifies the user's permissions.
+   *
+   * @param \Drupal\user\Entity\Role[] $roles
+   *   The user's roles.
+   *
+   * @return string
+   *   The permissions hash.
+   */
+  protected function doGenerate(array $roles) {
+    // @todo Once Drupal gets rid of user_role_permissions(), we should be able
+    // to inject the user role controller and call a method on that instead.
+    $permissions = user_role_permissions($roles);
+    return hash('sha256', $this->privateKey->get() . drupal_get_hash_salt() . serialize($permissions));
+  }
+
+}
diff --git a/core/modules/user/lib/Drupal/user/PermissionsHashInterface.php b/core/modules/user/lib/Drupal/user/PermissionsHashInterface.php
new file mode 100644
index 0000000..3356645
--- /dev/null
+++ b/core/modules/user/lib/Drupal/user/PermissionsHashInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @file
+ * Contains Drupal\user\PermissionsHashInterface.
+ */
+
+namespace Drupal\user;
+
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Defines the user permissions hash interface.
+ */
+interface PermissionsHashInterface {
+
+  /**
+   * Generates a hash that uniquely identifies a user's permissions.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The user account for which to get the permissions hash.
+   *
+   * @return string
+   *   A permissions hash.
+   */
+  public function generate(AccountInterface $account);
+
+}
diff --git a/core/modules/user/lib/Drupal/user/Tests/UserPermissionsTest.php b/core/modules/user/lib/Drupal/user/Tests/UserPermissionsTest.php
index 72b74bd..06d4994 100644
--- a/core/modules/user/lib/Drupal/user/Tests/UserPermissionsTest.php
+++ b/core/modules/user/lib/Drupal/user/Tests/UserPermissionsTest.php
@@ -37,9 +37,13 @@ function setUp() {
    * Change user permissions and check user_access().
    */
   function testUserPermissionChanges() {
+    $permissions_hash_generator = $this->container->get('user.permissions_hash');
+
     $this->drupalLogin($this->admin_user);
     $rid = $this->rid;
     $account = $this->admin_user;
+    $previous_permissions_hash = $permissions_hash_generator->generate($account);
+    $this->assertIdentical($previous_permissions_hash, $permissions_hash_generator->generate($this->loggedInUser));
 
     // Add a permission.
     $this->assertFalse(user_access('administer nodes', $account), 'User does not have "administer nodes" permission.');
@@ -50,6 +54,10 @@ 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 = $permissions_hash_generator->generate($account);
+    $this->assertIdentical($current_permissions_hash, $permissions_hash_generator->generate($this->loggedInUser));
+    $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 +67,9 @@ 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 = $permissions_hash_generator->generate($account);
+    $this->assertIdentical($current_permissions_hash, $permissions_hash_generator->generate($this->loggedInUser));
+    $this->assertNotEqual($previous_permissions_hash, $current_permissions_hash, 'Permissions hash has changed.');
   }
 
   /**
@@ -87,8 +98,11 @@ function testAdministratorRole() {
    * Verify proper permission changes by user_role_change_permissions().
    */
   function testUserRoleChangePermissions() {
+    $permissions_hash_generator = $this->container->get('user.permissions_hash');
+
     $rid = $this->rid;
     $account = $this->admin_user;
+    $previous_permissions_hash = $permissions_hash_generator->generate($account);
 
     // Verify current permissions.
     $this->assertFalse(user_access('administer nodes', $account), 'User does not have "administer nodes" permission.');
@@ -106,5 +120,10 @@ 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 = $permissions_hash_generator->generate($account);
+    $this->assertNotEqual($previous_permissions_hash, $current_permissions_hash, 'Permissions hash has changed.');
   }
+
 }
diff --git a/core/modules/user/tests/Drupal/user/Tests/PermissionsHashTest.php b/core/modules/user/tests/Drupal/user/Tests/PermissionsHashTest.php
new file mode 100644
index 0000000..5bde4bc
--- /dev/null
+++ b/core/modules/user/tests/Drupal/user/Tests/PermissionsHashTest.php
@@ -0,0 +1,176 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\user\Tests\PermissionsHashTest.
+ */
+
+namespace Drupal\user\Tests {
+
+use Drupal\Tests\UnitTestCase;
+use Drupal\Component\Utility\Crypt;
+use Drupal\user\PermissionsHash;
+
+
+/**
+ * Tests the user permissions hash generator service.
+ *
+ * @group Drupal
+ * @group User
+ *
+ * @see \Drupal\user\PermissionsHash
+ */
+class PermissionsHashTest extends UnitTestCase {
+
+  /**
+   * A mocked account.
+   *
+   * @var \Drupal\user\UserInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $account_1;
+
+  /**
+   * An "updated" mocked account.
+   *
+   * @var \Drupal\user\UserInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $account_1_updated;
+
+  /**
+   * A different account.
+   *
+   * @var \Drupal\user\UserInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $account_2;
+
+  /**
+   * The mocked private key service.
+   *
+   * @var \Drupal\Core\PrivateKey|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $private_key;
+
+  /**
+   * The mocked cache backend.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $cache;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getInfo() {
+    return array(
+      'name' => 'Permission hash generator service',
+      'description' => 'Tests the user permission hash generator service',
+      'group' => 'User',
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Account 1: 'administrator' and 'authenticated' roles.
+    $roles_1 = array('administrator', 'authenticated');
+    $this->account_1 = $this->getMockBuilder('Drupal\user\Entity\User')
+      ->disableOriginalConstructor()
+      ->setMethods(array('getRoles'))
+      ->getMock();
+    $this->account_1->expects($this->any())
+      ->method('getRoles')
+      ->will($this->returnValue($roles_1));
+
+    // Account 2: 'authenticated' and 'administrator' roles (different order).
+    $roles_2 = array('authenticated', 'administrator');
+    $this->account_2 = $this->getMockBuilder('Drupal\user\Entity\User')
+      ->disableOriginalConstructor()
+      ->setMethods(array('getRoles'))
+      ->getMock();
+    $this->account_2->expects($this->any())
+      ->method('getRoles')
+      ->will($this->returnValue($roles_2));
+
+    // Updated account 1: now also 'editor' role.
+    $roles_1_updated = array('editor', 'administrator', 'authenticated');
+    $this->account_1_updated = $this->getMockBuilder('Drupal\user\Entity\User')
+      ->disableOriginalConstructor()
+      ->setMethods(array('getRoles'))
+      ->getMock();
+    $this->account_1_updated->expects($this->any())
+      ->method('getRoles')
+      ->will($this->returnValue($roles_1_updated));
+
+    // Mocked private key + cache services.
+    $random = Crypt::randomStringHashed(55);
+    $this->private_key = $this->getMockBuilder('Drupal\Core\PrivateKey')
+      ->disableOriginalConstructor()
+      ->setMethods(array('get'))
+      ->getMock();
+    $this->private_key->expects($this->any())
+      ->method('get')
+      ->will($this->returnValue($random));
+    $this->cache = $this->getMockBuilder('Drupal\Core\Cache\CacheBackendInterface')
+      ->disableOriginalConstructor()
+      ->getMock();
+  }
+
+  /**
+   * Tests the generate() method.
+   */
+  public function testGenerate() {
+    // Set expectations for the mocked cache backend.
+    $expected_cid = 'user_permissions_hash:administrator,authenticated';
+    $this->cache->expects($this->at(0))->method('get')->with($expected_cid)->will($this->returnValue(FALSE));
+    $this->cache->expects($this->at(1))->method('set')->with($expected_cid);
+    $this->cache->expects($this->at(2))->method('get')->with($expected_cid)->will($this->returnValue(FALSE));
+    $this->cache->expects($this->at(3))->method('set')->with($expected_cid);
+    $expected_cid .= ',editor';
+    $this->cache->expects($this->at(4))->method('get')->with($expected_cid)->will($this->returnValue(FALSE));
+    $this->cache->expects($this->at(5))->method('set')->with($expected_cid);
+
+    // Ensure that two user accounts with the same roles generate the same hash.
+    $permissions_hash = new PermissionsHash($this->private_key, $this->cache);
+    $hash_1 = $permissions_hash->generate($this->account_1);
+    $hash_2 = $permissions_hash->generate($this->account_2);
+    $this->assertSame($hash_1, $hash_2, 'Different users with the same roles generate the same permissions hash.');
+
+    // Compare with hash for user account 1 with an additional role.
+    $updated_hash_1 = $permissions_hash->generate($this->account_1_updated);
+    $this->assertNotSame($hash_1, $updated_hash_1, 'Same user with updated roles generates different permissions hash.');
+  }
+
+}
+
+}
+
+namespace {
+
+  // @todo remove once user_role_permissions() can be injected.
+  if (!function_exists('user_role_permissions')) {
+    function user_role_permissions(array $roles) {
+      $role_permissions = array();
+      foreach ($roles as $rid) {
+        $role_permissions[$rid] = array();
+      }
+      return $role_permissions;
+    }
+  }
+
+  // @todo remove once drupal_get_hash_salt() can be injected.
+  if (!function_exists('drupal_get_hash_salt')) {
+    function drupal_get_hash_salt() {
+      static $salt;
+
+      if (!isset($salt)) {
+        $salt = Drupal\Component\Utility\Crypt::randomStringHashed(55);
+      }
+
+      return $salt;
+    }
+  }
+
+}
diff --git a/core/modules/user/user.module b/core/modules/user/user.module
index 5751f02..303e519 100644
--- a/core/modules/user/user.module
+++ b/core/modules/user/user.module
@@ -131,9 +131,13 @@ 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' => \Drupal::service('user.permissions_hash')->generate($user),
     ),
   );
 }
diff --git a/core/modules/user/user.services.yml b/core/modules/user/user.services.yml
index 6fb7d47..c9f144b 100644
--- a/core/modules/user/user.services.yml
+++ b/core/modules/user/user.services.yml
@@ -25,3 +25,6 @@ services:
     class: Drupal\user\EventSubscriber\MaintenanceModeSubscriber
     tags:
       - { name: event_subscriber }
+  user.permissions_hash:
+    class: Drupal\user\PermissionsHash
+    arguments: ['@private_key', '@cache.cache']
