Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions specification/v0_9/docs/a2ui_protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,18 @@ This message signals the client to create a new surface and begin rendering it.

### `updateComponents`

This message provides a list of UI components to be added to or updated within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. Note that components may reference children or data bindings that do not yet exist; clients should handle this gracefully by rendering placeholders (progressive rendering).
This message provides a list of UI components to be added, updated, or removed within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. Note that components may reference children or data bindings that do not yet exist; clients should handle this gracefully by rendering placeholders (progressive rendering).

**Adding or Updating Components:**
To add or update a component, provide the full component definition object. If a component with the same `id` already exists, it will be replaced.

**Deleting Components:**
To delete a component, provide an object with the component's `id` and omit the `component` property. This removes the component from the client's memory.
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This documentation correctly states that deletion is done by omitting the component property, which aligns with the JSON schema. However, the pull request title (feat: Support component deletion via component: null) and description are inconsistent with this, as they state deletion is done via component: null. Please update the PR title and description to match the implemented behavior to avoid confusion.


**Properties:**

- `surfaceId` (string, required): The unique identifier for the UI surface to be updated. This is typically a name with meaning (e.g. "user_profile_card"), and it has to be unique within the context of the GenUI session.
- `components` (array, required): A list of component objects. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list.
- `components` (array, required): A list of component objects (for updates) or tombstone objects (for deletions).
Copy link
Collaborator

Choose a reason for hiding this comment

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

What about the adjacency list description? I think we should keep that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh yes, let's reinstate that sorry!


**Example:**

Expand All @@ -180,6 +186,22 @@ This message provides a list of UI components to be added to or updated within a
]
}
}

{
"updateComponents": {
"surfaceId": "user_profile_card",
"components": [
{
"id": "root",
"component": "Column",
"children": ["user_name"]
},
{
"id": "user_title"
}
]
}
}
```

### `updateDataModel`
Expand Down
1 change: 1 addition & 0 deletions specification/v0_9/eval/src/generation_flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Standard Instructions:
13. Do NOT use a 'style' property. Use standard properties like 'align', 'justify', 'variant', etc.
14. Do NOT invent properties that are not in the schema. Check the 'properties' list for each component type.
15. Use 'checks' property for validation rules if required.
16. To delete a component, output an object with ONLY the 'id' property (omit the 'component' property).
${catalogRules ? `\nInstructions specific to this catalog:\n${catalogRules}` : ""}

Schemas:
Expand Down
5 changes: 5 additions & 0 deletions specification/v0_9/eval/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const prompts: TestPrompt[] = [
description: "A DeleteSurface message to remove a UI surface.",
promptText: `Generate a JSON message containing a deleteSurface for the surface 'dashboard-surface-1'.`,
},
{
name: "deleteComponent",
description: "An updateComponents message to delete a component.",
promptText: `Generate an 'updateComponents' message for surfaceId 'main'. This message should delete the component with ID 'loading-spinner' and update the component with ID 'status-text' to have the text "Loaded".`,
},
{
name: "dogBreedGenerator",
description:
Expand Down
10 changes: 8 additions & 2 deletions specification/v0_9/eval/src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,14 @@ export class Validator {
}

const componentType = component.component;
if (!componentType || typeof componentType !== "string") {
errors.push(`Component '${id}' is missing 'component' property.`);

// Handle deletion (tombstone)
if (componentType === undefined || componentType === null) {
return;
}
Comment on lines +302 to +304
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This check is more permissive than what the JSON schema allows. The schema (server_to_client.json) defines a tombstone for deletion as an object with only an id property, enforced by additionalProperties: false. This means a payload with component: null would fail schema validation before this custom validation code is even reached. To avoid confusion and align strictly with the schema, this check should only allow for an omitted component property.

Suggested change
if (componentType === undefined || componentType === null) {
return;
}
if (componentType === undefined) {
// This is a tombstone for component deletion, which is valid per the schema.
return;
}


if (typeof componentType !== "string") {
errors.push(`Component '${id}' has invalid 'component' property.`);
return;
}

Expand Down
17 changes: 16 additions & 1 deletion specification/v0_9/json/server_to_client.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,22 @@
"description": "A list containing all UI components for the surface.",
"minItems": 1,
"items": {
"$ref": "standard_catalog.json#/$defs/anyComponent"
"oneOf": [
{
"$ref": "standard_catalog.json#/$defs/anyComponent"
},
{
"type": "object",
"description": "A tombstone to remove a component. Omit the 'component' property to indicate deletion.",
"properties": {
"id": {
"$ref": "common_types.json#/$defs/ComponentId"
}
},
"required": ["id"],
"additionalProperties": false
}
]
}
}
},
Expand Down
4 changes: 2 additions & 2 deletions specification/v0_9/test/cases/button_checks.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
"id": "btn_primary",
"component": "Button",
"child": "txt_primary",
"action": { "name": "submit" },
"action": { "event": { "name": "submit" } },
"variant": "primary"
},
{ "id": "txt_primary", "component": "Text", "text": "Submit" }
Expand All @@ -144,7 +144,7 @@
"id": "btn_borderless",
"component": "Button",
"child": "txt_borderless",
"action": { "name": "cancel" },
"action": { "event": { "name": "cancel" } },
"variant": "borderless"
},
{ "id": "txt_borderless", "component": "Text", "text": "Cancel" }
Expand Down
65 changes: 65 additions & 0 deletions specification/v0_9/test/cases/component_deletion.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{
"schema": "server_to_client.json",
"tests": [
{
"description": "UpdateComponents with component deletion (tombstone)",
"valid": true,
"data": {
"updateComponents": {
"surfaceId": "test_surface",
"components": [
{
"id": "item_to_delete"
}
]
}
}
},
{
"description": "UpdateComponents mixing update and deletion",
"valid": true,
"data": {
"updateComponents": {
"surfaceId": "test_surface",
"components": [
{
"id": "new_item",
"component": "Text",
"text": "Hello"
},
{
"id": "old_item"
}
]
}
}
},
{
"description": "Deletion with extra properties (should be invalid)",
"valid": false,
"data": {
"updateComponents": {
"surfaceId": "test_surface",
"components": [
{
"id": "item_to_delete",
"text": "Should not be here"
}
]
}
}
},
{
"description": "Deletion missing ID (should be invalid)",
"valid": false,
"data": {
"updateComponents": {
"surfaceId": "test_surface",
"components": [
{}
]
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

These test cases are great for verifying the deletion-by-omission behavior. To make the contract even more explicit and prevent future regressions, it would be beneficial to add a test case that asserts that using component: null is invalid, as per the current JSON schema. This would help solidify that only omitting the component property is the supported method for deletion.

    },
    {
      "description": "Deletion with component: null (should be invalid per schema)",
      "valid": false,
      "data": {
        "updateComponents": {
          "surfaceId": "test_surface",
          "components": [
            {
              "id": "item_to_delete",
              "component": null
            }
          ]
        }
      }
    }

]
}
2 changes: 1 addition & 1 deletion specification/v0_9/test/cases/contact_form_example.jsonl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"createSurface":{"surfaceId":"contact_form_1","catalogId":"https://a2ui.dev/specification/v0_9/standard_catalog.json"}}
{"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Card","child":"form_container"},{"id":"form_container","component":"Column","children":["header_row","name_row","email_group","phone_group","pref_group","divider_1","newsletter_checkbox","submit_button"],"justify":"start","align":"stretch"},{"id":"header_row","component":"Row","children":["header_icon","header_text"],"align":"center"},{"id":"header_icon","component":"Icon","name":"mail"},{"id":"header_text","component":"Text","text":"# Contact Us","variant":"h2"},{"id":"name_row","component":"Row","children":["first_name_group","last_name_group"],"justify":"spaceBetween"},{"id":"first_name_group","component":"Column","children":["first_name_label","first_name_field"],"weight":1},{"id":"first_name_label","component":"Text","text":"First Name","variant":"caption"},{"id":"first_name_field","component":"TextField","label":"First Name","value":{"path":"/contact/firstName"},"variant":"shortText"},{"id":"last_name_group","component":"Column","children":["last_name_label","last_name_field"],"weight":1},{"id":"last_name_label","component":"Text","text":"Last Name","variant":"caption"},{"id":"last_name_field","component":"TextField","label":"Last Name","value":{"path":"/contact/lastName"},"variant":"shortText"},{"id":"email_group","component":"Column","children":["email_label","email_field"]},{"id":"email_label","component":"Text","text":"Email Address","variant":"caption"},{"id":"email_field","component":"TextField","label":"Email","value":{"path":"/contact/email"},"variant":"shortText","checks":[{"call":"required","args":[{"path":"/contact/email"}],"message":"Email is required."},{"call":"email","args":[{"path":"/contact/email"}],"message":"Please enter a valid email address."}]},{"id":"phone_group","component":"Column","children":["phone_label","phone_field"]},{"id":"phone_label","component":"Text","text":"Phone Number","variant":"caption"},{"id":"phone_field","component":"TextField","label":"Phone","value":{"path":"/contact/phone"},"variant":"shortText","checks":[{"call":"regex","args":[{"path":"/contact/phone"},"^\\d{10}$\\"],"message":"Phone number must be 10 digits."}]},{"id":"pref_group","component":"Column","children":["pref_label","pref_picker"]},{"id":"pref_label","component":"Text","text":"Preferred Contact Method","variant":"caption"},{"id":"pref_picker","component":"ChoicePicker","variant":"mutuallyExclusive","options":[{"label":"Email","value":"email"},{"label":"Phone","value":"phone"},{"label":"SMS","value":"sms"}],"value":{"path":"/contact/preference"}},{"id":"divider_1","component":"Divider","axis":"horizontal"},{"id":"newsletter_checkbox","component":"CheckBox","label":"Subscribe to our newsletter","value":{"path":"/contact/subscribe"}},{"id":"submit_button_label","component":"Text","text":"Send Message"},{"id":"submit_button","component":"Button","child":"submit_button_label","variant":"primary","action":{"name":"submitContactForm","context":{"formId":"contact_form_1","clientTime":{"call":"now","returnType":"string"},"isNewsletterSubscribed":{"path":"/contact/subscribe"}}}}}]}}
{"updateComponents": {"surfaceId": "contact_form_1", "components": [{"id": "root", "component": "Card", "child": "form_container"}, {"id": "form_container", "component": "Column", "children": ["header_row", "name_row", "email_group", "phone_group", "pref_group", "divider_1", "newsletter_checkbox", "submit_button"], "justify": "start", "align": "stretch"}, {"id": "header_row", "component": "Row", "children": ["header_icon", "header_text"], "align": "center"}, {"id": "header_icon", "component": "Icon", "name": "mail"}, {"id": "header_text", "component": "Text", "text": "# Contact Us", "variant": "h2"}, {"id": "name_row", "component": "Row", "children": ["first_name_group", "last_name_group"], "justify": "spaceBetween"}, {"id": "first_name_group", "component": "Column", "children": ["first_name_label", "first_name_field"], "weight": 1}, {"id": "first_name_label", "component": "Text", "text": "First Name", "variant": "caption"}, {"id": "first_name_field", "component": "TextField", "label": "First Name", "value": {"path": "/contact/firstName"}, "variant": "shortText"}, {"id": "last_name_group", "component": "Column", "children": ["last_name_label", "last_name_field"], "weight": 1}, {"id": "last_name_label", "component": "Text", "text": "Last Name", "variant": "caption"}, {"id": "last_name_field", "component": "TextField", "label": "Last Name", "value": {"path": "/contact/lastName"}, "variant": "shortText"}, {"id": "email_group", "component": "Column", "children": ["email_label", "email_field"]}, {"id": "email_label", "component": "Text", "text": "Email Address", "variant": "caption"}, {"id": "email_field", "component": "TextField", "label": "Email", "value": {"path": "/contact/email"}, "variant": "shortText", "checks": [{"call": "required", "args": [{"path": "/contact/email"}], "message": "Email is required."}, {"call": "email", "args": [{"path": "/contact/email"}], "message": "Please enter a valid email address."}]}, {"id": "phone_group", "component": "Column", "children": ["phone_label", "phone_field"]}, {"id": "phone_label", "component": "Text", "text": "Phone Number", "variant": "caption"}, {"id": "phone_field", "component": "TextField", "label": "Phone", "value": {"path": "/contact/phone"}, "variant": "shortText", "checks": [{"call": "regex", "args": [{"path": "/contact/phone"}, "^\\d{10}$"], "message": "Phone number must be 10 digits."}]}, {"id": "pref_group", "component": "Column", "children": ["pref_label", "pref_picker"]}, {"id": "pref_label", "component": "Text", "text": "Preferred Contact Method", "variant": "caption"}, {"id": "pref_picker", "component": "ChoicePicker", "variant": "mutuallyExclusive", "options": [{"label": "Email", "value": "email"}, {"label": "Phone", "value": "phone"}, {"label": "SMS", "value": "sms"}], "value": {"path": "/contact/preference"}}, {"id": "divider_1", "component": "Divider", "axis": "horizontal"}, {"id": "newsletter_checkbox", "component": "CheckBox", "label": "Subscribe to our newsletter", "value": {"path": "/contact/subscribe"}}, {"id": "submit_button_label", "component": "Text", "text": "Send Message"}, {"id": "submit_button", "component": "Button", "child": "submit_button_label", "variant": "primary", "action": {"event": {"name": "submitContactForm", "context": {"formId": "contact_form_1", "clientTime": {"call": "now", "returnType": "string"}, "isNewsletterSubscribed": {"path": "/contact/subscribe"}}}}}]}}
{"updateDataModel":{"surfaceId":"contact_form_1","path":"/contact","value":{"firstName":"John","lastName":"Doe","email":"john.doe@example.com","phone":"1234567890","preference":["email"],"subscribe":true}}}
{"deleteSurface":{"surfaceId":"contact_form_1"}}