4040 * key by key (an associative array merges member by member, a nested `null`
4141 * deletes just that leaf, a scalar replaces just that value), while `set()`
4242 * swaps the whole value. A nested `null` deletes just the leaf it names in
43- * every case. Each patch also declares the configuration schema
43+ * every case. A patch value whose shape does not match the current value —
44+ * an associative array where a list lives, or the reverse — is rejected with
45+ * a notice rather than merged, and an empty array under `merge()` is a
46+ * no-op. Each patch also declares the configuration schema
4447 * version it was written against (currently 1), so a future WordPress release
4548 * that changes the configuration shape can migrate existing patches forward
4649 * instead of breaking them.
@@ -308,6 +311,12 @@ public function remove( array $spec, int $version ) {
308311 * stops inheriting core's future additions to it — but it's useful when a
309312 * contributor needs to pin a list to an exact set of members.
310313 *
314+ * The shape rule applies here too: a patch value whose shape does not match
315+ * the current value — an associative array where a list lives, or a
316+ * non-empty list where an associative value lives — is rejected with a
317+ * notice and leaves the current value unchanged. An empty array is exempt,
318+ * so replacing a list with an empty list still clears it.
319+ *
311320 * A patch that declares an unsupported schema version is rejected and does
312321 * not change anything.
313322 *
@@ -354,6 +363,13 @@ public function replace( array $patch, int $version ) {
354363 * - default_layouts will be updated so that newField is appended to the badgeFields.
355364 * - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'.
356365 *
366+ * A patch value only merges into a current value of the same shape: an
367+ * associative array where a list lives, or a non-empty list where an
368+ * associative value lives, is rejected with a notice and leaves the current
369+ * value unchanged. An empty array merges nothing and is a no-op — clear a
370+ * list with replace() and an empty list, or reset a key to its default with
371+ * a top-level `null`.
372+ *
357373 * A patch that declares an unsupported schema version is rejected and does
358374 * not change anything.
359375 *
@@ -477,6 +493,15 @@ private function strip_nulls( $value ) {
477493 * $replace_lists flag is carried down through associative nesting so that,
478494 * under replace(), every list reached along the way is swapped wholesale.
479495 *
496+ * An array in $incoming only merges into a current value of the same shape.
497+ * A non-empty mismatch — an associative array where a list lives, or a
498+ * non-empty list where an associative value lives — is reported with
499+ * _doing_it_wrong() and leaves the current value unchanged, so a malformed
500+ * patch cannot silently destroy configuration. An empty array is
501+ * shape-ambiguous and merges nothing, so it is a no-op: clearing a list is
502+ * spelled replace() with an empty list, and resetting a key is spelled
503+ * `null`.
504+ *
480505 * @since 7.1.0
481506 *
482507 * @param mixed $current The current value.
@@ -493,20 +518,48 @@ private function merge_properties( $current, $incoming, $replace_lists ) {
493518
494519 // Numerical indexed arrays are expected to be lists (sequential integer keys starting at 0).
495520 if ( array_is_list ( $ incoming ) ) {
521+ // A non-empty list only lands where a list (or nothing) lives, under
522+ // merge() and replace() alike. An empty array is shape-ambiguous and
523+ // exempt, so replace() with an empty list can still clear a list.
524+ if ( array () !== $ incoming && is_array ( $ current ) && ! array_is_list ( $ current ) && array () !== $ current ) {
525+ _doing_it_wrong (
526+ __METHOD__ ,
527+ esc_html__ ( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array. ' ),
528+ '7.1.0 '
529+ );
530+ return $ current ;
531+ }
532+
496533 // replace() takes an incoming list as-is; merge() merges it by member identity.
497534 if ( $ replace_lists ) {
498535 // As-is except for nulls: a list swapped in wholesale has no
499536 // existing leaf for a null to delete (the same rationale as
500537 // set()), so a null member is dropped rather than stored.
501538 return $ this ->strip_nulls ( $ incoming );
502539 }
540+
541+ // An empty list has no members to merge, and an empty array is
542+ // shape-ambiguous, so merging one is a no-op rather than a reset.
543+ if ( array () === $ incoming ) {
544+ return $ current ;
545+ }
546+
503547 return $ this ->merge_list_by_identity (
504548 is_array ( $ current ) && array_is_list ( $ current ) ? $ current : array (),
505549 $ incoming
506550 );
507551 }
508552
509553 // Consider any other array as associative (keys are strings).
554+ if ( is_array ( $ current ) && array_is_list ( $ current ) && array () !== $ current ) {
555+ _doing_it_wrong (
556+ __METHOD__ ,
557+ esc_html__ ( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array. ' ),
558+ '7.1.0 '
559+ );
560+ return $ current ;
561+ }
562+
510563 $ result = is_array ( $ current ) && ! array_is_list ( $ current ) ? $ current : array ();
511564 foreach ( $ incoming as $ key => $ value ) {
512565 // A null patch value deletes the property.
@@ -603,7 +656,9 @@ private function remove_list_member( array $members, $identity ) {
603656 * A member of the incoming list whose identity matches one already present
604657 * merges into it in place, keeping its position; an unmatched member is
605658 * appended to the end, except a literal `null`, which carries no identity
606- * and holds nothing to merge and so is dropped. A matched member's contents
659+ * and holds nothing to merge and so is dropped. An appended member has no
660+ * existing leaf for a nested `null` to delete (the same rationale as set()),
661+ * so its nulls are stripped rather than stored. A matched member's contents
607662 * merge recursively with the same rules (merge_properties), so the
608663 * identity-aware merge applies at
609664 * any nesting level: each key named by the patch is substituted while the
@@ -639,7 +694,9 @@ private function merge_list_by_identity( array $current, array $incoming ) {
639694 }
640695 }
641696 if ( null === $ index ) {
642- $ result [] = $ item ;
697+ // An appended member has no existing leaf for a nested null to
698+ // delete, so nulls are dropped rather than stored.
699+ $ result [] = $ this ->strip_nulls ( $ item );
643700 continue ;
644701 }
645702
0 commit comments