diff --git a/renderers/angular/src/v0_8/components/checkbox.ts b/renderers/angular/src/v0_8/components/checkbox.ts index e3a20aae4..97312f782 100644 --- a/renderers/angular/src/v0_8/components/checkbox.ts +++ b/renderers/angular/src/v0_8/components/checkbox.ts @@ -50,9 +50,21 @@ export class Checkbox extends DynamicComponent { 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 } }], + }); + } } } diff --git a/renderers/angular/src/v0_8/components/datetime-input.ts b/renderers/angular/src/v0_8/components/datetime-input.ts index 56bc339e2..1ff8e0b4a 100644 --- a/renderers/angular/src/v0_8/components/datetime-input.ts +++ b/renderers/angular/src/v0_8/components/datetime-input.ts @@ -63,7 +63,19 @@ export class DateTimeInput extends DynamicComponent { 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) { diff --git a/renderers/angular/src/v0_8/components/multiple-choice.ts b/renderers/angular/src/v0_8/components/multiple-choice.ts index e19265842..960285463 100644 --- a/renderers/angular/src/v0_8/components/multiple-choice.ts +++ b/renderers/angular/src/v0_8/components/multiple-choice.ts @@ -73,7 +73,19 @@ export class MultipleChoice extends DynamicComponent { 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) { diff --git a/renderers/angular/src/v0_8/components/slider.ts b/renderers/angular/src/v0_8/components/slider.ts index 39393a244..361640713 100644 --- a/renderers/angular/src/v0_8/components/slider.ts +++ b/renderers/angular/src/v0_8/components/slider.ts @@ -56,7 +56,19 @@ export class Slider extends DynamicComponent { 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) { diff --git a/renderers/angular/src/v0_8/components/surface.ts b/renderers/angular/src/v0_8/components/surface.ts index fa7002bbc..74e1bba43 100644 --- a/renderers/angular/src/v0_8/components/surface.ts +++ b/renderers/angular/src/v0_8/components/surface.ts @@ -23,10 +23,8 @@ import { Types } from '../types'; selector: 'a2ui-surface', imports: [Renderer], template: ` - @if (surface(); as s) { - @if (s.componentTree; as root) { - - } + @if (rootComponent()) { + } `, styles: ` @@ -44,6 +42,12 @@ export class Surface { readonly surfaceInput = input(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; + }); } diff --git a/renderers/angular/src/v0_8/components/text-field.ts b/renderers/angular/src/v0_8/components/text-field.ts index b1d0a89b5..3aaedd1b8 100644 --- a/renderers/angular/src/v0_8/components/text-field.ts +++ b/renderers/angular/src/v0_8/components/text-field.ts @@ -64,7 +64,19 @@ export class TextField extends DynamicComponent { 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) { diff --git a/renderers/angular/src/v0_8/data/processor.ts b/renderers/angular/src/v0_8/data/processor.ts index bdca636a8..65fc33b73 100644 --- a/renderers/angular/src/v0_8/data/processor.ts +++ b/renderers/angular/src/v0_8/data/processor.ts @@ -35,23 +35,22 @@ export class MessageProcessor { private readonly eventsSubject = new Subject(); readonly events: Observable = this.eventsSubject.asObservable(); - readonly surfacesSignal = signal>(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(); - 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[]) { @@ -89,11 +88,13 @@ export class MessageProcessor { return this.baseProcessor.resolvePath(path, dataContextPath); } - getSurfaces(): Map { - return this.baseProcessor.getSurfaces() as Map; + getSurfaces(): ReadonlyMap { + this.versionSignal(); // Track dependency + return this.baseProcessor.getSurfaces(); } clearSurfaces() { this.baseProcessor.clearSurfaces(); + this.notify(); } } diff --git a/renderers/angular/src/v0_8/rendering/renderer.ts b/renderers/angular/src/v0_8/rendering/renderer.ts index a756bc9c4..7ca43fe6d 100644 --- a/renderers/angular/src/v0_8/rendering/renderer.ts +++ b/renderers/angular/src/v0_8/rendering/renderer.ts @@ -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]', @@ -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(); readonly component = input.required(); + private currentId: string | null = null; + private currentType: string | null = null; + private currentComponentRef: ComponentRef> | null = null; + constructor() { const platformId = inject(PLATFORM_ID); const document = inject(DOCUMENT); @@ -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; @@ -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 | 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>; + this.currentComponentRef = componentRef; + this.updateInputs(componentRef, node, surfaceId); + } + }); + } else if (componentTypeOrPromise) { + const componentRef = container.createComponent(componentTypeOrPromise) as ComponentRef>; + this.currentComponentRef = componentRef; + this.updateInputs(componentRef, node, surfaceId); + } + } + + private resolveComponentType(config: any): Type | Promise> | 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; - 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>, + 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; + 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(); } } diff --git a/samples/client/angular/projects/contact/src/app/app.ts b/samples/client/angular/projects/contact/src/app/app.ts index 4caf11503..9cf25b81e 100644 --- a/samples/client/angular/projects/contact/src/app/app.ts +++ b/samples/client/angular/projects/contact/src/app/app.ts @@ -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(() => { diff --git a/samples/client/angular/projects/restaurant/src/app/app.html b/samples/client/angular/projects/restaurant/src/app/app.html index 02a3a5ea3..82b5966cf 100644 --- a/samples/client/angular/projects/restaurant/src/app/app.html +++ b/samples/client/angular/projects/restaurant/src/app/app.html @@ -43,7 +43,7 @@

Restaurant Finder

@let surfaces = processor.getSurfaces(); - @for (entry of surfaces; track $index) { + @for (entry of surfaces; track entry[0]) { }