diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 9747e167b..c7a7e6242 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1072,7 +1072,7 @@ }, { "name": "catalog", - "type": "InputSignal>>>", + "type": "InputSignal | RenderViewEntry>>>", "description": "", "optional": false }, @@ -1673,7 +1673,7 @@ }, { "name": "views", - "type": "InputSignal>> | undefined>", + "type": "InputSignal | RenderViewEntry>> | undefined>", "description": "", "optional": false }, @@ -2801,7 +2801,7 @@ }, { "name": "views", - "type": "InputSignal>> | undefined>", + "type": "InputSignal | RenderViewEntry>> | undefined>", "description": "A2UI component catalog forwarded to the inner . Without it,\nmessages classified as A2UI parse correctly but never mount a\nsurface. Pass `a2uiBasicCatalog()` from `@ngaf/chat`.", "optional": false } @@ -3063,7 +3063,7 @@ }, { "name": "views", - "type": "InputSignal>> | undefined>", + "type": "InputSignal | RenderViewEntry>> | undefined>", "description": "A2UI component catalog forwarded to the inner . Without it,\nmessages classified as A2UI parse correctly but never mount a\nsurface. Pass `a2uiBasicCatalog()` from `@ngaf/chat`.", "optional": false } @@ -3104,7 +3104,7 @@ }, { "name": "resolvedRegistry", - "type": "Signal>>>", + "type": "Signal | RenderViewEntry>>>", "description": "", "optional": false }, @@ -3122,7 +3122,7 @@ }, { "name": "viewRegistry", - "type": "InputSignal>> | undefined>", + "type": "InputSignal | RenderViewEntry>> | undefined>", "description": "", "optional": false } @@ -5586,8 +5586,8 @@ { "name": "ViewRegistry", "kind": "type", - "description": "A registry of view components available for generative UI rendering.\nPlain frozen object mapping view names to Angular component types.\nCompose via object spread: `views({ ...base, ...more })`.", - "signature": "Readonly>>", + "description": "A registry of view components available for generative UI rendering.\nEach entry is either a bare component Type (legacy shape) or a\n`RenderViewEntry` { component, fallback? }.", + "signature": "Readonly | RenderViewEntry>>", "examples": [] }, { @@ -6141,11 +6141,11 @@ "name": "views", "kind": "function", "description": "Creates a view registry from a name → component map.", - "signature": "views(map: Record>): ViewRegistry", + "signature": "views(map: Record | RenderViewEntry>): ViewRegistry", "params": [ { "name": "map", - "type": "Record>", + "type": "Record | RenderViewEntry>", "description": "", "optional": false } @@ -6185,7 +6185,7 @@ "name": "withViews", "kind": "function", "description": "Adds views to a registry without overwriting existing entries.\nNew keys are added; keys that already exist in `base` are preserved.", - "signature": "withViews(base: ViewRegistry, additions: Record>): ViewRegistry", + "signature": "withViews(base: ViewRegistry, additions: Record | RenderViewEntry>): ViewRegistry", "params": [ { "name": "base", @@ -6195,7 +6195,7 @@ }, { "name": "additions", - "type": "Record>", + "type": "Record | RenderViewEntry>", "description": "", "optional": false } diff --git a/apps/website/content/docs/render/api/api-docs.json b/apps/website/content/docs/render/api/api-docs.json index 63e1ce0c6..8b5e05fc6 100644 --- a/apps/website/content/docs/render/api/api-docs.json +++ b/apps/website/content/docs/render/api/api-docs.json @@ -1,4 +1,13 @@ [ + { + "name": "DefaultFallbackComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [], + "methods": [] + }, { "name": "RenderElementComponent", "kind": "class", @@ -24,6 +33,18 @@ "description": "", "optional": false }, + { + "name": "mountClass", + "type": "Signal", + "description": "Picks fallback or real based on notReady. The mountedReal latch is\n driven by a constructor effect (not this computed) — Angular forbids\n signal writes inside computed.", + "optional": false + }, + { + "name": "notReady", + "type": "Signal", + "description": "True when ANY resolved prop value is undefined (i.e. a state\n binding points at a path the store hasn't populated). Framework-\n injected keys (bindings, emit, loading, childKeys, spec) are\n excluded — only consumer-resolved props matter for readiness.", + "optional": false + }, { "name": "parentInjector", "type": "Injector<>", @@ -186,6 +207,12 @@ "description": "", "optional": false }, + { + "name": "getFallback", + "type": "unknown", + "description": "", + "optional": false + }, { "name": "names", "type": "unknown", @@ -391,6 +418,26 @@ ], "examples": [] }, + { + "name": "RenderViewEntry", + "kind": "interface", + "description": "A view registry entry. Bare `Type` form is the legacy shape; the\nobject form lets consumers attach a per-component fallback that\nmounts while any state-bound prop on the element is still\nunresolved. The fallback is monotonic per element instance: once\nthe real component mounts, subsequent re-renders never revert to\nfallback even if a prop later resolves to undefined.", + "properties": [ + { + "name": "component", + "type": "Type", + "description": "", + "optional": false + }, + { + "name": "fallback", + "type": "Type", + "description": "", + "optional": true + } + ], + "examples": [] + }, { "name": "RepeatScope", "kind": "interface", @@ -434,19 +481,19 @@ { "name": "ViewRegistry", "kind": "type", - "description": "A registry of view components available for generative UI rendering.\nPlain frozen object mapping view names to Angular component types.\nCompose via object spread: `views({ ...base, ...more })`.", - "signature": "Readonly>>", + "description": "A registry of view components available for generative UI rendering.\nEach entry is either a bare component Type (legacy shape) or a\n`RenderViewEntry` { component, fallback? }.", + "signature": "Readonly | RenderViewEntry>>", "examples": [] }, { "name": "defineAngularRegistry", "kind": "function", "description": "", - "signature": "defineAngularRegistry(componentMap: Record): AngularRegistry", + "signature": "defineAngularRegistry(componentMap: RegistryInput): AngularRegistry", "params": [ { "name": "componentMap", - "type": "Record", + "type": "RegistryInput", "description": "", "optional": false } @@ -537,11 +584,11 @@ "name": "views", "kind": "function", "description": "Creates a view registry from a name → component map.", - "signature": "views(map: Record>): ViewRegistry", + "signature": "views(map: Record | RenderViewEntry>): ViewRegistry", "params": [ { "name": "map", - "type": "Record>", + "type": "Record | RenderViewEntry>", "description": "", "optional": false } @@ -581,7 +628,7 @@ "name": "withViews", "kind": "function", "description": "Adds views to a registry without overwriting existing entries.\nNew keys are added; keys that already exist in `base` are preserved.", - "signature": "withViews(base: ViewRegistry, additions: Record>): ViewRegistry", + "signature": "withViews(base: ViewRegistry, additions: Record | RenderViewEntry>): ViewRegistry", "params": [ { "name": "base", @@ -591,7 +638,7 @@ }, { "name": "additions", - "type": "Record>", + "type": "Record | RenderViewEntry>", "description": "", "optional": false } diff --git a/libs/chat/src/lib/markdown/markdown-children.component.ts b/libs/chat/src/lib/markdown/markdown-children.component.ts index 1a5ceffa8..2b0213c93 100644 --- a/libs/chat/src/lib/markdown/markdown-children.component.ts +++ b/libs/chat/src/lib/markdown/markdown-children.component.ts @@ -48,6 +48,9 @@ export class MarkdownChildrenComponent { }); protected resolve(child: MarkdownNode): Type | null { - return this.registry[child.type] ?? null; + const entry = this.registry[child.type]; + if (!entry) return null; + // ViewRegistry entries are either a bare Type or { component, fallback? }. + return typeof entry === 'function' ? entry : entry.component; } } diff --git a/libs/render/eslint.config.mjs b/libs/render/eslint.config.mjs index 8aef7b347..d578e100d 100644 --- a/libs/render/eslint.config.mjs +++ b/libs/render/eslint.config.mjs @@ -10,7 +10,7 @@ export default [ 'error', { ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], - ignoredDependencies: ['vite', '@nx/vite'], + ignoredDependencies: ['vite', '@nx/vite', '@analogjs/vite-plugin-angular'], }, ], }, diff --git a/libs/render/src/lib/default-fallback.component.spec.ts b/libs/render/src/lib/default-fallback.component.spec.ts new file mode 100644 index 000000000..eddf858f3 --- /dev/null +++ b/libs/render/src/lib/default-fallback.component.spec.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { DefaultFallbackComponent } from './default-fallback.component'; + +describe('DefaultFallbackComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [DefaultFallbackComponent] })); + + it('renders a region role with the Building UI status text', () => { + const fx = TestBed.createComponent(DefaultFallbackComponent); + fx.detectChanges(); + const status = fx.nativeElement.querySelector('[role="status"]'); + expect(status).toBeTruthy(); + expect(status.textContent).toContain('Building UI'); + }); + + it('renders three shimmer rows', () => { + const fx = TestBed.createComponent(DefaultFallbackComponent); + fx.detectChanges(); + const rows = fx.nativeElement.querySelectorAll('.render-default-fallback__row'); + expect(rows.length).toBe(3); + }); +}); diff --git a/libs/render/src/lib/default-fallback.component.ts b/libs/render/src/lib/default-fallback.component.ts new file mode 100644 index 000000000..59f789492 --- /dev/null +++ b/libs/render/src/lib/default-fallback.component.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'render-default-fallback', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [` + :host { display: block; width: 100%; } + .render-default-fallback { + border: 1px solid var(--ngaf-chat-separator, #303540); + border-radius: 10px; + padding: 14px; + background: var(--ngaf-chat-surface-alt, #1a1d23); + } + .render-default-fallback__label { + font-size: 12px; + color: var(--ngaf-chat-text-muted, #9aa0aa); + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 6px; + } + .render-default-fallback__rows { + display: flex; flex-direction: column; gap: 8px; + } + .render-default-fallback__row { + height: 10px; border-radius: 5px; + background: linear-gradient( + 90deg, + var(--ngaf-chat-separator, #303540) 0%, + color-mix(in srgb, var(--ngaf-chat-separator, #303540) 70%, transparent) 50%, + var(--ngaf-chat-separator, #303540) 100% + ); + background-size: 200% 100%; + animation: render-default-fallback-shimmer 1.4s ease-in-out infinite; + } + .render-default-fallback__row:nth-child(1) { width: 70%; } + .render-default-fallback__row:nth-child(2) { width: 90%; } + .render-default-fallback__row:nth-child(3) { width: 50%; } + @keyframes render-default-fallback-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + `], + template: ` +
+
+ + Building UI… +
+
+
+
+
+
+
+ `, +}) +export class DefaultFallbackComponent {} diff --git a/libs/render/src/lib/define-angular-registry.spec.ts b/libs/render/src/lib/define-angular-registry.spec.ts index 4bf0517e1..67ea60ab9 100644 --- a/libs/render/src/lib/define-angular-registry.spec.ts +++ b/libs/render/src/lib/define-angular-registry.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { Component } from '@angular/core'; import { defineAngularRegistry } from './define-angular-registry'; +import { DefaultFallbackComponent } from './default-fallback.component'; @Component({ selector: 'render-test-card', standalone: true, template: '
card
' }) class TestCardComponent {} @@ -9,6 +10,12 @@ class TestCardComponent {} @Component({ selector: 'render-test-button', standalone: true, template: '' }) class TestButtonComponent {} +@Component({ standalone: true, template: 'real' }) +class FakeRealComponent {} + +@Component({ standalone: true, template: 'fallback' }) +class FakeFallbackComponent {} + describe('defineAngularRegistry', () => { it('should create a registry mapping component names to Angular components', () => { const registry = defineAngularRegistry({ @@ -32,3 +39,39 @@ describe('defineAngularRegistry', () => { expect(registry.names()).toEqual(['Card', 'Button']); }); }); + +describe('defineAngularRegistry — fallback API', () => { + it('bare type entry: get returns the type; getFallback returns the default', () => { + const reg = defineAngularRegistry({ button: FakeRealComponent }); + expect(reg.get('button')).toBe(FakeRealComponent); + expect(reg.getFallback('button')).toBe(DefaultFallbackComponent); + }); + + it('object entry with fallback: get returns component; getFallback returns the configured fallback', () => { + const reg = defineAngularRegistry({ + button: { component: FakeRealComponent, fallback: FakeFallbackComponent }, + }); + expect(reg.get('button')).toBe(FakeRealComponent); + expect(reg.getFallback('button')).toBe(FakeFallbackComponent); + }); + + it('object entry without fallback: getFallback returns the default', () => { + const reg = defineAngularRegistry({ button: { component: FakeRealComponent } }); + expect(reg.get('button')).toBe(FakeRealComponent); + expect(reg.getFallback('button')).toBe(DefaultFallbackComponent); + }); + + it('unknown name: get returns undefined; getFallback returns undefined', () => { + const reg = defineAngularRegistry({ button: FakeRealComponent }); + expect(reg.get('unknown')).toBeUndefined(); + expect(reg.getFallback('unknown')).toBeUndefined(); + }); + + it('names() returns all registered keys regardless of entry shape', () => { + const reg = defineAngularRegistry({ + button: FakeRealComponent, + card: { component: FakeRealComponent, fallback: FakeFallbackComponent }, + }); + expect(reg.names().sort()).toEqual(['button', 'card']); + }); +}); diff --git a/libs/render/src/lib/define-angular-registry.ts b/libs/render/src/lib/define-angular-registry.ts index 9986e393b..154795a61 100644 --- a/libs/render/src/lib/define-angular-registry.ts +++ b/libs/render/src/lib/define-angular-registry.ts @@ -1,12 +1,35 @@ // SPDX-License-Identifier: MIT -import type { AngularComponentRenderer, AngularRegistry } from './render.types'; +import { Type } from '@angular/core'; +import type { AngularRegistry, RenderViewEntry } from './render.types'; +import { DefaultFallbackComponent } from './default-fallback.component'; -export function defineAngularRegistry( - componentMap: Record, -): AngularRegistry { - const map = new Map(Object.entries(componentMap)); +type RegistryInput = Record | RenderViewEntry>; + +interface NormalizedEntry { + component: Type; + fallback: Type; +} + +function normalize(entry: Type | RenderViewEntry): NormalizedEntry { + // Bare Type — register with the default fallback. + if (typeof entry === 'function') { + return { component: entry, fallback: DefaultFallbackComponent }; + } + // Object form — preserve component; use configured fallback or default. + return { + component: entry.component, + fallback: entry.fallback ?? DefaultFallbackComponent, + }; +} + +export function defineAngularRegistry(componentMap: RegistryInput): AngularRegistry { + const map = new Map(); + for (const [name, entry] of Object.entries(componentMap)) { + map.set(name, normalize(entry)); + } return { - get: (name: string) => map.get(name), + get: (name: string) => map.get(name)?.component, + getFallback: (name: string) => map.get(name)?.fallback, names: () => [...map.keys()], }; } diff --git a/libs/render/src/lib/render-element.component.spec.ts b/libs/render/src/lib/render-element.component.spec.ts index 660edeb01..d5352778d 100644 --- a/libs/render/src/lib/render-element.component.spec.ts +++ b/libs/render/src/lib/render-element.component.spec.ts @@ -396,3 +396,96 @@ describe('RenderElementComponent — element-level memoization', () => { }); }); }); + +// --- Fallback gate tests (Task 1.3) --- + +import { TestBed } from '@angular/core/testing'; +import { Component as Cmp2, Input } from '@angular/core'; +import { RenderElementComponent } from './render-element.component'; +import { RENDER_CONTEXT } from './contexts/render-context'; + +@Cmp2({ standalone: true, template: 'label={{ label }}' }) +class FakeRealCmp { + @Input() label?: string; +} + +@Cmp2({ standalone: true, template: 'SKEL' }) +class FakeFallbackCmp {} + +function specWithBinding(): Spec { + return { + root: 'btn1', + elements: { + btn1: { type: 'button', props: { label: { $state: '/label' } } }, + }, + } as Spec; +} + +@Cmp2({ + standalone: true, + imports: [RenderElementComponent], + template: ``, +}) +class FallbackHost { + spec = specWithBinding(); +} + +describe('RenderElementComponent — fallback gate', () => { + let store: ReturnType; + beforeEach(() => { + store = signalStateStore({ }); + TestBed.configureTestingModule({ + imports: [FallbackHost], + providers: [{ + provide: RENDER_CONTEXT, + useValue: { + store, + registry: defineAngularRegistry({ + button: { component: FakeRealCmp, fallback: FakeFallbackCmp }, + }), + functions: {}, + handlers: {}, + }, + }], + }); + }); + + it('renders the fallback when a state-bound prop resolves to undefined', () => { + const fx = TestBed.createComponent(FallbackHost); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('[data-test="fallback"]')).toBeTruthy(); + expect(fx.nativeElement.querySelector('[data-test="real"]')).toBeNull(); + }); + + it('renders the real component once the state-bound prop is populated', () => { + store.set('/label', 'click me'); + const fx = TestBed.createComponent(FallbackHost); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('[data-test="real"]')).toBeTruthy(); + expect(fx.nativeElement.querySelector('[data-test="fallback"]')).toBeNull(); + }); + + it('null counts as ready (not undefined)', () => { + store.set('/label', null); + const fx = TestBed.createComponent(FallbackHost); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('[data-test="real"]')).toBeTruthy(); + }); + + it('monotonic: once real mounts, a later undefined does not revert to fallback', async () => { + store.set('/label', 'click me'); + const fx = TestBed.createComponent(FallbackHost); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('[data-test="real"]')).toBeTruthy(); + + // Flush microtasks so the mountedReal latch flips. + await Promise.resolve(); + + // Now CLEAR the binding — undefined again. + store.set('/label', undefined); + fx.detectChanges(); + // Still real, never reverts. + expect(fx.nativeElement.querySelector('[data-test="real"]')).toBeTruthy(); + expect(fx.nativeElement.querySelector('[data-test="fallback"]')).toBeNull(); + }); +}); diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index 1c94c9573..0f8faae16 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -4,11 +4,13 @@ import { Component, computed, DestroyRef, + effect, inject, Injector, input, OnInit, runInInjectionContext, + signal, type Signal, } from '@angular/core'; import { NgComponentOutlet } from '@angular/common'; @@ -74,13 +76,13 @@ function coerceValue(raw: string): unknown { @if (!element()?.repeat) { @if (visible()) { } } @else { @for (repeatInjector of repeatInjectors(); track $index) { } } @@ -108,6 +110,20 @@ export class RenderElementComponent implements OnInit { }); } }); + + // Latch mountedReal=true once the real component is selected. Lives in + // an effect (not the computed) because Angular forbids signal writes + // inside computed — they're for derivation only. Effects are the + // idiomatic place for "signal change → signal write" side effects. + effect(() => { + if (this.mountedReal()) return; + const el = this.element(); + if (!el) return; + // Only latch when notReady is false AND a real component is registered. + if (!this.notReady() && this.ctx.registry.get(el.type)) { + this.mountedReal.set(true); + } + }); } ngOnInit(): void { @@ -145,11 +161,43 @@ export class RenderElementComponent implements OnInit { ), ); + /** Once real mounts, never revert to fallback even if a state-bound + * prop later becomes undefined. Per-instance monotonic gate. */ + private readonly mountedReal = signal(false); + + /** True when ANY resolved prop value is undefined (i.e. a state + * binding points at a path the store hasn't populated). Framework- + * injected keys (bindings, emit, loading, childKeys, spec) are + * excluded — only consumer-resolved props matter for readiness. */ + readonly notReady = computed(() => { + if (this.mountedReal()) return false; + const el = this.element(); + if (!el || !el.props) return false; + const resolved = resolveElementProps(el.props, this.propCtx()); + for (const v of Object.values(resolved)) { + if (v === undefined) return true; + } + return false; + }); + + /** Picks fallback or real based on notReady. The mountedReal latch is + * driven by a constructor effect (not this computed) — Angular forbids + * signal writes inside computed. */ + readonly mountClass = computed(() => { + const el = this.element(); + if (!el) return null; + const real = this.ctx.registry.get(el.type) ?? null; + if (this.notReady()) { + return this.ctx.registry.getFallback(el.type) ?? null; + } + return real; + }); + /** Whether the element is visible (non-repeat path). */ readonly visible = computed(() => { const el = this.element(); if (!el) return false; - if (this.componentClass() === null) return false; + if (this.mountClass() === null) return false; return evaluateVisibility(el.visible, this.propCtx()); }); diff --git a/libs/render/src/lib/render-spec.component.ts b/libs/render/src/lib/render-spec.component.ts index 06b8a8085..cac8accfc 100644 --- a/libs/render/src/lib/render-spec.component.ts +++ b/libs/render/src/lib/render-spec.component.ts @@ -90,7 +90,7 @@ export class RenderSpecComponent implements OnInit { const configRegistry = this.config?.registry; if (configRegistry) return configRegistry; // Fallback: empty registry - return { get: () => undefined, names: () => [] }; + return { get: () => undefined, getFallback: () => undefined, names: () => [] }; }); /** Wraps input handlers to emit RenderHandlerEvent after execution. */ diff --git a/libs/render/src/lib/render.types.ts b/libs/render/src/lib/render.types.ts index 52508c3ec..1aa691d63 100644 --- a/libs/render/src/lib/render.types.ts +++ b/libs/render/src/lib/render.types.ts @@ -19,8 +19,27 @@ export interface AngularComponentInputs { export type AngularComponentRenderer = Type; +/** + * A view registry entry. Bare `Type` form is the legacy shape; the + * object form lets consumers attach a per-component fallback that + * mounts while any state-bound prop on the element is still + * unresolved. The fallback is monotonic per element instance: once + * the real component mounts, subsequent re-renders never revert to + * fallback even if a prop later resolves to undefined. + */ +export interface RenderViewEntry { + component: Type; + fallback?: Type; +} + export interface AngularRegistry { get(name: string): AngularComponentRenderer | undefined; + /** + * Returns the configured fallback for a registered name, OR the + * lib's default fallback if the entry omits one, OR undefined if + * the name is not registered. + */ + getFallback(name: string): AngularComponentRenderer | undefined; names(): string[]; } diff --git a/libs/render/src/lib/views.ts b/libs/render/src/lib/views.ts index 67d0e63d8..f3367d39f 100644 --- a/libs/render/src/lib/views.ts +++ b/libs/render/src/lib/views.ts @@ -1,19 +1,19 @@ // SPDX-License-Identifier: MIT import { Type } from '@angular/core'; -import type { AngularRegistry } from './render.types'; +import type { AngularRegistry, RenderViewEntry } from './render.types'; import { defineAngularRegistry } from './define-angular-registry'; /** * A registry of view components available for generative UI rendering. - * Plain frozen object mapping view names to Angular component types. - * Compose via object spread: `views({ ...base, ...more })`. + * Each entry is either a bare component Type (legacy shape) or a + * `RenderViewEntry` { component, fallback? }. */ -export type ViewRegistry = Readonly>>; +export type ViewRegistry = Readonly | RenderViewEntry>>; /** * Creates a view registry from a name → component map. */ -export function views(map: Record>): ViewRegistry { +export function views(map: Record | RenderViewEntry>): ViewRegistry { return Object.freeze({ ...map }); } @@ -23,7 +23,7 @@ export function views(map: Record>): ViewRegistry { */ export function withViews( base: ViewRegistry, - additions: Record>, + additions: Record | RenderViewEntry>, ): ViewRegistry { return Object.freeze({ ...additions, ...base }); } diff --git a/libs/render/src/public-api.ts b/libs/render/src/public-api.ts index 29f6d58b3..a02ad70b2 100644 --- a/libs/render/src/public-api.ts +++ b/libs/render/src/public-api.ts @@ -39,3 +39,7 @@ export type { RenderStateChangeEvent, RenderLifecycleEvent, } from './lib/render-event'; + +// Fallback +export { DefaultFallbackComponent } from './lib/default-fallback.component'; +export type { RenderViewEntry } from './lib/render.types'; diff --git a/libs/render/tsconfig.spec.json b/libs/render/tsconfig.spec.json new file mode 100644 index 000000000..13e304ba3 --- /dev/null +++ b/libs/render/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": false, + "lib": ["es2022", "dom"], + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/libs/render/vite.config.mts b/libs/render/vite.config.mts index ce406638a..1306fd366 100644 --- a/libs/render/vite.config.mts +++ b/libs/render/vite.config.mts @@ -1,13 +1,15 @@ import { defineConfig } from 'vite'; +import angular from '@analogjs/vite-plugin-angular'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; export default defineConfig({ - plugins: [nxViteTsPaths()], + plugins: [angular(), nxViteTsPaths()], test: { globals: true, environment: 'jsdom', include: ['src/**/*.spec.ts'], setupFiles: ['src/test-setup.ts'], passWithNoTests: true, + pool: 'forks', }, });