 .../Drupal/block/Tests/Views/DisplayBlockTest.php  |   25 +++-
 core/modules/contextual/contextual.js              |   89 +++++++-----
 core/modules/contextual/contextual.module          |  152 ++++++++++++++++++--
 core/modules/contextual/contextual.routing.yml     |    6 +
 core/modules/contextual/contextual.toolbar.js      |   45 +++---
 .../lib/Drupal/contextual/ContextualController.php |   50 +++++++
 .../Plugin/views/field/ContextualLinks.php         |   29 ++--
 .../Tests/ContextualDynamicContextTest.php         |  139 ++++++++++++++++--
 .../Drupal/contextual/Tests/ContextualUnitTest.php |  122 ++++++++++++++++
 core/modules/edit/js/edit.js                       |    7 +-
 .../menu/lib/Drupal/menu/Tests/MenuTest.php        |   25 +++-
 core/modules/views/js/views-contextual.js          |    9 +-
 core/modules/views/views.module                    |   35 +++--
 .../lib/Drupal/views_ui/Tests/DisplayTest.php      |   21 ++-
 core/modules/views_ui/views_ui.module              |    4 +-
 15 files changed, 638 insertions(+), 120 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 7178e6c..d88894d 100644
--- a/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php
+++ b/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php
@@ -152,10 +152,29 @@ public function testViewsBlockForm() {
    * Tests the contextual links on a Views block.
    */
   public function testBlockContextualLinks() {
-    $this->drupalLogin($this->drupalCreateUser(array('administer views', 'access contextual links')));
-    $this->drupalPlaceBlock('views_block:test_view_block-block_1', array(), array('title' => 'test_view_block-block_1:1'));
+    $this->drupalLogin($this->drupalCreateUser(array('administer views', 'access contextual links', 'administer blocks')));
+    $block = $this->drupalPlaceBlock('views_block:test_view_block-block_1');
     $this->drupalGet('test-page');
-    $this->assertLinkByHref("admin/structure/views/view/test_view_block/edit");
+
+    $id = 'block:admin/structure/block/manage:' . $block->id() . ':|views_ui:admin/structure/views/view:test_view_block:location=block&name=test_view_block&display_id=block_1';
+    // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder()
+    $this->assertRaw('<div data-contextual-id="'. $id . '"></div>', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id)));
+
+    // Get server-rendered contextual links.
+    // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks()
+    $post = urlencode('ids[0]') . '=' . urlencode($id);
+    $response = $this->curlExec(array(
+      CURLOPT_URL => url('contextual/render', array('absolute' => TRUE, 'query' => array('destination' => 'test-page'))),
+      CURLOPT_POST => TRUE,
+      CURLOPT_POSTFIELDS => $post,
+      CURLOPT_HTTPHEADER => array(
+        'Accept: application/json',
+        'Content-Type: application/x-www-form-urlencoded',
+      ),
+    ));
+    $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() . '/configure?destination=test-page">Configure block</a></li><li class="views-ui-edit even last"><a href="' . base_path() . 'admin/structure/views/view/test_view_block/edit/block_1?destination=test-page">Edit view</a></li></ul>');
   }
 
 }
diff --git a/core/modules/contextual/contextual.js b/core/modules/contextual/contextual.js
index 2c696d5..2ccb2f7 100644
--- a/core/modules/contextual/contextual.js
+++ b/core/modules/contextual/contextual.js
@@ -17,31 +17,26 @@ var options = $.extend({
 /**
  * Initializes a contextual link: updates its DOM, sets up model and views
  *
- * @param DOM links
- *   A contextual links DOM element as rendered by the server.
+ * @param jQuery $contextual
+ *   A contextual links placeholder DOM element, containing the actual
+ *   contextual links as rendered by the server.
  */
-function initContextual (index, links) {
-  var $links = $(links);
-  var $region = $links.closest('.contextual-region');
+function initContextual ($contextual) {
+  var $region = $contextual.closest('.contextual-region');
   var contextual = Drupal.contextual;
 
-  // Create a contextual links wrapper to provide positioning and behavior
-  // attachment context.
-  var $wrapper = $(Drupal.theme('contextualWrapper'))
-    .insertBefore($links)
-    // In the wrapper, first add the trigger element.
-    .append(Drupal.theme('contextualTrigger'))
-    // In the wrapper, then add the contextual links.
-    .append($links);
+  $contextual
+    // 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'))
 
-  // Create a model, add it to the collection.
+  // Create a model and the appropriate views.
   var model = new contextual.Model({
     title: $region.find('h2:first').text().trim()
   });
-  contextual.collection.add(model);
-
-  // Create the appropriate views for this model.
-  var viewOptions = $.extend({ el: $wrapper, model: model }, options);
+  var viewOptions = $.extend({ el: $contextual, model: model }, options);
   contextual.views.push({
     visual: new contextual.VisualView(viewOptions),
     aural: new contextual.AuralView(viewOptions),
@@ -51,15 +46,20 @@ function initContextual (index, links) {
     $.extend({ el: $region, model: model }, options))
   );
 
+  // Add the model to the collection. This must happen after the views have been
+  // associated with it, otherwise collection change event handlers can't
+  // trigger the model change event handler in its views.
+  contextual.collection.add(model);
+
   // Let other JavaScript react to the adding of a new contextual link.
   $(document).trigger('drupalContextualLinkAdded', {
-    $el: $links,
+    $el: $contextual,
     $region: $region,
     model: model
   });
 
   // Fix visual collisions between contextual link triggers.
-  adjustIfNestedAndOverlapping($wrapper);
+  adjustIfNestedAndOverlapping($contextual);
 }
 
 /**
@@ -68,7 +68,8 @@ function initContextual (index, links) {
  * This only deals with two levels of nesting; deeper levels are not touched.
  *
  * @param jQuery $contextual
- *   A contextual link.
+ *   A contextual links placeholder DOM element, containing the actual
+ *   contextual links as rendered by the server.
  */
 function adjustIfNestedAndOverlapping ($contextual) {
   var $contextuals = $contextual
@@ -110,7 +111,40 @@ function adjustIfNestedAndOverlapping ($contextual) {
  */
 Drupal.behaviors.contextual = {
   attach: function (context) {
-    $(context).find('.contextual-links').once('contextual').each(initContextual);
+    var $context = $(context);
+
+    // Find all contextual links placeholders, if any.
+    var $placeholders = $context.find('[data-contextual-id]').once('contextual-render');
+    if ($placeholders.length === 0) {
+      return;
+    }
+
+    // Collect the IDs for all contextual links placeholders.
+    var ids = [];
+    $placeholders.each(function () {
+      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]);
+
+            // Initialize the contextual link.
+            initContextual($placeholder);
+          }
+        }
+      }
+     });
   }
 };
 
@@ -370,17 +404,6 @@ Drupal.contextual = {
 // A Backbone.Collection of Drupal.contextual.Model instances.
 Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.Model });
 
-
-/**
- * Wraps contextual links.
- *
- * @return String
- *   A string representing a DOM fragment.
- */
-Drupal.theme.contextualWrapper = function () {
-  return '<div class="contextual" />';
-};
-
 /**
  * A trigger is an interactive element often bound to a click handler.
  *
diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module
index 51747c6..2505e77 100644
--- a/core/modules/contextual/contextual.module
+++ b/core/modules/contextual/contextual.module
@@ -6,6 +6,19 @@
  */
 
 /**
+ * Implements hook_custom_theme().
+ *
+ * @todo Add an event subscriber to the Ajax system to automatically set the
+ *   base page theme for all Ajax requests, and then remove this one off.
+ *   See http://drupal.org/node/1954892.
+ */
+function contextual_custom_theme() {
+  if (substr(current_path(), 0, 11) === 'contextual/') {
+    return ajax_base_page_theme();
+  }
+}
+
+/**
  * Implements hook_toolbar().
  */
 function contextual_toolbar() {
@@ -24,8 +37,6 @@ function contextual_toolbar() {
         'role' => 'button',
         'aria-pressed' => 'false',
       ),
-      // @todo remove this once http://drupal.org/node/1908906 lands.
-      '#options' => array('attributes' => array()),
     ),
     '#wrapper_attributes' => array(
       'class' => array('element-hidden', 'contextual-toolbar-tab'),
@@ -41,6 +52,22 @@ function contextual_toolbar() {
 }
 
 /**
+ * Implements hook_page_build().
+ *
+ * Adds the drupal.contextual-links library to the page for any user who has the
+ * 'access contextual links' permission.
+ *
+ * @see contextual_preprocess()
+ */
+function contextual_page_build(&$page) {
+  if (!user_access('access contextual links')) {
+    return;
+  }
+
+  $page['#attached']['library'][] = array('contextual', 'drupal.contextual-links');
+}
+
+/**
  * Implements hook_help().
  */
 function contextual_help($path, $arg) {
@@ -115,6 +142,7 @@ function contextual_library_info() {
       array('system', 'jquery.once'),
       array('system', 'drupal.tabbingmanager'),
       array('system', 'drupal.announce'),
+      array('contextual', 'drupal.contextual-links')
     ),
   );
 
@@ -125,6 +153,10 @@ function contextual_library_info() {
  * Implements hook_element_info().
  */
 function contextual_element_info() {
+  $types['contextual_links_placeholder'] = array(
+    '#pre_render' => array('contextual_pre_render_placeholder'),
+    '#id' => NULL,
+  );
   $types['contextual_links'] = array(
     '#pre_render' => array('contextual_pre_render_links'),
     '#theme' => 'links__contextual',
@@ -142,14 +174,11 @@ function contextual_element_info() {
 /**
  * Implements hook_preprocess().
  *
- * @see contextual_pre_render_links()
+ * @see contextual_pre_render_placeholder()
+ * @see contextual_page_build()
+ * @see \Drupal\contextual\ContextualController::render()
  */
 function contextual_preprocess(&$variables, $hook) {
-  // Nothing to do here if the user is not permitted to access contextual links.
-  if (!user_access('access contextual links')) {
-    return;
-  }
-
   $hooks = theme_get_registry(FALSE);
 
   // Determine the primary theme function argument.
@@ -165,18 +194,43 @@ function contextual_preprocess(&$variables, $hook) {
   }
 
   if (isset($element) && is_array($element) && !empty($element['#contextual_links'])) {
-    // Initialize the template variable as a renderable array.
-    $variables['title_suffix']['contextual_links'] = array(
-      '#type' => 'contextual_links',
-      '#contextual_links' => $element['#contextual_links'],
-      '#element' => $element,
-    );
     // Mark this element as potentially having contextual links attached to it.
     $variables['attributes']['class'][] = 'contextual-region';
+
+    // Renders a contextual links placeholder unconditionally, thus not breaking
+    // the render cache. Although the empty placeholder is rendered for all
+    // users, contextual_page_build() only adds the drupal.contextual-links
+    // library for users with the 'access contextual links' permission, thus
+    // preventing unnecessary HTTP requests for users without that permission.
+    $variables['title_suffix']['contextual_links'] = array(
+      '#type' => 'contextual_links_placeholder',
+      '#id' => _contextual_links_to_id($element['#contextual_links']),
+    );
   }
 }
 
 /**
+ * Pre-render callback: Renders a contextual links placeholder into #markup.
+ *
+ * Renders an empty (hence invisible) placeholder div with a data-attribute that
+ * contains an identifier ("contextual id"), which allows the JavaScript of the
+ * drupal.contextual-links library to dynamically render contextual links.
+ *
+ * @param $element
+ *   A structured array with #id containing a "contextual id".
+ *
+ * @return
+ *   The passed-in element with a contextual link placeholder in '#markup'.
+ *
+ * @see _contextual_links_to_id()
+ * @see contextual_element_info()
+ */
+function contextual_pre_render_placeholder($element) {
+  $element['#markup'] = '<div data-contextual-id="' . $element['#id'] . '"></div>';
+  return $element;
+}
+
+/**
  * Pre-render callback: Builds a renderable array for contextual links.
  *
  * @param $element
@@ -230,3 +284,73 @@ function contextual_pre_render_links($element) {
   return $element;
 }
 
+/**
+ * Implements hook_contextual_links_view_alter().
+ *
+ * @see \Drupal\contextual\Plugin\views\field\ContextualLinks::render()
+ */
+function contextual_contextual_links_view_alter(&$element, $items) {
+  if (isset($element['#contextual_links']['contextual'])) {
+    $encoded_links = $element['#contextual_links']['contextual'][2]['contextual-views-field-links'];
+    $element['#links'] = drupal_json_decode(rawurldecode($encoded_links));
+  }
+}
+
+/**
+ * Serializes #contextual_links property value array to a string.
+ *
+ * Examples:
+ *  - node:node:1:
+ *  - views_ui:admin/structure/views/view:frontpage:location=page&view_name=frontpage&view_display_id=page_1
+ *  - menu:admin/structure/menu/manage:tools:|block:admin/structure/block/manage:bartik.tools:
+ *
+ * So, expressed in a pattern:
+ *  <module name>:<parent path>:<path args>:<options>
+ *
+ * The (dynamic) path args are joined with slashes. The options are encoded as a
+ * query string.
+ *
+ * @param array $contextual_links
+ *   The $element['#contextual_links'] value for some render element.
+ *
+ * @return string
+ *   A serialized representation of a #contextual_links property value array for
+ *   use in a data- attribute.
+ */
+function _contextual_links_to_id($contextual_links) {
+  $id = '';
+  foreach ($contextual_links as $module => $args) {
+    $parent_path = $args[0];
+    $path_args = implode('/', $args[1]);
+    $metadata = drupal_http_build_query((isset($args[2])) ? $args[2] : array());
+
+    if (drupal_strlen($id) > 0) {
+      $id .= '|';
+    }
+    $id .= $module . ':' . $parent_path . ':' . $path_args . ':' . $metadata;
+  }
+  return $id;
+}
+
+/**
+ * Unserializes the result of _contextual_links_to_id().
+ *
+ * @see _contextual_links_to_id
+ *
+ * @param string $id
+ *   A serialized representation of a #contextual_links property value array.
+ *
+ * @return array
+ *   The value for a #contextual_links property.
+ */
+function _contextual_id_to_links($id) {
+  $contextual_links = array();
+  $contexts = explode('|', $id);
+  foreach ($contexts as $context) {
+    list($module, $parent_path, $path_args, $metadata_raw) = explode(':', $context);
+    $path_args = explode('/', $path_args);
+    $metadata = drupal_get_query_array($metadata_raw);
+    $contextual_links[$module] = array($parent_path, $path_args, $metadata);
+  }
+  return $contextual_links;
+}
diff --git a/core/modules/contextual/contextual.routing.yml b/core/modules/contextual/contextual.routing.yml
new file mode 100644
index 0000000..5dd4457
--- /dev/null
+++ b/core/modules/contextual/contextual.routing.yml
@@ -0,0 +1,6 @@
+contextual_render:
+  pattern: '/contextual/render'
+  defaults:
+    _controller: '\Drupal\contextual\ContextualController::render'
+  requirements:
+    _permission: 'access contextual links'
diff --git a/core/modules/contextual/contextual.toolbar.js b/core/modules/contextual/contextual.toolbar.js
index 635e745..0059868 100644
--- a/core/modules/contextual/contextual.toolbar.js
+++ b/core/modules/contextual/contextual.toolbar.js
@@ -23,10 +23,7 @@ var options = {
  */
 function initContextualToolbar (context) {
   var contextualToolbar = Drupal.contextualToolbar;
-  var model = contextualToolbar.model = new contextualToolbar.Model({
-    isViewing: true,
-    isVisible: false
-  });
+  var model = contextualToolbar.model = new contextualToolbar.Model();
 
   var viewOptions = $.extend({
     el: $('.js .toolbar .bar .contextual-toolbar-tab'),
@@ -38,27 +35,33 @@ function initContextualToolbar (context) {
   // Update the model based on overlay events.
   $(document).on({
     'drupalOverlayOpen.contextualToolbar': function () {
-      model.set('isVisible', false);
+      model.set('overlayIsOpen', true);
     },
     'drupalOverlayClose.contextualToolbar': function () {
-      model.set('isVisible', true);
+      model.set('overlayIsOpen', false);
     }
   });
 
   // Show the edit tab while there's >=1 contextual link.
-  var collection = Drupal.contextual.collection;
-  var updateVisibility = function () {
-    model.set('isVisible', collection.length > 0);
-  };
-  collection.on('reset remove add', updateVisibility);
-  updateVisibility();
-
-  // Whenever edit mode is toggled, update all contextual links.
+  var contextualCollection = Drupal.contextual.collection;
+  function trackContextualCount () {
+    model.set('contextualCount', contextualCollection.length);
+  }
+  contextualCollection.on('reset remove add', trackContextualCount);
+  trackContextualCount();
+
+  // Whenever edit mode is toggled, lock all contextual links.
   model.on('change:isViewing', function() {
-    collection.each(function (contextualModel) {
+    contextualCollection.each(function (contextualModel) {
       contextualModel.set('isLocked', !model.get('isViewing'));
     });
   });
+  // When a new contextual link is added and edit mode is enabled, lock it.
+  contextualCollection.on('add', function (contextualModel) {
+    if (!model.get('isViewing')) {
+      contextualModel.set('isLocked', true);
+    }
+  });
 
   // Checks whether localStorage indicates we should start in edit mode
   // rather than view mode.
@@ -93,11 +96,21 @@ Drupal.contextualToolbar = {
     defaults: {
       // Indicates whether the toggle is currently in "view" or "edit" mode.
       isViewing: true,
-      // Indicates whether the toggle should be visible or hidden.
+      // Indicates whether the toggle should be visible or hidden. Automatically
+      // calculated, depends on overlayIsOpen and contextualCount.
       isVisible: false,
+      // Indicates whether the overlay is open or not.
+      overlayIsOpen: false,
+      // Tracks how many contextual links exist on the page.
+      contextualCount: 0,
       // A TabbingContext object as returned by Drupal.TabbingManager: the set
       // of tabbable elements when edit mode is enabled.
       tabbingContext: null
+    },
+    initialize: function () {
+      this.on('change:overlayIsOpen change:contextualCount', function (model) {
+        model.set('isVisible', !model.get('overlayIsOpen') && model.get('contextualCount') > 0);
+      });
     }
   }),
 
diff --git a/core/modules/contextual/lib/Drupal/contextual/ContextualController.php b/core/modules/contextual/lib/Drupal/contextual/ContextualController.php
new file mode 100644
index 0000000..aca277d
--- /dev/null
+++ b/core/modules/contextual/lib/Drupal/contextual/ContextualController.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\contextual\ContextualController.
+ */
+
+namespace Drupal\contextual;
+
+use Symfony\Component\DependencyInjection\ContainerAware;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Returns responses for Contextual module routes.
+ */
+class ContextualController extends ContainerAware {
+
+  /**
+   * Returns the requested rendered contextual links.
+   *
+   * Given a list of contextual links IDs, render them. Hence this must be
+   * robust to handle arbitrary input.
+   *
+   * @see contextual_preprocess()
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   *   The JSON response.
+   */
+  public function render(Request $request) {
+    $ids = $request->request->get('ids');
+    if (!isset($ids)) {
+      throw new BadRequestHttpException(t('No contextual ids specified.'));
+    }
+
+    $rendered = array();
+    foreach ($ids as $id) {
+      $element = array(
+        '#type' => 'contextual_links',
+        '#contextual_links' => _contextual_id_to_links($id),
+      );
+      $rendered[$id] = drupal_render($element);
+    }
+
+    return new JsonResponse($rendered);
+  }
+
+}
diff --git a/core/modules/contextual/lib/Drupal/contextual/Plugin/views/field/ContextualLinks.php b/core/modules/contextual/lib/Drupal/contextual/Plugin/views/field/ContextualLinks.php
index 86aa796..13f88a4 100644
--- a/core/modules/contextual/lib/Drupal/contextual/Plugin/views/field/ContextualLinks.php
+++ b/core/modules/contextual/lib/Drupal/contextual/Plugin/views/field/ContextualLinks.php
@@ -64,6 +64,9 @@ function pre_render(&$values) {
 
   /**
    * Render the contextual fields.
+   *
+   * @see contextual_preprocess()
+   * @see contextual_contextual_links_view_alter()
    */
   function render($values) {
     $links = array();
@@ -92,19 +95,23 @@ function render($values) {
       }
     }
 
+    // Renders a contextual links placeholder.
     if (!empty($links)) {
-      $build = array(
-        '#prefix' => '<div class="contextual">',
-        '#suffix' => '</div>',
-        '#theme' => 'links__contextual',
-        '#links' => $links,
-        '#attributes' => array('class' => array('contextual-links')),
-        '#attached' => array(
-          'library' => array(array('contextual', 'contextual-links')),
-        ),
-        '#access' => user_access('access contextual links'),
+      $contextual_links = array(
+        'contextual' => array(
+          '',
+          array(),
+          array(
+            'contextual-views-field-links' => drupal_encode_path(drupal_json_encode($links)),
+          )
+        )
       );
-      return drupal_render($build);
+
+      $element = array(
+        '#type' => 'contextual_links_placeholder',
+        '#id' => _contextual_links_to_id($contextual_links),
+      );
+      return drupal_render($element);
     }
     else {
       return '';
diff --git a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
index f84e9f4..c4f38e9 100644
--- a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
+++ b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
@@ -2,7 +2,7 @@
 
 /**
  * @file
- * Definition of Drupal\contextual\Tests\ContextualDynamicContextTest.
+ * Contains \Drupal\contextual\Tests\ContextualDynamicContextTest.
  */
 
 namespace Drupal\contextual\Tests;
@@ -19,7 +19,7 @@ class ContextualDynamicContextTest extends WebTestBase {
    *
    * @var array
    */
-  public static $modules = array('contextual', 'node', 'views');
+  public static $modules = array('contextual', 'node', 'views', 'views_ui');
 
   public static function getInfo() {
     return array(
@@ -31,16 +31,24 @@ public static function getInfo() {
 
   function setUp() {
     parent::setUp();
+
     $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page'));
     $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article'));
-    $web_user = $this->drupalCreateUser(array('access content', 'access contextual links', 'edit any article content'));
-    $this->drupalLogin($web_user);
+
+    $this->editor_user = $this->drupalCreateUser(array('access content', 'access contextual links', 'edit any article content'));
+    $this->authenticated_user = $this->drupalCreateUser(array('access content', 'access contextual links'));
+    $this->anonymous_user = $this->drupalCreateUser(array('access content'));
   }
 
   /**
-   * Tests contextual links on node lists with different permissions.
+   * Tests contextual links with different permissions.
+   *
+   * Ensures that contextual link placeholders always exist, even if the user is
+   * not allowed to use contextual links.
    */
-  function testNodeLinks() {
+  function testDifferentPermissions() {
+    $this->drupalLogin($this->editor_user);
+
     // Create three nodes in the following order:
     // - An article, which should be user-editable.
     // - A page, which should not be user-editable.
@@ -49,11 +57,120 @@ function testNodeLinks() {
     $node2 = $this->drupalCreateNode(array('type' => 'page', 'promote' => 1));
     $node3 = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
 
-    // Now, on the front page, all article nodes should have contextual edit
-    // links. The page node in between should not.
+    // Now, on the front page, all article nodes should have contextual links
+    // placeholders, as should the view that contains them.
+    $ids = array(
+      'node:node:' . $node1->nid . ':',
+      'node:node:' . $node2->nid . ':',
+      'node:node:' . $node3->nid . ':',
+      'views_ui:admin/structure/views/view:frontpage:location=page&name=frontpage&display_id=page_1',
+    );
+
+    // Editor user: can access contextual links and can edit articles.
+    $this->drupalGet('node');
+    for ($i = 0; $i < count($ids); $i++) {
+      $this->assertContextualLinkPlaceHolder($ids[$i]);
+    }
+    $this->renderContextualLinks(array(), 'node');
+    $this->assertResponse(400);
+    $this->assertRaw('No contextual ids specified.');
+    $response = $this->renderContextualLinks($ids, 'node');
+    $this->assertResponse(200);
+    $json = drupal_json_decode($response);
+    $this->assertIdentical($json[$ids[0]], '<ul class="contextual-links"><li class="node-edit odd first last"><a href="' . base_path() . 'node/1/edit?destination=node">Edit</a></li></ul>');
+    $this->assertIdentical($json[$ids[1]], '');
+    $this->assertIdentical($json[$ids[2]], '<ul class="contextual-links"><li class="node-edit odd first last"><a href="' . base_path() . 'node/3/edit?destination=node">Edit</a></li></ul>');
+    $this->assertIdentical($json[$ids[3]], '');
+
+    // Authenticated user: can access contextual links, cannot edit articles.
+    $this->drupalLogin($this->authenticated_user);
     $this->drupalGet('node');
-    $this->assertRaw('node/' . $node1->nid . '/edit', 'Edit link for oldest article node showing.');
-    $this->assertNoRaw('node/' . $node2->nid . '/edit', 'No edit link for page nodes.');
-    $this->assertRaw('node/' . $node3->nid . '/edit', 'Edit link for most recent article node showing.');
+    for ($i = 0; $i < count($ids); $i++) {
+      $this->assertContextualLinkPlaceHolder($ids[$i]);
+    }
+    $this->renderContextualLinks(array(), 'node');
+    $this->assertResponse(400);
+    $this->assertRaw('No contextual ids specified.');
+    $response = $this->renderContextualLinks($ids, 'node');
+    $this->assertResponse(200);
+    $json = drupal_json_decode($response);
+    $this->assertIdentical($json[$ids[0]], '');
+    $this->assertIdentical($json[$ids[1]], '');
+    $this->assertIdentical($json[$ids[2]], '');
+    $this->assertIdentical($json[$ids[3]], '');
+
+    // Anonymous user: cannot access contextual links.
+    $this->drupalLogin($this->anonymous_user);
+    $this->drupalGet('node');
+    for ($i = 0; $i < count($ids); $i++) {
+      $this->assertContextualLinkPlaceHolder($ids[$i]);
+    }
+    $this->renderContextualLinks(array(), 'node');
+    $this->assertResponse(403);
+    $this->renderContextualLinks($ids, 'node');
+    $this->assertResponse(403);
+  }
+
+  /**
+   * Asserts that a contextual link placeholder with the given id exists.
+   *
+   * @param string $id
+   *   A contextual link id.
+   *
+   * @return bool
+   */
+  protected function assertContextualLinkPlaceHolder($id) {
+    $this->assertRaw('<div data-contextual-id="'. $id . '"></div>', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id)));
+  }
+
+  /**
+   * Asserts that a contextual link placeholder with the given id does not exist.
+   *
+   * @param string $id
+   *   A contextual link id.
+   *
+   * @return bool
+   */
+  protected function assertNoContextualLinkPlaceHolder($id) {
+    $this->assertNoRaw('<div data-contextual-id="'. $id . '"></div>', format_string('Contextual link placeholder with id @id does not exist.', array('@id' => $id)));
+  }
+
+  /**
+   * Get server-rendered contextual links for the given contextual link ids.
+   *
+   * @param array $ids
+   *   An array of contextual link ids.
+   * @param string $current_path
+   *   The Drupal path for the page for which the contextual links are rendered.
+   *
+   * @return string
+   *   The response body.
+   */
+  protected function renderContextualLinks($ids, $current_path) {
+    // Build POST values.
+    $post = array();
+    for ($i = 0; $i < count($ids); $i++) {
+      $post['ids[' . $i . ']'] = $ids[$i];
+    }
+
+    // Serialize POST values.
+    foreach ($post as $key => $value) {
+      // Encode according to application/x-www-form-urlencoded
+      // Both names and values needs to be urlencoded, according to
+      // http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1
+      $post[$key] = urlencode($key) . '=' . urlencode($value);
+    }
+    $post = implode('&', $post);
+
+    // Perform HTTP request.
+    return $this->curlExec(array(
+      CURLOPT_URL => url('contextual/render', array('absolute' => TRUE, 'query' => array('destination' => $current_path))),
+      CURLOPT_POST => TRUE,
+      CURLOPT_POSTFIELDS => $post,
+      CURLOPT_HTTPHEADER => array(
+        'Accept: application/json',
+        'Content-Type: application/x-www-form-urlencoded',
+      ),
+    ));
   }
 }
diff --git a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualUnitTest.php b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualUnitTest.php
new file mode 100644
index 0000000..51e9a51
--- /dev/null
+++ b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualUnitTest.php
@@ -0,0 +1,122 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\contextual\Tests\ContextualUnitTest.
+ */
+
+namespace Drupal\contextual\Tests;
+
+use Drupal\simpletest\UnitTestBase;
+
+/**
+ * Tests _contextual_links_to_id() & _contextual_id_to_links().
+ */
+class ContextualUnitTest extends UnitTestBase {
+  public static function getInfo() {
+    return array(
+      'name' => 'Conversion to and from "contextual id"s (for placeholders)',
+      'description' => 'Tests all edge cases of converting from #contextual_links to ids and vice versa.',
+      'group' => 'Contextual',
+    );
+  }
+
+  /**
+   * Provides testcases for testContextualLinksToId() and
+   */
+  function _contextual_links_id_testcases() {
+    // Test branch conditions:
+    // - one module.
+    // - one dynamic path argument.
+    // - no metadata.
+    $tests[] = array(
+      'links' => array(
+        'node' => array(
+          'node',
+          array('14031991'),
+          array()
+        ),
+      ),
+      'id' => 'node:node:14031991:',
+    );
+
+    // Test branch conditions:
+    // - one module.
+    // - multiple dynamic path arguments.
+    // - no metadata.
+    $tests[] = array(
+      'links' => array(
+        'foo' => array(
+          'baz/in/ga',
+          array('bar', 'baz', 'qux'),
+          array()
+        ),
+      ),
+      'id' => 'foo:baz/in/ga:bar/baz/qux:',
+    );
+
+    // Test branch conditions:
+    // - one module.
+    // - one dynamic path argument.
+    // - metadata.
+    $tests[] = array(
+      'links' => array(
+        'views_ui' => array(
+          'admin/structure/views/view',
+          array('frontpage'),
+          array(
+            'location' => 'page',
+            'display' => 'page_1',
+          )
+        ),
+      ),
+      'id' => 'views_ui:admin/structure/views/view:frontpage:location=page&display=page_1',
+    );
+
+    // Test branch conditions:
+    // - multiple modules.
+    // - multiple dynamic path arguments.
+    $tests[] = array(
+      'links' => array(
+        'node' => array(
+          'node',
+          array('14031991'),
+          array()
+        ),
+        'foo' => array(
+          'baz/in/ga',
+          array('bar', 'baz', 'qux'),
+          array()
+        ),
+        'edge' => array(
+          'edge',
+          array('20011988'),
+          array()
+        ),
+      ),
+      'id' => 'node:node:14031991:|foo:baz/in/ga:bar/baz/qux:|edge:edge:20011988:',
+    );
+
+    return $tests;
+  }
+
+  /**
+   * Tests _contextual_links_to_id().
+   */
+  function testContextualLinksToId() {
+    $tests = $this->_contextual_links_id_testcases();
+    foreach ($tests as $test) {
+      $this->assertIdentical(_contextual_links_to_id($test['links']), $test['id']);
+    }
+  }
+
+  /**
+   * Tests _contextual_id_to_links().
+   */
+  function testContextualIdToLinks() {
+    $tests = $this->_contextual_links_id_testcases();
+    foreach ($tests as $test) {
+      $this->assertIdentical(_contextual_id_to_links($test['id']), $test['links']);
+    }
+  }
+}
diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js
index 6899125..36c884f 100644
--- a/core/modules/edit/js/edit.js
+++ b/core/modules/edit/js/edit.js
@@ -271,9 +271,9 @@ function fetchMissingMetadata (callback) {
  *   An object with the following properties:
  *     - String entity: an Edit entity identifier, e.g. "node/1" or
  *       "custom_block/5".
- *     - jQuery $el: element pointing to the contextual links for this entity.
- *     - jQuery $region: element pointing to the contextual region for this
+ *     - DOM el: element pointing to the contextual links placeholder for this
  *       entity.
+ *     - DOM region: element pointing to the contextual region for this entity.
  * @return Boolean
  *   Returns true when a contextual the given contextual link metadata can be
  *   removed from the queue (either because the contextual link has been set up
@@ -324,8 +324,9 @@ function initializeEntityContextualLink (contextualLink) {
     fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
 
     // Set up contextual link view.
+    var $links = $(contextualLink.el).find('.contextual-links');
     var contextualLinkView = new Drupal.edit.ContextualLinkView($.extend({
-      el: $('<li class="quick-edit"><a href=""></a></li>').prependTo(contextualLink.el),
+      el: $('<li class="quick-edit"><a href=""></a></li>').prependTo($links),
       model: entityModel,
       appModel: Drupal.edit.app.model
     }, options));
diff --git a/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php b/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php
index f3a4391..6c3ed1a 100644
--- a/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php
+++ b/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php
@@ -306,11 +306,30 @@ function testSystemMenuRename() {
    * Tests the contextual links on a menu block.
    */
   public function testBlockContextualLinks() {
-    $this->drupalLogin($this->drupalCreateUser(array('administer menu', 'access contextual links')));
+    $this->drupalLogin($this->drupalCreateUser(array('administer menu', 'access contextual links', 'administer blocks')));
     $this->addMenuLink();
-    $this->drupalPlaceBlock('system_menu_block:menu-tools', array('label' => 'Tools', 'module' => 'system'));
+    $block = $this->drupalPlaceBlock('system_menu_block:menu-tools', array('label' => 'Tools', 'module' => 'system'));
     $this->drupalGet('test-page');
-    $this->assertLinkByHref("admin/structure/menu/manage/tools/edit");
+
+    $id = 'block:admin/structure/block/manage:' . $block->id() . ':|menu:admin/structure/menu/manage:tools:';
+    // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder()
+    $this->assertRaw('<div data-contextual-id="'. $id . '"></div>', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id)));
+
+    // Get server-rendered contextual links.
+    // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks()
+    $post = urlencode('ids[0]') . '=' . urlencode($id);
+    $response = $this->curlExec(array(
+      CURLOPT_URL => url('contextual/render', array('absolute' => TRUE, 'query' => array('destination' => 'test-page'))),
+      CURLOPT_POST => TRUE,
+      CURLOPT_POSTFIELDS => $post,
+      CURLOPT_HTTPHEADER => array(
+        'Accept: application/json',
+        'Content-Type: application/x-www-form-urlencoded',
+      ),
+    ));
+    $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() . '/configure?destination=test-page">Configure block</a></li><li class="menu-edit even last"><a href="' . base_path() . 'admin/structure/menu/manage/tools/edit?destination=test-page">Edit menu</a></li></ul>');
   }
 
   /**
diff --git a/core/modules/views/js/views-contextual.js b/core/modules/views/js/views-contextual.js
index 3c3ae96..e90283d 100644
--- a/core/modules/views/js/views-contextual.js
+++ b/core/modules/views/js/views-contextual.js
@@ -8,10 +8,11 @@
 
 Drupal.behaviors.viewsContextualLinks = {
   attach: function (context) {
-    // If there are views-related contextual links attached to the main page
-    // content, find the smallest region that encloses both the links and the
-    // view, and display it as a contextual links region.
-    $('.views-contextual-links-page', context).closest(':has(.view)').addClass('contextual-region');
+    var id = $('body').attr('data-views-page-contextual-id');
+
+    $('[data-contextual-id="' + id + '"]')
+      .closest(':has(.view)')
+      .addClass('contextual-region');
   }
 };
 
diff --git a/core/modules/views/views.module b/core/modules/views/views.module
index 9075033..61826f8 100644
--- a/core/modules/views/views.module
+++ b/core/modules/views/views.module
@@ -468,6 +468,11 @@ function views_page_alter(&$page) {
  * Implements MODULE_preprocess_HOOK().
  */
 function views_preprocess_html(&$variables) {
+  // Early-return to prevent adding unnecessary JavaScript.
+  if (!user_access('access contextual links')) {
+    return;
+  }
+
   // If the page contains a view as its main content, contextual links may have
   // been attached to the page as a whole; for example, by views_page_alter().
   // This allows them to be associated with the page and rendered by default
@@ -480,10 +485,11 @@ function views_preprocess_html(&$variables) {
   // page.tpl.php, so we can only find it using JavaScript. We therefore remove
   // the "contextual-region" class from the <body> tag here and add
   // JavaScript that will insert it back in the correct place.
-  if (!empty($variables['page']['#views_contextual_links_info'])) {
+  if (!empty($variables['page']['#views_contextual_links'])) {
     $key = array_search('contextual-region', $variables['attributes']['class']);
     if ($key !== FALSE) {
       unset($variables['attributes']['class'][$key]);
+      $variables['attributes']['data-views-page-contextual-id'] = $variables['title_suffix']['contextual_links']['#id'];
       // Add the JavaScript, with a group and weight such that it will run
       // before modules/contextual/contextual.js.
       drupal_add_library('views', 'views.contextual-links');
@@ -492,18 +498,6 @@ function views_preprocess_html(&$variables) {
 }
 
 /**
- * Implements hook_contextual_links_view_alter().
- */
-function views_contextual_links_view_alter(&$element, $items) {
-  // If we are rendering views-related contextual links attached to the overall
-  // page array, add a class to the list of contextual links. This will be used
-  // by the JavaScript added in views_preprocess_html().
-  if (!empty($element['#element']['#views_contextual_links_info']) && !empty($element['#element']['#type']) && $element['#element']['#type'] == 'page') {
-    $element['#attributes']['class'][] = 'views-contextual-links-page';
-  }
-}
-
-/**
  * Adds contextual links associated with a view display to a renderable array.
  *
  * This function should be called when a view is being rendered in a particular
@@ -612,12 +606,15 @@ function views_add_contextual_links(&$render_element, $location, ViewExecutable
         // If the link was valid, attach information about it to the renderable
         // array.
         if ($valid) {
-          $render_element['#contextual_links'][$module] = array($link['parent path'], $args);
-          $render_element['#views_contextual_links_info'][$module] = array(
-            'location' => $location,
-            'view' => $view,
-            'view_name' => $view->storage->id(),
-            'view_display_id' => $display_id,
+          $render_element['#views_contextual_links'] = TRUE;
+          $render_element['#contextual_links'][$module] = array(
+            $link['parent path'],
+            $args,
+            array(
+              'location' => $location,
+              'name' => $view->storage->id(),
+              'display_id' => $display_id,
+            )
           );
         }
       }
diff --git a/core/modules/views_ui/lib/Drupal/views_ui/Tests/DisplayTest.php b/core/modules/views_ui/lib/Drupal/views_ui/Tests/DisplayTest.php
index 3c22792..b3ded3b 100644
--- a/core/modules/views_ui/lib/Drupal/views_ui/Tests/DisplayTest.php
+++ b/core/modules/views_ui/lib/Drupal/views_ui/Tests/DisplayTest.php
@@ -284,8 +284,27 @@ public function testPageContextualLinks() {
     $this->drupalLogin($this->drupalCreateUser(array('administer views', 'access contextual links')));
     $view = entity_load('view', 'test_display');
     $view->enable()->save();
+
     $this->drupalGet('test-display');
-    $this->assertLinkByHref("admin/structure/views/view/{$view->id()}/edit/page_1");
+    $id = 'views_ui:admin/structure/views/view:test_display:location=page&name=test_display&display_id=page_1';
+    // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder()
+    $this->assertRaw('<div data-contextual-id="'. $id . '"></div>', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id)));
+
+    // Get server-rendered contextual links.
+    // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks()
+    $post = urlencode('ids[0]') . '=' . urlencode($id);
+    $response = $this->curlExec(array(
+      CURLOPT_URL => url('contextual/render', array('absolute' => TRUE, 'query' => array('destination' => 'test-display'))),
+      CURLOPT_POST => TRUE,
+      CURLOPT_POSTFIELDS => $post,
+      CURLOPT_HTTPHEADER => array(
+        'Accept: application/json',
+        'Content-Type: application/x-www-form-urlencoded',
+      ),
+    ));
+    $this->assertResponse(200);
+    $json = drupal_json_decode($response);
+    $this->assertIdentical($json[$id], '<ul class="contextual-links"><li class="views-ui-edit odd first last"><a href="' . base_path() . 'admin/structure/views/view/test_display/edit/page_1?destination=test-display">Edit view</a></li></ul>');
   }
 
 }
diff --git a/core/modules/views_ui/views_ui.module b/core/modules/views_ui/views_ui.module
index f169fd0..0d461be 100644
--- a/core/modules/views_ui/views_ui.module
+++ b/core/modules/views_ui/views_ui.module
@@ -339,8 +339,8 @@ function views_ui_contextual_links_view_alter(&$element, $items) {
   // Append the display ID to the Views UI edit links, so that clicking on the
   // contextual link takes you directly to the correct display tab on the edit
   // screen.
-  elseif (!empty($element['#links']['views-ui-edit']) && !empty($element['#element']['#views_contextual_links_info']['views_ui']['view_display_id'])) {
-    $display_id = $element['#element']['#views_contextual_links_info']['views_ui']['view_display_id'];
+  elseif (!empty($element['#links']['views-ui-edit'])) {
+    $display_id = $element['#contextual_links']['views_ui'][2]['display_id'];
     $element['#links']['views-ui-edit']['href'] .= '/' . $display_id;
   }
 }
