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 f612c5f8e..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 { A2uiClientAction, A2uiMessage } from '@a2ui/web_core/v0_9';
+import { A2uiClientAction, A2uiMessage, CreateSurfaceMessage } from '@a2ui/web_core/v0_9';
import { ActionDispatcher } from './action-dispatcher.service';
/**
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..13402ea8d 100644
--- a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts
+++ b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts
@@ -30,6 +30,13 @@ import { createFunctionImplementation, FunctionImplementation } from '@a2ui/web_
})
export class DemoCatalog extends BasicCatalogBase {
constructor() {
+ const cardApi: AngularComponentImplementation = {
+ name: 'Card',
+ schema: z.object({
+ child: z.string().optional(),
+ }) as any,
+ component: CardComponent,
+ };
const customSliderApi: AngularComponentImplementation = {
name: 'CustomSlider',
schema: z.object({
@@ -41,14 +48,6 @@ export class DemoCatalog extends BasicCatalogBase {
component: CustomSliderComponent,
};
- const cardApi: AngularComponentImplementation = {
- name: 'Card',
- schema: z.object({
- child: z.string().optional(),
- }) as any,
- component: CardComponent,
- };
-
const capitalizeImplementation: FunctionImplementation = createFunctionImplementation(
{
name: 'capitalize',
diff --git a/renderers/angular/a2ui_explorer/src/app/demo.component.ts b/renderers/angular/a2ui_explorer/src/app/demo.component.ts
index b17f96764..13f9c6a51 100644
--- a/renderers/angular/a2ui_explorer/src/app/demo.component.ts
+++ b/renderers/angular/a2ui_explorer/src/app/demo.component.ts
@@ -19,9 +19,10 @@ import { CommonModule } from '@angular/common';
import { A2uiRendererService, A2UI_RENDERER_CONFIG } from '@a2ui/angular/v0_9';
import { AgentStubService } from './agent-stub.service';
import { 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 { A2uiClientAction, 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 { ActionDispatcher } from './action-dispatcher.service';
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/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..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
@@ -20,6 +20,8 @@ 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';
+import { createBoundProperty, StubComponent, createMockA2uiRendererService, createMockComponentBinder } from '../../testing';
describe('ButtonComponent', () => {
let component: ButtonComponent;
@@ -35,40 +37,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({
@@ -84,14 +60,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 +81,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 +100,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 +113,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..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
@@ -122,12 +122,10 @@ export class ChoicePickerComponent {
private rendererService = inject(A2uiRendererService);
- displayStyle = computed(() => this.props()['displayStyle']?.value());
- choices = computed(
- () => this.props()['choices']?.value() || this.props()['options']?.value() || [],
- );
- variant = computed(() => this.props()['variant']?.value());
- selectedValue = computed(() => this.props()['value']?.value());
+ displayStyle = computed(() => this.props()['displayStyle']?.());
+ choices = computed(() => this.props()['choices']?.() || this.props()['options']?.() || []);
+ variant = computed(() => this.props()['variant']?.());
+ selectedValue = computed(() => this.props()['value']?.());
isMultiple(): boolean {
return this.variant() === 'multipleSelection';
@@ -160,10 +158,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..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,13 +61,9 @@ 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']),
- raw: ['child1', 'child2'],
- onUpdate: () => {},
- },
+ justify: createBoundProperty('start'),
+ align: createBoundProperty('stretch'),
+ children: createBoundProperty(['child1', 'child2']),
});
});
@@ -111,14 +91,10 @@ describe('ColumnComponent', () => {
it('should render repeating children', () => {
fixture.componentRef.setInput('props', {
...component.props(),
- children: {
- value: signal([{}, {}]),
- raw: {
- componentId: 'template1',
- path: 'items',
- },
- onUpdate: () => {},
- },
+ children: createBoundProperty([{}, {}], {
+ componentId: 'template1',
+ path: 'items',
+ }),
});
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..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,65 +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 {
- return {
- value: angularSignal(val),
- raw: val,
- onUpdate: jasmine.createSpy('onUpdate'),
- };
- }
describe('CheckBoxComponent', () => {
let component: CheckBoxComponent;
@@ -128,16 +87,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 +142,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 +178,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 +222,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 +272,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 +285,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..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,13 +61,9 @@ 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']),
- raw: ['child1', 'child2'],
- onUpdate: () => {},
- },
+ justify: createBoundProperty('center'),
+ align: createBoundProperty('baseline'),
+ children: createBoundProperty(['child1', 'child2']),
});
});
@@ -111,14 +91,10 @@ describe('RowComponent', () => {
it('should render repeating children', () => {
fixture.componentRef.setInput('props', {
...component.props(),
- children: {
- value: signal([{}, {}]), // two items
- raw: {
- componentId: 'template1',
- path: 'items',
- },
- onUpdate: () => {},
- },
+ children: createBoundProperty([{}, {}], {
+ componentId: 'template1',
+ path: 'items',
+ }),
});
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..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,65 +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 {
- return {
- value: angularSignal(val),
- raw: val,
- onUpdate: jasmine.createSpy('onUpdate'),
- };
- }
describe('DividerComponent', () => {
let component: 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..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
@@ -19,12 +19,16 @@ 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';
+import { createBoundProperty } from '../../testing';
describe('TextFieldComponent', () => {
let component: TextFieldComponent;
let fixture: ComponentFixture;
let mockRendererService: any;
+
+
beforeEach(async () => {
mockRendererService = {};
@@ -36,14 +40,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 +61,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 +80,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 +97,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..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
@@ -18,12 +18,15 @@ 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';
+import { createBoundProperty } from '../../testing';
describe('TextComponent', () => {
let component: TextComponent;
let fixture: ComponentFixture;
beforeEach(async () => {
+
await TestBed.configureTestingModule({
imports: [TextComponent],
}).compileComponents();
@@ -31,9 +34,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.spec.ts b/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts
new file mode 100644
index 000000000..b2795e356
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.spec.ts
@@ -0,0 +1,70 @@
+/**
+ * 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';
+
+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
new file mode 100644
index 000000000..68454e21a
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/angular-reactive-provider.ts
@@ -0,0 +1,91 @@
+/**
+ * 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, isSignal } from '@angular/core';
+import { GenericSignal, ReactiveProvider } from '@a2ui/web_core/v0_9';
+
+/**
+ * Bridges Angular signals to the GenericSignal interface.
+ */
+function wrapAngularSignal(sig: any): GenericSignal {
+ 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: setter,
+ configurable: true,
+ },
+ peek: {
+ value: () => untracked(() => sig()),
+ configurable: true,
+ },
+ set: {
+ value: setter,
+ configurable: true,
+ },
+ _isGenericSignal: { value: true, configurable: true },
+ });
+
+ return wrapper as unknown 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 = effect(callback, { injector: this.injector });
+ return () => effectRef.destroy();
+ }
+
+ isSignal(v: any): v is GenericSignal {
+ return !!v && (v._isGenericSignal || isSignal(v));
+ }
+
+ toGenericSignal(v: any): GenericSignal {
+ if (v && v._isGenericSignal) {
+ return v as GenericSignal;
+ }
+ if (isSignal(v)) {
+ return wrapAngularSignal(v);
+ }
+ return this.signal(v);
+ }
+
+ batch(callback: () => T): T {
+ // 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 483e4ea67..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,14 +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) => {
- if (val === 'Hello') return mockpSigText;
- if (val === true) return mockpSigVisible;
- return preactSignal(val);
+ // 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'),
};
@@ -73,8 +70,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');
@@ -88,9 +85,13 @@ describe('ComponentBinder', () => {
},
};
- const mockpSig = preactSignal('initial');
+ const provider = new PreactReactiveProvider();
const mockDataContext = {
- resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig),
+ 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'),
};
@@ -102,11 +103,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');
@@ -119,9 +120,13 @@ describe('ComponentBinder', () => {
},
};
- const mockpSig = preactSignal('Literal String');
+ const provider = new PreactReactiveProvider();
const mockDataContext = {
- resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig),
+ 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'),
};
@@ -133,11 +138,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..ec8f5de0b 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,42 @@ 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) => {
+ 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);
+ }
};
+
+ 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
+ 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/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 b3b570c74..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,17 +14,20 @@
* limitations under the License.
*/
-import { DataContext, SurfaceModel } 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 { toAngularSignal } from './utils';
describe('Function Bindings', () => {
let mockDestroyRef: jasmine.SpyObj;
+ let mockProvider: ReactiveProvider;
+
beforeEach(() => {
mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']);
mockDestroyRef.onDestroy.and.returnValue(() => {});
+
+ mockProvider = new PreactReactiveProvider();
});
describe('add', () => {
@@ -32,7 +35,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 +51,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 +73,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 +87,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 +104,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 +116,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..0831fe8ca 100644
--- a/renderers/angular/src/v0_9/core/types.ts
+++ b/renderers/angular/src/v0_9/core/types.ts
@@ -14,39 +14,25 @@
* 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;
-}
+ /** 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. */
+ 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/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..f57e2a855
--- /dev/null
+++ b/renderers/angular/src/v0_9/testing.ts
@@ -0,0 +1,108 @@
+/**
+ * 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, 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.
+ * 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);
+}
+
+/**
+ * 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, name = 'test-prop'): 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,
+ },
+ raw: { value: rawValue !== undefined ? rawValue : val, configurable: true },
+ name: { value: name, configurable: true },
+ });
+ return prop as unknown as BoundProperty;
+}
+
+/**
+ * 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']);
+}
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/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..eede0f331
--- /dev/null
+++ b/renderers/web_core/src/v0_9/common/preact-provider.ts
@@ -0,0 +1,88 @@
+/**
+ * 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;
+ },
+ configurable: true,
+ },
+ peek: {
+ value: () => sig.peek(),
+ configurable: true,
+ },
+ set: {
+ value: (v: T) => {
+ sig.value = v;
+ },
+ configurable: true,
+ },
+ _isGenericSignal: { value: true, configurable: 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);
+ }
+
+ 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
new file mode 100644
index 000000000..51793d387
--- /dev/null
+++ b/renderers/web_core/src/v0_9/common/reactive.ts
@@ -0,0 +1,75 @@
+/**
+ * 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;
+
+ /** Updates the value of the signal. */
+ set(value: T): void;
+}
+
+/**
+ * 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;
+
+ /**
+ * 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.
+ */
+ 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..f79148b92 100644
--- a/renderers/web_core/src/v0_9/index.ts
+++ b/renderers/web_core/src/v0_9/index.ts
@@ -24,18 +24,22 @@
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";
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/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.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 5e4b21c25..75410e946 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 {
@@ -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;
@@ -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.toGenericSignal(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,23 @@ export class DataContext {
{},
abortController.signal,
);
- const sig = result instanceof Signal ? result : signal(result);
- (sig as any).unsubscribe = () => abortController.abort();
- return sig;
+ return this.surface.provider.toGenericSignal(result as V);
}
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();
@@ -236,9 +234,9 @@ export class DataContext {
abortController.signal,
);
- if (isSignal(res)) {
- innerUnsubscribe = effect(() => {
- resultSig.value = res.value;
+ if (this.surface.provider.isSignal(res)) {
+ innerUnsubscribe = this.surface.provider.effect(() => {
+ resultSig.value = (res as any).value;
});
} else {
resultSig.value = res;
@@ -262,10 +260,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.toGenericSignal(value as unknown as V);
}
/**
@@ -303,7 +301,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);
}
}
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 };