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}>${tag}>`;
}
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'];