Index: /trunk/src/wp-includes/class-wp-view-config-data.php
===================================================================
--- /trunk/src/wp-includes/class-wp-view-config-data.php	(revision 62824)
+++ /trunk/src/wp-includes/class-wp-view-config-data.php	(revision 62825)
@@ -14,25 +14,40 @@
  * methods on the instance and returning it. The configuration has four
  * top-level keys — `default_view`, `default_layouts`, `view_list`, and
- * `form` — and there are two ways to contribute:
+ * `form` — and there are three ways to contribute. They form a gradient of how
+ * deep the replacement reaches:
  *
- * - The `update_*()` methods merge partial changes (patches) into what is
- *   already there, each covering one part of the configuration:
- *   `update_properties()` for `default_view`, `default_layouts`, and the
- *   `form` settings other than its `fields`; `update_view_list_items()` for
- *   the `view_list` entries, keyed by view `slug`; and `update_form_fields()`
- *   for the `form` fields, keyed by field `id`. This is what plugins should
- *   use: patches compose with core's configuration and with other plugins'.
- * - `set()` replaces a whole top-level key. It shouldn't be the default
- *   choice — a callback using it stops inheriting core's future changes to
- *   that key — but it's useful for cases like a post type that doesn't
- *   want the default form at all.
+ * - The `merge()` method merges partial changes (patches) into what is already
+ *   there: `default_view`, `default_layouts`, and the `form` settings by key,
+ *   and the `view_list` entries by view `slug` identity. This is what plugins
+ *   should use: patches compose with core's configuration and with other
+ *   plugins'.
+ * - `replace()` applies a patch the same way `merge()` does, with one
+ *   difference: a list in the patch replaces the current list wholesale
+ *   instead of merging into it by member identity. It shouldn't be the
+ *   default choice — a callback that replaces a list stops inheriting core's
+ *   future additions to it — but it's useful when a contributor needs to pin
+ *   a list to an exact set of members.
+ * - `set()` goes one step further: it replaces each top-level key the patch
+ *   names wholesale, dropping whatever that key held instead of merging into
+ *   it. It's for a callback that owns a key outright and wants to pin it to an
+ *   exact shape without the inherited default leaking through a key-by-key
+ *   merge.
  *
- * Patches follow three shared rules: an associative array merges key by
- * key, a numerically indexed array replaces the current value wholesale,
- * and `null` deletes what it names — deleting a whole top-level key resets
- * it to its default. Each patch and each `set()` value also declares the
- * configuration schema version it was written against (currently 1), so a
- * future WordPress release that changes the configuration shape can migrate
- * existing patches forward instead of breaking them.
+ * All three touch only the top-level keys the patch names — an omitted key
+ * keeps whatever it had, and a top-level `null` value drops the key it names,
+ * which resets it to its default. They differ only in how deep the replacement
+ * reaches once a key is named: `merge()` and `replace()` merge the value in
+ * key by key (an associative array merges member by member, a nested `null`
+ * deletes just that leaf, a scalar replaces just that value), while `set()`
+ * swaps the whole value. A nested `null` deletes just the leaf it names in
+ * every case. Each patch also declares the configuration schema
+ * version it was written against (currently 1), so a future WordPress release
+ * that changes the configuration shape can migrate existing patches forward
+ * instead of breaking them.
+ *
+ * Where those three write values, `remove()` deletes them: it takes a spec of
+ * names — a list to delete entries at a level, or a nested map to reach deeper —
+ * and prunes just what it names, mirroring the configuration's shape all the way
+ * down to individual list members.
  *
  * @since 7.1.0
@@ -65,4 +80,12 @@
 
 	/**
+	 * The default configuration.
+	 *
+	 * @since 7.1.0
+	 * @var array
+	 */
+	private $defaults;
+
+	/**
 	 * Constructor.
 	 *
@@ -72,5 +95,6 @@
 	 */
 	public function __construct( array $config ) {
-		$this->config = $config;
+		$this->config   = $config;
+		$this->defaults = $config;
 	}
 
@@ -78,80 +102,171 @@
 	 * Returns the current configuration array.
 	 *
+	 * Deliberately private: filter callbacks receive the container, not the
+	 * materialized configuration, so they cannot read the built result and
+	 * become coupled to a specific configuration shape or schema version. Only
+	 * the class itself reconciles the container back into an array.
+	 *
 	 * @since 7.1.0
 	 *
 	 * @return array The configuration.
 	 */
-	public function get_config() {
+	private function get_data() {
 		return $this->config;
 	}
 
 	/**
-	 * Replaces a whole top-level key with a new value.
-	 *
-	 * It shouldn't be the default choice — a callback using it stops
-	 * inheriting core's future changes to that key — but it's useful for
-	 * cases like a post type that doesn't want the default form at all.
-	 *
-	 * A value that declares an unsupported schema version is rejected and
-	 * does not replace anything.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param string $key     The configuration key to replace.
-	 * @param mixed  $value   The new value.
-	 * @param int    $version The schema version the value was authored against.
+	 * Applies the entity view configuration filter and returns the result.
+	 *
+	 * Exposes the container through the dynamic
+	 * `get_entity_view_config_{$kind}_{$name}` filter so that core and third
+	 * parties can provide the configuration for a specific entity, then
+	 * reconciles the filtered container back into a plain configuration array,
+	 * limited to the documented configuration keys.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param string $kind The entity kind (e.g. `postType`).
+	 * @param string $name The entity name (e.g. `page`).
+	 * @return array The filtered configuration, limited to the documented keys.
+	 */
+	public function apply_filters( $kind, $name ) {
+		/**
+		 * Filters the view configuration for a given entity.
+		 *
+		 * The dynamic portions of the hook name, `$kind` and `$name`, refer to the
+		 * entity kind (e.g. `postType`) and the entity name (e.g. `page`).
+		 *
+		 * Callbacks receive a WP_View_Config_Data object and change the
+		 * configuration through its methods. Each write method takes the schema
+		 * version the change was authored against as its second argument,
+		 * and returns the object for chaining:
+		 *
+		 * - `merge( $patch, $version )` merges a partial change into the current
+		 *   configuration. It touches only the top-level keys the patch names, and
+		 *   merges each named value into the current one by shape: a scalar
+		 *   replaces, an associative array merges key by key, and a list merges by
+		 *   member identity (`id`, `slug`, or `field`). A `null` value drops the
+		 *   key it names, resetting it to its default.
+		 * - `replace( $patch, $version )` applies a patch exactly like `merge()`,
+		 *   but swaps any list it names wholesale instead of merging that list by
+		 *   member identity.
+		 * - `set( $patch, $version )` also touches only the keys the patch names,
+		 *   but swaps each named value in wholesale, dropping whatever the key held
+		 *   before — for a callback that owns those keys outright.
+		 * - `remove( $spec, $version )` deletes named properties. The spec mirrors
+		 *   the configuration shape: a list of names deletes entries at that level,
+		 *   and a nested map recurses to prune from within a named value, down to
+		 *   individual list members.
+		 *
+		 * A change that declares an unsupported schema version is rejected and does
+		 * not alter anything. Callbacks mutate the container in place, so there is no
+		 * need to return it; any returned value is ignored. Callbacks must not replace
+		 * the container with a different value, as later callbacks receive whatever the
+		 * the previous one returned.
+		 *
+		 * @since 7.1.0
+		 *
+		 * @param WP_View_Config_Data $data   The view configuration container
+		 *                                    for the entity, exposing the
+		 *                                    `default_view`, `default_layouts`,
+		 *                                    `view_list`, and `form` keys.
+		 * @param array               $entity {
+		 *     The entity the configuration is built for.
+		 *
+		 *     @type string $kind The entity kind.
+		 *     @type string $name The entity name.
+		 * }
+		 */
+		apply_filters(
+			"get_entity_view_config_{$kind}_{$name}",
+			$this,
+			array(
+				'kind' => $kind,
+				'name' => $name,
+			)
+		);
+
+		// Discard any keys the filter introduced that are not part of the
+		// documented configuration shape.
+		return array_intersect_key( $this->get_data(), array_flip( self::CONFIG_KEYS ) );
+	}
+
+	/**
+	 * Replaces whole top-level keys, leaving the rest of the configuration alone.
+	 *
+	 * Like merge() and replace(), set() applies a patch of top-level keys and
+	 * touches only the keys the patch names: a key the patch omits keeps whatever
+	 * it had, and a `null` value drops the key it names (which resets it to its
+	 * default). The difference is depth — where merge() and replace() merge a
+	 * named key's value into the current one key by key, set() swaps the whole
+	 * value in wholesale, dropping whatever the key held before. A `null` nested
+	 * within that value still drops the property it names, so set() honours
+	 * nulls at every depth just as merge() and replace() do.
+	 *
+	 * Use it when a callback owns a key outright and wants to pin it to an exact
+	 * shape, without the inherited default leaking through a key-by-key merge.
+	 *
+	 * A patch that declares an unsupported schema version is rejected and does
+	 * not change anything.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param array $patch   The partial configuration whose named keys to replace.
+	 * @param int   $version The schema version the patch was authored against.
 	 * @return WP_View_Config_Data The instance, for chaining.
 	 */
-	public function set( $key, $value, int $version ) {
-		if ( ! $this->check_version( $version, __METHOD__ ) ) {
-			return $this;
-		}
-
-		if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) {
+	public function set( array $patch, int $version ) {
+		return $this->apply( $patch, $version, __METHOD__, 'set' );
+	}
+
+	/**
+	 * Removes named properties from the configuration, leaving the rest alone.
+	 *
+	 * Where merge(), replace(), and set() take a patch of *values* to write,
+	 * remove() takes a spec of *names* to delete, and its shape mirrors the
+	 * configuration it prunes:
+	 *
+	 * - A list of names deletes each named entry from the value at that level: a
+	 *   key from an associative array, or the member with a matching identity
+	 *   (`id`, `slug`, `field`, or a bare scalar) from a list.
+	 * - An associative array maps a name to a nested spec, recursing into that
+	 *   entry's value to delete from within it.
+	 *
+	 * Naming a top-level configuration key is the one exception: like a `null`
+	 * value in a patch, it resets that key to its default rather than dropping it
+	 * outright, so top-level removal and top-level `null` compose the same way.
+	 *
+	 * So `array( 'default_view' )` resets the whole `default_view` key to its
+	 * default, `array( 'default_view' => array( 'sort' ) )` drops just its `sort`
+	 * property, and `array( 'default_view' => array( 'fields' => array( 'f2' ) ) )`
+	 * drops the `f2` member from its `fields` list. A name that is not present is
+	 * ignored, and a list is renumbered after a member is removed.
+	 *
+	 * A spec that declares an unsupported schema version is rejected and does not
+	 * change anything.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param array $spec    The names to remove, keyed to match the configuration shape.
+	 * @param int   $version The schema version the spec was authored against.
+	 * @return WP_View_Config_Data The instance, for chaining.
+	 */
+	public function remove( array $spec, int $version ) {
+		if ( $version <= 0 || $version > self::LATEST_VERSION ) {
 			_doing_it_wrong(
 				__METHOD__,
-				sprintf(
-					/* translators: %s: the configuration key. */
-					esc_html__( '"%s" is not a documented view configuration key.' ),
-					esc_html( $key )
-				),
+				esc_html__( 'A view configuration patch must declare a supported schema version.' ),
 				'7.1.0'
 			);
+
 			return $this;
 		}
 
-		$this->config[ $key ] = $value;
-		return $this;
-	}
-
-	/**
-	 * Merges a partial configuration into `default_view`, `default_layouts`,
-	 * and the `form` settings other than its `fields`.
-	 *
-	 * An associative array merges key by key, a numerically indexed array
-	 * replaces the current value wholesale, and `null` deletes the key it
-	 * names; deleting a whole top-level key (any documented key, including
-	 * `view_list`) resets it to its default.
-	 *
-	 * The keyed collections have dedicated methods and are rejected here: a
-	 * non-null `view_list` value must go through `update_view_list_items()`,
-	 * and a `fields` key inside a `form` value must go through
-	 * `update_form_fields()`.
-	 *
-	 * A patch that declares an unsupported schema version is rejected and
-	 * does not merge.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param array $patch   The partial configuration to merge.
-	 * @param int   $version The schema version the patch was authored against.
-	 * @return WP_View_Config_Data The instance, for chaining.
-	 */
-	public function update_properties( array $patch, int $version ) {
-		if ( ! $this->check_version( $version, __METHOD__ ) ) {
-			return $this;
-		}
-
-		foreach ( $patch as $key => $value ) {
+		// A flat list names top-level keys to reset; a map recurses into each
+		// named key to prune from within its value.
+		$spec_is_list = array_is_list( $spec );
+		foreach ( $spec as $spec_key => $spec_value ) {
+			$key = $spec_is_list ? $spec_value : $spec_key;
+
 			if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) {
 				_doing_it_wrong(
@@ -166,27 +281,148 @@
 				continue;
 			}
-			// A null patch value drops the whole key from the container rather
-			// than assigning null.
-			if ( null === $value ) {
-				unset( $this->config[ $key ] );
-				continue;
-			}
-			if ( 'view_list' === $key ) {
+
+			if ( $spec_is_list ) {
+				// Removing a top-level key resets it to its default, just as a
+				// null patch value does.
+				$this->config[ $key ] = $this->defaults[ $key ] ?? array();
+			} elseif ( array_key_exists( $key, $this->config ) ) {
+				$this->config[ $key ] = $this->remove_properties( $this->config[ $key ], $spec_value );
+			}
+		}
+
+		return $this;
+	}
+
+	/**
+	 * Replaces list values while merging the rest of a partial configuration.
+	 *
+	 * Takes the same arguments as merge() and applies the patch the same way,
+	 * with one difference: a list in the patch replaces the current list
+	 * wholesale instead of merging into it by member identity. Associative
+	 * arrays still merge key by key, `null` still drops what it names, and a
+	 * scalar still replaces the current value.
+	 *
+	 * It shouldn't be the default choice — a callback that replaces a list
+	 * stops inheriting core's future additions to it — but it's useful when a
+	 * contributor needs to pin a list to an exact set of members.
+	 *
+	 * A patch that declares an unsupported schema version is rejected and does
+	 * not change anything.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param array $patch   The partial configuration to apply.
+	 * @param int   $version The schema version the patch was authored against.
+	 * @return WP_View_Config_Data The instance, for chaining.
+	 */
+	public function replace( array $patch, int $version ) {
+		return $this->apply( $patch, $version, __METHOD__, 'replace' );
+	}
+
+	/**
+	 * Merges a partial configuration into the existing one.
+	 *
+	 * Applies a patch of top-level keys and touches only the keys the patch
+	 * names: a key the patch omits keeps whatever it had, and a `null` value
+	 * drops the key it names (which resets it to its default). Each named key's
+	 * value is then merged into the current one by value shape:
+	 *
+	 * - a scalar replaces the current value;
+	 * - an associative array merges key by key, with a nested `null` deleting
+	 *   just the leaf it names;
+	 * - a list merges into the current list by member identity.
+	 *
+	 * Identity is the member's value cast to a string: a bare scalar is its own
+	 * identity, and a map is identified by the value of the first of the
+	 * well-known identity keys (`id`, `slug`, `field`) it carries. A member
+	 * whose identity matches one already present merges into it in place, keeping
+	 * its position; a member with no identity is appended to the end of the list.
+	 *
+	 * For example, given this patch:
+	 *
+	 * ```php
+	 * array(
+	 *   'default_view' => array( 'search' => 'new search', 'fields' => array( 'newField' ) ),
+	 *   'default_layouts' => array( 'grid' => array( 'layout' => array( 'badgeFields' => array( 'newField' ) ) ) ),
+	 *   'view_list' => array( array( 'slug' => 'table', 'title' => 'New title' ) ),
+	 * )
+	 * ```
+	 *
+	 * - default_view will be updated so the search string is 'new search' and the newField is appended to the list of fields.
+	 * - default_layouts will be updated so that newField is appended to the badgeFields.
+	 * - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'.
+	 *
+	 * A patch that declares an unsupported schema version is rejected and does
+	 * not change anything.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param array $patch   The partial configuration to merge.
+	 * @param int   $version The schema version the patch was authored against.
+	 * @return WP_View_Config_Data The instance, for chaining.
+	 */
+	public function merge( array $patch, int $version ) {
+		return $this->apply( $patch, $version, __METHOD__, 'merge' );
+	}
+
+	/**
+	 * Applies a patch to the configuration, top-level key by top-level key.
+	 *
+	 * Shared by merge(), replace(), and set(); the three differ only in how the
+	 * value of a named key is applied, which is carried by $mode:
+	 *
+	 * - `merge`   merges the value into the current one, lists by member identity;
+	 * - `replace` merges the value in the same way but swaps lists wholesale;
+	 * - `set`     swaps the whole value in wholesale, without merging.
+	 *
+	 * In every mode a top-level `null` resets the key it names to its default, a
+	 * nested `null` drops the property it names, and an omitted key is left
+	 * untouched, so all three treat nulls the same way at every depth.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param array  $patch   The partial configuration to apply.
+	 * @param int    $version The schema version the patch was authored against.
+	 * @param string $method  The public method the patch was passed to, for misuse reporting.
+	 * @param string $mode    How to apply each named key's value: `merge`, `replace`, or `set`.
+	 * @return WP_View_Config_Data The instance, for chaining.
+	 */
+	private function apply( array $patch, int $version, $method, $mode ) {
+		if ( $version <= 0 || $version > self::LATEST_VERSION ) {
+			_doing_it_wrong(
+				esc_html( $method ),
+				esc_html__( 'A view configuration patch must declare a supported schema version.' ),
+				'7.1.0'
+			);
+
+			return $this;
+		}
+
+		foreach ( $patch as $key => $value ) {
+			if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) {
 				_doing_it_wrong(
-					__METHOD__,
-					esc_html__( 'The "view_list" entries are patched by identity. Use update_view_list_items() instead.' ),
+					esc_html( $method ),
+					sprintf(
+						/* translators: %s: the configuration key. */
+						esc_html__( '"%s" is not a documented view configuration key.' ),
+						esc_html( $key )
+					),
 					'7.1.0'
 				);
 				continue;
 			}
-			if ( 'form' === $key ) {
-				$value = $this->extract_form_properties( $value );
-				// Nothing left to merge: the value was off-shape, or held only
-				// the rejected `fields` key.
-				if ( null === $value || array() === $value ) {
-					continue;
-				}
-			}
-			$this->config[ $key ] = $this->deep_merge( $this->config[ $key ] ?? array(), $value );
+
+			// A null patch value makes the top-level property reset to defaults.
+			if ( null === $value ) {
+				$this->config[ $key ] = $this->defaults[ $key ] ?? array();
+				continue;
+			}
+
+			// set() swaps the whole value in; merge()/replace() merge it into the
+			// current one, differing only in how they treat lists. In every mode a
+			// nested null still drops the property it names.
+			$this->config[ $key ] = 'set' === $mode
+				? $this->strip_nulls( $value )
+				: $this->merge_properties( $this->config[ $key ] ?? array(), $value, 'replace' === $mode );
 		}
 
@@ -195,318 +431,251 @@
 
 	/**
-	 * Adds, updates, or removes `view_list` entries, keyed by view `slug`.
-	 *
-	 * Each patch key names the `slug` of the view it targets: a matching view
-	 * merges in place and keeps its position (following the shared rules —
-	 * e.g. the view's `filters`, being numerically indexed, replace
-	 * wholesale), an unknown slug appends a new view to the end, and `null`
-	 * removes the view. The patch key is the identity: a `slug` property
-	 * inside the value is ignored. A `null` for a slug that is not found is a
-	 * silent no-op — the view may have been removed by another callback or
-	 * simply not apply to this entity.
-	 *
-	 * A patch that declares an unsupported schema version is rejected and
-	 * does not merge.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param array $items   The view patches, keyed by slug.
-	 * @param int   $version The schema version the patch was authored against.
-	 * @return WP_View_Config_Data The instance, for chaining.
-	 */
-	public function update_view_list_items( array $items, int $version ) {
-		if ( ! $this->check_version( $version, __METHOD__ ) ) {
-			return $this;
-		}
-
-		if ( empty( $items ) ) {
-			return $this;
-		}
-		if ( array_is_list( $items ) ) {
-			_doing_it_wrong(
-				__METHOD__,
-				esc_html__( 'A view list patch must be keyed by view "slug".' ),
-				'7.1.0'
+	 * Recursively drops every property whose value is `null` from a value.
+	 *
+	 * set() swaps a named key's value in wholesale rather than merging it into
+	 * the current one, so it has no existing leaf for a nested `null` to delete
+	 * the way merge() and replace() do. Stripping nulls here gives a nested
+	 * `null` the same "drop the property it names" meaning under set() that it
+	 * carries everywhere else. The same applies to a list replace() swaps in
+	 * wholesale. A list is renumbered after a member is removed so removed
+	 * entries do not leave gaps.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param mixed $value The value to strip nulls from.
+	 * @return mixed The value with every `null` property removed, recursively.
+	 */
+	private function strip_nulls( $value ) {
+		if ( ! is_array( $value ) ) {
+			return $value;
+		}
+
+		$result = array();
+		foreach ( $value as $key => $item ) {
+			// A null value drops the property it names.
+			if ( null === $item ) {
+				continue;
+			}
+
+			$result[ $key ] = $this->strip_nulls( $item );
+		}
+
+		// Renumber a list so a removed member does not leave a gap.
+		return array_is_list( $value ) ? array_values( $result ) : $result;
+	}
+
+	/**
+	 * Merges an incoming value into the current one, recursing by value shape.
+	 *
+	 * This is the core of the merge algorithm and is applied at every nesting
+	 * level: a scalar (or `null`) in $incoming replaces $current outright, an
+	 * associative array merges key by key (recursing here for each key, with a
+	 * `null` value deleting that key), and a list either replaces $current
+	 * wholesale ($replace_lists) or merges into it by member identity. The
+	 * $replace_lists flag is carried down through associative nesting so that,
+	 * under replace(), every list reached along the way is swapped wholesale.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param mixed $current       The current value.
+	 * @param mixed $incoming      The incoming value.
+	 * @param bool  $replace_lists Whether a list in $incoming replaces the current list
+	 *                             wholesale instead of merging into it by member identity.
+	 * @return mixed The merged value.
+	 */
+	private function merge_properties( $current, $incoming, $replace_lists ) {
+		// Scalar properties are merged as-is.
+		if ( ! is_array( $incoming ) ) {
+			return $incoming;
+		}
+
+		// Numerical indexed arrays are expected to be lists (sequential integer keys starting at 0).
+		if ( array_is_list( $incoming ) ) {
+			// replace() takes an incoming list as-is; merge() merges it by member identity.
+			if ( $replace_lists ) {
+				// As-is except for nulls: a list swapped in wholesale has no
+				// existing leaf for a null to delete (the same rationale as
+				// set()), so a null member is dropped rather than stored.
+				return $this->strip_nulls( $incoming );
+			}
+			return $this->merge_list_by_identity(
+				is_array( $current ) && array_is_list( $current ) ? $current : array(),
+				$incoming
 			);
-			return $this;
-		}
-
-		$view_list = isset( $this->config['view_list'] ) && is_array( $this->config['view_list'] ) ? $this->config['view_list'] : array();
-
-		foreach ( $items as $slug => $value ) {
-			// PHP casts numeric-string array keys to integers; identities are strings.
-			$slug = (string) $slug;
-
-			if ( null === $value ) {
-				$view_list = array_values(
-					array_filter(
-						$view_list,
-						static fn( $item ) => ! is_array( $item ) || ! isset( $item['slug'] ) || $item['slug'] !== $slug
-					)
-				);
-				continue;
-			}
-
-			if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) {
-				_doing_it_wrong(
-					__METHOD__,
-					esc_html__( 'Each view patch must be an associative array of view properties, or null to remove the view.' ),
-					'7.1.0'
-				);
-				continue;
-			}
-
-			// The patch key is the identity.
-			unset( $value['slug'] );
-
-			$index = null;
-			foreach ( $view_list as $i => $item ) {
-				if ( is_array( $item ) && isset( $item['slug'] ) && $item['slug'] === $slug ) {
-					$index = $i;
-					break;
-				}
-			}
-
-			if ( null === $index ) {
-				$view_list[] = array_merge( array( 'slug' => $slug ), $value );
-				continue;
-			}
-			// An empty patch value has nothing to merge (and deep_merge would
-			// treat an empty array as a list, replacing the whole view).
-			if ( array() !== $value ) {
-				$view_list[ $index ] = $this->deep_merge( $view_list[ $index ], $value );
-			}
-		}
-
-		$this->config['view_list'] = array_values( $view_list );
-
-		return $this;
-	}
-
-	/**
-	 * Adds, updates, or removes `form` fields, keyed by field `id`.
-	 *
-	 * Each patch key names the `id` of the field it targets, and the field is
-	 * found wherever it lives — at the top level or nested inside a group's
-	 * `children`. Fields are visited in document order and a group is checked
-	 * before its own children, so when an id appears at both levels the group
-	 * wins. A matching field merges in place, an unknown id appends a new field
-	 * to the end of the top-level fields, and `null` removes the field. The
-	 * patch key is the identity: an `id` property inside the value is ignored.
-	 * A `null` for an id that is not found is a silent no-op — the field may
-	 * have been removed by another callback or simply not apply to this
-	 * entity.
-	 *
-	 * Inside a field patch, `children` follows the shared rules: an associative
-	 * array merges into the group's children by id (appending unknown ones), a
-	 * numerically indexed array replaces the children wholesale, and `null`
-	 * deletes the key.
-	 *
-	 * A patch that declares an unsupported schema version is rejected and
-	 * does not merge.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param array $fields  The field patches, keyed by field id.
-	 * @param int   $version The schema version the patch was authored against.
-	 * @return WP_View_Config_Data The instance, for chaining.
-	 */
-	public function update_form_fields( array $fields, int $version ) {
-		if ( ! $this->check_version( $version, __METHOD__ ) ) {
-			return $this;
-		}
-
-		if ( empty( $fields ) ) {
-			return $this;
-		}
-		if ( array_is_list( $fields ) ) {
-			_doing_it_wrong(
-				__METHOD__,
-				esc_html__( 'A fields patch must be keyed by field "id".' ),
-				'7.1.0'
-			);
-			return $this;
-		}
-
-		if ( ! isset( $this->config['form'] ) || ! is_array( $this->config['form'] ) ) {
-			$this->config['form'] = array();
-		}
-		$current = isset( $this->config['form']['fields'] ) && is_array( $this->config['form']['fields'] ) ? $this->config['form']['fields'] : array();
-
-		$this->config['form']['fields'] = $this->merge_fields_by_identity( $current, $fields );
-
-		return $this;
-	}
-
-	/**
-	 * Validates a declared patch version, reporting misuse against the given
-	 * public method.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param int    $version The declared version.
-	 * @param string $method  The public method the patch was passed to.
-	 * @return bool Whether the declared version is a supported schema version.
-	 */
-	private function check_version( int $version, $method ) {
-		if ( $version >= 1 && $version <= self::LATEST_VERSION ) {
-			return true;
-		}
-
-		_doing_it_wrong(
-			esc_html( $method ),
-			esc_html__( 'A view configuration contribution must declare a supported schema version.' ),
-			'7.1.0'
-		);
-
-		return false;
-	}
-
-	/**
-	 * Validates a `form` patch value for update_properties() and strips the
-	 * `fields` key, which is managed by update_form_fields().
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param mixed $value The incoming `form` patch value.
-	 * @return array|null The form properties to merge, or null when the value
-	 *                    is off-shape.
-	 */
-	private function extract_form_properties( $value ) {
-		if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) {
-			_doing_it_wrong(
-				'WP_View_Config_Data::update_properties',
-				esc_html__( 'A "form" patch must be an associative array of form properties.' ),
-				'7.1.0'
-			);
-			return null;
-		}
-		if ( array_key_exists( 'fields', $value ) ) {
-			_doing_it_wrong(
-				'WP_View_Config_Data::update_properties',
-				esc_html__( 'The form "fields" are patched by identity. Use update_form_fields() instead.' ),
-				'7.1.0'
-			);
-			unset( $value['fields'] );
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Recursively merges two values.
-	 *
-	 * Associative arrays (maps) merge key by key and a null patch value deletes
-	 * the key; lists and scalars are replaced wholesale by the incoming value,
-	 * since lists without a defined identity cannot be merged member by member.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param mixed $current  The current value.
-	 * @param mixed $incoming The incoming value.
-	 * @return mixed The merged value.
-	 */
-	private function deep_merge( $current, $incoming ) {
-		// An empty array counts as a list, so patching with array() empties
-		// the key (e.g. 'filters' => array() clears the filters) rather than
-		// being a no-op map merge.
-		if ( ! is_array( $incoming ) || array_is_list( $incoming ) ) {
-			return $incoming;
-		}
-
-		// Merge onto the current map, or onto an empty base when the current
-		// value is absent, empty, or not a map, so null delete-markers in the
-		// patch are consumed rather than stored as literal values (e.g.
-		// array( 'layout' => null ) merged into an empty layouts entry yields
-		// array(), not array( 'layout' => null )).
+		}
+
+		// Consider any other array as associative (keys are strings).
 		$result = is_array( $current ) && ! array_is_list( $current ) ? $current : array();
 		foreach ( $incoming as $key => $value ) {
+			// A null patch value deletes the property.
 			if ( null === $value ) {
-				// A null patch value deletes the key.
 				unset( $result[ $key ] );
 				continue;
 			}
-			$result[ $key ] = $this->deep_merge(
+
+			$result[ $key ] = $this->merge_properties(
 				array_key_exists( $key, $result ) ? $result[ $key ] : array(),
-				$value
+				$value,
+				$replace_lists
 			);
 		}
+
 		return $result;
 	}
 
 	/**
-	 * Merges a map of field patches into a field list by identity.
-	 *
-	 * Shared by the top-level `form` fields and a group's `children`: a `null`
-	 * value removes the matching field (recursing into children), a map value
-	 * merges into the matching field wherever it lives, and an unknown id
-	 * appends a new field to the end of this list. A `null` for an id that is
-	 * not found is a silent no-op.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param array $current The current list of fields.
-	 * @param array $patches The field patches, keyed by field id.
-	 * @return array The merged list of fields.
-	 */
-	private function merge_fields_by_identity( array $current, array $patches ) {
-		foreach ( $patches as $id => $value ) {
-			// PHP casts numeric-string array keys to integers; identities are strings.
-			$id = (string) $id;
-
-			if ( null === $value ) {
-				$current = $this->reject_fields( $current, array( $id ) );
+	 * Removes the properties a spec names from the current value.
+	 *
+	 * The mirror of merge_properties(), applied at every nesting level: a list in
+	 * $spec names entries to delete from $current — associative keys are unset,
+	 * and list members are matched by identity (list_item_identity) and dropped —
+	 * while an associative $spec recurses into each named entry to prune from
+	 * within it. A name absent from $current is ignored, and a list is renumbered
+	 * after members are removed so it keeps sequential keys.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param mixed $current The current value.
+	 * @param mixed $spec    The names to remove from it.
+	 * @return mixed The pruned value.
+	 */
+	private function remove_properties( $current, $spec ) {
+		if ( ! is_array( $current ) || ! is_array( $spec ) ) {
+			return $current;
+		}
+
+		$current_is_list = array_is_list( $current );
+
+		if ( array_is_list( $spec ) ) {
+			// Each entry names something to delete from the current value.
+			foreach ( $spec as $name ) {
+				if ( $current_is_list ) {
+					$current = $this->remove_list_member( $current, $name );
+				} else {
+					unset( $current[ $name ] );
+				}
+			}
+		} else {
+			// Each key names an entry to recurse into and prune from within.
+			foreach ( $spec as $name => $subspec ) {
+				if ( $current_is_list ) {
+					foreach ( $current as $index => $member ) {
+						if ( $this->list_item_identity( $member ) === (string) $name ) {
+							$current[ $index ] = $this->remove_properties( $member, $subspec );
+							break;
+						}
+					}
+				} elseif ( array_key_exists( $name, $current ) ) {
+					$current[ $name ] = $this->remove_properties( $current[ $name ], $subspec );
+				}
+			}
+		}
+
+		// Renumber so a list from which a member was removed keeps sequential keys.
+		return $current_is_list ? array_values( $current ) : $current;
+	}
+
+	/**
+	 * Removes the first list member matching an identity, leaving the rest.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param array $members  The current list.
+	 * @param mixed $identity The identity of the member to remove.
+	 * @return array The list with the matching member removed, if any.
+	 */
+	private function remove_list_member( array $members, $identity ) {
+		foreach ( $members as $index => $member ) {
+			if ( $this->list_item_identity( $member ) === (string) $identity ) {
+				unset( $members[ $index ] );
+				break;
+			}
+		}
+
+		return $members;
+	}
+
+	/**
+	 * Merges an incoming list into the current one by member identity.
+	 *
+	 * A member of the incoming list whose identity matches one already present
+	 * merges into it in place, keeping its position; an unmatched member is
+	 * appended to the end, except a literal `null`, which carries no identity
+	 * and holds nothing to merge and so is dropped. A matched member's contents
+	 * merge recursively with the same rules (merge_properties), so the
+	 * identity-aware merge applies at
+	 * any nesting level: each key named by the patch is substituted while the
+	 * others are left intact, and a list nested inside a member merges by
+	 * identity just like the list it lives in.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param array $current  The current list.
+	 * @param array $incoming The incoming list.
+	 * @return array The merged list.
+	 */
+	private function merge_list_by_identity( array $current, array $incoming ) {
+		$result = $current;
+		foreach ( $incoming as $item ) {
+			// A null member carries no identity and holds nothing to merge,
+			// so it is dropped rather than appended as a literal null.
+			if ( null === $item ) {
 				continue;
 			}
-			if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) {
-				_doing_it_wrong(
-					'WP_View_Config_Data::update_form_fields',
-					esc_html__( 'Each field patch must be an associative array of field properties, or null to remove the field.' ),
-					'7.1.0'
-				);
+
+			$identity = $this->list_item_identity( $item );
+
+			// Find the index of the existing member with the same identity, if any.
+			// If there's none, append the incoming member to the end of the list.
+			$index = null;
+			if ( null !== $identity ) {
+				foreach ( $result as $i => $existing ) {
+					if ( $this->list_item_identity( $existing ) === $identity ) {
+						$index = $i;
+						break;
+					}
+				}
+			}
+			if ( null === $index ) {
+				$result[] = $item;
 				continue;
 			}
 
-			// The patch key is the identity.
-			unset( $value['id'] );
-
-			$merged = $this->merge_field_in_tree( $current, $id, $value );
-			if ( null !== $merged ) {
-				$current = $merged;
-				continue;
-			}
-			// An unknown id appends: as a bare string reference when the patch
-			// carries no overrides, as an array otherwise.
-			$current[] = array() === $value ? $id : $this->merge_field_item( $id, $id, $value );
-		}
-
-		return $current;
-	}
-
-	/**
-	 * Merges a field patch into the field carrying the given identity, wherever
-	 * it lives in the tree.
-	 *
-	 * Fields are visited in document order and a group is checked before its
-	 * own children, so when an id appears at both levels the group wins.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param array  $fields The list of fields to search.
-	 * @param string $id     The identity of the field to patch.
-	 * @param array  $value  The field patch.
-	 * @return array|null The updated list, or null when the id was not found.
-	 */
-	private function merge_field_in_tree( array $fields, $id, array $value ) {
-		foreach ( $fields as $index => $field ) {
-			if ( $this->field_identity( $field ) === $id ) {
-				$fields[ $index ] = $this->merge_field_item( $field, $id, $value );
-				return $fields;
-			}
-			if ( is_array( $field ) && isset( $field['children'] ) && is_array( $field['children'] ) ) {
-				$children = $this->merge_field_in_tree( $field['children'], $id, $value );
-				if ( null !== $children ) {
-					$fields[ $index ]['children'] = $children;
-					return $fields;
+			// Otherwise, merge the incoming member into the existing one in place.
+			$result[ $index ] = $this->merge_properties( $result[ $index ], $item, false );
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Resolves the identity used to match a list member against another.
+	 *
+	 * The identity is simply the member's value cast to a string, regardless of
+	 * which key carries it: a bare scalar is its own identity, and a map is
+	 * identified by the value of the first of the well-known identity keys
+	 * (`id`, `slug`, `field`) it carries. Because the key is not part of
+	 * the identity, a bare field like `'f3'` matches any map carrying that
+	 * value, whether it appears as `array( 'id' => 'f3' )`,
+	 * `array( 'slug' => 'f3' )`, and so on — this lets the same shorthand target
+	 * lists keyed by different fields. Casting to string keeps numeric
+	 * identities matching whether they arrive as an int or a string. Anything
+	 * else (e.g. a nested list) has no identity and never matches, so it is
+	 * always appended.
+	 *
+	 * @since 7.1.0
+	 *
+	 * @param mixed $item The list member.
+	 * @return string|null The identity, or null when the member has none.
+	 */
+	private function list_item_identity( $item ) {
+		if ( is_scalar( $item ) ) {
+			return (string) $item;
+		}
+
+		if ( is_array( $item ) && ! array_is_list( $item ) ) {
+			foreach ( array( 'id', 'slug', 'field' ) as $key ) {
+				if ( isset( $item[ $key ] ) && is_scalar( $item[ $key ] ) ) {
+					return (string) $item[ $key ];
 				}
 			}
@@ -515,111 +684,3 @@
 		return null;
 	}
-
-	/**
-	 * Merges a field patch into an existing field.
-	 *
-	 * A bare string reference is promoted to an array so the overrides apply.
-	 * The `children` key follows the same rules — a map merges into the
-	 * group's children by id, a list replaces them wholesale, and `null`
-	 * deletes the key — and every other key merges via deep_merge().
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param array|string $existing The existing field.
-	 * @param string       $id       The field identity.
-	 * @param array        $value    The field patch.
-	 * @return array|string The merged field.
-	 */
-	private function merge_field_item( $existing, $id, array $value ) {
-		if ( ! is_array( $existing ) ) {
-			// Nothing to apply: keep the bare string reference.
-			if ( array() === $value ) {
-				return $existing;
-			}
-			// Promote the reference so the incoming overrides apply.
-			$existing = array( 'id' => $id );
-		}
-
-		foreach ( $value as $key => $item ) {
-			if ( 'children' === $key ) {
-				if ( null === $item ) {
-					unset( $existing['children'] );
-					continue;
-				}
-				if ( ! is_array( $item ) ) {
-					_doing_it_wrong(
-						'WP_View_Config_Data::update_form_fields',
-						esc_html__( 'A "children" patch must be an associative array keyed by field id to merge, a numerically indexed array to replace the children wholesale, or null to delete the key.' ),
-						'7.1.0'
-					);
-					continue;
-				}
-				// A list replaces the children wholesale (an empty array counts
-				// as a list, clearing them)...
-				if ( array_is_list( $item ) ) {
-					$existing['children'] = $item;
-					continue;
-				}
-				// ...and a map merges into them by identity.
-				$children             = isset( $existing['children'] ) && is_array( $existing['children'] ) ? $existing['children'] : array();
-				$existing['children'] = $this->merge_fields_by_identity( $children, $item );
-				continue;
-			}
-			if ( null === $item ) {
-				// A null patch value deletes the key.
-				unset( $existing[ $key ] );
-				continue;
-			}
-			$existing[ $key ] = $this->deep_merge(
-				array_key_exists( $key, $existing ) ? $existing[ $key ] : array(),
-				$item
-			);
-		}
-
-		return $existing;
-	}
-
-	/**
-	 * Returns a field list with the fields matching the given identities removed,
-	 * recursing into group children.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param array    $fields The list of fields.
-	 * @param string[] $ids    The identities of the fields to remove.
-	 * @return array The list with the matching fields removed.
-	 */
-	private function reject_fields( array $fields, array $ids ) {
-		$result = array();
-		foreach ( $fields as $field ) {
-			if ( in_array( $this->field_identity( $field ), $ids, true ) ) {
-				continue;
-			}
-			if ( is_array( $field ) && isset( $field['children'] ) && is_array( $field['children'] ) ) {
-				$field['children'] = $this->reject_fields( $field['children'], $ids );
-			}
-			$result[] = $field;
-		}
-		return $result;
-	}
-
-	/**
-	 * Resolves the identity of a form field.
-	 *
-	 * A bare string is its own identity; an object is identified by its `id`.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param mixed $field The field.
-	 * @return string|null The identity, or null if it cannot be resolved.
-	 */
-	private function field_identity( $field ) {
-		if ( is_string( $field ) ) {
-			return $field;
-		}
-		if ( is_array( $field ) && isset( $field['id'] ) && is_string( $field['id'] ) ) {
-			return $field['id'];
-		}
-		return null;
-	}
 }
Index: /trunk/src/wp-includes/view-config.php
===================================================================
--- /trunk/src/wp-includes/view-config.php	(revision 62824)
+++ /trunk/src/wp-includes/view-config.php	(revision 62825)
@@ -154,56 +154,5 @@
 	$data = new WP_View_Config_Data( $config );
 
-	/**
-	 * Filters the view configuration for a given entity.
-	 *
-	 * The dynamic portions of the hook name, `$kind` and `$name`, refer to the
-	 * entity kind (e.g. `postType`) and the entity name (e.g. `page`).
-	 *
-	 * Callbacks receive a WP_View_Config_Data object and change the
-	 * configuration through its methods: the `update_*()` methods merge
-	 * partial changes into the current configuration, while `set()` replaces
-	 * a whole top-level key. Callbacks must return the object they were
-	 * given.
-	 *
-	 * @since 7.1.0
-	 *
-	 * @param WP_View_Config_Data $data   The view configuration container
-	 *                                    for the entity, exposing the
-	 *                                    `default_view`, `default_layouts`,
-	 *                                    `view_list`, and `form` keys.
-	 * @param array               $entity {
-	 *     The entity the configuration is built for.
-	 *
-	 *     @type string $kind The entity kind.
-	 *     @type string $name The entity name.
-	 * }
-	 */
-	$filtered = apply_filters(
-		"get_entity_view_config_{$kind}_{$name}",
-		$data,
-		array(
-			'kind' => $kind,
-			'name' => $name,
-		)
-	);
-
-	// A well-behaved callback returns the object it was given. Fall back to the
-	// unfiltered config if a callback replaced it with something else.
-	if ( ! $filtered instanceof WP_View_Config_Data ) {
-		_doing_it_wrong(
-			__FUNCTION__,
-			sprintf(
-				/* translators: %s: the filter hook name. */
-				esc_html__( 'A "%s" filter callback must return the WP_View_Config_Data object it was given.' ),
-				esc_html( "get_entity_view_config_{$kind}_{$name}" )
-			),
-			'7.1.0'
-		);
-		return $config;
-	}
-
-	// Backfill any dropped keys with their defaults, then discard any keys the
-	// filter introduced that are not part of the documented configuration shape.
-	return array_intersect_key( array_merge( $config, $filtered->get_config() ), $config );
+	return $data->apply_filters( $kind, $name );
 }
 
@@ -334,9 +283,14 @@
 	);
 
-	$data->set( 'default_layouts', $default_layouts, 1 );
-	$data->set( 'default_view', $default_view, 1 );
+	$data->set(
+		array(
+			'default_view'    => $default_view,
+			'default_layouts' => $default_layouts,
+		),
+		1
+	);
 	// Append the status views, thereby preserving the base "all items" view,
 	// so its post-type-specific title is kept.
-	$data->update_view_list_items( array_column( $view_list, null, 'slug' ), 1 );
+	$data->merge( array( 'view_list' => $view_list ), 1 );
 
 	return $data;
@@ -379,7 +333,4 @@
 	);
 
-	$data->set( 'default_layouts', $default_layouts, 1 );
-	$data->set( 'default_view', $default_view, 1 );
-
 	$view_list = array(
 		array(
@@ -429,28 +380,32 @@
 	}
 
-	$data->set( 'view_list', $view_list, 1 );
+	$form = array(
+		'layout' => array( 'type' => 'panel' ),
+		'fields' => array(
+			array(
+				'id'     => 'excerpt',
+				'layout' => array(
+					'type'          => 'panel',
+					'labelPosition' => 'top',
+				),
+			),
+			array(
+				'id'     => 'post-content-info',
+				'layout' => array(
+					'type'          => 'regular',
+					'labelPosition' => 'none',
+				),
+			),
+			'sync-status',
+			'revisions',
+		),
+	);
 
 	$data->set(
-		'form',
-		array(
-			'layout' => array( 'type' => 'panel' ),
-			'fields' => array(
-				array(
-					'id'     => 'excerpt',
-					'layout' => array(
-						'type'          => 'panel',
-						'labelPosition' => 'top',
-					),
-				),
-				array(
-					'id'     => 'post-content-info',
-					'layout' => array(
-						'type'          => 'regular',
-						'labelPosition' => 'none',
-					),
-				),
-				'sync-status',
-				'revisions',
-			),
+		array(
+			'default_view'    => $default_view,
+			'default_layouts' => $default_layouts,
+			'view_list'       => $view_list,
+			'form'            => $form,
 		),
 		1
@@ -493,7 +448,4 @@
 		'layout'     => $default_layouts['grid']['layout'],
 	);
-
-	$data->set( 'default_layouts', $default_layouts, 1 );
-	$data->set( 'default_view', $default_view, 1 );
 
 	$view_list = array(
@@ -538,20 +490,24 @@
 	}
 
-	$data->set( 'view_list', $view_list, 1 );
+	$form = array(
+		'layout' => array( 'type' => 'panel' ),
+		'fields' => array(
+			array(
+				'id'     => 'last_edited_date',
+				'layout' => array(
+					'type'          => 'panel',
+					'labelPosition' => 'none',
+				),
+			),
+			'revisions',
+		),
+	);
 
 	$data->set(
-		'form',
-		array(
-			'layout' => array( 'type' => 'panel' ),
-			'fields' => array(
-				array(
-					'id'     => 'last_edited_date',
-					'layout' => array(
-						'type'          => 'panel',
-						'labelPosition' => 'none',
-					),
-				),
-				'revisions',
-			),
+		array(
+			'default_view'    => $default_view,
+			'default_layouts' => $default_layouts,
+			'view_list'       => $view_list,
+			'form'            => $form,
 		),
 		1
@@ -590,7 +546,4 @@
 		'list'  => array( 'showMedia' => false ),
 	);
-
-	$data->set( 'default_view', $default_view, 1 );
-	$data->set( 'default_layouts', $default_layouts, 1 );
 
 	$view_list = array(
@@ -687,5 +640,9 @@
 					$plugins         = get_plugins();
 					$plugin_basename = plugin_basename( sanitize_text_field( $template->theme . '.php' ) );
-					$plugin_name     = $plugins[ $plugin_basename ]['Name'] ?? $template->plugin ?? $template->theme;
+					if ( isset( $plugins[ $plugin_basename ] ) && isset( $plugins[ $plugin_basename ]['Name'] ) ) {
+						$plugin_name = $plugins[ $plugin_basename ]['Name'];
+					} else {
+						$plugin_name = $template->plugin ?? $template->theme;
+					}
 				}
 				$author_text = $plugin_name;
@@ -728,41 +685,45 @@
 	}
 
-	$data->set( 'view_list', array_merge( $view_list, $registered_authors, $user_authors ), 1 );
+	$form = array(
+		'layout' => array( 'type' => 'panel' ),
+		'fields' => array(
+			array(
+				'id'     => 'description',
+				'layout' => array(
+					'type'          => 'panel',
+					'labelPosition' => 'top',
+				),
+			),
+			array(
+				'id'     => 'description_readonly',
+				'layout' => array(
+					'type'          => 'regular',
+					'labelPosition' => 'none',
+				),
+			),
+			array(
+				'id'     => 'last_edited_date',
+				'layout' => array(
+					'type'          => 'panel',
+					'labelPosition' => 'none',
+				),
+			),
+			'revisions',
+			// The following fields are only meaningful in the `home`/`index`
+			// template summary. They edit other entities (`root/site` and the
+			// posts page); the editor merges those records into the form data
+			// under a namespace and controls when the fields are shown.
+			'posts_page_title',
+			'posts_per_page',
+			'default_comment_status',
+		),
+	);
 
 	$data->set(
-		'form',
-		array(
-			'layout' => array( 'type' => 'panel' ),
-			'fields' => array(
-				array(
-					'id'     => 'description',
-					'layout' => array(
-						'type'          => 'panel',
-						'labelPosition' => 'top',
-					),
-				),
-				array(
-					'id'     => 'description_readonly',
-					'layout' => array(
-						'type'          => 'regular',
-						'labelPosition' => 'none',
-					),
-				),
-				array(
-					'id'     => 'last_edited_date',
-					'layout' => array(
-						'type'          => 'panel',
-						'labelPosition' => 'none',
-					),
-				),
-				'revisions',
-				// The following fields are only meaningful in the `home`/`index`
-				// template summary. They edit other entities (`root/site` and the
-				// posts page); the editor merges those records into the form data
-				// under a namespace and controls when the fields are shown.
-				'posts_page_title',
-				'posts_per_page',
-				'default_comment_status',
-			),
+		array(
+			'default_view'    => $default_view,
+			'default_layouts' => $default_layouts,
+			'view_list'       => array_merge( $view_list, $registered_authors, $user_authors ),
+			'form'            => $form,
 		),
 		1
Index: /trunk/tests/phpunit/tests/rest-api/rest-view-config-controller.php
===================================================================
--- /trunk/tests/phpunit/tests/rest-api/rest-view-config-controller.php	(revision 62824)
+++ /trunk/tests/phpunit/tests/rest-api/rest-view-config-controller.php	(revision 62825)
@@ -342,12 +342,15 @@
 
 		$filter = static function ( $data ) {
-			return $data->update_view_list_items(
+			return $data->merge(
 				array(
-					'custom' => array(
-						'title' => 'Custom',
-						'view'  => array(
-							'type'   => 'table',
-							'layout' => array(
-								'styles' => array(),
+					'view_list' => array(
+						array(
+							'slug'  => 'custom',
+							'title' => 'Custom',
+							'view'  => array(
+								'type'   => 'table',
+								'layout' => array(
+									'styles' => array(),
+								),
 							),
 						),
Index: /trunk/tests/phpunit/tests/view-config-data.php
===================================================================
--- /trunk/tests/phpunit/tests/view-config-data.php	(revision 62824)
+++ /trunk/tests/phpunit/tests/view-config-data.php	(revision 62825)
@@ -12,48 +12,231 @@
 
 	/**
-	 * set() replaces a whole documented key.
+	 * Reads the materialized configuration out of a container for assertions.
+	 *
+	 * The container keeps `get_data()` private so filter callbacks cannot read
+	 * the built result; the tests reach it through reflection instead.
+	 *
+	 * @param WP_View_Config_Data $data The container to read from.
+	 * @return array The materialized configuration.
+	 */
+	private static function read_config( WP_View_Config_Data $data ) {
+		$property = new ReflectionProperty( 'WP_View_Config_Data', 'config' );
+		if ( PHP_VERSION_ID < 80100 ) {
+			$property->setAccessible( true );
+		}
+
+		return $property->getValue( $data );
+	}
+
+	/**
+	 * set() replaces the whole value of each top-level key it names, dropping
+	 * whatever that key held before instead of merging into it, while a key the
+	 * patch omits (`form`) is left untouched. This is where it diverges from
+	 * replace(), which would keep the untouched props under `default_view`.
 	 *
 	 * @covers ::set
 	 */
-	public function test_set_replaces_key() {
-		$data = new WP_View_Config_Data(
+	public function test_set_replaces_named_keys_and_leaves_the_rest() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'fields'     => array( 'f1', 'f2' ),
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			)
+		);
+		$data->set(
+			array(
+				'default_view' => array(
+					'type' => 'table',
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'type' => 'table',
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * set() resets a top-level key to its default when the patch value is null,
+	 * leaving the keys it does not name in place.
+	 *
+	 * @covers ::set
+	 */
+	public function test_set_null_resets_top_level_key_to_defaults() {
+		$defaults = array(
+			'default_view' => array( 'type' => 'table' ),
+			'form'         => array( 'layout' => array( 'type' => 'panel' ) ),
+		);
+		$data     = new WP_View_Config_Data( $defaults );
+		$data->set(
+			array(
+				'default_view' => array(
+					'type' => 'grid',
+				),
+			),
+			1
+		);
+		$data->set(
+			array(
+				'default_view' => null,
+			),
+			1
+		);
+
+		$this->assertSame(
+			$defaults,
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * set() drops a property whose value in the patch is null.
+	 *
+	 * @covers ::set
+	 */
+	public function test_set_null_unsets_key() {
+		$defaults = array(
+			'default_view' => array(
+				'type'    => 'table',
+				'perPage' => 20,
+			),
+			'form'         => array( 'layout' => array( 'type' => 'panel' ) ),
+		);
+		$data     = new WP_View_Config_Data( $defaults );
+		$data->set(
+			array(
+				'default_view' => array(
+					'type'    => 'grid',
+					'perPage' => null,
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array( 'type' => 'grid' ),
+				'form'         => array( 'layout' => array( 'type' => 'panel' ) ),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * set() rejects an undocumented top-level key and leaves the configuration
+	 * untouched.
+	 *
+	 * @covers ::set
+	 */
+	public function test_set_rejects_unknown_key() {
+		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::set' );
+
+		$data   = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
+		$before = self::read_config( $data );
+		$data->set( array( 'not_a_real_key' => 'nope' ), 1 );
+
+		$this->assertSame( $before, self::read_config( $data ) );
+	}
+
+	/**
+	 * set() rejects a patch with an unsupported version and leaves the
+	 * configuration untouched.
+	 *
+	 * @covers ::set
+	 */
+	public function test_set_rejects_updates_with_invalid_version() {
+		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::set' );
+
+		$data   = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
+		$before = self::read_config( $data );
+
+		$version = WP_View_Config_Data::LATEST_VERSION + 1;
+		$data->set( array( 'default_view' => array( 'type' => 'grid' ) ), $version );
+
+		$this->assertSame( $before, self::read_config( $data ) );
+	}
+
+	/**
+	 * remove() with a bare top-level key resets that key to its default — just
+	 * like a `null` value does — rather than dropping it, while a key the spec
+	 * omits (`form`) is left untouched.
+	 *
+	 * @covers ::remove
+	 */
+	public function test_remove_top_level_key_resets_to_defaults() {
+		$defaults = array(
+			'default_view' => array(
+				'type'       => 'table',
+				'perPage'    => 23,
+				'showLevels' => true,
+				'fields'     => array( 'f1', 'f2' ),
+				'sort'       => array(
+					'field'     => 'title',
+					'direction' => 'asc',
+				),
+			),
+			'form'         => array(
+				'fields' => array( 'f1', 'f2' ),
+			),
+		);
+		$data     = new WP_View_Config_Data( $defaults );
+		// Mutate the key, then remove it: removal restores its default.
+		$data->merge( array( 'default_view' => array( 'type' => 'grid' ) ), 1 );
+		$data->remove( array( 'default_view' ), 1 );
+
+		$this->assertSame( $defaults, self::read_config( $data ) );
+	}
+
+	/**
+	 * remove() deletes a named scalar property from within a top-level key.
+	 *
+	 * @covers ::remove
+	 */
+	public function test_remove_deletes_scalar_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'fields'     => array( 'f1', 'f2' ),
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			)
+		);
+		$data->remove( array( 'default_view' => array( 'showLevels' ) ), 1 );
+
+		$this->assertSame(
 			array(
 				'default_view' => array(
 					'type'    => 'table',
-					'perPage' => 20,
-				),
-			)
-		);
-		$data->set( 'default_view', array( 'type' => 'grid' ), 1 );
-
-		$this->assertSame( array( 'type' => 'grid' ), $data->get_config()['default_view'] );
-	}
-
-	/**
-	 * set() rejects an undocumented key.
-	 *
-	 * @covers ::set
-	 */
-	public function test_set_unknown_key_triggers_doing_it_wrong() {
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::set' );
-
-		$data   = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
-		$before = $data->get_config();
-		$data->set( 'not_a_real_key', 'nope', 1 );
-
-		$this->assertSame( $before, $data->get_config() );
-	}
-
-	/**
-	 * update_properties() merges object-shaped keys recursively.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_merges_default_view_recursively() {
-		$data = new WP_View_Config_Data(
-			array(
-				'default_view' => array(
-					'type'    => 'table',
-					'perPage' => 20,
+					'perPage' => 23,
+					'fields'  => array( 'f1', 'f2' ),
 					'sort'    => array(
 						'field'     => 'title',
@@ -61,116 +244,541 @@
 					),
 				),
-			)
-		);
-		$data->update_properties(
-			array(
-				'default_view' => array(
-					'perPage' => 50,
-					'sort'    => array( 'direction' => 'desc' ),
-				),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				'type'    => 'table',
-				'perPage' => 50,
-				'sort'    => array(
-					'field'     => 'title',
-					'direction' => 'desc',
-				),
-			),
-			$data->get_config()['default_view']
-		);
-	}
-
-	/**
-	 * update_properties() merges default_layouts by map key and adds unknown ones.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_merges_default_layouts_by_key() {
-		$data = new WP_View_Config_Data(
-			array(
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * remove() deletes a named associative-array property from within a
+	 * top-level key.
+	 *
+	 * @covers ::remove
+	 */
+	public function test_remove_deletes_associative_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'fields'     => array( 'f1', 'f2' ),
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			)
+		);
+		$data->remove( array( 'default_view' => array( 'sort' ) ), 1 );
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'fields'     => array( 'f1', 'f2' ),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * remove() deletes a named list property from within a top-level key.
+	 *
+	 * @covers ::remove
+	 */
+	public function test_remove_deletes_indexed_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'fields'     => array( 'f1', 'f2' ),
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			)
+		);
+		$data->remove( array( 'default_view' => array( 'fields' ) ), 1 );
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * remove() deletes a single member from a list property and renumbers
+	 * the list.
+	 *
+	 * @covers ::remove
+	 */
+	public function test_remove_deletes_items_in_indexed_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'fields'     => array( 'f1', 'f2', 'f3' ),
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			)
+		);
+		$data->remove( array( 'default_view' => array( 'fields' => array( 'f2' ) ) ), 1 );
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'fields'     => array( 'f1', 'f3' ),
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() merges scalar and associative properties within a documented
+	 * key just like merge() does — the untouched `fields` and `sort` under
+	 * default_view survive; only the keys the patch names change.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_scalar_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 23,
+					'showLevels' => true,
+					'fields'     => array( 'f1', 'f2' ),
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'default_view' => array(
+					'type'       => 'grid',
+					'perPage'    => 50,
+					'showLevels' => false,
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'type'       => 'grid',
+					'perPage'    => 50,
+					'showLevels' => false,
+					'fields'     => array( 'f1', 'f2' ),
+					'sort'       => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'form'         => array(
+					'fields' => array( 'f1', 'f2' ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() rejects an undocumented top-level key and leaves the
+	 * configuration untouched.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_rejects_unknown_key() {
+		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::replace' );
+
+		$data   = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
+		$before = self::read_config( $data );
+		$data->replace( array( 'not_a_real_key' => 'nope' ), 1 );
+
+		$this->assertSame( $before, self::read_config( $data ) );
+	}
+
+	/**
+	 * replace() rejects a patch with an unsupported version and leaves the
+	 * configuration untouched.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_rejects_updates_with_invalid_version() {
+		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::replace' );
+
+		$data   = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
+		$before = self::read_config( $data );
+
+		$version = WP_View_Config_Data::LATEST_VERSION + 1;
+		$data->replace( array( 'default_view' => array( 'type' => 'grid' ) ), $version );
+
+		$this->assertSame( $before, self::read_config( $data ) );
+	}
+
+	/**
+	 * replace() updates object property values. With no lists involved it
+	 * behaves exactly like merge(): associative arrays merge key by key.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_associative_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view'    => array(
+					'sort' => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
 				'default_layouts' => array(
-					'table' => array(),
-					'grid'  => array(),
-				),
-			)
-		);
-		$data->update_properties(
-			array(
+					'table' => array(
+						'layout' => array(
+							'styles'       => array(
+								'width' => 1,
+							),
+							'density'      => 'd2',
+							'enableMoving' => true,
+						),
+					),
+				),
+				'form'            => array(
+					'layout' => array(
+						'type'          => 'panel',
+						'labelPosition' => 'top',
+						'openAs'        => array(
+							'type'       => 'modal',
+							'applyLabel' => 'Apply',
+						),
+					),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'default_view'    => array(
+					'sort' => array(
+						'direction' => 'desc',
+					),
+				),
 				'default_layouts' => array(
-					'table'    => array( 'density' => 'compact' ),
-					'activity' => array(),
-				),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				'table'    => array( 'density' => 'compact' ),
-				'grid'     => array(),
-				'activity' => array(),
-			),
-			$data->get_config()['default_layouts']
-		);
-	}
-
-	/**
-	 * update_properties() merges the form's plain properties and leaves its
-	 * fields untouched.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_merges_form_layout() {
-		$data = new WP_View_Config_Data(
-			array(
-				'form' => array(
-					'layout' => array( 'type' => 'panel' ),
-					'fields' => array( 'date', 'slug' ),
-				),
-			)
-		);
-		$data->update_properties(
-			array( 'form' => array( 'layout' => array( 'type' => 'card' ) ) ),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				'layout' => array( 'type' => 'card' ),
-				'fields' => array( 'date', 'slug' ),
-			),
-			$data->get_config()['form']
-		);
-	}
-
-	/**
-	 * update_properties() merges a documented key that is absent from the config.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_merges_a_documented_key_absent_from_config() {
-		$data = new WP_View_Config_Data( array( 'default_view' => array() ) );
-		$data->update_properties(
-			array( 'default_layouts' => array( 'table' => array( 'density' => 'compact' ) ) ),
-			1
-		);
-
-		$this->assertSame(
-			array( 'table' => array( 'density' => 'compact' ) ),
-			$data->get_config()['default_layouts']
-		);
-	}
-
-	/**
-	 * update_properties() unsets a property when the patch value is null.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_null_unsets_property() {
+					'table' => array(
+						'layout' => array(
+							'styles'  => array(
+								'minWidth' => 2,
+							),
+							'density' => 'd2',
+						),
+					),
+				),
+				'form'            => array(
+					'layout' => array(
+						'type'          => 'panel',
+						'labelPosition' => 'side',
+						'openAs'        => array(
+							'type' => 'drawer',
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view'    => array(
+					'sort' => array(
+						'field'     => 'title',
+						'direction' => 'desc',
+					),
+				),
+				'default_layouts' => array(
+					'table' => array(
+						'layout' => array(
+							'styles'       => array(
+								'width'    => 1,
+								'minWidth' => 2,
+							),
+							'density'      => 'd2',
+							'enableMoving' => true,
+						),
+					),
+				),
+				'form'            => array(
+					'layout' => array(
+						'type'          => 'panel',
+						'labelPosition' => 'side',
+						'openAs'        => array(
+							'type'       => 'drawer',
+							'applyLabel' => 'Apply',
+						),
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() replaces list property values wholesale instead of merging
+	 * them by member identity — this is the one way it differs from merge().
+	 * Associative arrays around the lists still merge key by key, so an
+	 * untouched associative key is preserved while every list the patch names
+	 * (fields, filters, badgeFields, view_list, summary, form fields) is
+	 * swapped for exactly what the patch carries.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_indexed_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'fields'  => array(
+						array( 'title' ),
+					),
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'op1',
+							'value'    => array( 'val1' ),
+						),
+					),
+					'layout'  => array(
+						'badgeFields' => array( 'b1', 'b2' ),
+					),
+				),
+				'view_list'    => array(
+					array(
+						'title' => 'All',
+						'slug'  => 'all',
+					),
+					array(
+						'title' => 'Published',
+						'slug'  => 'published',
+						'view'  => array(
+							'type' => 'list',
+							'sort' => array(
+								'field'     => 'title',
+								'direction' => 'asc',
+							),
+						),
+					),
+				),
+				'form'         => array(
+					'layout' => array(
+						'summary' => array( 'f1' ),
+					),
+					'fields' => array(
+						'f1',
+						array(
+							'id'       => 'f2',
+							'label'    => 'Field label',
+							'children' => array(
+								'child1',
+								array(
+									'id'    => 'child2',
+									'label' => 'Child 2 label',
+								),
+							),
+						),
+						'f3',
+					),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'default_view' => array(
+					'fields'  => array(
+						array( 'slug' ),
+					),
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'change',
+							'isLocked' => true,
+						),
+						array(
+							'field'    => 'id2',
+							'operator' => 'op2',
+							'value'    => array( 'val2' ),
+						),
+					),
+					'layout'  => array(
+						'badgeFields' => array( 'b2' ),
+					),
+				),
+				'view_list'    => array(
+					array(
+						'slug'  => 'published',
+						'title' => 'Live',
+						'view'  => array(
+							'sort' => array( 'direction' => 'desc' ),
+						),
+					),
+					array(
+						'slug'  => 'mine',
+						'title' => 'Mine',
+					),
+				),
+				'form'         => array(
+					'layout' => array(
+						'summary' => array( 'f2' ),
+					),
+					'fields' => array(
+						'f4',
+						array(
+							'id'       => 'f2',
+							'label'    => 'Updated label',
+							'children' => array(
+								array(
+									'id'    => 'child2',
+									'label' => 'Child 2 updated label',
+								),
+								array(
+									'id'    => 'child3',
+									'label' => 'Child 3 label',
+								),
+							),
+						),
+						array(
+							'id'    => 'f3',
+							'label' => 'Field 3 label',
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'fields'  => array(
+						array( 'slug' ),
+					),
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'change',
+							'isLocked' => true,
+						),
+						array(
+							'field'    => 'id2',
+							'operator' => 'op2',
+							'value'    => array( 'val2' ),
+						),
+					),
+					'layout'  => array(
+						'badgeFields' => array( 'b2' ),
+					),
+				),
+				'view_list'    => array(
+					array(
+						'slug'  => 'published',
+						'title' => 'Live',
+						'view'  => array(
+							'sort' => array( 'direction' => 'desc' ),
+						),
+					),
+					array(
+						'slug'  => 'mine',
+						'title' => 'Mine',
+					),
+				),
+				'form'         => array(
+					'layout' => array(
+						'summary' => array( 'f2' ),
+					),
+					'fields' => array(
+						'f4',
+						array(
+							'id'       => 'f2',
+							'label'    => 'Updated label',
+							'children' => array(
+								array(
+									'id'    => 'child2',
+									'label' => 'Child 2 updated label',
+								),
+								array(
+									'id'    => 'child3',
+									'label' => 'Child 3 label',
+								),
+							),
+						),
+						array(
+							'id'    => 'f3',
+							'label' => 'Field 3 label',
+						),
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() unsets a property when the patch value is null.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_null_unsets_scalar_properties() {
 		$data = new WP_View_Config_Data(
 			array(
@@ -181,17 +789,24 @@
 			)
 		);
-		$data->update_properties( array( 'default_view' => array( 'perPage' => null ) ), 1 );
-
-		$this->assertSame( array( 'type' => 'table' ), $data->get_config()['default_view'] );
-	}
-
-	/**
-	 * update_properties() unsets a deeply nested layout property when the value is null.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_null_unsets_nested_layout_prop() {
-		$data = new WP_View_Config_Data(
-			array(
+		$data->replace( array( 'default_view' => array( 'perPage' => null ) ), 1 );
+
+		$this->assertSame( array( 'type' => 'table' ), self::read_config( $data )['default_view'] );
+	}
+
+	/**
+	 * replace() unsets a deeply nested layout property when the value is null.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_null_unsets_associative_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view'    => array(
+					'type' => 'table',
+					'sort' => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
 				'default_layouts' => array(
 					'table' => array(
@@ -204,256 +819,528 @@
 			)
 		);
-		$data->update_properties(
-			array( 'default_layouts' => array( 'table' => array( 'layout' => array( 'styles' => null ) ) ) ),
-			1
-		);
-
-		$this->assertSame(
-			array( 'layout' => array( 'density' => 'compact' ) ),
-			$data->get_config()['default_layouts']['table']
-		);
-	}
-
-	/**
-	 * update_properties() drops a whole top-level key when the patch value is
-	 * null — any documented key, including the identity-keyed view_list —
-	 * rather than storing a literal null. wp_get_entity_view_config()
-	 * backfills a dropped documented key from the defaults, so that reads as
-	 * a reset.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_null_drops_whole_top_level_key() {
-		$data = new WP_View_Config_Data(
-			array(
-				'default_view' => array( 'type' => 'table' ),
-				'view_list'    => array(
-					array(
-						'title' => 'All',
-						'slug'  => 'all',
-					),
-				),
-				'form'         => array( 'layout' => array( 'type' => 'panel' ) ),
-			)
-		);
-		$data->update_properties(
-			array(
-				'default_view' => null,
-				'view_list'    => null,
-			),
-			1
-		);
-
-		$this->assertSame(
-			array( 'form' => array( 'layout' => array( 'type' => 'panel' ) ) ),
-			$data->get_config()
-		);
-	}
-
-	/**
-	 * update_properties() consumes a null delete-marker merged into an empty
-	 * base instead of storing it as a literal value.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_null_into_empty_base_is_consumed() {
-		$data = new WP_View_Config_Data( array( 'default_layouts' => array( 'table' => array() ) ) );
-		$data->update_properties(
-			array( 'default_layouts' => array( 'table' => array( 'layout' => null ) ) ),
-			1
-		);
-
-		$this->assertSame( array(), $data->get_config()['default_layouts']['table'] );
-	}
-
-	/**
-	 * update_properties() strips nulls from a subtree assigned to a key absent
-	 * from the base instead of storing them as literal values.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_null_stripped_from_absent_key_subtree() {
-		$data = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
-		// The base default_view has no `layout` key.
-		$data->update_properties(
-			array(
-				'default_view' => array(
-					'layout' => array(
-						'type'        => 'flex',
-						'badgeFields' => null,
-					),
-				),
-			),
-			1
-		);
-
-		$this->assertSame( array( 'type' => 'flex' ), $data->get_config()['default_view']['layout'] );
-	}
-
-	/**
-	 * update_properties() rejects the identity-keyed branches: a non-null
-	 * view_list value and form fields belong to their dedicated functions. A
-	 * valid sibling form property still merges.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_rejects_identity_keyed_branches() {
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_properties' );
-
-		$data = new WP_View_Config_Data(
-			array(
-				'form' => array(
-					'layout' => array( 'type' => 'panel' ),
-					'fields' => array( 'date' ),
-				),
-			)
-		);
-
-		$data->update_properties(
-			array(
-				'view_list' => array(
-					array(
-						'slug'  => 'mine',
-						'title' => 'Mine',
-					),
-				),
-			),
-			1
-		);
-		$this->assertArrayNotHasKey( 'view_list', $data->get_config() );
-
-		$data->update_properties(
-			array(
-				'form' => array(
-					'layout' => array( 'type' => 'card' ),
-					'fields' => array( 'my_field' ),
-				),
-			),
-			1
-		);
-		$this->assertSame(
-			array(
-				'layout' => array( 'type' => 'card' ),
-				'fields' => array( 'date' ),
-			),
-			$data->get_config()['form']
-		);
-	}
-
-	/**
-	 * update_properties() rejects an undocumented top-level key. Nested
-	 * properties are not validated: their vocabulary is owned by the
-	 * client-side consumers.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_warns_on_unknown_top_level_key() {
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_properties' );
-
-		$data = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
-		$data->update_properties( array( 'not_a_real_key' => 'nope' ), 1 );
-
-		$this->assertSame( array( 'default_view' => array( 'type' => 'table' ) ), $data->get_config() );
-	}
-
-	/**
-	 * update_properties() rejects a list where the form map is expected.
-	 *
-	 * @covers ::update_properties
-	 */
-	public function test_update_properties_rejects_list_shaped_form_patch() {
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_properties' );
-
-		$data   = new WP_View_Config_Data( array( 'form' => array( 'layout' => array( 'type' => 'panel' ) ) ) );
-		$before = $data->get_config();
-		$data->update_properties( array( 'form' => array( array( 'id' => 'my_field' ) ) ), 1 );
-
-		$this->assertSame( $before, $data->get_config() );
-	}
-
-	/**
-	 * Every update function and set() reject a patch whose version cannot be
-	 * migrated — newer than the latest supported version.
-	 *
-	 * @covers ::update_properties
-	 * @covers ::update_view_list_items
-	 * @covers ::update_form_fields
-	 * @covers ::set
-	 */
-	public function test_update_functions_reject_unmigratable_version() {
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_properties' );
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_view_list_items' );
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_form_fields' );
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::set' );
-
-		$data   = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
-		$before = $data->get_config();
-
-		$version = WP_View_Config_Data::LATEST_VERSION + 1;
-		$data->update_properties( array( 'default_view' => array( 'type' => 'grid' ) ), $version );
-		$data->update_view_list_items( array( 'mine' => array( 'title' => 'Mine' ) ), $version );
-		$data->update_form_fields( array( 'excerpt' => array( 'layout' => array( 'labelPosition' => 'side' ) ) ), $version );
-		$data->set( 'default_view', array( 'type' => 'grid' ), $version );
-
-		$this->assertSame( $before, $data->get_config() );
-	}
-
-	/**
-	 * update_view_list_items() merges a matching slug in place and appends an
-	 * unknown one, injecting the slug from the patch key.
-	 *
-	 * @covers ::update_view_list_items
-	 */
-	public function test_update_view_list_items_merges_by_slug_and_appends_unknown() {
-		$data = new WP_View_Config_Data(
-			array(
-				'view_list' => array(
-					array(
-						'title' => 'All',
-						'slug'  => 'all',
-					),
-					array(
-						'title' => 'Published',
-						'slug'  => 'published',
-					),
-				),
-			)
-		);
-		$data->update_view_list_items(
-			array(
-				'published' => array( 'title' => 'Live' ),
-				'mine'      => array( 'title' => 'Mine' ),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
+		$data->replace(
+			array(
+				'default_view'    => array(
+					'sort' => null,
+				),
+				'default_layouts' => array(
+					'table' => array( 'layout' => array( 'styles' => null ) ),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view'    => array(
+					'type' => 'table',
+				),
+				'default_layouts' => array(
+					'table' => array( 'layout' => array( 'density' => 'compact' ) ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() unsets a deeply nested list property when the value is null.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_null_unsets_indexed_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view'    => array(
+					'type'    => 'table',
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'op1',
+							'value'    => array( 'val1' ),
+						),
+					),
+				),
+				'default_layouts' => array(
+					'grid' => array(
+						'layout' => array(
+							'density'     => 'compact',
+							'badgeFields' => array( 'b1', 'b2' ),
+						),
+					),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'default_view'    => array(
+					'filters' => null,
+				),
+				'default_layouts' => array(
+					'grid' => array( 'layout' => array( 'badgeFields' => null ) ),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view'    => array(
+					'type' => 'table',
+				),
+				'default_layouts' => array(
+					'grid' => array( 'layout' => array( 'density' => 'compact' ) ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() resets a whole top-level key to its default when the patch value
+	 * is null — any documented key, including the identity-keyed view_list —
+	 * rather than storing a literal null.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_null_resets_top_level_keys_to_defaults() {
+		$defaults = array(
+			'default_view' => array( 'type' => 'table' ),
+			'view_list'    => array(
 				array(
 					'title' => 'All',
 					'slug'  => 'all',
 				),
-				array(
-					'title' => 'Live',
-					'slug'  => 'published',
-				),
-				array(
-					'slug'  => 'mine',
-					'title' => 'Mine',
-				),
-			),
-			$data->get_config()['view_list']
-		);
-	}
-
-	/**
-	 * update_view_list_items() removes a view when the patch value is null.
-	 *
-	 * @covers ::update_view_list_items
-	 */
-	public function test_update_view_list_items_null_removes_view() {
+			),
+			'form'         => array( 'layout' => array( 'type' => 'panel' ) ),
+		);
+		$data     = new WP_View_Config_Data( $defaults );
+		// Mutate the keys, then null them: each resets to its default.
+		$data->replace(
+			array(
+				'default_view' => array( 'type' => 'grid' ),
+				'view_list'    => array(
+					array(
+						'slug'  => 'mine',
+						'title' => 'Mine',
+					),
+				),
+			),
+			1
+		);
+		$data->replace(
+			array(
+				'default_view' => null,
+				'view_list'    => null,
+			),
+			1
+		);
+
+		$this->assertSame( $defaults, self::read_config( $data ) );
+	}
+
+	/**
+	 * replace() swaps a scalar list wholesale rather than appending to it.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_identity_for_scalars() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'fields' => array(
+						'title',
+					),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'default_view' => array(
+					'fields' => array(
+						'slug',
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'fields' => array(
+						'slug',
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() swaps an id-keyed list wholesale, dropping members the patch
+	 * omits rather than matching them by identity.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_identity_for_key_id() {
+		$data = new WP_View_Config_Data(
+			array(
+				'form' => array(
+					'fields' => array(
+						'title',
+						array(
+							'id'    => 'slug',
+							'label' => 'Slug',
+						),
+					),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'form' => array(
+					'fields' => array(
+						array(
+							'id'    => 'title',
+							'label' => 'Changed',
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'form' => array(
+					'fields' => array(
+						array(
+							'id'    => 'title',
+							'label' => 'Changed',
+						),
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() swaps a slug-keyed list wholesale.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_identity_for_key_slug() {
 		$data = new WP_View_Config_Data(
 			array(
 				'view_list' => array(
+					array(
+						'slug'  => 'all',
+						'title' => 'All',
+					),
+					array(
+						'slug'  => 'published',
+						'title' => 'Published',
+					),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'view_list' => array(
+					array(
+						'slug'  => 'all',
+						'title' => 'Changed',
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'view_list' => array(
+					array(
+						'slug'  => 'all',
+						'title' => 'Changed',
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() swaps a field-keyed list wholesale: the matched member's
+	 * untouched props (e.g. `value`) are dropped rather than preserved, which
+	 * is exactly where it diverges from merge().
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_identity_for_key_field() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'op1',
+							'value'    => array( 'val1' ),
+						),
+					),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'default_view' => array(
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'change',
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'change',
+						),
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * replace() drops a null member from an incoming list rather than storing
+	 * it: a list still replaces the current one wholesale, but a literal null
+	 * member carries no meaning and must not be persisted — at the top level
+	 * (`view_list`) or nested (`default_view.filters`) alike.
+	 *
+	 * @covers ::replace
+	 */
+	public function test_replace_ignores_null_list_members() {
+		$data = new WP_View_Config_Data(
+			array(
+				'view_list'    => array(
+					array(
+						'slug'  => 'all',
+						'title' => 'All',
+					),
+				),
+				'default_view' => array(
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'op1',
+						),
+					),
+				),
+			)
+		);
+		$data->replace(
+			array(
+				'view_list'    => array(
+					null,
+					array(
+						'slug'  => 'mine',
+						'title' => 'Mine',
+					),
+				),
+				'default_view' => array( 'filters' => array( null ) ),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'view_list'    => array(
+					array(
+						'slug'  => 'mine',
+						'title' => 'Mine',
+					),
+				),
+				'default_view' => array(
+					'filters' => array(),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() updates scalar property values
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_scalar_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'       => 'table',
+					'perPage'    => 20,
+					'showLevels' => false,
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'default_view' => array(
+					'type'       => 'grid',
+					'perPage'    => 50,
+					'showLevels' => false,
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'type'       => 'grid',
+				'perPage'    => 50,
+				'showLevels' => false,
+			),
+			self::read_config( $data )['default_view']
+		);
+	}
+
+	/**
+	 * merge() updates object property values
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_associative_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view'    => array(
+					'sort' => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'default_layouts' => array(
+					'table' => array(
+						'layout' => array(
+							'styles'       => array(
+								'width' => 1,
+							),
+							'density'      => 'd2',
+							'enableMoving' => true,
+						),
+					),
+				),
+				'form'            => array(
+					'layout' => array(
+						'type'          => 'panel',
+						'labelPosition' => 'top',
+						'openAs'        => array(
+							'type'       => 'modal',
+							'applyLabel' => 'Apply',
+						),
+					),
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'default_view'    => array(
+					'sort' => array(
+						'direction' => 'desc',
+					),
+				),
+				'default_layouts' => array(
+					'table' => array(
+						'layout' => array(
+							'styles'  => array(
+								'minWidth' => 2,
+							),
+							'density' => 'd2',
+						),
+					),
+				),
+				'form'            => array(
+					'layout' => array(
+						'type'          => 'panel',
+						'labelPosition' => 'side',
+						'openAs'        => array(
+							'type' => 'drawer',
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view'    => array(
+					'sort' => array(
+						'field'     => 'title',
+						'direction' => 'desc',
+					),
+				),
+				'default_layouts' => array(
+					'table' => array(
+						'layout' => array(
+							'styles'       => array(
+								'width'    => 1,
+								'minWidth' => 2,
+							),
+							'density'      => 'd2',
+							'enableMoving' => true,
+						),
+					),
+				),
+				'form'            => array(
+					'layout' => array(
+						'type'          => 'panel',
+						'labelPosition' => 'side',
+						'openAs'        => array(
+							'type'       => 'drawer',
+							'applyLabel' => 'Apply',
+						),
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() updates list property values, including an identity-keyed
+	 * view_list whose matching entries merge in place by slug (keeping their
+	 * position and deep-merging nested props) while unknown ones are appended.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_indexed_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'fields'  => array(
+						array( 'title' ),
+					),
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'op1',
+							'value'    => array( 'val1' ),
+						),
+					),
+					'layout'  => array(
+						'badgeFields' => array( 'b1', 'b2' ),
+					),
+				),
+				'view_list'    => array(
 					array(
 						'title' => 'All',
@@ -463,12 +1350,311 @@
 						'title' => 'Published',
 						'slug'  => 'published',
-					),
-				),
-			)
-		);
-		$data->update_view_list_items( array( 'published' => null ), 1 );
-
-		$this->assertSame(
-			array(
+						'view'  => array(
+							'type' => 'list',
+							'sort' => array(
+								'field'     => 'title',
+								'direction' => 'asc',
+							),
+						),
+					),
+				),
+				'form'         => array(
+					'layout' => array(
+						'summary' => array( 'f1' ),
+					),
+					'fields' => array(
+						'f1',
+						array(
+							'id'       => 'f2',
+							'label'    => 'Field label',
+							'children' => array(
+								'child1',
+								array(
+									'id'    => 'child2',
+									'label' => 'Child 2 label',
+								),
+							),
+						),
+						'f3',
+					),
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'default_view' => array(
+					'fields'  => array(
+						array( 'slug' ),
+					),
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'change',
+							'isLocked' => true,
+						),
+						array(
+							'field'    => 'id2',
+							'operator' => 'op2',
+							'value'    => array( 'val2' ),
+						),
+					),
+					'layout'  => array(
+						'badgeFields' => array( 'b2' ),
+					),
+				),
+				'view_list'    => array(
+					array(
+						'slug'  => 'published',
+						'title' => 'Live',
+						'view'  => array(
+							'sort' => array( 'direction' => 'desc' ),
+						),
+					),
+					array(
+						'slug'  => 'mine',
+						'title' => 'Mine',
+					),
+				),
+				'form'         => array(
+					'layout' => array(
+						'summary' => array( 'f2' ),
+					),
+					'fields' => array(
+						'f4',
+						array(
+							'id'       => 'f2',
+							'label'    => 'Updated label',
+							'children' => array(
+								array(
+									'id'    => 'child2',
+									'label' => 'Child 2 updated label',
+								),
+								array(
+									'id'    => 'child3',
+									'label' => 'Child 3 label',
+								),
+							),
+						),
+						array(
+							'id'    => 'f3',
+							'label' => 'Field 3 label',
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'fields'  => array(
+						array( 'title' ),
+						array( 'slug' ),
+					),
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'change',
+							'value'    => array( 'val1' ),
+							'isLocked' => true,
+						),
+						array(
+							'field'    => 'id2',
+							'operator' => 'op2',
+							'value'    => array( 'val2' ),
+						),
+					),
+					'layout'  => array(
+						'badgeFields' => array( 'b1', 'b2' ),
+					),
+				),
+				'view_list'    => array(
+					array(
+						'title' => 'All',
+						'slug'  => 'all',
+					),
+					array(
+						'title' => 'Live',
+						'slug'  => 'published',
+						'view'  => array(
+							'type' => 'list',
+							'sort' => array(
+								'field'     => 'title',
+								'direction' => 'desc',
+							),
+						),
+					),
+					array(
+						'slug'  => 'mine',
+						'title' => 'Mine',
+					),
+				),
+				'form'         => array(
+					'layout' => array(
+						'summary' => array( 'f1', 'f2' ),
+					),
+					'fields' => array(
+						'f1',
+						array(
+							'id'       => 'f2',
+							'label'    => 'Updated label',
+							'children' => array(
+								'child1',
+								array(
+									'id'    => 'child2',
+									'label' => 'Child 2 updated label',
+								),
+								array(
+									'id'    => 'child3',
+									'label' => 'Child 3 label',
+								),
+							),
+						),
+						array(
+							'id'    => 'f3',
+							'label' => 'Field 3 label',
+						),
+						'f4',
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() unsets a property when the patch value is null.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_null_unsets_scalar_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'    => 'table',
+					'perPage' => 20,
+				),
+			)
+		);
+		$data->merge( array( 'default_view' => array( 'perPage' => null ) ), 1 );
+
+		$this->assertSame( array( 'type' => 'table' ), self::read_config( $data )['default_view'] );
+	}
+
+	/**
+	 * merge() unsets a deeply nested layout property when the value is null.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_null_unsets_associative_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view'    => array(
+					'type' => 'table',
+					'sort' => array(
+						'field'     => 'title',
+						'direction' => 'asc',
+					),
+				),
+				'default_layouts' => array(
+					'table' => array(
+						'layout' => array(
+							'styles'  => array( 'title' => array( 'width' => '20%' ) ),
+							'density' => 'compact',
+						),
+					),
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'default_view'    => array(
+					'sort' => null,
+				),
+				'default_layouts' => array(
+					'table' => array( 'layout' => array( 'styles' => null ) ),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view'    => array(
+					'type' => 'table',
+				),
+				'default_layouts' => array(
+					'table' => array( 'layout' => array( 'density' => 'compact' ) ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() unsets a deeply nested list property when the value is null.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_null_unsets_indexed_array_properties() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view'    => array(
+					'type'    => 'table',
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'op1',
+							'value'    => array( 'val1' ),
+						),
+					),
+				),
+				'default_layouts' => array(
+					'grid' => array(
+						'layout' => array(
+							'density'     => 'compact',
+							'badgeFields' => array( 'b1', 'b2' ),
+						),
+					),
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'default_view'    => array(
+					'filters' => null,
+				),
+				'default_layouts' => array(
+					'grid' => array( 'layout' => array( 'badgeFields' => null ) ),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view'    => array(
+					'type' => 'table',
+				),
+				'default_layouts' => array(
+					'grid' => array( 'layout' => array( 'density' => 'compact' ) ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() resets a whole top-level key to its default when the patch value
+	 * is null — any documented key, including the identity-keyed view_list —
+	 * rather than storing a literal null.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_null_resets_top_level_keys_to_defaults() {
+		$defaults = array(
+			'default_view' => array( 'type' => 'table' ),
+			'view_list'    => array(
 				array(
 					'title' => 'All',
@@ -476,81 +1662,306 @@
 				),
 			),
-			$data->get_config()['view_list']
-		);
-	}
-
-	/**
-	 * The patch key is the identity: a conflicting `slug` property inside the
-	 * value is ignored.
-	 *
-	 * @covers ::update_view_list_items
-	 */
-	public function test_update_view_list_items_patch_key_wins_over_slug_property() {
-		$data = new WP_View_Config_Data( array( 'view_list' => array() ) );
-		$data->update_view_list_items(
-			array(
-				'mine' => array(
-					'slug'  => 'other',
-					'title' => 'Mine',
-				),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
+			'form'         => array( 'layout' => array( 'type' => 'panel' ) ),
+		);
+		$data     = new WP_View_Config_Data( $defaults );
+		// Mutate the keys, then null them: each resets to its default.
+		$data->merge(
+			array(
+				'default_view' => array( 'type' => 'grid' ),
+				'view_list'    => array(
+					array(
+						'slug'  => 'mine',
+						'title' => 'Mine',
+					),
+				),
+			),
+			1
+		);
+		$data->merge(
+			array(
+				'default_view' => null,
+				'view_list'    => null,
+			),
+			1
+		);
+
+		$this->assertSame( $defaults, self::read_config( $data ) );
+	}
+
+	/**
+	 * merge() rejects a patch with an invalid version.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_rejects_updates_with_invalid_version() {
+		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge' );
+
+		$data   = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
+		$before = self::read_config( $data );
+
+		$version = WP_View_Config_Data::LATEST_VERSION + 1;
+		$data->merge( array( 'default_view' => array( 'type' => 'grid' ) ), $version );
+
+		$this->assertSame( $before, self::read_config( $data ) );
+	}
+
+	/**
+	 * merge() rejects an undocumented top-level key. Nested
+	 * properties are not validated: their vocabulary is owned by the
+	 * client-side consumers.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_rejects_unknown_key() {
+		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::merge' );
+
+		$data = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) );
+		$data->merge( array( 'not_a_real_key' => 'nope' ), 1 );
+
+		$this->assertSame( array( 'default_view' => array( 'type' => 'table' ) ), self::read_config( $data ) );
+	}
+
+
+	/**
+	 * merge() treats a scalar list member as its own identity: an incoming
+	 * scalar that already appears is a no-op, and a new one is appended.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_identity_for_scalars() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'fields' => array(
+						'title',
+					),
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'default_view' => array(
+					'fields' => array(
+						'title',
+						'slug',
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'fields' => array(
+						'title',
+						'slug',
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() matches list members by their `id`, and a bare scalar member
+	 * (`'title'`) matches an incoming map carrying that same value
+	 * (`array( 'id' => 'title' )`), merging into it in place.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_identity_for_key_id() {
+		$data = new WP_View_Config_Data(
+			array(
+				'form' => array(
+					'fields' => array(
+						'title', // this scalar will be matched with array( 'id' => 'title' )
+						array(
+							'id'    => 'slug',
+							'label' => 'Slug',
+						),
+					),
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'form' => array(
+					'fields' => array(
+						array(
+							'id'    => 'title',
+							'label' => 'Changed',
+						),
+						array(
+							'id'    => 'slug',
+							'label' => 'Changed',
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'form' => array(
+					'fields' => array(
+						array(
+							'id'    => 'title',
+							'label' => 'Changed',
+						),
+						array(
+							'id'    => 'slug',
+							'label' => 'Changed',
+						),
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() matches view_list members by their `slug`, merging an incoming
+	 * view into the existing one of the same slug in place.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_identity_for_key_slug() {
+		$data = new WP_View_Config_Data(
+			array(
+				'view_list' => array(
+					array(
+						'slug'  => 'all',
+						'title' => 'All',
+					),
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'view_list' => array(
+					array(
+						'slug'  => 'all',
+						'title' => 'Changed',
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'view_list' => array(
+					array(
+						'slug'  => 'all',
+						'title' => 'Changed',
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() matches filter members by their `field`, merging the incoming
+	 * member's keys onto the existing one so untouched props (e.g. `value`)
+	 * are preserved — the behavior that distinguishes merge() from replace().
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_identity_for_key_field() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'op1',
+							'value'    => array( 'val1' ),
+						),
+					),
+				),
+			)
+		);
+		$data->merge(
+			array(
+				'default_view' => array(
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'change',
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'filters' => array(
+						array(
+							'field'    => 'id1',
+							'operator' => 'change',
+							'value'    => array( 'val1' ),
+						),
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * merge() ignores a null member in an incoming list: null carries no
+	 * identity and holds nothing to merge, so it is dropped rather than
+	 * appended as a literal null member — `view_list => array( null )` leaves
+	 * the existing list untouched. The same applies to lists at any nesting
+	 * level, such as `default_view.filters`.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_ignores_null_list_members() {
+		$existing = array(
+			'view_list'    => array(
 				array(
-					'slug'  => 'mine',
-					'title' => 'Mine',
-				),
-			),
-			$data->get_config()['view_list']
-		);
-	}
-
-	/**
-	 * update_view_list_items() rejects patches that are not keyed by slug and
-	 * members that are not view objects.
-	 *
-	 * @covers ::update_view_list_items
-	 */
-	public function test_update_view_list_items_rejects_off_shape_patches() {
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_view_list_items' );
-
-		$data   = new WP_View_Config_Data(
-			array(
-				'view_list' => array(
-					array(
-						'title' => 'All',
-						'slug'  => 'all',
-					),
-				),
-			)
-		);
-		$before = $data->get_config();
-
-		// A positional list where a map keyed by slug is expected.
-		$data->update_view_list_items(
-			array(
-				array(
-					'slug'  => 'mine',
-					'title' => 'Mine',
-				),
-			),
-			1
-		);
-		// A scalar where a view object (or null) is expected.
-		$data->update_view_list_items( array( 'all' => 'nope' ), 1 );
-
-		$this->assertSame( $before, $data->get_config() );
-	}
-
-	/**
-	 * update_form_fields() merges a top-level field by its id: in place, with
-	 * siblings untouched and nothing appended.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_merges_top_level_field_by_id() {
+					'slug'  => 'all',
+					'title' => 'All',
+				),
+			),
+			'default_view' => array(
+				'filters' => array(
+					array(
+						'field'    => 'id1',
+						'operator' => 'op1',
+					),
+				),
+			),
+		);
+		$data     = new WP_View_Config_Data( $existing );
+		$data->merge(
+			array(
+				'view_list'    => array( null ),
+				'default_view' => array( 'filters' => array( null ) ),
+			),
+			1
+		);
+
+		$this->assertSame( $existing, self::read_config( $data ) );
+	}
+
+	/**
+	 * A field written as a bare name means "show this field with the consumer's
+	 * default props", so merging one over a field currently stored as a map resets
+	 * it to defaults: the explicit overrides (here `layout`) are discarded and the
+	 * member is left as the bare name. This mirrors the reverse — a map merged over
+	 * a bare name *adds* overrides. Sibling members are untouched. To reset a single
+	 * override without dropping the others, set that prop to `null` instead.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_bare_name_resets_field_to_defaults() {
 		$data = new WP_View_Config_Data(
 			array(
@@ -558,83 +1969,42 @@
 					'fields' => array(
 						array(
-							'id'     => 'excerpt',
-							'layout' => array(
-								'type'          => 'panel',
-								'labelPosition' => 'top',
-							),
-						),
-						'date',
-					),
-				),
-			)
-		);
-		$data->update_form_fields( array( 'excerpt' => array( 'layout' => array( 'labelPosition' => 'side' ) ) ), 1 );
-
-		$this->assertSame(
-			array(
-				array(
-					'id'     => 'excerpt',
-					'layout' => array(
-						'type'          => 'panel',
-						'labelPosition' => 'side',
-					),
-				),
-				'date',
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * update_form_fields() finds a nested field by its bare id: the caller does
-	 * not need to know (or address) the group the field lives in.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_merges_nested_field_without_addressing_group() {
-		$data = new WP_View_Config_Data(
+							'id'     => 'featured_media',
+							'layout' => array( 'type' => 'regular' ),
+						),
+						'author',
+					),
+				),
+			)
+		);
+		$data->merge(
 			array(
 				'form' => array(
-					'fields' => array(
-						array(
-							'id'       => 'discussion',
-							'label'    => 'Discussion',
-							'children' => array( 'comment_status', 'ping_status' ),
-						),
-					),
-				),
-			)
-		);
-		$data->update_form_fields( array( 'ping_status' => array( 'layout' => array( 'labelPosition' => 'side' ) ) ), 1 );
-
-		// comment_status stays a bare string; the matched ping_status child is
-		// promoted from a bare string and merged with the incoming overrides.
-		$this->assertSame(
-			array(
-				array(
-					'id'       => 'discussion',
-					'label'    => 'Discussion',
-					'children' => array(
-						'comment_status',
-						array(
-							'id'     => 'ping_status',
-							'layout' => array( 'labelPosition' => 'side' ),
-						),
-					),
-				),
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * Fields are visited in document order and a group matches before its own
-	 * children, so a group and a child sharing an id (as in core's default
-	 * `status` group) resolve to the group; the child is reached through a
-	 * `children` patch on the group.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_matches_group_before_its_children() {
+					'fields' => array( 'featured_media' ),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
+			array(
+				'form' => array(
+					'fields' => array(
+						'featured_media', // Reset to defaults: the `layout` override is discarded.
+						'author',
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * The same reset-to-defaults rule applies at any nesting level. The parent
+	 * field (`status`) merges in place and keeps its `label`, while the bare child
+	 * name resets the matching child to defaults, discarding that child's `layout`.
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_bare_name_resets_nested_child_to_defaults() {
 		$data = new WP_View_Config_Data(
 			array(
@@ -644,444 +2014,128 @@
 							'id'       => 'status',
 							'label'    => 'Status',
-							'children' => array( 'status', 'password' ),
-						),
-					),
-				),
-			)
-		);
-		$data->update_form_fields(
-			array(
-				'status' => array(
-					'label'    => 'Visibility',
-					'children' => array( 'status' => array( 'layout' => array( 'labelPosition' => 'none' ) ) ),
-				),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				array(
-					'id'       => 'status',
-					'label'    => 'Visibility',
-					'children' => array(
-						array(
-							'id'     => 'status',
-							'layout' => array( 'labelPosition' => 'none' ),
-						),
-						'password',
-					),
-				),
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * update_form_fields() appends an unknown id to the end of the top-level
-	 * list: as an object when the patch carries overrides, as a bare string
-	 * reference otherwise.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_appends_unknown_field() {
-		$data = new WP_View_Config_Data( array( 'form' => array( 'fields' => array( 'date' ) ) ) );
-		$data->update_form_fields(
-			array(
-				'my_field'    => array( 'layout' => array( 'labelPosition' => 'side' ) ),
-				'other_field' => array(),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				'date',
-				array(
-					'id'     => 'my_field',
-					'layout' => array( 'labelPosition' => 'side' ),
-				),
-				'other_field',
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * A null patch value removes the field wherever it lives, including nested
-	 * inside a group's children.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_null_removes_field_wherever_it_lives() {
-		$data = new WP_View_Config_Data(
+							'children' => array(
+								array(
+									'id'     => 'comment_status',
+									'layout' => array( 'type' => 'regular' ),
+								),
+								'ping_status',
+							),
+						),
+					),
+				),
+			)
+		);
+		$data->merge(
 			array(
 				'form' => array(
 					'fields' => array(
 						array(
-							'id'     => 'excerpt',
-							'layout' => array( 'type' => 'panel' ),
-						),
-						array(
-							'id'       => 'discussion',
-							'label'    => 'Discussion',
-							'children' => array( 'comment_status', 'ping_status' ),
-						),
-						'date',
-					),
-				),
-			)
-		);
-		$data->update_form_fields(
-			array(
-				'excerpt'     => null,
-				'ping_status' => null,
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				array(
-					'id'       => 'discussion',
-					'label'    => 'Discussion',
-					'children' => array( 'comment_status' ),
-				),
-				'date',
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * A `children` map merges into the group's children by id, appending
-	 * unknown ones.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_children_map_merges_by_id() {
-		$data = new WP_View_Config_Data(
+							'id'       => 'status',
+							'children' => array( 'comment_status' ),
+						),
+					),
+				),
+			),
+			1
+		);
+
+		$this->assertSame(
 			array(
 				'form' => array(
 					'fields' => array(
 						array(
-							'id'       => 'discussion',
-							'label'    => 'Discussion',
-							'children' => array( 'comment_status', 'ping_status' ),
-						),
-					),
-				),
-			)
-		);
-		$data->update_form_fields(
-			array(
-				'discussion' => array(
-					'children' => array(
-						'comment_status' => array( 'layout' => array( 'labelPosition' => 'none' ) ),
-						'my_field'       => array(),
-					),
-				),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				array(
-					'id'       => 'discussion',
-					'label'    => 'Discussion',
-					'children' => array(
-						array(
-							'id'     => 'comment_status',
-							'layout' => array( 'labelPosition' => 'none' ),
-						),
-						'ping_status',
-						'my_field',
-					),
-				),
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * A `children` list replaces the group's children wholesale while the group
-	 * keeps its position among the other top-level fields.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_children_list_replaces_wholesale() {
-		$data = new WP_View_Config_Data(
-			array(
-				'form' => array(
-					'fields' => array(
-						'excerpt',
-						array(
-							'id'       => 'discussion',
-							'label'    => 'Discussion',
-							'children' => array( 'comment_status', 'ping_status' ),
-						),
-						'date',
-					),
-				),
-			)
-		);
-		$data->update_form_fields(
-			array( 'discussion' => array( 'children' => array( 'ping_status', 'my_field' ) ) ),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				'excerpt',
-				array(
-					'id'       => 'discussion',
-					'label'    => 'Discussion',
-					'children' => array( 'ping_status', 'my_field' ),
-				),
-				'date',
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * A null `children` value deletes the key, turning the group into a plain
-	 * field.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_children_null_drops_key() {
-		$data = new WP_View_Config_Data(
-			array(
-				'form' => array(
-					'fields' => array(
-						array(
-							'id'       => 'discussion',
-							'label'    => 'Discussion',
-							'children' => array( 'comment_status' ),
-						),
-					),
-				),
-			)
-		);
-		$data->update_form_fields( array( 'discussion' => array( 'children' => null ) ), 1 );
-
-		$this->assertSame(
-			array(
-				array(
-					'id'    => 'discussion',
-					'label' => 'Discussion',
-				),
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * The patch key is the identity: a conflicting `id` property inside the
-	 * value is ignored.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_patch_key_wins_over_id_property() {
-		$data = new WP_View_Config_Data(
-			array(
-				'form' => array(
-					'fields' => array(
-						array(
-							'id'     => 'excerpt',
-							'layout' => array( 'labelPosition' => 'top' ),
-						),
-					),
-				),
-			)
-		);
-		$data->update_form_fields(
-			array(
-				'excerpt' => array(
-					'id'     => 'other',
-					'layout' => array( 'labelPosition' => 'side' ),
-				),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				array(
-					'id'     => 'excerpt',
-					'layout' => array( 'labelPosition' => 'side' ),
-				),
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * update_form_fields() rejects patches that are not keyed by id and members
-	 * that are not field objects.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_rejects_off_shape_patches() {
-		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_form_fields' );
-
-		$data   = new WP_View_Config_Data( array( 'form' => array( 'fields' => array( 'date' ) ) ) );
-		$before = $data->get_config();
-
-		// A positional list where a map keyed by id is expected.
-		$data->update_form_fields( array( array( 'id' => 'my_field' ) ), 1 );
-		// A scalar where a field object (or null) is expected.
-		$data->update_form_fields( array( 'date' => 'nope' ), 1 );
-
-		$this->assertSame( $before, $data->get_config() );
-	}
-
-	/**
-	 * Several null field patches drop several members in one patch: both nested
-	 * children are removed while the group itself remains.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_null_removes_multiple_nested_fields() {
-		$data = new WP_View_Config_Data(
-			array(
-				'form' => array(
-					'fields' => array(
-						'excerpt',
-						array(
-							'id'       => 'discussion',
-							'label'    => 'Discussion',
-							'children' => array( 'comment_status', 'ping_status' ),
-						),
-					),
-				),
-			)
-		);
-		$data->update_form_fields(
-			array(
-				'ping_status'    => null,
-				'comment_status' => null,
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				'excerpt',
-				array(
-					'id'       => 'discussion',
-					'label'    => 'Discussion',
-					'children' => array(),
-				),
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * Removing a group removes its children with it: they are not hoisted to
-	 * the top level.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_null_removes_group_with_its_children() {
-		$data = new WP_View_Config_Data(
-			array(
-				'form' => array(
-					'fields' => array(
-						array(
-							'id'       => 'discussion',
-							'label'    => 'Discussion',
-							'children' => array( 'comment_status', 'ping_status' ),
-						),
-						'date',
-					),
-				),
-			)
-		);
-		$data->update_form_fields( array( 'discussion' => null ), 1 );
-
-		$this->assertSame( array( 'date' ), $data->get_config()['form']['fields'] );
-	}
-
-	/**
-	 * Patch entries apply in order and a null removes every occurrence of the
-	 * id, so a field moves into a group by removing it first and appending it
-	 * to the group's children later in the same patch.
-	 *
-	 * @covers ::update_form_fields
-	 */
-	public function test_update_form_fields_moves_field_into_group_in_one_patch() {
-		$data = new WP_View_Config_Data(
-			array(
-				'form' => array(
-					'fields' => array(
-						'author',
-						array(
-							'id'       => 'discussion',
-							'label'    => 'Discussion',
-							'children' => array( 'comment_status' ),
-						),
-					),
-				),
-			)
-		);
-		$data->update_form_fields(
-			array(
-				'author'     => null,
-				'discussion' => array( 'children' => array( 'author' => array() ) ),
-			),
-			1
-		);
-
-		$this->assertSame(
-			array(
-				array(
-					'id'       => 'discussion',
-					'label'    => 'Discussion',
-					'children' => array( 'comment_status', 'author' ),
-				),
-			),
-			$data->get_config()['form']['fields']
-		);
-	}
-
-	/**
-	 * A null patch for an identity that is not found is a silent no-op.
-	 *
-	 * A member that is not present may have been removed by another filter or
-	 * simply not apply to this entity, so it is not treated as misuse.
-	 *
-	 * @covers ::update_form_fields
-	 * @covers ::update_view_list_items
-	 */
-	public function test_null_patch_for_unknown_identity_is_silent_no_op() {
-		$data = new WP_View_Config_Data( array( 'form' => array( 'fields' => array( 'date' ) ) ) );
-		$data->update_form_fields( array( 'does_not_exist' => null ), 1 );
-
-		$this->assertSame( array( 'date' ), $data->get_config()['form']['fields'] );
-
-		$data = new WP_View_Config_Data(
-			array(
-				'view_list' => array(
-					array(
-						'title' => 'All',
-						'slug'  => 'all',
-					),
-				),
-			)
-		);
-		$data->update_view_list_items( array( 'does_not_exist' => null ), 1 );
-
-		$this->assertSame(
-			array(
-				array(
-					'title' => 'All',
-					'slug'  => 'all',
-				),
-			),
-			$data->get_config()['view_list']
+							'id'       => 'status',
+							'label'    => 'Status',
+							'children' => array(
+								'comment_status', // Reset to defaults: the `layout` override is discarded.
+								'ping_status',
+							),
+						),
+					),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * A `null` value resets a top-level key to its default. A later merge()
+	 * into that same key merges onto the restored default rather than onto an
+	 * empty value, so the default's untouched props (`type`, `fields`) survive
+	 * alongside the overridden one (`perPage`).
+	 *
+	 * @covers ::merge
+	 */
+	public function test_merge_after_null_merges_onto_defaults() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'    => 'table',
+					'perPage' => 10,
+					'fields'  => array( 'title', 'author' ),
+				),
+				'form'         => array(
+					'fields' => array( 'title' ),
+				),
+			)
+		);
+
+		$data->merge( array( 'default_view' => null ), 1 );
+		$data->merge( array( 'default_view' => array( 'perPage' => 20 ) ), 1 );
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'type'    => 'table',
+					'perPage' => 20,
+					'fields'  => array( 'title', 'author' ),
+				),
+				'form'         => array(
+					'fields' => array( 'title' ),
+				),
+			),
+			self::read_config( $data )
+		);
+	}
+
+	/**
+	 * remove() with a bare top-level key resets it to its default, just like a
+	 * `null` value does. A later merge() into that same key merges onto the
+	 * restored default rather than onto an empty value, so the default's
+	 * untouched props (`type`, `fields`) survive alongside the overridden one
+	 * (`perPage`).
+	 *
+	 * @covers ::remove
+	 * @covers ::merge
+	 */
+	public function test_merge_after_remove_merges_onto_defaults() {
+		$data = new WP_View_Config_Data(
+			array(
+				'default_view' => array(
+					'type'    => 'table',
+					'perPage' => 10,
+					'fields'  => array( 'title', 'author' ),
+				),
+				'form'         => array(
+					'fields' => array( 'title' ),
+				),
+			)
+		);
+
+		$data->remove( array( 'default_view' ), 1 );
+		$data->merge( array( 'default_view' => array( 'perPage' => 20 ) ), 1 );
+
+		$this->assertSame(
+			array(
+				'default_view' => array(
+					'type'    => 'table',
+					'perPage' => 20,
+					'fields'  => array( 'title', 'author' ),
+				),
+				'form'         => array(
+					'fields' => array( 'title' ),
+				),
+			),
+			self::read_config( $data )
 		);
 	}
Index: /trunk/tests/phpunit/tests/view-config.php
===================================================================
--- /trunk/tests/phpunit/tests/view-config.php	(revision 62824)
+++ /trunk/tests/phpunit/tests/view-config.php	(revision 62825)
@@ -78,5 +78,5 @@
 	 * The default configuration exposes the documented shape for an unknown entity.
 	 */
-	public function test_returns_default_config_shape_for_unknown_entity() {
+	public function test_default_config_for_unknown_entity() {
 		$config = wp_get_entity_view_config( 'custom_kind', 'custom_name' );
 
@@ -150,11 +150,11 @@
 
 	/**
-	 * A filter can override configuration values through update_properties().
-	 */
-	public function test_filter_update_properties_overrides_config() {
-		add_filter(
-			'get_entity_view_config_custom_kind_custom_name',
-			function ( $data ) {
-				return $data->update_properties(
+	 * A filter can override configuration values through merge().
+	 */
+	public function test_filter_data_is_merged() {
+		add_filter(
+			'get_entity_view_config_custom_kind_custom_name',
+			function ( $data ) {
+				return $data->merge(
 					array( 'default_view' => array( 'type' => 'grid' ) ),
 					1
@@ -169,19 +169,20 @@
 
 	/**
-	 * Successive filters share the same WP_View_Config_Data instance, so their
-	 * effects compose: a later filter can remove a form field that an earlier
-	 * one added.
-	 */
-	public function test_filters_compose_across_the_chain() {
-		add_filter(
-			'get_entity_view_config_custom_kind_custom_name',
-			function ( $data ) {
-				return $data->set(
-					'form',
-					array(
-						'fields' => array(
-							array(
-								'id'       => 'discussion',
-								'children' => array( 'comment_status', 'ping_status' ),
+	 * Successive filters share the same WP_View_Config_Data instance, so
+	 * their effects compose: a later filter can add a form field to a group that
+	 * an earlier one defined.
+	 */
+	public function test_filter_data_chain() {
+		add_filter(
+			'get_entity_view_config_custom_kind_custom_name',
+			function ( $data ) {
+				return $data->replace(
+					array(
+						'form' => array(
+							'fields' => array(
+								array(
+									'id'       => 'discussion',
+									'children' => array( 'comment_status' ),
+								),
 							),
 						),
@@ -195,5 +196,17 @@
 			'get_entity_view_config_custom_kind_custom_name',
 			function ( $data ) {
-				return $data->update_form_fields( array( 'ping_status' => null ), 1 );
+				return $data->merge(
+					array(
+						'form' => array(
+							'fields' => array(
+								array(
+									'id'       => 'discussion',
+									'children' => array( 'ping_status' ),
+								),
+							),
+						),
+					),
+					1
+				);
 			},
 			11
@@ -203,5 +216,5 @@
 
 		$this->assertSame(
-			array( 'comment_status' ),
+			array( 'comment_status', 'ping_status' ),
 			$config['form']['fields'][0]['children']
 		);
@@ -213,15 +226,20 @@
 	 * from the defaults.
 	 */
-	public function test_off_shape_container_return_is_normalized() {
-		add_filter(
-			'get_entity_view_config_custom_kind_custom_name',
-			function () {
-				return new WP_View_Config_Data(
+	public function test_filter_data_normalized() {
+		$this->setExpectedIncorrectUsage( 'WP_View_Config_Data::set' );
+
+		add_filter(
+			'get_entity_view_config_custom_kind_custom_name',
+			function ( $data ) {
+				return $data->set(
 					array(
 						'default_view'   => array( 'type' => 'grid' ),
 						'not_a_real_key' => 'nope',
-					)
-				);
-			}
+					),
+					1
+				);
+			},
+			10,
+			2
 		);
 
@@ -242,9 +260,9 @@
 	 * the defaults, so a null never reaches the response.
 	 */
-	public function test_filter_null_reset_is_backfilled_from_defaults() {
-		add_filter(
-			'get_entity_view_config_custom_kind_custom_name',
-			function ( $data ) {
-				return $data->update_properties( array( 'default_view' => null ), 1 );
+	public function test_filter_data_backfilled_if_null() {
+		add_filter(
+			'get_entity_view_config_custom_kind_custom_name',
+			function ( $data ) {
+				return $data->merge( array( 'default_view' => null ), 1 );
 			}
 		);
@@ -255,26 +273,115 @@
 	}
 
-	/**
-	 * A filter that returns something other than the container falls back to the
-	 * default config.
-	 */
-	public function test_non_object_filter_return_falls_back_to_default() {
-		$this->setExpectedIncorrectUsage( 'wp_get_entity_view_config' );
-
-		add_filter(
-			'get_entity_view_config_custom_kind_custom_name',
-			function () {
-				return 'not the container';
-			}
-		);
-
-		$config = wp_get_entity_view_config( 'custom_kind', 'custom_name' );
-
-		$this->assertIsArray( $config );
-		$this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) );
-		$this->assertSame( self::DEFAULT_VIEW, $config['default_view'] );
-		$this->assertSame( self::DEFAULT_LAYOUTS, $config['default_layouts'] );
-		$this->assertSame( self::DEFAULT_VIEW_LIST, $config['view_list'] );
-		$this->assertSame( self::DEFAULT_FORM, $config['form'] );
+	public function test_filter_default_view_merge_fields() {
+		add_filter(
+			'get_entity_view_config_custom_kind_custom_name',
+			function ( $data ) {
+				return $data->merge(
+					array(
+						'default_view' => array(
+							'fields' => array( 'title', 'author' ),
+						),
+					),
+					1
+				);
+			}
+		);
+
+		$config = wp_get_entity_view_config( 'custom_kind', 'custom_name' );
+
+		$this->assertSame(
+			array( 'author', 'status', 'title' ),
+			$config['default_view']['fields']
+		);
+	}
+
+	public function test_filter_default_view_replace_fields() {
+		add_filter(
+			'get_entity_view_config_custom_kind_custom_name',
+			function ( $data ) {
+				return $data->replace(
+					array(
+						'default_view' => array(
+							'fields' => array( 'title' ),
+						),
+					),
+					1
+				);
+			}
+		);
+
+		$config = wp_get_entity_view_config( 'custom_kind', 'custom_name' );
+
+		$this->assertSame(
+			array( 'title' ),
+			$config['default_view']['fields']
+		);
+	}
+
+	public function test_filter_default_view_remove_fields() {
+		add_filter(
+			'get_entity_view_config_custom_kind_custom_name',
+			function ( $data ) {
+				$data->merge(
+					array(
+						'default_view' => array(
+							'fields' => null,
+						),
+					),
+					1
+				);
+				$data->merge(
+					array(
+						'default_view' => array(
+							'fields' => array( 'author' ),
+						),
+					),
+					1
+				);
+				return $data;
+			}
+		);
+
+		$config = wp_get_entity_view_config( 'custom_kind', 'custom_name' );
+
+		$this->assertSame(
+			array( 'author' ),
+			$config['default_view']['fields']
+		);
+	}
+
+	public function test_filter_view_list_add_view() {
+		add_filter(
+			'get_entity_view_config_custom_kind_custom_name',
+			function ( $data ) {
+				return $data->merge(
+					array(
+						'view_list' => array(
+							array(
+								'slug'  => 'my_view',
+								'title' => 'My View',
+							),
+						),
+					),
+					1
+				);
+			}
+		);
+
+		$config = wp_get_entity_view_config( 'custom_kind', 'custom_name' );
+
+		$this->assertSame(
+			array(
+				array(
+					'title' => 'All items',
+					'slug'  => 'all',
+				),
+				array(
+					'slug'  => 'my_view',
+					'title' => 'My View',
+				),
+			),
+			$config['view_list']
+		);
 	}
 }
