diff --git a/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php b/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php
index 19a701638d..0b813eaca1 100644
--- a/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php
+++ b/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php
@@ -4,9 +4,15 @@
 
 use Drupal\block\BlockForm;
 use Drupal\block\BlockInterface;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\RedirectCommand;
+use Drupal\Core\Ajax\ReplaceCommand;
 use Drupal\Core\Block\BlockPluginInterface;
+use Drupal\Core\Form\DialogFormTrait;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Plugin\PluginWithFormsInterface;
+use Drupal\Core\Url;
+use Drupal\outside_in\OffCanvasFormDialogTrait;
 
 /**
  * Provides form for block instance forms when used in the off-canvas dialog.
@@ -111,4 +117,80 @@ protected function getPluginForm(BlockPluginInterface $block) {
     return $block;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    parent::submitForm($form, $form_state);
+    // \Drupal\block\BlockForm::submitForm() always redirects to block listing
+    // via \Drupal\Core\Form\FormStateInterface::setRedirect(). This method
+    // does not work with Ajax submit.
+    $form_state->disableRedirect();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildForm($form, $form_state);
+    $form['actions']['submit']['#ajax'] = [
+      'callback' => '::submitFormDialog',
+      'event' => 'click',
+    ];
+
+    $form['#attached']['library'][] = 'core/drupal.dialog.ajax';
+    $form['#attributes']['id'] = 'off-canvas-form';
+    return $form;
+  }
+
+  /**
+   * Submit form dialog #ajax callback.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   An AJAX response that display validation error messages or redirects
+   *   to a URL
+   */
+  public function submitFormDialog(array &$form, FormStateInterface $form_state) {
+    $response = new AjaxResponse();
+    if ($form_state->hasAnyErrors()) {
+      $form['status_messages'] = [
+        '#type' => 'status_messages',
+        '#weight' => -1000,
+      ];
+      $command = new ReplaceCommand('#off-canvas-form', $form);
+    }
+    else {
+      if ($redirect_url = $this->getRedirectUrl()) {
+        $command = new RedirectCommand($redirect_url->setAbsolute()->toString());
+      }
+      else {
+        // Settings Tray always provides a destination.
+        throw new \Exception("No destination provide for Settings Tray form");
+      }
+    }
+    return $response->addCommand($command);
+  }
+
+  /**
+   * Gets the form's redirect URL from 'destination' provide in the request.
+   *
+   * @return \Drupal\Core\Url|null
+   *   The redirect URL or NULL if dialog should just be closed.
+   */
+  protected function getRedirectUrl() {
+    // \Drupal\Core\Routing\RedirectDestination::get() cannot be used directly
+    // because it will use <current> if 'destination' is not in the query
+    // string.
+    if ($this->getRequest()->get('destination')) {
+      if ($destination = $this->getRedirectDestination()->get()) {
+        return Url::fromUserInput('/' . $destination);
+      }
+    }
+  }
+
 }
diff --git a/core/modules/outside_in/tests/modules/outside_in_test/outside_in_test.info.yml b/core/modules/outside_in/tests/modules/outside_in_test/outside_in_test.info.yml
new file mode 100644
index 0000000000..590f559329
--- /dev/null
+++ b/core/modules/outside_in/tests/modules/outside_in_test/outside_in_test.info.yml
@@ -0,0 +1,9 @@
+name: 'Settings Tray Test'
+type: module
+description: 'Provides Settings Tray test functionality.'
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+  - block
+  - outside_in
diff --git a/core/modules/outside_in/tests/modules/outside_in_test/outside_in_test.module b/core/modules/outside_in/tests/modules/outside_in_test/outside_in_test.module
new file mode 100644
index 0000000000..5410cfb535
--- /dev/null
+++ b/core/modules/outside_in/tests/modules/outside_in_test/outside_in_test.module
@@ -0,0 +1,30 @@
+<?php
+
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function outside_in_test_form_block_off_canvas_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+  $form['settings']['label']['#element_validate'][] = '_outside_in_test_validate_title';
+
+
+}
+
+
+/**
+ * Element validate callback test form validation messages.
+ *
+ * @param $element
+ *   An associative array containing the properties and children of the
+ *   generic form element.
+ * @param $form_state
+ *   The current state of the form.
+ * @param array $complete_form
+ *   The complete form structure.
+ */
+function _outside_in_test_validate_title(&$element, FormStateInterface $form_state, &$complete_form) {
+  if ($form_state->getValue(['settings', 'label']) == 'Block label') {
+    $form_state->setError($element, 'Meta label error.');
+  }
+}
diff --git a/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php b/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php
index 04fc230f5e..131e27b937 100644
--- a/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php
+++ b/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php
@@ -32,6 +32,7 @@ class OutsideInBlockFormTest extends OutsideInJavascriptTestBase {
     'quickedit',
     'search',
     'block_content',
+    'outside_in_test',
     // Add test module to override CSS pointer-events properties because they
     // cause test failures.
     'outside_in_test_css',
@@ -114,10 +115,9 @@ public function testBlocks($block_plugin, $new_page_text, $element_selector, $la
       if (isset($new_page_text)) {
         $page->pressButton($button_text);
         // Make sure the changes are present.
-        // @todo Use a wait method that will take into account the form submitting
-        //   and all JavaScript activity. https://www.drupal.org/node/2837676
-        //   The use \Behat\Mink\WebAssert::pageTextContains to check text.
-        $this->assertJsCondition('jQuery("' . $block_selector . ' ' . $label_selector . '").html() == "' . $new_page_text . '"');
+        $new_page_text_locator = "$block_selector $label_selector:contains($new_page_text)";
+        $this->assertElementVisibleAfterWait('css', $new_page_text_locator);
+        $web_assert->assertWaitOnAjaxRequest();
       }
 
       $this->openBlockForm($block_selector);
@@ -131,7 +131,7 @@ public function testBlocks($block_plugin, $new_page_text, $element_selector, $la
       // Open block form by clicking a element inside the block.
       // This confirms that default action for links and form elements is
       // suppressed.
-      $this->openBlockForm("$block_selector {$element_selector}");
+      $this->openBlockForm("$block_selector {$element_selector}", $block_selector);
       $web_assert->elementTextContains('css', '.contextual-toolbar-tab button', 'Editing');
       $web_assert->elementAttributeContains('css', '.dialog-off-canvas__main-canvas', 'class', 'js-outside-in-edit-mode');
       // Simulate press the Escape key.
@@ -154,7 +154,7 @@ public function providerTestBlocks() {
     $blocks = [
       'block-powered' => [
         'block_plugin' => 'system_powered_by_block',
-        'new_page_text' => 'Can you imagine anyone showing the label on this block?',
+        'new_page_text' => 'Can you imagine anyone showing the label on this block',
         'element_selector' => 'span a',
         'label_selector' => 'h2',
         'button_text' => 'Save Powered by Drupal',
@@ -162,7 +162,7 @@ public function providerTestBlocks() {
       ],
       'block-branding' => [
         'block_plugin' => 'system_branding_block',
-        'new_page_text' => 'The site that will live a very short life.',
+        'new_page_text' => 'The site that will live a very short life',
         'element_selector' => "a[rel='home']:last-child",
         'label_selector' => "a[rel='home']:last-child",
         'button_text' => 'Save Site branding',
@@ -192,6 +192,7 @@ protected function enableEditMode() {
    * Disables edit mode by pressing edit button in the toolbar.
    */
   protected function disableEditMode() {
+    $this->assertSession()->assertWaitOnAjaxRequest();
     $this->pressToolbarEditButton();
     $this->assertEditModeDisabled();
   }
@@ -225,8 +226,20 @@ protected function assertOffCanvasBlockFormIsValid() {
    *
    * @param string $block_selector
    *   A css selector selects the block or an element within it.
+   * @param string $contextual_link_container
+   *   The element that contains the contextual links. If none provide the
+   *   $block_selector will be used.
    */
-  protected function openBlockForm($block_selector) {
+  protected function openBlockForm($block_selector, $contextual_link_container = '') {
+    if (!$contextual_link_container) {
+      $contextual_link_container = $block_selector;
+    }
+    // Ensure that contextual link element is present because this is required
+    // to open the off-canvas dialog in edit mode.
+    $contextual_link = $this->assertSession()->waitForElement('css', "$contextual_link_container .contextual-links a");
+    $this->assertNotEmpty($contextual_link);
+    // Ensure that all other Ajax activity is completed.
+    $this->assertSession()->assertWaitOnAjaxRequest();
     $this->click($block_selector);
     $this->waitForOffCanvasToOpen();
     $this->assertOffCanvasBlockFormIsValid();
@@ -490,4 +503,34 @@ protected function isLabelInputVisible() {
     return $this->getSession()->getPage()->find('css', static::LABEL_INPUT_SELECTOR)->isVisible();
   }
 
+  /**
+   * Test that validation errors appear in the off-canvas dialog.
+   */
+  public function testValidationMessages() {
+    $page = $this->getSession()->getPage();
+    $web_assert = $this->assertSession();
+    foreach ($this->getTestThemes() as $theme) {
+      $this->enableTheme($theme);
+      $block = $this->placeBlock('system_powered_by_block');
+      $this->drupalGet('user');
+      $this->enableEditMode();
+      $this->openBlockForm($this->getBlockSelector($block));
+
+      // Confirm "Display Title" is not checked.
+      $web_assert->checkboxNotChecked('settings[label_display]');
+      // Confirm Title is not visible.
+      $this->assertEquals($this->isLabelInputVisible(), FALSE, 'Label is not visible');
+      $page->checkField('settings[label_display]');
+      $this->assertEquals($this->isLabelInputVisible(), TRUE, 'Label is visible');
+      // Use label that will trigger validation error.
+      // @see _outside_in_test_validate_title
+      $page->fillField('settings[label]', 'Block label');
+      $page->pressButton('Save Powered by Drupal');
+      $web_assert->assertWaitOnAjaxRequest();
+      $web_assert->elementContains('css', '#drupal-off-canvas', 'Meta label error');
+      $this->disableEditMode();
+      $block->delete();
+    }
+  }
+
 }
