diff --git a/blueprints/modules/a2ui_framework_adapter.blueprint.md b/blueprints/modules/a2ui_framework_adapter.blueprint.md index da656e5f6f..ac7c7df0bf 100644 --- a/blueprints/modules/a2ui_framework_adapter.blueprint.md +++ b/blueprints/modules/a2ui_framework_adapter.blueprint.md @@ -74,6 +74,54 @@ interface ComponentImplementation extends ComponentApi { The entrypoint widget/view for a specific framework. It is instantiated with a `SurfaceModel` (from the Core SDK). It listens to the model for lifecycle events and dynamically builds the UI tree, initiating the recursive rendering loop at the component with ID `root`. +### Fallback States + +When the Surface's render loop cannot render a node, it surfaces one of two consumer-facing fallback states, named identically across every renderer: `loading` (the component is referenced but has not yet streamed in) and `unknownComponent` (the component's `type` has no implementation in the surface's catalog). A third state, `error` (a runtime exception thrown while rendering), is dispatch-only and is not a consumer fallback key. + +The payload passed to a fallback is `A2uiFallbackInfo`, a three-arm discriminated union keyed on `state`. The `componentType` field lives only on the `unknownComponent` arm; the `error` arm carries `reason: unknown` (the thrown value); every arm carries `componentId` and an optional `parentComponentType` hint that is absent at the surface root. + +```typescript +export type A2uiFallbackInfo = + | { + /** The component is referenced but has not yet been streamed to the client. */ + state: 'loading'; + /** The id of the component that could not be rendered. */ + componentId: string; + /** The `type` of the component that instantiated this one. Absent at the surface root. */ + parentComponentType?: string; + } + | { + /** The component's `type` has no implementation in the surface's catalog. */ + state: 'unknownComponent'; + /** The id of the component that could not be rendered. */ + componentId: string; + /** The component's declared type. */ + componentType: string; + /** The `type` of the component that instantiated this one. Absent at the surface root. */ + parentComponentType?: string; + } + | { + /** A runtime exception occurred while rendering the component. */ + state: 'error'; + /** The id of the component that could not be rendered. */ + componentId: string; + /** The thrown value. If it is an `Error`, the stack is available on it. */ + reason: unknown; + /** The `type` of the component that instantiated this one. Absent at the surface root. */ + parentComponentType?: string; + }; + +export type A2uiFallbackState = A2uiFallbackInfo['state']; +``` + +Renderers render nothing by default in both consumer-facing states and keep a `console.warn` for developer experience. Each renderer delivers consumer fallbacks in its own idiomatic way: + +- React: a context-delivered `fallbacks` prop on `A2uiSurface`, so nested pending children pick it up through context. +- Angular: `loadingTemplate` and `unknownComponentTemplate` `TemplateRef` inputs on the surface, propagated to nested hosts through the `A2UI_FALLBACK_TEMPLATES` token. +- Lit: a preserved `loading` slot for the root case plus a `fallbacks` property provided over `@lit/context` for nested children. + +On the `unknownComponent` path, and the Lit root `error` path, the renderer calls `surface.dispatchError` with a structured code such as `COMPONENT_NOT_FOUND` once per type, so the agent learns about hallucinated components. + --- ## 3. Component Implementation Strategies diff --git a/renderers/angular/CHANGELOG.md b/renderers/angular/CHANGELOG.md index 34e04903be..e4808f2fd5 100644 --- a/renderers/angular/CHANGELOG.md +++ b/renderers/angular/CHANGELOG.md @@ -2,6 +2,7 @@ - Standardize package entry points to `index.ts` while maintaining `public-api.ts` wrappers for backward compatibility. - (v0_8) Export `A2uiMessageSchema` in public API. +- (v0_9) Add optional `loadingTemplate` / `unknownComponentTemplate` `TemplateRef` inputs to `SurfaceComponent`, propagated to nested component hosts via the new `A2UI_FALLBACK_TEMPLATES` injection token; unknown component types now dispatch a structured `COMPONENT_NOT_FOUND` error to the agent. Default behavior (render nothing, existing console output) is unchanged. [#2013](https://github.com/a2ui-project/a2ui/issues/2013) ## 0.10.4 diff --git a/renderers/angular/README.md b/renderers/angular/README.md index efd09514be..7693d8b342 100644 --- a/renderers/angular/README.md +++ b/renderers/angular/README.md @@ -69,6 +69,35 @@ import {SurfaceComponent} from '@a2ui/angular/v0_9'; export class AppComponent {} ``` +### Custom Fallbacks + +Provide `loadingTemplate` and `unknownComponentTemplate` inputs to render your own UI while a component is pending or when its type is unknown. Both are `TemplateRef`s and their context exposes `componentId` and `componentType`. Templates set on the surface reach nested component hosts automatically. + +```typescript +import {Component} from '@angular/core'; +import {SurfaceComponent} from '@a2ui/angular/v0_9'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [SurfaceComponent], + template: ` + + + Loading... + + + Unsupported component: {{ type }} + + `, +}) +export class AppComponent {} +``` + ### Dynamic Component Hosting For more fine-grained control, use the `ComponentHostComponent` to render specific components within a surface: diff --git a/renderers/angular/src/v0_9/core/component-host.component.spec.ts b/renderers/angular/src/v0_9/core/component-host.component.spec.ts index babb4d5247..5a28498066 100644 --- a/renderers/angular/src/v0_9/core/component-host.component.spec.ts +++ b/renderers/angular/src/v0_9/core/component-host.component.spec.ts @@ -19,13 +19,28 @@ import {By} from '@angular/platform-browser'; import {ComponentHostComponent} from './component-host.component'; import {A2uiRendererService} from './a2ui-renderer.service'; import { + A2uiFallbackInfo, ComponentApi, ComponentModel, SurfaceComponentsModel, SurfaceModel, } from '@a2ui/web_core/v0_9'; -import {Component, EnvironmentInjector, EventEmitter, Input, NgZone} from '@angular/core'; +import { + Component, + EnvironmentInjector, + EventEmitter, + Input, + NgZone, + TemplateRef, + forwardRef, + viewChild, +} from '@angular/core'; import {initializeAngularReactivity} from './reactivity'; +import { + A2UI_FALLBACK_TEMPLATES, + A2uiFallbackTemplateContext, + A2uiFallbackTemplates, +} from './fallback-templates'; @Component({ selector: 'test-child', @@ -38,6 +53,84 @@ class TestChildComponent { @Input() dataContextPath?: string; } +@Component({ + selector: 'test-parent', + standalone: true, + imports: [ComponentHostComponent], + template: ` +
+ + +
+ `, +}) +class TestParentComponent { + @Input() props: any; + @Input() surfaceId?: string; + @Input() componentId?: string; + @Input() dataContextPath?: string; +} + +// Mimics a catalog component that embeds a second surface: it hosts a +// component-host bound to a DIFFERENT surfaceId, so the inner host's +// `skipSelf` injection resolves to the outer host across a surface boundary. +@Component({ + selector: 'test-nested-surface', + standalone: true, + imports: [ComponentHostComponent], + template: ` + + + `, +}) +class TestNestedSurfaceComponent { + @Input() props: any; + @Input() surfaceId?: string; + @Input() componentId?: string; + @Input() dataContextPath?: string; +} + +@Component({ + selector: 'test-fallback-wrapper', + standalone: true, + imports: [ComponentHostComponent], + providers: [ + { + provide: A2UI_FALLBACK_TEMPLATES, + useExisting: forwardRef(() => TestFallbackWrapperComponent), + }, + ], + template: ` + +
Loading {{ componentId }}
+ {{ capture(info) }} +
+ +
Unknown {{ componentType }}
+ {{ capture(info) }} +
+ + + `, +}) +class TestFallbackWrapperComponent implements A2uiFallbackTemplates { + readonly loadingTemplate = viewChild>('loadingTpl'); + readonly unknownComponentTemplate = + viewChild>('unknownTpl'); + + componentKey: string | {id: string; basePath: string} = {id: 'comp1', basePath: '/'}; + surfaceId = 'surf1'; + + readonly capturedInfos: A2uiFallbackInfo[] = []; + + capture(info: A2uiFallbackInfo): string { + if (!this.capturedInfos.includes(info)) { + this.capturedInfos.push(info); + } + return ''; + } +} + describe('ComponentHostComponent', () => { let component: ComponentHostComponent; let fixture: ComponentFixture; @@ -61,7 +154,8 @@ describe('ComponentHostComponent', () => { id: 'surf1', componentsModel: mockSurfaceComponentsModel, catalog: mockCatalog, - } as SurfaceModel; + dispatchError: jasmine.createSpy('dispatchError').and.resolveTo(), + } as unknown as SurfaceModel; mockSurfaceGroup = { getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface), @@ -207,6 +301,19 @@ describe('ComponentHostComponent', () => { ); }); + it('clears resolvedModelType on reset when re-entering a fallback state', () => { + spyOn(console, 'warn'); + fixture.detectChanges(); + expect(component.resolvedModelType).toBe('TestType'); + + // Re-point at a missing component: the host drops into loading and must + // not keep advertising the previously resolved type as its parent hint. + fixture.componentRef.setInput('componentKey', {id: 'ghost', basePath: '/'}); + fixture.detectChanges(); + + expect(component.resolvedModelType).toBeUndefined(); + }); + it('should trigger destroyRef on destroy', () => { fixture.detectChanges(); // Trigger change detection @@ -264,4 +371,251 @@ describe('ComponentHostComponent', () => { expect(childInstance.dataContextPath).toBe('/some/path'); }); }); + + describe('Fallback dispatch', () => { + let dispatchErrorSpy: jasmine.Spy; + + beforeEach(() => { + dispatchErrorSpy = (mockSurface as any).dispatchError; + }); + + it('renders nothing by default while pending', () => { + const consoleWarnSpy = spyOn(console, 'warn'); + mockSurface.componentsModel.dispose(); + + fixture.detectChanges(); + + expect(fixture.nativeElement.children.length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Component comp1 not found in surface surf1. Waiting for it...', + ); + }); + + it('dispatches COMPONENT_NOT_FOUND once per warned type', async () => { + spyOn(console, 'error'); + mockCatalog.components.clear(); + + fixture.detectChanges(); + await Promise.resolve(); + + expect(dispatchErrorSpy).toHaveBeenCalledTimes(1); + // Exact payload equality also proves no surfaceId key is passed — + // SurfaceModel.dispatchError appends it itself. + expect(dispatchErrorSpy).toHaveBeenCalledWith({ + code: 'COMPONENT_NOT_FOUND', + message: 'Component implementation not found for type: TestType', + componentId: 'comp1', + componentType: 'TestType', + }); + + // Re-setup with the same type via a new key object identity: the + // per-instance dedup must keep the count at 1. + fixture.componentRef.setInput('componentKey', {id: 'comp1', basePath: '/'}); + fixture.detectChanges(); + await Promise.resolve(); + + expect(dispatchErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('dispatches again for a second distinct unknown type on the same mount', async () => { + spyOn(console, 'error'); + mockCatalog.components.clear(); + + fixture.detectChanges(); + await Promise.resolve(); + expect(dispatchErrorSpy).toHaveBeenCalledTimes(1); + + mockSurface.componentsModel.removeComponent('comp1'); + mockSurface.componentsModel.addComponent(new ComponentModel('comp1', 'TestType2', {})); + fixture.componentRef.setInput('componentKey', {id: 'comp1', basePath: '/'}); + fixture.detectChanges(); + await Promise.resolve(); + + expect(dispatchErrorSpy).toHaveBeenCalledTimes(2); + expect(dispatchErrorSpy.calls.mostRecent().args[0].componentType).toBe('TestType2'); + }); + + it('a type flap dispatches once per distinct unknown type', async () => { + spyOn(console, 'error'); + mockCatalog.components.clear(); + + fixture.detectChanges(); + await Promise.resolve(); + + const replaceComp = async (type: string) => { + mockSurface.componentsModel.removeComponent('comp1'); + mockSurface.componentsModel.addComponent(new ComponentModel('comp1', type, {})); + fixture.componentRef.setInput('componentKey', {id: 'comp1', basePath: '/'}); + fixture.detectChanges(); + await Promise.resolve(); + }; + + // TestType -> TestType2 -> TestType: returning to an already-dispatched + // type must not re-dispatch. + await replaceComp('TestType2'); + await replaceComp('TestType'); + + expect(dispatchErrorSpy).toHaveBeenCalledTimes(2); + const dispatchedTypes = dispatchErrorSpy.calls + .allArgs() + .map(([payload]) => payload.componentType); + expect(dispatchedTypes).toEqual(['TestType', 'TestType2']); + }); + }); + + describe('Fallback templates', () => { + let wrapperFixture: ComponentFixture; + let wrapper: TestFallbackWrapperComponent; + + const createWrapper = (key: string | {id: string; basePath: string}) => { + wrapperFixture = TestBed.createComponent(TestFallbackWrapperComponent); + wrapper = wrapperFixture.componentInstance; + wrapper.componentKey = key; + wrapperFixture.detectChanges(); + // A second pass lets the viewChild template queries settle into the + // host's computed before asserting. + wrapperFixture.detectChanges(); + }; + + it('renders consumer fallback for pending (root)', () => { + const consoleWarnSpy = spyOn(console, 'warn'); + createWrapper('compX'); + + const marker = wrapperFixture.debugElement.query(By.css('.loading-marker')); + expect(marker).toBeTruthy(); + expect(marker.nativeElement.textContent).toContain('compX'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Component compX not found in surface surf1. Waiting for it...', + ); + }); + + it('renders consumer unknownComponent fallback with componentType context', () => { + spyOn(console, 'error'); + mockCatalog.components.clear(); + createWrapper({id: 'comp1', basePath: '/'}); + + const marker = wrapperFixture.debugElement.query(By.css('.unknown-marker')); + expect(marker).toBeTruthy(); + expect(marker.nativeElement.textContent).toContain('TestType'); + }); + + it('renders consumer fallback for a nested child via the injection token', () => { + spyOn(console, 'warn'); + mockCatalog.components.set('TestParent', {component: TestParentComponent}); + mockSurface.componentsModel.addComponent(new ComponentModel('parent1', 'TestParent', {})); + createWrapper('parent1'); + + // The loading marker renders INSIDE the parent's DOM — the templates + // crossed ngComponentOutlet into the nested host via the token. + const nestedMarker = wrapperFixture.debugElement.query( + By.css('.test-parent .loading-marker'), + ); + expect(nestedMarker).toBeTruthy(); + expect(nestedMarker.nativeElement.textContent).toContain('child1'); + }); + + it('clears the fallback when the component arrives', () => { + spyOn(console, 'warn'); + createWrapper('comp2'); + + expect(wrapperFixture.debugElement.query(By.css('.loading-marker'))).toBeTruthy(); + + mockSurface.componentsModel.addComponent( + new ComponentModel('comp2', 'TestType', {text: 'Loaded Data'}), + ); + wrapperFixture.detectChanges(); + + expect(wrapperFixture.debugElement.query(By.css('.loading-marker'))).toBeFalsy(); + expect(wrapperFixture.debugElement.query(By.directive(TestChildComponent))).toBeTruthy(); + }); + + it('runs component initialization inside the Angular Zone when the component arrives', () => { + spyOn(console, 'warn'); + createWrapper('comp2'); + + // In this arrival flow the onCreated handler's ngZone.run is the only + // run() call (detectChanges does not call it and onUpdated does not fire), + // so the spy discriminates the zone-wrapped path from the un-zoned one. + const ngZone = TestBed.inject(NgZone); + const runSpy = spyOn(ngZone, 'run').and.callThrough(); + + mockSurface.componentsModel.addComponent( + new ComponentModel('comp2', 'TestType', {text: 'Loaded'}), + ); + wrapperFixture.detectChanges(); + + expect(runSpy).toHaveBeenCalled(); + }); + + it('passes parentComponentType to nested loading fallbacks', () => { + spyOn(console, 'warn'); + mockCatalog.components.set('TestParent', {component: TestParentComponent}); + mockSurface.componentsModel.addComponent(new ComponentModel('parent1', 'TestParent', {})); + createWrapper('parent1'); + + const info = wrapper.capturedInfos.find(i => i.state === 'loading'); + expect(info).toBeDefined(); + expect(info!.parentComponentType).toBe('TestParent'); + }); + + it('omits parentComponentType at the root', () => { + spyOn(console, 'warn'); + createWrapper('compX'); + + const info = wrapper.capturedInfos.find(i => i.state === 'loading'); + expect(info).toBeDefined(); + // The key must be ABSENT at the root, not present with an undefined value. + expect('parentComponentType' in info!).toBe(false); + }); + + it('does not inherit the outer surface type at a nested surface root', () => { + spyOn(console, 'warn'); + mockCatalog.components.set('TestNestedSurface', {component: TestNestedSurfaceComponent}); + mockSurface.componentsModel.addComponent( + new ComponentModel('parent1', 'TestNestedSurface', {}), + ); + + // A second surface whose root component ('innerChild') has not streamed + // in yet, so the inner host settles into the loading fallback. + const surf2ComponentsModel = new SurfaceComponentsModel(); + const surf2 = { + id: 'surf2', + componentsModel: surf2ComponentsModel, + catalog: mockCatalog, + dispatchError: jasmine.createSpy('dispatchError').and.resolveTo(), + } as unknown as SurfaceModel; + mockSurfaceGroup.getSurface.and.callFake((sid: string) => + sid === 'surf2' ? surf2 : mockSurface, + ); + + createWrapper('parent1'); + + // The inner host's parent (via skipSelf) is the outer host, which + // resolved 'TestNestedSurface' on surf1. Because the inner host lives on + // surf2, that type must NOT leak in as its parentComponentType. + const innerInfo = wrapper.capturedInfos.find( + i => i.state === 'loading' && i.componentId === 'innerChild', + ); + expect(innerInfo).toBeDefined(); + expect('parentComponentType' in innerInfo!).toBe(false); + }); + + it('passes parentComponentType to nested unknownComponent fallbacks', () => { + spyOn(console, 'warn'); + spyOn(console, 'error'); + mockCatalog.components.set('TestParent', {component: TestParentComponent}); + mockSurface.componentsModel.addComponent(new ComponentModel('parent1', 'TestParent', {})); + mockSurface.componentsModel.addComponent(new ComponentModel('child1', 'Nope', {})); + createWrapper('parent1'); + + const info = wrapper.capturedInfos.find(i => i.state === 'unknownComponent'); + expect(info).toBeDefined(); + expect(info!.parentComponentType).toBe('TestParent'); + const nestedMarker = wrapperFixture.debugElement.query( + By.css('.test-parent .unknown-marker'), + ); + expect(nestedMarker).toBeTruthy(); + expect(nestedMarker.nativeElement.textContent).toContain('Nope'); + }); + }); }); 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 ddf4439194..2ba8fb3802 100644 --- a/renderers/angular/src/v0_9/core/component-host.component.ts +++ b/renderers/angular/src/v0_9/core/component-host.component.ts @@ -19,17 +19,25 @@ import { Component, DestroyRef, Type, + computed, inject, input, effect, signal, NgZone, } from '@angular/core'; -import {NgComponentOutlet} from '@angular/common'; -import {ComponentContext, ComponentModel, SurfaceModel, Subscription} from '@a2ui/web_core/v0_9'; +import {NgComponentOutlet, NgTemplateOutlet} from '@angular/common'; +import { + A2uiFallbackInfo, + ComponentContext, + ComponentModel, + SurfaceModel, + Subscription, +} from '@a2ui/web_core/v0_9'; import {A2uiRendererService} from './a2ui-renderer.service'; import {AngularCatalog} from '../catalog/types'; import {ComponentBinder} from './component-binder.service'; +import {A2UI_FALLBACK_TEMPLATES, A2uiFallbackTemplateContext} from './fallback-templates'; import {BoundProperty} from './types'; /** @@ -44,7 +52,7 @@ import {BoundProperty} from './types'; */ @Component({ selector: 'a2ui-v09-component-host', - imports: [NgComponentOutlet], + imports: [NgComponentOutlet, NgTemplateOutlet], host: { style: 'display: contents;', }, @@ -61,6 +69,8 @@ import {BoundProperty} from './types'; } " > + } @else if (fallbackTemplate(); as tpl) { + } `, changeDetection: ChangeDetectionStrategy.OnPush, @@ -76,14 +86,43 @@ export class ComponentHostComponent { private readonly binder = inject(ComponentBinder); private readonly destroyRef = inject(DestroyRef); private readonly ngZone = inject(NgZone); + private readonly fallbackTemplates = inject(A2UI_FALLBACK_TEMPLATES, {optional: true}); + private readonly parentHost = inject(ComponentHostComponent, {optional: true, skipSelf: true}); protected readonly componentType = signal | null>(null); protected readonly props = signal>({}); + protected readonly fallbackInfo = signal(null); private context?: ComponentContext; protected resolvedComponentId: string = ''; protected resolvedDataContextPath: string = '/'; + /** The resolved A2UI type of this host's component; read by child hosts as their parent hint. */ + resolvedModelType?: string; + + /** Unknown types already dispatched to the agent — once per type per host instance. */ + private readonly dispatchedErrorTypes = new Set(); + + protected readonly fallbackTemplate = computed(() => { + const info = this.fallbackInfo(); + if (!info || !this.fallbackTemplates) return null; + if (info.state === 'loading') return this.fallbackTemplates.loadingTemplate() ?? null; + if (info.state === 'unknownComponent') { + return this.fallbackTemplates.unknownComponentTemplate() ?? null; + } + return null; + }); + + protected readonly fallbackContext = computed(() => { + const info = this.fallbackInfo(); + if (!info) return null; + return { + $implicit: info, + componentId: info.componentId, + ...(info.state === 'unknownComponent' ? {componentType: info.componentType} : {}), + }; + }); + private propsSub?: Subscription; private createSub?: Subscription; private surfaceSub?: Subscription; @@ -153,10 +192,21 @@ export class ComponentHostComponent { if (!componentModel) { console.warn(`Component ${id} not found in surface ${surfaceId}. Waiting for it...`); + this.fallbackInfo.set({ + state: 'loading', + componentId: id, + ...this.parentTypeHint(), + }); const sub = surface.componentsModel.onCreated.subscribe(comp => { if (comp.id === id) { - this.initializeComponent(surface, comp, id, basePath); + // onCreated originates from transport callbacks that can run outside + // the Angular zone; clearing the loading fallback rides this path, so + // wrap it like the sibling surfaceSub/propsSub handlers to stay + // reactive under provideZoneChangeDetection({ignoreChangesOutsideZone}). + this.ngZone.run(() => { + this.initializeComponent(surface, comp, id, basePath); + }); sub.unsubscribe(); } }); @@ -179,9 +229,31 @@ export class ComponentHostComponent { if (!api) { console.error(`Component type "${componentModel.type}" not found in catalog "${catalog.id}"`); + if (!this.dispatchedErrorTypes.has(componentModel.type)) { + this.dispatchedErrorTypes.add(componentModel.type); + // Deferred: initializeComponent can run inside change detection + // (constructor effect); onError listeners must not run synchronously + // from it. + queueMicrotask(() => { + void surface.dispatchError({ + code: 'COMPONENT_NOT_FOUND', + message: `Component implementation not found for type: ${componentModel.type}`, + componentId: id, + componentType: componentModel.type, + }); + }); + } + this.fallbackInfo.set({ + state: 'unknownComponent', + componentId: id, + componentType: componentModel.type, + ...this.parentTypeHint(), + }); return; } + this.resolvedModelType = componentModel.type; this.componentType.set(api.component); + this.fallbackInfo.set(null); // Create context this.context = new ComponentContext(surface, id, basePath); @@ -197,6 +269,21 @@ export class ComponentHostComponent { }); } + /** + * The parent host's resolved type, but only when the parent belongs to the + * same surface. The `skipSelf` injection walks past a nested + * `SurfaceComponent` boundary, so without this guard an embedded surface's + * root host would report the OUTER surface's type — violating the shared + * contract (parentComponentType absent at a surface root) and diverging + * from React, where the hint is threaded explicitly per surface. + */ + private parentTypeHint(): {parentComponentType?: string} { + const parent = this.parentHost; + const type = + parent && parent.surfaceId() === this.surfaceId() ? parent.resolvedModelType : undefined; + return type !== undefined ? {parentComponentType: type} : {}; + } + /** * Resets the component host state, unsubscribing from active subscriptions * and clearing component properties to avoid rendering stale data while @@ -209,6 +296,8 @@ export class ComponentHostComponent { this.componentType.set(null); this.props.set({}); + this.fallbackInfo.set(null); this.resolvedDataContextPath = '/'; + this.resolvedModelType = undefined; } } diff --git a/renderers/angular/src/v0_9/core/fallback-templates.ts b/renderers/angular/src/v0_9/core/fallback-templates.ts new file mode 100644 index 0000000000..c48606767f --- /dev/null +++ b/renderers/angular/src/v0_9/core/fallback-templates.ts @@ -0,0 +1,48 @@ +/** + * 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 {InjectionToken, Signal, TemplateRef} from '@angular/core'; +import {A2uiFallbackInfo} from '@a2ui/web_core/v0_9'; + +/** + * Context passed to consumer-provided fallback templates via `ngTemplateOutlet`. + */ +export interface A2uiFallbackTemplateContext { + /** The full fallback info payload. */ + $implicit: A2uiFallbackInfo; + /** The id of the component that could not be rendered. */ + componentId: string; + /** The component's declared type. Only present for the `unknownComponent` state. */ + componentType?: string; +} + +/** + * Consumer-provided fallback templates, one per shared fallback state. + * Provided by `SurfaceComponent` from its template inputs; nested + * `ComponentHostComponent`s inject it optionally. + */ +export interface A2uiFallbackTemplates { + readonly loadingTemplate: Signal | undefined>; + readonly unknownComponentTemplate: Signal | undefined>; +} + +/** + * Injection token delivering fallback templates to component hosts, + * mirroring `A2UI_RENDERER_CONFIG`. + */ +export const A2UI_FALLBACK_TEMPLATES = new InjectionToken( + 'A2UI_FALLBACK_TEMPLATES', +); diff --git a/renderers/angular/src/v0_9/core/surface.component.spec.ts b/renderers/angular/src/v0_9/core/surface.component.spec.ts index 911b6d2265..51828b3d74 100644 --- a/renderers/angular/src/v0_9/core/surface.component.spec.ts +++ b/renderers/angular/src/v0_9/core/surface.component.spec.ts @@ -36,6 +36,26 @@ class TestTextComponent { @Input() dataContextPath?: string; } +@Component({ + selector: 'test-surface-host', + standalone: true, + imports: [SurfaceComponent], + template: ` + + +
Loading {{ componentId }}
+
+ +
Unknown {{ componentType }}
+
+ `, +}) +class TestSurfaceHostComponent {} + describe('SurfaceComponent', () => { let component: SurfaceComponent; let fixture: ComponentFixture; @@ -94,4 +114,29 @@ describe('SurfaceComponent', () => { const host = fixture.debugElement.query(By.directive(ComponentHostComponent)); expect(host.componentInstance.componentKey()).toEqual({id: 'root', basePath: '/'}); }); + + it('forwards fallback templates to the component host', () => { + spyOn(console, 'warn'); + // The shared fixture has no surfaceId input; destroy it so a scheduled + // tick cannot refresh it and trip NG0950. + fixture.destroy(); + // The root component is missing: componentsModel.get returns undefined and + // onCreated.subscribe hands back a stub subscription. + mockRendererService.surfaceGroup.getSurface.and.returnValue({ + componentsModel: { + get: () => undefined, + onCreated: {subscribe: () => ({unsubscribe: () => {}})}, + }, + catalog: {id: 'mock-catalog', components: new Map()}, + }); + + const hostFixture = TestBed.createComponent(TestSurfaceHostComponent); + hostFixture.detectChanges(); + // A second pass lets the viewChild-independent template refs settle. + hostFixture.detectChanges(); + + const marker = hostFixture.debugElement.query(By.css('.loading-marker')); + expect(marker).toBeTruthy(); + expect(marker.nativeElement.textContent).toContain('root'); + }); }); diff --git a/renderers/angular/src/v0_9/core/surface.component.ts b/renderers/angular/src/v0_9/core/surface.component.ts index 79c77c4a28..a03f15a561 100644 --- a/renderers/angular/src/v0_9/core/surface.component.ts +++ b/renderers/angular/src/v0_9/core/surface.component.ts @@ -14,8 +14,13 @@ * limitations under the License. */ -import {ChangeDetectionStrategy, Component, input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, forwardRef, input, TemplateRef} from '@angular/core'; import {ComponentHostComponent} from './component-host.component'; +import { + A2UI_FALLBACK_TEMPLATES, + A2uiFallbackTemplateContext, + A2uiFallbackTemplates, +} from './fallback-templates'; /** * High-level component for rendering an entire A2UI surface. @@ -39,8 +44,9 @@ import {ComponentHostComponent} from './component-host.component'; `, changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{provide: A2UI_FALLBACK_TEMPLATES, useExisting: forwardRef(() => SurfaceComponent)}], }) -export class SurfaceComponent { +export class SurfaceComponent implements A2uiFallbackTemplates { /** The unique identifier of the surface to render. */ surfaceId = input.required(); @@ -49,4 +55,10 @@ export class SurfaceComponent { * Defaults to the root ('/'). */ dataContextPath = input('/'); + + /** Optional template rendered while a referenced component has not yet streamed in. */ + loadingTemplate = input>(); + + /** Optional template rendered when a component's type has no catalog implementation. */ + unknownComponentTemplate = input>(); } diff --git a/renderers/angular/src/v0_9/index.ts b/renderers/angular/src/v0_9/index.ts index de8f22c7d8..e6bdc1c2d7 100644 --- a/renderers/angular/src/v0_9/index.ts +++ b/renderers/angular/src/v0_9/index.ts @@ -27,6 +27,7 @@ export * from './core/a2ui-renderer.service'; export * from './core/component-host.component'; export * from './core/surface.component'; +export * from './core/fallback-templates'; export * from './core/catalog_component'; export * from './core/component-binder.service'; export * from './core/types'; diff --git a/renderers/lit/CHANGELOG.md b/renderers/lit/CHANGELOG.md index 7e7d5b6118..0aa93c1c6b 100644 --- a/renderers/lit/CHANGELOG.md +++ b/renderers/lit/CHANGELOG.md @@ -1,5 +1,15 @@ ## Unreleased +- (v0_9) The root loading state now renders an empty `` by + default instead of the visible `Loading surface...` placeholder text (#2013). + This is a deliberate rendering-default change, not a breaking change: + consumer-projected `slot="loading"` content still renders while the root is + pending. +- (v0_9) Add consumer fallbacks via a new `fallbacks` property on + `` (#2013). Fallbacks reach nested pending and unknown-component + children through `@lit/context`, and unknown component types and root render + errors are reported to the agent via `SurfaceModel.dispatchError`. + ## 0.10.2 - (v0_9) Normalize Safari placeholder text color for `DateTimeInput` by updating CSS selectors for `.a2ui-date-time-input`. diff --git a/renderers/lit/README.md b/renderers/lit/README.md index 4658579d06..4ba5f3eb5e 100644 --- a/renderers/lit/README.md +++ b/renderers/lit/README.md @@ -77,6 +77,38 @@ export class MyApp extends LitElement { } ``` +## Custom Fallbacks + +By default `` renders nothing while a component streams in or when a type is unknown. There are two ways to supply your own UI. For the root loading state, project content into the preserved `loading` slot: + +```html + + Loading... + +``` + +For nested children, which the slot cannot reach, set the `fallbacks` property. It is provided over `@lit/context` so descendant elements receive it: + +```typescript +import {LitElement, html} from 'lit'; +import type {A2uiLitFallbacks} from '@a2ui/lit/v0_9'; + +class ChatView extends LitElement { + // Keep a stable reference; a new object identity re-renders pending children. + private fallbacks: A2uiLitFallbacks = { + loading: info => html`Loading...`, + unknownComponent: info => html`Unsupported: ${info.componentType}`, + }; + + render() { + return html``; + } +} +``` + ## Defining Custom Components A2UI v0.9 separates a component's API (Schema) from its implementation. diff --git a/renderers/lit/src/v0_9/a2ui-lit-element.ts b/renderers/lit/src/v0_9/a2ui-lit-element.ts index f053bbd62a..92599ccb51 100644 --- a/renderers/lit/src/v0_9/a2ui-lit-element.ts +++ b/renderers/lit/src/v0_9/a2ui-lit-element.ts @@ -16,8 +16,11 @@ import {LitElement, nothing} from 'lit'; import {property} from 'lit/decorators.js'; -import {ComponentContext, ComponentApi, type ComponentId} from '@a2ui/web_core/v0_9'; +import {consume} from '@lit/context'; +import {ComponentContext, ComponentApi, SurfaceModel, type ComponentId} from '@a2ui/web_core/v0_9'; import {renderA2uiNode} from './surface/render-a2ui-node.js'; +import {Context} from './context/context.js'; +import {A2uiLitFallbacks, renderFallback} from './context/fallbacks.js'; import {A2uiController} from './a2ui-controller.js'; /** @@ -40,6 +43,21 @@ export abstract class A2uiLitElement extends LitElemen @property({type: Object}) accessor context!: ComponentContext; protected controller!: A2uiController; + /** + * Consumer-provided fallbacks, consumed from the surface via `@lit/context`. + * The element CAN consume; the pure `renderA2uiNode` cannot, which is why the + * value is threaded into it as a parameter below. + */ + @consume({context: Context.fallbacks, subscribe: true}) + accessor fallbacks: A2uiLitFallbacks | undefined; + + /** + * Active onCreated subscriptions for pending child ids, keyed by child id so at + * most one subscription exists per child despite render-time (re-)detection. + * The value is the unsubscribe thunk. Cleared in `disconnectedCallback`. + */ + readonly #childSubs = new Map void>(); + /** * Instantiates the unique controller for this element's specific bound API. * @@ -90,7 +108,72 @@ export abstract class A2uiLitElement extends LitElemen // refs without a basePath. path = path ?? parentPath; - return renderA2uiNode(new ComponentContext(surface, componentId, path), surface.catalog); + // The parent guard above checks the parent id. This second guard checks the + // resolved CHILD id: a nested child referenced before it has streamed would + // make the ComponentContext constructor throw. Pre-empt with a guard (not a + // try/catch, so a genuine bug still surfaces): render the consumer loading + // fallback (or nothing) and re-render when the child arrives. + // + // Detection is at render time because, unlike the surface (which statically + // knows 'root'), the base element does not know which children a subclass + // will render until render runs. The Map guard keeps this leak-free: at most + // one onCreated subscription exists per pending child id. + if (!surface.componentsModel.get(componentId)) { + this.#subscribePendingChild(surface, componentId); + const parentComponentType = this.context.componentModel.type; + return renderFallback(this.fallbacks?.loading, { + state: 'loading', + componentId, + ...(parentComponentType ? {parentComponentType} : {}), + }); + } + + return renderA2uiNode( + new ComponentContext(surface, componentId, path), + surface.catalog, + this.fallbacks, + this.context.componentModel.type, + ); + } + + /** + * Ensures a single targeted onCreated subscription exists for a pending child. + * + * Mirrors the surface's subscribe discipline (`a2ui-surface.ts`): filter to the + * pending child id, `requestUpdate` on arrival, then unsubscribe. The Map guard + * bounds it to one subscription per child id despite per-render detection. + */ + #subscribePendingChild(surface: SurfaceModel, componentId: ComponentId) { + if (this.#childSubs.has(componentId)) return; + const sub = surface.componentsModel.onCreated.subscribe(comp => { + if (comp.id !== componentId) return; + this.requestUpdate(); + this.#childSubs.get(componentId)?.(); + this.#childSubs.delete(componentId); + }); + this.#childSubs.set(componentId, () => sub.unsubscribe()); + } + + /** + * Unsubscribes every pending-child subscription and clears the Map. + * + * Shared by `disconnectedCallback` and the `context`-change branch of + * `willUpdate` so a rebind cannot leave subscriptions closed over a stale + * `SurfaceModel` alive (cross-surface subscription leak). + */ + #clearChildSubs() { + for (const unsubscribe of this.#childSubs.values()) { + unsubscribe(); + } + this.#childSubs.clear(); + } + + /** + * Cleans up any outstanding pending-child subscriptions. + */ + override disconnectedCallback() { + super.disconnectedCallback(); + this.#clearChildSubs(); } /** @@ -103,6 +186,10 @@ export abstract class A2uiLitElement extends LitElemen override willUpdate(changedProperties: Map) { super.willUpdate(changedProperties); if (changedProperties.has('context') && this.context) { + // Drop subscriptions bound to the previous context/surface BEFORE + // recreating the controller, so a rebind does not leak stale onCreated + // subscriptions over the old SurfaceModel. + this.#clearChildSubs(); if (this.controller) { this.removeController(this.controller); this.controller.dispose(); diff --git a/renderers/lit/src/v0_9/context/context.ts b/renderers/lit/src/v0_9/context/context.ts index 74b991a3ab..29c90bcdbc 100644 --- a/renderers/lit/src/v0_9/context/context.ts +++ b/renderers/lit/src/v0_9/context/context.ts @@ -15,10 +15,12 @@ */ import {markdown} from './markdown.js'; +import {fallbacks} from './fallbacks.js'; /** * Contexts used to inject dependencies into the Lit renderer. */ export const Context = { markdown, + fallbacks, }; diff --git a/renderers/lit/src/v0_9/context/fallbacks.ts b/renderers/lit/src/v0_9/context/fallbacks.ts new file mode 100644 index 0000000000..964bc996b7 --- /dev/null +++ b/renderers/lit/src/v0_9/context/fallbacks.ts @@ -0,0 +1,65 @@ +/* + * 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 + * + * https://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 {createContext} from '@lit/context'; +import {nothing, type TemplateResult} from 'lit'; +import type {A2uiFallbackInfo, A2uiFallbackState} from '@a2ui/web_core/v0_9'; + +/** + * A fallback value: a static template, `nothing`, or a function of the state's + * fallback info. The Lit analog of React's `A2uiFallbackRenderer`. + */ +export type A2uiLitFallbackRenderer = + | TemplateResult + | typeof nothing + | ((info: Extract) => TemplateResult | typeof nothing); + +/** + * Consumer-provided fallbacks, one per shared fallback state. + * + * There is no `error` key: the `error` state is dispatch-only (reported to the + * agent via `SurfaceModel.dispatchError`), matching the React and Angular + * renderers. + */ +export interface A2uiLitFallbacks { + loading?: A2uiLitFallbackRenderer<'loading'>; + unknownComponent?: A2uiLitFallbackRenderer<'unknownComponent'>; +} + +/** + * The consumer-fallbacks context. + * + * `` `@provide`s it; descendant `A2uiLitElement`s `@consume` it so + * nested pending/unknown children can render consumer-supplied fallback UI. + */ +export const fallbacks = createContext(Symbol('A2UIFallbacks')); + +/** + * Resolves a fallback renderer against its info payload. + * + * Returns `nothing` when the renderer is undefined, invokes it with `info` when + * it is a function, and otherwise returns the static template as-is. The Lit + * analog of React's `renderFallback`; reused by both the pure-function + * unknownComponent path and the base element's loading guard. + */ +export function renderFallback( + renderer: A2uiLitFallbackRenderer | undefined, + info: Extract, +): TemplateResult | typeof nothing { + if (renderer === undefined) return nothing; + if (typeof renderer === 'function') return renderer(info); + return renderer; +} diff --git a/renderers/lit/src/v0_9/index.ts b/renderers/lit/src/v0_9/index.ts index a6e5bb3335..0d18847b3e 100644 --- a/renderers/lit/src/v0_9/index.ts +++ b/renderers/lit/src/v0_9/index.ts @@ -19,4 +19,5 @@ export {A2uiController} from './a2ui-controller.js'; export {A2uiSurface} from './surface/a2ui-surface.js'; export {A2uiLitElement} from './a2ui-lit-element.js'; export {Context} from './context/context.js'; +export type {A2uiLitFallbacks, A2uiLitFallbackRenderer} from './context/fallbacks.js'; export {basicCatalog} from './catalogs/basic/index.js'; diff --git a/renderers/lit/src/v0_9/surface/a2ui-surface.ts b/renderers/lit/src/v0_9/surface/a2ui-surface.ts index d7d7046a37..a28027db3e 100644 --- a/renderers/lit/src/v0_9/surface/a2ui-surface.ts +++ b/renderers/lit/src/v0_9/surface/a2ui-surface.ts @@ -16,8 +16,11 @@ import {html, nothing, LitElement, PropertyValues} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; +import {provide} from '@lit/context'; import {SurfaceModel, ComponentContext} from '@a2ui/web_core/v0_9'; import {renderA2uiNode} from './render-a2ui-node.js'; +import {Context} from '../context/context.js'; +import {A2uiLitFallbacks} from '../context/fallbacks.js'; import {LitComponentApi} from '../types.js'; /** @@ -36,6 +39,16 @@ export class A2uiSurface extends LitElement { */ @property({type: Object}) accessor surface: SurfaceModel | undefined; + /** + * Consumer-provided fallbacks. Provided via `@lit/context` so descendant + * `A2uiLitElement`s can `@consume` them for nested pending/unknown children. + * Keep a stable reference. Because this is a reactive property, a new object + * identity on each render re-renders every pending child. + */ + @provide({context: Context.fallbacks}) + @property({attribute: false}) + accessor fallbacks: A2uiLitFallbacks | undefined; + /** * Internal state indicating whether the root component exists. * @internal @@ -46,6 +59,14 @@ export class A2uiSurface extends LitElement { * @internal */ private unsubscribe?: () => void; + /** + * Guards the root-error dispatch so a persistently failing root reports once + * PER SURFACE. Keyed on the `SurfaceModel` (mirroring `warnedBySurface` in + * `render-a2ui-node.ts`) so a per-element boolean cannot stay latched from a + * prior surface and suppress a second surface's root failure. + * @internal + */ + private readonly errorDispatchedBySurface = new WeakMap, boolean>(); /** * Handles lifecycle updates, specifically when the `surface` property changes. @@ -98,14 +119,32 @@ export class A2uiSurface extends LitElement { override render() { if (!this.surface) return nothing; if (!this._hasRoot) { - return html`
Loading surface...
`; + // Render no visible default text; keep the named slot so a consumer-projected + // `
` still renders while the root is pending. + return html``; } try { const rootContext = new ComponentContext(this.surface, 'root', '/'); - return html`${renderA2uiNode(rootContext, this.surface.catalog)}`; + return html`${renderA2uiNode(rootContext, this.surface.catalog, this.fallbacks)}`; } catch (e) { console.error('Error creating root context:', e); + // Report the error state to the agent, deferred out of the render phase and + // guarded so a persistently failing root does not re-dispatch every render. + // The visible fallback text is intentionally retained (real-exception path). + // Capture the surface now: the deferred microtask must key its guard and + // dispatch on the failing surface, not whatever `this.surface` is later. + const surface = this.surface; + queueMicrotask(() => { + if (!surface || this.errorDispatchedBySurface.get(surface)) return; + this.errorDispatchedBySurface.set(surface, true); + void surface.dispatchError({ + code: 'SURFACE_RENDER_ERROR', + message: 'Error rendering surface', + componentId: 'root', + reason: e, + }); + }); return html`
Error rendering surface
`; } } diff --git a/renderers/lit/src/v0_9/surface/render-a2ui-node.ts b/renderers/lit/src/v0_9/surface/render-a2ui-node.ts index ab6d4fe729..ccb15bba25 100644 --- a/renderers/lit/src/v0_9/surface/render-a2ui-node.ts +++ b/renderers/lit/src/v0_9/surface/render-a2ui-node.ts @@ -14,10 +14,18 @@ * limitations under the License. */ -import {nothing} from 'lit'; import {html, unsafeStatic} from 'lit/static-html.js'; -import {ComponentContext, Catalog} from '@a2ui/web_core/v0_9'; +import {ComponentContext, Catalog, SurfaceModel} from '@a2ui/web_core/v0_9'; import {LitComponentApi} from '../types.js'; +import {A2uiLitFallbacks, renderFallback} from '../context/fallbacks.js'; + +/** + * Tracks the unknown component types already warned/dispatched per surface, so a + * missing implementation is reported exactly once per type per surface. Keyed on + * the SurfaceModel (reachable from the pure function via + * `context.dataContext.surface`) so the Set's lifetime is bounded to the surface. + */ +const warnedBySurface = new WeakMap, Set>(); /** * Pure function that acts as a generic container for A2UI components. @@ -28,21 +36,63 @@ import {LitComponentApi} from '../types.js'; * * @param context The component context defining the data model and type to render. * @param catalog The catalog of component implementations. - * @returns A Lit TemplateResult representing the resolved component, or `nothing` if the component is invalid or unresolvable. + * @param fallbacks Optional consumer-provided fallbacks. When the component + * `type` has no catalog implementation, the `unknownComponent` fallback is + * rendered (defaulting to `nothing`). + * @param parentComponentType The `type` of the instantiating parent, threaded in + * by the base element (the pure function cannot see the parent). Populated on + * the `unknownComponent` fallback info to match the React renderer; absent at + * the surface root. + * @returns A Lit TemplateResult representing the resolved component, the consumer + * `unknownComponent` fallback, or `nothing` if the component is unresolvable. * * This method should be used directly very rarely. Instead, programmers should use * the `renderNode` method on the base `A2uiLitElement` class, which handles context * creation automatically. */ -export function renderA2uiNode(context: ComponentContext, catalog: Catalog) { +export function renderA2uiNode( + context: ComponentContext, + catalog: Catalog, + fallbacks?: A2uiLitFallbacks, + parentComponentType?: string, +) { const type = context.componentModel.type; const implementation = catalog.components.get(type); if (!implementation) { - console.warn(`Component implementation not found for type: ${type}`); - return nothing; + // Defer the warn + dispatch out of the render phase. Lit has no post-commit + // hook, so a microtask is the idiomatic equivalent of React's `useEffect`. + // Dedup once-per-type-per-surface: the re-check runs INSIDE the microtask so + // multiple synchronous renders of the same unknown type report only once. + const surface = context.dataContext.surface; + const componentId = context.componentModel.id; + queueMicrotask(() => { + let warned = warnedBySurface.get(surface); + if (!warned) { + warned = new Set(); + warnedBySurface.set(surface, warned); + } + if (warned.has(type)) return; + warned.add(type); + console.warn(`Component implementation not found for type: ${type}`); + void surface.dispatchError({ + code: 'COMPONENT_NOT_FOUND', + message: `Component implementation not found for type: ${type}`, + componentId, + componentType: type, + }); + }); + return renderFallback(fallbacks?.unknownComponent, { + state: 'unknownComponent', + componentId, + componentType: type, + ...(parentComponentType ? {parentComponentType} : {}), + }); } + // Security: only the catalog-resolved (trusted) tagName reaches `unsafeStatic`. + // The model-controlled `type` flows solely into the warn text and dispatch + // payload above (data), never into a template sink. const tag = unsafeStatic(implementation.tagName); return html`<${tag} .context=${context}>`; } diff --git a/renderers/lit/src/v0_9/tests/a2ui-surface.test.ts b/renderers/lit/src/v0_9/tests/a2ui-surface.test.ts index 68a9dac115..6be49e81e6 100644 --- a/renderers/lit/src/v0_9/tests/a2ui-surface.test.ts +++ b/renderers/lit/src/v0_9/tests/a2ui-surface.test.ts @@ -68,7 +68,7 @@ describe('A2uiSurface', () => { document.body.removeChild(el); }); - it('should render loading state when surface has no root component', async () => { + it('should render no default text but preserve the loading slot when the root is missing', async () => { const el = document.createElement('a2ui-surface') as unknown as A2uiSurface; document.body.appendChild(el); @@ -77,7 +77,8 @@ describe('A2uiSurface', () => { }); const html = el.shadowRoot?.innerHTML; - assert.ok(html?.includes('Loading surface'), 'Should contain loading text'); + assert.ok(!html?.includes('Loading surface'), 'Should not render default loading text'); + assert.ok(html?.includes(''), 'The loading slot should be preserved'); document.body.removeChild(el); }); @@ -90,7 +91,10 @@ describe('A2uiSurface', () => { e.surface = surfaceModel; }); - assert.ok(el.shadowRoot?.innerHTML?.includes('Loading surface')); + assert.ok( + !el.shadowRoot?.innerHTML?.includes('Loading surface'), + 'The pre-root state should render no default text', + ); // Add root component await asyncUpdate(el, () => { @@ -119,10 +123,12 @@ describe('A2uiSurface', () => { await childEl.updateComplete; } - const html = el.shadowRoot?.innerHTML; const childHtml = childEl?.shadowRoot?.innerHTML; - assert.ok(!html?.includes('Loading surface'), 'Loading text should be gone'); + assert.ok( + el.shadowRoot?.querySelector('a2ui-basic-text'), + 'The root component should be rendered', + ); assert.ok(childHtml?.includes('Hello JSDOM'), 'Actual child HTML: ' + childHtml); document.body.removeChild(el); diff --git a/renderers/lit/src/v0_9/tests/fallbacks.test.ts b/renderers/lit/src/v0_9/tests/fallbacks.test.ts new file mode 100644 index 0000000000..da1e31d1c0 --- /dev/null +++ b/renderers/lit/src/v0_9/tests/fallbacks.test.ts @@ -0,0 +1,488 @@ +/* + * 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 + * + * https://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 {setupTestDom, teardownTestDom, asyncUpdate} from './dom-setup.js'; +import assert from 'node:assert'; +import {describe, it, beforeEach, afterEach, after, before} from 'node:test'; +import type {html as HtmlTag} from 'lit'; +import type {A2uiSurface} from '../surface/a2ui-surface.js'; +import {MessageProcessor} from '@a2ui/web_core/v0_9'; + +/** + * Verifies the Lit renderer's fallback behaviour (LIT-01/02/03, FB-02): + * - the root pending state renders no visible default text but keeps its slot; + * - a nested pending child renders nothing (and does not throw), then re-renders + * on arrival; + * - consumer `loading` / `unknownComponent` fallbacks reach nested children via + * `@lit/context`; + * - unknown component types dispatch COMPONENT_NOT_FOUND once per type, and the + * root context-creation failure dispatches the error state. + */ +describe('fallbacks', () => { + let basicCatalog: any; + // Imported dynamically AFTER jsdom globals exist (see dom-setup.ts): importing + // `lit` before setupTestDom evaluates LitElement in an empty Node context and + // breaks custom-element registration. + let html: typeof HtmlTag; + + before(async () => { + setupTestDom(); + html = (await import('lit')).html; + await import('../surface/a2ui-surface.js'); + basicCatalog = (await import('../catalogs/basic/index.js')).basicCatalog; + }); + after(teardownTestDom); + + let processor: MessageProcessor; + let surfaceModel: any; + const mounted: Array = []; + + beforeEach(() => { + processor = new MessageProcessor([basicCatalog]); + processor.processMessages([ + { + version: 'v0.9', + createSurface: {surfaceId: 'test-surface', catalogId: basicCatalog.id}, + }, + ]); + surfaceModel = processor.model.getSurface('test-surface')!; + }); + + afterEach(() => { + for (const el of mounted.splice(0)) el.remove(); + }); + + /** Applies an `updateComponents` message to the shared test surface. */ + function updateComponents(components: unknown[]) { + processor.processMessages([ + {version: 'v0.9', updateComponents: {surfaceId: 'test-surface', components}}, + ]); + } + + /** Creates a second, independent surface model with the given components. */ + function makeSurface(surfaceId: string, components: unknown[]) { + processor.processMessages([ + {version: 'v0.9', createSurface: {surfaceId, catalogId: basicCatalog.id}}, + {version: 'v0.9', updateComponents: {surfaceId, components}}, + ]); + return processor.model.getSurface(surfaceId)!; + } + + /** + * Wraps a components model's `onCreated.subscribe` to count live/total + * subscriptions, so leak/dedup/cleanup invariants can be asserted directly. + */ + function spyOnCreated(componentsModel: any) { + const emitter = componentsModel.onCreated; + const orig = emitter.subscribe; + const state = {active: 0, total: 0}; + emitter.subscribe = (cb: any) => { + state.total++; + state.active++; + const sub = orig.call(emitter, cb); + const origUnsub = sub.unsubscribe.bind(sub); + return { + unsubscribe: () => { + state.active--; + origUnsub(); + }, + }; + }; + return {state, restore: () => (emitter.subscribe = orig)}; + } + + /** Creates, mounts, and awaits an `` after `configure` runs. */ + async function mountSurface(configure: (el: A2uiSurface) => void): Promise { + const el = document.createElement('a2ui-surface') as unknown as A2uiSurface; + mounted.push(el as unknown as HTMLElement); + document.body.appendChild(el); + await asyncUpdate(el, e => configure(e)); + await el.updateComplete; + return el; + } + + /** Resolves after the current microtask queue drains (the deferred dispatch). */ + const flushMicrotasks = () => new Promise(r => queueMicrotask(() => r())); + + /** Awaits a nested catalog element's own update cycle before reading its DOM. */ + async function childShadow(el: A2uiSurface, selector: string): Promise { + const child = el.shadowRoot?.querySelector(selector) as any; + if (child?.updateComplete) await child.updateComplete; + return child?.shadowRoot?.innerHTML; + } + + // --- LIT-01: silent root default, preserved slot ------------------------- + + it('LIT-01: root pending renders no visible default text', async () => { + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + + const shadow = el.shadowRoot?.innerHTML ?? ''; + assert.ok(!shadow.includes('Loading surface'), 'No default loading text: ' + shadow); + assert.ok(shadow.includes(''), 'Slot must be preserved: ' + shadow); + }); + + it('LIT-01: a consumer-projected slot="loading" still renders while pending', async () => { + const el = await mountSurface(e => { + e.innerHTML = '
custom-loading
'; + e.surface = surfaceModel; + }); + + const slot = el.shadowRoot?.querySelector('slot[name="loading"]') as any; + assert.ok(slot, 'Named slot element should exist in the shadow root'); + const projected = el.querySelector('[slot="loading"]'); + assert.ok(projected, 'Consumer slot="loading" content should remain projectable'); + assert.strictEqual(projected?.textContent, 'custom-loading'); + // The slot resolves the projected light-DOM child (jsdom supports assignedNodes). + const assigned = slot.assignedNodes?.({flatten: true}) ?? []; + assert.ok( + assigned.includes(projected), + 'Projected content should be assigned to the loading slot', + ); + }); + + // --- LIT-02: nested pending child, no throw, re-render on arrival --------- + + it('LIT-02: a nested pending child renders nothing without throwing', async () => { + updateComponents([{id: 'root', component: 'Column', children: ['child1']}]); + + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + + const columnHtml = await childShadow(el, 'a2ui-basic-column'); + assert.ok(columnHtml !== undefined, 'Column should render (no throw)'); + assert.ok(!columnHtml?.includes('a2ui-basic-text'), 'No child should be present yet'); + }); + + it('LIT-02: re-renders the nested child once it arrives via updateComponents', async () => { + updateComponents([{id: 'root', component: 'Column', children: ['child1']}]); + + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + + // The child streams in later; the targeted onCreated subscription must fire + // and re-render the column (an A2uiLitElement) to resolve the child. + updateComponents([{id: 'child1', component: 'Text', text: 'Arrived'}]); + await el.updateComplete; + + const columnEl = el.shadowRoot?.querySelector('a2ui-basic-column') as any; + await columnEl?.updateComplete; + const textEl = columnEl?.shadowRoot?.querySelector('a2ui-basic-text') as any; + await textEl?.updateComplete; + const textHtml = textEl?.shadowRoot?.innerHTML; + assert.ok( + textHtml?.includes('Arrived'), + 'Nested child should render after arrival (proves onCreated re-subscribe): ' + textHtml, + ); + }); + + // --- LIT-03: consumer fallbacks reach nested children via context -------- + + it('LIT-03: a nested pending child renders the consumer loading fallback via context', async () => { + updateComponents([{id: 'root', component: 'Column', children: ['child1']}]); + + const el = await mountSurface(e => { + e.surface = surfaceModel; + e.fallbacks = {loading: html`
spinning
`}; + }); + + const columnHtml = await childShadow(el, 'a2ui-basic-column'); + assert.ok( + columnHtml?.includes('nested-spin'), + 'Nested loading fallback should render inside the column via @lit/context. Got: ' + + columnHtml, + ); + }); + + it('LIT-03: the loading fallback receives the fallback info payload', async () => { + updateComponents([{id: 'root', component: 'Column', children: ['child1']}]); + + const el = await mountSurface(e => { + e.surface = surfaceModel; + e.fallbacks = { + loading: info => + html`${info.componentId}:${info.parentComponentType}`, + }; + }); + + // Lit interpolates each expression as a separate text node (comment markers + // sit between them), so assert the two values are present rather than the + // concatenated string. + const columnHtml = await childShadow(el, 'a2ui-basic-column'); + assert.ok( + columnHtml?.includes('child1'), + 'componentId should flow through. Got: ' + columnHtml, + ); + assert.ok( + columnHtml?.includes('Column'), + 'parentComponentType should flow through. Got: ' + columnHtml, + ); + }); + + it('LIT-03: an unknown component type renders the consumer unknownComponent fallback', async () => { + const origWarn = console.warn; + console.warn = () => {}; + try { + updateComponents([{id: 'root', component: 'Nope', text: 'x'}]); + + const el = await mountSurface(e => { + e.surface = surfaceModel; + e.fallbacks = { + unknownComponent: info => html`
${info.componentType}
`, + }; + }); + await flushMicrotasks(); + + const shadow = el.shadowRoot?.innerHTML ?? ''; + assert.ok( + shadow.includes('unknown-fb'), + 'unknownComponent fallback should render: ' + shadow, + ); + assert.ok(shadow.includes('Nope'), 'componentType should flow to the fallback: ' + shadow); + } finally { + console.warn = origWarn; + } + }); + + // --- FB-02: structured error dispatch ------------------------------------ + + it('FB-02: dispatches COMPONENT_NOT_FOUND once per unknown type', async () => { + const origWarn = console.warn; + console.warn = () => {}; + const calls: any[] = []; + surfaceModel.dispatchError = (e: any) => { + calls.push(e); + return Promise.resolve(); + }; + try { + updateComponents([{id: 'root', component: 'Nope', text: 'x'}]); + + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + await flushMicrotasks(); + + assert.strictEqual(calls.length, 1, 'Exactly one dispatch for the unknown type'); + assert.strictEqual(calls[0].code, 'COMPONENT_NOT_FOUND'); + assert.strictEqual(calls[0].componentId, 'root'); + assert.strictEqual(calls[0].componentType, 'Nope'); + assert.ok(String(calls[0].message).includes('Nope')); + + // A second render of the same unknown type must not re-dispatch. + await asyncUpdate(el, e => { + e.fallbacks = {loading: html``}; + }); + await el.updateComplete; + await flushMicrotasks(); + + assert.strictEqual(calls.length, 1, 'The warn-once guard must hold across re-renders'); + } finally { + console.warn = origWarn; + } + }); + + it('FB-02: the root context-creation failure dispatches the error state', async () => { + const origWarn = console.warn; + const origError = console.error; + console.warn = () => {}; + console.error = () => {}; + const calls: any[] = []; + surfaceModel.dispatchError = (e: any) => { + calls.push(e); + return Promise.resolve(); + }; + try { + updateComponents([{id: 'root', component: 'Text', text: 'Hello'}]); + + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + + // Remove root while _hasRoot is still true so the render-time + // `new ComponentContext(surface, 'root', ...)` throws (not-found) and hits + // the root catch. + surfaceModel.componentsModel.removeComponent('root'); + el.requestUpdate(); + await el.updateComplete; + await flushMicrotasks(); + + const shadow = el.shadowRoot?.innerHTML ?? ''; + assert.ok( + shadow.includes('Error rendering surface'), + 'Visible error text retained: ' + shadow, + ); + const errorCall = calls.find(c => c.code === 'SURFACE_RENDER_ERROR'); + assert.ok(errorCall, 'An error-state dispatch should occur: ' + JSON.stringify(calls)); + assert.ok('reason' in errorCall, 'The error dispatch should carry the thrown reason'); + } finally { + console.warn = origWarn; + console.error = origError; + } + }); + + // --- WR-04: subscription lifecycle / no-leak invariants ------------------- + + it('WR-04a: disconnectedCallback unsubscribes the pending-child subscription', async () => { + updateComponents([{id: 'root', component: 'Column', children: ['child1']}]); + const spy = spyOnCreated(surfaceModel.componentsModel); + try { + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + const columnEl = el.shadowRoot?.querySelector('a2ui-basic-column') as any; + await columnEl?.updateComplete; + assert.strictEqual(spy.state.active, 1, 'One live subscription for the pending child'); + + el.remove(); + assert.strictEqual( + spy.state.active, + 0, + 'disconnectedCallback must unsubscribe the pending-child subscription', + ); + + // A late arrival must not resurrect a disconnected subscription. + updateComponents([{id: 'child1', component: 'Text', text: 'Late'}]); + await flushMicrotasks(); + assert.strictEqual(spy.state.active, 0, 'No subscription should survive disconnect'); + } finally { + spy.restore(); + } + }); + + it('WR-04b: a pending child yields exactly one subscription across repeated renders', async () => { + updateComponents([{id: 'root', component: 'Column', children: ['child1']}]); + const spy = spyOnCreated(surfaceModel.componentsModel); + try { + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + const columnEl = el.shadowRoot?.querySelector('a2ui-basic-column') as any; + await columnEl?.updateComplete; + + // Re-render the Column repeatedly while child1 is still pending; the Map + // guard must keep this at exactly one subscription per child id. + for (let i = 0; i < 3; i++) { + columnEl.requestUpdate(); + await columnEl.updateComplete; + } + + assert.strictEqual( + spy.state.total, + 1, + 'Exactly one onCreated subscription per pending child id (no double-subscribe)', + ); + } finally { + spy.restore(); + } + }); + + it('WR-04c: rebinding to a new surface does not leak the old surface subscription', async () => { + updateComponents([{id: 'root', component: 'Column', children: ['child1']}]); + const surfaceB = makeSurface('surface-b', [ + {id: 'root', component: 'Column', children: ['child1']}, + ]); + const spyA = spyOnCreated(surfaceModel.componentsModel); + const spyB = spyOnCreated(surfaceB.componentsModel); + try { + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + let columnEl = el.shadowRoot?.querySelector('a2ui-basic-column') as any; + await columnEl?.updateComplete; + assert.strictEqual( + spyA.state.active, + 1, + 'Column subscribes to surface A while child pending', + ); + + // Rebind the reused element to surface B. willUpdate must clear the stale + // #childSubs before resubscribing, or surface A retains a live subscription. + await asyncUpdate(el, e => { + e.surface = surfaceB; + }); + await el.updateComplete; + columnEl = el.shadowRoot?.querySelector('a2ui-basic-column') as any; + await columnEl?.updateComplete; + + assert.strictEqual( + spyA.state.active, + 0, + 'No stale subscription must survive on the old surface (WR-01)', + ); + assert.strictEqual(spyB.state.active, 1, 'The child subscription moves to the new surface'); + } finally { + spyA.restore(); + spyB.restore(); + } + }); + + it('WR-04d: a second surface root failure re-arms the error dispatch', async () => { + const origWarn = console.warn; + const origError = console.error; + console.warn = () => {}; + console.error = () => {}; + const calls: any[] = []; + surfaceModel.dispatchError = (e: any) => { + calls.push({s: 'A', ...e}); + return Promise.resolve(); + }; + const surfaceB = makeSurface('surface-b', [{id: 'root', component: 'Text', text: 'B'}]); + surfaceB.dispatchError = (e: any) => { + calls.push({s: 'B', ...e}); + return Promise.resolve(); + }; + try { + updateComponents([{id: 'root', component: 'Text', text: 'Hello'}]); + const el = await mountSurface(e => { + e.surface = surfaceModel; + }); + + // Fail surface A's root render so the error dispatch fires for A. + surfaceModel.componentsModel.removeComponent('root'); + el.requestUpdate(); + await el.updateComplete; + await flushMicrotasks(); + assert.ok( + calls.some(c => c.s === 'A' && c.code === 'SURFACE_RENDER_ERROR'), + 'Surface A root failure should dispatch: ' + JSON.stringify(calls), + ); + + // Rebind to surface B (root present, renders fine). + await asyncUpdate(el, e => { + e.surface = surfaceB; + }); + await el.updateComplete; + + // Fail surface B's root render. A per-element boolean guard would stay + // latched from surface A and suppress this; a surface-keyed guard re-arms. + surfaceB.componentsModel.removeComponent('root'); + el.requestUpdate(); + await el.updateComplete; + await flushMicrotasks(); + + assert.ok( + calls.some(c => c.s === 'B' && c.code === 'SURFACE_RENDER_ERROR'), + 'A second surface root failure must re-arm and dispatch (WR-02): ' + JSON.stringify(calls), + ); + } finally { + console.warn = origWarn; + console.error = origError; + } + }); +}); diff --git a/renderers/react/CHANGELOG.md b/renderers/react/CHANGELOG.md index 0741f6b4a0..d7887d719e 100644 --- a/renderers/react/CHANGELOG.md +++ b/renderers/react/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- (v0_9) Render nothing by default for pending and unknown components instead of showing debug placeholders (`[Loading {id}...]`, `Unknown component: {type}`); unknown component types now log a `console.warn` and report a structured `COMPONENT_NOT_FOUND` error via `surface.dispatchError`. Add an optional `fallbacks` prop to `A2uiSurface` for consumer-provided loading/unknown-component fallbacks, delivered via context to nested children ([#2013](https://github.com/a2ui-project/a2ui/issues/2013)). + ## 0.10.2 - (v0_9) Normalize Safari placeholder text color for `DateTimeInput` by injecting WebKit-specific styles via a global stylesheet and adding the `.a2ui-date-time-input` class. diff --git a/renderers/react/README.md b/renderers/react/README.md index 36dce0c6bc..0415a40c32 100644 --- a/renderers/react/README.md +++ b/renderers/react/README.md @@ -113,6 +113,26 @@ Running this example should display "Hello from A2UI!". The example demonstrates - [`updateComponents`](../../specification/v0_9/docs/a2ui_protocol.md#updatecomponents) defines the UI tree. Here, a `Column` containing two `Text` components. - [`updateDataModel`](../../specification/v0_9/docs/a2ui_protocol.md#updatedatamodel) provides the data that the components reference via [`path` bindings](../../specification/v0_9/docs/a2ui_protocol.md#path-resolution--scope) (e.g. `{ path: '/title' }` resolves to the `title` field in the data model). +## Custom Fallbacks + +By default the renderer shows nothing while a component is still streaming (`loading`) or when a component's type has no catalog implementation (`unknownComponent`). Pass an optional `fallbacks` prop to `A2uiSurface` to supply your own UI for either state. A fallback is a React node or a function of the fallback info, and it reaches nested children through context. + +```tsx +import {A2uiSurface, type A2uiFallbacks} from '@a2ui/react/v0_9'; + +// Hoist the object so its identity is stable across renders. +const fallbacks: A2uiFallbacks = { + loading: Loading..., + unknownComponent: info => Unsupported component: {info.componentType}, +}; + +function Chat({surface}) { + return ; +} +``` + +The `info` argument is a discriminated union keyed on `state`: the `loading` form carries `componentId` and an optional `parentComponentType`, while the `unknownComponent` form additionally carries a required `componentType`. + ## Defining Custom Components A2UI v0.9 strictly separates a component's API (Schema) from its implementation. diff --git a/renderers/react/src/v0_9/A2uiSurface.tsx b/renderers/react/src/v0_9/A2uiSurface.tsx index e3015671db..892f8db357 100644 --- a/renderers/react/src/v0_9/A2uiSurface.tsx +++ b/renderers/react/src/v0_9/A2uiSurface.tsx @@ -14,9 +14,29 @@ * limitations under the License. */ -import React, {useSyncExternalStore, memo, useMemo, useCallback} from 'react'; -import {type SurfaceModel, ComponentContext, type ComponentModel} from '@a2ui/web_core/v0_9'; +import React, {useSyncExternalStore, memo, useMemo, useCallback, useRef, useEffect} from 'react'; +import { + type SurfaceModel, + ComponentContext, + type ComponentModel, + type A2uiFallbackInfo, + type A2uiFallbackState, +} from '@a2ui/web_core/v0_9'; import type {ReactComponentImplementation} from './adapter'; +import { + FallbackContext, + useFallbacks, + type A2uiFallbacks, + type A2uiFallbackRenderer, +} from './FallbackContext'; + +function renderFallback( + renderer: A2uiFallbackRenderer | undefined, + info: Extract, +): React.ReactNode { + if (renderer === undefined) return null; + return typeof renderer === 'function' ? renderer(info) : renderer; +} const ResolvedChild = memo( ({ @@ -51,10 +71,11 @@ const ResolvedChild = memo( surface={surface} id={childId} basePath={path} + parentComponentType={componentModel.type} /> ); }, - [surface, context.dataContext.path], + [surface, context.dataContext.path, componentModel.type], ); return ; @@ -66,7 +87,17 @@ export const DeferredChild: React.FC<{ surface: SurfaceModel; id: string; basePath: string; -}> = memo(({surface, id, basePath}) => { + /** The `type` of the instantiating parent. Absent at the surface root. */ + parentComponentType?: string; +}> = memo(({surface, id, basePath, parentComponentType}) => { + const fallbacks = useFallbacks(); + + // Tracks every unknown type warned about, so re-renders (including + // StrictMode double-invokes) and type flaps (Nope -> Nope2 -> Nope) do + // not repeat the warning for this mount. Lazily initialized so a new Set + // is not allocated on every render. + const warnedTypesRef = useRef | null>(null); + // 1. Subscribe specifically to this component's existence const store = useMemo(() => { let version = 0; @@ -101,15 +132,45 @@ export const DeferredChild: React.FC<{ useSyncExternalStore(store.subscribe, store.getSnapshot); const componentModel = surface.componentsModel.get(id); + const compImpl = componentModel ? surface.catalog.components.get(componentModel.type) : undefined; + + // The resolved unknown type, or undefined when the component is pending or + // resolves to a known implementation. Keys the warn/dispatch effect so a + // change in the unknown type re-runs it. + const unknownComponentType = componentModel && !compImpl ? componentModel.type : undefined; + + // Warn + dispatch as a post-commit effect so render stays pure. The Set gate + // keeps it once-per-distinct-unknown-type for this mount, immune to type + // flaps (Nope -> Nope2 -> Nope) and StrictMode double-invokes. + useEffect(() => { + if (unknownComponentType === undefined) return; + const warnedTypes = (warnedTypesRef.current ??= new Set()); + if (warnedTypes.has(unknownComponentType)) return; + warnedTypes.add(unknownComponentType); + console.warn(`Component implementation not found for type: ${unknownComponentType}`); + void surface.dispatchError({ + code: 'COMPONENT_NOT_FOUND', + message: `Component implementation not found for type: ${unknownComponentType}`, + componentId: id, + componentType: unknownComponentType, + }); + }, [surface, id, unknownComponentType]); if (!componentModel) { - return
[Loading {id}...]
; + return renderFallback(fallbacks?.loading, { + state: 'loading', + componentId: id, + ...(parentComponentType !== undefined ? {parentComponentType} : {}), + }); } - const compImpl = surface.catalog.components.get(componentModel.type); - if (!compImpl) { - return
Unknown component: {componentModel.type}
; + return renderFallback(fallbacks?.unknownComponent, { + state: 'unknownComponent', + componentId: id, + componentType: componentModel.type, + ...(parentComponentType !== undefined ? {parentComponentType} : {}), + }); } return ( @@ -124,9 +185,19 @@ export const DeferredChild: React.FC<{ }); DeferredChild.displayName = 'DeferredChild'; -export const A2uiSurface: React.FC<{surface: SurfaceModel}> = ({ - surface, -}) => { +export const A2uiSurface: React.FC<{ + surface: SurfaceModel; + /** + * Optional fallbacks for the `loading` and `unknownComponent` states, + * delivered via context to nested children. Hoist or memoize this object — + * a new identity on every render re-evaluates every pending child. + */ + fallbacks?: A2uiFallbacks; +}> = ({surface, fallbacks}) => { // The root component always has ID 'root' and base path '/' - return ; + return ( + + + + ); }; diff --git a/renderers/react/src/v0_9/FallbackContext.tsx b/renderers/react/src/v0_9/FallbackContext.tsx new file mode 100644 index 0000000000..759ce10116 --- /dev/null +++ b/renderers/react/src/v0_9/FallbackContext.tsx @@ -0,0 +1,34 @@ +/** + * 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 {createContext, useContext} from 'react'; +import type React from 'react'; +import type {A2uiFallbackInfo, A2uiFallbackState} from '@a2ui/web_core/v0_9'; + +/** A fallback value: a static node, or a function of the state's fallback info. */ +export type A2uiFallbackRenderer = + | React.ReactNode + | ((info: Extract) => React.ReactNode); + +/** Consumer-provided fallbacks, one per shared fallback state. */ +export interface A2uiFallbacks { + loading?: A2uiFallbackRenderer<'loading'>; + unknownComponent?: A2uiFallbackRenderer<'unknownComponent'>; +} + +export const FallbackContext = createContext(undefined); + +export const useFallbacks = () => useContext(FallbackContext); diff --git a/renderers/react/src/v0_9/index.ts b/renderers/react/src/v0_9/index.ts index 31c37ce9fa..9f92c3134b 100644 --- a/renderers/react/src/v0_9/index.ts +++ b/renderers/react/src/v0_9/index.ts @@ -16,6 +16,7 @@ export * from './A2uiSurface'; export * from './adapter'; +export * from './FallbackContext'; // Export basic catalog components directly for 3P developers export * from './catalog/basic'; diff --git a/renderers/react/tests/v0_9/adapter.test.tsx b/renderers/react/tests/v0_9/adapter.test.tsx index 96e952e03d..cf1dcfa246 100644 --- a/renderers/react/tests/v0_9/adapter.test.tsx +++ b/renderers/react/tests/v0_9/adapter.test.tsx @@ -163,8 +163,8 @@ describe('adapter', () => { const {getByTestId, queryByTestId} = render(); - // Assert the missing child renders the fallback - expect(getByTestId('parent').textContent).toContain('[Loading child1...]'); + // Assert the missing child renders nothing by default + expect(getByTestId('parent').textContent).toBe(''); const countBeforeChild = parentRenderCount; diff --git a/renderers/react/tests/v0_9/fallbacks.test.tsx b/renderers/react/tests/v0_9/fallbacks.test.tsx new file mode 100644 index 0000000000..99317de38e --- /dev/null +++ b/renderers/react/tests/v0_9/fallbacks.test.tsx @@ -0,0 +1,333 @@ +/** + * 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 {describe, it, expect, vi} from 'vitest'; +import {render, act} from '@testing-library/react'; +import {createComponentImplementation} from '../../src/v0_9/adapter'; +import {A2uiSurface} from '../../src/v0_9/A2uiSurface'; +import { + ComponentModel, + SurfaceModel, + Catalog, + CommonSchemas, + type A2uiFallbackInfo, +} from '@a2ui/web_core/v0_9'; +import {z} from 'zod'; + +const ParentApiDef = { + name: 'TestParent', + schema: z.object({child: CommonSchemas.ComponentId}), +}; +const ChildApiDef = { + name: 'TestChild', + schema: z.object({text: CommonSchemas.DynamicString}), +}; + +/** Parent references a missing `child1` — the nested-pending harness from adapter.test.tsx. */ +function createParentChildSurface() { + const TestParent = createComponentImplementation(ParentApiDef, ({props, buildChild}) => ( +
{props.child && buildChild(props.child)}
+ )); + const TestChild = createComponentImplementation(ChildApiDef, ({props}) => ( + {props.text} + )); + const catalog = new Catalog('test', [TestParent, TestChild], []); + const surface = new SurfaceModel('test-surface', catalog); + surface.componentsModel.addComponent(new ComponentModel('root', 'TestParent', {child: 'child1'})); + return surface; +} + +describe('fallbacks', () => { + it('renders nothing by default while pending', () => { + const surface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + + const {container} = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing by default for unknown type', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const surface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + surface.componentsModel.addComponent(new ComponentModel('root', 'Nope', {})); + + const {container} = render(); + + expect(container.firstChild).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith('Component implementation not found for type: Nope'); + warnSpy.mockRestore(); + }); + + it('renders consumer fallback for pending (root)', () => { + const surface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + + const {getByTestId} = render( + }} />, + ); + + expect(getByTestId('spin')).toBeInTheDocument(); + }); + + it('renders consumer fallback for pending nested child via context', () => { + const surface = createParentChildSurface(); + + const {getByTestId} = render( + }} />, + ); + + // The fallback appears INSIDE the parent — context crossed the buildChild recursion. + expect(getByTestId('parent')).toContainElement(getByTestId('nested-spin')); + }); + + it('function-form receives the fallback info payload', () => { + // loading: componentId flows through. + const pendingSurface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + const {getByTestId} = render( + {info.componentId}}} + />, + ); + expect(getByTestId('loading-info').textContent).toBe('root'); + + // unknownComponent: componentType flows through (required in the union arm). + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const unknownSurface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + unknownSurface.componentsModel.addComponent(new ComponentModel('root', 'Nope', {})); + const unknown = render( + {info.componentType}, + }} + />, + ); + expect(unknown.getByTestId('unknown-info').textContent).toBe('Nope'); + warnSpy.mockRestore(); + }); + + it('clears the fallback when the component arrives', async () => { + const surface = createParentChildSurface(); + + const {getByTestId, queryByTestId} = render( + }} />, + ); + + expect(queryByTestId('nested-spin')).not.toBeNull(); + + await act(async () => { + surface.componentsModel.addComponent( + new ComponentModel('child1', 'TestChild', {text: 'Loaded Data'}), + ); + }); + + expect(queryByTestId('nested-spin')).toBeNull(); + expect(getByTestId('resolved').textContent).toBe('Loaded Data'); + }); + + it('a new fallbacks value updates a pending child', () => { + const surface = createParentChildSurface(); + + const {rerender, queryByTestId} = render( + }} />, + ); + + expect(queryByTestId('a')).not.toBeNull(); + + rerender(}} />); + + expect(queryByTestId('a')).toBeNull(); + expect(queryByTestId('b')).not.toBeNull(); + }); + + it('providing fallbacks does not re-render resolved children', () => { + let parentRenderCount = 0; + let childRenderCount = 0; + + const TestParent = createComponentImplementation(ParentApiDef, ({props, buildChild}) => { + parentRenderCount++; + return
{props.child && buildChild(props.child)}
; + }); + const TestChild = createComponentImplementation(ChildApiDef, ({props}) => { + childRenderCount++; + return {props.text}; + }); + + const catalog = new Catalog('test', [TestParent, TestChild], []); + const surface = new SurfaceModel('test-surface', catalog); + surface.componentsModel.addComponent( + new ComponentModel('root', 'TestParent', {child: 'child1'}), + ); + surface.componentsModel.addComponent( + new ComponentModel('child1', 'TestChild', {text: 'Loaded Data'}), + ); + + const {getByTestId, rerender} = render( + }} />, + ); + + expect(getByTestId('resolved').textContent).toBe('Loaded Data'); + const parentCountBefore = parentRenderCount; + const childCountBefore = childRenderCount; + + // A NEW fallbacks object identity re-evaluates DeferredChild readers only; + // resolved subtrees are skipped by ResolvedChild's memo. + rerender( + }} />, + ); + + expect(parentRenderCount).toBe(parentCountBefore); + expect(childRenderCount).toBe(childCountBefore); + }); + + it('dispatches COMPONENT_NOT_FOUND once per warned type', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const surface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + surface.componentsModel.addComponent(new ComponentModel('root', 'Nope', {})); + const dispatchSpy = vi.spyOn(surface, 'dispatchError'); + + const {rerender} = render( + }} />, + ); + + // The dispatch runs in a post-commit effect, out of the render phase. + await act(async () => {}); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'COMPONENT_NOT_FOUND', + componentId: 'root', + componentType: 'Nope', + message: expect.stringContaining('Nope'), + }), + ); + + // A NEW fallbacks object identity forces DeferredChild to re-render; the + // dispatch shares the warn-once guard, so the count must not grow. + rerender(}} />); + + await act(async () => {}); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + dispatchSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('dispatches again for a second distinct unknown type on the same mount', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const surface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + surface.componentsModel.addComponent(new ComponentModel('root', 'Nope', {})); + const dispatchSpy = vi.spyOn(surface, 'dispatchError'); + + render(); + await act(async () => {}); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + + // Replace root with a DIFFERENT unknown type: a once-EVER guard (e.g. a + // boolean ref) would silence it; the per-type contract requires a dispatch. + await act(async () => { + surface.componentsModel.removeComponent('root'); + surface.componentsModel.addComponent(new ComponentModel('root', 'Nope2', {})); + }); + + expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenLastCalledWith( + expect.objectContaining({code: 'COMPONENT_NOT_FOUND', componentType: 'Nope2'}), + ); + dispatchSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('a type flap dispatches once per distinct unknown type', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const surface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + surface.componentsModel.addComponent(new ComponentModel('root', 'Nope', {})); + const dispatchSpy = vi.spyOn(surface, 'dispatchError'); + + render(); + + const replaceRoot = (type: string) => + act(async () => { + surface.componentsModel.removeComponent('root'); + surface.componentsModel.addComponent(new ComponentModel('root', type, {})); + }); + + // Nope -> Nope2 -> Nope: returning to an already-warned type must not + // re-dispatch, no matter how often the agent flaps between the two. + await replaceRoot('Nope2'); + await replaceRoot('Nope'); + await act(async () => {}); + + expect(dispatchSpy).toHaveBeenCalledTimes(2); + const dispatchedTypes = dispatchSpy.mock.calls.map(([payload]) => payload['componentType']); + expect(dispatchedTypes).toEqual(['Nope', 'Nope2']); + dispatchSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('passes parentComponentType to nested loading fallbacks', () => { + const surface = createParentChildSurface(); + + const {getByTestId} = render( + {info.parentComponentType}}} + />, + ); + + expect(getByTestId('pinfo').textContent).toBe('TestParent'); + }); + + it('omits parentComponentType at the root', () => { + const surface = new SurfaceModel('test-surface', new Catalog('test', [], [])); + let captured: A2uiFallbackInfo | undefined; + + render( + { + captured = info; + return null; + }, + }} + />, + ); + + expect(captured).toBeDefined(); + // The key must be ABSENT at the root, not present with an undefined value. + expect('parentComponentType' in captured!).toBe(false); + }); + + it('passes parentComponentType to nested unknownComponent fallbacks', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const surface = createParentChildSurface(); + surface.componentsModel.addComponent(new ComponentModel('child1', 'Nope', {})); + + const {getByTestId} = render( + ( + {`${info.parentComponentType}:${info.componentType}`} + ), + }} + />, + ); + + expect(getByTestId('uinfo').textContent).toBe('TestParent:Nope'); + warnSpy.mockRestore(); + }); +}); diff --git a/renderers/web_core/CHANGELOG.md b/renderers/web_core/CHANGELOG.md index 474d832566..4312b0dd19 100644 --- a/renderers/web_core/CHANGELOG.md +++ b/renderers/web_core/CHANGELOG.md @@ -2,6 +2,7 @@ - (v0_9) Add prototype pollution protection and safe property lookup to `DataModel` (non-breaking security fix). - (v0_8) Export `A2uiMessageSchema` in public API. +- (v0_9) Add shared renderer fallback contract types (`A2uiFallbackState`, `A2uiFallbackInfo`) covering the `loading`, `unknownComponent`, and `error` states, with an optional `parentComponentType` hint from the instantiating parent, for consistent fallback handling across framework renderers ([#2013](https://github.com/a2ui-project/a2ui/issues/2013)). ## 0.10.5 diff --git a/renderers/web_core/README.md b/renderers/web_core/README.md index 41d1441755..628d4936a2 100644 --- a/renderers/web_core/README.md +++ b/renderers/web_core/README.md @@ -54,6 +54,10 @@ Generate coverage reports: yarn test:coverage ``` +## Fallback Contract + +`@a2ui/web_core/v0_9` exports the shared vocabulary that every framework renderer uses to describe a component it cannot render yet. Two states are shared across renderers: `loading` (the component id is referenced but has not streamed in) and `unknownComponent` (the component's type has no catalog implementation). `A2uiFallbackInfo` is a discriminated union keyed on `A2uiFallbackState`, carrying `componentId` on every arm plus a required `componentType` on the `unknownComponent` arm. Renderers render nothing by default in both consumer-facing states, and each offers a framework-idiomatic way to override that. See the React, Angular, and Lit renderer READMEs for the per-framework API. + ## Legal Notice This library is part of the A2UI project. Please refer to the top-level documentation for security considerations and developer responsibilities when handling untrusted agent data. diff --git a/renderers/web_core/src/v0_9/index.ts b/renderers/web_core/src/v0_9/index.ts index db2b165fce..c5eb630a9d 100644 --- a/renderers/web_core/src/v0_9/index.ts +++ b/renderers/web_core/src/v0_9/index.ts @@ -27,6 +27,7 @@ export * from './common/events.js'; export * from './processing/message-processor.js'; export * from './rendering/component-context.js'; export * from './rendering/data-context.js'; +export * from './rendering/fallback.js'; export * from './rendering/generic-binder.js'; export * from './schema/index.js'; export * from './state/component-model.js'; diff --git a/renderers/web_core/src/v0_9/rendering/fallback.ts b/renderers/web_core/src/v0_9/rendering/fallback.ts new file mode 100644 index 0000000000..8061b1e0bd --- /dev/null +++ b/renderers/web_core/src/v0_9/rendering/fallback.ts @@ -0,0 +1,71 @@ +/* + * 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 + * + * https://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. + */ + +/** + * Shared fallback contract for A2UI framework renderers (React, Lit, Angular). + * + * Renderers encounter three states in which a component cannot be rendered: + * + * - `loading`: a component id is referenced by its parent but the component + * has not yet been streamed to the client (`SurfaceComponentsModel.get` + * returns `undefined`). The component may arrive in a later + * `updateComponents` message. + * - `unknownComponent`: the component model exists, but its `type` has no + * implementation in the surface's catalog (`catalog.components.get` + * returns `undefined`). + * - `error`: a runtime exception occurred while rendering (for example, a + * failing function call). `reason` carries the thrown value. + * + * Renderers MUST render nothing by default in these states, and MAY offer a + * framework-idiomatic mechanism for consumers to provide custom fallback UI. + * These types define the shared vocabulary for those mechanisms. Renderers + * SHOULD report `unknownComponent` and `error` to the agent via + * `SurfaceModel.dispatchError` with a structured code. + */ + +/** Information describing why a fallback is being rendered. */ +export type A2uiFallbackInfo = + | { + /** The component is referenced but has not yet been streamed to the client. */ + state: 'loading'; + /** The id of the component that could not be rendered. */ + componentId: string; + /** The `type` of the component that instantiated this one. Absent at the surface root. */ + parentComponentType?: string; + } + | { + /** The component's `type` has no implementation in the surface's catalog. */ + state: 'unknownComponent'; + /** The id of the component that could not be rendered. */ + componentId: string; + /** The component's declared type. */ + componentType: string; + /** The `type` of the component that instantiated this one. Absent at the surface root. */ + parentComponentType?: string; + } + | { + /** A runtime exception occurred while rendering the component. */ + state: 'error'; + /** The id of the component that could not be rendered. */ + componentId: string; + /** The thrown value. If it is an `Error`, the stack is available on it. */ + reason: unknown; + /** The `type` of the component that instantiated this one. Absent at the surface root. */ + parentComponentType?: string; + }; + +/** The fallback states shared across all A2UI framework renderers. */ +export type A2uiFallbackState = A2uiFallbackInfo['state'];