diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 5da51e8..cc3fd71 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -233,6 +233,15 @@ drupal.machine-name:
     - core/drupalSettings
     - core/drupal.form
 
+drupal.message:
+  version: VERSION
+  js:
+    misc/message.js: {}
+  dependencies:
+    - core/jquery
+    - core/drupal
+    - core/drupal.debounce
+
 drupal.progress:
   version: VERSION
   js:
diff --git a/core/lib/Drupal/Core/Render/Element/StatusMessages.php b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
index 530457f..cd3bf52 100644
--- a/core/lib/Drupal/Core/Render/Element/StatusMessages.php
+++ b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
@@ -75,19 +75,17 @@ public static function generatePlaceholder(array $element) {
   public static function renderMessages($type) {
     $render = [];
     $messages = drupal_get_messages($type);
-    if ($messages) {
-      // Render the messages.
-      $render = [
-        '#theme' => 'status_messages',
-        // @todo Improve when https://www.drupal.org/node/2278383 lands.
-        '#message_list' => $messages,
-        '#status_headings' => [
-          'status' => t('Status message'),
-          'error' => t('Error message'),
-          'warning' => t('Warning message'),
-        ],
-      ];
-    }
+    // Render the messages.
+    $render = [
+      '#theme' => 'status_messages',
+      // @todo Improve when https://www.drupal.org/node/2278383 lands.
+      '#message_list' => $messages,
+      '#status_headings' => [
+        'status' => t('Status message'),
+        'error' => t('Error message'),
+        'warning' => t('Warning message'),
+      ],
+    ];
     return $render;
   }
 
diff --git a/core/misc/announce.js b/core/misc/announce.js
index acf850a..cd1ee5b 100644
--- a/core/misc/announce.js
+++ b/core/misc/announce.js
@@ -50,21 +50,23 @@
   /**
    * Concatenates announcements to a single string; appends to the live region.
    */
-  function announce() {
+  function processAnnounce() {
     var text = [];
     var priority = 'polite';
     var announcement;
 
-    // Create an array of announcement strings to be joined and appended to the
-    // aria live region.
-    var il = announcements.length;
-    for (var i = 0; i < il; i++) {
-      announcement = announcements.pop();
-      text.unshift(announcement.text);
-      // If any of the announcements has a priority of assertive then the group
-      // of joined announcements will have this priority.
-      if (announcement.priority === 'assertive') {
-        priority = 'assertive';
+    if (announcements.length) {
+      // Create an array of announcement strings to be joined and appended to
+      // the aria live region.
+      var il = announcements.length;
+      for (var i = 0; i < il; i++) {
+        announcement = announcements.pop();
+        text.unshift(announcement.text);
+        // If any of the announcements has a priority of assertive then the
+        // group of joined announcements will have this priority.
+        if (announcement.priority === 'assertive') {
+          priority = 'assertive';
+        }
       }
     }
 
@@ -81,8 +83,13 @@
       // The live text area is updated. Allow the AT to announce the text.
       liveElement.setAttribute('aria-busy', 'false');
     }
+
   }
 
+  // 200 ms is right at the cusp where humans notice a pause, so we will wait
+  // at most this much time before the set of queued announcements is read.
+  var debouncedProcessAnnounce = debounce(processAnnounce, 200);
+
   /**
    * Triggers audio UAs to read the supplied text.
    *
@@ -96,25 +103,24 @@
    *
    * @param {string} text
    *   A string to be read by the UA.
-   * @param {string} [priority='polite']
+   * @param {string} priority
    *   A string to indicate the priority of the message. Can be either
    *   'polite' or 'assertive'.
    *
-   * @return {function}
-   *   The return of the call to debounce.
-   *
    * @see http://www.w3.org/WAI/PF/aria-practices/#liveprops
    */
   Drupal.announce = function (text, priority) {
-    // Save the text and priority into a closure variable. Multiple simultaneous
-    // announcements will be concatenated and read in sequence.
-    announcements.push({
-      text: text,
-      priority: priority
-    });
-    // Immediately invoke the function that debounce returns. 200 ms is right at
-    // the cusp where humans notice a pause, so we will wait
-    // at most this much time before the set of queued announcements is read.
-    return (debounce(announce, 200)());
+    if (typeof text === 'string') {
+      // Save the text and priority into a closure variable. Multiple
+      // simultaneous announcements will be concatenated and read in sequence.
+      announcements.push({
+        text: text,
+        priority: priority
+      });
+      debouncedProcessAnnounce(announcements);
+    }
+    else {
+      throw new Error(Drupal.t('"text" passed Drupal.announce must be a string.'));
+    }
   };
 }(Drupal, Drupal.debounce));
diff --git a/core/misc/message.js b/core/misc/message.js
new file mode 100644
index 0000000..867bf67
--- /dev/null
+++ b/core/misc/message.js
@@ -0,0 +1,111 @@
+/**
+ * @file
+ * Message API.
+ */
+(function ($, Drupal, debounce) {
+
+  'use strict';
+
+  var messages = [];
+  var messagesElement;
+  var debouncedProcessMessages;
+  var messageWrapperSelector = '[data-drupal-messages]';
+
+  /**
+   * Builds a div element with the aria-live attribute and attaches it to the
+   * DOM.
+   */
+  Drupal.behaviors.drupalMessage = {
+    attach: function () {
+      if (!messagesElement) {
+        var $messagesWrapper = $(messageWrapperSelector);
+        if (!$messagesWrapper.length) {
+          throw new Error(Drupal.t('There is no element with a [data-drupal-messages] attribute'));
+        }
+        messagesElement = $messagesWrapper[0];
+      }
+    }
+  };
+
+  // Debounce the function in case Drupal.message() is used in a loop.
+  debouncedProcessMessages = debounce(processMessages, 100);
+
+  /**
+   * Displays all queued messages in one pass instead of one after the other.
+   * @see debouncedProcessMessages
+   */
+  function processMessages() {
+    var text = [];
+
+    messages.reverse().forEach(function (message) {
+      text.unshift(Drupal.theme('message', message));
+    });
+    if (text.length) {
+      messagesElement.innerHTML += text.join('');
+    }
+    // Reset the messages array after the messages are processed.
+    messages = [];
+
+  }
+
+  Drupal.message = {};
+
+  /**
+   * Displays a message on the page.
+   *
+   * @param {string} message
+   *   The message to display
+   * @param {string} type
+   *   Message type, can be either 'status', 'error' or 'warning'.
+   *   Default to 'status'
+   * @param {string} context
+   *   The context of the message, used for removing messages again.
+   */
+  Drupal.message.add = function (message, type, context) {
+    if (typeof message === 'string') {
+      // Save the text and priority into a closure variable. Multiple
+      // simultaneous announcements will be concatenated and read in sequence.
+      messages.push({
+        context: context || 'default',
+        text: message,
+        type: type || 'status'
+      });
+      debouncedProcessMessages();
+    }
+  };
+
+  /**
+   * Removes messages from the page.
+   *
+   * @param {string} context
+   *   The message context to remove
+   * @param {string} type
+   *   Message type, can be either 'status', 'error' or 'warning'.
+   */
+  Drupal.message.remove = function (context, type) {
+    var selector = '.js-messages-context--' + (context || 'default');
+    if (type) {
+      selector = '.messages--' + (type || 'status') + selector;
+    }
+    $(selector).remove();
+  };
+
+  /**
+   * Theme function for a message.
+   *
+   * @param {object} message
+   *   The message object.
+   * @param {string} message.text
+   *   The message text.
+   * @param {string} message.type
+   *   The message type.
+   * @param {string} message.context
+   *   The message context.
+   * @return {string}
+   *   A string representing a DOM fragment.
+   */
+  Drupal.theme.message = function (message) {
+    return '<div class="messages messages--' + message.type + ' js-messages js-messages-context--' + message.context + '">' + message.text + '</div>';
+  };
+
+}(jQuery, Drupal, Drupal.debounce));
diff --git a/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php b/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php
index 69ec86f..ea8affa 100644
--- a/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php
+++ b/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php
@@ -88,33 +88,14 @@ public static function cases(ContainerInterface $container = NULL, AccountInterf
     if ($container && $user) {
       $status_messages->embeddedAjaxResponseCommands = [
         [
-          'command' => 'settings',
-          'settings' => [
-            'ajaxPageState' => [
-              'theme' => 'classy',
-              'libraries' => 'big_pipe/big_pipe,classy/base,classy/messages,core/drupal.active-link,core/html5shiv,core/normalize,system/base',
-            ],
-            'pluralDelimiter' => PluralTranslatableMarkup::DELIMITER,
-            'user' => [
-              'uid' => '1',
-              'permissionsHash' => $container->get('user_permissions_hash_generator')->generate($user),
-            ],
-          ],
-          'merge' => TRUE,
-        ],
-        [
-          'command' => 'add_css',
-          'data' => '<link rel="stylesheet" href="' . base_path() . 'core/themes/classy/css/components/messages.css?' . $container->get('state')->get('system.css_js_query_string') . '" media="all" />' . "\n"
-        ],
-        [
           'command' => 'insert',
           'method' => 'replaceWith',
           'selector' => '[data-big-pipe-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=_HAdUpwWmet0TOTe2PSiJuMntExoshbm1kh2wQzzzAA"]',
-          'data' => "\n" . '    <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n    ",
+          'data' => '<div data-drupal-messages>' . "\n" . '    <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n" . '    </div>' . "\n",
           'settings' => NULL,
         ],
       ];
-      $status_messages->embeddedHtmlResponse = '<link rel="stylesheet" href="' . base_path() . 'core/themes/classy/css/components/messages.css?' . $container->get('state')->get('system.css_js_query_string') . '" media="all" />' . "\n" . "\n" . '    <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n    \n";
+      $status_messages->embeddedHtmlResponse = '<div data-drupal-messages>' . "\n" . '    <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n" . '    </div>' . "\n\n";
     }
 
 
diff --git a/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php b/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php
new file mode 100644
index 0000000..c389e79
--- /dev/null
+++ b/core/modules/hal/src/Normalizer/TimestampItemNormalizer.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\hal\Normalizer;
+
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem;
+use Drupal\serialization\Normalizer\TimeStampItemNormalizerTrait;
+
+/**
+ * Converts values for TimestampItem to and from common formats for hal.
+ */
+class TimestampItemNormalizer extends FieldItemNormalizer {
+
+  use TimeStampItemNormalizerTrait;
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = TimestampItem::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function normalizedFieldValues(FieldItemInterface $field_item, $format, array $context) {
+    $normalized = parent::normalizedFieldValues($field_item, $format, $context);
+    return $this->processNormalizedValues($normalized);
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php b/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php
new file mode 100644
index 0000000..c9a6290
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/BcTimestampNormalizerUnixTestTrait.php
@@ -0,0 +1,44 @@
+<?php
+
+
+namespace Drupal\Tests\rest\Functional;
+
+/**
+ * Trait for ResourceTestBase subclasses formatting expected timestamp data.
+ */
+trait BcTimestampNormalizerUnixTestTrait {
+
+  /**
+   * Formats a UNIX timestamp.
+   *
+   * Depending on the 'bc_timestamp_normalizer_unix' setting. The return will be
+   * an RFC3339 date string or the same timestamp that was passed in.
+   *
+   * @param int $timestamp
+   *   The timestamp value to format.
+   *
+   * @return string|int
+   *   The formatted RFC3339 date string or UNIX timestamp.
+   *
+   * @see \Drupal\serialization\Normalizer\TimestampItemNormalizer
+   */
+  protected function formatExpectedTimestampItemValues($timestamp) {
+    // If the setting is enabled, just return the timestamp as-is now.
+    if ($this->config('serialization.settings')->get('bc_timestamp_normalizer_unix')) {
+      return ['value' => $timestamp];
+    }
+
+    // Otherwise, format the date string to the same that
+    // \Drupal\serialization\Normalizer\TimestampItemNormalizer will produce.
+    $date = new \DateTime();
+    $date->setTimestamp($timestamp);
+    $date->setTimezone(new \DateTimeZone('UTC'));
+
+    // Format is also added to the expected return values.
+    return [
+      'value' => $date->format(\DateTime::RFC3339),
+      'format' => \DateTime::RFC3339,
+    ];
+  }
+
+}
diff --git a/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php b/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php
new file mode 100644
index 0000000..2fb956b
--- /dev/null
+++ b/core/modules/serialization/src/Normalizer/TimeStampItemNormalizerTrait.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\serialization\Normalizer;
+
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * A trait for TimestampItem normalization functionality.
+ */
+trait TimeStampItemNormalizerTrait {
+
+  /**
+   * Allowed timestamps formats for the denormalizer.
+   *
+   * The denormalizer allows deserialization to timestamps from three
+   * different formats. Validation of the input data and creation of the
+   * numerical timestamp value is handled with \DateTime::createFromFormat().
+   * The list is chosen to be unambiguous and language neutral, but also common
+   * for data interchange.
+   *
+   * @var string[]
+   *
+   * @see http://php.net/manual/en/datetime.createfromformat.php
+   */
+  protected $allowedFormats = [
+    'UNIX timestamp' => 'U',
+    'ISO 8601' => \DateTime::ISO8601,
+    'RFC 3339' => \DateTime::RFC3339,
+  ];
+
+  /**
+   * Processes normalized timestamp values to add a formatted date and format.
+   *
+   * @param array $data
+   * @return array
+   */
+  protected function processNormalizedValues(array $data) {
+    // Use a RFC 3339 timestamp with the time zone set to UTC to replace the
+    // timestamp value.
+    $date = new \DateTime();
+    $date->setTimestamp($data['value']);
+    $date->setTimezone(new \DateTimeZone('UTC'));
+    $data['value'] = $date->format(\DateTime::RFC3339);
+    // 'format' is not a property on TimestampItem fields. This is present to
+    // assist consumers of this data.
+    $data['format'] = \DateTime::RFC3339;
+
+    return $data;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function constructValue($data, $context) {
+    // Loop through the allowed formats and create a TimestampItem from the
+    // input data if it matches the defined pattern. Since the formats are
+    // unambiguous (i.e., they reference an absolute time with a defined time
+    // zone), only one will ever match.
+    $timezone = new \DateTimeZone('UTC');
+
+    // First check for a provided format.
+    if (!empty($data['format']) && in_array($data['format'], $this->allowedFormats)) {
+      $date = \DateTime::createFromFormat($data['format'], $data['value'], $timezone);
+      return ['value' => $date->getTimestamp()];
+    }
+    // Otherwise, loop through formats.
+    else {
+      foreach ($this->allowedFormats as $format) {
+        if (($date = \DateTime::createFromFormat($format, $data['value'], $timezone)) !== FALSE) {
+          return ['value' => $date->getTimestamp()];
+        }
+      }
+    }
+
+    $format_strings = [];
+
+    foreach ($this->allowedFormats as $label => $format) {
+      $format_strings[] = "\"$format\" ($label)";
+    }
+
+    $formats = implode(', ', $format_strings);
+    throw new UnexpectedValueException(sprintf('The specified date "%s" is not in an accepted format: %s.', $data['value'], $formats));
+  }
+
+}
diff --git a/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php b/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php
new file mode 100644
index 0000000..8f6d8ad
--- /dev/null
+++ b/core/modules/serialization/src/Normalizer/TimestampItemNormalizer.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\serialization\Normalizer;
+
+use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem;
+use Symfony\Component\Serializer\Exception\InvalidArgumentException;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * Converts values for TimestampItem to and from common formats.
+ */
+class TimestampItemNormalizer extends FieldItemNormalizer {
+
+  use TimeStampItemNormalizerTrait;
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = TimestampItem::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($field_item, $format = NULL, array $context = []) {
+    $data = parent::normalize($field_item, $format, $context);
+
+    return $this->processNormalizedValues($data);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    if (empty($data['value'])) {
+      throw new InvalidArgumentException('No "value" attribute present');
+    }
+
+    return parent::denormalize($data, $class, $format, $context);
+  }
+
+
+}
diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/TimestampItemNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/TimestampItemNormalizerTest.php
new file mode 100644
index 0000000..9470949
--- /dev/null
+++ b/core/modules/serialization/tests/src/Unit/Normalizer/TimestampItemNormalizerTest.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace Drupal\Tests\serialization\Unit\Normalizer;
+
+use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
+use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem;
+use Drupal\serialization\Normalizer\TimestampItemNormalizer;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+use Symfony\Component\Serializer\Serializer;
+
+/**
+ * Tests that entities can be serialized to supported core formats.
+ *
+ * @group serialization
+ * @coversDefaultClass \Drupal\serialization\Normalizer\TimestampItemNormalizer
+ */
+class TimestampItemNormalizerTest extends UnitTestCase {
+
+  /**
+   * @var \Drupal\serialization\Normalizer\TimestampItemNormalizer
+   */
+  protected $normalizer;
+
+  /**
+   * The test TimestampItem.
+   *
+   * @var \Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem
+   */
+  protected $item;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->normalizer = new TimestampItemNormalizer();
+  }
+
+  /**
+   * @covers ::supportsNormalization
+   */
+  public function testSupportsNormalization() {
+    $timestamp_item = $this->createTimestampItemProphecy();
+    $this->assertTrue($this->normalizer->supportsNormalization($timestamp_item->reveal()));
+
+    $entity_ref_item = $this->prophesize(EntityReferenceItem::class);
+    $this->assertFalse($this->normalizer->supportsNormalization($entity_ref_item->reveal()));
+  }
+
+  /**
+   * @covers ::supportsDenormalization
+   */
+  public function testSupportsDenormalization() {
+    $timestamp_item = $this->createTimestampItemProphecy();
+    $this->assertTrue($this->normalizer->supportsDenormalization($timestamp_item->reveal(), TimestampItem::class));
+
+    $entity_ref_item = $this->prophesize(EntityReferenceItem::class);
+    $this->assertFalse($this->normalizer->supportsNormalization($entity_ref_item->reveal(), TimestampItem::class));
+  }
+
+  /**
+   * Tests the normalize function.
+   *
+   * @covers ::normalize
+   */
+  public function testNormalize() {
+    $expected = ['value' => '2016-11-06T09:02:00+00:00', 'format' => \DateTime::RFC3339];
+
+    $timestamp_item = $this->createTimestampItemProphecy();
+    $timestamp_item->getIterator()
+      ->willReturn(new \ArrayIterator(['value' => 1478422920]));
+
+    $serializer = new Serializer();
+    $this->normalizer->setSerializer($serializer);
+
+    $normalized = $this->normalizer->normalize($timestamp_item->reveal());
+    $this->assertSame($expected, $normalized);
+  }
+
+  /**
+   * Tests the denormalize function with good data.
+   *
+   * @covers ::denormalize
+   * @dataProvider providerTestDenormalizeValidFormats
+   */
+  public function testDenormalizeValidFormats($value, $expected) {
+    $normalized = ['value' => $value];
+
+    $timestamp_item = $this->createTimestampItemProphecy();
+    // The field item should be set with the expected timestamp.
+    $timestamp_item->setValue(['value' => $expected])
+      ->shouldBeCalled();
+
+    $context = ['target_instance' => $timestamp_item->reveal()];
+
+    $denormalized = $this->normalizer->denormalize($normalized, TimestampItem::class, NULL, $context);
+    $this->assertTrue($denormalized instanceof TimestampItem);
+  }
+
+  /**
+   * Data provider for testDenormalizeValidFormats.
+   *
+   * @return array
+   */
+  public function providerTestDenormalizeValidFormats() {
+    $expected_stamp = 1478422920;
+
+    $data = [];
+
+    $data['U'] = [$expected_stamp, $expected_stamp];
+    $data['RFC3339'] = ['2016-11-06T09:02:00+00:00', $expected_stamp];
+    $data['ISO8601'] = ['2016-11-06T09:02:00+0000', $expected_stamp];
+
+    return $data;
+  }
+
+  /**
+   * Tests the denormalize function with bad data.
+   *
+   * @covers ::denormalize
+   */
+  public function testDenormalizeException() {
+    $this->setExpectedException(UnexpectedValueException::class, 'The specified date "2016/11/06 09:02am GMT" is not in an accepted format: "U" (UNIX timestamp), "Y-m-d\TH:i:sO" (ISO 8601), "Y-m-d\TH:i:sP" (RFC 3339).');
+
+    $context = ['target_instance' => $this->createTimestampItemProphecy()->reveal()];
+
+    $normalized = ['value' => '2016/11/06 09:02am GMT'];
+    $this->normalizer->denormalize($normalized, TimestampItem::class, NULL, $context);
+  }
+
+  /**
+   * Creates a Timestamp Item prophecy.
+   *
+   * @return \Prophecy\Prophecy\ObjectProphecy
+   */
+  protected function createTimestampItemProphecy() {
+    $timestamp_item = $this->prophesize(TimestampItem::class);
+    $timestamp_item->getParent()
+      ->willReturn(TRUE);
+
+    return $timestamp_item;
+  }
+
+}
diff --git a/core/modules/system/templates/status-messages.html.twig b/core/modules/system/templates/status-messages.html.twig
index 73bd9b7..e8f64e0 100644
--- a/core/modules/system/templates/status-messages.html.twig
+++ b/core/modules/system/templates/status-messages.html.twig
@@ -23,25 +23,27 @@
  * @ingroup themeable
  */
 #}
+<div data-drupal-messages>
 {% for type, messages in message_list %}
   <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes|without('role', 'aria-label') }}>
     {% if type == 'error' %}
       <div role="alert">
     {% endif %}
-      {% if status_headings[type] %}
-        <h2 class="visually-hidden">{{ status_headings[type] }}</h2>
-      {% endif %}
-      {% if messages|length > 1 %}
-        <ul>
-          {% for message in messages %}
-            <li>{{ message }}</li>
-          {% endfor %}
-        </ul>
-      {% else %}
-        {{ messages|first }}
-      {% endif %}
+    {% if status_headings[type] %}
+      <h2 class="visually-hidden">{{ status_headings[type] }}</h2>
+    {% endif %}
+    {% if messages|length > 1 %}
+      <ul>
+        {% for message in messages %}
+          <li>{{ message }}</li>
+        {% endfor %}
+      </ul>
+    {% else %}
+      {{ messages|first }}
+    {% endif %}
     {% if type == 'error' %}
       </div>
     {% endif %}
   </div>
 {% endfor %}
+</div>
diff --git a/core/modules/system/tests/modules/js_message_test/js/js_message_test.js b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
new file mode 100644
index 0000000..b38a1c2
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
@@ -0,0 +1,40 @@
+/**
+ * @file
+ *  Testing behavior for JSMessageTest.
+ */
+
+(function ($, message) {
+
+  'use strict';
+
+  /**
+   * @type {Drupal~behavior}
+   *
+   * @prop {Drupal~behaviorAttach} attach
+   *   Add click listeners that show and remove links with context and type.
+   */
+  Drupal.behaviors.js_message_test = {
+    attach: function (context) {
+      $('.show-link').once('show-msg').on('click', function (e) {
+        e.preventDefault();
+        var type = e.currentTarget.getAttribute('data-type');
+        var context = e.currentTarget.getAttribute('data-context');
+        message.add('Msg-' + context + '-' + type, type, context);
+      });
+      $('.remove-link').once('remove-msg').on('click', function (e) {
+        e.preventDefault();
+        var type = e.currentTarget.getAttribute('data-type');
+        var context = e.currentTarget.getAttribute('data-context');
+        message.remove(context, type);
+      });
+      $('.show-multiple').once('show-msg').on('click', function (e) {
+        e.preventDefault();
+        for (var i = 0; i < 10; i++) {
+          message.add('Msg-' + i, 'status', 'context-' + i);
+        }
+
+      });
+    }
+  };
+
+})(jQuery, Drupal.message);
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml
new file mode 100644
index 0000000..e8bc73b
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml
@@ -0,0 +1,6 @@
+name: 'JS Message test module'
+type: module
+description: 'Module for the JSMessageTest test.'
+package: Testing
+version: VERSION
+core: 8.x
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml
new file mode 100644
index 0000000..53da0f9
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml
@@ -0,0 +1,7 @@
+show_message:
+  version: VERSION
+  js:
+    js/js_message_test.js: {}
+  dependencies:
+    - core/drupal.message
+    - core/jquery.once
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml
new file mode 100644
index 0000000..4c325e8
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml
@@ -0,0 +1,7 @@
+js_message_test.links:
+  path: '/js_message_test_link'
+  defaults:
+    _controller: '\Drupal\js_message_test\Controller\JSMessageTestController::messageLinks'
+    _title: 'JsMessageLinks'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php
new file mode 100644
index 0000000..239d4ef
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\js_message_test\Controller;
+
+use Drupal\Core\Url;
+
+/**
+ * Test Controller to show message links.
+ */
+class JSMessageTestController {
+
+  /**
+   * Displays links to show messages via Javascript.
+   *
+   * @return array
+   *   Render array for links.
+   */
+  public function messageLinks() {
+    $links = [];
+    foreach (['context1', 'context2'] as $context) {
+      foreach (['status', 'error', 'warning'] as $type) {
+        $links["show-$context-$type"] = [
+          'title' => "Show-$context-$type",
+          'url' => Url::fromRoute('js_message_test.links'),
+          'attributes' => [
+            'id' => "show-$context-$type",
+            'data-context' => $context,
+            'data-type' => $type,
+            'class' => ['show-link'],
+          ],
+        ];
+        $links["remove-$context-$type"] = [
+          'title' => "Remove-$context-$type",
+          'url' => Url::fromRoute('js_message_test.links'),
+          'attributes' => [
+            'id' => "remove-$context-$type",
+            'data-context' => $context,
+            'data-type' => $type,
+            'class' => ['remove-link'],
+          ],
+        ];
+      }
+      $links["remove-$context"] = [
+        'title' => "Remove-$context-all",
+        'url' => Url::fromRoute('js_message_test.links'),
+        'attributes' => [
+          'id' => "remove-$context",
+          'data-context' => $context,
+          'class' => ['remove-link'],
+        ],
+      ];
+    }
+    $links['show-multi'] = [
+      'title' => "Show Multiple",
+      'url' => Url::fromRoute('js_message_test.links'),
+      'attributes' => [
+        'class' => ['show-multiple'],
+      ],
+    ];
+    return [
+      '#theme' => 'links',
+      '#links' => $links,
+      '#attached' => [
+        'library' => [
+          'js_message_test/show_message',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
new file mode 100644
index 0000000..c218f41
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Core;
+
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+
+/**
+ * Tests core/drupal.messages library.
+ *
+ * @group Javascript
+ */
+class JsMessageTest extends JavascriptTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['js_message_test'];
+
+  /**
+   * The possible message contexts.
+   *
+   * @var array
+   *
+   * @see \Drupal\js_message_test\Controller\JSMessageTestController::messageLinks
+   */
+  protected $msgContexts = ['context1', 'context2'];
+
+  /**
+   * The possible message types.
+   *
+   * @var array
+   *
+   * @see \Drupal\js_message_test\Controller\JSMessageTestController::messageLinks
+   */
+  protected $msgTypes = ['status', 'error', 'warning'];
+
+  /**
+   * Test click on links to show messages and remove messages.
+   */
+  public function testAddRemoveMessages() {
+    $web_assert = $this->assertSession();
+    $this->drupalGet('js_message_test_link');
+    $web_assert->elementExists('css', '[data-drupal-messages]');
+
+    foreach ($this->msgContexts as $context) {
+      foreach ($this->msgTypes as $type) {
+        $this->clickLink("Show-$context-$type");
+        $selector = ".messages.messages--$type.js-messages.js-messages-context--$context";
+        $msg_element = $web_assert->waitForElementVisible('css', $selector);
+        $this->assertNotEmpty($msg_element, "Message element visible: $selector");
+        $web_assert->elementContains('css', $selector, "Msg-$context-$type");
+        // Click all remove links except the one that will remove the message.
+        $this->clickAllRemoveLinksExcept($context, $type);
+        // Confirm the message was not removed.
+        $web_assert->elementContains('css', $selector, "Msg-$context-$type");
+        // Click the remove links that should remove the message.
+        $this->clickLink("Remove-$context-$type");
+        $web_assert->elementNotExists('css', $selector);
+      }
+    }
+
+    // Test removing all messages of a specific context.
+    $this->clickLink("Show-context1-error");
+    $this->clickLink("Show-context2-error");
+    $this->clickLink("Show-context1-warning");
+    $this->clickLink("Show-context2-warning");
+    $this->waitForMessageElement('warning', 'context2');
+    $this->assertCurrentMessages([
+      'Msg-context1-error',
+      'Msg-context2-error',
+      'Msg-context1-warning',
+      'Msg-context2-warning',
+    ]);
+    $this->clickLink('Remove-context1-all');
+    $web_assert->assertWaitOnAjaxRequest();
+    $this->assertCurrentMessages([
+      'Msg-context2-error',
+      'Msg-context2-warning',
+    ]);
+    $this->clickLink('Remove-context2-all');
+    $this->assertCurrentMessages([]);
+
+    // Test adding multiple messages at once.
+    // @see processMessages()
+    $this->clickLink('Show Multiple');
+    $this->waitForMessageElement('status', 'context-9');
+
+    $current_messages = [];
+    for ($i = 0; $i < 10; $i++) {
+      $current_messages[] = "Msg-$i";
+    }
+    $this->assertCurrentMessages($current_messages);
+  }
+
+  /**
+   * Clicks all remove message links except for combination specified.
+   *
+   * @param string $exclude_context
+   *   The message context to exclude.
+   * @param string $exclude_type
+   *   The message type to exclude.
+   */
+  protected function clickAllRemoveLinksExcept($exclude_context, $exclude_type) {
+    foreach ($this->msgContexts as $context) {
+      foreach ($this->msgTypes as $type) {
+        if ($context !== $exclude_context || $type !== $exclude_type) {
+          $this->clickLink("Remove-$context-$type");
+        }
+      }
+    }
+  }
+
+  /**
+   * Asserts that currently shown messages match expected messages.
+   *
+   * @param array $expected_messages
+   *   Expected messages.
+   */
+  protected function assertCurrentMessages(array $expected_messages) {
+    $current_messages = [];
+    if ($message_divs = $this->getSession()->getPage()->findAll('css', '.messages')) {
+      foreach ($message_divs as $message_div) {
+        /** @var \Behat\Mink\Element\NodeElement $message_div */
+        $current_messages[] = $message_div->getText();
+      }
+    }
+    $this->assertEquals($expected_messages, $current_messages);
+  }
+
+  /**
+   * Waits for an element message to be visible.
+   *
+   * @param string $type
+   *   The type of the message.
+   * @param string $context
+   *   The context of the message.
+   */
+  protected function waitForMessageElement($type, $context) {
+    $last_msg_element = $this->assertSession()->waitForElementVisible('css', ".messages.messages--$type.js-messages.js-messages-context--$context");
+    $this->assertNotEmpty($last_msg_element, "Message element visible.");
+  }
+
+}
diff --git a/core/themes/classy/templates/misc/status-messages.html.twig b/core/themes/classy/templates/misc/status-messages.html.twig
index bc8fd10..0d059f3 100644
--- a/core/themes/classy/templates/misc/status-messages.html.twig
+++ b/core/themes/classy/templates/misc/status-messages.html.twig
@@ -21,7 +21,7 @@
  *   - class: HTML classes.
  */
 #}
-{{ attach_library('classy/messages') }}
+<div data-drupal-messages>
 {% block messages %}
 {% for type, messages in message_list %}
   {%
@@ -54,3 +54,4 @@
   {% set attributes = attributes.removeClass(classes) %}
 {% endfor %}
 {% endblock messages %}
+</div>
