diff --git src/wp-admin/css/widgets.css src/wp-admin/css/widgets.css
index d06fc29f70..c622a2a51c 100644
--- src/wp-admin/css/widgets.css
+++ src/wp-admin/css/widgets.css
@@ -619,6 +619,26 @@ div#widgets-right .widget-top:hover,
 	cursor: move;
 }
 
+/* =Specific widget styling
+-------------------------------------------------------------- */
+.text-widget-fields {
+	position: relative;
+}
+.text-widget-fields .wp-pointer.wp-pointer-top {
+	position: absolute;
+	z-index: 3;
+	top: 100px;
+	right: 10px;
+	left: 10px;
+}
+.text-widget-fields .wp-pointer .wp-pointer-arrow {
+	left: auto;
+	right: 15px;
+}
+.text-widget-fields .wp-pointer .wp-pointer-buttons {
+	line-height: 1.4em;
+}
+
 /* =Media Queries
 -------------------------------------------------------------- */
 
diff --git src/wp-admin/js/widgets/text-widgets.js src/wp-admin/js/widgets/text-widgets.js
index 7932606e3d..a7ba7f0c06 100644
--- src/wp-admin/js/widgets/text-widgets.js
+++ src/wp-admin/js/widgets/text-widgets.js
@@ -3,7 +3,9 @@
 wp.textWidgets = ( function( $ ) {
 	'use strict';
 
-	var component = {};
+	var component = {
+		dismissedPointers: []
+	};
 
 	/**
 	 * Text widget control.
@@ -45,6 +47,29 @@ wp.textWidgets = ( function( $ ) {
 			control.$el.addClass( 'text-widget-fields' );
 			control.$el.html( wp.template( 'widget-text-control-fields' ) );
 
+			control.customHtmlWidgetPointer = control.$el.find( '.wp-pointer.custom-html-widget-pointer' );
+			if ( control.customHtmlWidgetPointer.length ) {
+				control.customHtmlWidgetPointer.find( '.close' ).on( 'click', function( event ) {
+					event.preventDefault();
+					control.customHtmlWidgetPointer.remove();
+					control.dismissPointers( [ 'text_widget_custom_html' ] );
+				});
+				control.customHtmlWidgetPointer.find( '.add-widget' ).on( 'click', function( event ) {
+					event.preventDefault();
+					control.customHtmlWidgetPointer.remove();
+					control.openAvailableWidgetsPanel();
+				});
+			}
+
+			control.pasteHtmlPointer = control.$el.find( '.wp-pointer.paste-html-pointer' );
+			if ( control.pasteHtmlPointer.length ) {
+				control.pasteHtmlPointer.find( '.close' ).on( 'click', function( event ) {
+					event.preventDefault();
+					control.pasteHtmlPointer.remove();
+					control.dismissPointers( [ 'text_widget_custom_html', 'text_widget_paste_html' ] );
+				});
+			}
+
 			control.fields = {
 				title: control.$el.find( '.title' ),
 				text: control.$el.find( '.text' )
@@ -66,6 +91,45 @@ wp.textWidgets = ( function( $ ) {
 		},
 
 		/**
+		 * Dismiss pointers for Custom HTML widget.
+		 *
+		 * @since 4.8.1
+		 *
+		 * @param {Array} pointers Pointer IDs to dismiss.
+		 * @returns {void}
+		 */
+		dismissPointers: function dismissPointers( pointers ) {
+			_.each( pointers, function( pointer ) {
+				wp.ajax.post( 'dismiss-wp-pointer', {
+					pointer: pointer
+				});
+				component.dismissedPointers.push( pointer );
+			});
+		},
+
+		/**
+		 * Open available widgets panel.
+		 *
+		 * @since 4.8.1
+		 * @returns {void}
+		 */
+		openAvailableWidgetsPanel: function openAvailableWidgetsPanel() {
+			var sidebarControl;
+			wp.customize.section.each( function( section ) {
+				if ( section.extended( wp.customize.Widgets.SidebarSection ) && section.expanded() ) {
+					sidebarControl = wp.customize.control( 'sidebars_widgets[' + section.params.sidebarId + ']' );
+				}
+			});
+			if ( ! sidebarControl ) {
+				return;
+			}
+			setTimeout( function() { // Timeout to prevent click event from causing panel to immediately collapse.
+				wp.customize.Widgets.availableWidgetsPanel.open( sidebarControl );
+				wp.customize.Widgets.availableWidgetsPanel.$search.val( 'HTML' ).trigger( 'keyup' );
+			});
+		},
+
+		/**
 		 * Update input fields from the sync fields.
 		 *
 		 * This function is called at the widget-updated and widget-synced events.
@@ -152,6 +216,34 @@ wp.textWidgets = ( function( $ ) {
 					if ( restoreTextMode ) {
 						switchEditors.go( id, 'toggle' );
 					}
+
+					// Show the pointer.
+					$( '#' + id + '-html' ).on( 'click', function() {
+						control.pasteHtmlPointer.hide(); // Hide the HTML pasting pointer.
+
+						if ( -1 !== component.dismissedPointers.indexOf( 'text_widget_custom_html' ) ) {
+							return;
+						}
+						control.customHtmlWidgetPointer.show();
+					});
+
+					// Hide the pointer when switching tabs.
+					$( '#' + id + '-tmce' ).on( 'click', function() {
+						control.customHtmlWidgetPointer.hide();
+					});
+
+					// Show pointer when pasting HTML.
+					editor.on( 'pastepreprocess', function( event ) {
+						var content = event.content;
+						if ( -1 !== component.dismissedPointers.indexOf( 'text_widget_paste_html' ) || ! content || ! /&lt;\w+.*?&gt;/.test( content ) ) {
+							return;
+						}
+
+						// Show the pointer after a slight delay so the user sees what they pasted.
+						_.delay( function() {
+							control.pasteHtmlPointer.show();
+						}, 250 );
+					});
 				};
 
 				if ( editor.initialized ) {
@@ -233,6 +325,11 @@ wp.textWidgets = ( function( $ ) {
 			return;
 		}
 
+		// Bypass using TinyMCE when widget is in legacy mode.
+		if ( widgetForm.find( '.legacy' ).length > 0 ) {
+			return;
+		}
+
 		/*
 		 * Create a container element for the widget control fields.
 		 * This is inserted into the DOM immediately before the the .widget-content
@@ -337,10 +434,13 @@ wp.textWidgets = ( function( $ ) {
 	 * When WordPress enqueues this script, it should have an inline script
 	 * attached which calls wp.textWidgets.init().
 	 *
+	 * @param {object} exports      Server exports.
+	 * @param {object} exports.l10n Translations.
 	 * @returns {void}
 	 */
-	component.init = function init() {
+	component.init = function init( exports ) {
 		var $document = $( document );
+		component.data = exports;
 		$document.on( 'widget-added', component.handleWidgetAdded );
 		$document.on( 'widget-synced widget-updated', component.handleWidgetUpdated );
 
diff --git src/wp-includes/script-loader.php src/wp-includes/script-loader.php
index 7562e2839b..2cd280937c 100644
--- src/wp-includes/script-loader.php
+++ src/wp-includes/script-loader.php
@@ -845,7 +845,7 @@ function wp_default_styles( &$styles ) {
 	$styles->add( 'themes',              "/wp-admin/css/themes$suffix.css" );
 	$styles->add( 'about',               "/wp-admin/css/about$suffix.css" );
 	$styles->add( 'nav-menus',           "/wp-admin/css/nav-menus$suffix.css" );
-	$styles->add( 'widgets',             "/wp-admin/css/widgets$suffix.css" );
+	$styles->add( 'widgets',             "/wp-admin/css/widgets$suffix.css", array( 'wp-pointer' ) );
 	$styles->add( 'site-icon',           "/wp-admin/css/site-icon$suffix.css" );
 	$styles->add( 'l10n',                "/wp-admin/css/l10n$suffix.css" );
 
diff --git src/wp-includes/widgets/class-wp-widget-text.php src/wp-includes/widgets/class-wp-widget-text.php
index 7d149c0a0f..210814d98b 100644
--- src/wp-includes/widgets/class-wp-widget-text.php
+++ src/wp-includes/widgets/class-wp-widget-text.php
@@ -53,6 +53,129 @@ class WP_Widget_Text extends WP_Widget {
 	}
 
 	/**
+	 * Determines whether a given instance is legacy and should bypass using TinyMCE.
+	 *
+	 * @since 4.8.1
+	 *
+	 * @param array $instance {
+	 *     Instance data.
+	 *
+	 *     @type string      $text   Content.
+	 *     @type bool|string $filter Whether autop or content filters should apply.
+	 *     @type bool        $legacy Whether widget is in legacy mode.
+	 * }
+	 * @return bool Whether Text widget instance contains legacy data.
+	 */
+	public function is_legacy_instance( $instance ) {
+
+		// If the widget has been updated while in legacy mode, it stays in legacy mode.
+		if ( ! empty( $instance['legacy'] ) ) {
+			return true;
+		}
+
+		// If the widget has been added/updated in 4.8 then filter prop is 'content' and it is no longer legacy.
+		if ( isset( $instance['filter'] ) && 'content' === $instance['filter'] ) {
+			return false;
+		}
+
+		// If the text is empty, then nothing is preventing migration to TinyMCE.
+		if ( empty( $instance['text'] ) ) {
+			return false;
+		}
+
+		$wpautop = ! empty( $instance['filter'] );
+		$has_line_breaks = ( false !== strpos( $instance['text'], "\n" ) );
+
+		// If auto-paragraphs are not enabled and there are line breaks, then ensure legacy mode.
+		if ( ! $wpautop && $has_line_breaks ) {
+			return true;
+		}
+
+		// If an HTML comment is present, assume legacy mode.
+		if ( false !== strpos( $instance['text'], '<!--' ) ) {
+			return true;
+		}
+
+		/*
+		 * If a shortcode is present (with support added by a plugin), assume legacy mode
+		 * since shortcodes would apply at the widget_text filter and thus be applied
+		 * before wpautop runs at the widget_text_content filter.
+		 */
+		if ( preg_match( '/' . get_shortcode_regex() . '/', $instance['text'] ) ) {
+			return true;
+		}
+
+		// In the rare case that DOMDocument is not available we cannot reliably sniff content and so we assume legacy.
+		if ( ! class_exists( 'DOMDocument' ) ) {
+			// @codeCoverageIgnoreStart
+			return true;
+			// @codeCoverageIgnoreEnd
+		}
+
+		$doc = new DOMDocument();
+		$doc->loadHTML( sprintf(
+			'<html><head><meta charset="%s"></head><body>%s</body></html>',
+			esc_attr( get_bloginfo( 'charset' ) ),
+			$instance['text']
+		) );
+		$body = $doc->getElementsByTagName( 'body' )->item( 0 );
+
+		// See $allowedposttags.
+		$safe_elements_attributes = array(
+			'strong' => array(),
+			'em' => array(),
+			'b' => array(),
+			'i' => array(),
+			'u' => array(),
+			's' => array(),
+			'ul' => array(),
+			'ol' => array(),
+			'li' => array(),
+			'hr' => array(),
+			'abbr' => array(),
+			'acronym' => array(),
+			'code' => array(),
+			'dfn' => array(),
+			'a' => array(
+				'href' => true,
+			),
+			'img' => array(
+				'src' => true,
+				'alt' => true,
+			),
+		);
+		$safe_empty_elements = array( 'img', 'hr', 'iframe' );
+
+		foreach ( $body->getElementsByTagName( '*' ) as $element ) {
+			/** @var DOMElement $element */
+			$tag_name = strtolower( $element->nodeName );
+
+			// If the element is not safe, then the instance is legacy.
+			if ( ! isset( $safe_elements_attributes[ $tag_name ] ) ) {
+				return true;
+			}
+
+			// If the element is not safely empty and it has empty contents, then legacy mode.
+			if ( ! in_array( $tag_name, $safe_empty_elements, true ) && '' === trim( $element->textContent ) ) {
+				return true;
+			}
+
+			// If an attribute is not recognized as safe, then the instance is legacy.
+			foreach ( $element->attributes as $attribute ) {
+				/** @var DOMAttr $attribute */
+				$attribute_name = strtolower( $attribute->nodeName );
+
+				if ( ! isset( $safe_elements_attributes[ $tag_name ][ $attribute_name ] ) ) {
+					return true;
+				}
+			}
+		}
+
+		// Otherwise, the text contains no elements/attributes that TinyMCE could drop, and therefore the widget does not need legacy mode.
+		return false;
+	}
+
+	/**
 	 * Outputs the content for the current Text widget instance.
 	 *
 	 * @since 2.8.0
@@ -68,6 +191,20 @@ class WP_Widget_Text extends WP_Widget {
 		$title = apply_filters( 'widget_title', empty( $instance['title'] ) ? '' : $instance['title'], $instance, $this->id_base );
 
 		$text = ! empty( $instance['text'] ) ? $instance['text'] : '';
+		$is_visual_text_widget = ( isset( $instance['filter'] ) && 'content' === $instance['filter'] );
+
+		/*
+		 * Just-in-time temporarily upgrade Visual Text widget shortcode handling
+		 * (with support added by plugin) from the widget_text filter to
+		 * widget_text_content:11 to prevent wpautop from corrupting HTML output
+		 * added by the shortcode.
+		 */
+		$widget_text_do_shortcode_priority = has_filter( 'widget_text', 'do_shortcode' );
+		$should_upgrade_shortcode_handling = ( $is_visual_text_widget && false !== $widget_text_do_shortcode_priority );
+		if ( $should_upgrade_shortcode_handling ) {
+			remove_filter( 'widget_text', 'do_shortcode', $widget_text_do_shortcode_priority );
+			add_filter( 'widget_text_content', 'do_shortcode', 11 );
+		}
 
 		/**
 		 * Filters the content of the Text widget.
@@ -102,6 +239,12 @@ class WP_Widget_Text extends WP_Widget {
 			}
 		}
 
+		// Undo temporary upgrade of the plugin-supplied shortcode handling.
+		if ( $should_upgrade_shortcode_handling ) {
+			remove_filter( 'widget_text_content', 'do_shortcode', 11 );
+			add_filter( 'widget_text', 'do_shortcode', $widget_text_do_shortcode_priority );
+		}
+
 		echo $args['before_widget'];
 		if ( ! empty( $title ) ) {
 			echo $args['before_title'] . $title . $args['after_title'];
@@ -134,12 +277,20 @@ class WP_Widget_Text extends WP_Widget {
 		}
 
 		/*
-		 * Re-use legacy 'filter' (wpautop) property to now indicate content filters will always apply.
+		 * If the Text widget is in legacy mode, then a hidden input will indicate this
+		 * and the new content value for the filter prop will by bypassed. Otherwise,
+		 * re-use legacy 'filter' (wpautop) property to now indicate content filters will always apply.
 		 * Prior to 4.8, this is a boolean value used to indicate whether or not wpautop should be
 		 * applied. By re-using this property, downgrading WordPress from 4.8 to 4.7 will ensure
 		 * that the content for Text widgets created with TinyMCE will continue to get wpautop.
 		 */
-		$instance['filter'] = 'content';
+		if ( isset( $new_instance['legacy'] ) || isset( $old_instance['legacy'] ) || ( isset( $new_instance['filter'] ) && 'content' !== $new_instance['filter'] ) ) {
+			$instance['filter'] = ! empty( $new_instance['filter'] );
+			$instance['legacy'] = true;
+		} else {
+			$instance['filter'] = 'content';
+			unset( $instance['legacy'] );
+		}
 
 		return $instance;
 	}
@@ -153,6 +304,7 @@ class WP_Widget_Text extends WP_Widget {
 	public function enqueue_admin_scripts() {
 		wp_enqueue_editor();
 		wp_enqueue_script( 'text-widgets' );
+		wp_enqueue_style( 'wp-pointer' );
 	}
 
 	/**
@@ -160,6 +312,7 @@ class WP_Widget_Text extends WP_Widget {
 	 *
 	 * @since 2.8.0
 	 * @since 4.8.0 Form only contains hidden inputs which are synced with JS template.
+	 * @since 4.8.1 Restored original form to be displayed when in legacy mode.
 	 * @access public
 	 * @see WP_Widget_Visual_Text::render_control_template_scripts()
 	 *
@@ -175,9 +328,26 @@ class WP_Widget_Text extends WP_Widget {
 			)
 		);
 		?>
-		<input id="<?php echo $this->get_field_id( 'title' ); ?>" name="<?php echo $this->get_field_name( 'title' ); ?>" class="title" type="hidden" value="<?php echo esc_attr( $instance['title'] ); ?>">
-		<input id="<?php echo $this->get_field_id( 'text' ); ?>" name="<?php echo $this->get_field_name( 'text' ); ?>" class="text" type="hidden" value="<?php echo esc_attr( $instance['text'] ); ?>">
-		<?php
+		<?php if ( ! $this->is_legacy_instance( $instance ) ) : ?>
+			<input id="<?php echo $this->get_field_id( 'title' ); ?>" name="<?php echo $this->get_field_name( 'title' ); ?>" class="title" type="hidden" value="<?php echo esc_attr( $instance['title'] ); ?>">
+			<input id="<?php echo $this->get_field_id( 'text' ); ?>" name="<?php echo $this->get_field_name( 'text' ); ?>" class="text" type="hidden" value="<?php echo esc_attr( $instance['text'] ); ?>">
+		<?php else : ?>
+			<input name="<?php echo $this->get_field_name( 'legacy' ); ?>" type="hidden" class="legacy" value="true">
+			<p>
+				<label for="<?php echo $this->get_field_id( 'title' ); ?>"><?php _e( 'Title:' ); ?></label>
+				<input class="widefat" id="<?php echo $this->get_field_id( 'title' ); ?>" name="<?php echo $this->get_field_name( 'title' ); ?>" type="text" value="<?php echo esc_attr( $instance['title'] ); ?>"/>
+			</p>
+			<div class="notice inline notice-info notice-alt">
+				<p><?php _e( 'This widget contains code that may work better in the new &#8220;Custom HTML&#8221; widget. How about trying that widget instead?' ); ?></p>
+			</div>
+			<p>
+				<label for="<?php echo $this->get_field_id( 'text' ); ?>"><?php _e( 'Content:' ); ?></label>
+				<textarea class="widefat" rows="16" cols="20" id="<?php echo $this->get_field_id( 'text' ); ?>" name="<?php echo $this->get_field_name( 'text' ); ?>"><?php echo esc_textarea( $instance['text'] ); ?></textarea>
+			</p>
+			<p>
+				<input id="<?php echo $this->get_field_id( 'filter' ); ?>" name="<?php echo $this->get_field_name( 'filter' ); ?>" type="checkbox"<?php checked( ! empty( $instance['filter'] ) ); ?> />&nbsp;<label for="<?php echo $this->get_field_id( 'filter' ); ?>"><?php _e( 'Automatically add paragraphs' ); ?></label>
+			</p>
+		<?php endif;
 	}
 
 	/**
@@ -187,6 +357,7 @@ class WP_Widget_Text extends WP_Widget {
 	 * @access public
 	 */
 	public function render_control_template_scripts() {
+		$dismissed_pointers = explode( ',', (string) get_user_meta( get_current_user_id(), 'dismissed_wp_pointers', true ) );
 		?>
 		<script type="text/html" id="tmpl-widget-text-control-fields">
 			<# var elementIdPrefix = 'el' + String( Math.random() ).replace( /\D/g, '' ) + '_' #>
@@ -194,6 +365,41 @@ class WP_Widget_Text extends WP_Widget {
 				<label for="{{ elementIdPrefix }}title"><?php esc_html_e( 'Title:' ); ?></label>
 				<input id="{{ elementIdPrefix }}title" type="text" class="widefat title">
 			</p>
+
+			<?php if ( ! in_array( 'text_widget_custom_html', $dismissed_pointers ) ) : ?>
+				<div hidden class="wp-pointer custom-html-widget-pointer wp-pointer-top">
+					<div class="wp-pointer-content">
+						<h3><?php _e( 'New Custom HTML Widget' ); ?></h3>
+						<?php if ( is_customize_preview() ) : ?>
+							<p><?php _e( 'Hey, did you hear we have a &#8220;Custom HTML&#8221; widget now? You can find it by pressing the &#8220;<a class="add-widget" href="#">Add a Widget</a>&#8221; button and searching for &#8220;HTML&#8221;. Check it out to add some custom code to your site!' ); ?></p>
+						<?php else : ?>
+							<p><?php _e( 'Hey, did you hear we have a &#8220;Custom HTML&#8221; widget now? You can find it by scanning the list of available widgets on this screen. Check it out to add some custom code to your site!' ); ?></p>
+						<?php endif; ?>
+						<div class="wp-pointer-buttons">
+							<a class="close" href="#"><?php _e( 'Dismiss' ); ?></a>
+						</div>
+					</div>
+					<div class="wp-pointer-arrow">
+						<div class="wp-pointer-arrow-inner"></div>
+					</div>
+				</div>
+			<?php endif; ?>
+
+			<?php if ( ! in_array( 'text_widget_paste_html', $dismissed_pointers ) ) : ?>
+				<div hidden class="wp-pointer paste-html-pointer wp-pointer-top">
+					<div class="wp-pointer-content">
+						<h3><?php _e( 'Did you just paste HTML?' ); ?></h3>
+						<p><?php _e( 'Hey there, looks like you just pasted HTML into the &#8220;Visual&#8221; tab of the Text widget. You may want to paste your code into the &#8220;Text&#8221; tab instead. Alternately, try out the new &#8220;Custom HTML&#8221; widget!' ); ?></p>
+						<div class="wp-pointer-buttons">
+							<a class="close" href="#"><?php _e( 'Dismiss' ); ?></a>
+						</div>
+					</div>
+					<div class="wp-pointer-arrow">
+						<div class="wp-pointer-arrow-inner"></div>
+					</div>
+				</div>
+			<?php endif; ?>
+
 			<p>
 				<label for="{{ elementIdPrefix }}text" class="screen-reader-text"><?php esc_html_e( 'Content:' ); ?></label>
 				<textarea id="{{ elementIdPrefix }}text" class="widefat text wp-editor-area" style="height: 200px" rows="16" cols="20"></textarea>
diff --git tests/phpunit/tests/widgets/text-widget.php tests/phpunit/tests/widgets/text-widget.php
index a28e6a668a..af3e2755f5 100644
--- tests/phpunit/tests/widgets/text-widget.php
+++ tests/phpunit/tests/widgets/text-widget.php
@@ -40,6 +40,20 @@ class Test_WP_Widget_Text extends WP_UnitTestCase {
 	}
 
 	/**
+	 * Test constructor method.
+	 *
+	 * @covers WP_Widget_Text::__construct
+	 */
+	function test_construct() {
+		$widget = new WP_Widget_Text();
+		$this->assertEquals( 'text', $widget->id_base );
+		$this->assertEquals( 'widget_text', $widget->widget_options['classname'] );
+		$this->assertTrue( $widget->widget_options['customize_selective_refresh'] );
+		$this->assertEquals( 400, $widget->control_options['width'] );
+		$this->assertEquals( 350, $widget->control_options['height'] );
+	}
+
+	/**
 	 * Test enqueue_admin_scripts method.
 	 *
 	 * @covers WP_Widget_Text::_register
@@ -122,6 +136,78 @@ class Test_WP_Widget_Text extends WP_UnitTestCase {
 	}
 
 	/**
+	 * Example shortcode content to test for wpautop corruption.
+	 *
+	 * @var string
+	 */
+	protected $example_shortcode_content = "<p>One\nTwo\n\nThree</p>\n<script>\ndocument.write('Test1');\n\ndocument.write('Test2');\n</script>";
+
+	/**
+	 * Do example shortcode.
+	 *
+	 * @return string Shortcode content.
+	 */
+	function do_example_shortcode() {
+		return $this->example_shortcode_content;
+	}
+
+	/**
+	 * Test widget method when a plugin has added shortcode support.
+	 *
+	 * @covers WP_Widget_Text::widget
+	 */
+	function test_widget_shortcodes() {
+		$args = array(
+			'before_title'  => '<h2>',
+			'after_title'   => "</h2>\n",
+			'before_widget' => '<section>',
+			'after_widget'  => "</section>\n",
+		);
+		$widget = new WP_Widget_Text();
+		add_filter( 'widget_text', 'do_shortcode' );
+		add_shortcode( 'example', array( $this, 'do_example_shortcode' ) );
+
+		$base_instance = array(
+			'title' => 'Example',
+			'text' => "This is an example:\n\n[example]",
+			'filter' => false,
+		);
+
+		// Legacy Text Widget.
+		$instance = array_merge( $base_instance, array(
+			'filter' => false,
+		) );
+		ob_start();
+		$widget->widget( $args, $instance );
+		$output = ob_get_clean();
+		$this->assertContains( $this->example_shortcode_content, $output, 'Shortcode was applied without wpautop corrupting it.' );
+		$this->assertEquals( 10, has_filter( 'widget_text', 'do_shortcode' ), 'Filter was restored.' );
+
+		// Visual Text Widget.
+		$instance = array_merge( $base_instance, array(
+			'filter' => 'content',
+		) );
+		ob_start();
+		$widget->widget( $args, $instance );
+		$output = ob_get_clean();
+		$this->assertContains( $this->example_shortcode_content, $output, 'Shortcode was applied without wpautop corrupting it.' );
+		$this->assertEquals( 10, has_filter( 'widget_text', 'do_shortcode' ), 'Filter was restored.' );
+		$this->assertFalse( has_filter( 'widget_text_content', 'do_shortcode' ), 'Filter was removed.' );
+
+		// Visual Text Widget with properly-used widget_text_content filter.
+		remove_filter( 'widget_text', 'do_shortcode' );
+		add_filter( 'widget_text_content', 'do_shortcode', 11 );
+		$instance = array_merge( $base_instance, array(
+			'filter' => 'content',
+		) );
+		ob_start();
+		$widget->widget( $args, $instance );
+		$output = ob_get_clean();
+		$this->assertContains( $this->example_shortcode_content, $output, 'Shortcode was applied without wpautop corrupting it.' );
+		$this->assertFalse( has_filter( 'widget_text', 'do_shortcode' ), 'Filter was not erroneously restored.' );
+	}
+
+	/**
 	 * Filters the content of the Text widget.
 	 *
 	 * @param string         $widget_text The widget content.
@@ -152,6 +238,147 @@ class Test_WP_Widget_Text extends WP_UnitTestCase {
 	}
 
 	/**
+	 * Test is_legacy_instance method.
+	 *
+	 * @covers WP_Widget_Text::is_legacy_instance
+	 */
+	function test_is_legacy_instance() {
+		$widget = new WP_Widget_Text();
+		$base_instance = array(
+			'title' => 'Title',
+			'text' => "Hello\n\nWorld",
+		);
+
+		$instance = array_merge( $base_instance, array(
+			'legacy' => true,
+		) );
+		$this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when legacy prop is present.' );
+
+		$instance = array_merge( $base_instance, array(
+			'filter' => 'content',
+		) );
+		$this->assertFalse( $widget->is_legacy_instance( $instance ), 'Not legacy when filter is explicitly content.' );
+
+		$instance = array_merge( $base_instance, array(
+			'text' => '',
+			'filter' => true,
+		) );
+		$this->assertFalse( $widget->is_legacy_instance( $instance ), 'Not legacy when text is empty.' );
+
+		$instance = array_merge( $base_instance, array(
+			'text' => "One\nTwo",
+			'filter' => false,
+		) );
+		$this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when not-wpautop and there are line breaks.' );
+
+		$instance = array_merge( $base_instance, array(
+			'text' => "One\n\nTwo",
+			'filter' => false,
+		) );
+		$this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when not-wpautop and there are paragraph breaks.' );
+
+		$instance = array_merge( $base_instance, array(
+			'text' => "One\nTwo",
+			'filter' => true,
+		) );
+		$this->assertFalse( $widget->is_legacy_instance( $instance ), 'Not automatically legacy when wpautop and there are line breaks.' );
+
+		$instance = array_merge( $base_instance, array(
+			'text' => "One\n\nTwo",
+			'filter' => true,
+		) );
+		$this->assertFalse( $widget->is_legacy_instance( $instance ), 'Not automatically legacy when wpautop and there are paragraph breaks.' );
+
+		$instance = array_merge( $base_instance, array(
+			'text' => 'Test<!-- comment -->',
+			'filter' => true,
+		) );
+		$this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when HTML comment is present.' );
+
+		$instance = array_merge( $base_instance, array(
+			'text' => 'Here is a [gallery]',
+			'filter' => true,
+		) );
+		$this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy mode when a shortcode is present.' );
+
+		// Check text examples that will not migrate to TinyMCE.
+		$legacy_text_examples = array(
+			'<span class="hello"></span>',
+			'<span></span>',
+			"<ul>\n<li><a href=\"#\" class=\"location\"></a>List Item 1</li>\n<li><a href=\"#\" class=\"location\"></a>List Item 2</li>\n</ul>",
+			'<a href="#" class="map"></a>',
+			"<script>\n\\Line one\n\n\\Line two</script>",
+			"<style>body {\ncolor:red;\n}</style>",
+			'<span class="fa fa-cc-discover fa-2x" aria-hidden="true"></span>',
+			"<p>\nStay updated with our latest news and specials. We never sell your information and you can unsubscribe at any time.\n</p>\n\n<div class=\"custom-form-class\">\n\t<form action=\"#\" method=\"post\" name=\"mc-embedded-subscribe-form\">\n\n\t\t<label class=\"screen-reader-text\" for=\"mce-EMAIL-b\">Email </label>\n\t\t<input id=\"mce-EMAIL-b\" class=\"required email\" name=\"EMAIL\" required=\"\" type=\"email\" value=\"\" placeholder=\"Email Address*\" />\n\n\t\t<input class=\"button\" name=\"subscribe\" type=\"submit\" value=\"Go!\" />\n\n\t</form>\n</div>",
+			'<span class="sectiondown"><a href="#front-page-3"><i class="fa fa-chevron-circle-down"></i></a></span>',
+		);
+		foreach ( $legacy_text_examples as $legacy_text_example ) {
+			$instance = array_merge( $base_instance, array(
+				'text' => $legacy_text_example,
+				'filter' => true,
+			) );
+			$this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when wpautop and there is HTML that is not liable to be mutated.' );
+
+			$instance = array_merge( $base_instance, array(
+				'text' => $legacy_text_example,
+				'filter' => false,
+			) );
+			$this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when not-wpautop and there is HTML that is not liable to be mutated.' );
+		}
+
+		// Check text examples that will migrate to TinyMCE, where elements and attributes are not in whitelist.
+		$migratable_text_examples = array(
+			'Check out <a href="http://example.com">Example</a>',
+			'<img src="http://example.com/img.jpg" alt="Img">',
+			'<strong><em>Hello</em></strong>',
+			'<b><i><u><s>Hello</s></u></i></b>',
+			"<ul>\n<li>One</li>\n<li>One</li>\n<li>One</li>\n</ul>",
+			"<ol>\n<li>One</li>\n<li>One</li>\n<li>One</li>\n</ol>",
+			"Text\n<hr>\nAddendum",
+			"Look at this code:\n\n<code>echo 'Hello World!';</code>",
+		);
+		foreach ( $migratable_text_examples as $migratable_text_example ) {
+			$instance = array_merge( $base_instance, array(
+				'text' => $migratable_text_example,
+				'filter' => true,
+			) );
+			$this->assertFalse( $widget->is_legacy_instance( $instance ), 'Legacy when wpautop and there is HTML that is not liable to be mutated.' );
+		}
+	}
+
+	/**
+	 * Test update method.
+	 *
+	 * @covers WP_Widget_Text::form
+	 */
+	function test_form() {
+		$widget = new WP_Widget_Text();
+		$instance = array(
+			'title' => 'Title',
+			'text' => 'Text',
+			'filter' => false,
+			'legacy' => true,
+		);
+		$this->assertTrue( $widget->is_legacy_instance( $instance ) );
+		ob_start();
+		$widget->form( $instance );
+		$form = ob_get_clean();
+		$this->assertContains( 'class="legacy"', $form );
+
+		$instance = array(
+			'title' => 'Title',
+			'text' => 'Text',
+			'filter' => 'content',
+		);
+		$this->assertFalse( $widget->is_legacy_instance( $instance ) );
+		ob_start();
+		$widget->form( $instance );
+		$form = ob_get_clean();
+		$this->assertNotContains( 'class="legacy"', $form );
+	}
+
+	/**
 	 * Test update method.
 	 *
 	 * @covers WP_Widget_Text::update
@@ -161,21 +388,21 @@ class Test_WP_Widget_Text extends WP_UnitTestCase {
 		$instance = array(
 			'title' => "The\nTitle",
 			'text'  => "The\n\nText",
-			'filter' => false,
+			'filter' => 'content',
 		);
 
 		wp_set_current_user( $this->factory()->user->create( array(
 			'role' => 'administrator',
 		) ) );
 
-		// Should return valid instance.
+		// Should return valid instance in legacy mode since filter=false and there are line breaks.
 		$expected = array(
 			'title'  => sanitize_text_field( $instance['title'] ),
 			'text'   => $instance['text'],
 			'filter' => 'content',
 		);
 		$result = $widget->update( $instance, array() );
-		$this->assertEquals( $result, $expected );
+		$this->assertEquals( $expected, $result );
 		$this->assertTrue( ! empty( $expected['filter'] ), 'Expected filter prop to be truthy, to handle case where 4.8 is downgraded to 4.7.' );
 
 		// Make sure KSES is applying as expected.
@@ -184,7 +411,7 @@ class Test_WP_Widget_Text extends WP_UnitTestCase {
 		$instance['text'] = '<script>alert( "Howdy!" );</script>';
 		$expected['text'] = $instance['text'];
 		$result = $widget->update( $instance, array() );
-		$this->assertEquals( $result, $expected );
+		$this->assertEquals( $expected, $result );
 		remove_filter( 'map_meta_cap', array( $this, 'grant_unfiltered_html_cap' ) );
 
 		add_filter( 'map_meta_cap', array( $this, 'revoke_unfiltered_html_cap' ), 10, 2 );
@@ -192,11 +419,63 @@ class Test_WP_Widget_Text extends WP_UnitTestCase {
 		$instance['text'] = '<script>alert( "Howdy!" );</script>';
 		$expected['text'] = wp_kses_post( $instance['text'] );
 		$result = $widget->update( $instance, array() );
-		$this->assertEquals( $result, $expected );
+		$this->assertEquals( $expected, $result );
 		remove_filter( 'map_meta_cap', array( $this, 'revoke_unfiltered_html_cap' ), 10 );
 	}
 
 	/**
+	 * Test update for legacy widgets.
+	 *
+	 * @covers WP_Widget_Text::update
+	 */
+	function test_update_legacy() {
+		$widget = new WP_Widget_Text();
+
+		// Updating a widget with explicit filter=true persists with legacy mode.
+		$instance = array(
+			'title' => 'Legacy',
+			'text' => 'Text',
+			'filter' => true,
+		);
+		$result = $widget->update( $instance, array() );
+		$expected = array_merge( $instance, array(
+			'legacy' => true,
+			'filter' => true,
+		) );
+		$this->assertEquals( $expected, $result );
+
+		// Updating a widget with explicit filter=false persists with legacy mode.
+		$instance['filter'] = false;
+		$result = $widget->update( $instance, array() );
+		$expected = array_merge( $instance, array(
+			'legacy' => true,
+			'filter' => false,
+		) );
+		$this->assertEquals( $expected, $result );
+
+		// Updating a widget in legacy form results in filter=false when checkbox not checked.
+		$instance['filter'] = true;
+		$result = $widget->update( $instance, array() );
+		$expected = array_merge( $instance, array(
+			'legacy' => true,
+			'filter' => true,
+		) );
+		$this->assertEquals( $expected, $result );
+
+		// Updating a widget that previously had legacy form results in filter persisting.
+		unset( $instance['legacy'] );
+		$instance['filter'] = true;
+		$result = $widget->update( $instance, array(
+			'legacy' => true,
+		) );
+		$expected = array_merge( $instance, array(
+			'legacy' => true,
+			'filter' => true,
+		) );
+		$this->assertEquals( $expected, $result );
+	}
+
+	/**
 	 * Grant unfiltered_html cap via map_meta_cap.
 	 *
 	 * @param array  $caps    Returns the user's actual capabilities.
