 core/modules/contextual/contextual.js              |   30 +++--
 core/modules/contextual/contextual.module          |   73 +++++++++--
 .../lib/Drupal/contextual/ContextualController.php |   17 +--
 .../Tests/ContextualDynamicContextTest.php         |  137 ++++++++++++++++++--
 .../Drupal/contextual/Tests/ContextualUnitTest.php |  122 +++++++++++++++++
 .../lib/Drupal/views/Tests/UI/DisplayTest.php      |   21 ++-
 core/modules/views/views.module                    |   16 ++-
 core/modules/views/views_ui/views_ui.module        |    4 +-
 8 files changed, 360 insertions(+), 60 deletions(-)

diff --git a/core/modules/contextual/contextual.js b/core/modules/contextual/contextual.js
index 31e30e4..c06128c 100644
--- a/core/modules/contextual/contextual.js
+++ b/core/modules/contextual/contextual.js
@@ -3,7 +3,7 @@
  * Attaches behaviors for the Contextual module.
  */
 
-(function ($, Drupal, document, window) {
+(function ($, Drupal, drupalSettings) {
 
 "use strict";
 
@@ -49,22 +49,24 @@ Drupal.behaviors.contextual = {
       ids.push($(this).attr('data-contextual-id'));
     });
     $.ajax({
-      url: Drupal.url('contextual/render'),
+      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)) {
-          var $contextual = $context
-            // Find the location for the current rendered contextual link.
-            .find('[data-contextual-id="' + id + '"]')
-            // Move it into the DOM.
-            .html(results[id]);
-          // Create a Drupal.contextual object and notify listeners of a new
-          // contextual link.
-          $(document).trigger('drupalContextualLinkAdded', {
-            contextual: new Drupal.contextual($contextual, $contextual.closest('.contextual-region'))
-          });
+        for (var id in results) {
+          if (results.hasOwnProperty(id)) {
+            var $contextual = $context
+              // Find the location for the current rendered contextual link.
+              .find('[data-contextual-id="' + id + '"]')
+              // Move it into the DOM.
+              .html(results[id]);
+            // Create a Drupal.contextual object and notify listeners of a new
+            // contextual link.
+            $(document).trigger('drupalContextualLinkAdded', {
+              contextual: new Drupal.contextual($contextual, $contextual.closest('.contextual-region'))
+            });
+          }
         }
       }
     });
@@ -241,4 +243,4 @@ Drupal.theme.contextualTrigger = function () {
   return '<button class="trigger element-invisible element-focusable" type="button"></button>';
 };
 
-})(jQuery, Drupal, document, window);
+})(jQuery, Drupal, drupalSettings);
diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module
index ea5e439..d9a0461 100644
--- a/core/modules/contextual/contextual.module
+++ b/core/modules/contextual/contextual.module
@@ -174,17 +174,8 @@ function contextual_preprocess(&$variables, $hook) {
     // JavaScript to determine which contextual links should be rendered.
     // This div with data- attribute is added unconditionally, and thus does not
     // break the render cache.
-    // Examples of the data- attribute syntax:
-    //  - node[node]:1
-    //  - views_ui[admin/structure/views/view]:frontpage
-    //  - menu[admin/structure/menu/manage]:tools|block[admin/structure/block/manage]:bartik.tools
-    $id = '';
-    foreach ($element['#contextual_links'] as $module => $args) {
-      if (drupal_strlen($id) > 0) {
-        $id .= '|';
-      }
-      $id .= $module . '[' . $args[0] . ']:' . implode(':', $args[1]);
-    }
+    // @see _contextual_links_to_id()
+    $id = _contextual_links_to_id($element['#contextual_links']);
     $variables['title_suffix']['contextual_links']['#id'] = $id;
     $variables['title_suffix']['contextual_links']['#markup'] = '<div data-contextual-id="' . $id . '"></div>';
   }
@@ -244,3 +235,63 @@ function contextual_pre_render_links($element) {
   return $element;
 }
 
+/**
+ * Serializes #contextual_links property metadata to a "contextual id".
+ *
+ * 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>:<metadata>
+ *
+ * The (dynamic) path args are joined with slashes. The metadata is encoded as a
+ * query string
+ *
+ * @param array $contextual_links
+ *   The $element['#contextual_links'] value for some render element.
+ *
+ * @return string
+ *   A contextual id.
+ */
+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;
+}
+
+/**
+ * Serializes a contextual id back to #contextual_links property metadata.
+ *
+ * The inverse operation of _contextual_links_to_id().
+ *
+ * @see _contextual_links_to_id
+ *
+ * @param string $id
+ *   A contextual id.
+ *
+ * @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/lib/Drupal/contextual/ContextualController.php b/core/modules/contextual/lib/Drupal/contextual/ContextualController.php
index f776844..5c986b6 100644
--- a/core/modules/contextual/lib/Drupal/contextual/ContextualController.php
+++ b/core/modules/contextual/lib/Drupal/contextual/ContextualController.php
@@ -32,28 +32,15 @@ class ContextualController extends ContainerAware {
   public function render(Request $request) {
     $ids = $request->request->get('ids');
     if (!isset($ids)) {
-      throw new BadRequestHttpException();
+      throw new BadRequestHttpException(t('No contextual ids specified.'));
     }
 
     $rendered = array();
     foreach ($ids as $id) {
       $element = array(
         '#type' => 'contextual_links',
-        '#contextual_links' => array(),
+        '#contextual_links' => _contextual_id_to_links($id),
       );
-
-      // Figure out which contextual links should be rendered.
-      $contexts = explode('|', $id);
-      foreach ($contexts as $context) {
-        $args = explode(':', $context);
-        $provider = array_shift($args);
-        $pos = strpos($provider, '[');
-        $module = drupal_substr($provider, 0, $pos);
-        $parent_path = drupal_substr($provider, $pos + 1, drupal_strlen($provider) - $pos - 2);
-        $element['#contextual_links'][$module] = array($parent_path, $args);
-      }
-
-      // Render the contextual links.
       $rendered[$id] = drupal_render($element);
     }
 
diff --git a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
index c657684..45f9fd6 100644
--- a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
+++ b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php
@@ -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',
+    );
+
+    // 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="/d8/node/1/edit?destination=node">Edit</a></li></ul>');
+    $this->assertIdentical($json[$ids[1]], NULL);
+    $this->assertIdentical($json[$ids[2]], '<ul class="contextual-links"><li class="node-edit odd first last"><a href="/d8/node/3/edit?destination=node">Edit</a></li></ul>');
+    $this->assertIdentical($json[$ids[3]], NULL);
+
+    // 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]], NULL);
+    $this->assertIdentical($json[$ids[1]], NULL);
+    $this->assertIdentical($json[$ids[2]], NULL);
+    $this->assertIdentical($json[$ids[3]], NULL);
+
+    // 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/views/lib/Drupal/views/Tests/UI/DisplayTest.php b/core/modules/views/lib/Drupal/views/Tests/UI/DisplayTest.php
index 15cb97a..0398d30 100644
--- a/core/modules/views/lib/Drupal/views/Tests/UI/DisplayTest.php
+++ b/core/modules/views/lib/Drupal/views/Tests/UI/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="/d8/admin/structure/views/view/test_display/edit/page_1?destination=test-display">Edit view</a></li></ul>');
   }
 
 }
diff --git a/core/modules/views/views.module b/core/modules/views/views.module
index 698f32e..5c86dd6 100644
--- a/core/modules/views/views.module
+++ b/core/modules/views/views.module
@@ -484,7 +484,7 @@ 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']['#contextual_links']['views_ui'])) {
     $key = array_search('contextual-region', $variables['attributes']['class']->value());
     if ($key !== FALSE) {
       unset($variables['attributes']['class'][$key]);
@@ -605,12 +605,14 @@ 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['#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/views_ui/views_ui.module b/core/modules/views/views_ui/views_ui.module
index 4c3ae95..d62eaa5 100644
--- a/core/modules/views/views_ui/views_ui.module
+++ b/core/modules/views/views_ui/views_ui.module
@@ -376,8 +376,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;
   }
 }
