Skip to content

Prototype react renderer#827

Draft
jacobsimionato wants to merge 33 commits intogoogle:mainfrom
jacobsimionato:react-1
Draft

Prototype react renderer#827
jacobsimionato wants to merge 33 commits intogoogle:mainfrom
jacobsimionato:react-1

Conversation

@jacobsimionato
Copy link
Collaborator

@jacobsimionato jacobsimionato commented Mar 12, 2026

Description

This PR introduces the @a2ui/react_prototype package, a React-based renderer for the A2UI protocol (v0.9). It leverages @a2ui/web_core for the framework-agnostic data layer and provides a reactive bridge to React components using useSyncExternalStore.

Key Features

  • Reactive Data Binding: Components automatically re-render when the underlying A2UI data model changes, powered by rxjs and useSyncExternalStore.
  • Generic Component Adapter: The createReactComponent utility automatically resolves A2UI DynamicValue fields (like DynamicString) into standard JavaScript types for the React component. It also automatically generates set<Property> callbacks (e.g., setValue) for two-way data binding.
  • Full Basic Catalog: Implements the complete set of A2UI 0.9 basic components, including layout (Row, Column, List), inputs (TextField, Slider, ChoicePicker), and interactive elements (Modal, Tabs).
  • Recursive Rendering: The A2uiSurface root component manages the recursive construction of the UI tree, handling structural properties and child building.

API Examples

Rendering a Surface

The A2uiSurface is the entry point for rendering an A2UI interface. It requires a SurfaceModel initialized with a compatible catalog.

import { A2uiSurface, basicCatalog } from '@a2ui/react_prototype';
import { MessageProcessor } from '@a2ui/web_core/v0_9';

// 1. Initialize the data layer
const processor = new MessageProcessor(basicCatalog);

// 2. Consume messages from your agent/transport
processor.receiveMessage({
  version: 'v0.9',
  createSurface: {
    surfaceId: 'my-surface',
    catalogId: 'https://a2ui.org/specification/v0_9/basic_catalog.json'
  }
});

// 3. Render in React
const MyComponent = () => {
  const surface = processor.getSurface('my-surface');
  return surface ? <A2uiSurface surface={surface} /> : <div>Loading...</div>;
};

Creating Custom Components

You can extend the renderer by creating new components using createReactComponent. The adapter handles schema validation and data-binding automatically.

import { createReactComponent } from '@a2ui/react_prototype';
import { CommonSchemas } from '@a2ui/web_core/v0_9';
import { z } from 'zod';

// 1. Define the API
const MyCustomApi = {
  name: 'CustomCard',
  schema: z.object({
    title: CommonSchemas.DynamicString,
    content: CommonSchemas.ComponentId
  })
};

// 2. Implement the implementation
export const ReactCustomCard = createReactComponent(
  MyCustomApi,
  ({ props, buildChild }) => {
    return (
      <div className="custom-card">
        <h3>{props.title}</h3>
        <div className="card-body">
          {props.content && buildChild(props.content)}
        </div>
      </div>
    );
  }
);

Two-Way Data Binding

The generic binder automatically generates setter functions for properties that accept DynamicValues. This makes updating the data model simple.

export const ReactSimpleInput = createReactComponent(
  SimpleInputApi,
  ({ props }) => {
    const handleChange = (e) => {
      // The GenericBinder provides a `setValue` function if `value` is a DynamicValue
      if (props.setValue) {
        props.setValue(e.target.value);
      }
    };

    return <input value={props.value || ""} onChange={handleChange} />;
  }
);

Implemented Components

The basicCatalog currently includes React implementations for:

  • Layout: Row, Column, List, Card, Divider.
  • Display: Text, Image, Icon, Video, AudioPlayer.
  • Inputs: Button, TextField, CheckBox, ChoicePicker, Slider, DateTimeInput.
  • Navigation/Overlays: Tabs, Modal.

Pre-launch Checklist

  • I signed the [CLA].
  • I read the [Contributors Guide].
  • I read the [Style Guide].
  • I have added updates to the [CHANGELOG].
  • I updated/added relevant documentation.
  • My code changes (if any) have tests.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a prototype React renderer for A2UI, including the core rendering logic, a component library for the minimal catalog, and a gallery application for demonstration and testing. The implementation is well-structured and follows the provided design document, leveraging modern React features like useSyncExternalStore for reactivity. My review includes a few suggestions to improve type safety, package configuration, and test coverage.

RenderComponent: React.FC<ReactA2uiComponentProps<T>>
) {
return function ReactWrapper({ context, buildChild }: { context: ComponentContext, buildChild: (id: string, basePath?: string) => React.ReactNode }) {
const bindingRef = useRef<ComponentBinding<T>>(null);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The useRef is initialized with null, but its generic type ComponentBinding<T> does not include null. With strict null checks enabled in your tsconfig.json, this will cause a type error. The ref's type should be explicitly set to ComponentBinding<T> | null to reflect its possible null value.

Suggested change
const bindingRef = useRef<ComponentBinding<T>>(null);
const bindingRef = useRef<ComponentBinding<T> | null>(null);

Comment on lines +14 to +17
"peerDependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

rxjs and zod are used by the library but are not listed as peerDependencies. Based on the installation instructions in README.md and the externals in vite.config.ts, they should be declared as peerDependencies to ensure that the consuming application provides compatible versions.

  "peerDependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "rxjs": "^7.8.1",
    "zod": "^3.23.8"
  }

}
};
// resolve synchronous initial value
bindingRef.current.currentVal = Array.isArray(context.dataContext.resolveDynamicValue({ path: childList.path })) ? context.dataContext.resolveDynamicValue({ path: childList.path }) as any[] : EMPTY_ARRAY;
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The dynamic value at childList.path is resolved twice in this line. You can store the result in a variable to avoid the redundant call and improve performance slightly.

        const initialValue = context.dataContext.resolveDynamicValue({ path: childList.path });
        bindingRef.current.currentVal = Array.isArray(initialValue) ? initialValue as any[] : EMPTY_ARRAY;

Comment on lines +79 to +101
const spyAddListener = vi.spyOn(context.dataContext, 'subscribeDynamicValue');

type TestProps = { text?: string };

const RenderTest: React.FC<{ props: TestProps }> = ({ props }) => {
return <div>{props.text}</div>;
};

const TestComponent = createReactComponent<TestProps>(
(ctx) => createGenericBinding<TestProps>(ctx, []),
RenderTest as any
);

const { unmount } = render(<TestComponent context={context} buildChild={() => null} />);

expect(spyAddListener).toHaveBeenCalled();
// One listener added

unmount();

// We would need a way to check if removeListener was called, but checking that the binding logic doesn't crash on unmount is a good start.
// If listeners aren't cleaned up, subsequent updates might throw if component is destroyed.
});
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 test for cleanup on unmount can be made more robust. You can verify that the unsubscribe function is actually called by mocking the return value of subscribeDynamicValue and spying on its unsubscribe method. This provides a more direct confirmation that cleanup is happening correctly.

    const unsubscribeSpy = vi.fn();
    const spyAddListener = vi.spyOn(context.dataContext, 'subscribeDynamicValue').mockReturnValue({
      value: 'initial',
      unsubscribe: unsubscribeSpy,
    });

    type TestProps = { text?: string };

    const RenderTest: React.FC<{ props: TestProps }> = ({ props }) => {
      return <div>{props.text}</div>;
    };

    const TestComponent = createReactComponent<TestProps>(
      (ctx) => createGenericBinding<TestProps>(ctx, []),
      RenderTest as any
    );

    const { unmount } = render(<TestComponent context={context} buildChild={() => null} />);

    expect(spyAddListener).toHaveBeenCalled();
    
    unmount();
    
    expect(unsubscribeSpy).toHaveBeenCalled();

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

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant