Skip to content
Merged
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
20 changes: 16 additions & 4 deletions renderers/angular/src/v0_8/components/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,21 @@ export class Checkbox extends DynamicComponent<Types.CheckboxNode> {

onToggle(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.sendAction({
name: 'toggle',
context: [{ key: 'checked', value: { literalBoolean: checked } }],
});
const checkedNode = this.checked();
if (checkedNode && typeof checkedNode === 'object' && 'path' in checkedNode && checkedNode.path) {
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
this.processor.processMessages([{
dataModelUpdate: {
surfaceId: this.surfaceId()!,
path: this.processor.resolvePath(checkedNode.path as string, this.component().dataContextPath),
contents: [{ key: '.', valueBoolean: checked }],
},
}]);
} else {
this.sendAction({
name: 'toggle',
context: [{ key: 'checked', value: { literalBoolean: checked } }],
});
}
}
}
14 changes: 13 additions & 1 deletion renderers/angular/src/v0_8/components/datetime-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,19 @@ export class DateTimeInput extends DynamicComponent<Types.DateTimeInputNode> {

onChange(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.handleAction('change', { value });
const valueNode = this.value();
if (valueNode && typeof valueNode === 'object' && 'path' in valueNode && valueNode.path) {
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
this.processor.processMessages([{
dataModelUpdate: {
surfaceId: this.surfaceId()!,
path: this.processor.resolvePath(valueNode.path as string, this.component().dataContextPath),
contents: [{ key: '.', valueString: value }],
},
}]);
} else {
this.handleAction('change', { value });
}
}

private handleAction(name: string, context: Record<string, unknown>) {
Expand Down
14 changes: 13 additions & 1 deletion renderers/angular/src/v0_8/components/multiple-choice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,19 @@ export class MultipleChoice extends DynamicComponent<Types.MultipleChoiceNode> {

onChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
this.handleAction('change', { value });
const selectionsNode = this.selections();
if (selectionsNode && typeof selectionsNode === 'object' && 'path' in selectionsNode && selectionsNode.path) {
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
this.processor.processMessages([{
dataModelUpdate: {
surfaceId: this.surfaceId()!,
path: this.processor.resolvePath(selectionsNode.path as string, this.component().dataContextPath),
contents: [{ key: '.', valueString: JSON.stringify({ literalArray: [value] }) }],
},
}]);
} else {
this.handleAction('change', { value });
}
}

private handleAction(name: string, context: Record<string, unknown>) {
Expand Down
14 changes: 13 additions & 1 deletion renderers/angular/src/v0_8/components/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,19 @@ export class Slider extends DynamicComponent<Types.SliderNode> {

onInput(event: Event) {
const value = Number((event.target as HTMLInputElement).value);
this.handleAction('change', { value });
const valueNode = this.value();
if (valueNode && typeof valueNode === 'object' && 'path' in valueNode && valueNode.path) {
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
this.processor.processMessages([{
dataModelUpdate: {
surfaceId: this.surfaceId()!,
path: this.processor.resolvePath(valueNode.path as string, this.component().dataContextPath),
contents: [{ key: '.', valueNumber: value }],
},
}]);
} else {
this.handleAction('change', { value });
}
}

private handleAction(name: string, context: Record<string, unknown>) {
Expand Down
12 changes: 8 additions & 4 deletions renderers/angular/src/v0_8/components/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ import { Types } from '../types';
selector: 'a2ui-surface',
imports: [Renderer],
template: `
@if (surface(); as s) {
@if (s.componentTree; as root) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()" [component]="root" />
}
@if (rootComponent()) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()" [component]="rootComponent()!" />
}
`,
styles: `
Expand All @@ -44,6 +42,12 @@ export class Surface {
readonly surfaceInput = input<Types.Surface | null>(null, { alias: 'surface' });

protected readonly surface = computed(() => {
this.processor.version(); // Track dependency on in-place mutations
return this.surfaceInput() ?? this.processor.getSurfaces().get(this.surfaceId()) ?? null;
});

protected readonly rootComponent = computed(() => {
this.processor.version(); // Track dependency on in-place mutations
return this.surface()?.componentTree ?? null;
});
}
14 changes: 13 additions & 1 deletion renderers/angular/src/v0_8/components/text-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,19 @@ export class TextField extends DynamicComponent<Types.TextFieldNode> {

onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.handleAction('input', { value });
const textNode = this.text();
if (textNode && typeof textNode === 'object' && 'path' in textNode && textNode.path) {
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
this.processor.processMessages([{
dataModelUpdate: {
surfaceId: this.surfaceId()!,
path: this.processor.resolvePath(textNode.path as string, this.component().dataContextPath),
contents: [{ key: '.', valueString: value }],
},
}]);
} else {
this.handleAction('input', { value });
}
}

private handleAction(name: string, context: Record<string, unknown>) {
Expand Down
27 changes: 14 additions & 13 deletions renderers/angular/src/v0_8/data/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,22 @@ export class MessageProcessor {

private readonly eventsSubject = new Subject<A2UIClientEvent>();
readonly events: Observable<A2UIClientEvent> = this.eventsSubject.asObservable();
readonly surfacesSignal = signal<ReadonlyMap<string, WebCore.Surface>>(new Map());
// Signal to track the version of the data in the MessageProcessor. Since the base processor updates
// surfaces in-place (mutating the Map), we use this to force Angular's change detection to
// re-evaluate any components or effects that depend on getSurfaces().
private readonly versionSignal = signal(0);
readonly version = this.versionSignal.asReadonly();

constructor() {
this.baseProcessor = new WebCore.A2uiMessageProcessor();
}

/**
* Increments the version signal to notify Angular that the data model has changed.
* This should be called after any update to the underlying base processor's surfaces.
*/
private notify() {
// Angular signals (and change detection) are based on reference equality for
// objects. During streaming, the base MessageProcessor updates surfaces in-place.
// By shallow-cloning the surface objects into a new Map, we ensure that
// anything watching surfacesSignal() correctly detects that the data has
// changed, even if only internal properties of a surface were updated.
const clonedSurfaces = new Map<string, WebCore.Surface>();
for (const [id, surface] of this.getSurfaces()) {
clonedSurfaces.set(id, { ...surface });
}
this.surfacesSignal.set(clonedSurfaces);
this.versionSignal.update((v) => v + 1);
}

processMessages(messages: Types.ServerToClientMessage[]) {
Expand Down Expand Up @@ -89,11 +88,13 @@ export class MessageProcessor {
return this.baseProcessor.resolvePath(path, dataContextPath);
}

getSurfaces(): Map<string, WebCore.Surface> {
return this.baseProcessor.getSurfaces() as Map<string, WebCore.Surface>;
getSurfaces(): ReadonlyMap<string, WebCore.Surface> {
this.versionSignal(); // Track dependency
return this.baseProcessor.getSurfaces();
}

clearSurfaces() {
this.baseProcessor.clearSurfaces();
this.notify();
}
}
111 changes: 85 additions & 26 deletions renderers/angular/src/v0_8/rendering/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ import {
ViewContainerRef,
Type,
PLATFORM_ID,
ComponentRef,
} from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { structuralStyles } from '@a2ui/web_core/styles/index';
import { Catalog } from './catalog';
import { MessageProcessor } from '../data';
import { Types } from '../types';
import { DynamicComponent } from './dynamic-component';

@Directive({
selector: '[a2ui-renderer]',
Expand All @@ -37,10 +40,15 @@ export class Renderer {

private readonly catalog = inject(Catalog);
private readonly container = inject(ViewContainerRef);
private readonly processor: MessageProcessor = inject(MessageProcessor);

readonly surfaceId = input.required<Types.SurfaceID>();
readonly component = input.required<Types.AnyComponentNode>();

private currentId: string | null = null;
private currentType: string | null = null;
private currentComponentRef: ComponentRef<DynamicComponent<Types.AnyComponentNode>> | null = null;

constructor() {
const platformId = inject(PLATFORM_ID);
const document = inject(DOCUMENT);
Expand All @@ -53,10 +61,14 @@ export class Renderer {
}

effect(() => {
const container = this.container;
container.clear();
// Explicitly depend on the MessageProcessor's version signal. This ensures that the effect re-runs
// whenever data model changes occur, even if the node's object reference remains identical
// (as in the case of in-place mutations from local updates).
this.processor.version();

let node = this.component();
const surfaceId = this.surfaceId();

// Handle v0.8 wrapped component format
if (!node.type && (node as any).component) {
const wrapped = (node as any).component;
Expand All @@ -70,48 +82,95 @@ export class Renderer {
}
}

const config = this.catalog[node.type];
const id = node.id;
const type = node.type;

// Focus Loss Prevention:
// If we have an existing component and its unique identity (ID and Type) hasn't changed,
// we update its @Input() values in-place. This preserves the underlying DOM element,
// maintaining focus, text selection, and cursor position.
if (this.currentComponentRef && this.currentId === id && this.currentType === type) {
this.updateInputs(this.currentComponentRef, node, surfaceId);
return;
}

// Otherwise, clear and re-create the component because its identity has changed.
const container = this.container;
container.clear();
this.currentComponentRef = null;
this.currentId = id;
this.currentType = type;

const config = this.catalog[node.type];
if (!config) {
console.error(`Unknown component type: ${node.type}`);
return;
}

this.render(container, node, config);
this.render(container, node, surfaceId, config);
});
}

private async render(container: ViewContainerRef, node: Types.AnyComponentNode, config: any) {
let componentType: Type<unknown> | null = null;
private render(
container: ViewContainerRef,
node: Types.AnyComponentNode,
surfaceId: string,
config: any,
) {
const componentTypeOrPromise = this.resolveComponentType(config);

if (componentTypeOrPromise instanceof Promise) {
componentTypeOrPromise.then((componentType) => {
// Ensure we are still supposed to render this component
if (this.currentId === node.id && this.currentType === node.type) {
const componentRef = container.createComponent(componentType) as ComponentRef<DynamicComponent<Types.AnyComponentNode>>;
this.currentComponentRef = componentRef;
this.updateInputs(componentRef, node, surfaceId);
}
});
} else if (componentTypeOrPromise) {
const componentRef = container.createComponent(componentTypeOrPromise) as ComponentRef<DynamicComponent<Types.AnyComponentNode>>;
this.currentComponentRef = componentRef;
this.updateInputs(componentRef, node, surfaceId);
}
}

private resolveComponentType(config: any): Type<unknown> | Promise<Type<unknown>> | null {
if (typeof config === 'function') {
const res = config();
componentType = res instanceof Promise ? await res : res;
return config();
} else if (typeof config === 'object' && config !== null) {
if (typeof config.type === 'function') {
const res = config.type();
componentType = res instanceof Promise ? await res : res;
return config.type();
} else {
componentType = config.type;
return config.type;
}
}
return null;
}

if (componentType) {
const componentRef = container.createComponent(componentType);
componentRef.setInput('surfaceId', this.surfaceId());
componentRef.setInput('component', node);
componentRef.setInput('weight', node.weight ?? 0);

const props = node.properties as Record<string, unknown>;
for (const [key, value] of Object.entries(props)) {
try {
componentRef.setInput(key, value);
} catch (e) {
console.warn(
`[Renderer] Property "${key}" could not be set on component ${node.type}. If this property is required by the specification, ensure the component declares it as an input.`,
);
}
/**
* Updates the inputs of an existing component instance with the latest data from the node.
* This is called during component reuse to keep the UI in sync without losing DOM state (like focus).
*/
private updateInputs(
componentRef: ComponentRef<DynamicComponent<Types.AnyComponentNode>>,
node: Types.AnyComponentNode,
surfaceId: string,
) {
componentRef.setInput('surfaceId', surfaceId);
componentRef.setInput('component', node);
componentRef.setInput('weight', node.weight ?? 0);

const props = node.properties as Record<string, unknown>;
for (const [key, value] of Object.entries(props)) {
try {
componentRef.setInput(key, value);
} catch (e) {
console.warn(
`[Renderer] Property "${key}" could not be set on component ${node.type}. If this property is required by the specification, ensure the component declares it as an input.`,
);
}
}
componentRef.changeDetectorRef.markForCheck();
}
}
2 changes: 1 addition & 1 deletion samples/client/angular/projects/contact/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class App {
protected hasData = signal(false);
protected userInput = signal('Casey Smith');
protected surfaces = computed(() => {
return Array.from(this.processor.surfacesSignal().entries());
return Array.from(this.processor.getSurfaces().entries());
});

protected statusText = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ <h1 class="app-title">Restaurant Finder</h1>
<div class="surfaces">
@let surfaces = processor.getSurfaces();

@for (entry of surfaces; track $index) {
@for (entry of surfaces; track entry[0]) {
<a2ui-surface [surfaceId]="entry[0]" [surface]="entry[1]"/>
}
</div>
Expand Down
Loading