Skip to content
Draft
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
2 changes: 0 additions & 2 deletions renderers/markdown/markdown-it/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 99 additions & 0 deletions renderers/react/a2ui_explorer/src/Integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { render, screen, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MessageProcessor } from '@a2ui/web_core/v0_9';
import { basicCatalog, A2uiSurface } from '@a2ui/react/v0_9';

describe('A2UI React Integration', () => {
it('renders a reactive node tree from messages', async () => {
const processor = new MessageProcessor([basicCatalog]);

render(<A2uiSurface surface={processor.model.getSurface('s1') as any} />);

// 1. Create surface
await act(async () => {
processor.processMessages([
{
version: 'v0.9',
createSurface: {
surfaceId: 's1',
catalogId: 'https://a2ui.org/specification/v0_9/basic_catalog.json'
}
}
]);
});

// Re-render with the new surface
const surface = processor.model.getSurface('s1');
expect(surface).toBeDefined();

render(<A2uiSurface surface={surface as any} />);

// 2. Add components
await act(async () => {
processor.processMessages([
{
version: 'v0.9',
updateComponents: {
surfaceId: 's1',
components: [
{
id: 'root',
component: 'Card',
child: 'title'
},
{
id: 'title',
component: 'Text',
text: { path: '/title' }
}
]
}
}
]);
});

// Should show loading or empty initially as data is missing
expect(screen.queryByText('Hello World')).toBeNull();

// 3. Update data
await act(async () => {
processor.processMessages([
{
version: 'v0.9',
updateDataModel: {
surfaceId: 's1',
path: '/title',
value: 'Hello World'
}
}
]);
});

// Now it should be rendered
expect(screen.getByText('Hello World')).toBeDefined();

// 4. Update data reactively
await act(async () => {
surface?.dataModel.set('/title', 'Updated Title');
});

expect(screen.getByText('Updated Title')).toBeDefined();
expect(screen.queryByText('Hello World')).toBeNull();
});
});
4 changes: 2 additions & 2 deletions renderers/react/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

159 changes: 65 additions & 94 deletions renderers/react/src/v0_9/A2uiSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,119 +14,90 @@
* limitations under the License.
*/

import React, {useSyncExternalStore, memo, useMemo, useCallback} from 'react';
import {type SurfaceModel, ComponentContext, type ComponentModel} from '@a2ui/web_core/v0_9';
import React, {useSyncExternalStore, memo, useCallback, createContext, useContext} from 'react';
import {type SurfaceModel, type A2uiNode} from '@a2ui/web_core/v0_9';
import type {ReactComponentImplementation} from './adapter';

const ResolvedChild = memo(
({
surface,
id,
basePath,
compImpl,
componentModel,
}: {
surface: SurfaceModel<ReactComponentImplementation>;
id: string;
basePath: string;
componentModel: ComponentModel;
compImpl: ReactComponentImplementation;
}) => {
const ComponentToRender = compImpl.render;

// Create context. Recreate if the componentModel instance changes (e.g. type change recreation).
const context = useMemo(
() => new ComponentContext(surface, id, basePath),
// componentModel is used as a trigger for recreation even if not in the body
// eslint-disable-next-line react-hooks/exhaustive-deps
[surface, id, basePath, componentModel]
);

const buildChild = useCallback(
(childId: string, specificPath?: string) => {
const path = specificPath || context.dataContext.path;
return (
<DeferredChild
key={`${childId}-${path}`}
surface={surface}
id={childId}
basePath={path}
/>
);
},
[surface, context.dataContext.path]
);
export const SurfaceContext = createContext<SurfaceModel<ReactComponentImplementation> | undefined>(
undefined
);

return <ComponentToRender context={context} buildChild={buildChild} />;
export const useSurface = () => {
const surface = useContext(SurfaceContext);
if (!surface) {
throw new Error('useSurface must be used within an A2uiSurface');
}
);
ResolvedChild.displayName = 'ResolvedChild';
return surface;
};

export const DeferredChild: React.FC<{
surface: SurfaceModel<ReactComponentImplementation>;
id: string;
basePath: string;
}> = memo(({surface, id, basePath}) => {
// 1. Subscribe specifically to this component's existence
const store = useMemo(() => {
let version = 0;
return {
subscribe: (cb: () => void) => {
const unsub1 = surface.componentsModel.onCreated.subscribe((comp) => {
if (comp.id === id) {
version++;
cb();
}
});
const unsub2 = surface.componentsModel.onDeleted.subscribe((delId) => {
if (delId === id) {
version++;
cb();
}
const useNodeStore = (signal?: {
subscribe: (cb: (val: unknown) => void) => () => void;
peek: () => any;

Check warning on line 35 in renderers/react/src/v0_9/A2uiSurface.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
}) => {
const subscribe = useCallback(
(callback: () => void) => {
if (!signal) return () => {};
const dispose = signal.subscribe(() => {
callback();
});
return () => dispose();
},
[signal]
);
const getSnapshot = useCallback(() => signal?.peek(), [signal]);
return useSyncExternalStore(subscribe, getSnapshot);
};

export const NodeRenderer = memo(({node}: {node: A2uiNode}) => {
const surface = useSurface();
// 1. Subscribe specifically to this node's destruction
const isDestroyed = useSyncExternalStore(
useCallback(
(cb: () => void) => {
let active = true;
const sub = node.onDestroyed.subscribe(() => {
if (active) cb();
});
return () => {
unsub1.unsubscribe();
unsub2.unsubscribe();
active = false;
sub.unsubscribe();
};
},
getSnapshot: () => {
const comp = surface.componentsModel.get(id);
// We use instance identity + version as the snapshot to ensure
// type replacements (e.g. Button -> Text) trigger a re-render.
return comp ? `${comp.type}-${version}` : `missing-${version}`;
},
};
}, [surface, id]);

useSyncExternalStore(store.subscribe, store.getSnapshot);

const componentModel = surface.componentsModel.get(id);
[node]
),
() => false // It's only true if the callback fires, causing unmount
);

if (!componentModel) {
return <div style={{color: 'gray', padding: '4px'}}>[Loading {id}...]</div>;
if (isDestroyed) {
return null;
}

const compImpl = surface.catalog.components.get(componentModel.type);
const compImpl = surface.catalog.components.get(node.type);

if (!compImpl) {
return <div style={{color: 'red'}}>Unknown component: {componentModel.type}</div>;
return <div style={{color: 'red'}}>Unknown component: {node.type}</div>;
}

return (
<ResolvedChild
surface={surface}
id={id}
basePath={basePath}
componentModel={componentModel}
compImpl={compImpl}
/>
);
const ComponentToRender = compImpl.render;

return <ComponentToRender node={node} />;
});
DeferredChild.displayName = 'DeferredChild';
NodeRenderer.displayName = 'NodeRenderer';

export const A2uiSurface: React.FC<{surface: SurfaceModel<ReactComponentImplementation>}> = ({
surface,
}) => {
// The root component always has ID 'root' and base path '/'
return <DeferredChild surface={surface} id="root" basePath="/" />;
const rootNode = useNodeStore(surface?.rootNode);

if (!surface) return null;

return (
<SurfaceContext.Provider value={surface}>
{!rootNode ? (
<div style={{color: 'gray', padding: '4px'}}>[Loading root...]</div>
) : (
<NodeRenderer node={rootNode} />
)}
</SurfaceContext.Provider>
);
};
Loading
Loading