Skip to content

Update view config API versioning#80319

Open
oandregal wants to merge 38 commits into
trunkfrom
update/view-config-api-versioning
Open

Update view config API versioning#80319
oandregal wants to merge 38 commits into
trunkfrom
update/view-config-api-versioning

Conversation

@oandregal

@oandregal oandregal commented Jul 15, 2026

Copy link
Copy Markdown
Member

See #79809 (comment)

What?

Iterates on the view config API versioning — the Gutenberg_View_Config_Data object that get_entity_view_config_{$kind}_{$name} filter callbacks receive — from a set of shape-specific methods into a small set of general ones that form a gradient of how deep a change reaches.

Before:

public function set( $key, $value, $version ){ /* ... */ }
public function update_properties( $patch, $version ){ /* ... */ }
public function update_view_list_items( $items, $version ){ /* ... */ }
public function update_form_fields( $fields, $version ){ /* ... */ }

After:

public function set( $patch, $version ) { /* ... */ }
public function merge( $patch, $version ) { /* ... */ }
public function replace( $patch, $version ) { /* ... */ }
public function remove( $spec, $version ) { /* ... */ }

Why?

The previous method relied on using get_config to process the shape of the object, but that's precisely what this API wanted to avoid to ensure future compatibility with changes to the object shape, see #79809 (comment).

A single patch model with uniform rules is smaller (conceptually and code-wise), composes predictably at every nesting level, and covers add/update/remove for all four top-level keys.

How?

Each method takes the schema $version and a partial argument applied recursively, touching only the top-level keys it names. The three writers form a gradient of how deep a named key's replacement reaches, and remove() is the counterpart that deletes by name.

merge() merges the value in key by key — the default: patches compose with core's configuration and with other plugins'.

// Retitle the existing Drafts view and add a new one — every other view stays.
$data->merge(
    array(
        'view_list' => array(
            array( 'slug' => 'drafts', 'title' => __( 'In progress', 'example' ) ),
            array( 'slug' => 'my-drafts', 'title' => __( 'My drafts', 'example' ) ),
        ),
    ),
    1
);

replace() merges the same way, except a list in the patch replaces the current list wholesale instead of merging by identity.

// Pin the form to an exact set of fields (drops any not listed).
$data->replace(
    array( 'form' => array( 'fields' => array( 'title', 'status', 'author' ) ) ),
    1
);

set() goes one step further and swaps each named top-level key wholesale, so the inherited default never leaks through a key-by-key merge.

// Own default_view outright — pin it to an exact shape.
$data->set(
    array( 'default_view' => array( 'type' => 'table' ) ),
    1
);

remove() takes a spec of names to delete rather than values to write, its shape mirroring the configuration it prunes. It's the targeted counterpart to replace(): drop a single member and keep inheriting core's future additions to the rest.

// Drop the Pending saved view by slug, without touching the other views.
$data->remove( array( 'view_list' => array( 'pending' ) ), 1 );

The core's own per-post-type callbacks (page, wp_block, wp_template, …) are migrated to the new methods, and the docs in filters-and-hooks.md are rewritten around merge()/replace()/set()/remove().

Testing Instructions

  • Verify that the site editor pages (Page, Template, Patterns, Parts) still work as before.
  • Verify the Inspector experiment works as before.

Use of AI Tools

Claude Code was used in creating these changes, and they all were reviewed by the author.

@oandregal oandregal self-assigned this Jul 15, 2026
@oandregal
oandregal force-pushed the update/view-config-api-versioning branch from 59c5ac5 to d31d113 Compare July 16, 2026 12:20
@oandregal
oandregal requested a review from ntsekouras July 16, 2026 16:50
@oandregal

oandregal commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

Inspiration for the API: RFC 7386 JSON Merge Patch and PHP's array_replace/array_merge.

Two thoughts on this approach:

  • Using null to remove parts of the tree:
    • This is inherited from the existing implementation. It's a nice touch, but the limitation here is that null cannot be a valid value for the tree. I don't think that's necessary today, but I don't know about the future. This could be mitigated with a specific remove method.
  • Operating on numeric indexed arrays is limited:
    • You can append or update existing values by leveraging the identity mechanism (see list_item_identity).
    • Remove a single value needs access to all the values of the list (still needs a get_data method). This could be addressed by adding a remove method`.
    • Prepend or reposition values requires access to all the values of the list (still needs a get_data method). Examples of this could be reordering the fields in the table layout, or changing the positioning of fields in the form.

@ntsekouras

Copy link
Copy Markdown
Contributor

The previous method relied on using get_config to process the shape of the object,

That's not entirely true. That was an oversight that your two examples showcased that consumers might need them. With this approach the same thing still holds though for cases. If a consumer would want to remove a form.field or view list item they would still need the new get_data.

So besides whatever changes we make to accommodate the use cases we want, we'd still need to try to make get_data inaccessible (private) from filter consumers. This should be an extra safeguard, because our API should be able to express all the use cases without the need for consumers calling that function.

Comment thread lib/compat/wordpress-7.1/class-gutenberg-view-config-data.php
Comment thread lib/compat/wordpress-7.1/view-config-api.php Outdated
@oandregal

Copy link
Copy Markdown
Member Author

Note this:

	$data->replace( array( 'default_layouts' => null ), 1 );
	$data->replace( array( 'default_layouts' => $default_layouts ), 1 );

We set the object to null so replace later with only the values we want (example). An advantage of replacing without nulling first is that if the view config grows (e.g., add new key) it'll have a default, even though old consumers don't set one. Alternatively, we may want to add anew verb (e.g., set) that implements that operation.

@oandregal

oandregal commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

Paired with Nik to review this. The plan is going forward with this direction for the Gutenberg_View_Config_Data class and:

  • implement set
  • implement remove
  • delete get_data
  • identity: be more specific

We won't implement the ability to reordering items within a numerical indexed list (e.g., reorder fields in a table layout, reorder fields in a form) as that requires access to the existing data (which would prevent us from being able to introduce any changes to the shape of the tree in the future).

Comment thread lib/compat/wordpress-7.1/view-config-api.php Outdated
Comment thread lib/compat/wordpress-7.1/view-config-api.php Outdated
Comment thread lib/compat/wordpress-7.1/view-config-api.php Outdated
- A **list** of names deletes each named entry at that level: a key from a map, 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 prune from within it.

For example, `array( 'default_view' )` drops the whole `default_view` key, `array( 'default_view' => array( 'sort' ) )` drops just its `sort` property, and `array( 'view_list' => array( 'drafts' ) )` drops the saved view whose `slug` is `drafts` while leaving every other view in place. A name that is not present is ignored, and a list is renumbered after a member is removed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to take a good pass in the docs in the end for catching the details. For example here we mention default_view, but we actually reset to defaults for the four top level keys.

- A **scalar** replaces the current value.
- An **associative array** (map) merges key by key, recursing into each key.
- A **list** (a sequential, zero-indexed array) merges member by member *by identity*: a member whose identity matches one already present merges into it in place and keeps its position, and an unmatched member is appended to the end. A member's identity is the value of the first identity key (`id`, `slug`, or `field`) it carries, or, for a bare scalar member, the scalar itself. Because only the value matters, a bare string like `'my_field'` matches a map carrying that same value under any identity key.
- `null` deletes the property it names. Passing `null` for a whole top-level key drops it, which reads as a reset: `gutenberg_get_entity_view_config()` backfills any missing top-level key from the defaults.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to express very clearly when consumers should use replace with null and when remove. For example here we should mention that we can't delete list members.


`replace( $patch, $version )` applies a patch exactly like `merge()` — scalars replace, maps merge key by key, and `null` deletes — with one difference: any **list** the patch names replaces the current list wholesale instead of merging into it by identity. Use it to pin a list to an exact set of members, for example to remove saved views by giving a shorter `view_list`, or to drop form fields by giving a shorter `form.fields`.

`replace()` does *not* replace a whole top-level key wholesale: because a map still merges key by key, keys the patch omits from an associative value (such as `default_view` or `form`) are preserved. To fully swap an associative top-level key, drop it first with a `null` patch and then apply the new value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With set we can remove the comments: ....To fully swap an associative top-level key, drop it first...

Comment thread lib/compat/wordpress-7.1/class-gutenberg-view-config-data.php
@ntsekouras

Copy link
Copy Markdown
Contributor

I have reviewed and left some comments around the new changes (set, remove) and this is the direction we discussed with @oandregal (see #80319 (comment)).

Removing a child field with identity (by also looking into nested children fields) was a nice touch in the previous implementation, but wouldn't consider it a block for these updates and we could always enhance/update in the future.

I think it's very important to have clear docs and plenty of examples. This was also part of the original PR that tries to use simpler language than what usually LLMs produce.

A thing worth looking at is that a bare-name append over an existing map field (e.g. merge( form.fields => array( 'featured_media' ) ) drops its other possible props. In my example and for page post type it would make featured_media lose its layout prop. Should we check for field existence first and leave it untouched in that case? 🤔

The remaining things to be updated (delete get_data and identity: be more specific) are more straightforward.

I'll be AFK the whole next week, so my reviews/comments so far hopefully help next reviewers while I'm away.

@oandregal oandregal added Backport to WP 7.1 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta [Type] Bug An existing feature does not function as intended labels Jul 20, 2026
@oandregal
oandregal force-pushed the update/view-config-api-versioning branch from 3851584 to 8dfc9fe Compare July 20, 2026 09:49
@oandregal

Copy link
Copy Markdown
Member Author

A thing worth looking at is that a bare-name append over an existing map field (e.g. merge( form.fields => array( 'featured_media' ) ) drops its other possible props. In my example and for page post type it would make featured_media lose its layout prop. Should we check for field existence first and leave it untouched in that case? 🤔

This is how it should work, and I've pushed a couple of unit tests to document it.

If you had this field definition:

array(
    'form' => array(
        'fields' => array(
            array(
                'id'     => 'featured_media',
                'layout' => array( 'type' => 'regular' ),
            ),
        ),
    ),
)

and this one:

array(
    'form' => array(
        'fields' => array( 'featured_media' ),
    ),
),

what are the differences? The second would use the defaults for layout. The same should happen when a filter callback updates the field configuration, hence the current behavior.

@oandregal
oandregal marked this pull request as ready for review July 20, 2026 11:32
Copilot AI review requested due to automatic review settings July 20, 2026 11:32
@oandregal
oandregal requested a review from fabiankaegy as a code owner July 20, 2026 11:32
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: oandregal <oandregal@git.wordpress.org>
Co-authored-by: ntsekouras <ntsekouras@git.wordpress.org>
Co-authored-by: jorgefilipecosta <jorgefilipecosta@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@oandregal
oandregal force-pushed the update/view-config-api-versioning branch from 412a7b4 to dd778f4 Compare July 20, 2026 15:14
'7.1.0'
);
return $this;
return $config;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is safe to do, if there is a single plugin callback doing something wrong I think we are ignoring all filters including the default ones added at gutenberg_register_entity_view_config_filters.


// A null patch value drops the property.
if ( null === $value ) {
unset( $this->config[ $key ] );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we may have an issue here supposely null should reset to degfault "keeps whatever it had, and a null value drops the key it names, which resets it to its default.".
After a filter merging something to null, and another filter after merge something on that paht, instad of that filter being overwrring the default it will merge with an empty array. I guess we should apply default here and not just unset?

if ( null === $item ) {
// A null patch value deletes the key.
unset( $existing[ $key ] );
if ( null === $index ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge( array( 'view_list' => array( null ) ), 1 ) appends a null to list_view is that ok? Or should we check for null item here and not just index?

- A **list** (a sequential, zero-indexed array) merges member by member *by identity*: a member whose identity matches one already present merges into it in place and keeps its position, and an unmatched member is appended to the end. A member's identity is the value of the first identity key (`id`, `slug`, or `field`) it carries, or, for a bare scalar member, the scalar itself. Because only the value matters, a bare string like `'my_field'` matches a map carrying that same value under any identity key.
- `null` deletes the property it names. Passing `null` for a whole top-level key drops it, which reads as a reset: `gutenberg_get_entity_view_config()` backfills any missing top-level key from the defaults.

These rules apply at every nesting level. In `view_list`, entries merge by `slug`. Within a `form`, `fields` is a list whose members merge by their `id`; a group's `children` is an identity-merged list too, so listing a field in a group's `children` appends it there. Because `merge()` only adds to or updates lists, it never removes an existing list member — to drop members, replace the list with `replace()`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit pick: Is it really the case that merge never removes?

If status is:

array(
    'id'       => 'status',
    'label'    => __( 'Status', 'gutenberg' ),
    'children' => array(
        array(
            'id'     => 'status',
            'layout' => array(
                'type'          => 'regular',
                'labelPosition' => 'none',
            ),
        ),
        'scheduled_date',
        'password',
        'sticky',
    ),
),

And we call:

merge( array( 'form' => array( 'fields' => array( 'status' ) ) ), 1 )

We will remove the children of status.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backport to WP 7.1 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants