From c21229ce09ca340b1e5dcbf0db253025c1833e19 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 11:18:55 -0700 Subject: [PATCH 01/14] fix: resolve Angular build break caused by @a2ui/web_core protocol update - Updated `A2uiRendererService` and `RendererConfiguration` to use `A2uiClientAction` instead of `SurfaceGroupAction`. - Modified `ComponentHostComponent` to accept and pass `componentId` to rendered components. - Updated all v0.9 catalog components to include a required `componentId` input. - Passing `sourceComponentId` in `SurfaceModel.dispatchAction` calls (e.g., in `ButtonComponent`). - Updated all test specs and dummy components to support the new `componentId` input. This fix aligns the Angular renderer with the client data model synchronization changes introduced in @a2ui/web_core. --- .../v0_9/catalog/basic/button.component.spec.ts | 14 ++++++++------ .../src/v0_9/catalog/basic/button.component.ts | 3 ++- .../src/v0_9/catalog/basic/card.component.ts | 1 + .../src/v0_9/catalog/basic/check-box.component.ts | 1 + .../v0_9/catalog/basic/choice-picker.component.ts | 2 +- .../v0_9/catalog/basic/column.component.spec.ts | 10 +++++----- .../src/v0_9/catalog/basic/column.component.ts | 1 + .../v0_9/catalog/basic/complex-components.spec.ts | 1 + .../catalog/basic/date-time-input.component.ts | 1 + .../src/v0_9/catalog/basic/divider.component.ts | 1 + .../src/v0_9/catalog/basic/icon.component.ts | 4 ++-- .../src/v0_9/catalog/basic/image.component.ts | 4 ++-- .../src/v0_9/catalog/basic/list.component.ts | 1 + .../src/v0_9/catalog/basic/modal.component.ts | 1 + .../src/v0_9/catalog/basic/row.component.spec.ts | 10 +++++----- .../src/v0_9/catalog/basic/row.component.ts | 1 + .../v0_9/catalog/basic/simple-components.spec.ts | 1 + .../src/v0_9/catalog/basic/slider.component.ts | 1 + .../src/v0_9/catalog/basic/tabs.component.ts | 1 + .../src/v0_9/catalog/basic/text-field.component.ts | 4 ++-- .../src/v0_9/catalog/basic/text.component.ts | 4 ++-- .../src/v0_9/catalog/basic/video.component.ts | 4 ++-- .../angular/src/v0_9/core/a2ui-renderer.service.ts | 4 ++-- .../src/v0_9/core/component-host.component.ts | 7 ++++++- 24 files changed, 51 insertions(+), 31 deletions(-) diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts index fcb4d7faa..16c24b259 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts @@ -15,7 +15,7 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, Input, signal } from '@angular/core'; +import { Component, input, signal } from '@angular/core'; import { ButtonComponent } from './button.component'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; @@ -47,9 +47,10 @@ describe('ButtonComponent', () => { template: 'Dummy Text', }) class DummyText { - @Input() props: any; - @Input() surfaceId?: string; - @Input() dataContextPath?: string; + props = input(); + surfaceId = input(); + componentId = input(); + dataContextPath = input(); } return DummyText; })(), @@ -81,6 +82,7 @@ describe('ButtonComponent', () => { fixture = TestBed.createComponent(ButtonComponent); component = fixture.componentInstance; fixture.componentRef.setInput('surfaceId', 'surf1'); + fixture.componentRef.setInput('componentId', 'comp1'); fixture.componentRef.setInput('props', { variant: { value: signal('primary'), raw: 'primary', onUpdate: () => {} }, child: { value: signal('child1'), raw: 'child1', onUpdate: () => {} }, @@ -123,13 +125,13 @@ describe('ButtonComponent', () => { expect(button.nativeElement.classList).toContain('primary'); }); - it('should handle click and dispatch action', () => { + it('should handle click and dispatch action with sourceComponentId', () => { fixture.detectChanges(); const button = fixture.debugElement.query(By.css('button')); button.triggerEventHandler('click', null); expect(mockSurfaceGroup.getSurface).toHaveBeenCalledWith('surf1'); - expect(mockSurface.dispatchAction).toHaveBeenCalled(); + expect(mockSurface.dispatchAction).toHaveBeenCalledWith(jasmine.any(Object), 'comp1'); }); it('should show child component host if child prop is present', () => { diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.ts index 5a7e9a2a9..d0a72589c 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.ts @@ -80,6 +80,7 @@ export class ButtonComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input.required(); dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); @@ -95,7 +96,7 @@ export class ButtonComponent { if (surface) { const dataContext = new DataContext(surface, this.dataContextPath()); const resolvedAction = dataContext.resolveAction(action); - surface.dispatchAction(resolvedAction); + surface.dispatchAction(resolvedAction, this.componentId()); } } } diff --git a/renderers/angular/src/v0_9/catalog/basic/card.component.ts b/renderers/angular/src/v0_9/catalog/basic/card.component.ts index ba1920469..f53ede01b 100644 --- a/renderers/angular/src/v0_9/catalog/basic/card.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/card.component.ts @@ -61,6 +61,7 @@ export class CardComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); child = computed(() => this.props()['child']?.value()); diff --git a/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts b/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts index 0f65be750..e972a2fc5 100644 --- a/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts @@ -70,6 +70,7 @@ export class CheckBoxComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); diff --git a/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts b/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts index 507c7b348..0b156aa21 100644 --- a/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts @@ -117,7 +117,7 @@ export class ChoicePickerComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input.required(); + componentId = input(); dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); diff --git a/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts index 0ea4ea724..777ed4786 100644 --- a/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts @@ -15,9 +15,8 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, Input } from '@angular/core'; +import { Component, input, signal } from '@angular/core'; import { ColumnComponent } from './column.component'; -import { signal } from '@angular/core'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; @@ -28,9 +27,10 @@ import { By } from '@angular/platform-browser'; template: 'Dummy Child', }) class DummyChild { - @Input() props: any; - @Input() surfaceId?: string; - @Input() dataContextPath?: string; + props = input(); + surfaceId = input(); + componentId = input(); + dataContextPath = input(); } describe('ColumnComponent', () => { diff --git a/renderers/angular/src/v0_9/catalog/basic/column.component.ts b/renderers/angular/src/v0_9/catalog/basic/column.component.ts index 5986ff6aa..2bae36f54 100644 --- a/renderers/angular/src/v0_9/catalog/basic/column.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/column.component.ts @@ -73,6 +73,7 @@ export class ColumnComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); protected justify = computed(() => this.props()['justify']?.value()); diff --git a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts index 49b271cd5..07d427d9d 100644 --- a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts @@ -81,6 +81,7 @@ describe('Complex Components', () => { text?: string; props = input(); surfaceId = input(); + componentId = input(); dataContextPath = input(); } diff --git a/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts b/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts index f57342629..3050c22fa 100644 --- a/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts @@ -95,6 +95,7 @@ export class DateTimeInputComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); label = computed(() => this.props()['label']?.value()); diff --git a/renderers/angular/src/v0_9/catalog/basic/divider.component.ts b/renderers/angular/src/v0_9/catalog/basic/divider.component.ts index 84f0cb054..5e7f2b574 100644 --- a/renderers/angular/src/v0_9/catalog/basic/divider.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/divider.component.ts @@ -61,6 +61,7 @@ export class DividerComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); axis = computed(() => this.props()['axis']?.value() ?? 'horizontal'); diff --git a/renderers/angular/src/v0_9/catalog/basic/icon.component.ts b/renderers/angular/src/v0_9/catalog/basic/icon.component.ts index 55a53f853..e8d8e313a 100644 --- a/renderers/angular/src/v0_9/catalog/basic/icon.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/icon.component.ts @@ -74,9 +74,9 @@ export class IconComponent { * - `color`: The CSS color to apply to the icon. */ props = input>({}); - surfaceId = input(); + surfaceId = input.required(); componentId = input(); - dataContextPath = input(); + dataContextPath = input('/'); color = computed(() => this.props()['color']?.value()); iconNameRaw = computed(() => this.props()['name']?.value()); diff --git a/renderers/angular/src/v0_9/catalog/basic/image.component.ts b/renderers/angular/src/v0_9/catalog/basic/image.component.ts index 76ddd475a..e36ea2427 100644 --- a/renderers/angular/src/v0_9/catalog/basic/image.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/image.component.ts @@ -63,9 +63,9 @@ export class ImageComponent { * - `variant`: Style variant ('default', 'circle', 'rounded'). */ props = input>({}); - surfaceId = input(); + surfaceId = input.required(); componentId = input(); - dataContextPath = input(); + dataContextPath = input('/'); url = computed(() => this.props()['url']?.value()); description = computed(() => this.props()['description']?.value() || ''); diff --git a/renderers/angular/src/v0_9/catalog/basic/list.component.ts b/renderers/angular/src/v0_9/catalog/basic/list.component.ts index 79f088ba9..190cc10dc 100644 --- a/renderers/angular/src/v0_9/catalog/basic/list.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/list.component.ts @@ -111,6 +111,7 @@ export class ListComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); listStyle = computed(() => this.props()['listStyle']?.value()); diff --git a/renderers/angular/src/v0_9/catalog/basic/modal.component.ts b/renderers/angular/src/v0_9/catalog/basic/modal.component.ts index 1c3867788..b53b7fc2f 100644 --- a/renderers/angular/src/v0_9/catalog/basic/modal.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/modal.component.ts @@ -115,6 +115,7 @@ export class ModalComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); isOpen = signal(false); diff --git a/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts index c8dc7002f..88f0f98bd 100644 --- a/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts @@ -15,9 +15,8 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, Input } from '@angular/core'; +import { Component, input, signal } from '@angular/core'; import { RowComponent } from './row.component'; -import { signal } from '@angular/core'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; @@ -28,9 +27,10 @@ import { By } from '@angular/platform-browser'; template: 'Dummy Child', }) class DummyChild { - @Input() props: any; - @Input() surfaceId?: string; - @Input() dataContextPath?: string; + props = input(); + surfaceId = input(); + componentId = input(); + dataContextPath = input(); } describe('RowComponent', () => { diff --git a/renderers/angular/src/v0_9/catalog/basic/row.component.ts b/renderers/angular/src/v0_9/catalog/basic/row.component.ts index 6f1e88fa8..d4fc623ff 100644 --- a/renderers/angular/src/v0_9/catalog/basic/row.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/row.component.ts @@ -72,6 +72,7 @@ export class RowComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); protected justify = computed(() => this.props()['justify']?.value()); diff --git a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts index 295155a59..94e61883d 100644 --- a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts @@ -80,6 +80,7 @@ describe('Simple Components', () => { text?: string; props = input(); surfaceId = input(); + componentId = input(); dataContextPath = input(); } diff --git a/renderers/angular/src/v0_9/catalog/basic/slider.component.ts b/renderers/angular/src/v0_9/catalog/basic/slider.component.ts index 163dc08a7..0149e9758 100644 --- a/renderers/angular/src/v0_9/catalog/basic/slider.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/slider.component.ts @@ -78,6 +78,7 @@ export class SliderComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); diff --git a/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts b/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts index c91f97d5f..e2f506c62 100644 --- a/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts @@ -95,6 +95,7 @@ export class TabsComponent { */ props = input>({}); surfaceId = input.required(); + componentId = input(); dataContextPath = input('/'); activeTabIndex = signal(0); diff --git a/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts b/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts index dc2a15236..d061182a9 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts @@ -75,9 +75,9 @@ export class TextFieldComponent { * - `variant`: Input type variant ('default', 'obscured' (password), 'number'). */ props = input>({}); - surfaceId = input(); + surfaceId = input.required(); componentId = input(); - dataContextPath = input(); + dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); diff --git a/renderers/angular/src/v0_9/catalog/basic/text.component.ts b/renderers/angular/src/v0_9/catalog/basic/text.component.ts index f971a6966..f34e91eb1 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text.component.ts @@ -43,9 +43,9 @@ export class TextComponent { * - `style`: Font style (e.g., 'italic', 'normal'). */ props = input>({}); - surfaceId = input(); + surfaceId = input.required(); componentId = input(); - dataContextPath = input(); + dataContextPath = input('/'); weight = computed(() => this.props()['weight']?.value()); style = computed(() => this.props()['style']?.value()); diff --git a/renderers/angular/src/v0_9/catalog/basic/video.component.ts b/renderers/angular/src/v0_9/catalog/basic/video.component.ts index 69afe53fa..4d9a8f983 100644 --- a/renderers/angular/src/v0_9/catalog/basic/video.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/video.component.ts @@ -62,9 +62,9 @@ export class VideoComponent { * - `posterUrl`: The URL of an image to show before the video starts. */ props = input>({}); - surfaceId = input(); + surfaceId = input.required(); componentId = input(); - dataContextPath = input(); + dataContextPath = input('/'); url = computed(() => this.props()['url']?.value()); posterUrl = computed(() => this.props()['posterUrl']?.value()); diff --git a/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts index 1dcd22791..2e4e00458 100644 --- a/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts +++ b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts @@ -20,7 +20,7 @@ import { SurfaceGroupModel, ActionListener as ActionHandler, A2uiMessage, - SurfaceGroupAction, + A2uiClientAction as Action, } from '@a2ui/web_core/v0_9'; import { AngularComponentImplementation, AngularCatalog } from '../catalog/types'; @@ -36,7 +36,7 @@ export interface RendererConfiguration { * This callback is invoked whenever a component in any surface triggers an action * (e.g., clicking a button with an `onTap` property). */ - actionHandler?: (action: SurfaceGroupAction) => void; + actionHandler?: (action: Action) => void; } /** diff --git a/renderers/angular/src/v0_9/core/component-host.component.ts b/renderers/angular/src/v0_9/core/component-host.component.ts index ccd6a599d..4f5cb0fb6 100644 --- a/renderers/angular/src/v0_9/core/component-host.component.ts +++ b/renderers/angular/src/v0_9/core/component-host.component.ts @@ -47,7 +47,12 @@ import { ComponentBinder } from './component-binder.service'; } From cb4aa55c325ac517f1286983d612202d14a8a92d Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 11:38:03 -0700 Subject: [PATCH 02/14] refactor(angular): implement native Angular signals and unify property API This major refactor migrates the Angular v0.9 renderer to use native Angular Signals instead of translating from Preact signals. Key changes: - Core: Introduced ReactiveProvider in web_core and AngularReactiveProvider in @a2ui/angular to allow the core logic to use the renderer's native reactive primitives. - Components: Refactored all v0.9 catalog components to use signal-based property getters (this.props()['key']?()) instead of the .value() API. - Inputs: Migrated all component inputs (props, surfaceId, componentId, dataContextPath) to use the Angular input() and input.required() Signal API. - Protocol: Adopted the updated A2UI v0.9 Action protocol from main, moving from functionCall to named event name and context. - Integration: Reconciled all merge conflicts with the main branch following the adoption of Signal inputs in both branches. - Tests: Updated the comprehensive test suite and integration tests to support the new signal-based property and action structures. --- .../src/app/action-dispatcher.service.ts | 6 +- .../src/app/agent-stub.service.ts | 113 +++++++++--------- .../a2ui_explorer/src/app/card.component.ts | 4 +- .../src/app/custom-slider.component.ts | 10 +- .../a2ui_explorer/src/app/demo-catalog.ts | 20 +--- .../a2ui_explorer/src/app/demo.component.ts | 20 ++-- .../catalog/basic/audio-player.component.ts | 4 +- .../catalog/basic/button.component.spec.ts | 35 +++--- .../v0_9/catalog/basic/button.component.ts | 6 +- .../src/v0_9/catalog/basic/card.component.ts | 4 +- .../v0_9/catalog/basic/check-box.component.ts | 8 +- .../catalog/basic/choice-picker.component.ts | 12 +- .../catalog/basic/column.component.spec.ts | 28 +++-- .../v0_9/catalog/basic/column.component.ts | 8 +- .../catalog/basic/complex-components.spec.ts | 58 +++++---- .../basic/date-time-input.component.ts | 16 +-- .../v0_9/catalog/basic/divider.component.ts | 4 +- .../src/v0_9/catalog/basic/icon.component.ts | 4 +- .../src/v0_9/catalog/basic/image.component.ts | 8 +- .../src/v0_9/catalog/basic/list.component.ts | 8 +- .../src/v0_9/catalog/basic/modal.component.ts | 6 +- .../v0_9/catalog/basic/row.component.spec.ts | 28 +++-- .../src/v0_9/catalog/basic/row.component.ts | 8 +- .../catalog/basic/simple-components.spec.ts | 10 +- .../v0_9/catalog/basic/slider.component.ts | 14 +-- .../src/v0_9/catalog/basic/tabs.component.ts | 4 +- .../basic/text-field.component.spec.ts | 30 +++-- .../catalog/basic/text-field.component.ts | 10 +- .../v0_9/catalog/basic/text.component.spec.ts | 16 ++- .../src/v0_9/catalog/basic/text.component.ts | 6 +- .../src/v0_9/catalog/basic/video.component.ts | 4 +- renderers/angular/src/v0_9/catalog/types.ts | 13 +- .../src/v0_9/core/a2ui-renderer.service.ts | 11 +- .../v0_9/core/angular-reactive-provider.ts | 81 +++++++++++++ .../core/component-binder.service.spec.ts | 55 ++++++--- .../src/v0_9/core/component-binder.service.ts | 52 +++++--- .../src/v0_9/core/function_binding.spec.ts | 46 ++++--- renderers/angular/src/v0_9/core/types.ts | 42 ++----- renderers/angular/src/v0_9/core/utils.spec.ts | 83 +------------ renderers/angular/src/v0_9/core/utils.ts | 43 ------- renderers/web_core/src/v0_9/catalog/types.ts | 12 +- .../src/v0_9/common/preact-provider.ts | 63 ++++++++++ .../web_core/src/v0_9/common/reactive.ts | 59 +++++++++ renderers/web_core/src/v0_9/index.ts | 2 + .../src/v0_9/processing/message-processor.ts | 7 +- .../src/v0_9/rendering/data-context.ts | 30 ++--- .../web_core/src/v0_9/state/data-model.ts | 23 ++-- .../v0_9/state/surface-group-model.test.ts | 20 ++-- .../src/v0_9/state/surface-group-model.ts | 6 + .../src/v0_9/state/surface-model.test.ts | 2 +- .../web_core/src/v0_9/state/surface-model.ts | 5 +- .../web_core/src/v0_9/test/test-utils.ts | 2 +- 52 files changed, 679 insertions(+), 490 deletions(-) create mode 100644 renderers/angular/src/v0_9/core/angular-reactive-provider.ts create mode 100644 renderers/web_core/src/v0_9/common/preact-provider.ts create mode 100644 renderers/web_core/src/v0_9/common/reactive.ts diff --git a/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts b/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts index 4a70e99a2..20477c558 100644 --- a/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts +++ b/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts @@ -16,14 +16,14 @@ import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; -import { SurfaceGroupAction } from '@a2ui/web_core/v0_9'; +import { A2uiClientAction } from '@a2ui/web_core/v0_9'; @Injectable({ providedIn: 'root' }) export class ActionDispatcher { - private action$ = new Subject(); + private action$ = new Subject(); actions = this.action$.asObservable(); - dispatch(action: SurfaceGroupAction) { + dispatch(action: A2uiClientAction) { this.action$.next(action); } } diff --git a/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts b/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts index efd4dea4b..58fab557a 100644 --- a/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts +++ b/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts @@ -17,7 +17,7 @@ import { Injectable } from '@angular/core'; import { A2uiRendererService } from '@a2ui/angular/v0_9'; -import { SurfaceGroupAction, A2uiMessage } from '@a2ui/web_core/v0_9'; +import { A2uiClientAction, A2uiMessage, CreateSurfaceMessage } from '@a2ui/web_core/v0_9'; import { ActionDispatcher } from './action-dispatcher.service'; /** @@ -46,7 +46,7 @@ interface SubmitFormContext { }) export class AgentStubService { /** Log of actions received from the surface. */ - actionsLog: Array<{ timestamp: Date; action: SurfaceGroupAction }> = []; + actionsLog: Array<{ timestamp: Date; action: A2uiClientAction }> = []; constructor( private rendererService: A2uiRendererService, @@ -58,62 +58,57 @@ export class AgentStubService { /** * Pushes actions triggered from the rendered Canvas frame through simulation. - * - Logs actions into inspector event frame aggregates. - * - Emulates generic server-side evaluation triggers delaying deferred updates. - * - Dispatch subsequent node-tree node triggers back over `A2uiRendererService`. */ - handleAction(action: SurfaceGroupAction) { + handleAction(action: A2uiClientAction) { console.log('[AgentStub] handleAction action:', action); this.actionsLog.push({ timestamp: new Date(), action }); // Simulate server processing delay setTimeout(() => { - if ('event' in action) { - const { name, context } = action.event; - if (name === 'update_property' && context) { - const { path, value, surfaceId } = context as unknown as UpdatePropertyContext; - console.log( - '[AgentStub] update_property path:', - path, - 'value:', - value, - 'surfaceId:', - surfaceId, - ); - this.rendererService.processMessages([ - { - version: 'v0.9', - updateDataModel: { - surfaceId: surfaceId || action.surfaceId, - path: path, - value: value, - }, + const { name, context } = action; + if (name === 'update_property' && context) { + const { path, value, surfaceId } = context as unknown as UpdatePropertyContext; + console.log( + '[AgentStub] update_property path:', + path, + 'value:', + value, + 'surfaceId:', + surfaceId, + ); + this.rendererService.processMessages([ + { + version: 'v0.9', + updateDataModel: { + surfaceId: surfaceId || action.surfaceId, + path: path, + value: value, }, - ]); - } else if (name === 'submit_form' && context) { - const formData = context as unknown as SubmitFormContext; - const nameValue = formData.name || 'Anonymous'; + }, + ]); + } else if (name === 'submit_form' && context) { + const formData = context as unknown as SubmitFormContext; + const nameValue = formData.name || 'Anonymous'; - // Respond with an update to the data model in v0.9 layout - this.rendererService.processMessages([ - { - version: 'v0.9', - updateDataModel: { - surfaceId: action.surfaceId, - path: '/form/submitted', - value: true, - }, + // Respond with an update to the data model in v0.9 layout + this.rendererService.processMessages([ + { + version: 'v0.9', + updateDataModel: { + surfaceId: action.surfaceId, + path: '/form/submitted', + value: true, }, - { - version: 'v0.9', - updateDataModel: { - surfaceId: action.surfaceId, - path: '/form/responseMessage', - value: `Hello, ${nameValue}! Your form has been processed.`, - }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: action.surfaceId, + path: '/form/responseMessage', + value: `Hello, ${nameValue}! Your form has been processed.`, }, - ]); - } + }, + ]); } }, 50); // Shorter delay for property updates } @@ -124,16 +119,18 @@ export class AgentStubService { initializeDemo(initialMessages: A2uiMessage[]) { // Before replaying initial messages (which contains createSurface), // ensure any existing surface with the same ID is cleared. - for (const msg of initialMessages) { - if ('createSurface' in msg) { - const createSurface = msg.createSurface; - if (this.rendererService.surfaceGroup.getSurface(createSurface.surfaceId)) { - this.rendererService.processMessages([ - { - version: 'v0.9', - deleteSurface: { surfaceId: createSurface.surfaceId }, - }, - ]); + if (this.rendererService.surfaceGroup) { + for (const msg of initialMessages) { + if ('createSurface' in msg) { + const createSurface = msg.createSurface; + if (this.rendererService.surfaceGroup.getSurface(createSurface.surfaceId)) { + this.rendererService.processMessages([ + { + version: 'v0.9', + deleteSurface: { surfaceId: createSurface.surfaceId }, + }, + ]); + } } } } diff --git a/renderers/angular/a2ui_explorer/src/app/card.component.ts b/renderers/angular/a2ui_explorer/src/app/card.component.ts index d4089e5ee..61ff8887e 100644 --- a/renderers/angular/a2ui_explorer/src/app/card.component.ts +++ b/renderers/angular/a2ui_explorer/src/app/card.component.ts @@ -32,8 +32,8 @@ import { BoundProperty } from '@a2ui/angular/v0_9'; style="border: 1px solid #ccc; padding: 16px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 16px; background-color: white;" > diff --git a/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts b/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts index bc8d6915f..ec083a161 100644 --- a/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts +++ b/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts @@ -28,12 +28,12 @@ import { BoundProperty } from '@a2ui/angular/v0_9'; imports: [CommonModule], template: `
- +
@@ -57,6 +57,6 @@ export class CustomSliderComponent { handleInput(event: Event) { const val = Number((event.target as HTMLInputElement).value); - this.props['value']?.onUpdate(val); + this.props['value']?.set(val); } } diff --git a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts index 9d5fd2bce..ff941a835 100644 --- a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts +++ b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts @@ -30,24 +30,16 @@ import { createFunctionImplementation, FunctionImplementation } from '@a2ui/web_ }) export class DemoCatalog extends BasicCatalogBase { constructor() { - const customSliderApi: AngularComponentImplementation = { - name: 'CustomSlider', - schema: z.object({ - label: z.string().optional(), - value: z.number().optional(), - min: z.number().optional(), - max: z.number().optional(), - }) as any, - component: CustomSliderComponent, - }; - const cardApi: AngularComponentImplementation = { name: 'Card', - schema: z.object({ - child: z.string().optional(), - }) as any, + schema: z.object({}).passthrough(), component: CardComponent, }; + const customSliderApi: AngularComponentImplementation = { + name: 'CustomSlider', + schema: z.object({}).passthrough(), + component: CustomSliderComponent, + }; const capitalizeImplementation: FunctionImplementation = createFunctionImplementation( { diff --git a/renderers/angular/a2ui_explorer/src/app/demo.component.ts b/renderers/angular/a2ui_explorer/src/app/demo.component.ts index 3e1675554..b93ad9e82 100644 --- a/renderers/angular/a2ui_explorer/src/app/demo.component.ts +++ b/renderers/angular/a2ui_explorer/src/app/demo.component.ts @@ -19,14 +19,16 @@ import { CommonModule } from '@angular/common'; import { A2uiRendererService, A2UI_RENDERER_CONFIG } from '@a2ui/angular/v0_9'; import { AgentStubService } from './agent-stub.service'; import { ComponentHostComponent, SurfaceComponent } from '@a2ui/angular/v0_9'; -import { AngularCatalog } from '@a2ui/angular/v0_9'; +import { AngularCatalog, AngularComponentImplementation } from '@a2ui/angular/v0_9'; +import { createFunctionImplementation, FunctionImplementation } from '@a2ui/web_core/v0_9'; import { DemoCatalog } from './demo-catalog'; -import { SurfaceGroupAction, A2uiMessage, CreateSurfaceMessage } from '@a2ui/web_core/v0_9'; +import { A2uiClientAction, A2uiMessage, CreateSurfaceMessage } from '@a2ui/web_core/v0_9'; import { EXAMPLES } from './examples-bundle'; import { Example } from './types'; import { Subscription } from 'rxjs'; import { ActionDispatcher } from './action-dispatcher.service'; + /** * Main dashboard component for A2UI v0.9 Angular Renderer. * It provides a sidebar of examples, a canvas for rendering, @@ -339,7 +341,7 @@ import { ActionDispatcher } from './action-dispatcher.service'; provide: A2UI_RENDERER_CONFIG, useFactory: (catalog: AngularCatalog, dispatcher: ActionDispatcher) => ({ catalogs: [catalog], - actionHandler: (action: SurfaceGroupAction) => dispatcher.dispatch(action), + actionHandler: (action: A2uiClientAction) => dispatcher.dispatch(action), }), deps: [AngularCatalog, ActionDispatcher], }, @@ -356,7 +358,7 @@ export class DemoComponent implements OnInit, OnDestroy { inspectTab: 'data' | 'events' = 'data'; currentDataModel: Record = {}; - eventsLog: Array<{ timestamp: Date; action: SurfaceGroupAction }> = []; + eventsLog: Array<{ timestamp: Date; action: A2uiClientAction }> = []; private actionSub?: { unsubscribe: () => void }; private dataModelSub?: { unsubscribe: () => void }; @@ -418,14 +420,8 @@ export class DemoComponent implements OnInit, OnDestroy { } /** Gets a display string for the action type. */ - getActionType(action: SurfaceGroupAction): string { - if ('event' in action) { - return action.event.name; - } - if ('functionCall' in action) { - return `Call: ${action.functionCall.call}`; - } - return 'Action'; + getActionType(action: A2uiClientAction): string { + return action.name || 'Action'; } ngOnDestroy(): void { diff --git a/renderers/angular/src/v0_9/catalog/basic/audio-player.component.ts b/renderers/angular/src/v0_9/catalog/basic/audio-player.component.ts index 8bdcb446a..19bbbe0e4 100644 --- a/renderers/angular/src/v0_9/catalog/basic/audio-player.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/audio-player.component.ts @@ -70,6 +70,6 @@ export class AudioPlayerComponent { componentId = input(); dataContextPath = input(); - description = computed(() => this.props()['description']?.value()); - url = computed(() => this.props()['url']?.value()); + description = computed(() => this.props()['description']?.()); + url = computed(() => this.props()['url']?.()); } diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts index 16c24b259..9bb8e6499 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts @@ -20,6 +20,7 @@ import { ButtonComponent } from './button.component'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; +import { BoundProperty } from '../../core/types'; describe('ButtonComponent', () => { let component: ButtonComponent; @@ -28,6 +29,19 @@ describe('ButtonComponent', () => { let mockSurface: any; let mockSurfaceGroup: any; + function createBoundProperty(val: any): BoundProperty { + const sig = signal(val); + const prop = Object.assign(() => sig(), { + value: sig, + peek: () => sig(), + set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + update: jasmine.createSpy('update').and.callFake((fn: any) => sig.update(fn)), + raw: val, + }); + Object.defineProperty(prop, 'value', { get: () => sig() }); + return prop as any; + } + beforeEach(async () => { mockSurface = { dispatchAction: jasmine.createSpy('dispatchAction'), @@ -84,14 +98,11 @@ describe('ButtonComponent', () => { fixture.componentRef.setInput('surfaceId', 'surf1'); fixture.componentRef.setInput('componentId', 'comp1'); fixture.componentRef.setInput('props', { - variant: { value: signal('primary'), raw: 'primary', onUpdate: () => {} }, - child: { value: signal('child1'), raw: 'child1', onUpdate: () => {} }, - action: { - value: signal({ type: 'test-action', data: {} }), - raw: { type: 'test-action', data: {} }, - onUpdate: () => {}, - }, + variant: createBoundProperty('primary'), + child: createBoundProperty('child1'), + action: createBoundProperty({ type: 'test-action', data: {} }), }); + fixture.componentRef.setInput('componentId', 'test-btn'); }); it('should create', () => { @@ -108,11 +119,7 @@ describe('ButtonComponent', () => { it('should set button type to button for non-primary variant', () => { fixture.componentRef.setInput('props', { ...component.props(), - variant: { - value: signal('secondary'), - raw: 'secondary', - onUpdate: () => {}, - }, + variant: createBoundProperty('secondary'), }); fixture.detectChanges(); const button = fixture.debugElement.query(By.css('button')); @@ -131,7 +138,7 @@ describe('ButtonComponent', () => { button.triggerEventHandler('click', null); expect(mockSurfaceGroup.getSurface).toHaveBeenCalledWith('surf1'); - expect(mockSurface.dispatchAction).toHaveBeenCalledWith(jasmine.any(Object), 'comp1'); + expect(mockSurface.dispatchAction).toHaveBeenCalledWith(jasmine.any(Object), 'test-btn'); }); it('should show child component host if child prop is present', () => { @@ -144,7 +151,7 @@ describe('ButtonComponent', () => { it('should not show child component host if child prop is absent', () => { fixture.componentRef.setInput('props', { ...component.props(), - child: { value: signal(null), raw: null, onUpdate: () => {} }, + child: createBoundProperty(null), }); fixture.detectChanges(); const host = fixture.debugElement.query(By.css('a2ui-v09-component-host')); diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.ts index d0a72589c..80fd6d69d 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.ts @@ -85,9 +85,9 @@ export class ButtonComponent { private rendererService = inject(A2uiRendererService); - variant = computed(() => this.props()['variant']?.value() ?? 'default'); - child = computed(() => this.props()['child']?.value()); - action = computed(() => this.props()['action']?.value()); + variant = computed(() => this.props()['variant']?.() ?? 'default'); + child = computed(() => this.props()['child']?.()); + action = computed(() => this.props()['action']?.()); handleClick() { const action = this.action(); diff --git a/renderers/angular/src/v0_9/catalog/basic/card.component.ts b/renderers/angular/src/v0_9/catalog/basic/card.component.ts index f53ede01b..32c2ff714 100644 --- a/renderers/angular/src/v0_9/catalog/basic/card.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/card.component.ts @@ -61,8 +61,8 @@ export class CardComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - child = computed(() => this.props()['child']?.value()); + child = computed(() => this.props()['child']?.()); } diff --git a/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts b/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts index e972a2fc5..f32e68c44 100644 --- a/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/check-box.component.ts @@ -70,16 +70,16 @@ export class CheckBoxComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); - value = computed(() => this.props()['value']?.value() === true); - label = computed(() => this.props()['label']?.value()); + value = computed(() => this.props()['value']?.() === true); + label = computed(() => this.props()['label']?.()); handleChange(event: Event) { const checked = (event.target as HTMLInputElement).checked; - this.props()['value']?.onUpdate(checked); + this.props()['value']?.set(checked); } } diff --git a/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts b/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts index 0b156aa21..29df6097a 100644 --- a/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts @@ -122,12 +122,12 @@ export class ChoicePickerComponent { private rendererService = inject(A2uiRendererService); - displayStyle = computed(() => this.props()['displayStyle']?.value()); + displayStyle = computed(() => this.props()['displayStyle']?.()); choices = computed( - () => this.props()['choices']?.value() || this.props()['options']?.value() || [], + () => this.props()['choices']?.() || this.props()['options']?.() || [], ); - variant = computed(() => this.props()['variant']?.value()); - selectedValue = computed(() => this.props()['value']?.value()); + variant = computed(() => this.props()['variant']?.()); + selectedValue = computed(() => this.props()['value']?.()); isMultiple(): boolean { return this.variant() === 'multipleSelection'; @@ -160,10 +160,10 @@ export class ChoicePickerComponent { } else { next = next.filter((v: any) => v !== value); } - this.props()['value']?.onUpdate(next); + this.props()['value']?.set(next); } else { if (active) { - this.props()['value']?.onUpdate(value); + this.props()['value']?.set(value); } } } diff --git a/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts index 777ed4786..2623dd500 100644 --- a/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts @@ -77,13 +77,21 @@ describe('ColumnComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('surfaceId', 'surf1'); fixture.componentRef.setInput('props', { - justify: { value: signal('start'), raw: 'start', onUpdate: () => {} }, - align: { value: signal('stretch'), raw: 'stretch', onUpdate: () => {} }, - children: { - value: signal(['child1', 'child2']), + justify: Object.assign(() => 'start', { + value: 'start', + raw: 'start', + set: jasmine.createSpy('set'), + }), + align: Object.assign(() => 'stretch', { + value: 'stretch', + raw: 'stretch', + set: jasmine.createSpy('set'), + }), + children: Object.assign(() => ['child1', 'child2'], { + value: ['child1', 'child2'], raw: ['child1', 'child2'], - onUpdate: () => {}, - }, + set: jasmine.createSpy('set'), + }), }); }); @@ -111,14 +119,14 @@ describe('ColumnComponent', () => { it('should render repeating children', () => { fixture.componentRef.setInput('props', { ...component.props(), - children: { - value: signal([{}, {}]), + children: Object.assign(() => [{}, {}], { + value: [{}, {}], raw: { componentId: 'template1', path: 'items', }, - onUpdate: () => {}, - }, + set: jasmine.createSpy('set'), + }), }); fixture.detectChanges(); diff --git a/renderers/angular/src/v0_9/catalog/basic/column.component.ts b/renderers/angular/src/v0_9/catalog/basic/column.component.ts index 2bae36f54..4f1e82e39 100644 --- a/renderers/angular/src/v0_9/catalog/basic/column.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/column.component.ts @@ -73,14 +73,14 @@ export class ColumnComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - protected justify = computed(() => this.props()['justify']?.value()); - protected align = computed(() => this.props()['align']?.value()); + protected justify = computed(() => this.props()['justify']?.()); + protected align = computed(() => this.props()['align']?.()); protected children = computed(() => { - const raw = this.props()['children']?.value() || []; + const raw = this.props()['children']?.() || []; return Array.isArray(raw) ? raw : []; }); diff --git a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts index 07d427d9d..7eec8327f 100644 --- a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts @@ -86,11 +86,13 @@ describe('Complex Components', () => { } function createBoundProperty(val: any): BoundProperty { - return { - value: angularSignal(val), + const sig = angularSignal(val); + const prop = Object.assign(() => sig(), { + value: sig, raw: val, - onUpdate: jasmine.createSpy('onUpdate'), - }; + set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + }); + return prop as any; } describe('CheckBoxComponent', () => { @@ -128,16 +130,16 @@ describe('Complex Components', () => { expect(fixture.nativeElement.textContent).toContain('Check me'); }); - it('should call onUpdate when toggled', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + it('should call set when toggled', () => { + const prop = createBoundProperty(false); fixture.componentRef.setInput('props', { label: createBoundProperty('Check me'), - value: { value: angularSignal(false), raw: false, onUpdate: onUpdateSpy }, + value: prop, }); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); input.click(); - expect(onUpdateSpy).toHaveBeenCalledWith(true); + expect(prop.set).toHaveBeenCalledWith(true); }); }); @@ -183,32 +185,32 @@ describe('Complex Components', () => { expect(options[0].textContent).toContain('Opt 1'); }); - it('should call onUpdate when option selected', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + it('should call set when option selected', () => { + const prop = createBoundProperty('1'); fixture.componentRef.setInput('props', { label: createBoundProperty('Pick one'), options: createBoundProperty([ { label: 'Opt 1', value: '1' }, { label: 'Opt 2', value: '2' }, ]), - value: { value: angularSignal('1'), raw: '1', onUpdate: onUpdateSpy }, + value: prop, variant: createBoundProperty('mutuallyExclusive'), displayStyle: createBoundProperty('checkbox'), }); fixture.detectChanges(); const inputs = fixture.nativeElement.querySelectorAll('input'); inputs[1].click(); - expect(onUpdateSpy).toHaveBeenCalledWith('2'); + expect(prop.set).toHaveBeenCalledWith('2'); }); it('should render chips and toggle selection', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + const prop = createBoundProperty(['c1']); fixture.componentRef.setInput('props', { choices: createBoundProperty([ { label: 'Chip 1', value: 'c1' }, { label: 'Chip 2', value: 'c2' }, ]), - value: { value: angularSignal(['c1']), raw: ['c1'], onUpdate: onUpdateSpy }, + value: prop, variant: createBoundProperty('multipleSelection'), displayStyle: createBoundProperty('chips'), }); @@ -219,10 +221,10 @@ describe('Complex Components', () => { expect(chips[1].classList.contains('active')).toBeFalse(); chips[1].click(); - expect(onUpdateSpy).toHaveBeenCalledWith(['c1', 'c2']); + expect(prop.set).toHaveBeenCalledWith(['c1', 'c2']); chips[0].click(); - expect(onUpdateSpy).toHaveBeenCalledWith([]); + expect(prop.set).toHaveBeenCalledWith(['c2']); }); }); @@ -263,16 +265,16 @@ describe('Complex Components', () => { expect(fixture.nativeElement.textContent).toContain('Brightness'); }); - it('should call onUpdate when slider value changes', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + it('should call set when slider value changes', () => { + const prop = createBoundProperty(50); fixture.componentRef.setInput('props', { - value: { value: angularSignal(50), raw: 50, onUpdate: onUpdateSpy }, + value: prop, }); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); input.value = '75'; input.dispatchEvent(new Event('input')); - expect(onUpdateSpy).toHaveBeenCalledWith(75); + expect(prop.set).toHaveBeenCalledWith(75); }); }); @@ -313,14 +315,10 @@ describe('Complex Components', () => { expect(input.value).toBe('2026-03-16'); }); - it('should call onUpdate when date or time changes', () => { - const onUpdateSpy = jasmine.createSpy('onUpdate'); + it('should call set when date or time changes', () => { + const prop = createBoundProperty('2026-03-16T10:00:00'); fixture.componentRef.setInput('props', { - value: { - value: angularSignal('2026-03-16T10:00:00'), - raw: '2026-03-16T10:00:00', - onUpdate: onUpdateSpy, - }, + value: prop, enableDate: createBoundProperty(true), enableTime: createBoundProperty(true), }); @@ -330,12 +328,12 @@ describe('Complex Components', () => { dateInput.value = '2026-03-17'; dateInput.dispatchEvent(new Event('change')); - expect(onUpdateSpy).toHaveBeenCalled(); + expect(prop.set).toHaveBeenCalled(); - onUpdateSpy.calls.reset(); + (prop.set as jasmine.Spy).calls.reset(); timeInput.value = '11:00'; timeInput.dispatchEvent(new Event('change')); - expect(onUpdateSpy).toHaveBeenCalled(); + expect(prop.set).toHaveBeenCalled(); }); }); diff --git a/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts b/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts index 3050c22fa..32298f71c 100644 --- a/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/date-time-input.component.ts @@ -95,14 +95,14 @@ export class DateTimeInputComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - label = computed(() => this.props()['label']?.value()); - enableDate = computed(() => this.props()['enableDate']?.value() ?? true); - enableTime = computed(() => this.props()['enableTime']?.value() ?? false); + label = computed(() => this.props()['label']?.()); + enableDate = computed(() => this.props()['enableDate']?.() ?? true); + enableTime = computed(() => this.props()['enableTime']?.() ?? false); - private rawValue = computed(() => this.props()['value']?.value() || ''); + private rawValue = computed(() => this.props()['value']?.() || ''); dateValue = computed(() => { const val = this.rawValue(); @@ -121,9 +121,9 @@ export class DateTimeInputComponent { const current = this.rawValue(); if (this.enableTime()) { const time = current.includes('T') ? current.split('T')[1] : '00:00:00'; - this.props()['value']?.onUpdate(`${date}T${time}`); + this.props()['value']?.set(`${date}T${time}`); } else { - this.props()['value']?.onUpdate(date); + this.props()['value']?.set(date); } } @@ -133,6 +133,6 @@ export class DateTimeInputComponent { const date = current.includes('T') ? current.split('T')[0] : current || new Date().toISOString().split('T')[0]; - this.props()['value']?.onUpdate(`${date}T${time}:00`); + this.props()['value']?.set(`${date}T${time}:00`); } } diff --git a/renderers/angular/src/v0_9/catalog/basic/divider.component.ts b/renderers/angular/src/v0_9/catalog/basic/divider.component.ts index 5e7f2b574..8c574f9cd 100644 --- a/renderers/angular/src/v0_9/catalog/basic/divider.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/divider.component.ts @@ -61,8 +61,8 @@ export class DividerComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - axis = computed(() => this.props()['axis']?.value() ?? 'horizontal'); + axis = computed(() => this.props()['axis']?.() ?? 'horizontal'); } diff --git a/renderers/angular/src/v0_9/catalog/basic/icon.component.ts b/renderers/angular/src/v0_9/catalog/basic/icon.component.ts index e8d8e313a..1a5702773 100644 --- a/renderers/angular/src/v0_9/catalog/basic/icon.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/icon.component.ts @@ -78,8 +78,8 @@ export class IconComponent { componentId = input(); dataContextPath = input('/'); - color = computed(() => this.props()['color']?.value()); - iconNameRaw = computed(() => this.props()['name']?.value()); + color = computed(() => this.props()['color']?.()); + iconNameRaw = computed(() => this.props()['name']?.()); isPath = computed(() => { const name = this.iconNameRaw(); diff --git a/renderers/angular/src/v0_9/catalog/basic/image.component.ts b/renderers/angular/src/v0_9/catalog/basic/image.component.ts index e36ea2427..fe601bf24 100644 --- a/renderers/angular/src/v0_9/catalog/basic/image.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/image.component.ts @@ -67,8 +67,8 @@ export class ImageComponent { componentId = input(); dataContextPath = input('/'); - url = computed(() => this.props()['url']?.value()); - description = computed(() => this.props()['description']?.value() || ''); - fit = computed(() => this.props()['fit']?.value() || 'cover'); - variant = computed(() => this.props()['variant']?.value() || 'default'); + url = computed(() => this.props()['url']?.()); + description = computed(() => this.props()['description']?.() || ''); + fit = computed(() => this.props()['fit']?.() || 'cover'); + variant = computed(() => this.props()['variant']?.() || 'default'); } diff --git a/renderers/angular/src/v0_9/catalog/basic/list.component.ts b/renderers/angular/src/v0_9/catalog/basic/list.component.ts index 190cc10dc..6ef117814 100644 --- a/renderers/angular/src/v0_9/catalog/basic/list.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/list.component.ts @@ -111,13 +111,13 @@ export class ListComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - listStyle = computed(() => this.props()['listStyle']?.value()); - orientation = computed(() => this.props()['orientation']?.value() || 'vertical'); + listStyle = computed(() => this.props()['listStyle']?.()); + orientation = computed(() => this.props()['orientation']?.() || 'vertical'); children = computed(() => { - const raw = this.props()['children']?.value(); + const raw = this.props()['children']?.(); return Array.isArray(raw) ? raw : []; }); diff --git a/renderers/angular/src/v0_9/catalog/basic/modal.component.ts b/renderers/angular/src/v0_9/catalog/basic/modal.component.ts index b53b7fc2f..dee8a4fba 100644 --- a/renderers/angular/src/v0_9/catalog/basic/modal.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/modal.component.ts @@ -115,13 +115,13 @@ export class ModalComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); isOpen = signal(false); - trigger = computed(() => this.props()['trigger']?.value()); - content = computed(() => this.props()['content']?.value()); + trigger = computed(() => this.props()['trigger']?.()); + content = computed(() => this.props()['content']?.()); openModal() { this.isOpen.set(true); diff --git a/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts index 88f0f98bd..8e7686932 100644 --- a/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts @@ -77,13 +77,21 @@ describe('RowComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('surfaceId', 'surf1'); fixture.componentRef.setInput('props', { - justify: { value: signal('center'), raw: 'center', onUpdate: () => {} }, - align: { value: signal('baseline'), raw: 'baseline', onUpdate: () => {} }, - children: { - value: signal(['child1', 'child2']), + justify: Object.assign(() => 'center', { + value: 'center', + raw: 'center', + set: jasmine.createSpy('set'), + }), + align: Object.assign(() => 'baseline', { + value: 'baseline', + raw: 'baseline', + set: jasmine.createSpy('set'), + }), + children: Object.assign(() => ['child1', 'child2'], { + value: ['child1', 'child2'], raw: ['child1', 'child2'], - onUpdate: () => {}, - }, + set: jasmine.createSpy('set'), + }), }); }); @@ -111,14 +119,14 @@ describe('RowComponent', () => { it('should render repeating children', () => { fixture.componentRef.setInput('props', { ...component.props(), - children: { - value: signal([{}, {}]), // two items + children: Object.assign(() => [{}, {}], { + value: [{}, {}], raw: { componentId: 'template1', path: 'items', }, - onUpdate: () => {}, - }, + set: jasmine.createSpy('set'), + }), }); fixture.detectChanges(); diff --git a/renderers/angular/src/v0_9/catalog/basic/row.component.ts b/renderers/angular/src/v0_9/catalog/basic/row.component.ts index d4fc623ff..18a48422c 100644 --- a/renderers/angular/src/v0_9/catalog/basic/row.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/row.component.ts @@ -72,14 +72,14 @@ export class RowComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); - protected justify = computed(() => this.props()['justify']?.value()); - protected align = computed(() => this.props()['align']?.value()); + protected justify = computed(() => this.props()['justify']?.()); + protected align = computed(() => this.props()['align']?.()); protected children = computed(() => { - const raw = this.props()['children']?.value() || []; + const raw = this.props()['children']?.() || []; return Array.isArray(raw) ? raw : []; }); diff --git a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts index 94e61883d..8c56497c4 100644 --- a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts @@ -85,11 +85,13 @@ describe('Simple Components', () => { } function createBoundProperty(val: any): BoundProperty { - return { - value: angularSignal(val), + const sig = angularSignal(val); + const prop = Object.assign(() => sig(), { + value: sig, raw: val, - onUpdate: jasmine.createSpy('onUpdate'), - }; + set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + }); + return prop as any; } describe('DividerComponent', () => { diff --git a/renderers/angular/src/v0_9/catalog/basic/slider.component.ts b/renderers/angular/src/v0_9/catalog/basic/slider.component.ts index 0149e9758..3da1641d2 100644 --- a/renderers/angular/src/v0_9/catalog/basic/slider.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/slider.component.ts @@ -78,19 +78,19 @@ export class SliderComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); private rendererService = inject(A2uiRendererService); - label = computed(() => this.props()['label']?.value()); - value = computed(() => this.props()['value']?.value()); - min = computed(() => this.props()['min']?.value() ?? 0); - max = computed(() => this.props()['max']?.value() ?? 100); - step = computed(() => this.props()['step']?.value() ?? 1); + label = computed(() => this.props()['label']?.()); + value = computed(() => this.props()['value']?.()); + min = computed(() => this.props()['min']?.() ?? 0); + max = computed(() => this.props()['max']?.() ?? 100); + step = computed(() => this.props()['step']?.() ?? 1); handleInput(event: Event) { const val = Number((event.target as HTMLInputElement).value); - this.props()['value']?.onUpdate(val); + this.props()['value']?.set(val); } } diff --git a/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts b/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts index e2f506c62..79efd6d32 100644 --- a/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/tabs.component.ts @@ -95,12 +95,12 @@ export class TabsComponent { */ props = input>({}); surfaceId = input.required(); - componentId = input(); + componentId = input.required(); dataContextPath = input('/'); activeTabIndex = signal(0); - tabs = computed(() => this.props()['tabs']?.value() || []); + tabs = computed(() => this.props()['tabs']?.() || []); activeTab = computed(() => this.tabs()[this.activeTabIndex()]); setActiveTab(index: number) { diff --git a/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts index 37f831cb8..a36aa8966 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts @@ -25,6 +25,16 @@ describe('TextFieldComponent', () => { let fixture: ComponentFixture; let mockRendererService: any; + function createBoundProperty(val: any): any { + const sig = signal(val); + const prop = Object.assign(() => sig(), { + value: sig, + raw: val, + set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + }); + return prop as any; + } + beforeEach(async () => { mockRendererService = {}; @@ -36,14 +46,10 @@ describe('TextFieldComponent', () => { fixture = TestBed.createComponent(TextFieldComponent); component = fixture.componentInstance; fixture.componentRef.setInput('props', { - label: { value: signal('Username'), raw: 'Username', onUpdate: () => {} }, - value: { - value: signal('testuser'), - raw: 'testuser', - onUpdate: jasmine.createSpy('onUpdate'), - }, - placeholder: { value: signal('Enter username'), raw: 'Enter username', onUpdate: () => {} }, - variant: { value: signal('text'), raw: 'text', onUpdate: () => {} }, + label: createBoundProperty('Username'), + value: createBoundProperty('testuser'), + placeholder: createBoundProperty('Enter username'), + variant: createBoundProperty('text'), }); }); @@ -61,7 +67,7 @@ describe('TextFieldComponent', () => { it('should not render label if not provided', () => { fixture.componentRef.setInput('props', { ...component.props(), - label: { value: signal(null), raw: null, onUpdate: () => {} }, + label: createBoundProperty(null), }); fixture.detectChanges(); const label = fixture.debugElement.query(By.css('label')); @@ -80,13 +86,13 @@ describe('TextFieldComponent', () => { fixture.componentRef.setInput('props', { ...component.props(), - variant: { value: signal('obscured'), raw: 'obscured', onUpdate: () => {} }, + variant: createBoundProperty('obscured'), }); expect(component.inputType()).toBe('password'); fixture.componentRef.setInput('props', { ...component.props(), - variant: { value: signal('number'), raw: 'number', onUpdate: () => {} }, + variant: createBoundProperty('number'), }); expect(component.inputType()).toBe('number'); }); @@ -97,6 +103,6 @@ describe('TextFieldComponent', () => { input.nativeElement.value = 'newuser'; input.triggerEventHandler('input', { target: input.nativeElement }); - expect(component.props()['value'].onUpdate).toHaveBeenCalledWith('newuser'); + expect((component.props()['value'] as any).set).toHaveBeenCalledWith('newuser'); }); }); diff --git a/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts b/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts index d061182a9..282075950 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text-field.component.ts @@ -81,10 +81,10 @@ export class TextFieldComponent { private rendererService = inject(A2uiRendererService); - label = computed(() => this.props()['label']?.value()); - value = computed(() => this.props()['value']?.value() || ''); - placeholder = computed(() => this.props()['placeholder']?.value() || ''); - variant = computed(() => this.props()['variant']?.value()); + label = computed(() => this.props()['label']?.()); + value = computed(() => this.props()['value']?.() || ''); + placeholder = computed(() => this.props()['placeholder']?.() || ''); + variant = computed(() => this.props()['variant']?.()); inputType = computed(() => { switch (this.variant()) { @@ -101,6 +101,6 @@ export class TextFieldComponent { const value = (event.target as HTMLInputElement).value; // Update the data path. If anything is listening to this path, it will be // notified. - this.props()['value']?.onUpdate(value); + this.props()['value']?.set(value); } } diff --git a/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts index fc639d2f5..38a338af4 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts @@ -24,6 +24,16 @@ describe('TextComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { + function createBoundProperty(val: any): any { + const sig = signal(val); + const prop = Object.assign(() => sig(), { + value: sig, + raw: val, + set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + }); + return prop as any; + } + await TestBed.configureTestingModule({ imports: [TextComponent], }).compileComponents(); @@ -31,9 +41,9 @@ describe('TextComponent', () => { fixture = TestBed.createComponent(TextComponent); component = fixture.componentInstance; fixture.componentRef.setInput('props', { - text: { value: signal('Hello World'), raw: 'Hello World', onUpdate: () => {} }, - weight: { value: signal('bold'), raw: 'bold', onUpdate: () => {} }, - style: { value: signal('italic'), raw: 'italic', onUpdate: () => {} }, + text: createBoundProperty('Hello World'), + weight: createBoundProperty('bold'), + style: createBoundProperty('italic'), }); }); diff --git a/renderers/angular/src/v0_9/catalog/basic/text.component.ts b/renderers/angular/src/v0_9/catalog/basic/text.component.ts index f34e91eb1..62cb09b8b 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text.component.ts @@ -47,7 +47,7 @@ export class TextComponent { componentId = input(); dataContextPath = input('/'); - weight = computed(() => this.props()['weight']?.value()); - style = computed(() => this.props()['style']?.value()); - text = computed(() => this.props()['text']?.value()); + weight = computed(() => this.props()['weight']?.()); + style = computed(() => this.props()['style']?.()); + text = computed(() => this.props()['text']?.()); } diff --git a/renderers/angular/src/v0_9/catalog/basic/video.component.ts b/renderers/angular/src/v0_9/catalog/basic/video.component.ts index 4d9a8f983..4836527c3 100644 --- a/renderers/angular/src/v0_9/catalog/basic/video.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/video.component.ts @@ -66,6 +66,6 @@ export class VideoComponent { componentId = input(); dataContextPath = input('/'); - url = computed(() => this.props()['url']?.value()); - posterUrl = computed(() => this.props()['posterUrl']?.value()); + url = computed(() => this.props()['url']?.()); + posterUrl = computed(() => this.props()['posterUrl']?.()); } diff --git a/renderers/angular/src/v0_9/catalog/types.ts b/renderers/angular/src/v0_9/catalog/types.ts index 4b385ce43..deee13cb7 100644 --- a/renderers/angular/src/v0_9/catalog/types.ts +++ b/renderers/angular/src/v0_9/catalog/types.ts @@ -15,7 +15,7 @@ */ import { Type } from '@angular/core'; -import { Catalog, ComponentApi } from '@a2ui/web_core/v0_9'; +import { Catalog, ComponentApi, FunctionImplementation } from '@a2ui/web_core/v0_9'; /** * Extends the generic {@link ComponentApi} to include Angular-specific component metadata. @@ -38,4 +38,13 @@ export interface AngularComponentImplementation extends ComponentApi { * definitions and by {@link ComponentHostComponent} to instantiate the * correct Angular components. */ -export class AngularCatalog extends Catalog {} +export class AngularCatalog extends Catalog { + constructor( + id: string, + components: AngularComponentImplementation[] | Record, + functions: FunctionImplementation[] = [], + ) { + const compArray = Array.isArray(components) ? components : Object.values(components); + super(id, compArray, functions); + } +} diff --git a/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts index 2e4e00458..cf2a189f6 100644 --- a/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts +++ b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { Injectable, OnDestroy, InjectionToken, Inject } from '@angular/core'; +import { Injectable, OnDestroy, InjectionToken, Inject, Injector } from '@angular/core'; +import { AngularReactiveProvider } from './angular-reactive-provider'; import { MessageProcessor, SurfaceGroupModel, @@ -58,12 +59,18 @@ export class A2uiRendererService implements OnDestroy { private _messageProcessor: MessageProcessor; private _catalogs: AngularCatalog[] = []; - constructor(@Inject(A2UI_RENDERER_CONFIG) private config: RendererConfiguration) { + constructor( + @Inject(A2UI_RENDERER_CONFIG) private config: RendererConfiguration, + private injector: Injector, + ) { this._catalogs = this.config.catalogs; + const provider = new AngularReactiveProvider(this.injector); + this._messageProcessor = new MessageProcessor( this._catalogs, this.config.actionHandler as ActionHandler, + provider, ); } diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts new file mode 100644 index 000000000..d87644bab --- /dev/null +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts @@ -0,0 +1,81 @@ +/** + * 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 { signal, computed, effect, untracked, Injector, runInInjectionContext } from '@angular/core'; +import { GenericSignal, ReactiveProvider } from '@a2ui/web_core/v0_9'; + +/** + * Bridges Angular signals to the GenericSignal interface. + */ +function wrapAngularSignal(sig: any): GenericSignal { + // sig is a function in Angular. + // We need to add a .value property and .peek() method. + const wrapper = () => sig(); + + Object.defineProperties(wrapper, { + value: { + get: () => sig(), + set: (v: T) => { + if (typeof sig.set === 'function') { + sig.set(v); + } else { + console.warn('Cannot set value on a computed Angular signal.'); + } + }, + }, + peek: { + value: () => untracked(() => sig()), + }, + // Add set method for direct access as well + set: { + value: (v: T) => sig.set?.(v), + }, + _isGenericSignal: { value: true }, + }); + + return wrapper as any as GenericSignal; +} + +/** + * A ReactiveProvider that uses Angular's native signals as its backend. + */ +export class AngularReactiveProvider implements ReactiveProvider { + constructor(private injector: Injector) {} + + signal(value: T): GenericSignal { + return wrapAngularSignal(signal(value)); + } + + computed(compute: () => T): GenericSignal { + return wrapAngularSignal(computed(compute)); + } + + effect(callback: () => void): () => void { + const effectRef = runInInjectionContext(this.injector, () => { + return effect(() => { + callback(); + }); + }); + return () => effectRef.destroy(); + } + + batch(callback: () => T): T { + // Angular doesn't have an explicit global `batch` like Preact, + // but signal updates are generally batched by the change detection cycle. + // For now, we just execute the callback. + return callback(); + } +} diff --git a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts index 483e4ea67..2eff0fea5 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts @@ -57,9 +57,19 @@ describe('ComponentBinder', () => { const mockDataContext = { resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => { - if (val === 'Hello') return mockpSigText; - if (val === true) return mockpSigVisible; - return preactSignal(val); + let sig: any; + if (val === 'Hello') sig = mockpSigText; + else if (val === true) sig = mockpSigVisible; + else sig = preactSignal(val); + + const callableSig = () => sig.value; + Object.defineProperties(callableSig, { + value: { get: () => sig.value, set: (v) => sig.value = v }, + peek: { value: () => sig.peek() }, + set: { value: (v: any) => sig.value = v }, + }); + (callableSig as any).raw = val; + return callableSig as any; }), set: jasmine.createSpy('set'), }; @@ -73,8 +83,8 @@ describe('ComponentBinder', () => { expect(bound['text']).toBeDefined(); expect(bound['visible']).toBeDefined(); - expect(bound['text'].value()).toBe('Hello'); - expect(bound['visible'].value()).toBe(true); + expect(bound['text']()).toBe('Hello'); + expect(bound['visible']()).toBe(true); // Verify resolveSignal was called expect(mockDataContext.resolveSignal).toHaveBeenCalledWith('Hello'); @@ -90,7 +100,14 @@ describe('ComponentBinder', () => { const mockpSig = preactSignal('initial'); const mockDataContext = { - resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig), + resolveSignal: jasmine.createSpy('resolveSignal').and.callFake(() => { + const callableSig = () => mockpSig.value; + Object.defineProperties(callableSig, { + value: { get: () => mockpSig.value, set: (v) => mockpSig.value = v }, + peek: { value: () => mockpSig.peek() }, + }); + return callableSig as any; + }), set: jasmine.createSpy('set'), }; @@ -102,11 +119,11 @@ describe('ComponentBinder', () => { const bound = binder.bind(mockContext); expect(bound['value']).toBeDefined(); - expect(bound['value'].value()).toBe('initial'); - expect(bound['value'].onUpdate).toBeDefined(); + expect(bound['value']()).toBe('initial'); + expect(bound['value'].set).toBeDefined(); - // Call update - bound['value'].onUpdate('new-value'); + // Call set + bound['value'].set('new-value'); // Verify set was called on DataContext expect(mockDataContext.set).toHaveBeenCalledWith('/data/text', 'new-value'); @@ -121,7 +138,15 @@ describe('ComponentBinder', () => { const mockpSig = preactSignal('Literal String'); const mockDataContext = { - resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig), + resolveSignal: jasmine.createSpy('resolveSignal').and.callFake(() => { + const callableSig = () => mockpSig.value; + Object.defineProperties(callableSig, { + value: { get: () => mockpSig.value, set: (v) => mockpSig.value = v }, + peek: { value: () => mockpSig.peek() }, + set: { value: (v: any) => mockpSig.value = v }, + }); + return callableSig as any; + }), set: jasmine.createSpy('set'), }; @@ -133,11 +158,11 @@ describe('ComponentBinder', () => { const bound = binder.bind(mockContext); expect(bound['text']).toBeDefined(); - expect(bound['text'].value()).toBe('Literal String'); - expect(bound['text'].onUpdate).toBeDefined(); // No-op for literals + expect(bound['text']()).toBe('Literal String'); + expect(bound['text'].set).toBeDefined(); // No-op for literals - // Call onUpdate on literal, should not crash or call set - bound['text'].onUpdate('new'); + // Call set on literal, should not crash or call set + bound['text'].set('new'); expect(mockDataContext.set).not.toHaveBeenCalled(); }); }); diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts index a56fcb0d0..55f4a0183 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { DestroyRef, Injectable, inject, NgZone } from '@angular/core'; +import { Injectable } from '@angular/core'; import { ComponentContext } from '@a2ui/web_core/v0_9'; -import { toAngularSignal } from './utils'; import { BoundProperty } from './types'; /** @@ -31,8 +30,7 @@ import { BoundProperty } from './types'; providedIn: 'root', }) export class ComponentBinder { - private destroyRef = inject(DestroyRef); - private ngZone = inject(NgZone); + constructor() {} /** * Binds all properties of a component to an object of Angular Signals. @@ -42,22 +40,48 @@ export class ComponentBinder { */ bind(context: ComponentContext): Record { const props = context.componentModel.properties; - const bound: Record = {}; + const bound: Record = {}; for (const key of Object.keys(props)) { const value = props[key]; - const preactSig = context.dataContext.resolveSignal(value); - const angSig = toAngularSignal(preactSig as any, this.destroyRef, this.ngZone); + const sig = context.dataContext.resolveSignal(value); + // Augment the signal into a BoundProperty const isBoundPath = value && typeof value === 'object' && 'path' in value; - - bound[key] = { - value: angSig, - raw: value, - onUpdate: isBoundPath - ? (newValue: any) => context.dataContext.set(value.path, newValue) - : () => {}, // No-op for non-bound values + + const boundProp = sig as any as BoundProperty; + + // Defensively define properties only if they don't already exist or are configurable + const defineSafe = (obj: any, key: string, descriptor: PropertyDescriptor) => { + const existing = Object.getOwnPropertyDescriptor(obj, key); + if (!existing || existing.configurable) { + Object.defineProperty(obj, key, descriptor); + } }; + + defineSafe(boundProp, 'raw', { value: value, configurable: true }); + + // Defensively define properties to avoid "Cannot redefine property" errors + try { + Object.defineProperty(boundProp, 'name', { + value: key, + configurable: true, + writable: true, + enumerable: true + }); + } catch (e) { + // Ignore if name cannot be redefined (it's mostly for debugging) + } + + // Only define 'set' if we have a path-bound property to handle + if (isBoundPath) { + defineSafe(boundProp, 'set', { + value: (newValue: any) => context.dataContext.set(value.path, newValue), + configurable: true + }); + } + + bound[key] = boundProp; } return bound; diff --git a/renderers/angular/src/v0_9/core/function_binding.spec.ts b/renderers/angular/src/v0_9/core/function_binding.spec.ts index b3b570c74..3076075ed 100644 --- a/renderers/angular/src/v0_9/core/function_binding.spec.ts +++ b/renderers/angular/src/v0_9/core/function_binding.spec.ts @@ -14,17 +14,40 @@ * limitations under the License. */ -import { DataContext, SurfaceModel } from '@a2ui/web_core/v0_9'; +import { DataContext, SurfaceModel, ReactiveProvider } from '@a2ui/web_core/v0_9'; import { DestroyRef } from '@angular/core'; import { BasicCatalogBase } from '../catalog/basic/basic-catalog'; -import { toAngularSignal } from './utils'; +import { signal as preactSignal, computed as preactComputed, effect as preactEffect, batch as preactBatch } from '@preact/signals-core'; describe('Function Bindings', () => { let mockDestroyRef: jasmine.SpyObj; + let mockProvider: ReactiveProvider; + beforeEach(() => { mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); mockDestroyRef.onDestroy.and.returnValue(() => {}); + + // Reactive mock provider using Preact + mockProvider = { + signal: (initial: any) => { + const s = preactSignal(initial); + const wrapper = () => s.value; + Object.defineProperty(wrapper, 'value', { get: () => s.value, set: (v) => s.value = v }); + (wrapper as any).peek = () => s.peek(); + (wrapper as any).set = (v: any) => s.value = v; + return wrapper as any; + }, + computed: (fn: any) => { + const s = preactComputed(fn); + const wrapper = () => s.value; + Object.defineProperty(wrapper, 'value', { get: () => s.value }); + (wrapper as any).peek = () => s.peek(); + return wrapper as any; + }, + effect: preactEffect, + batch: preactBatch, + }; }); describe('add', () => { @@ -32,7 +55,7 @@ describe('Function Bindings', () => { const catalog = new BasicCatalogBase(); // Create Surface Model and DataContext - const surface = new SurfaceModel('surface_1', catalog); + const surface = new SurfaceModel('surface_1', catalog, mockProvider); const dataModel = surface.dataModel; const context = new DataContext(surface, '/'); @@ -48,10 +71,7 @@ describe('Function Bindings', () => { }; // 1. Resolve Signal - const resSig = context.resolveSignal(callValue as any); - - // 2. Convert to Angular Signal - const angSig = toAngularSignal(resSig, mockDestroyRef); + const angSig = context.resolveSignal(callValue as any) as any; // 3. Initial state expect(isNaN(angSig())).toBe(true); @@ -73,7 +93,7 @@ describe('Function Bindings', () => { const catalog = new BasicCatalogBase(); // Create Surface Model and DataContext - const surface = new SurfaceModel('surface_1', catalog); + const surface = new SurfaceModel('surface_1', catalog, mockProvider); const dataModel = surface.dataModel; const context = new DataContext(surface, '/'); @@ -87,10 +107,7 @@ describe('Function Bindings', () => { }; // 1. Resolve Signal (Preact) - const resSig = context.resolveSignal(callValue as any); - - // 2. Convert to Angular Signal - const angSig = toAngularSignal(resSig, mockDestroyRef); + const angSig = context.resolveSignal(callValue as any) as any; // 3. Initial state (price is undefined, so should be '$') expect(angSig()).toBe('$'); @@ -107,7 +124,7 @@ describe('Function Bindings', () => { it('should handle multiple path interpolations correctly', () => { const catalog = new BasicCatalogBase(); - const surface = new SurfaceModel('surface_1', catalog); + const surface = new SurfaceModel('surface_1', catalog, mockProvider); const dataModel = surface.dataModel; const context = new DataContext(surface, '/'); @@ -119,8 +136,7 @@ describe('Function Bindings', () => { returnType: 'string', }; - const resSig = context.resolveSignal(callValue as any); - const angSig = toAngularSignal(resSig, mockDestroyRef); + const angSig = context.resolveSignal(callValue as any) as any; dataModel.set('/firstName', 'A2UI'); dataModel.set('/lastName', 'Renderer'); diff --git a/renderers/angular/src/v0_9/core/types.ts b/renderers/angular/src/v0_9/core/types.ts index 86c689836..a33470b13 100644 --- a/renderers/angular/src/v0_9/core/types.ts +++ b/renderers/angular/src/v0_9/core/types.ts @@ -14,39 +14,23 @@ * limitations under the License. */ -import { Signal } from '@angular/core'; +import { Signal, WritableSignal } from '@angular/core'; +import { GenericSignal } from '@a2ui/web_core/v0_9'; /** - * Represents a component property bound to an Angular Signal and update logic. + * A BoundProperty is a reactive signal that represents a component property + * from the A2UI component tree. * - * This interface is used by {@link ComponentBinder} to expose properties to - * Angular components. It contains the current value as a Signal, the raw - * property definition, and an update function. + * Components can read the current value by calling the property as a function: `props().key()`. + * If the property is bound to a data path, it also supports `.set(newValue)` to update the model. * * @template T The type of the property value. */ -export interface BoundProperty { - /** - * The reactive Angular Signal containing the current resolved value. - * - * This signal automatically updates whenever the underlying A2UI data - * model changes. - */ - readonly value: Signal; - - /** - * The raw value from the A2UI ComponentModel. - * - * This may be a literal value or a data binding path object. - */ +export type BoundProperty = (Signal | WritableSignal | GenericSignal) & { + /** The raw property definition from the component model (literal or binding). */ readonly raw: any; - - /** - * Callback to update the value in the A2UI DataContext. - * - * If the property is bound to a path in the data model, calling this - * will update that path. If the property is a literal value, this - * is typically a no-op. - */ - readonly onUpdate: (newValue: T) => void; -} + /** Direct access to the current value (same as calling the signal function). */ + readonly value: Signal; + /** Updates the underlying data model if the property is path-bound. */ + readonly set: (value: T) => void; +}; diff --git a/renderers/angular/src/v0_9/core/utils.spec.ts b/renderers/angular/src/v0_9/core/utils.spec.ts index 1c6dd72c3..079fba918 100644 --- a/renderers/angular/src/v0_9/core/utils.spec.ts +++ b/renderers/angular/src/v0_9/core/utils.spec.ts @@ -13,88 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { DestroyRef } from '@angular/core'; -import { signal as preactSignal } from '@preact/signals-core'; -import { toAngularSignal, getNormalizedPath } from './utils'; - -describe('toAngularSignal', () => { - let mockDestroyRef: jasmine.SpyObj; - let onDestroyCallback: () => void; - - beforeEach(() => { - onDestroyCallback = () => {}; - mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); - mockDestroyRef.onDestroy.and.callFake((callback: () => void) => { - onDestroyCallback = callback; - return () => {}; // Return unregister function - }); - }); - - it('should initialize with the current value of Preact signal', () => { - const pSig = preactSignal('initial'); - const angSig = toAngularSignal(pSig, mockDestroyRef); - - expect(angSig()).toBe('initial'); - }); - - it('should update Angular signal when Preact signal changes', () => { - const pSig = preactSignal('initial'); - const angSig = toAngularSignal(pSig, mockDestroyRef); - - expect(angSig()).toBe('initial'); - - pSig.value = 'updated'; - expect(angSig()).toBe('updated'); - }); - - it('should dispose Preact effect when DestroyRef triggers', () => { - const pSig = preactSignal('initial'); - const angSig = toAngularSignal(pSig, mockDestroyRef); - - expect(angSig()).toBe('initial'); - - // Trigger cleanup - onDestroyCallback(); - - pSig.value = 'updated'; - // Angular signal should NOT update after disposal - expect(angSig()).toBe('initial'); - }); - - it('should call unsubscribe on Preact signal if available on destroy', () => { - const pSig = preactSignal('initial') as any; - const unsubscribeSpy = jasmine.createSpy('unsubscribe'); - pSig.unsubscribe = unsubscribeSpy; - - toAngularSignal(pSig, mockDestroyRef); - - expect(unsubscribeSpy).not.toHaveBeenCalled(); - - // Trigger cleanup - onDestroyCallback(); - - expect(unsubscribeSpy).toHaveBeenCalled(); - }); - - it('should run update within NgZone if provided', () => { - const pSig = preactSignal('initial'); - const mockNgZone = jasmine.createSpyObj('NgZone', ['run']); - mockNgZone.run.and.callFake((fn: () => void) => fn()); - - const angSig = toAngularSignal(pSig, mockDestroyRef, mockNgZone); - - expect(angSig()).toBe('initial'); - expect(mockNgZone.run).toHaveBeenCalled(); - - mockNgZone.run.calls.reset(); - pSig.value = 'updated'; - - expect(angSig()).toBe('updated'); - expect(mockNgZone.run).toHaveBeenCalled(); - }); -}); - +import { getNormalizedPath } from './utils'; describe('getNormalizedPath', () => { it('should handle absolute paths', () => { expect(getNormalizedPath('/absolute', '/', 0)).toBe('/absolute/0'); diff --git a/renderers/angular/src/v0_9/core/utils.ts b/renderers/angular/src/v0_9/core/utils.ts index eccb4476a..622ea9e19 100644 --- a/renderers/angular/src/v0_9/core/utils.ts +++ b/renderers/angular/src/v0_9/core/utils.ts @@ -14,51 +14,8 @@ * limitations under the License. */ -import { DestroyRef, Signal, signal as angularSignal } from '@angular/core'; -import { Signal as PreactSignal, effect } from '@a2ui/web_core/v0_9'; - -/** - * Bridges a Preact Signal (from A2UI web_core) to a reactive Angular Signal. - * - * This utility handles the lifecycle mapping between Preact and Angular, - * ensuring that updates from the A2UI data model are propagated correctly - * to Angular's change detection, and resources are cleaned up when the - * component is destroyed. - * - * @param preactSignal The source Preact Signal. - * @param destroyRef Angular DestroyRef for lifecycle management. - * @param ngZone Optional NgZone to ensure updates run within the Angular zone - * (necessary for correct change detection in OnPush components). - * @returns A read-only Angular Signal. - */ import { NgZone } from '@angular/core'; -export function toAngularSignal( - preactSignal: PreactSignal, - destroyRef: DestroyRef, - ngZone?: NgZone, -): Signal { - const s = angularSignal(preactSignal.peek()); - - const dispose = effect(() => { - if (ngZone) { - ngZone.run(() => s.set(preactSignal.value)); - } else { - s.set(preactSignal.value); - } - }); - - destroyRef.onDestroy(() => { - dispose(); - // Some signals returned by DataContext.resolveSignal have a custom unsubscribe for AbortControllers - if ((preactSignal as any).unsubscribe) { - (preactSignal as any).unsubscribe(); - } - }); - - return s.asReadonly(); -} - /** * Normalizes a data model path by combining a relative path with a base context. * diff --git a/renderers/web_core/src/v0_9/catalog/types.ts b/renderers/web_core/src/v0_9/catalog/types.ts index 3700fc6ee..fbd2ec927 100644 --- a/renderers/web_core/src/v0_9/catalog/types.ts +++ b/renderers/web_core/src/v0_9/catalog/types.ts @@ -16,14 +16,14 @@ import { z } from "zod"; import { DataContext } from "../rendering/data-context.js"; -import { Signal } from "@preact/signals-core"; +import { GenericSignal } from "../common/reactive.js"; import { A2uiExpressionError } from "../errors.js"; /** - * Robust check for a Preact Signal that works across package boundaries. + * Robust check for a GenericSignal that works across package boundaries. */ -export function isSignal(val: any): val is Signal { - return val && typeof val === "object" && "value" in val && "peek" in val; +export function isSignal(val: any): val is GenericSignal { + return val && (typeof val === "object" || typeof val === "function") && "value" in val && "peek" in val; } export type A2uiReturnType = 'string' | 'number' | 'boolean' | 'array' | 'object' | 'any' | 'void'; @@ -54,7 +54,7 @@ export interface FunctionImplementation extends FunctionApi { args: Record, context: DataContext, abortSignal?: AbortSignal - ): unknown | Signal; + ): unknown | GenericSignal; } export function createFunctionImplementation< @@ -66,7 +66,7 @@ export function createFunctionImplementation< args: z.infer, context: DataContext, abortSignal?: AbortSignal - ) => InferA2uiReturnType | Signal> + ) => InferA2uiReturnType | GenericSignal> ): FunctionImplementation { return { name: api.name, diff --git a/renderers/web_core/src/v0_9/common/preact-provider.ts b/renderers/web_core/src/v0_9/common/preact-provider.ts new file mode 100644 index 000000000..0706dfffa --- /dev/null +++ b/renderers/web_core/src/v0_9/common/preact-provider.ts @@ -0,0 +1,63 @@ +/** + * 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 { signal, computed, effect, batch, Signal } from '@preact/signals-core'; +import { GenericSignal, ReactiveProvider } from './reactive'; + +/** + * Bridges Preact signals to the GenericSignal interface. + */ +function wrapPreactSignal(sig: Signal): GenericSignal { + // A Preact signal already acts as a GenericSignal if we treat it right. + // It has a .value property and a .peek() method. + // We just need to make it callable as a function sig() for Angular compatibility. + const wrapper = () => sig.value; + Object.defineProperties(wrapper, { + value: { + get: () => sig.value, + set: (v: T) => { + sig.value = v; + }, + }, + peek: { + value: () => sig.peek(), + }, + // We add this to easily detect it as a signal in catalog code. + _isGenericSignal: { value: true }, + }); + return wrapper as any as GenericSignal; +} + +/** + * A ReactiveProvider that uses @preact/signals-core as its backend. + */ +export class PreactReactiveProvider implements ReactiveProvider { + signal(value: T): GenericSignal { + return wrapPreactSignal(signal(value)); + } + + computed(compute: () => T): GenericSignal { + return wrapPreactSignal(computed(compute)); + } + + effect(callback: () => void): () => void { + return effect(callback); + } + + batch(callback: () => T): T { + return batch(callback); + } +} diff --git a/renderers/web_core/src/v0_9/common/reactive.ts b/renderers/web_core/src/v0_9/common/reactive.ts new file mode 100644 index 000000000..d0d036808 --- /dev/null +++ b/renderers/web_core/src/v0_9/common/reactive.ts @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * A generalized interface for a reactive primitive (a \"signal\"). + * It represents a value that can be read, but it also has a .value property + * for direct access (like Preact signals). + */ +export interface GenericSignal { + /** The current value (accessor). Functions as a getter/setter. */ + value: T; + + /** + * Invoking the signal as a function returns its current value. + * This is compatible with both Preact and Angular signals. + */ + (): T; + + /** Reads the value without creating a reactive dependency. */ + peek(): T; +} + +/** + * An abstraction for creating reactive primitives and observing changes. + * This allows web_core to be agnostic to the underlying signal implementation. + */ +export interface ReactiveProvider { + /** Creates a reactive signal. */ + signal(value: T): GenericSignal; + + /** Creates a computed signal based on other signals. */ + computed(compute: () => T): GenericSignal; + + /** + * Runs an effect that automatically tracks and re-executes when its signal + * dependencies change. + * @returns A cleanup function to stop the effect. + */ + effect(callback: () => void): () => void; + + /** + * Batches multiple signal updates so that effects and computations are + * triggered only once at the end of the batch. + */ + batch(callback: () => T): T; +} diff --git a/renderers/web_core/src/v0_9/index.ts b/renderers/web_core/src/v0_9/index.ts index d973eae52..376a91755 100644 --- a/renderers/web_core/src/v0_9/index.ts +++ b/renderers/web_core/src/v0_9/index.ts @@ -24,11 +24,13 @@ export * from "./catalog/function_invoker.js"; export * from "./catalog/types.js"; export * from "./common/events.js"; +export * from "./common/reactive.js"; export * from "./processing/message-processor.js"; export * from "./rendering/component-context.js"; export * from "./rendering/data-context.js"; export * from "./rendering/generic-binder.js"; export * from "./schema/index.js"; +export * from "./schema/client-to-server.js"; export * from "./state/component-model.js"; export * from "./state/data-model.js"; export * from "./state/surface-components-model.js"; diff --git a/renderers/web_core/src/v0_9/processing/message-processor.ts b/renderers/web_core/src/v0_9/processing/message-processor.ts index 2c9939fa9..f9371f1d3 100644 --- a/renderers/web_core/src/v0_9/processing/message-processor.ts +++ b/renderers/web_core/src/v0_9/processing/message-processor.ts @@ -19,6 +19,8 @@ import { Catalog, ComponentApi } from "../catalog/types.js"; import { SurfaceGroupModel } from "../state/surface-group-model.js"; import { ComponentModel } from "../state/component-model.js"; import { Subscription } from "../common/events.js"; +import { ReactiveProvider } from "../common/reactive.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; import { A2uiMessage, @@ -42,12 +44,14 @@ export class MessageProcessor { * * @param catalogs A list of available catalogs. * @param actionHandler A global handler for actions from all surfaces. + * @param provider The reactive provider to use for signals and effects. */ constructor( private catalogs: Catalog[], private actionHandler?: ActionListener, + private provider: ReactiveProvider = new PreactReactiveProvider(), ) { - this.model = new SurfaceGroupModel(); + this.model = new SurfaceGroupModel(this.provider); if (this.actionHandler) { this.model.onAction.subscribe(this.actionHandler); } @@ -152,6 +156,7 @@ export class MessageProcessor { const surface = new SurfaceModel( surfaceId, catalog, + this.provider, theme, sendDataModel ?? false, ); diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index 5e4b21c25..8f7520ce9 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { signal, computed, Signal, effect } from "@preact/signals-core"; +import { GenericSignal } from "../common/reactive.js"; import { z } from "zod"; import { DataModel, DataSubscription } from "../state/data-model.js"; import type { @@ -142,7 +142,7 @@ export class DataContext { let isSync = true; let currentValue = sig.peek(); - const dispose = effect(() => { + const dispose = this.surface.provider.effect(() => { const val = sig.value; currentValue = val; if (!isSync) { @@ -174,22 +174,22 @@ export class DataContext { * @param value The DynamicValue to evaluate and observe. * @returns A Preact Signal containing the reactive result of the evaluation. */ - resolveSignal(value: DynamicValue): Signal { + resolveSignal(value: DynamicValue): GenericSignal { // 1. Literal if (typeof value !== "object" || value === null || Array.isArray(value)) { - return signal(value as V); + return this.surface.provider.signal(value as V); } // 2. Path Check if ("path" in value) { const absolutePath = this.resolvePath((value as DataBinding).path); - return this.dataModel.getSignal(absolutePath) as Signal; + return this.dataModel.getSignal(absolutePath) as GenericSignal; } // 3. Function Call if ("call" in value) { const call = value as FunctionCall; - const argSignals: Record> = {}; + const argSignals: Record> = {}; for (const [key, argVal] of Object.entries(call.args)) { argSignals[key] = this.resolveSignal(argVal); @@ -202,25 +202,25 @@ export class DataContext { {}, abortController.signal, ); - const sig = result instanceof Signal ? result : signal(result); + const sig = isSignal(result) ? result : this.surface.provider.signal(result); (sig as any).unsubscribe = () => abortController.abort(); return sig; } const keys = Object.keys(argSignals); - const resultSig = signal(undefined); + const resultSig = this.surface.provider.signal(undefined); let abortController: AbortController | undefined; let innerUnsubscribe: (() => void) | undefined; - const argsSig = computed(() => { + const argsSig = this.surface.provider.computed(() => { const argsRecord: Record = {}; for (let i = 0; i < keys.length; i++) { - argsRecord[keys[i]] = argSignals[keys[i]].value; + argsRecord[keys[i]] = argSignals[keys[i]](); } return argsRecord; }); - const stopper = effect(() => { + const stopper = this.surface.provider.effect(() => { try { const args = argsSig.value; if (abortController) abortController.abort(); @@ -237,7 +237,7 @@ export class DataContext { ); if (isSignal(res)) { - innerUnsubscribe = effect(() => { + innerUnsubscribe = this.surface.provider.effect(() => { resultSig.value = res.value; }); } else { @@ -262,10 +262,10 @@ export class DataContext { } }; - return resultSig as unknown as Signal; + return resultSig as unknown as GenericSignal; } - return signal(value as unknown as V); + return this.surface.provider.signal(value as unknown as V); } /** @@ -303,7 +303,7 @@ export class DataContext { name: string, args: Record, abortSignal?: AbortSignal, - ): Signal | V { + ): GenericSignal | V { try { return this.functionInvoker(name, args, this, abortSignal); } catch (e: any) { diff --git a/renderers/web_core/src/v0_9/state/data-model.ts b/renderers/web_core/src/v0_9/state/data-model.ts index 7455f2730..03aff487f 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -16,7 +16,8 @@ import { Subscription as BaseSubscription } from "../common/events.js"; import { A2uiDataError } from "../errors.js"; -import { signal, Signal, batch, effect } from "@preact/signals-core"; +import { ReactiveProvider, GenericSignal } from "../common/reactive.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; /** * Represents a reactive connection to a specific path in the data model. @@ -38,15 +39,19 @@ function isNumeric(value: string): boolean { */ export class DataModel { private data: Record = {}; - private readonly signals: Map> = new Map(); + private readonly signals: Map> = new Map(); private readonly subscriptions: Set<() => void> = new Set(); // To track direct subscriptions for dispose /** * Creates a new data model. * * @param initialData The initial data for the model. Defaults to an empty object. + * @param provider The reactive provider to use. Defaults to Preact. */ - constructor(initialData: Record = {}) { + constructor( + initialData: Record = {}, + private readonly provider: ReactiveProvider = new PreactReactiveProvider(), + ) { this.data = initialData; } @@ -59,12 +64,12 @@ export class DataModel { * @param path The JSON pointer path to create or retrieve a signal for. * @returns A Preact Signal representing the value at the specified path. */ - getSignal(path: string): Signal { + getSignal(path: string): GenericSignal { const normalizedPath = this.normalizePath(path); if (!this.signals.has(normalizedPath)) { - this.signals.set(normalizedPath, signal(this.get(normalizedPath))); + this.signals.set(normalizedPath, this.provider.signal(this.get(normalizedPath))); } - return this.signals.get(normalizedPath) as Signal; + return this.signals.get(normalizedPath) as GenericSignal; } /** @@ -189,7 +194,7 @@ export class DataModel { let isSync = true; let currentValue = sig.peek(); - const dispose = effect(() => { + const dispose = this.provider.effect(() => { const val = sig.value; currentValue = val; if (!isSync) { @@ -236,7 +241,7 @@ export class DataModel { private notifySignals(path: string): void { const normalizedPath = this.normalizePath(path); - batch(() => { + this.provider.batch(() => { this.updateSignal(normalizedPath); // Notify Ancestors @@ -270,7 +275,7 @@ export class DataModel { } private notifyAllSignals(): void { - batch(() => { + this.provider.batch(() => { for (const path of this.signals.keys()) { this.updateSignal(path); } diff --git a/renderers/web_core/src/v0_9/state/surface-group-model.test.ts b/renderers/web_core/src/v0_9/state/surface-group-model.test.ts index 51b0e77fe..de5c5e226 100644 --- a/renderers/web_core/src/v0_9/state/surface-group-model.test.ts +++ b/renderers/web_core/src/v0_9/state/surface-group-model.test.ts @@ -30,22 +30,22 @@ describe("SurfaceGroupModel", () => { }); it("adds surface", () => { - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); assert.ok(model.getSurface("s1")); assert.strictEqual(model.getSurface("s1"), surface); }); it("ignores duplicate surface addition", () => { - const s1 = new SurfaceModel("s1", catalog, {}); - const s2 = new SurfaceModel("s1", catalog, {}); // Same ID + const s1 = new SurfaceModel("s1", catalog); + const s2 = new SurfaceModel("s1", catalog); // Same ID model.addSurface(s1); model.addSurface(s2); assert.strictEqual(model.getSurface("s1"), s1); // Should still be the first one }); it("deletes surface", () => { - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); assert.ok(model.getSurface("s1")); @@ -64,7 +64,7 @@ describe("SurfaceGroupModel", () => { deletedId = id; }); - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); assert.ok(created); assert.strictEqual(created?.id, "s1"); @@ -79,7 +79,7 @@ describe("SurfaceGroupModel", () => { receivedAction = action; }); - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); await surface.dispatchAction({ event: { name: "test" } }, "c1"); @@ -94,7 +94,7 @@ describe("SurfaceGroupModel", () => { callCount++; }); - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); model.deleteSurface("s1"); @@ -103,7 +103,7 @@ describe("SurfaceGroupModel", () => { }); it("exposes surfacesMap", () => { - const surface = new SurfaceModel("s1", catalog, {}); + const surface = new SurfaceModel("s1", catalog); model.addSurface(surface); const map = model.surfacesMap; assert.strictEqual(map.size, 1); @@ -111,8 +111,8 @@ describe("SurfaceGroupModel", () => { }); it("disposes correctly", () => { - const s1 = new SurfaceModel("s1", catalog, {}); - const s2 = new SurfaceModel("s2", catalog, {}); + const s1 = new SurfaceModel("s1", catalog); + const s2 = new SurfaceModel("s2", catalog); model.addSurface(s1); model.addSurface(s2); diff --git a/renderers/web_core/src/v0_9/state/surface-group-model.ts b/renderers/web_core/src/v0_9/state/surface-group-model.ts index 21cc91ad4..94f80c989 100644 --- a/renderers/web_core/src/v0_9/state/surface-group-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-group-model.ts @@ -18,6 +18,8 @@ import { SurfaceModel } from "./surface-model.js"; import { ComponentApi } from "../catalog/types.js"; import { EventEmitter, EventSource, Subscription } from "../common/events.js"; import { A2uiClientAction } from "../schema/client-to-server.js"; +import { ReactiveProvider } from "../common/reactive.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; /** * The root state model for the A2UI system. @@ -27,6 +29,10 @@ export class SurfaceGroupModel { private surfaces: Map> = new Map(); private surfaceUnsubscribers: Map = new Map(); + constructor( + public readonly provider: ReactiveProvider = new PreactReactiveProvider(), + ) {} + private readonly _onSurfaceCreated = new EventEmitter>(); private readonly _onSurfaceDeleted = new EventEmitter(); private readonly _onAction = new EventEmitter(); diff --git a/renderers/web_core/src/v0_9/state/surface-model.test.ts b/renderers/web_core/src/v0_9/state/surface-model.test.ts index 9359aac2d..c322bd33d 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.test.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.test.ts @@ -31,7 +31,7 @@ describe("SurfaceModel", () => { actions = []; errors = []; catalog = new Catalog("test-catalog", []); - surface = new SurfaceModel("surface-1", catalog, {}); + surface = new SurfaceModel("surface-1", catalog); surface.onAction.subscribe(async (action) => { actions.push(action); }); diff --git a/renderers/web_core/src/v0_9/state/surface-model.ts b/renderers/web_core/src/v0_9/state/surface-model.ts index a95b1af9a..73b906bf1 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.ts @@ -22,6 +22,8 @@ import { A2uiClientAction, A2uiClientActionSchema, } from "../schema/client-to-server.js"; +import { ReactiveProvider } from "../common/reactive.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; /** A function that listens for actions emitted from a surface. */ export type ActionListener = (action: A2uiClientAction) => void | Promise; @@ -60,10 +62,11 @@ export class SurfaceModel { constructor( readonly id: string, readonly catalog: Catalog, + public readonly provider: ReactiveProvider = new PreactReactiveProvider(), readonly theme: any = {}, readonly sendDataModel: boolean = false, ) { - this.dataModel = new DataModel({}); + this.dataModel = new DataModel({}, provider); this.componentsModel = new SurfaceComponentsModel(); } diff --git a/renderers/web_core/src/v0_9/test/test-utils.ts b/renderers/web_core/src/v0_9/test/test-utils.ts index 384def9fd..07adfd0e9 100644 --- a/renderers/web_core/src/v0_9/test/test-utils.ts +++ b/renderers/web_core/src/v0_9/test/test-utils.ts @@ -21,7 +21,7 @@ import { ComponentModel } from "../state/component-model.js"; export class TestSurfaceModel extends SurfaceModel { constructor(actionHandler: any = async () => {}) { - super("test", new Catalog("test-catalog", []), {}); + super("test", new Catalog("test-catalog", [])); this.onAction.subscribe(actionHandler); } } From 6a8ee1769dd33c06867e862483f2b71d1883aa1d Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 12:42:40 -0700 Subject: [PATCH 03/14] fix(angular): make signal wrapper properties configurable for data binding Resolved a regression in the v0.9 demo where two-way data binding for text fields and other interactive components was failing. The root cause was that signal wrapper properties created by the reactive providers were non-configurable by default, preventing the ComponentBinder from augmenting them with DataModel setters. Changes: - Set `configurable: true` for all wrapped signal properties in both Angular and Preact reactive providers. - Added explicit `.set(v)` method to Preact's signal wrapper for API consistency across renderers. - Updated `GenericSignal` interface in `web_core` to include `.set()`. - Verified fix in `a2a_explorer` Login Form example. --- .../src/v0_9/core/angular-reactive-provider.ts | 5 ++++- renderers/web_core/src/v0_9/common/preact-provider.ts | 11 +++++++++-- renderers/web_core/src/v0_9/common/reactive.ts | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts index d87644bab..2501d338b 100644 --- a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts @@ -35,15 +35,18 @@ function wrapAngularSignal(sig: any): GenericSignal { console.warn('Cannot set value on a computed Angular signal.'); } }, + configurable: true, }, peek: { value: () => untracked(() => sig()), + configurable: true, }, // Add set method for direct access as well set: { value: (v: T) => sig.set?.(v), + configurable: true, }, - _isGenericSignal: { value: true }, + _isGenericSignal: { value: true, configurable: true }, }); return wrapper as any as GenericSignal; diff --git a/renderers/web_core/src/v0_9/common/preact-provider.ts b/renderers/web_core/src/v0_9/common/preact-provider.ts index 0706dfffa..2866b017f 100644 --- a/renderers/web_core/src/v0_9/common/preact-provider.ts +++ b/renderers/web_core/src/v0_9/common/preact-provider.ts @@ -31,12 +31,19 @@ function wrapPreactSignal(sig: Signal): GenericSignal { set: (v: T) => { sig.value = v; }, + configurable: true, }, peek: { value: () => sig.peek(), + configurable: true, }, - // We add this to easily detect it as a signal in catalog code. - _isGenericSignal: { value: true }, + set: { + value: (v: T) => { + sig.value = v; + }, + configurable: true, + }, + _isGenericSignal: { value: true, configurable: true }, }); return wrapper as any as GenericSignal; } diff --git a/renderers/web_core/src/v0_9/common/reactive.ts b/renderers/web_core/src/v0_9/common/reactive.ts index d0d036808..605e2e82e 100644 --- a/renderers/web_core/src/v0_9/common/reactive.ts +++ b/renderers/web_core/src/v0_9/common/reactive.ts @@ -31,6 +31,9 @@ export interface GenericSignal { /** Reads the value without creating a reactive dependency. */ peek(): T; + + /** Updates the value of the signal. */ + set(value: T): void; } /** From b2292f5623a7e69b6d2be92bfb613fb5ac29d05e Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 13:45:29 -0700 Subject: [PATCH 04/14] chore(angular): log warning if signal name property definition fails Previously, failures in defining the "name" property (used primarily for debugging) were silently ignored. Added a console.warn to capture unexpected redefinition errors. --- renderers/angular/src/v0_9/core/component-binder.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts index 55f4a0183..bb301a701 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.ts @@ -70,7 +70,7 @@ export class ComponentBinder { enumerable: true }); } catch (e) { - // Ignore if name cannot be redefined (it's mostly for debugging) + console.warn(`Failed to define "name" property on bound signal for "${key}":`, e); } // Only define 'set' if we have a path-bound property to handle From 0b425ffaefa306804e7539c6befbb5a9c2ddbb9c Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 14:00:32 -0700 Subject: [PATCH 05/14] refactor: implement engine-agnostic reactive provider in web_core v0.9 - Introduce ReactiveProvider and GenericSignal abstractions to permit multiple reactive engines. - Add isSignal type guard and toGenericSignal coercion method to ReactiveProvider. - Implement AngularReactiveProvider to bridge native Angular signals. - Update DataContext to use standardized signal resolution for both path-bindings and literals. - Fix web_core test failures by providing proper reactive providers to mock surfaces. - Support direct signal updates via prop.set(v) in ComponentBinder and Angular components. --- .../a2ui_explorer/src/app/demo.component.ts | 1 - .../angular/src/v0_8/components/modal.ts | 12 +----- .../catalog/basic/button.component.spec.ts | 8 ++-- .../catalog/basic/choice-picker.component.ts | 4 +- .../v0_9/core/angular-reactive-provider.ts | 29 +++++++++++++-- .../core/component-binder.service.spec.ts | 12 +++--- .../src/v0_9/core/component-binder.service.ts | 10 ++--- .../src/v0_9/core/component-host.component.ts | 10 ++--- .../src/v0_9/core/function_binding.spec.ts | 37 ++++++++++++++++--- .../functions/basic_functions.test.ts | 3 ++ .../src/v0_9/common/preact-provider.ts | 18 +++++++++ .../web_core/src/v0_9/common/reactive.ts | 13 +++++++ .../src/v0_9/rendering/data-context.test.ts | 2 + .../src/v0_9/rendering/data-context.ts | 14 +++---- 14 files changed, 122 insertions(+), 51 deletions(-) diff --git a/renderers/angular/a2ui_explorer/src/app/demo.component.ts b/renderers/angular/a2ui_explorer/src/app/demo.component.ts index b93ad9e82..3c9d91cc8 100644 --- a/renderers/angular/a2ui_explorer/src/app/demo.component.ts +++ b/renderers/angular/a2ui_explorer/src/app/demo.component.ts @@ -28,7 +28,6 @@ import { Example } from './types'; import { Subscription } from 'rxjs'; import { ActionDispatcher } from './action-dispatcher.service'; - /** * Main dashboard component for A2UI v0.9 Angular Renderer. * It provides a sidebar of examples, a canvas for rendering, diff --git a/renderers/angular/src/v0_8/components/modal.ts b/renderers/angular/src/v0_8/components/modal.ts index 78e9aa659..10df7d6f1 100644 --- a/renderers/angular/src/v0_8/components/modal.ts +++ b/renderers/angular/src/v0_8/components/modal.ts @@ -25,11 +25,7 @@ import { Types } from '../types'; template: `
@if (entryPointChild()) { - + }
@@ -37,11 +33,7 @@ import { Types } from '../types';
@if (contentChild()) { - + }
diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts index cd0cc2165..52ff23759 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts @@ -61,10 +61,10 @@ describe('ButtonComponent', () => { template: 'Dummy Text', }) class DummyText { - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); + props = input(); + surfaceId = input(); + componentId = input(); + dataContextPath = input(); } return DummyText; })(), diff --git a/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts b/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts index 29df6097a..119661377 100644 --- a/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts +++ b/renderers/angular/src/v0_9/catalog/basic/choice-picker.component.ts @@ -123,9 +123,7 @@ export class ChoicePickerComponent { private rendererService = inject(A2uiRendererService); displayStyle = computed(() => this.props()['displayStyle']?.()); - choices = computed( - () => this.props()['choices']?.() || this.props()['options']?.() || [], - ); + choices = computed(() => this.props()['choices']?.() || this.props()['options']?.() || []); variant = computed(() => this.props()['variant']?.()); selectedValue = computed(() => this.props()['value']?.()); diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts index 2501d338b..0c261ca7d 100644 --- a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts @@ -14,7 +14,14 @@ * limitations under the License. */ -import { signal, computed, effect, untracked, Injector, runInInjectionContext } from '@angular/core'; +import { + signal, + computed, + effect, + untracked, + Injector, + runInInjectionContext, +} from '@angular/core'; import { GenericSignal, ReactiveProvider } from '@a2ui/web_core/v0_9'; /** @@ -24,7 +31,7 @@ function wrapAngularSignal(sig: any): GenericSignal { // sig is a function in Angular. // We need to add a .value property and .peek() method. const wrapper = () => sig(); - + Object.defineProperties(wrapper, { value: { get: () => sig(), @@ -48,7 +55,7 @@ function wrapAngularSignal(sig: any): GenericSignal { }, _isGenericSignal: { value: true, configurable: true }, }); - + return wrapper as any as GenericSignal; } @@ -75,8 +82,22 @@ export class AngularReactiveProvider implements ReactiveProvider { return () => effectRef.destroy(); } + isSignal(v: any): v is GenericSignal { + return v && (v._isGenericSignal || (typeof v === 'function' && !!(v as any).set)); + } + + toGenericSignal(v: any): GenericSignal { + if (v && v._isGenericSignal) { + return v as GenericSignal; + } + if (this.isSignal(v)) { + return wrapAngularSignal(v); + } + return this.signal(v); + } + batch(callback: () => T): T { - // Angular doesn't have an explicit global `batch` like Preact, + // Angular doesn't have an explicit global `batch` like Preact, // but signal updates are generally batched by the change detection cycle. // For now, we just execute the callback. return callback(); diff --git a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts index 2eff0fea5..231ae5fc2 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts @@ -61,12 +61,12 @@ describe('ComponentBinder', () => { if (val === 'Hello') sig = mockpSigText; else if (val === true) sig = mockpSigVisible; else sig = preactSignal(val); - + const callableSig = () => sig.value; Object.defineProperties(callableSig, { - value: { get: () => sig.value, set: (v) => sig.value = v }, + value: { get: () => sig.value, set: (v) => (sig.value = v) }, peek: { value: () => sig.peek() }, - set: { value: (v: any) => sig.value = v }, + set: { value: (v: any) => (sig.value = v) }, }); (callableSig as any).raw = val; return callableSig as any; @@ -103,7 +103,7 @@ describe('ComponentBinder', () => { resolveSignal: jasmine.createSpy('resolveSignal').and.callFake(() => { const callableSig = () => mockpSig.value; Object.defineProperties(callableSig, { - value: { get: () => mockpSig.value, set: (v) => mockpSig.value = v }, + value: { get: () => mockpSig.value, set: (v) => (mockpSig.value = v) }, peek: { value: () => mockpSig.peek() }, }); return callableSig as any; @@ -141,9 +141,9 @@ describe('ComponentBinder', () => { resolveSignal: jasmine.createSpy('resolveSignal').and.callFake(() => { const callableSig = () => mockpSig.value; Object.defineProperties(callableSig, { - value: { get: () => mockpSig.value, set: (v) => mockpSig.value = v }, + value: { get: () => mockpSig.value, set: (v) => (mockpSig.value = v) }, peek: { value: () => mockpSig.peek() }, - set: { value: (v: any) => mockpSig.value = v }, + set: { value: (v: any) => (mockpSig.value = v) }, }); return callableSig as any; }), diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts index bb301a701..fa45da8ad 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.ts @@ -48,9 +48,9 @@ export class ComponentBinder { // Augment the signal into a BoundProperty const isBoundPath = value && typeof value === 'object' && 'path' in value; - + const boundProp = sig as any as BoundProperty; - + // Defensively define properties only if they don't already exist or are configurable const defineSafe = (obj: any, key: string, descriptor: PropertyDescriptor) => { const existing = Object.getOwnPropertyDescriptor(obj, key); @@ -67,17 +67,17 @@ export class ComponentBinder { value: key, configurable: true, writable: true, - enumerable: true + enumerable: true, }); } catch (e) { console.warn(`Failed to define "name" property on bound signal for "${key}":`, e); } - + // Only define 'set' if we have a path-bound property to handle if (isBoundPath) { defineSafe(boundProp, 'set', { value: (newValue: any) => context.dataContext.set(value.path, newValue), - configurable: true + configurable: true, }); } diff --git a/renderers/angular/src/v0_9/core/component-host.component.ts b/renderers/angular/src/v0_9/core/component-host.component.ts index 4f5cb0fb6..7e85903d4 100644 --- a/renderers/angular/src/v0_9/core/component-host.component.ts +++ b/renderers/angular/src/v0_9/core/component-host.component.ts @@ -48,11 +48,11 @@ import { ComponentBinder } from './component-binder.service'; *ngComponentOutlet=" componentType; inputs: { - props: props, - surfaceId: surfaceId(), - componentId: componentId(), - dataContextPath: dataContextPath(), - } + props: props, + surfaceId: surfaceId(), + componentId: componentId(), + dataContextPath: dataContextPath(), + } " > } diff --git a/renderers/angular/src/v0_9/core/function_binding.spec.ts b/renderers/angular/src/v0_9/core/function_binding.spec.ts index 3076075ed..1ab2c4407 100644 --- a/renderers/angular/src/v0_9/core/function_binding.spec.ts +++ b/renderers/angular/src/v0_9/core/function_binding.spec.ts @@ -17,25 +17,30 @@ import { DataContext, SurfaceModel, ReactiveProvider } from '@a2ui/web_core/v0_9'; import { DestroyRef } from '@angular/core'; import { BasicCatalogBase } from '../catalog/basic/basic-catalog'; -import { signal as preactSignal, computed as preactComputed, effect as preactEffect, batch as preactBatch } from '@preact/signals-core'; +import { + signal as preactSignal, + computed as preactComputed, + effect as preactEffect, + batch as preactBatch, +} from '@preact/signals-core'; describe('Function Bindings', () => { let mockDestroyRef: jasmine.SpyObj; let mockProvider: ReactiveProvider; - + beforeEach(() => { mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); mockDestroyRef.onDestroy.and.returnValue(() => {}); - + // Reactive mock provider using Preact mockProvider = { signal: (initial: any) => { const s = preactSignal(initial); const wrapper = () => s.value; - Object.defineProperty(wrapper, 'value', { get: () => s.value, set: (v) => s.value = v }); + Object.defineProperty(wrapper, 'value', { get: () => s.value, set: (v) => (s.value = v) }); (wrapper as any).peek = () => s.peek(); - (wrapper as any).set = (v: any) => s.value = v; + (wrapper as any).set = (v: any) => (s.value = v); return wrapper as any; }, computed: (fn: any) => { @@ -47,6 +52,28 @@ describe('Function Bindings', () => { }, effect: preactEffect, batch: preactBatch, + isSignal: (v: any): v is any => { + return ( + v && + (v._isGenericSignal || + (typeof v === 'object' && v.brand === Symbol.for('preact-signals'))) + ); + }, + toGenericSignal: function (v: any) { + if (v && v._isGenericSignal) return v; + if (this.isSignal(v)) { + const wrapper = () => v.value; + Object.defineProperty(wrapper, 'value', { + get: () => v.value, + set: (val) => (v.value = val), + }); + (wrapper as any).peek = () => v.peek(); + (wrapper as any).set = (val: any) => (v.value = val); + (wrapper as any)._isGenericSignal = true; + return wrapper as any; + } + return this.signal(v); + }, }; }); diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts index 176631842..3a63fdbc1 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts @@ -30,6 +30,8 @@ function invoke(name: string, args: Record, context: DataContext) { return testCatalog.invoker(name, args, context); } +import { PreactReactiveProvider } from "../../common/preact-provider.js"; + const createTestDataContext = ( model: DataModel, path: string, @@ -39,6 +41,7 @@ const createTestDataContext = ( dataModel: model, catalog: { invoker: functionInvoker }, dispatchError: () => {}, + provider: new PreactReactiveProvider(), } as any; return new DataContext(mockSurface, path); }; diff --git a/renderers/web_core/src/v0_9/common/preact-provider.ts b/renderers/web_core/src/v0_9/common/preact-provider.ts index 2866b017f..eede0f331 100644 --- a/renderers/web_core/src/v0_9/common/preact-provider.ts +++ b/renderers/web_core/src/v0_9/common/preact-provider.ts @@ -64,6 +64,24 @@ export class PreactReactiveProvider implements ReactiveProvider { return effect(callback); } + isSignal(v: any): v is GenericSignal { + return ( + v && + (v._isGenericSignal || + (typeof v === 'object' && v.brand === Symbol.for('preact-signals'))) + ); + } + + toGenericSignal(v: any): GenericSignal { + if (v && v._isGenericSignal) { + return v as GenericSignal; + } + if (v && typeof v === 'object' && v.brand === Symbol.for('preact-signals')) { + return wrapPreactSignal(v); + } + return this.signal(v); + } + batch(callback: () => T): T { return batch(callback); } diff --git a/renderers/web_core/src/v0_9/common/reactive.ts b/renderers/web_core/src/v0_9/common/reactive.ts index 605e2e82e..51793d387 100644 --- a/renderers/web_core/src/v0_9/common/reactive.ts +++ b/renderers/web_core/src/v0_9/common/reactive.ts @@ -54,6 +54,19 @@ export interface ReactiveProvider { */ effect(callback: () => void): () => void; + /** + * Returns true if the value is a reactive signal (either generic or native). + */ + isSignal(v: any): v is GenericSignal; + + /** + * Coerces a value into a GenericSignal. + * - If it's already a GenericSignal from this provider, returns it. + * - If it's a native signal from this provider's backend, wraps it. + * - Otherwise, creates a new signal containing the literal value. + */ + toGenericSignal(value: T | GenericSignal): GenericSignal; + /** * Batches multiple signal updates so that effects and computations are * triggered only once at the end of the batch. diff --git a/renderers/web_core/src/v0_9/rendering/data-context.test.ts b/renderers/web_core/src/v0_9/rendering/data-context.test.ts index 814e4d845..fa78835b1 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.test.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.test.ts @@ -21,6 +21,7 @@ import { z } from "zod"; import { DataModel } from "../state/data-model.js"; import { DataContext } from "./data-context.js"; import { A2uiExpressionError } from "../errors.js"; +import { PreactReactiveProvider } from "../common/preact-provider.js"; const createTestDataContext = ( model: DataModel, @@ -32,6 +33,7 @@ const createTestDataContext = ( dataModel: model, catalog: { invoker: functionInvoker }, dispatchError, + provider: new PreactReactiveProvider(), } as any; return new DataContext(mockSurface, path); }; diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index 8f7520ce9..75410e946 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -115,7 +115,7 @@ export class DataContext { return undefined as any; } - return (isSignal(result) ? result.peek() : result) as V; + return (this.surface.provider.isSignal(result) ? (result as any).peek() : result) as V; } return value as V; @@ -177,7 +177,7 @@ export class DataContext { resolveSignal(value: DynamicValue): GenericSignal { // 1. Literal if (typeof value !== "object" || value === null || Array.isArray(value)) { - return this.surface.provider.signal(value as V); + return this.surface.provider.toGenericSignal(value as V); } // 2. Path Check @@ -202,9 +202,7 @@ export class DataContext { {}, abortController.signal, ); - const sig = isSignal(result) ? result : this.surface.provider.signal(result); - (sig as any).unsubscribe = () => abortController.abort(); - return sig; + return this.surface.provider.toGenericSignal(result as V); } const keys = Object.keys(argSignals); @@ -236,9 +234,9 @@ export class DataContext { abortController.signal, ); - if (isSignal(res)) { + if (this.surface.provider.isSignal(res)) { innerUnsubscribe = this.surface.provider.effect(() => { - resultSig.value = res.value; + resultSig.value = (res as any).value; }); } else { resultSig.value = res; @@ -265,7 +263,7 @@ export class DataContext { return resultSig as unknown as GenericSignal; } - return this.surface.provider.signal(value as unknown as V); + return this.surface.provider.toGenericSignal(value as unknown as V); } /** From 3791120ac16e7753017f2bfbdf0b0f8f047e7a81 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 14:32:28 -0700 Subject: [PATCH 06/14] fix(angular): resolve reactive update regression in v0.9 - Support `set()` method in `AngularReactiveProvider` to allow direct signal mutations. - Update `ComponentBinder` to use standardized `GenericSignal` interface from `@a2ui/web_core`. - Refactor test specs to utilize `PreactReactiveProvider` from `web_core/v0_9/testing`. - Ensure `DataModel` notifications trigger Angular reactivity by properly updating wrapped signal values. - Verified fix with the Login Form demo example; text input now correctly propagates to the data model and action context. --- .../core/angular-reactive-provider.spec.ts | 54 +++++++++++++++++++ .../v0_9/core/angular-reactive-provider.ts | 31 +++++------ .../core/component-binder.service.spec.ts | 50 ++++++----------- .../src/v0_9/core/component-binder.service.ts | 10 ++-- .../src/v0_9/core/function_binding.spec.ts | 51 +----------------- renderers/angular/src/v0_9/public-api.ts | 3 ++ renderers/angular/src/v0_9/testing.ts | 36 +++++++++++++ renderers/web_core/src/v0_9/index.ts | 2 + renderers/web_core/src/v0_9/testing.ts | 44 +++++++++++++++ 9 files changed, 175 insertions(+), 106 deletions(-) create mode 100644 renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts create mode 100644 renderers/angular/src/v0_9/testing.ts create mode 100644 renderers/web_core/src/v0_9/testing.ts diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts new file mode 100644 index 000000000..f3208d957 --- /dev/null +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts @@ -0,0 +1,54 @@ +import { signal, computed, effect, untracked, Injector } from '@angular/core'; +import { AngularReactiveProvider } from './angular-reactive-provider'; +import { TestBed } from '@angular/core/testing'; + +describe('AngularReactiveProvider', () => { + let provider: AngularReactiveProvider; + let injector: Injector; + + beforeEach(() => { + TestBed.configureTestingModule({}); + injector = TestBed.inject(Injector); + provider = new AngularReactiveProvider(injector); + }); + + it('should create a signal and read/write value', () => { + const sig = provider.signal('initial'); + expect(sig()).toBe('initial'); + expect(sig.value).toBe('initial'); + + sig.value = 'updated'; + expect(sig()).toBe('updated'); + expect(sig.value).toBe('updated'); + + sig.set('direct'); + expect(sig()).toBe('direct'); + }); + + it('should create a computed signal', () => { + const s1 = provider.signal(1); + const s2 = provider.signal(2); + const sum = provider.computed(() => s1() + s2()); + + expect(sum()).toBe(3); + s1.value = 10; + expect(sum()).toBe(12); + }); + + it('should track changes in an effect', () => { + const sig = provider.signal('initial'); + let effectValue = ''; + + provider.effect(() => { + effectValue = sig.value; + }); + + // In Angular tests, effects are scheduled. flushEffects() runs them. + TestBed.flushEffects(); + expect(effectValue).toBe('initial'); + + sig.value = 'updated'; + TestBed.flushEffects(); + expect(effectValue).toBe('updated'); + }); +}); diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts index 0c261ca7d..6b3658909 100644 --- a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts @@ -14,14 +14,7 @@ * limitations under the License. */ -import { - signal, - computed, - effect, - untracked, - Injector, - runInInjectionContext, -} from '@angular/core'; +import { signal, computed, effect, untracked, Injector, isSignal } from '@angular/core'; import { GenericSignal, ReactiveProvider } from '@a2ui/web_core/v0_9'; /** @@ -50,7 +43,13 @@ function wrapAngularSignal(sig: any): GenericSignal { }, // Add set method for direct access as well set: { - value: (v: T) => sig.set?.(v), + value: (v: T) => { + if (typeof sig.set === 'function') { + sig.set(v); + } else if (typeof sig === 'function' && '_isGenericSignal' in sig && typeof (sig as any).set === 'function') { + (sig as any).set(v); + } + }, configurable: true, }, _isGenericSignal: { value: true, configurable: true }, @@ -74,32 +73,26 @@ export class AngularReactiveProvider implements ReactiveProvider { } effect(callback: () => void): () => void { - const effectRef = runInInjectionContext(this.injector, () => { - return effect(() => { - callback(); - }); - }); + const effectRef = effect(callback, { injector: this.injector }); return () => effectRef.destroy(); } isSignal(v: any): v is GenericSignal { - return v && (v._isGenericSignal || (typeof v === 'function' && !!(v as any).set)); + return !!v && (v._isGenericSignal || isSignal(v)); } toGenericSignal(v: any): GenericSignal { if (v && v._isGenericSignal) { return v as GenericSignal; } - if (this.isSignal(v)) { + if (isSignal(v)) { return wrapAngularSignal(v); } return this.signal(v); } batch(callback: () => T): T { - // Angular doesn't have an explicit global `batch` like Preact, - // but signal updates are generally batched by the change detection cycle. - // For now, we just execute the callback. + // Angular handles batching through its scheduling system. return callback(); } } diff --git a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts index 231ae5fc2..158024a3b 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts @@ -16,8 +16,7 @@ import { TestBed } from '@angular/core/testing'; import { DestroyRef } from '@angular/core'; -import { signal as preactSignal } from '@preact/signals-core'; -import { ComponentContext } from '@a2ui/web_core/v0_9'; +import { ComponentContext, PreactReactiveProvider } from '@a2ui/web_core/v0_9'; import { ComponentBinder } from './component-binder.service'; describe('ComponentBinder', () => { @@ -52,24 +51,12 @@ describe('ComponentBinder', () => { }, }; - const mockpSigText = preactSignal('Hello'); - const mockpSigVisible = preactSignal(true); - + const provider = new PreactReactiveProvider(); const mockDataContext = { resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => { - let sig: any; - if (val === 'Hello') sig = mockpSigText; - else if (val === true) sig = mockpSigVisible; - else sig = preactSignal(val); - - const callableSig = () => sig.value; - Object.defineProperties(callableSig, { - value: { get: () => sig.value, set: (v) => (sig.value = v) }, - peek: { value: () => sig.peek() }, - set: { value: (v: any) => (sig.value = v) }, - }); - (callableSig as any).raw = val; - return callableSig as any; + // For test purposes, bindings resolve to 'initial' + if (typeof val === 'object' && val?.path) return provider.toGenericSignal('initial'); + return provider.toGenericSignal(val); }), set: jasmine.createSpy('set'), }; @@ -98,15 +85,12 @@ describe('ComponentBinder', () => { }, }; - const mockpSig = preactSignal('initial'); + const provider = new PreactReactiveProvider(); const mockDataContext = { - resolveSignal: jasmine.createSpy('resolveSignal').and.callFake(() => { - const callableSig = () => mockpSig.value; - Object.defineProperties(callableSig, { - value: { get: () => mockpSig.value, set: (v) => (mockpSig.value = v) }, - peek: { value: () => mockpSig.peek() }, - }); - return callableSig as any; + resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => { + // For test purposes, bindings resolve to 'initial' + if (typeof val === 'object' && val?.path) return provider.toGenericSignal('initial'); + return provider.toGenericSignal(val); }), set: jasmine.createSpy('set'), }; @@ -136,16 +120,12 @@ describe('ComponentBinder', () => { }, }; - const mockpSig = preactSignal('Literal String'); + const provider = new PreactReactiveProvider(); const mockDataContext = { - resolveSignal: jasmine.createSpy('resolveSignal').and.callFake(() => { - const callableSig = () => mockpSig.value; - Object.defineProperties(callableSig, { - value: { get: () => mockpSig.value, set: (v) => (mockpSig.value = v) }, - peek: { value: () => mockpSig.peek() }, - set: { value: (v: any) => (mockpSig.value = v) }, - }); - return callableSig as any; + resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => { + // For test purposes, bindings resolve to 'initial' + if (typeof val === 'object' && val?.path) return provider.toGenericSignal('initial'); + return provider.toGenericSignal(val); }), set: jasmine.createSpy('set'), }; diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts index fa45da8ad..4557d0641 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.ts @@ -53,9 +53,13 @@ export class ComponentBinder { // Defensively define properties only if they don't already exist or are configurable const defineSafe = (obj: any, key: string, descriptor: PropertyDescriptor) => { - const existing = Object.getOwnPropertyDescriptor(obj, key); - if (!existing || existing.configurable) { - Object.defineProperty(obj, key, descriptor); + try { + const existing = Object.getOwnPropertyDescriptor(obj, key); + if (!existing || existing.configurable) { + Object.defineProperty(obj, key, descriptor); + } + } catch (e) { + console.warn(`Failed to define "${key}" property on bound signal:`, e); } }; diff --git a/renderers/angular/src/v0_9/core/function_binding.spec.ts b/renderers/angular/src/v0_9/core/function_binding.spec.ts index 1ab2c4407..6279f0df7 100644 --- a/renderers/angular/src/v0_9/core/function_binding.spec.ts +++ b/renderers/angular/src/v0_9/core/function_binding.spec.ts @@ -14,15 +14,9 @@ * limitations under the License. */ -import { DataContext, SurfaceModel, ReactiveProvider } from '@a2ui/web_core/v0_9'; +import { DataContext, SurfaceModel, ReactiveProvider, PreactReactiveProvider } from '@a2ui/web_core/v0_9'; import { DestroyRef } from '@angular/core'; import { BasicCatalogBase } from '../catalog/basic/basic-catalog'; -import { - signal as preactSignal, - computed as preactComputed, - effect as preactEffect, - batch as preactBatch, -} from '@preact/signals-core'; describe('Function Bindings', () => { let mockDestroyRef: jasmine.SpyObj; @@ -33,48 +27,7 @@ describe('Function Bindings', () => { mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']); mockDestroyRef.onDestroy.and.returnValue(() => {}); - // Reactive mock provider using Preact - mockProvider = { - signal: (initial: any) => { - const s = preactSignal(initial); - const wrapper = () => s.value; - Object.defineProperty(wrapper, 'value', { get: () => s.value, set: (v) => (s.value = v) }); - (wrapper as any).peek = () => s.peek(); - (wrapper as any).set = (v: any) => (s.value = v); - return wrapper as any; - }, - computed: (fn: any) => { - const s = preactComputed(fn); - const wrapper = () => s.value; - Object.defineProperty(wrapper, 'value', { get: () => s.value }); - (wrapper as any).peek = () => s.peek(); - return wrapper as any; - }, - effect: preactEffect, - batch: preactBatch, - isSignal: (v: any): v is any => { - return ( - v && - (v._isGenericSignal || - (typeof v === 'object' && v.brand === Symbol.for('preact-signals'))) - ); - }, - toGenericSignal: function (v: any) { - if (v && v._isGenericSignal) return v; - if (this.isSignal(v)) { - const wrapper = () => v.value; - Object.defineProperty(wrapper, 'value', { - get: () => v.value, - set: (val) => (v.value = val), - }); - (wrapper as any).peek = () => v.peek(); - (wrapper as any).set = (val: any) => (v.value = val); - (wrapper as any)._isGenericSignal = true; - return wrapper as any; - } - return this.signal(v); - }, - }; + mockProvider = new PreactReactiveProvider(); }); describe('add', () => { diff --git a/renderers/angular/src/v0_9/public-api.ts b/renderers/angular/src/v0_9/public-api.ts index 7956ec1b0..2091cc8e4 100644 --- a/renderers/angular/src/v0_9/public-api.ts +++ b/renderers/angular/src/v0_9/public-api.ts @@ -54,3 +54,6 @@ export * from './catalog/basic/check-box.component'; export * from './catalog/basic/choice-picker.component'; export * from './catalog/basic/slider.component'; export * from './catalog/basic/date-time-input.component'; + +// Testing Utilities +export * from './testing'; diff --git a/renderers/angular/src/v0_9/testing.ts b/renderers/angular/src/v0_9/testing.ts new file mode 100644 index 000000000..87c937ea0 --- /dev/null +++ b/renderers/angular/src/v0_9/testing.ts @@ -0,0 +1,36 @@ +/** + * 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 { Injector } from '@angular/core'; +import { createTestDataContext as createBaseContext } from '@a2ui/web_core/v0_9'; +import { AngularReactiveProvider } from './core/angular-reactive-provider'; + +/** + * Creates a DataContext populated with an AngularReactiveProvider. + * Useful for testing Angular components or services that require native signals. + * + * @param injector The Angular Injector to use for the reactive provider. + * @param surfaceId Optional ID for the surface. + * @param basePath Optional base path in the data model. + * @returns A DataContext configured with Angular native signals. + */ +export function createAngularTestDataContext( + injector: Injector, + surfaceId?: string, + basePath?: string +) { + return createBaseContext(new AngularReactiveProvider(injector), surfaceId, basePath); +} diff --git a/renderers/web_core/src/v0_9/index.ts b/renderers/web_core/src/v0_9/index.ts index 376a91755..f79148b92 100644 --- a/renderers/web_core/src/v0_9/index.ts +++ b/renderers/web_core/src/v0_9/index.ts @@ -37,7 +37,9 @@ export * from "./state/surface-components-model.js"; export * from "./state/surface-group-model.js"; export * from "./state/surface-model.js"; export * from "./errors.js"; +export * from "./testing.js"; +export { PreactReactiveProvider } from "./common/preact-provider.js"; export { effect, Signal } from "@preact/signals-core"; import A2uiMessageSchemaRaw from "./schemas/server_to_client.json" with { type: "json" }; diff --git a/renderers/web_core/src/v0_9/testing.ts b/renderers/web_core/src/v0_9/testing.ts new file mode 100644 index 000000000..1ffc9e12a --- /dev/null +++ b/renderers/web_core/src/v0_9/testing.ts @@ -0,0 +1,44 @@ +/** + * 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 { Catalog } from './catalog/types.js'; +import { BASIC_COMPONENTS } from './basic_catalog/components/basic_components.js'; +import { BASIC_FUNCTIONS } from './basic_catalog/functions/basic_functions.js'; +import { PreactReactiveProvider } from './common/preact-provider.js'; +import { ReactiveProvider } from './common/reactive.js'; +import { DataContext } from './rendering/data-context.js'; +import { SurfaceModel } from './state/surface-model.js'; + +/** + * A standard catalog containing all basic A2UI components and functions. + */ +export const BasicCatalog = new Catalog('basic', BASIC_COMPONENTS, BASIC_FUNCTIONS); + +/** + * Creates a {@link DataContext} with a given {@link ReactiveProvider} (defaults to {@link PreactReactiveProvider}) for testing purposes. + * + * @param provider Optional provider to use for the context. + * @param surfaceId Optional ID for the surface. + * @param basePath Optional base path in the data model. + * @returns A fresh DataContext prepopulated with a reactive provider. + */ +export function createTestDataContext(provider?: ReactiveProvider, surfaceId = 'test_surface', basePath = '/') { + const p = provider ?? new PreactReactiveProvider(); + const mockSurface = new SurfaceModel(surfaceId, BasicCatalog, p); + return new DataContext(mockSurface, basePath); +} + +export { PreactReactiveProvider as TestReactiveProvider }; From a906072f3f5dc3cba4dd4af819f0f5bb89f0c5fa Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 14:59:14 -0700 Subject: [PATCH 07/14] refactor(angular): update BoundProperty.value to return T instead of Signal This aligns BoundProperty with GenericSignal and improves consistency. Updated test mocks (createBoundProperty) to use a getter for .value. Added missing BoundProperty imports in spec files. --- .../catalog/basic/button.component.spec.ts | 20 ++++++++++++------- .../catalog/basic/complex-components.spec.ts | 13 ++++++++---- .../catalog/basic/simple-components.spec.ts | 13 ++++++++---- .../basic/text-field.component.spec.ts | 16 ++++++++++----- .../v0_9/catalog/basic/text.component.spec.ts | 16 ++++++++++----- renderers/angular/src/v0_9/core/types.ts | 2 +- 6 files changed, 54 insertions(+), 26 deletions(-) diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts index 52ff23759..a825f4b45 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts @@ -31,14 +31,20 @@ describe('ButtonComponent', () => { function createBoundProperty(val: any): BoundProperty { const sig = signal(val); - const prop = Object.assign(() => sig(), { - value: sig, - peek: () => sig(), - set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), - update: jasmine.createSpy('update').and.callFake((fn: any) => sig.update(fn)), - raw: val, + const prop = () => sig(); + Object.defineProperties(prop, { + value: { get: () => sig(), configurable: true }, + peek: { value: () => sig(), configurable: true }, + set: { + value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + configurable: true, + }, + update: { + value: jasmine.createSpy('update').and.callFake((fn: any) => sig.update(fn)), + configurable: true, + }, + raw: { value: val, configurable: true }, }); - Object.defineProperty(prop, 'value', { get: () => sig() }); return prop as any; } diff --git a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts index 7eec8327f..31ac59968 100644 --- a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts @@ -87,10 +87,15 @@ describe('Complex Components', () => { function createBoundProperty(val: any): BoundProperty { const sig = angularSignal(val); - const prop = Object.assign(() => sig(), { - value: sig, - raw: val, - set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + const prop = () => sig(); + Object.defineProperties(prop, { + value: { get: () => sig(), configurable: true }, + raw: { value: val, configurable: true }, + set: { + value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + configurable: true, + }, + peek: { value: () => sig(), configurable: true }, }); return prop as any; } diff --git a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts index 8c56497c4..1c7be9f62 100644 --- a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts @@ -86,10 +86,15 @@ describe('Simple Components', () => { function createBoundProperty(val: any): BoundProperty { const sig = angularSignal(val); - const prop = Object.assign(() => sig(), { - value: sig, - raw: val, - set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + const prop = () => sig(); + Object.defineProperties(prop, { + value: { get: () => sig(), configurable: true }, + raw: { value: val, configurable: true }, + set: { + value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + configurable: true, + }, + peek: { value: () => sig(), configurable: true }, }); return prop as any; } diff --git a/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts index a36aa8966..7340ca3c6 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts @@ -19,18 +19,24 @@ import { TextFieldComponent } from './text-field.component'; import { signal } from '@angular/core'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { By } from '@angular/platform-browser'; +import { BoundProperty } from '../../core/types'; describe('TextFieldComponent', () => { let component: TextFieldComponent; let fixture: ComponentFixture; let mockRendererService: any; - function createBoundProperty(val: any): any { + function createBoundProperty(val: any): BoundProperty { const sig = signal(val); - const prop = Object.assign(() => sig(), { - value: sig, - raw: val, - set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + const prop = () => sig(); + Object.defineProperties(prop, { + value: { get: () => sig(), configurable: true }, + raw: { value: val, configurable: true }, + set: { + value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + configurable: true, + }, + peek: { value: () => sig(), configurable: true }, }); return prop as any; } diff --git a/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts index 38a338af4..c8191fe38 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts @@ -18,18 +18,24 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TextComponent } from './text.component'; import { By } from '@angular/platform-browser'; import { signal } from '@angular/core'; +import { BoundProperty } from '../../core/types'; describe('TextComponent', () => { let component: TextComponent; let fixture: ComponentFixture; beforeEach(async () => { - function createBoundProperty(val: any): any { + function createBoundProperty(val: any): BoundProperty { const sig = signal(val); - const prop = Object.assign(() => sig(), { - value: sig, - raw: val, - set: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + const prop = () => sig(); + Object.defineProperties(prop, { + value: { get: () => sig(), configurable: true }, + raw: { value: val, configurable: true }, + set: { + value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + configurable: true, + }, + peek: { value: () => sig(), configurable: true }, }); return prop as any; } diff --git a/renderers/angular/src/v0_9/core/types.ts b/renderers/angular/src/v0_9/core/types.ts index a33470b13..8abd95ccb 100644 --- a/renderers/angular/src/v0_9/core/types.ts +++ b/renderers/angular/src/v0_9/core/types.ts @@ -30,7 +30,7 @@ export type BoundProperty = (Signal | WritableSignal | GenericSig /** The raw property definition from the component model (literal or binding). */ readonly raw: any; /** Direct access to the current value (same as calling the signal function). */ - readonly value: Signal; + readonly value: T; /** Updates the underlying data model if the property is path-bound. */ readonly set: (value: T) => void; }; From 8f240200bf14229e8dcef8a05f0479c8d7807b63 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 15:00:49 -0700 Subject: [PATCH 08/14] fix(demo): restore explicit Zod schemas for Card and CustomSlider Replaced z.object({}).passthrough() with explicit property definitions for better type safety and validation in the demo catalog. --- .../angular/a2ui_explorer/src/app/demo-catalog.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts index ff941a835..13402ea8d 100644 --- a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts +++ b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts @@ -32,12 +32,19 @@ export class DemoCatalog extends BasicCatalogBase { constructor() { const cardApi: AngularComponentImplementation = { name: 'Card', - schema: z.object({}).passthrough(), + schema: z.object({ + child: z.string().optional(), + }) as any, component: CardComponent, }; const customSliderApi: AngularComponentImplementation = { name: 'CustomSlider', - schema: z.object({}).passthrough(), + schema: z.object({ + label: z.string().optional(), + value: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + }) as any, component: CustomSliderComponent, }; From 59cdd76e48779fa7b6b249e5e2f299868e4a07bd Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 15:03:29 -0700 Subject: [PATCH 09/14] test(angular): restore specific dispatchAction assertion in button spec Updated the assertion to verify that sourceComponentId ('test-btn') is correctly passed to mockSurface.dispatchAction. --- .../angular/src/v0_9/catalog/basic/button.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts index a825f4b45..05299ce79 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts @@ -144,7 +144,7 @@ describe('ButtonComponent', () => { button.triggerEventHandler('click', null); expect(mockSurfaceGroup.getSurface).toHaveBeenCalledWith('surf1'); - expect(mockSurface.dispatchAction).toHaveBeenCalled(); + expect(mockSurface.dispatchAction).toHaveBeenCalledWith(jasmine.any(Object), 'test-btn'); }); it('should show child component host if child prop is present', () => { From 52cdc5e699734956c0097f55f2cc0ea511704720 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 15:05:05 -0700 Subject: [PATCH 10/14] Add missing license --- .../v0_9/core/angular-reactive-provider.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts index f3208d957..b2795e356 100644 --- a/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts @@ -1,3 +1,19 @@ +/** + * Copyright 2025 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 { signal, computed, effect, untracked, Injector } from '@angular/core'; import { AngularReactiveProvider } from './angular-reactive-provider'; import { TestBed } from '@angular/core/testing'; From 8b8f8ed31cb5bf960a4932c7eaaa8051959f6762 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 15:16:13 -0700 Subject: [PATCH 11/14] test(angular): centralize testing boilerplate across basic catalog spec files - Extract `createBoundProperty`, `StubComponent`, and mock factories to `src/v0_9/testing.ts`. - Refactor `button`, `simple-components`, `complex-components`, `column`, `row`, `text`, and `text-field` spec files to use the new helpers. - Fix specific test assertions and service mocking in `button.component.spec.ts`. - Verify all 192 tests pass in the Angular renderer. --- .../catalog/basic/button.component.spec.ts | 55 ++----------- .../catalog/basic/column.component.spec.ts | 54 +++---------- .../catalog/basic/complex-components.spec.ts | 80 ++++--------------- .../v0_9/catalog/basic/row.component.spec.ts | 54 +++---------- .../catalog/basic/simple-components.spec.ts | 80 ++++--------------- .../basic/text-field.component.spec.ts | 16 +--- .../v0_9/catalog/basic/text.component.spec.ts | 15 +--- renderers/angular/src/v0_9/testing.ts | 77 +++++++++++++++++- 8 files changed, 139 insertions(+), 292 deletions(-) diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts index 05299ce79..c45b750bd 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts @@ -21,6 +21,7 @@ import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; import { BoundProperty } from '../../core/types'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; describe('ButtonComponent', () => { let component: ButtonComponent; @@ -29,24 +30,6 @@ describe('ButtonComponent', () => { let mockSurface: any; let mockSurfaceGroup: any; - function createBoundProperty(val: any): BoundProperty { - const sig = signal(val); - const prop = () => sig(); - Object.defineProperties(prop, { - value: { get: () => sig(), configurable: true }, - peek: { value: () => sig(), configurable: true }, - set: { - value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), - configurable: true, - }, - update: { - value: jasmine.createSpy('update').and.callFake((fn: any) => sig.update(fn)), - configurable: true, - }, - raw: { value: val, configurable: true }, - }); - return prop as any; - } beforeEach(async () => { mockSurface = { @@ -55,40 +38,14 @@ describe('ButtonComponent', () => { ['child1', { id: 'child1', type: 'Text', properties: { text: 'Child Content' } }], ]), catalog: { - id: 'test-catalog', - components: new Map([ - [ - 'Text', - { - component: (() => { - @Component({ - standalone: true, - selector: 'dummy-text', - template: 'Dummy Text', - }) - class DummyText { - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); - } - return DummyText; - })(), - }, - ], - ]), + id: 'mock-catalog', + components: new Map([['Text', { type: 'Text', component: StubComponent }]]), }, }; - mockSurfaceGroup = { - getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), - }; - - mockRendererService = { - surfaceGroup: mockSurfaceGroup, - }; - - const mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockRendererService = createMockA2uiRendererService(mockSurface); + mockSurfaceGroup = mockRendererService.surfaceGroup; + const mockBinder = createMockComponentBinder(); mockBinder.bind.and.returnValue({ text: { value: () => 'bound text' } }); await TestBed.configureTestingModule({ diff --git a/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts index 2623dd500..89e490337 100644 --- a/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/column.component.spec.ts @@ -15,23 +15,12 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, input, signal } from '@angular/core'; import { ColumnComponent } from './column.component'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; -@Component({ - standalone: true, - selector: 'dummy-child', - template: 'Dummy Child', -}) -class DummyChild { - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); -} describe('ColumnComponent', () => { let component: ColumnComponent; @@ -50,19 +39,14 @@ describe('ColumnComponent', () => { ]), catalog: { id: 'test-catalog', - components: new Map([['Child', { component: DummyChild }]]), + components: new Map([['Child', { component: StubComponent }]]), }, }; - mockSurfaceGroup = { - getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), - }; - - mockRendererService = { - surfaceGroup: mockSurfaceGroup, - }; + mockRendererService = createMockA2uiRendererService(mockSurface); + mockSurfaceGroup = mockRendererService.surfaceGroup; - mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockBinder = createMockComponentBinder(); mockBinder.bind.and.returnValue({ text: { value: () => 'bound' } }); await TestBed.configureTestingModule({ @@ -77,21 +61,9 @@ describe('ColumnComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('surfaceId', 'surf1'); fixture.componentRef.setInput('props', { - justify: Object.assign(() => 'start', { - value: 'start', - raw: 'start', - set: jasmine.createSpy('set'), - }), - align: Object.assign(() => 'stretch', { - value: 'stretch', - raw: 'stretch', - set: jasmine.createSpy('set'), - }), - children: Object.assign(() => ['child1', 'child2'], { - value: ['child1', 'child2'], - raw: ['child1', 'child2'], - set: jasmine.createSpy('set'), - }), + justify: createBoundProperty('start'), + align: createBoundProperty('stretch'), + children: createBoundProperty(['child1', 'child2']), }); }); @@ -119,13 +91,9 @@ describe('ColumnComponent', () => { it('should render repeating children', () => { fixture.componentRef.setInput('props', { ...component.props(), - children: Object.assign(() => [{}, {}], { - value: [{}, {}], - raw: { - componentId: 'template1', - path: 'items', - }, - set: jasmine.createSpy('set'), + children: createBoundProperty([{}, {}], { + componentId: 'template1', + path: 'items', }), }); fixture.detectChanges(); diff --git a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts index 31ac59968..1c79d916c 100644 --- a/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/complex-components.spec.ts @@ -15,7 +15,7 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, signal as angularSignal, input } from '@angular/core'; +import { Component, signal, input } from '@angular/core'; import { CheckBoxComponent } from './check-box.component'; import { ChoicePickerComponent } from './choice-picker.component'; import { SliderComponent } from './slider.component'; @@ -24,6 +24,7 @@ import { ListComponent } from './list.component'; import { TabsComponent } from './tabs.component'; import { ModalComponent } from './modal.component'; import { BoundProperty } from '../../core/types'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; @@ -33,72 +34,23 @@ describe('Complex Components', () => { let mockBinder: any; beforeEach(() => { - mockRendererService = { - surfaceGroup: { - getSurface: jasmine.createSpy('getSurface').and.returnValue({ - componentsModel: new Map([ - [ - 'child-1', - { id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } }, - ], - [ - 'child-2', - { id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } }, - ], - [ - 'content-1', - { id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } }, - ], - [ - 'content-2', - { id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } }, - ], - [ - 'trigger-btn', - { id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } }, - ], - [ - 'modal-content', - { id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } }, - ], - ]), - catalog: { - id: 'mock-catalog', - components: new Map([['Text', { type: 'Text', component: DummyTextComponent }]]), - }, - }), + mockRendererService = createMockA2uiRendererService({ + componentsModel: new Map([ + ['child-1', { id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } }], + ['child-2', { id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } }], + ['content-1', { id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } }], + ['content-2', { id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } }], + ['trigger-btn', { id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } }], + ['modal-content', { id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } }], + ]), + catalog: { + id: 'mock-catalog', + components: new Map([['Text', { type: 'Text', component: StubComponent }]]), }, - }; - mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + }); + mockBinder = createMockComponentBinder(); }); - @Component({ - selector: 'dummy-text', - template: '
{{text}}
', - standalone: true, - }) - class DummyTextComponent { - text?: string; - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); - } - - function createBoundProperty(val: any): BoundProperty { - const sig = angularSignal(val); - const prop = () => sig(); - Object.defineProperties(prop, { - value: { get: () => sig(), configurable: true }, - raw: { value: val, configurable: true }, - set: { - value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), - configurable: true, - }, - peek: { value: () => sig(), configurable: true }, - }); - return prop as any; - } describe('CheckBoxComponent', () => { let component: CheckBoxComponent; diff --git a/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts index 8e7686932..7355ffa1e 100644 --- a/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/row.component.spec.ts @@ -15,23 +15,12 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, input, signal } from '@angular/core'; import { RowComponent } from './row.component'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; import { By } from '@angular/platform-browser'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; -@Component({ - standalone: true, - selector: 'dummy-child', - template: 'Dummy Child', -}) -class DummyChild { - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); -} describe('RowComponent', () => { let component: RowComponent; @@ -50,19 +39,14 @@ describe('RowComponent', () => { ]), catalog: { id: 'test-catalog', - components: new Map([['Child', { component: DummyChild }]]), + components: new Map([['Child', { component: StubComponent }]]), }, }; - mockSurfaceGroup = { - getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), - }; - - mockRendererService = { - surfaceGroup: mockSurfaceGroup, - }; + mockRendererService = createMockA2uiRendererService(mockSurface); + mockSurfaceGroup = mockRendererService.surfaceGroup; - mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + mockBinder = createMockComponentBinder(); mockBinder.bind.and.returnValue({ text: { value: () => 'bound' } }); await TestBed.configureTestingModule({ @@ -77,21 +61,9 @@ describe('RowComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('surfaceId', 'surf1'); fixture.componentRef.setInput('props', { - justify: Object.assign(() => 'center', { - value: 'center', - raw: 'center', - set: jasmine.createSpy('set'), - }), - align: Object.assign(() => 'baseline', { - value: 'baseline', - raw: 'baseline', - set: jasmine.createSpy('set'), - }), - children: Object.assign(() => ['child1', 'child2'], { - value: ['child1', 'child2'], - raw: ['child1', 'child2'], - set: jasmine.createSpy('set'), - }), + justify: createBoundProperty('center'), + align: createBoundProperty('baseline'), + children: createBoundProperty(['child1', 'child2']), }); }); @@ -119,13 +91,9 @@ describe('RowComponent', () => { it('should render repeating children', () => { fixture.componentRef.setInput('props', { ...component.props(), - children: Object.assign(() => [{}, {}], { - value: [{}, {}], - raw: { - componentId: 'template1', - path: 'items', - }, - set: jasmine.createSpy('set'), + children: createBoundProperty([{}, {}], { + componentId: 'template1', + path: 'items', }), }); fixture.detectChanges(); diff --git a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts index 1c7be9f62..a7d93f798 100644 --- a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts @@ -15,7 +15,7 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, signal as angularSignal, signal, input } from '@angular/core'; +import { Component, signal, input } from '@angular/core'; import { By } from '@angular/platform-browser'; import { DividerComponent } from './divider.component'; import { ImageComponent } from './image.component'; @@ -24,6 +24,7 @@ import { VideoComponent } from './video.component'; import { AudioPlayerComponent } from './audio-player.component'; import { CardComponent } from './card.component'; import { BoundProperty } from '../../core/types'; +import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { ComponentBinder } from '../../core/component-binder.service'; @@ -32,72 +33,23 @@ describe('Simple Components', () => { let mockBinder: any; beforeEach(() => { - mockRendererService = { - surfaceGroup: { - getSurface: jasmine.createSpy('getSurface').and.returnValue({ - componentsModel: new Map([ - [ - 'child-1', - { id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } }, - ], - [ - 'child-2', - { id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } }, - ], - [ - 'content-1', - { id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } }, - ], - [ - 'content-2', - { id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } }, - ], - [ - 'trigger-btn', - { id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } }, - ], - [ - 'modal-content', - { id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } }, - ], - ]), - catalog: { - id: 'mock-catalog', - components: new Map([['Text', { type: 'Text', component: DummyTextComponent }]]), - }, - }), + mockRendererService = createMockA2uiRendererService({ + componentsModel: new Map([ + ['child-1', { id: 'child-1', type: 'Text', properties: { text: { value: 'Child 1' } } }], + ['child-2', { id: 'child-2', type: 'Text', properties: { text: { value: 'Child 2' } } }], + ['content-1', { id: 'content-1', type: 'Text', properties: { text: { value: 'Content 1' } } }], + ['content-2', { id: 'content-2', type: 'Text', properties: { text: { value: 'Content 2' } } }], + ['trigger-btn', { id: 'trigger-btn', type: 'Text', properties: { text: { value: 'Open' } } }], + ['modal-content', { id: 'modal-content', type: 'Text', properties: { text: { value: 'Modal' } } }], + ]), + catalog: { + id: 'mock-catalog', + components: new Map([['Text', { type: 'Text', component: StubComponent }]]), }, - }; - mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']); + }); + mockBinder = createMockComponentBinder(); }); - @Component({ - selector: 'dummy-text', - template: '
{{text}}
', - standalone: true, - }) - class DummyTextComponent { - text?: string; - props = input(); - surfaceId = input(); - componentId = input(); - dataContextPath = input(); - } - - function createBoundProperty(val: any): BoundProperty { - const sig = angularSignal(val); - const prop = () => sig(); - Object.defineProperties(prop, { - value: { get: () => sig(), configurable: true }, - raw: { value: val, configurable: true }, - set: { - value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), - configurable: true, - }, - peek: { value: () => sig(), configurable: true }, - }); - return prop as any; - } describe('DividerComponent', () => { let component: DividerComponent; diff --git a/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts index 7340ca3c6..bfb0aa708 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text-field.component.spec.ts @@ -20,26 +20,14 @@ import { signal } from '@angular/core'; import { A2uiRendererService } from '../../core/a2ui-renderer.service'; import { By } from '@angular/platform-browser'; import { BoundProperty } from '../../core/types'; +import { createBoundProperty } from '../../testing'; describe('TextFieldComponent', () => { let component: TextFieldComponent; let fixture: ComponentFixture; let mockRendererService: any; - function createBoundProperty(val: any): BoundProperty { - const sig = signal(val); - const prop = () => sig(); - Object.defineProperties(prop, { - value: { get: () => sig(), configurable: true }, - raw: { value: val, configurable: true }, - set: { - value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), - configurable: true, - }, - peek: { value: () => sig(), configurable: true }, - }); - return prop as any; - } + beforeEach(async () => { mockRendererService = {}; diff --git a/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts index c8191fe38..d8128c23d 100644 --- a/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/text.component.spec.ts @@ -19,26 +19,13 @@ import { TextComponent } from './text.component'; import { By } from '@angular/platform-browser'; import { signal } from '@angular/core'; import { BoundProperty } from '../../core/types'; +import { createBoundProperty } from '../../testing'; describe('TextComponent', () => { let component: TextComponent; let fixture: ComponentFixture; beforeEach(async () => { - function createBoundProperty(val: any): BoundProperty { - const sig = signal(val); - const prop = () => sig(); - Object.defineProperties(prop, { - value: { get: () => sig(), configurable: true }, - raw: { value: val, configurable: true }, - set: { - value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), - configurable: true, - }, - peek: { value: () => sig(), configurable: true }, - }); - return prop as any; - } await TestBed.configureTestingModule({ imports: [TextComponent], diff --git a/renderers/angular/src/v0_9/testing.ts b/renderers/angular/src/v0_9/testing.ts index 87c937ea0..b1df150db 100644 --- a/renderers/angular/src/v0_9/testing.ts +++ b/renderers/angular/src/v0_9/testing.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import { Injector } from '@angular/core'; +import { Injector, signal, Component, input } from '@angular/core'; import { createTestDataContext as createBaseContext } from '@a2ui/web_core/v0_9'; import { AngularReactiveProvider } from './core/angular-reactive-provider'; +import { BoundProperty } from './core/types'; /** * Creates a DataContext populated with an AngularReactiveProvider. @@ -34,3 +35,77 @@ export function createAngularTestDataContext( ) { return createBaseContext(new AngularReactiveProvider(injector), surfaceId, basePath); } + +/** + * Creates a mock BoundProperty for testing. + * + * Includes standard properties like value, raw, set, peek, and update. + * Spies are created for set and update using jasmine.createSpy. + * + * @param val The initial value of the property. + * @returns A mock BoundProperty. + */ +export function createBoundProperty(val: T, rawValue?: any): BoundProperty { + const sig = signal(val); + const prop = () => sig(); + Object.defineProperties(prop, { + value: { get: () => sig(), configurable: true }, + peek: { value: () => sig(), configurable: true }, + set: { + value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), + configurable: true, + }, + update: { + value: jasmine.createSpy('update').and.callFake((fn: any) => sig.update(fn)), + configurable: true, + }, + raw: { value: rawValue !== undefined ? rawValue : val, configurable: true }, + }); + return prop as any; +} + +/** + * A stub component for testing that accepts all standard A2UI inputs. + */ +@Component({ + selector: 'a2ui-stub', + template: '', + standalone: true, +}) +export class StubComponent { + props = input(); + surfaceId = input(); + componentId = input(); + dataContextPath = input(); +} + +/** + * Creates a robust mock for the A2uiRendererService. + * + * @param surface Optional custom surface mock to return. + * @returns A mock object for A2uiRendererService. + */ +export function createMockA2uiRendererService(surface?: any) { + const mockSurface = surface || { + componentsModel: new Map(), + catalog: { + id: 'mock-catalog', + components: new Map([['Text', { type: 'Text', component: StubComponent }]]), + }, + }; + + return { + surfaceGroup: { + getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), + }, + }; +} + +/** + * Creates a mock for the ComponentBinder service. + * + * @returns A mock object for ComponentBinder. + */ +export function createMockComponentBinder() { + return jasmine.createSpyObj('ComponentBinder', ['bind']); +} From 8f9da0a8bf685de8e6797b70cc75656da6036673 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 15:26:28 -0700 Subject: [PATCH 12/14] refactor(angular): align BoundProperty type and implementation - Add `name` property to `BoundProperty` type in `types.ts`. - Refactor `ComponentBinder` to use `defineSafe` for `name` property. - Update `wrapAngularSignal` and `createBoundProperty` mock to use `unknown` instead of `any` for safety. - Update `createBoundProperty` mock to include `name` property. - Deduplicate and simplify `setter` logic in `wrapAngularSignal`. - Verified with 192/192 passing tests. --- .../v0_9/core/angular-reactive-provider.ts | 29 +++++++------------ .../src/v0_9/core/component-binder.service.ts | 12 +------- renderers/angular/src/v0_9/core/types.ts | 2 ++ renderers/angular/src/v0_9/testing.ts | 5 ++-- 4 files changed, 17 insertions(+), 31 deletions(-) diff --git a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts index 6b3658909..68454e21a 100644 --- a/renderers/angular/src/v0_9/core/angular-reactive-provider.ts +++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts @@ -21,41 +21,34 @@ import { GenericSignal, ReactiveProvider } from '@a2ui/web_core/v0_9'; * Bridges Angular signals to the GenericSignal interface. */ function wrapAngularSignal(sig: any): GenericSignal { - // sig is a function in Angular. - // We need to add a .value property and .peek() method. const wrapper = () => sig(); + const setter = (v: T) => { + if (typeof sig.set === 'function') { + sig.set(v); + } else { + console.warn('Cannot set value on a computed Angular signal.'); + } + }; + Object.defineProperties(wrapper, { value: { get: () => sig(), - set: (v: T) => { - if (typeof sig.set === 'function') { - sig.set(v); - } else { - console.warn('Cannot set value on a computed Angular signal.'); - } - }, + set: setter, configurable: true, }, peek: { value: () => untracked(() => sig()), configurable: true, }, - // Add set method for direct access as well set: { - value: (v: T) => { - if (typeof sig.set === 'function') { - sig.set(v); - } else if (typeof sig === 'function' && '_isGenericSignal' in sig && typeof (sig as any).set === 'function') { - (sig as any).set(v); - } - }, + value: setter, configurable: true, }, _isGenericSignal: { value: true, configurable: true }, }); - return wrapper as any as GenericSignal; + return wrapper as unknown as GenericSignal; } /** diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts index 4557d0641..15c65350b 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.ts @@ -65,17 +65,7 @@ export class ComponentBinder { defineSafe(boundProp, 'raw', { value: value, configurable: true }); - // Defensively define properties to avoid "Cannot redefine property" errors - try { - Object.defineProperty(boundProp, 'name', { - value: key, - configurable: true, - writable: true, - enumerable: true, - }); - } catch (e) { - console.warn(`Failed to define "name" property on bound signal for "${key}":`, e); - } + defineSafe(boundProp, 'name', { value: key, configurable: true }); // Only define 'set' if we have a path-bound property to handle if (isBoundPath) { diff --git a/renderers/angular/src/v0_9/core/types.ts b/renderers/angular/src/v0_9/core/types.ts index 8abd95ccb..0831fe8ca 100644 --- a/renderers/angular/src/v0_9/core/types.ts +++ b/renderers/angular/src/v0_9/core/types.ts @@ -29,6 +29,8 @@ import { GenericSignal } from '@a2ui/web_core/v0_9'; export type BoundProperty = (Signal | WritableSignal | GenericSignal) & { /** The raw property definition from the component model (literal or binding). */ readonly raw: any; + /** The attribute name from the component model. */ + readonly name: string; /** Direct access to the current value (same as calling the signal function). */ readonly value: T; /** Updates the underlying data model if the property is path-bound. */ diff --git a/renderers/angular/src/v0_9/testing.ts b/renderers/angular/src/v0_9/testing.ts index b1df150db..45798742e 100644 --- a/renderers/angular/src/v0_9/testing.ts +++ b/renderers/angular/src/v0_9/testing.ts @@ -45,7 +45,7 @@ export function createAngularTestDataContext( * @param val The initial value of the property. * @returns A mock BoundProperty. */ -export function createBoundProperty(val: T, rawValue?: any): BoundProperty { +export function createBoundProperty(val: T, rawValue?: any, name = 'test-prop'): BoundProperty { const sig = signal(val); const prop = () => sig(); Object.defineProperties(prop, { @@ -60,8 +60,9 @@ export function createBoundProperty(val: T, rawValue?: any): BoundProperty configurable: true, }, raw: { value: rawValue !== undefined ? rawValue : val, configurable: true }, + name: { value: name, configurable: true }, }); - return prop as any; + return prop as unknown as BoundProperty; } /** From a20ab018e1585d5dc324896a27cea49dd0f4daf7 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 15:28:17 -0700 Subject: [PATCH 13/14] refactor(angular): align BoundProperty implementation with type and add defensive value getter - Add `name` property to `BoundProperty` type in `types.ts`. - Refactor `ComponentBinder` to use `defineSafe` for `name`, `raw`, `set`, and `value` properties. - Add defensive `value` getter in `ComponentBinder.bind` to ensure reactive property access. - Update `createBoundProperty` mock factory in `testing.ts` to include `name` property. - Update type casts to use `unknown` instead of `any` for better type safety. - Verified with 192/192 passing tests. --- renderers/angular/src/v0_9/core/component-binder.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts index 15c65350b..ec8f5de0b 100644 --- a/renderers/angular/src/v0_9/core/component-binder.service.ts +++ b/renderers/angular/src/v0_9/core/component-binder.service.ts @@ -64,7 +64,7 @@ export class ComponentBinder { }; defineSafe(boundProp, 'raw', { value: value, configurable: true }); - + defineSafe(boundProp, 'value', { get: () => sig(), configurable: true }); defineSafe(boundProp, 'name', { value: key, configurable: true }); // Only define 'set' if we have a path-bound property to handle From 47a03a5757101d6e6c3ef5d99a2427a4d4c811ad Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 20 Mar 2026 15:32:11 -0700 Subject: [PATCH 14/14] test(angular): refine createBoundProperty mock by removing update method - Remove `update` method from `createBoundProperty` mock factory in `testing.ts`. - Align mock object with the `BoundProperty` type definition. - Verified with 192/192 passing tests. --- .../angular/src/v0_9/catalog/basic/button.component.spec.ts | 1 - renderers/angular/src/v0_9/testing.ts | 4 ---- 2 files changed, 5 deletions(-) diff --git a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts index c45b750bd..28a0a0ee1 100644 --- a/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts +++ b/renderers/angular/src/v0_9/catalog/basic/button.component.spec.ts @@ -30,7 +30,6 @@ describe('ButtonComponent', () => { let mockSurface: any; let mockSurfaceGroup: any; - beforeEach(async () => { mockSurface = { dispatchAction: jasmine.createSpy('dispatchAction'), diff --git a/renderers/angular/src/v0_9/testing.ts b/renderers/angular/src/v0_9/testing.ts index 45798742e..f57e2a855 100644 --- a/renderers/angular/src/v0_9/testing.ts +++ b/renderers/angular/src/v0_9/testing.ts @@ -55,10 +55,6 @@ export function createBoundProperty(val: T, rawValue?: any, name = 'test-prop value: jasmine.createSpy('set').and.callFake((v: any) => sig.set(v)), configurable: true, }, - update: { - value: jasmine.createSpy('update').and.callFake((fn: any) => sig.update(fn)), - configurable: true, - }, raw: { value: rawValue !== undefined ? rawValue : val, configurable: true }, name: { value: name, configurable: true }, });