diff --git src/wp-includes/class-wp-customize-manager.php src/wp-includes/class-wp-customize-manager.php
index 4947d27..9ede050 100644
--- src/wp-includes/class-wp-customize-manager.php
+++ src/wp-includes/class-wp-customize-manager.php
@@ -102,6 +102,7 @@ final class WP_Customize_Manager {
 		add_action( 'wp_ajax_customize_save', array( $this, 'save' ) );
 
 		add_action( 'customize_register',                 array( $this, 'register_controls' ) );
+		add_action( 'customize_register',                 array( $this, 'register_dynamic_settings' ), 11 ); // allow code to create settings first
 		add_action( 'customize_controls_init',            array( $this, 'prepare_controls' ) );
 		add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_control_scripts' ) );
 	}
@@ -111,10 +112,22 @@ final class WP_Customize_Manager {
 	 *
 	 * @since 3.4.0
 	 *
+	 * @param string|null $action whether the supplied Ajax action is being run (since 4.2.0).
+	 *
 	 * @return bool
 	 */
-	public function doing_ajax() {
-		return isset( $_POST['customized'] ) || ( defined( 'DOING_AJAX' ) && DOING_AJAX );
+	public function doing_ajax( $action = null ) {
+		$doing_ajax = ( defined( 'DOING_AJAX' ) && DOING_AJAX );
+		if ( ! $doing_ajax ) {
+			return false;
+		}
+
+		if ( ! $action ) {
+			return true;
+		} else {
+			// Note: we can't just use doing_action( "wp_ajax_{$action}" ) because we need to check before admin-ajax.php gets to that point
+			return isset( $_REQUEST['action'] ) && wp_unslash( $_REQUEST['action'] ) === $action;
+		}
 	}
 
 	/**
@@ -726,6 +739,62 @@ final class WP_Customize_Manager {
 	}
 
 	/**
+	 * Register any dynamically-created settings, such as those from $_POST['customized'] that have no corresponding setting created.
+	 *
+	 * This is a mechanism to "wake up" settings that have been dynamically created
+	 * on the frontend and have been added to a transaction. When the transaction is
+	 * loaded, the dynamically-created settings then will get created and previewed
+	 * even though they are not directly created statically with code.
+	 *
+	 * @param string[] $setting_ids  The setting IDs to add.
+	 * @return WP_Customize_Setting[]  The settings added.
+	 */
+	public function add_dynamic_settings( $setting_ids ) {
+		$new_settings = array();
+		foreach ( $setting_ids as $setting_id ) {
+			// Skip settings already created
+			if ( $this->get_setting( $setting_id ) ) {
+				continue;
+			}
+			$setting_args = false;
+			$setting_class = 'WP_Customize_Setting';
+
+			/**
+			 * Filter a dynamic setting's constructor args.
+			 *
+			 * For a dynamic setting to be registered, this filter must be employed
+			 * to override the default false value with an array of args to pass to
+			 * the WP_Customize_Setting constructor.
+			 *
+			 * @since 4.2.0
+			 *
+			 * @param false|array $setting_args  The arguments to the WP_Customize_Setting constructor.
+			 * @param string $setting_id  ID for dynamic setting, usually coming from $_POST['customized'].
+			 */
+			$setting_args = apply_filters( 'customize_dynamic_setting_args', $setting_args, $setting_id );
+			if ( false === $setting_args ) {
+				continue;
+			}
+
+			/**
+			 * Allow non-statically created settings to be constructed with custom WP_Customize_Setting subclass.
+			 *
+			 * @since 4.2.0
+			 *
+			 * @param string $setting_class  WP_Customize_Setting or a subclass.
+			 * @param string $setting_id  ID for dynamic setting, usually coming from $_POST['customized'].
+			 * @param string $setting_args  WP_Customize_Setting or a subclass.
+			 */
+			$setting_class = apply_filters( 'customize_dynamic_setting_class', $setting_class, $setting_id, $setting_args );
+
+			$setting = new $setting_class( $this, $setting_id, $setting_args );
+			$this->add_setting( $setting );
+			$new_settings[] = $setting;
+		}
+		return $new_settings;
+	}
+
+	/**
 	 * Retrieve a customize setting.
 	 *
 	 * @since 3.4.0
@@ -1274,6 +1343,15 @@ final class WP_Customize_Manager {
 	}
 
 	/**
+	 * Add settings from the POST data that were not added with code, e.g. dynamically-created settings for Widgets
+	 *
+	 * @since 4.2.0
+	 */
+	public function register_dynamic_settings() {
+		$this->add_dynamic_settings( array_keys( $this->unsanitized_post_values() ) );
+	}
+
+	/**
 	 * Callback for validating the header_textcolor value.
 	 *
 	 * Accepts 'blank', and otherwise uses sanitize_hex_color_no_hash().
diff --git src/wp-includes/class-wp-customize-setting.php src/wp-includes/class-wp-customize-setting.php
index 6cbaf3d..1634c63 100644
--- src/wp-includes/class-wp-customize-setting.php
+++ src/wp-includes/class-wp-customize-setting.php
@@ -55,14 +55,6 @@ class WP_Customize_Setting {
 	protected $id_data = array();
 
 	/**
-	 * Cached and sanitized $_POST value for the setting.
-	 *
-	 * @access private
-	 * @var mixed
-	 */
-	private $_post_value;
-
-	/**
 	 * Constructor.
 	 *
 	 * Any supplied $args override class property defaults.
@@ -163,7 +155,7 @@ class WP_Customize_Setting {
 	 */
 	public function _preview_filter( $original ) {
 		$undefined = new stdClass(); // symbol hack
-		$post_value = $this->manager->post_value( $this, $undefined );
+		$post_value = $this->post_value( $undefined );
 		if ( $undefined === $post_value ) {
 			$value = $this->_original_value;
 		} else {
@@ -211,17 +203,7 @@ class WP_Customize_Setting {
 	 * @return mixed The default value on failure, otherwise the sanitized value.
 	 */
 	final public function post_value( $default = null ) {
-		// Check for a cached value
-		if ( isset( $this->_post_value ) )
-			return $this->_post_value;
-
-		// Call the manager for the post value
-		$result = $this->manager->post_value( $this );
-
-		if ( isset( $result ) )
-			return $this->_post_value = $result;
-		else
-			return $default;
+		return $this->manager->post_value( $this, $default );
 	}
 
 	/**
diff --git src/wp-includes/class-wp-customize-widgets.php src/wp-includes/class-wp-customize-widgets.php
index ba14828..9258e18 100644
--- src/wp-includes/class-wp-customize-widgets.php
+++ src/wp-includes/class-wp-customize-widgets.php
@@ -35,37 +35,35 @@ final class WP_Customize_Widgets {
 	/**
 	 * @since 3.9.0
 	 * @access protected
-	 * @var
-	 */
-	protected $_customized;
-
-	/**
-	 * @since 3.9.0
-	 * @access protected
 	 * @var array
 	 */
-	protected $_prepreview_added_filters = array();
+	protected $rendered_sidebars = array();
 
 	/**
 	 * @since 3.9.0
 	 * @access protected
 	 * @var array
 	 */
-	protected $rendered_sidebars = array();
+	protected $rendered_widgets = array();
 
 	/**
 	 * @since 3.9.0
 	 * @access protected
 	 * @var array
 	 */
-	protected $rendered_widgets = array();
+	protected $old_sidebars_widgets = array();
 
 	/**
-	 * @since 3.9.0
+	 * Mapping of setting type to setting ID pattern.
+	 *
+	 * @since 4.2.0
 	 * @access protected
 	 * @var array
 	 */
-	protected $old_sidebars_widgets = array();
+	protected $setting_id_patterns = array(
+		'widget_instance' => '/^(widget_.+?)(?:\[(\d+)\])?$/',
+		'sidebar_widgets' => '/^sidebars_widgets\[(.+?)\]$/',
+	);
 
 	/**
 	 * Initial loader.
@@ -78,7 +76,8 @@ final class WP_Customize_Widgets {
 	public function __construct( $manager ) {
 		$this->manager = $manager;
 
-		add_action( 'after_setup_theme',                       array( $this, 'setup_widget_addition_previews' ) );
+		add_filter( 'customize_dynamic_setting_args',          array( $this, 'filter_customize_dynamic_setting_args' ), 10, 2 );
+		add_action( 'after_setup_theme',                       array( $this, 'register_settings' ) );
 		add_action( 'wp_loaded',                               array( $this, 'override_sidebars_widgets_for_theme_switch' ) );
 		add_action( 'customize_controls_init',                 array( $this, 'customize_controls_init' ) );
 		add_action( 'customize_register',                      array( $this, 'schedule_customize_register' ), 1 );
@@ -95,193 +94,92 @@ final class WP_Customize_Widgets {
 	}
 
 	/**
-	 * Get an unslashed post value or return a default.
+	 * Get the widget setting type given a setting ID.
 	 *
-	 * @since 3.9.0
+	 * @since 4.2.0
 	 *
-	 * @access protected
+	 * @param $setting_id
 	 *
-	 * @param string $name    Post value.
-	 * @param mixed  $default Default post value.
-	 * @return mixed Unslashed post value or default value.
+	 * @return string|null
 	 */
-	protected function get_post_value( $name, $default = null ) {
-		if ( ! isset( $_POST[ $name ] ) ) {
-			return $default;
+	protected function get_setting_type( $setting_id ) {
+		static $cache = array();
+		if ( isset( $cache[ $setting_id ] ) ) {
+			return $cache[ $setting_id ];
 		}
-
-		return wp_unslash( $_POST[$name] );
+		foreach ( $this->setting_id_patterns as $type => $pattern ) {
+			if ( preg_match( $pattern, $setting_id ) ) {
+				$cache[ $setting_id ] = $type;
+				return $type;
+			}
+		}
+		return null;
 	}
 
 	/**
-	 * Set up widget addition previews.
+	 * Inspect the incoming customized data for any widget settings, and dynamically add them up-front so widgets will be initialized properly.
 	 *
-	 * Since the widgets get registered on 'widgets_init' before the Customizer
-	 * settings are set up on 'customize_register', we have to filter the options
-	 * similarly to how the setting previewer will filter the options later.
-	 *
-	 * @since 3.9.0
-	 *
-	 * @access public
+	 * @since 4.2.0
 	 */
-	public function setup_widget_addition_previews() {
-		$is_customize_preview = false;
-
-		if ( ! empty( $this->manager ) && ! is_admin() && 'on' === $this->get_post_value( 'wp_customize' ) ) {
-			$is_customize_preview = check_ajax_referer( 'preview-customize_' . $this->manager->get_stylesheet(), 'nonce', false );
-		}
-
-		$is_ajax_widget_update = false;
-		if ( $this->manager->doing_ajax() && 'update-widget' === $this->get_post_value( 'action' ) ) {
-			$is_ajax_widget_update = check_ajax_referer( 'update-widget', 'nonce', false );
-		}
-
-		$is_ajax_customize_save = false;
-		if ( $this->manager->doing_ajax() && 'customize_save' === $this->get_post_value( 'action' ) ) {
-			$is_ajax_customize_save = check_ajax_referer( 'save-customize_' . $this->manager->get_stylesheet(), 'nonce', false );
-		}
-
-		$is_valid_request = ( $is_ajax_widget_update || $is_customize_preview || $is_ajax_customize_save );
-		if ( ! $is_valid_request ) {
-			return;
-		}
-
-		// Input from Customizer preview.
-		if ( isset( $_POST['customized'] ) ) {
-			$this->_customized = json_decode( $this->get_post_value( 'customized' ), true );
-		} else { // Input from ajax widget update request.
-			$this->_customized = array();
-			$id_base = $this->get_post_value( 'id_base' );
-			$widget_number = $this->get_post_value( 'widget_number', false );
-			$option_name = 'widget_' . $id_base;
-			$this->_customized[ $option_name ] = array();
-			if ( preg_match( '/^[0-9]+$/', $widget_number ) ) {
-				$option_name .= '[' . $widget_number . ']';
-				$this->_customized[ $option_name ][ $widget_number ] = array();
+	public function register_settings() {
+		$widget_setting_ids = array();
+		$incoming_setting_ids = array_keys( $this->manager->unsanitized_post_values() );
+		foreach ( $incoming_setting_ids as $setting_id ) {
+			if ( ! is_null( $this->get_setting_type( $setting_id ) ) ) {
+				$widget_setting_ids[] = $setting_id;
 			}
 		}
+		if ( $this->manager->doing_ajax( 'update-widget' ) && isset( $_REQUEST['widget-id'] ) ) {
+			$widget_setting_ids[] = $this->get_setting_id( wp_unslash( $_REQUEST['widget-id'] ) );
+		}
 
-		$function = array( $this, 'prepreview_added_sidebars_widgets' );
-
-		$hook = 'option_sidebars_widgets';
-		add_filter( $hook, $function );
-		$this->_prepreview_added_filters[] = compact( 'hook', 'function' );
-
-		$hook = 'default_option_sidebars_widgets';
-		add_filter( $hook, $function );
-		$this->_prepreview_added_filters[] = compact( 'hook', 'function' );
-
-		$function = array( $this, 'prepreview_added_widget_instance' );
-		foreach ( $this->_customized as $setting_id => $value ) {
-			if ( preg_match( '/^(widget_.+?)(?:\[(\d+)\])?$/', $setting_id, $matches ) ) {
-				$option = $matches[1];
-
-				$hook = sprintf( 'option_%s', $option );
-				if ( ! has_filter( $hook, $function ) ) {
-					add_filter( $hook, $function );
-					$this->_prepreview_added_filters[] = compact( 'hook', 'function' );
-				}
-
-				$hook = sprintf( 'default_option_%s', $option );
-				if ( ! has_filter( $hook, $function ) ) {
-					add_filter( $hook, $function );
-					$this->_prepreview_added_filters[] = compact( 'hook', 'function' );
-				}
+		$settings = $this->manager->add_dynamic_settings( array_unique( $widget_setting_ids ) );
 
-				/*
-				 * Make sure the option is registered so that the update_option()
-				 * won't fail due to the filters providing a default value, which
-				 * causes the update_option() to get confused.
-				 */
-				add_option( $option, array() );
+		/*
+		 * Preview settings right away so that widgets and sidebars will get registered properly.
+		 * But don't do this if a customize_save because this will cause WP to think there is nothing
+		 * changed that needs to be saved.
+		 */
+		if ( ! $this->manager->doing_ajax( 'customize_save' ) ) {
+			foreach ( $settings as $setting ) {
+				$setting->preview();
 			}
 		}
 	}
 
 	/**
-	 * Ensure that newly-added widgets will appear in the widgets_sidebars.
+	 * Determine the arguments for a dynamically-created setting.
 	 *
-	 * This is necessary because the Customizer's setting preview filters
-	 * are added after the widgets_init action, which is too late for the
-	 * widgets to be set up properly.
+	 * @since 4.2.0
 	 *
-	 * @since 3.9.0
-	 * @access public
-	 *
-	 * @param array $sidebars_widgets Associative array of sidebars and their widgets.
-	 * @return array Filtered array of sidebars and their widgets.
+	 * @param false|array $args
+	 * @param string $setting_id
+	 * @return false|array
 	 */
-	public function prepreview_added_sidebars_widgets( $sidebars_widgets ) {
-		foreach ( $this->_customized as $setting_id => $value ) {
-			if ( preg_match( '/^sidebars_widgets\[(.+?)\]$/', $setting_id, $matches ) ) {
-				$sidebar_id = $matches[1];
-				$sidebars_widgets[ $sidebar_id ] = $value;
-			}
+	public function filter_customize_dynamic_setting_args( $args, $setting_id ) {
+		if ( $this->get_setting_type( $setting_id ) ) {
+			$args = $this->get_setting_args( $setting_id );
 		}
-		return $sidebars_widgets;
+		return $args;
 	}
 
 	/**
-	 * Ensure newly-added widgets have empty instances so they
-	 * will be recognized.
-	 *
-	 * This is necessary because the Customizer's setting preview
-	 * filters are added after the widgets_init action, which is
-	 * too late for the widgets to be set up properly.
+	 * Get an unslashed post value or return a default.
 	 *
 	 * @since 3.9.0
-	 * @access public
 	 *
-	 * @param array|bool|mixed $value Widget instance(s), false if open was empty.
-	 * @return array|mixed Widget instance(s) with additions.
-	 */
-	public function prepreview_added_widget_instance( $value = false ) {
-		if ( ! preg_match( '/^(?:default_)?option_(widget_(.+))/', current_filter(), $matches ) ) {
-			return $value;
-		}
-		$id_base = $matches[2];
-
-		foreach ( $this->_customized as $setting_id => $setting ) {
-			$parsed_setting_id = $this->parse_widget_setting_id( $setting_id );
-			if ( is_wp_error( $parsed_setting_id ) || $id_base !== $parsed_setting_id['id_base'] ) {
-				continue;
-			}
-			$widget_number = $parsed_setting_id['number'];
-
-			if ( is_null( $widget_number ) ) {
-				// Single widget.
-				if ( false === $value ) {
-					$value = array();
-				}
-			} else {
-				// Multi widget.
-				if ( empty( $value ) ) {
-					$value = array( '_multiwidget' => 1 );
-				}
-				if ( ! isset( $value[ $widget_number ] ) ) {
-					$value[ $widget_number ] = array();
-				}
-			}
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Remove pre-preview filters.
-	 *
-	 * Removes filters added in setup_widget_addition_previews()
-	 * to ensure widgets are populating the options during
-	 * 'widgets_init'.
+	 * @access protected
 	 *
-	 * @since 3.9.0
-	 * @access public
+	 * @param string $name    Post value.
+	 * @param mixed  $default Default post value.
+	 * @return mixed Unslashed post value or default value.
 	 */
-	public function remove_prepreview_filters() {
-		foreach ( $this->_prepreview_added_filters as $prepreview_added_filter ) {
-			remove_filter( $prepreview_added_filter['hook'], $prepreview_added_filter['function'] );
+	protected function get_post_value( $name, $default = null ) {
+		if ( ! isset( $_POST[ $name ] ) ) {
+			return $default;
 		}
-		$this->_prepreview_added_filters = array();
+
+		return wp_unslash( $_POST[ $name ] );
 	}
 
 	/**
@@ -380,7 +278,7 @@ final class WP_Customize_Widgets {
 	 * @access public
 	 */
 	public function schedule_customize_register() {
-		if ( is_admin() ) { // @todo for some reason, $wp_customize->is_preview() is true here?
+		if ( is_admin() ) {
 			$this->customize_register();
 		} else {
 			add_action( 'wp', array( $this, 'customize_register' ) );
@@ -412,12 +310,9 @@ final class WP_Customize_Widgets {
 		foreach ( array_keys( $wp_registered_widgets ) as $widget_id ) {
 			$setting_id   = $this->get_setting_id( $widget_id );
 			$setting_args = $this->get_setting_args( $setting_id );
-
-			$setting_args['sanitize_callback']    = array( $this, 'sanitize_widget_instance' );
-			$setting_args['sanitize_js_callback'] = array( $this, 'sanitize_widget_js_instance' );
-
-			$this->manager->add_setting( $setting_id, $setting_args );
-
+			if ( ! $this->manager->get_setting( $setting_id ) ) {
+				$this->manager->add_setting( $setting_id, $setting_args );
+			}
 			$new_setting_ids[] = $setting_id;
 		}
 
@@ -452,11 +347,9 @@ final class WP_Customize_Widgets {
 			if ( $is_registered_sidebar || $is_inactive_widgets ) {
 				$setting_id   = sprintf( 'sidebars_widgets[%s]', $sidebar_id );
 				$setting_args = $this->get_setting_args( $setting_id );
-
-				$setting_args['sanitize_callback']    = array( $this, 'sanitize_sidebar_widgets' );
-				$setting_args['sanitize_js_callback'] = array( $this, 'sanitize_sidebar_widgets_js_instance' );
-
-				$this->manager->add_setting( $setting_id, $setting_args );
+				if ( ! $this->manager->get_setting( $setting_id ) ) {
+					$this->manager->add_setting( $setting_id, $setting_args );
+				}
 				$new_setting_ids[] = $setting_id;
 
 				// Add section to contain controls.
@@ -527,12 +420,13 @@ final class WP_Customize_Widgets {
 		 * We have to register these settings later than customize_preview_init
 		 * so that other filters have had a chance to run.
 		 */
-		if ( did_action( 'customize_preview_init' ) ) {
+		if ( ! $this->manager->doing_ajax( 'customize_save' ) ) {
 			foreach ( $new_setting_ids as $new_setting_id ) {
 				$this->manager->get_setting( $new_setting_id )->preview();
 			}
 		}
-		$this->remove_prepreview_filters();
+
+		add_filter( 'sidebars_widgets',   array( $this, 'preview_sidebars_widgets' ), 1 );
 	}
 
 	/**
@@ -804,6 +698,15 @@ final class WP_Customize_Widgets {
 			'transport'  => 'refresh',
 			'default'    => array(),
 		);
+
+		if ( preg_match( $this->setting_id_patterns['sidebar_widgets'], $id, $matches ) ) {
+			$args['sanitize_callback'] = array( $this, 'sanitize_sidebar_widgets' );
+			$args['sanitize_js_callback'] = array( $this, 'sanitize_sidebar_widgets_js_instance' );
+		} else if ( preg_match( $this->setting_id_patterns['widget_instance'], $id, $matches ) ) {
+			$args['sanitize_callback'] = array( $this, 'sanitize_widget_instance' );
+			$args['sanitize_js_callback'] = array( $this, 'sanitize_widget_js_instance' );
+		}
+
 		$args = array_merge( $args, $overrides );
 
 		/**
@@ -831,15 +734,10 @@ final class WP_Customize_Widgets {
 	 * @return array Array of sanitized widget IDs.
 	 */
 	public function sanitize_sidebar_widgets( $widget_ids ) {
-		global $wp_registered_widgets;
-
-		$widget_ids           = array_map( 'strval', (array) $widget_ids );
+		$widget_ids = array_map( 'strval', (array) $widget_ids );
 		$sanitized_widget_ids = array();
-
 		foreach ( $widget_ids as $widget_id ) {
-			if ( array_key_exists( $widget_id, $wp_registered_widgets ) ) {
-				$sanitized_widget_ids[] = $widget_id;
-			}
+			$sanitized_widget_ids[] = preg_replace( '/[^a-z0-9_\-]/', '', $widget_id );
 		}
 		return $sanitized_widget_ids;
 	}
@@ -974,7 +872,6 @@ final class WP_Customize_Widgets {
 	 * @access public
 	 */
 	public function customize_preview_init() {
-		add_filter( 'sidebars_widgets',   array( $this, 'preview_sidebars_widgets' ), 1 );
 		add_action( 'wp_enqueue_scripts', array( $this, 'customize_preview_enqueue' ) );
 		add_action( 'wp_print_styles',    array( $this, 'print_preview_css' ), 1 );
 		add_action( 'wp_footer',          array( $this, 'export_preview_data' ), 20 );
@@ -1344,8 +1241,7 @@ final class WP_Customize_Widgets {
 		$form = ob_get_clean();
 
 		// Obtain the widget instance.
-		$option = get_option( $option_name );
-
+		$option = $this->get_captured_option( $option_name );
 		if ( null !== $parsed_id['number'] ) {
 			$instance = $option[$parsed_id['number']];
 		} else {
@@ -1383,8 +1279,8 @@ final class WP_Customize_Widgets {
 			wp_die( -1 );
 		}
 
-		if ( ! isset( $_POST['widget-id'] ) ) {
-			wp_send_json_error();
+		if ( empty( $_POST['widget-id'] ) ) {
+			wp_send_json_error( 'missing_widget-id' );
 		}
 
 		/** This action is documented in wp-admin/includes/ajax-actions.php */
@@ -1398,15 +1294,22 @@ final class WP_Customize_Widgets {
 
 		$widget_id = $this->get_post_value( 'widget-id' );
 		$parsed_id = $this->parse_widget_id( $widget_id );
-		$id_base   = $parsed_id['id_base'];
-
-		if ( isset( $_POST['widget-' . $id_base] ) && is_array( $_POST['widget-' . $id_base] ) && preg_match( '/__i__|%i%/', key( $_POST['widget-' . $id_base] ) ) ) {
-			wp_send_json_error();
+		$id_base = $parsed_id['id_base'];
+
+		$is_updating_widget_template = (
+			isset( $_POST[ 'widget-' . $id_base ] )
+			&&
+			is_array( $_POST[ 'widget-' . $id_base ] )
+			&&
+			preg_match( '/__i__|%i%/', key( $_POST[ 'widget-' . $id_base ] ) )
+		);
+		if ( $is_updating_widget_template ) {
+			wp_send_json_error( 'template_widget_not_updatable' );
 		}
 
 		$updated_widget = $this->call_widget_update( $widget_id ); // => {instance,form}
 		if ( is_wp_error( $updated_widget ) ) {
-			wp_send_json_error();
+			wp_send_json_error( $updated_widget->get_error_message() );
 		}
 
 		$form = $updated_widget['form'];
@@ -1463,6 +1366,22 @@ final class WP_Customize_Widgets {
 	}
 
 	/**
+	 * Get the option that was captured from being saved.
+	 *
+	 * @since 4.2.0
+	 * @access protected
+	 * @return mixed
+	 */
+	protected function get_captured_option( $name, $default = false ) {
+		if ( array_key_exists( $name, $this->_captured_options ) ) {
+			$value = $this->_captured_options[ $name ];
+		} else {
+			$value = $default;
+		}
+		return $value;
+	}
+
+	/**
 	 * Get the number of captured widget option updates.
 	 *
 	 * @since 3.9.0
diff --git tests/phpunit/tests/customize/manager.php tests/phpunit/tests/customize/manager.php
index 41d7b78..67aceb6 100644
--- tests/phpunit/tests/customize/manager.php
+++ tests/phpunit/tests/customize/manager.php
@@ -32,6 +32,36 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
 	}
 
 	/**
+	 * Test WP_Customize_Manager::doing_ajax()
+	 *
+	 * @group ajax
+	 */
+	function test_doing_ajax() {
+		if ( ! defined( 'DOING_AJAX' ) ) {
+			define( 'DOING_AJAX', true );
+		}
+
+		$manager = $this->instantiate();
+		$this->assertTrue( $manager->doing_ajax() );
+
+		$_REQUEST['action'] = 'customize_save';
+		$this->assertTrue( $manager->doing_ajax( 'customize_save' ) );
+		$this->assertFalse( $manager->doing_ajax( 'update-widget' ) );
+	}
+
+	/**
+	 * Test ! WP_Customize_Manager::doing_ajax()
+	 */
+	function test_not_doing_ajax() {
+		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
+			$this->markTestSkipped( 'Cannot test when DOING_AJAX' );
+		}
+
+		$manager = $this->instantiate();
+		$this->assertFalse( $manager->doing_ajax() );
+	}
+
+	/**
 	 * Test WP_Customize_Manager::unsanitized_post_values()
 	 *
 	 * @ticket 30988
@@ -71,5 +101,80 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase {
 		$this->assertEquals( 'post_value_bar_default', $manager->post_value( $bar_setting, 'post_value_bar_default' ), 'Expected post_value($bar_setting, $default) to return $default since no value supplied in $_POST[customized][bar]' );
 	}
 
+	/**
+	 * Test the WP_Customize_Manager::add_dynamic_settings() method.
+	 *
+	 * @ticket 30936
+	 */
+	function test_add_dynamic_settings() {
+		$manager = $this->instantiate();
+		$setting_ids = array( 'foo', 'bar' );
+		$manager->add_setting( 'foo', array( 'default' => 'foo_default' ) );
+		$this->assertEmpty( $manager->get_setting( 'bar' ), 'Expected there to not be a bar setting up front.' );
+		$manager->add_dynamic_settings( $setting_ids );
+		$this->assertEmpty( $manager->get_setting( 'bar' ), 'Expected the bar setting to remain absent since filters not added.' );
+
+		$this->action_customize_register_for_dynamic_settings();
+		$manager->add_dynamic_settings( $setting_ids );
+		$this->assertNotEmpty( $manager->get_setting( 'bar' ), 'Expected bar setting to be created since filters were added.' );
+		$this->assertEquals( 'foo_default', $manager->get_setting( 'foo' )->default, 'Expected static foo setting to not get overridden by dynamic setting.' );
+		$this->assertEquals( 'dynamic_bar_default', $manager->get_setting( 'bar' )->default, 'Expected dynamic setting bar to have default providd by filter.' );
+	}
+
+	/**
+	 * Test the WP_Customize_Manager::register_dynamic_settings() method.
+	 *
+	 * This is similar to test_add_dynamic_settings, except the settings are passed via $_POST['customized'].
+	 *
+	 * @ticket 30936
+	 */
+	function test_register_dynamic_settings() {
+		$posted_settings = array(
+			'foo' => 'OOF',
+			'bar' => 'RAB',
+		);
+		$_POST['customized'] = wp_slash( wp_json_encode( $posted_settings ) );
+
+		add_action( 'customize_register', array( $this, 'action_customize_register_for_dynamic_settings' ) );
+
+		$manager = $this->instantiate();
+		$manager->add_setting( 'foo', array( 'default' => 'foo_default' ) );
+
+		$this->assertEmpty( $manager->get_setting( 'bar' ), 'Expected dynamic setting "bar" to not be registered.' );
+		do_action( 'customize_register', $manager );
+		$this->assertNotEmpty( $manager->get_setting( 'bar' ), 'Expected dynamic setting "bar" to be automatically registered after customize_register action.' );
+		$this->assertEmpty( $manager->get_setting( 'baz' ), 'Expected unrecognized dynamic setting "baz" to remain unregistered.' );
+	}
+
+	/**
+	 * In lieu of closures, callback for customize_register action added in test_register_dynamic_settings()
+	 */
+	function action_customize_register_for_dynamic_settings() {
+		add_filter( 'customize_dynamic_setting_args', array( $this, 'filter_customize_dynamic_setting_args_for_test_dynamic_settings' ), 10, 2 );
+		add_filter( 'customize_dynamic_setting_class', array( $this, 'filter_customize_dynamic_setting_class_for_test_dynamic_settings' ), 10, 3 );
+	}
+
+	/**
+	 * In lieu of closures, callback for customize_dynamic_setting_args filter added for test_register_dynamic_settings()
+	 */
+	function filter_customize_dynamic_setting_args_for_test_dynamic_settings( $setting_args, $setting_id ) {
+		$this->assertEquals( false, $setting_args, 'Expected $setting_args to be false by default.' );
+		$this->assertInternalType( 'string', $setting_id );
+		if ( in_array( $setting_id, array( 'foo', 'bar' ) ) ) {
+			$setting_args = array( 'default' => "dynamic_{$setting_id}_default" );
+		}
+		return $setting_args;
+	}
+
+	/**
+	 * In lieu of closures, callback for customize_dynamic_setting_class filter added for test_register_dynamic_settings()
+	 */
+	function filter_customize_dynamic_setting_class_for_test_dynamic_settings( $setting_class, $setting_id, $setting_args ) {
+		$this->assertEquals( 'WP_Customize_Setting', $setting_class );
+		$this->assertInternalType( 'string', $setting_id );
+		$this->assertInternalType( 'array', $setting_args );
+		return $setting_class;
+	}
+
 }
 
diff --git tests/phpunit/tests/customize/widgets.php tests/phpunit/tests/customize/widgets.php
new file mode 100644
index 0000000..f070df2
--- /dev/null
+++ tests/phpunit/tests/customize/widgets.php
@@ -0,0 +1,196 @@
+<?php
+
+/**
+ * Tests for the WP_Customize_Widgets class.
+ *
+ * @group customize
+ */
+class Tests_WP_Customize_Widgets extends WP_UnitTestCase {
+
+	/**
+	 * @var WP_Customize_Manager
+	 */
+	protected $manager;
+
+	function setUp() {
+		parent::setUp();
+		require_once( ABSPATH . WPINC . '/class-wp-customize-manager.php' );
+		$GLOBALS['wp_customize'] = new WP_Customize_Manager(); // wpcs: override ok
+		$this->manager = $GLOBALS['wp_customize'];
+
+		unset( $GLOBALS['_wp_sidebars_widgets'] ); // clear out cache set by wp_get_sidebars_widgets()
+		$sidebars_widgets = wp_get_sidebars_widgets();
+		$this->assertEqualSets( array( 'wp_inactive_widgets', 'sidebar-1' ), array_keys( wp_get_sidebars_widgets() ) );
+		$this->assertContains( 'search-2', $sidebars_widgets['sidebar-1'] );
+		$this->assertContains( 'categories-2', $sidebars_widgets['sidebar-1'] );
+		$this->assertArrayHasKey( 2, get_option( 'widget_search' ) );
+		$widget_categories = get_option( 'widget_categories' );
+		$this->assertArrayHasKey( 2, $widget_categories );
+		$this->assertEquals( '', $widget_categories['title'] );
+
+		remove_action( 'after_setup_theme', 'twentyfifteen_setup' ); // @todo We should not be including a theme anyway
+
+		$user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $user_id );
+	}
+
+	function tearDown() {
+		parent::tearDown();
+		$this->manager = null;
+		unset( $GLOBALS['wp_customize'] );
+	}
+
+	function set_customized_post_data( $customized ) {
+		$_POST['customized'] = wp_slash( wp_json_encode( $customized ) );
+	}
+
+	function do_customize_boot_actions() {
+		do_action( 'setup_theme' );
+		$_REQUEST['nonce'] = wp_create_nonce( 'preview-customize_' . $this->manager->theme()->get_stylesheet() );
+		do_action( 'after_setup_theme' );
+		do_action( 'init' );
+		do_action( 'wp_loaded' );
+		do_action( 'wp', $GLOBALS['wp'] );
+	}
+
+	/**
+	 * Test WP_Customize_Widgets::__construct()
+	 */
+	function test_construct() {
+		$this->assertInstanceOf( 'WP_Customize_Widgets', $this->manager->widgets );
+		$this->assertEquals( $this->manager, $this->manager->widgets->manager );
+	}
+
+	/**
+	 * Test WP_Customize_Widgets::register_settings()
+	 *
+	 * @ticket 30988
+	 */
+	function test_register_settings() {
+
+		$raw_widget_customized = array(
+			'widget_categories[2]' => array(
+				'title' => 'Taxonomies Brand New Value',
+				'count' => 0,
+				'hierarchical' => 0,
+				'dropdown' => 0,
+			),
+			'widget_search[3]' => array(
+				'title' => 'Not as good as Google!',
+			),
+		);
+		$customized = array();
+		foreach ( $raw_widget_customized as $setting_id => $instance ) {
+			$customized[ $setting_id ] = $this->manager->widgets->sanitize_widget_js_instance( $instance );
+		}
+
+		$this->set_customized_post_data( $customized );
+		$this->do_customize_boot_actions();
+		$this->assertTrue( is_customize_preview() );
+
+		$this->assertNotEmpty( $this->manager->get_setting( 'widget_categories[2]' ), 'Expected setting for pre-existing widget category-2, being customized.' );
+		$this->assertNotEmpty( $this->manager->get_setting( 'widget_search[2]' ), 'Expected setting for pre-existing widget search-2, not being customized.' );
+		$this->assertNotEmpty( $this->manager->get_setting( 'widget_search[3]' ), 'Expected dynamic setting for non-existing widget search-3, being customized.' );
+
+		$widget_categories = get_option( 'widget_categories' );
+		$this->assertEquals( $raw_widget_customized['widget_categories[2]'], $widget_categories[2], 'Expected $wp_customize->get_setting(widget_categories[2])->preview() to have been called.' );
+	}
+
+	/**
+	 * Test WP_Customize_Widgets::get_setting_args()
+	 */
+	function test_get_setting_args() {
+
+		add_filter( 'widget_customizer_setting_args', array( $this, 'filter_widget_customizer_setting_args' ), 10, 2 );
+
+		$default_args = array(
+			'type' => 'option',
+			'capability' => 'edit_theme_options',
+			'transport' => 'refresh',
+			'default' => array(),
+			'sanitize_callback' => array( $this->manager->widgets, 'sanitize_widget_instance' ),
+			'sanitize_js_callback' => array( $this->manager->widgets, 'sanitize_widget_js_instance' ),
+		);
+
+		$args = $this->manager->widgets->get_setting_args( 'widget_foo[2]' );
+		foreach ( $default_args as $key => $default_value ) {
+			$this->assertEquals( $default_value, $args[ $key ] );
+		}
+		$this->assertEquals( 'WIDGET_FOO[2]', $args['uppercase_id_set_by_filter'] );
+
+		$override_args = array(
+			'type' => 'theme_mod',
+			'capability' => 'edit_posts',
+			'transport' => 'postMessage',
+			'default' => array( 'title' => 'asd' ),
+			'sanitize_callback' => '__return_empty_array',
+			'sanitize_js_callback' => '__return_empty_array',
+		);
+		$args = $this->manager->widgets->get_setting_args( 'widget_bar[3]', $override_args );
+		foreach ( $override_args as $key => $override_value ) {
+			$this->assertEquals( $override_value, $args[ $key ] );
+		}
+		$this->assertEquals( 'WIDGET_BAR[3]', $args['uppercase_id_set_by_filter'] );
+
+		$default_args = array(
+			'type' => 'option',
+			'capability' => 'edit_theme_options',
+			'transport' => 'refresh',
+			'default' => array(),
+			'sanitize_callback' => array( $this->manager->widgets, 'sanitize_sidebar_widgets' ),
+			'sanitize_js_callback' => array( $this->manager->widgets, 'sanitize_sidebar_widgets_js_instance' ),
+		);
+		$args = $this->manager->widgets->get_setting_args( 'sidebars_widgets[sidebar-1]' );
+		foreach ( $default_args as $key => $default_value ) {
+			$this->assertEquals( $default_value, $args[ $key ] );
+		}
+		$this->assertEquals( 'SIDEBARS_WIDGETS[SIDEBAR-1]', $args['uppercase_id_set_by_filter'] );
+
+		$override_args = array(
+			'type' => 'theme_mod',
+			'capability' => 'edit_posts',
+			'transport' => 'postMessage',
+			'default' => array( 'title' => 'asd' ),
+			'sanitize_callback' => '__return_empty_array',
+			'sanitize_js_callback' => '__return_empty_array',
+		);
+		$args = $this->manager->widgets->get_setting_args( 'sidebars_widgets[sidebar-2]', $override_args );
+		foreach ( $override_args as $key => $override_value ) {
+			$this->assertEquals( $override_value, $args[ $key ] );
+		}
+		$this->assertEquals( 'SIDEBARS_WIDGETS[SIDEBAR-2]', $args['uppercase_id_set_by_filter'] );
+	}
+
+	function filter_widget_customizer_setting_args( $args, $id ) {
+		$args['uppercase_id_set_by_filter'] = strtoupper( $id );
+		return $args;
+	}
+
+	/**
+	 * Test WP_Customize_Widgets::sanitize_widget_js_instance() and WP_Customize_Widgets::sanitize_widget_instance()
+	 */
+	function test_sanitize_widget_js_instance() {
+		$this->do_customize_boot_actions();
+
+		$new_categories_instance = array(
+			'title' => 'Taxonomies Brand New Value',
+			'count' => '1',
+			'hierarchical' => '1',
+			'dropdown' => '1',
+		);
+
+		$sanitized_for_js = $this->manager->widgets->sanitize_widget_js_instance( $new_categories_instance );
+		$this->assertArrayHasKey( 'encoded_serialized_instance', $sanitized_for_js );
+		$this->assertTrue( is_serialized( base64_decode( $sanitized_for_js['encoded_serialized_instance'] ), true ) );
+		$this->assertEquals( $new_categories_instance['title'], $sanitized_for_js['title'] );
+		$this->assertTrue( $sanitized_for_js['is_widget_customizer_js_value'] );
+		$this->assertArrayHasKey( 'instance_hash_key', $sanitized_for_js );
+
+		$corrupted_sanitized_for_js = $sanitized_for_js;
+		$corrupted_sanitized_for_js['encoded_serialized_instance'] = base64_encode( serialize( array( 'title' => 'EVIL' ) ) );
+		$this->assertNull( $this->manager->widgets->sanitize_widget_instance( $corrupted_sanitized_for_js ), 'Expected sanitize_widget_instance to reject corrupted data.' );
+
+		$unsanitized_from_js = $this->manager->widgets->sanitize_widget_instance( $sanitized_for_js );
+		$this->assertEquals( $unsanitized_from_js, $new_categories_instance );
+	}
+}
