Skip to content

Commit 05df08a

Browse files
authored
client: avoid necessary network requests and fix focus loss (#984)
* fix(angular): reduce redundant network requests via local data model updates * fix(angular): prevent focus loss by reusing components and using stable tracks * Address review comments
1 parent f3a23e4 commit 05df08a

File tree

10 files changed

+177
-53
lines changed

10 files changed

+177
-53
lines changed

renderers/angular/src/v0_8/components/checkbox.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,21 @@ export class Checkbox extends DynamicComponent<Types.CheckboxNode> {
5050

5151
onToggle(event: Event) {
5252
const checked = (event.target as HTMLInputElement).checked;
53-
this.sendAction({
54-
name: 'toggle',
55-
context: [{ key: 'checked', value: { literalBoolean: checked } }],
56-
});
53+
const checkedNode = this.checked();
54+
if (checkedNode && typeof checkedNode === 'object' && 'path' in checkedNode && checkedNode.path) {
55+
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
56+
this.processor.processMessages([{
57+
dataModelUpdate: {
58+
surfaceId: this.surfaceId()!,
59+
path: this.processor.resolvePath(checkedNode.path as string, this.component().dataContextPath),
60+
contents: [{ key: '.', valueBoolean: checked }],
61+
},
62+
}]);
63+
} else {
64+
this.sendAction({
65+
name: 'toggle',
66+
context: [{ key: 'checked', value: { literalBoolean: checked } }],
67+
});
68+
}
5769
}
5870
}

renderers/angular/src/v0_8/components/datetime-input.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,19 @@ export class DateTimeInput extends DynamicComponent<Types.DateTimeInputNode> {
6363

6464
onChange(event: Event) {
6565
const value = (event.target as HTMLInputElement).value;
66-
this.handleAction('change', { value });
66+
const valueNode = this.value();
67+
if (valueNode && typeof valueNode === 'object' && 'path' in valueNode && valueNode.path) {
68+
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
69+
this.processor.processMessages([{
70+
dataModelUpdate: {
71+
surfaceId: this.surfaceId()!,
72+
path: this.processor.resolvePath(valueNode.path as string, this.component().dataContextPath),
73+
contents: [{ key: '.', valueString: value }],
74+
},
75+
}]);
76+
} else {
77+
this.handleAction('change', { value });
78+
}
6779
}
6880

6981
private handleAction(name: string, context: Record<string, unknown>) {

renderers/angular/src/v0_8/components/multiple-choice.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,19 @@ export class MultipleChoice extends DynamicComponent<Types.MultipleChoiceNode> {
7373

7474
onChange(event: Event) {
7575
const value = (event.target as HTMLSelectElement).value;
76-
this.handleAction('change', { value });
76+
const selectionsNode = this.selections();
77+
if (selectionsNode && typeof selectionsNode === 'object' && 'path' in selectionsNode && selectionsNode.path) {
78+
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
79+
this.processor.processMessages([{
80+
dataModelUpdate: {
81+
surfaceId: this.surfaceId()!,
82+
path: this.processor.resolvePath(selectionsNode.path as string, this.component().dataContextPath),
83+
contents: [{ key: '.', valueString: JSON.stringify({ literalArray: [value] }) }],
84+
},
85+
}]);
86+
} else {
87+
this.handleAction('change', { value });
88+
}
7789
}
7890

7991
private handleAction(name: string, context: Record<string, unknown>) {

renderers/angular/src/v0_8/components/slider.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,19 @@ export class Slider extends DynamicComponent<Types.SliderNode> {
5656

5757
onInput(event: Event) {
5858
const value = Number((event.target as HTMLInputElement).value);
59-
this.handleAction('change', { value });
59+
const valueNode = this.value();
60+
if (valueNode && typeof valueNode === 'object' && 'path' in valueNode && valueNode.path) {
61+
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
62+
this.processor.processMessages([{
63+
dataModelUpdate: {
64+
surfaceId: this.surfaceId()!,
65+
path: this.processor.resolvePath(valueNode.path as string, this.component().dataContextPath),
66+
contents: [{ key: '.', valueNumber: value }],
67+
},
68+
}]);
69+
} else {
70+
this.handleAction('change', { value });
71+
}
6072
}
6173

6274
private handleAction(name: string, context: Record<string, unknown>) {

renderers/angular/src/v0_8/components/surface.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,8 @@ import { Types } from '../types';
2323
selector: 'a2ui-surface',
2424
imports: [Renderer],
2525
template: `
26-
@if (surface(); as s) {
27-
@if (s.componentTree; as root) {
28-
<ng-container a2ui-renderer [surfaceId]="surfaceId()" [component]="root" />
29-
}
26+
@if (rootComponent()) {
27+
<ng-container a2ui-renderer [surfaceId]="surfaceId()" [component]="rootComponent()!" />
3028
}
3129
`,
3230
styles: `
@@ -44,6 +42,12 @@ export class Surface {
4442
readonly surfaceInput = input<Types.Surface | null>(null, { alias: 'surface' });
4543

4644
protected readonly surface = computed(() => {
45+
this.processor.version(); // Track dependency on in-place mutations
4746
return this.surfaceInput() ?? this.processor.getSurfaces().get(this.surfaceId()) ?? null;
4847
});
48+
49+
protected readonly rootComponent = computed(() => {
50+
this.processor.version(); // Track dependency on in-place mutations
51+
return this.surface()?.componentTree ?? null;
52+
});
4953
}

renderers/angular/src/v0_8/components/text-field.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,19 @@ export class TextField extends DynamicComponent<Types.TextFieldNode> {
6464

6565
onInput(event: Event) {
6666
const value = (event.target as HTMLInputElement).value;
67-
this.handleAction('input', { value });
67+
const textNode = this.text();
68+
if (textNode && typeof textNode === 'object' && 'path' in textNode && textNode.path) {
69+
// Update the local data model directly to ensure immediate UI feedback and avoid unnecessary network requests.
70+
this.processor.processMessages([{
71+
dataModelUpdate: {
72+
surfaceId: this.surfaceId()!,
73+
path: this.processor.resolvePath(textNode.path as string, this.component().dataContextPath),
74+
contents: [{ key: '.', valueString: value }],
75+
},
76+
}]);
77+
} else {
78+
this.handleAction('input', { value });
79+
}
6880
}
6981

7082
private handleAction(name: string, context: Record<string, unknown>) {

renderers/angular/src/v0_8/data/processor.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,22 @@ export class MessageProcessor {
3535

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

4044
constructor() {
4145
this.baseProcessor = new WebCore.A2uiMessageProcessor();
4246
}
4347

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

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

92-
getSurfaces(): Map<string, WebCore.Surface> {
93-
return this.baseProcessor.getSurfaces() as Map<string, WebCore.Surface>;
91+
getSurfaces(): ReadonlyMap<string, WebCore.Surface> {
92+
this.versionSignal(); // Track dependency
93+
return this.baseProcessor.getSurfaces();
9494
}
9595

9696
clearSurfaces() {
9797
this.baseProcessor.clearSurfaces();
98+
this.notify();
9899
}
99100
}

renderers/angular/src/v0_8/rendering/renderer.ts

Lines changed: 85 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@ import {
2222
ViewContainerRef,
2323
Type,
2424
PLATFORM_ID,
25+
ComponentRef,
2526
} from '@angular/core';
2627
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
2728
import { structuralStyles } from '@a2ui/web_core/styles/index';
2829
import { Catalog } from './catalog';
30+
import { MessageProcessor } from '../data';
2931
import { Types } from '../types';
32+
import { DynamicComponent } from './dynamic-component';
3033

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

3841
private readonly catalog = inject(Catalog);
3942
private readonly container = inject(ViewContainerRef);
43+
private readonly processor: MessageProcessor = inject(MessageProcessor);
4044

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

48+
private currentId: string | null = null;
49+
private currentType: string | null = null;
50+
private currentComponentRef: ComponentRef<DynamicComponent<Types.AnyComponentNode>> | null = null;
51+
4452
constructor() {
4553
const platformId = inject(PLATFORM_ID);
4654
const document = inject(DOCUMENT);
@@ -53,10 +61,14 @@ export class Renderer {
5361
}
5462

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

5969
let node = this.component();
70+
const surfaceId = this.surfaceId();
71+
6072
// Handle v0.8 wrapped component format
6173
if (!node.type && (node as any).component) {
6274
const wrapped = (node as any).component;
@@ -70,48 +82,95 @@ export class Renderer {
7082
}
7183
}
7284

73-
const config = this.catalog[node.type];
85+
const id = node.id;
86+
const type = node.type;
7487

88+
// Focus Loss Prevention:
89+
// If we have an existing component and its unique identity (ID and Type) hasn't changed,
90+
// we update its @Input() values in-place. This preserves the underlying DOM element,
91+
// maintaining focus, text selection, and cursor position.
92+
if (this.currentComponentRef && this.currentId === id && this.currentType === type) {
93+
this.updateInputs(this.currentComponentRef, node, surfaceId);
94+
return;
95+
}
96+
97+
// Otherwise, clear and re-create the component because its identity has changed.
98+
const container = this.container;
99+
container.clear();
100+
this.currentComponentRef = null;
101+
this.currentId = id;
102+
this.currentType = type;
103+
104+
const config = this.catalog[node.type];
75105
if (!config) {
76106
console.error(`Unknown component type: ${node.type}`);
77107
return;
78108
}
79109

80-
this.render(container, node, config);
110+
this.render(container, node, surfaceId, config);
81111
});
82112
}
83113

84-
private async render(container: ViewContainerRef, node: Types.AnyComponentNode, config: any) {
85-
let componentType: Type<unknown> | null = null;
114+
private render(
115+
container: ViewContainerRef,
116+
node: Types.AnyComponentNode,
117+
surfaceId: string,
118+
config: any,
119+
) {
120+
const componentTypeOrPromise = this.resolveComponentType(config);
86121

122+
if (componentTypeOrPromise instanceof Promise) {
123+
componentTypeOrPromise.then((componentType) => {
124+
// Ensure we are still supposed to render this component
125+
if (this.currentId === node.id && this.currentType === node.type) {
126+
const componentRef = container.createComponent(componentType) as ComponentRef<DynamicComponent<Types.AnyComponentNode>>;
127+
this.currentComponentRef = componentRef;
128+
this.updateInputs(componentRef, node, surfaceId);
129+
}
130+
});
131+
} else if (componentTypeOrPromise) {
132+
const componentRef = container.createComponent(componentTypeOrPromise) as ComponentRef<DynamicComponent<Types.AnyComponentNode>>;
133+
this.currentComponentRef = componentRef;
134+
this.updateInputs(componentRef, node, surfaceId);
135+
}
136+
}
137+
138+
private resolveComponentType(config: any): Type<unknown> | Promise<Type<unknown>> | null {
87139
if (typeof config === 'function') {
88-
const res = config();
89-
componentType = res instanceof Promise ? await res : res;
140+
return config();
90141
} else if (typeof config === 'object' && config !== null) {
91142
if (typeof config.type === 'function') {
92-
const res = config.type();
93-
componentType = res instanceof Promise ? await res : res;
143+
return config.type();
94144
} else {
95-
componentType = config.type;
145+
return config.type;
96146
}
97147
}
148+
return null;
149+
}
98150

99-
if (componentType) {
100-
const componentRef = container.createComponent(componentType);
101-
componentRef.setInput('surfaceId', this.surfaceId());
102-
componentRef.setInput('component', node);
103-
componentRef.setInput('weight', node.weight ?? 0);
104-
105-
const props = node.properties as Record<string, unknown>;
106-
for (const [key, value] of Object.entries(props)) {
107-
try {
108-
componentRef.setInput(key, value);
109-
} catch (e) {
110-
console.warn(
111-
`[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.`,
112-
);
113-
}
151+
/**
152+
* Updates the inputs of an existing component instance with the latest data from the node.
153+
* This is called during component reuse to keep the UI in sync without losing DOM state (like focus).
154+
*/
155+
private updateInputs(
156+
componentRef: ComponentRef<DynamicComponent<Types.AnyComponentNode>>,
157+
node: Types.AnyComponentNode,
158+
surfaceId: string,
159+
) {
160+
componentRef.setInput('surfaceId', surfaceId);
161+
componentRef.setInput('component', node);
162+
componentRef.setInput('weight', node.weight ?? 0);
163+
164+
const props = node.properties as Record<string, unknown>;
165+
for (const [key, value] of Object.entries(props)) {
166+
try {
167+
componentRef.setInput(key, value);
168+
} catch (e) {
169+
console.warn(
170+
`[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.`,
171+
);
114172
}
115173
}
174+
componentRef.changeDetectorRef.markForCheck();
116175
}
117176
}

samples/client/angular/projects/contact/src/app/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export class App {
3232
protected hasData = signal(false);
3333
protected userInput = signal('Casey Smith');
3434
protected surfaces = computed(() => {
35-
return Array.from(this.processor.surfacesSignal().entries());
35+
return Array.from(this.processor.getSurfaces().entries());
3636
});
3737

3838
protected statusText = computed(() => {

samples/client/angular/projects/restaurant/src/app/app.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ <h1 class="app-title">Restaurant Finder</h1>
4343
<div class="surfaces">
4444
@let surfaces = processor.getSurfaces();
4545

46-
@for (entry of surfaces; track $index) {
46+
@for (entry of surfaces; track entry[0]) {
4747
<a2ui-surface [surfaceId]="entry[0]" [surface]="entry[1]"/>
4848
}
4949
</div>

0 commit comments

Comments
 (0)