From 604d969e1cebf838df0e76e0dff4eb92c7b0561b Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 18:07:46 +0300 Subject: [PATCH 01/12] feat(rive-angular): add dual runtime support with webgl2 fallback Introduce configurable canvas/webgl2 runtime loading with optional strict mode and automatic fallback to improve compatibility while enabling advanced rendering features. --- README.md | 73 ++++-- libs/rive-angular/CHANGELOG.md | 23 ++ libs/rive-angular/README.md | 73 ++++-- libs/rive-angular/package.json | 10 +- libs/rive-angular/src/index.ts | 4 +- .../components/rive-canvas.component.spec.ts | 7 + .../lib/components/rive-canvas.component.ts | 213 +++++++++++++----- .../rive-angular/src/lib/models/rive.model.ts | 4 +- .../src/lib/services/rive-file.service.ts | 10 +- libs/rive-angular/src/lib/utils/index.ts | 1 + .../src/lib/utils/rive-runtime.spec.ts | 84 +++++++ .../src/lib/utils/rive-runtime.ts | 104 ++++++--- libs/rive-angular/src/lib/utils/rive-sdk.ts | 54 +++++ .../src/lib/utils/runtime-config.ts | 10 + libs/rive-angular/src/lib/utils/validator.ts | 2 +- package-lock.json | 55 +---- package.json | 1 + 17 files changed, 545 insertions(+), 183 deletions(-) create mode 100644 libs/rive-angular/src/lib/utils/rive-runtime.spec.ts create mode 100644 libs/rive-angular/src/lib/utils/rive-sdk.ts diff --git a/README.md b/README.md index c84c966..a737632 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **1.x** is the stable major line: the public API follows [Semantic Versioning](https://semver.org/). +Current release candidate in this branch: **`1.2.0-beta.0`** (pre-release). ## What is Rive? @@ -54,7 +55,7 @@ This library follows the design principles of the official [rive-react](https:// | Aspect | rive-react | @grandgular/rive-angular | |--------|------------|--------------------------| -| Component API | `` component | `` | +| Component API | `` component | `` (`` legacy alias) | | Reactivity | Hooks (useState, useEffect) | Signals | | File preloading | `useRiveFile` hook | `RiveFileService` | | State access | Hook return values | Public signals | @@ -70,7 +71,7 @@ Both libraries provide similar features and follow the same philosophy of provid ```bash npm uninstall ng-rive -npm install @grandgular/rive-angular @rive-app/canvas +npm install @grandgular/rive-angular @rive-app/canvas @rive-app/webgl2 ``` ### 2. Update imports @@ -95,7 +96,7 @@ npm install @grandgular/rive-angular @rive-app/canvas ```html -` +- Legacy selector: `` (deprecated, will be removed in the next major version) + +Both selectors are supported in the current major for backward compatibility. + ### Basic usage ```typescript @@ -155,7 +163,7 @@ import { RiveCanvasComponent, Fit, Alignment } from '@grandgular/rive-angular'; standalone: true, imports: [RiveCanvasComponent], template: ` - `, styles: [` - rive-canvas { + rive { width: 100%; height: 400px; } @@ -196,7 +204,7 @@ import { RiveCanvasComponent } from '@grandgular/rive-angular'; standalone: true, imports: [RiveCanvasComponent], template: ` - @@ -298,7 +306,7 @@ import { RiveCanvasComponent } from '@grandgular/rive-angular'; standalone: true, imports: [RiveCanvasComponent], template: ` - + @@ -425,7 +433,7 @@ const hex = riveColorToHex({ r: 255, g: 0, b: 0, a: 255 }); // '#FF0000FF' If your `.riv` file contains multiple ViewModels, specify which one to use: ```typescript - @@ -497,7 +505,7 @@ import { RiveCanvasComponent, RiveFileService } from '@grandgular/rive-angular'; imports: [RiveCanvasComponent], template: ` @if (fileState().status === 'success') { - @@ -553,7 +561,7 @@ Available log levels: `'none' | 'error' | 'warn' | 'info' | 'debug'` Enable debug mode for a specific component instance: ```typescript - @@ -568,6 +576,9 @@ When debug mode is enabled, the library will log: Use `provideRiveRuntime()` to control when the Rive WASM runtime initializes. +By default, runtime uses `renderer: 'canvas'` for backward compatibility. +To enable vector feathering support, configure `renderer: 'webgl2'`. + ### Eager mode (default) Initializes runtime on app startup: @@ -578,7 +589,10 @@ import { provideRiveRuntime } from '@grandgular/rive-angular'; export const appConfig: ApplicationConfig = { providers: [ - provideRiveRuntime({ wasmUrl: 'assets/rive/rive.v1.wasm' }), + provideRiveRuntime({ + wasmUrl: 'assets/rive/rive.v1.wasm', + renderer: 'canvas', + }), ], }; ``` @@ -596,11 +610,27 @@ export const appConfig: ApplicationConfig = { provideRiveRuntime({ wasmUrl: 'assets/rive/rive.v1.wasm', lazy: true, + renderer: 'webgl2', + strict: false, }), ], }; ``` +### Renderer fallback behavior + +```typescript +provideRiveRuntime({ + renderer: 'webgl2', + strict: false, +}); +``` + +- `renderer` is optional; default is `'canvas'`. +- `strict` is optional; default is `false`. +- With `strict: false`, the library automatically falls back to the other renderer if the preferred renderer fails to initialize. +- With `strict: true`, fallback is disabled and initialization fails fast. + ### Migration from `provideAppInitializer` If you used: @@ -624,7 +654,7 @@ The library validates your configuration against the loaded Rive file and provid Validation errors (e.g., missing artboard or animation) are **non-fatal**. They are emitted via the `loadError` output but do not crash the application. ```typescript -` (with `` still supported as a legacy alias). +- **New runtime options** in `provideRiveRuntime()`: + - `renderer?: 'canvas' | 'webgl2'` (default: `'canvas'`) + - `strict?: boolean` (default: `false`) +- **Automatic fallback behavior** when `strict` is `false`: if the preferred renderer fails to initialize, the library tries the other renderer automatically. +- **Runtime tests** covering `webgl2`, fallback behavior, and strict mode failures. + +### Changed + +- Runtime initialization now resolves and returns the active renderer module internally, used by both `RiveCanvasComponent` and `RiveFileService`. +- Internal SDK imports are centralized through a shared facade to simplify runtime selection. + +### Notes + +- This release prioritizes backward compatibility and functional dual-support. +- Bundle-level optimization (ensuring non-selected runtime is excluded from bundles) is intentionally planned for a follow-up release. +- `` is now considered deprecated and is planned for removal in the next major release. + ## [1.1.0] - 2026-04-16 ### Added diff --git a/libs/rive-angular/README.md b/libs/rive-angular/README.md index 953b6f7..3c46be5 100644 --- a/libs/rive-angular/README.md +++ b/libs/rive-angular/README.md @@ -11,6 +11,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **1.x** is the stable major line: the public API follows [Semantic Versioning](https://semver.org/). +Current release candidate in this branch: **`1.2.0-beta.0`** (pre-release). ## What is Rive? @@ -52,7 +53,7 @@ This library follows the design principles of the official [rive-react](https:// | Aspect | rive-react | @grandgular/rive-angular | |--------|------------|--------------------------| -| Component API | `` component | `` | +| Component API | `` component | `` (`` legacy alias) | | Reactivity | Hooks (useState, useEffect) | Signals | | File preloading | `useRiveFile` hook | `RiveFileService` | | State access | Hook return values | Public signals | @@ -68,7 +69,7 @@ Both libraries provide similar features and follow the same philosophy of provid ```bash npm uninstall ng-rive -npm install @grandgular/rive-angular @rive-app/canvas +npm install @grandgular/rive-angular @rive-app/canvas @rive-app/webgl2 ``` ### 2. Update imports @@ -93,7 +94,7 @@ npm install @grandgular/rive-angular @rive-app/canvas ```html -` +- Legacy selector: `` (deprecated, will be removed in the next major version) + +Both selectors are supported in the current major for backward compatibility. + ### Basic usage ```typescript @@ -153,7 +161,7 @@ import { RiveCanvasComponent, Fit, Alignment } from '@grandgular/rive-angular'; standalone: true, imports: [RiveCanvasComponent], template: ` - `, styles: [` - rive-canvas { + rive { width: 100%; height: 400px; } @@ -194,7 +202,7 @@ import { RiveCanvasComponent } from '@grandgular/rive-angular'; standalone: true, imports: [RiveCanvasComponent], template: ` - @@ -296,7 +304,7 @@ import { RiveCanvasComponent } from '@grandgular/rive-angular'; standalone: true, imports: [RiveCanvasComponent], template: ` - + @@ -423,7 +431,7 @@ const hex = riveColorToHex({ r: 255, g: 0, b: 0, a: 255 }); // '#FF0000FF' If your `.riv` file contains multiple ViewModels, specify which one to use: ```typescript - @@ -495,7 +503,7 @@ import { RiveCanvasComponent, RiveFileService } from '@grandgular/rive-angular'; imports: [RiveCanvasComponent], template: ` @if (fileState().status === 'success') { - @@ -551,7 +559,7 @@ Available log levels: `'none' | 'error' | 'warn' | 'info' | 'debug'` Enable debug mode for a specific component instance: ```typescript - @@ -566,6 +574,9 @@ When debug mode is enabled, the library will log: Use `provideRiveRuntime()` to control when the Rive WASM runtime initializes. +By default, runtime uses `renderer: 'canvas'` for backward compatibility. +To enable vector feathering support, configure `renderer: 'webgl2'`. + ### Eager mode (default) Initializes runtime on app startup: @@ -576,7 +587,10 @@ import { provideRiveRuntime } from '@grandgular/rive-angular'; export const appConfig: ApplicationConfig = { providers: [ - provideRiveRuntime({ wasmUrl: 'assets/rive/rive.v1.wasm' }), + provideRiveRuntime({ + wasmUrl: 'assets/rive/rive.v1.wasm', + renderer: 'canvas', + }), ], }; ``` @@ -594,11 +608,27 @@ export const appConfig: ApplicationConfig = { provideRiveRuntime({ wasmUrl: 'assets/rive/rive.v1.wasm', lazy: true, + renderer: 'webgl2', + strict: false, }), ], }; ``` +### Renderer fallback behavior + +```typescript +provideRiveRuntime({ + renderer: 'webgl2', + strict: false, +}); +``` + +- `renderer` is optional; default is `'canvas'`. +- `strict` is optional; default is `false`. +- With `strict: false`, the library automatically falls back to the other renderer if the preferred renderer fails to initialize. +- With `strict: true`, fallback is disabled and initialization fails fast. + ### Migration from `provideAppInitializer` If you used: @@ -622,7 +652,7 @@ The library validates your configuration against the loaded Rive file and provid Validation errors (e.g., missing artboard or animation) are **non-fatal**. They are emitted via the `loadError` output but do not crash the application. ```typescript -=18.0.0 <22.0.0", "@angular/core": ">=18.0.0 <22.0.0", - "@rive-app/canvas": "^2.35.0" + "@rive-app/canvas": "^2.35.0", + "@rive-app/webgl2": "^2.35.0" + }, + "peerDependenciesMeta": { + "@rive-app/webgl2": { + "optional": true + } }, "publishConfig": { "access": "public" diff --git a/libs/rive-angular/src/index.ts b/libs/rive-angular/src/index.ts index b530508..e8347bb 100644 --- a/libs/rive-angular/src/index.ts +++ b/libs/rive-angular/src/index.ts @@ -50,7 +50,7 @@ export { riveColorToHex, } from './lib/utils/color-parser'; -// Re-export commonly used types from @rive-app/canvas for convenience +// Re-export commonly used types from Rive SDK for convenience export { Rive, RiveFile, @@ -60,4 +60,4 @@ export { type LayoutParameters, type RiveParameters, type RiveFileParameters, -} from '@rive-app/canvas'; +} from './lib/utils/rive-sdk'; diff --git a/libs/rive-angular/src/lib/components/rive-canvas.component.spec.ts b/libs/rive-angular/src/lib/components/rive-canvas.component.spec.ts index 07b11ef..fb91ed4 100644 --- a/libs/rive-angular/src/lib/components/rive-canvas.component.spec.ts +++ b/libs/rive-angular/src/lib/components/rive-canvas.component.spec.ts @@ -256,6 +256,13 @@ describe('RiveCanvasComponent', () => { expect(component).toBeTruthy(); }); + it('should expose both selectors for compatibility', () => { + const selectors = (RiveCanvasComponent as any).ɵcmp?.selectors ?? []; + expect(selectors).toEqual( + expect.arrayContaining([['rive'], ['rive-canvas']]), + ); + }); + it('should initialize with default values', () => { expect(component.autoplay()).toBe(true); expect(component.fit()).toBe(Fit.Contain); diff --git a/libs/rive-angular/src/lib/components/rive-canvas.component.ts b/libs/rive-angular/src/lib/components/rive-canvas.component.ts index e59a240..3c79674 100644 --- a/libs/rive-angular/src/lib/components/rive-canvas.component.ts +++ b/libs/rive-angular/src/lib/components/rive-canvas.component.ts @@ -16,24 +16,28 @@ import { } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import { - Rive, - RiveFile, - Layout, Fit, Alignment, - StateMachineInput, - type LayoutParameters, - Event as RiveEvent, EventType, - ViewModelInstance, -} from '@rive-app/canvas'; + CANVAS_RIVE_SDK, + DEFAULT_RIVE_RENDERER, + getFallbackRenderer, + type Rive, + type RiveFile, + type RiveRenderer, + type StateMachineInput, + type LayoutParameters, + type RiveEvent, + type ViewModelInstance, + type RiveSdkModule, +} from '../utils/rive-sdk'; import { RiveLoadError } from '../models'; import type { DataBindingValue, DataBindingChangeEvent, DataBindingPropertyType, RiveColor, -} from '../models/data-binding.types'; +} from '../models'; import { ElementObserver, RiveLogger, @@ -62,7 +66,7 @@ import { RiveValidationError } from '../models'; * * @example * ```html - * { if (this.#rive && isPlatformBrowser(this.#platformId)) { const layoutParams: LayoutParameters = { fit, alignment }; - this.#rive.layout = new Layout(layoutParams); + const layoutCtor = (this.#runtimeSdk ?? CANVAS_RIVE_SDK).Layout; + this.#rive.layout = new layoutCtor(layoutParams as never) as never; } }); }); @@ -536,25 +542,6 @@ export class RiveCanvasComponent implements AfterViewInit { alignment: this.alignment(), }; - // Build typed Rive configuration - const baseConfig = { - canvas, - autoplay: this.autoplay(), - layout: new Layout(layoutParams), - useOffscreenRenderer: this.useOffscreenRenderer(), - shouldDisableRiveListeners: this.shouldDisableRiveListeners(), - automaticallyHandleEvents: this.automaticallyHandleEvents(), - onLoad: () => this.onLoad(), - onLoadError: (error?: unknown) => this.onLoadError(error), - onPlay: () => this.onPlay(), - onPause: () => this.onPause(), - onStop: () => this.onStop(), - onLoop: (event: RiveEvent) => this.onLoop(event), - onAdvance: (event: RiveEvent) => this.onAdvance(event), - onStateChange: (event: RiveEvent) => this.onStateChange(event), - onRiveEvent: (event: RiveEvent) => this.onRiveEvent(event), - }; - // Add source (priority: riveFile > src > buffer) const sourceConfig = riveFile ? { riveFile } @@ -573,26 +560,47 @@ export class RiveCanvasComponent implements AfterViewInit { : {}), }; - // Combine all configurations - const config = { ...baseConfig, ...sourceConfig, ...optionalConfig }; - - const createRiveInstance = () => { + const createRiveInstance = (runtimeSdk: RiveSdkModule) => { if (requestId !== this.loadRequestId) { return; } - const rive = new Rive(config); + const config = { + canvas, + autoplay: this.autoplay(), + layout: new runtimeSdk.Layout(layoutParams as never), + useOffscreenRenderer: this.useOffscreenRenderer(), + shouldDisableRiveListeners: this.shouldDisableRiveListeners(), + automaticallyHandleEvents: this.automaticallyHandleEvents(), + onLoad: () => this.onLoad(), + onLoadError: (error?: unknown) => this.onLoadError(error), + onPlay: () => this.onPlay(), + onPause: () => this.onPause(), + onStop: () => this.onStop(), + onLoop: (event: RiveEvent) => this.onLoop(event), + onAdvance: (event: RiveEvent) => this.onAdvance(event), + onStateChange: (event: RiveEvent) => this.onStateChange(event), + onRiveEvent: (event: RiveEvent) => this.onRiveEvent(event), + ...sourceConfig, + ...optionalConfig, + }; + + const rive = new runtimeSdk.Rive(config as never) as unknown as Rive; if (requestId !== this.loadRequestId) { try { rive.cleanup(); } catch (cleanupError) { - this.logger.warn('Error during stale Rive cleanup:', cleanupError); + this.logger.warn( + 'Error during stale Rive cleanup:', + cleanupError, + ); } return; } this.#rive = rive; + this.#runtimeSdk = runtimeSdk; // Update public signal (riveReady will be emitted in onLoad) this.#ngZone.run(() => { @@ -601,13 +609,39 @@ export class RiveCanvasComponent implements AfterViewInit { }; if (!this.#runtimeConfig) { - createRiveInstance(); + createRiveInstance(CANVAS_RIVE_SDK); return; } - void ensureRiveRuntimeReady(this.#runtimeConfig) - .then(() => { - createRiveInstance(); + const runtimeConfig = this.#runtimeConfig; + const preferredRenderer = + runtimeConfig.renderer ?? DEFAULT_RIVE_RENDERER; + const strictMode = runtimeConfig.strict; + + void ensureRiveRuntimeReady(runtimeConfig) + .then(async (runtimeResult) => { + try { + createRiveInstance(runtimeResult.sdk); + } catch (primaryCreateError) { + if ( + strictMode || + runtimeResult.renderer !== preferredRenderer || + !this.shouldFallbackOnRendererError( + primaryCreateError, + preferredRenderer, + ) + ) { + throw primaryCreateError; + } + + const fallbackRuntime = await ensureRiveRuntimeReady({ + ...runtimeConfig, + lazy: runtimeConfig.lazy, + renderer: getFallbackRenderer(preferredRenderer), + strict: true, + }); + createRiveInstance(fallbackRuntime.sdk); + } }) .catch((error) => { if (requestId !== this.loadRequestId) { @@ -639,6 +673,23 @@ export class RiveCanvasComponent implements AfterViewInit { }); } + private shouldFallbackOnRendererError( + error: unknown, + renderer: RiveRenderer, + ): boolean { + if (renderer !== 'webgl2' || !(error instanceof Error)) { + return false; + } + + const message = error.message.toLowerCase(); + return ( + message.includes('webgl') || + message.includes('context') || + message.includes('gpu') || + message.includes('renderer') + ); + } + // Event handlers (run inside Angular zone for change detection) private onLoad(): void { // Validate loaded configuration @@ -1009,7 +1060,9 @@ export class RiveCanvasComponent implements AfterViewInit { this.withLocalMutation(path, () => { const resolved = this.resolveViewModelProperty(vmi, path); if (!resolved) { - this.logger.warn(`Data binding property "${path}" not found in ViewModel`); + this.logger.warn( + `Data binding property "${path}" not found in ViewModel`, + ); this.#ngZone.run(() => this.loadError.emit( new RiveValidationError( @@ -1138,7 +1191,12 @@ export class RiveCanvasComponent implements AfterViewInit { try { const parsedColor = parseRiveColor(color); - colorProp.rgba(parsedColor.r, parsedColor.g, parsedColor.b, parsedColor.a); + colorProp.rgba( + parsedColor.r, + parsedColor.g, + parsedColor.b, + parsedColor.a, + ); this.logger.debug(`Color "${path}" set to:`, parsedColor); } catch (error) { this.logger.warn(`Failed to set color "${path}":`, error); @@ -1206,7 +1264,9 @@ export class RiveCanvasComponent implements AfterViewInit { // Validate opacity range if (opacity < 0 || opacity > 1) { - this.logger.warn(`Invalid opacity value ${opacity}: must be between 0.0 and 1.0`); + this.logger.warn( + `Invalid opacity value ${opacity}: must be between 0.0 and 1.0`, + ); this.#ngZone.run(() => this.loadError.emit( new RiveValidationError( @@ -1314,7 +1374,9 @@ export class RiveCanvasComponent implements AfterViewInit { // If no ViewModel found (file doesn't use ViewModels), that's OK if (!viewModel) { - this.logger.debug('No ViewModel found in file (file may not use ViewModels)'); + this.logger.debug( + 'No ViewModel found in file (file may not use ViewModels)', + ); return; } @@ -1364,7 +1426,9 @@ export class RiveCanvasComponent implements AfterViewInit { /** * Get ViewModel property information for debug logging. */ - private getViewModelPropertyInfo(vmi: ViewModelInstance): Record { + private getViewModelPropertyInfo( + vmi: ViewModelInstance, + ): Record { const info: Record = {}; try { const properties = vmi.properties; @@ -1373,7 +1437,8 @@ export class RiveCanvasComponent implements AfterViewInit { // eslint-disable-next-line @typescript-eslint/no-explicit-any const propAny = prop as any; const propertyType = this.normalizeViewModelPropertyType(propAny?.type); - info[propAny.name || 'unknown'] = propertyType ?? (propAny.type || 'unknown'); + info[propAny.name || 'unknown'] = + propertyType ?? (propAny.type || 'unknown'); } } catch (error) { this.logger.warn('Failed to get ViewModel property info:', error); @@ -1420,7 +1485,7 @@ export class RiveCanvasComponent implements AfterViewInit { /** * Subscribe to changes for a specific ViewModel property. - * Uses multiple event APIs to maximize compatibility with @rive-app/canvas runtime versions. + * Uses multiple event APIs to maximize compatibility with Rive runtime versions. */ private subscribeToPropertyChanges( path: string, @@ -1470,19 +1535,31 @@ export class RiveCanvasComponent implements AfterViewInit { } } else if (typeof propertyAny.subscribe === 'function') { const handler = propertyAny.subscribe(callback); - unsubscribe = this.buildUnsubscribeFromHandler(propertyAny, callback, handler); + unsubscribe = this.buildUnsubscribeFromHandler( + propertyAny, + callback, + handler, + ); } else if (typeof propertyAny.addEventListener === 'function') { propertyAny.addEventListener('change', callback); - unsubscribe = () => propertyAny.removeEventListener?.('change', callback); + unsubscribe = () => + propertyAny.removeEventListener?.('change', callback); } else if (typeof propertyAny.addListener === 'function') { propertyAny.addListener('change', callback); unsubscribe = () => propertyAny.removeListener?.('change', callback); } else if (typeof propertyAny.onChange === 'function') { const handler = propertyAny.onChange(callback); - unsubscribe = this.buildUnsubscribeFromHandler(propertyAny, callback, handler); + unsubscribe = this.buildUnsubscribeFromHandler( + propertyAny, + callback, + handler, + ); } } catch (error) { - this.logger.warn(`Failed to subscribe to ViewModel property "${path}":`, error); + this.logger.warn( + `Failed to subscribe to ViewModel property "${path}":`, + error, + ); } if (!unsubscribe) { @@ -1504,11 +1581,21 @@ export class RiveCanvasComponent implements AfterViewInit { return handler as () => void; } - if (handler && typeof handler === 'object' && 'unsubscribe' in handler && typeof handler.unsubscribe === 'function') { + if ( + handler && + typeof handler === 'object' && + 'unsubscribe' in handler && + typeof handler.unsubscribe === 'function' + ) { return () => (handler as { unsubscribe: () => void }).unsubscribe(); } - if (handler && typeof handler === 'object' && 'dispose' in handler && typeof handler.dispose === 'function') { + if ( + handler && + typeof handler === 'object' && + 'dispose' in handler && + typeof handler.dispose === 'function' + ) { return () => (handler as { dispose: () => void }).dispose(); } @@ -1608,7 +1695,9 @@ export class RiveCanvasComponent implements AfterViewInit { return undefined; } - private normalizeViewModelPropertyType(type: unknown): DataBindingPropertyType | null { + private normalizeViewModelPropertyType( + type: unknown, + ): DataBindingPropertyType | null { if (typeof type !== 'string') { return null; } @@ -1700,9 +1789,12 @@ export class RiveCanvasComponent implements AfterViewInit { this.#ngZone.run(() => this.loadError.emit( new RiveValidationError( - formatErrorMessage(RiveErrorCode.DataBindingPropertyNotFound, { - path, - }), + formatErrorMessage( + RiveErrorCode.DataBindingPropertyNotFound, + { + path, + }, + ), RiveErrorCode.DataBindingPropertyNotFound, ), ), @@ -1808,7 +1900,9 @@ export class RiveCanvasComponent implements AfterViewInit { } if (type === 'trigger') { - this.logger.warn(`Cannot set trigger property "${path}" via setDataBinding`); + this.logger.warn( + `Cannot set trigger property "${path}" via setDataBinding`, + ); return false; } @@ -1839,6 +1933,7 @@ export class RiveCanvasComponent implements AfterViewInit { } this.#rive = null; } + this.#runtimeSdk = null; // Reset signals this.#riveInstance.set(null); diff --git a/libs/rive-angular/src/lib/models/rive.model.ts b/libs/rive-angular/src/lib/models/rive.model.ts index f093ed4..e8657ea 100644 --- a/libs/rive-angular/src/lib/models/rive.model.ts +++ b/libs/rive-angular/src/lib/models/rive.model.ts @@ -3,8 +3,8 @@ import { RiveErrorCode } from '../utils'; /** * Re-export Rive SDK types for consumer convenience */ -export { Fit, Alignment, EventType, LoopType } from '@rive-app/canvas'; -export type { Event as RiveEvent, LoopEvent } from '@rive-app/canvas'; +export { Fit, Alignment, EventType, LoopType } from '../utils/rive-sdk'; +export type { RiveEvent, LoopEvent } from '../utils/rive-sdk'; /** * Options for constructing a RiveLoadError with detailed context. diff --git a/libs/rive-angular/src/lib/services/rive-file.service.ts b/libs/rive-angular/src/lib/services/rive-file.service.ts index 582f64f..3196a32 100644 --- a/libs/rive-angular/src/lib/services/rive-file.service.ts +++ b/libs/rive-angular/src/lib/services/rive-file.service.ts @@ -1,8 +1,8 @@ import { Injectable, signal, Signal, inject } from '@angular/core'; -import { RiveFile, EventType } from '@rive-app/canvas'; import { RIVE_DEBUG_CONFIG, RIVE_RUNTIME_CONFIG } from '../utils'; import { RiveLogger } from '../utils'; import { ensureRiveRuntimeReady } from '../utils/rive-runtime'; +import { CANVAS_RIVE_SDK, EventType, type RiveFile } from '../utils'; /** * Status of RiveFile loading @@ -217,14 +217,14 @@ export class RiveFileService { }; try { - if (this.runtimeConfig) { - await ensureRiveRuntimeReady(this.runtimeConfig); - } + const runtimeSdk = this.runtimeConfig + ? (await ensureRiveRuntimeReady(this.runtimeConfig)).sdk + : CANVAS_RIVE_SDK; // Extract debug parameter - it's not part of RiveFile SDK API // eslint-disable-next-line @typescript-eslint/no-unused-vars const { debug, ...sdkParams } = params; - const file = new RiveFile(sdkParams); + const file = new runtimeSdk.RiveFile(sdkParams as never) as RiveFile; // Listeners must be attached BEFORE calling init() to avoid race conditions // where init() completes or fails synchronously/immediately. diff --git a/libs/rive-angular/src/lib/utils/index.ts b/libs/rive-angular/src/lib/utils/index.ts index 3e3c829..cb938f5 100644 --- a/libs/rive-angular/src/lib/utils/index.ts +++ b/libs/rive-angular/src/lib/utils/index.ts @@ -5,3 +5,4 @@ export * from './logger'; export * from './validator'; export * from './color-parser'; export * from './runtime-config'; +export * from './rive-sdk'; diff --git a/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts b/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts new file mode 100644 index 0000000..b7eeea3 --- /dev/null +++ b/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts @@ -0,0 +1,84 @@ +const canvasAwaitInstance = jest.fn(); +const canvasSetWasmUrl = jest.fn(); +const webgl2AwaitInstance = jest.fn(); +const webgl2SetWasmUrl = jest.fn(); + +jest.mock('@rive-app/canvas', () => ({ + RuntimeLoader: { + awaitInstance: (...args: unknown[]) => canvasAwaitInstance(...args), + setWasmUrl: (...args: unknown[]) => canvasSetWasmUrl(...args), + }, +})); + +jest.mock('@rive-app/webgl2', () => ({ + RuntimeLoader: { + awaitInstance: (...args: unknown[]) => webgl2AwaitInstance(...args), + setWasmUrl: (...args: unknown[]) => webgl2SetWasmUrl(...args), + }, +})); + +describe('ensureRiveRuntimeReady', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + canvasAwaitInstance.mockResolvedValue(undefined); + webgl2AwaitInstance.mockResolvedValue(undefined); + }); + + it('uses canvas renderer by default', async () => { + const { ensureRiveRuntimeReady } = await import('./rive-runtime'); + + const result = await ensureRiveRuntimeReady(); + + expect(result.renderer).toBe('canvas'); + expect(canvasAwaitInstance).toHaveBeenCalledTimes(1); + expect(webgl2AwaitInstance).not.toHaveBeenCalled(); + }); + + it('uses webgl2 renderer when explicitly configured', async () => { + const { ensureRiveRuntimeReady } = await import('./rive-runtime'); + + const result = await ensureRiveRuntimeReady({ + lazy: false, + renderer: 'webgl2', + strict: false, + wasmUrl: 'assets/rive/rive.wasm', + }); + + expect(result.renderer).toBe('webgl2'); + expect(webgl2AwaitInstance).toHaveBeenCalledTimes(1); + expect(webgl2SetWasmUrl).toHaveBeenCalledWith('assets/rive/rive.wasm'); + expect(canvasAwaitInstance).not.toHaveBeenCalled(); + }); + + it('falls back to canvas when webgl2 init fails and strict is false', async () => { + webgl2AwaitInstance.mockRejectedValueOnce(new Error('WebGL2 unavailable')); + const { ensureRiveRuntimeReady } = await import('./rive-runtime'); + + const result = await ensureRiveRuntimeReady({ + lazy: false, + renderer: 'webgl2', + strict: false, + }); + + expect(result.renderer).toBe('canvas'); + expect(webgl2AwaitInstance).toHaveBeenCalledTimes(1); + expect(canvasAwaitInstance).toHaveBeenCalledTimes(1); + }); + + it('throws when webgl2 init fails and strict is true', async () => { + webgl2AwaitInstance.mockRejectedValueOnce(new Error('WebGL2 unavailable')); + const { ensureRiveRuntimeReady } = await import('./rive-runtime'); + + await expect( + ensureRiveRuntimeReady({ + lazy: false, + renderer: 'webgl2', + strict: true, + }), + ).rejects.toThrow('WebGL2 unavailable'); + + expect(webgl2AwaitInstance).toHaveBeenCalledTimes(1); + expect(canvasAwaitInstance).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/rive-angular/src/lib/utils/rive-runtime.ts b/libs/rive-angular/src/lib/utils/rive-runtime.ts index a442cdf..8626cdc 100644 --- a/libs/rive-angular/src/lib/utils/rive-runtime.ts +++ b/libs/rive-angular/src/lib/utils/rive-runtime.ts @@ -1,47 +1,99 @@ -import { RuntimeLoader } from '@rive-app/canvas'; import type { RiveRuntimeResolvedConfig } from './runtime-config'; +import { + DEFAULT_RIVE_RENDERER, + getFallbackRenderer, + loadRiveSdk, + type RiveRenderer, + type RiveSdkLoadResult, +} from './rive-sdk'; -let runtimeInitPromise: Promise | null = null; -let isRuntimeReady = false; -let configuredWasmUrl: string | undefined; +interface RuntimeState { + runtimeInitPromise: Promise | null; + isRuntimeReady: boolean; + configuredWasmUrl?: string; +} + +const runtimeStates = new Map(); + +function getRuntimeState(renderer: RiveRenderer): RuntimeState { + const existing = runtimeStates.get(renderer); + if (existing) { + return existing; + } -function applyWasmUrl(config?: RiveRuntimeResolvedConfig | null): void { + const state: RuntimeState = { + runtimeInitPromise: null, + isRuntimeReady: false, + }; + runtimeStates.set(renderer, state); + return state; +} + +function applyWasmUrl( + sdk: RiveSdkLoadResult['sdk'], + state: RuntimeState, + config?: RiveRuntimeResolvedConfig | null, +): void { const wasmUrl = config?.wasmUrl; - if (!wasmUrl || configuredWasmUrl === wasmUrl) return; + if (!wasmUrl || state.configuredWasmUrl === wasmUrl) return; - RuntimeLoader.setWasmUrl(wasmUrl); - configuredWasmUrl = wasmUrl; + sdk.RuntimeLoader.setWasmUrl(wasmUrl); + state.configuredWasmUrl = wasmUrl; } -/** - * Ensure Rive WASM runtime is initialized once across the app. - * Safe to call from multiple concurrent code paths. - */ -export function ensureRiveRuntimeReady( +async function ensureRuntimeForRenderer( + renderer: RiveRenderer, config?: RiveRuntimeResolvedConfig | null, -): Promise { - if (typeof window === 'undefined') { - return Promise.resolve(); - } +): Promise { + const runtime = await loadRiveSdk(renderer); + const state = getRuntimeState(renderer); - applyWasmUrl(config); + applyWasmUrl(runtime.sdk, state, config); - if (isRuntimeReady) { - return Promise.resolve(); + if (state.isRuntimeReady) { + return runtime; } - if (runtimeInitPromise) { - return runtimeInitPromise; + if (state.runtimeInitPromise) { + await state.runtimeInitPromise; + return runtime; } - runtimeInitPromise = RuntimeLoader.awaitInstance() + state.runtimeInitPromise = runtime.sdk.RuntimeLoader.awaitInstance() .then(() => { - isRuntimeReady = true; + state.isRuntimeReady = true; }) .catch((error) => { - runtimeInitPromise = null; + state.runtimeInitPromise = null; throw error; }); - return runtimeInitPromise; + await state.runtimeInitPromise; + return runtime; +} + +/** + * Ensure Rive WASM runtime is initialized once across the app. + * Safe to call from multiple concurrent code paths. + */ +export async function ensureRiveRuntimeReady( + config?: RiveRuntimeResolvedConfig | null, +): Promise { + if (typeof window === 'undefined') { + return loadRiveSdk(config?.renderer ?? DEFAULT_RIVE_RENDERER); + } + + const preferredRenderer = config?.renderer ?? DEFAULT_RIVE_RENDERER; + const strictMode = config?.strict === true; + + try { + return await ensureRuntimeForRenderer(preferredRenderer, config); + } catch (primaryError) { + if (strictMode) { + throw primaryError; + } + + const fallbackRenderer = getFallbackRenderer(preferredRenderer); + return ensureRuntimeForRenderer(fallbackRenderer, config); + } } diff --git a/libs/rive-angular/src/lib/utils/rive-sdk.ts b/libs/rive-angular/src/lib/utils/rive-sdk.ts new file mode 100644 index 0000000..f2d70d6 --- /dev/null +++ b/libs/rive-angular/src/lib/utils/rive-sdk.ts @@ -0,0 +1,54 @@ +import * as canvasSdk from '@rive-app/canvas'; + +export type RiveRenderer = 'canvas' | 'webgl2'; + +export interface RiveSdkLoadResult { + renderer: RiveRenderer; + sdk: RiveSdkModule; +} + +export type CanvasRiveSdkModule = typeof import('@rive-app/canvas'); +export type Webgl2RiveSdkModule = typeof import('@rive-app/webgl2'); +export type RiveSdkModule = CanvasRiveSdkModule | Webgl2RiveSdkModule; + +export const DEFAULT_RIVE_RENDERER: RiveRenderer = 'canvas'; +export const CANVAS_RIVE_SDK: CanvasRiveSdkModule = canvasSdk; + +/** + * Keep type/value exports stable for existing consumers. + * Runtime-specific module selection is handled by `loadRiveSdk`. + */ +export { + Fit, + Alignment, + EventType, + LoopType, + Rive, + RiveFile, + Layout, + StateMachineInput, + ViewModelInstance, +} from '@rive-app/canvas'; + +export type { + LayoutParameters, + RiveParameters, + RiveFileParameters, + Event as RiveEvent, + LoopEvent, +} from '@rive-app/canvas'; + +export function getFallbackRenderer(renderer: RiveRenderer): RiveRenderer { + return renderer === 'webgl2' ? 'canvas' : 'webgl2'; +} + +export async function loadRiveSdk( + renderer: RiveRenderer, +): Promise { + if (renderer === 'webgl2') { + const sdk = await import('@rive-app/webgl2'); + return { renderer, sdk }; + } + + return { renderer: 'canvas', sdk: canvasSdk }; +} diff --git a/libs/rive-angular/src/lib/utils/runtime-config.ts b/libs/rive-angular/src/lib/utils/runtime-config.ts index dc4607d..d6aeb53 100644 --- a/libs/rive-angular/src/lib/utils/runtime-config.ts +++ b/libs/rive-angular/src/lib/utils/runtime-config.ts @@ -5,6 +5,10 @@ import { makeEnvironmentProviders, provideAppInitializer, } from '@angular/core'; +import { + DEFAULT_RIVE_RENDERER, + type RiveRenderer, +} from './rive-sdk'; import { ensureRiveRuntimeReady } from './rive-runtime'; /** @@ -15,6 +19,8 @@ import { ensureRiveRuntimeReady } from './rive-runtime'; export interface RiveRuntimeConfig { wasmUrl?: string; lazy?: true; + renderer?: RiveRenderer; + strict?: boolean; } /** @@ -23,6 +29,8 @@ export interface RiveRuntimeConfig { export interface RiveRuntimeResolvedConfig { wasmUrl?: string; lazy: boolean; + renderer: RiveRenderer; + strict: boolean; } /** @@ -37,6 +45,8 @@ function resolveRuntimeConfig( return { wasmUrl: config?.wasmUrl, lazy: config?.lazy === true, + renderer: config?.renderer ?? DEFAULT_RIVE_RENDERER, + strict: config?.strict === true, }; } diff --git a/libs/rive-angular/src/lib/utils/validator.ts b/libs/rive-angular/src/lib/utils/validator.ts index a58b4cc..8ed1809 100644 --- a/libs/rive-angular/src/lib/utils/validator.ts +++ b/libs/rive-angular/src/lib/utils/validator.ts @@ -1,4 +1,4 @@ -import { Rive } from '@rive-app/canvas'; +import type { Rive } from './rive-sdk'; import { RiveValidationError } from '../models/rive.model'; import { RiveErrorCode, formatErrorMessage } from './error-codes'; import { RiveLogger } from './logger'; diff --git a/package-lock.json b/package-lock.json index 3cf2a4f..e41bc3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser-dynamic": "~21.1.0", "@angular/router": "~21.1.0", "@rive-app/canvas": "^2.35.0", + "@rive-app/webgl2": "^2.37.2", "rxjs": "~7.8.0" }, "devDependencies": { @@ -1087,24 +1088,6 @@ "node": ">=10.13.0" } }, - "node_modules/@angular/build/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/@angular/cli": { "version": "21.1.4", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.4.tgz", @@ -9983,6 +9966,12 @@ "integrity": "sha512-aBFgoM11X/YgpSKZDxOvt3P9EZz8siJ+AZ5aevwaiLwu9K1nHSumXDCdmJPp+9FWhW9Ssk9Le14BptnlCR1m7w==", "license": "MIT" }, + "node_modules/@rive-app/webgl2": { + "version": "2.37.2", + "resolved": "https://registry.npmjs.org/@rive-app/webgl2/-/webgl2-2.37.2.tgz", + "integrity": "sha512-fQFBGDcjOGNIyhW33lBdamj15oVJTCLHsKBKGolXFjHziFj+Fw0kKd3pRekZNkyfZ0IUQwoS/P2bnF+Vz98CrQ==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.58", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.58.tgz", @@ -15911,21 +15900,6 @@ } } }, - "node_modules/data-urls/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/data-urls/node_modules/tr46": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", @@ -20909,21 +20883,6 @@ } } }, - "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/jsdom/node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", diff --git a/package.json b/package.json index cfc2473..c4ea9d2 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@angular/platform-browser-dynamic": "~21.1.0", "@angular/router": "~21.1.0", "@rive-app/canvas": "^2.35.0", + "@rive-app/webgl2": "^2.37.2", "rxjs": "~7.8.0" }, "nx": { From e949c96d64cf0b8119f4a505b05b5580c9fe65d3 Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 19:55:34 +0300 Subject: [PATCH 02/12] ci: pin npm 11.12.1 for npm ci to match lockfile --- .github/workflows/ci.yml | 6 ++++++ package.json | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b3dbf..36d0aa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' + - name: Install pinned npm (match packageManager) + run: npm install -g npm@11.12.1 + - name: Install dependencies run: npm ci @@ -63,6 +66,9 @@ jobs: node-version: '20.x' cache: 'npm' + - name: Install pinned npm (match packageManager) + run: npm install -g npm@11.12.1 + - name: Install dependencies run: npm ci diff --git a/package.json b/package.json index c4ea9d2..54bbd08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@nxw/source", "version": "0.0.0", + "packageManager": "npm@11.12.1", "license": "MIT", "scripts": { "build:lib": "nx build rive-angular", From d9d525b75bcd0c1a753ae6115909bf398a1b5257 Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 19:58:08 +0300 Subject: [PATCH 03/12] ci: use Corepack to activate pinned npm instead of global install --- .github/workflows/ci.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36d0aa5..661c0f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,11 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - - name: Install pinned npm (match packageManager) - run: npm install -g npm@11.12.1 + - name: Use pinned npm via Corepack (match packageManager) + run: | + corepack enable + corepack prepare npm@11.12.1 --activate + npm --version - name: Install dependencies run: npm ci @@ -66,8 +69,11 @@ jobs: node-version: '20.x' cache: 'npm' - - name: Install pinned npm (match packageManager) - run: npm install -g npm@11.12.1 + - name: Use pinned npm via Corepack (match packageManager) + run: | + corepack enable + corepack prepare npm@11.12.1 --activate + npm --version - name: Install dependencies run: npm ci From ca5e1812929232d80fe459320f1623922c5537c0 Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 20:14:50 +0300 Subject: [PATCH 04/12] chore: add yaml and @noble/hashes dev deps to sync lockfile for npm ci --- package-lock.json | 65 +++++++++++++++++++++++++++++------------------ package.json | 4 ++- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index e41bc3a..3ff040c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@angular/compiler-cli": "~21.1.0", "@angular/language-service": "~21.1.0", "@eslint/js": "^9.8.0", + "@noble/hashes": "2.2.0", "@nx/angular": "^22.5.0", "@nx/eslint": "22.5.0", "@nx/eslint-plugin": "22.5.0", @@ -61,7 +62,8 @@ "tslib": "^2.3.0", "typescript": "~5.9.2", "typescript-eslint": "^8.40.0", - "verdaccio": "^6.0.5" + "verdaccio": "^6.0.5", + "yaml": "2.8.3" } }, "node_modules/@acemir/cssom": { @@ -7778,13 +7780,13 @@ } }, "node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -15494,6 +15496,16 @@ "node": ">=10" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -23318,22 +23330,6 @@ "node": ">= 4" } }, - "node_modules/nx/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -24220,6 +24216,19 @@ "node": ">=16.0.0" } }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -29705,13 +29714,19 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index 54bbd08..6069b2d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@angular/compiler-cli": "~21.1.0", "@angular/language-service": "~21.1.0", "@eslint/js": "^9.8.0", + "@noble/hashes": "2.2.0", "@nx/angular": "^22.5.0", "@nx/eslint": "22.5.0", "@nx/eslint-plugin": "22.5.0", @@ -52,7 +53,8 @@ "tslib": "^2.3.0", "typescript": "~5.9.2", "typescript-eslint": "^8.40.0", - "verdaccio": "^6.0.5" + "verdaccio": "^6.0.5", + "yaml": "2.8.3" }, "dependencies": { "@angular/common": "~21.1.0", From 32a0ee647194939fd6694dddf4d0a20632ab4e84 Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 21:32:20 +0300 Subject: [PATCH 05/12] =?UTF-8?q?feat(rive-angular)!:=20v2=20beta=20?= =?UTF-8?q?=E2=80=94=20optional=20runtimes,=20dynamic=20SDK,=20type-only?= =?UTF-8?q?=20Rive=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load canvas/webgl2 SDKs via dynamic import; centralize missing-module and fallback errors with actionable messages - Default resolved runtime config when provideRiveRuntime is absent; unify component and file service initialization - Replace static re-exports from @rive-app/canvas with local enums/types; export Rive/RiveFile/Layout/StateMachineInput/ViewModelInstance as types only - Optional peerDependenciesMeta for both Rive runtimes; README install matrix - Bump package to 2.0.0-beta.0; changelog for v2; remove unreleased 1.2.0-beta.0 section; stabilize zoneless specs and reset runtime state in tests --- README.md | 57 +- libs/rive-angular/CHANGELOG.md | 34 +- libs/rive-angular/README.md | 57 +- libs/rive-angular/package.json | 5 +- libs/rive-angular/src/index.ts | 12 +- .../components/rive-canvas.component.spec.ts | 920 ++++++++---------- .../lib/components/rive-canvas.component.ts | 20 +- .../lib/services/rive-file.service.spec.ts | 21 +- .../src/lib/services/rive-file.service.ts | 19 +- .../src/lib/utils/rive-runtime-errors.ts | 72 ++ .../src/lib/utils/rive-runtime.spec.ts | 14 + .../src/lib/utils/rive-runtime.ts | 17 +- libs/rive-angular/src/lib/utils/rive-sdk.ts | 227 ++++- .../src/lib/utils/runtime-config.ts | 7 + 14 files changed, 874 insertions(+), 608 deletions(-) create mode 100644 libs/rive-angular/src/lib/utils/rive-runtime-errors.ts diff --git a/README.md b/README.md index a737632..eacd1af 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,26 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. -**1.x** is the stable major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`1.2.0-beta.0`** (pre-release). +**2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). +Current release candidate in this branch: **`2.0.0-beta.0`** (pre-release). + +## Migration from v1 to v2 + +Version `2.0.0` contains a deliberate breaking change to remove hard runtime linkage to `@rive-app/canvas` and fully support `webgl2-only` installations. + +### Breaking change: runtime value exports removed + +These exports are now **type-only** from `@grandgular/rive-angular`: + +- `Rive` +- `RiveFile` +- `Layout` +- `StateMachineInput` +- `ViewModelInstance` + +If your app used them as runtime values (for example `new Rive(...)` from this package), switch to direct SDK imports from `@rive-app/canvas` or `@rive-app/webgl2` depending on your runtime path. + +Most applications using `RiveCanvasComponent`, `RiveFileService`, `Fit`, `Alignment`, `EventType`, `LoopType`, and component APIs do not require code changes. ## What is Rive? @@ -71,9 +89,11 @@ Both libraries provide similar features and follow the same philosophy of provid ```bash npm uninstall ng-rive -npm install @grandgular/rive-angular @rive-app/canvas @rive-app/webgl2 +npm install @grandgular/rive-angular @rive-app/canvas ``` +If you use WebGL2 (`provideRiveRuntime({ renderer: 'webgl2' })`) or rely on automatic fallback from WebGL2 to Canvas, also install `@rive-app/webgl2` (see [Installation](#installation)). + ### 2. Update imports | ng-rive | @grandgular/rive-angular | @@ -133,22 +153,38 @@ onLoaded() { ## Installation +`@rive-app/canvas` and `@rive-app/webgl2` are **optional peer dependencies**: install the runtime package(s) that match how you configure the library. The bundler only pulls in the SDK that is actually imported for your `renderer` / fallback path. + +| Goal | Packages to install | +|------|------------------------| +| Canvas only (default `renderer`, or you never use WebGL2) | `@grandgular/rive-angular` + `@rive-app/canvas` | +| WebGL2 only (e.g. `renderer: 'webgl2'` and `strict: true`, so no Canvas fallback) | `@grandgular/rive-angular` + `@rive-app/webgl2` | +| WebGL2 with automatic fallback to Canvas (`strict: false`) | `@grandgular/rive-angular` + `@rive-app/canvas` + `@rive-app/webgl2` | + ```bash -npm install @grandgular/rive-angular @rive-app/canvas @rive-app/webgl2 +npm install @grandgular/rive-angular @rive-app/canvas ``` Or with yarn: ```bash -yarn add @grandgular/rive-angular @rive-app/canvas @rive-app/webgl2 +yarn add @grandgular/rive-angular @rive-app/canvas +``` + +Add WebGL2 when needed: + +```bash +npm install @rive-app/webgl2 ``` +If a required package is missing, initialization fails with an error that names the package and suggests either installing it or setting `strict: true` to avoid loading that renderer/fallback path. + ## Quick Start ### Selector notice - Preferred selector: `` -- Legacy selector: `` (deprecated, will be removed in the next major version) +- Legacy selector: `` (deprecated, will be removed in a future major version) Both selectors are supported in the current major for backward compatibility. @@ -628,8 +664,10 @@ provideRiveRuntime({ - `renderer` is optional; default is `'canvas'`. - `strict` is optional; default is `false`. -- With `strict: false`, the library automatically falls back to the other renderer if the preferred renderer fails to initialize. -- With `strict: true`, fallback is disabled and initialization fails fast. +- With `strict: false`, the library automatically falls back to the other renderer if the preferred renderer fails to initialize. **Both** `@rive-app/canvas` and `@rive-app/webgl2` must be installed for fallback to succeed if the other SDK is needed. +- With `strict: true`, fallback is disabled and initialization fails fast if the chosen renderer cannot load — useful when you intentionally ship only one runtime package. + +If fallback fails (for example WebGL2 unavailable **and** the Canvas package is not installed), the error explains what failed and suggests installing the missing package or using `strict: true` with a single renderer. ### Migration from `provideAppInitializer` @@ -884,8 +922,7 @@ See [CHANGELOG.md](libs/rive-angular/CHANGELOG.md) for complete details, migrati ## Requirements - Angular 18.0.0 or higher -- @rive-app/canvas 2.35.0 or higher -- @rive-app/webgl2 2.35.0 or higher (optional, required for vector feathering) +- At least one of: `@rive-app/canvas` or `@rive-app/webgl2` (^2.35.0), matching your `provideRiveRuntime` / fallback setup (see [Installation](#installation)) - TypeScript 5.4 or higher ## Contributing diff --git a/libs/rive-angular/CHANGELOG.md b/libs/rive-angular/CHANGELOG.md index 31cdc3d..a72e187 100644 --- a/libs/rive-angular/CHANGELOG.md +++ b/libs/rive-angular/CHANGELOG.md @@ -2,28 +2,38 @@ All notable changes to this project will be documented in this file. -## [1.2.0-beta.0] - 2026-04-17 +## [2.0.0-beta.0] - 2026-04-19 ### Added -- **Dual runtime support** for `@rive-app/canvas` and `@rive-app/webgl2`. -- **New preferred component selector**: `` (with `` still supported as a legacy alias). -- **New runtime options** in `provideRiveRuntime()`: - - `renderer?: 'canvas' | 'webgl2'` (default: `'canvas'`) - - `strict?: boolean` (default: `false`) -- **Automatic fallback behavior** when `strict` is `false`: if the preferred renderer fails to initialize, the library tries the other renderer automatically. +- **Dual runtime support** for `@rive-app/canvas` and `@rive-app/webgl2` with `provideRiveRuntime()` options `renderer` and `strict`, and automatic fallback when `strict` is `false`. +- **Preferred component selector**: `` (with `` still supported as a legacy alias). - **Runtime tests** covering `webgl2`, fallback behavior, and strict mode failures. ### Changed -- Runtime initialization now resolves and returns the active renderer module internally, used by both `RiveCanvasComponent` and `RiveFileService`. -- Internal SDK imports are centralized through a shared facade to simplify runtime selection. +- Runtime SDK loading now uses dynamic imports for both renderers and no longer keeps a static runtime import of `@rive-app/canvas`. +- Runtime error handling now emits clearer missing-package and fallback failure messages, including install hints and `strict: true` guidance. +- Runtime initialization defaults are centralized through `DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG` and reused by both `RiveCanvasComponent` and `RiveFileService`. +- README/docs were updated to describe optional runtime peers and renderer/fallback installation matrix. + +### Fixed + +- Stabilized async zoneless specs in `RiveCanvasComponent` and `RiveFileService` by removing dangling timers and isolating runtime lifecycle state between tests. + +### Breaking Changes + +- The following exports from `@grandgular/rive-angular` are now **type-only** and are no longer runtime values: `Rive`, `RiveFile`, `Layout`, `StateMachineInput`, `ViewModelInstance`. +- This removes accidental hard linkage to `@rive-app/canvas` in `webgl2-only` installs. + +### Migration + +- If you used these symbols as runtime classes/values, import them directly from the selected Rive SDK package (`@rive-app/canvas` or `@rive-app/webgl2`). +- Typical component/service usage (`RiveCanvasComponent`, `RiveFileService`, `Fit`, `Alignment`, `EventType`, `LoopType`) remains unchanged. ### Notes -- This release prioritizes backward compatibility and functional dual-support. -- Bundle-level optimization (ensuring non-selected runtime is excluded from bundles) is intentionally planned for a follow-up release. -- `` is now considered deprecated and is planned for removal in the next major release. +- `` is deprecated and is planned for removal in a future major release. ## [1.1.0] - 2026-04-16 diff --git a/libs/rive-angular/README.md b/libs/rive-angular/README.md index 3c46be5..8082c23 100644 --- a/libs/rive-angular/README.md +++ b/libs/rive-angular/README.md @@ -10,8 +10,26 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. -**1.x** is the stable major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`1.2.0-beta.0`** (pre-release). +**2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). +Current release candidate in this branch: **`2.0.0-beta.0`** (pre-release). + +## Migration from v1 to v2 + +Version `2.0.0` contains a deliberate breaking change to remove hard runtime linkage to `@rive-app/canvas` and fully support `webgl2-only` installations. + +### Breaking change: runtime value exports removed + +These exports are now **type-only** from `@grandgular/rive-angular`: + +- `Rive` +- `RiveFile` +- `Layout` +- `StateMachineInput` +- `ViewModelInstance` + +If your app used them as runtime values (for example `new Rive(...)` from this package), switch to direct SDK imports from `@rive-app/canvas` or `@rive-app/webgl2` depending on your runtime path. + +Most applications using `RiveCanvasComponent`, `RiveFileService`, `Fit`, `Alignment`, `EventType`, `LoopType`, and component APIs do not require code changes. ## What is Rive? @@ -69,9 +87,11 @@ Both libraries provide similar features and follow the same philosophy of provid ```bash npm uninstall ng-rive -npm install @grandgular/rive-angular @rive-app/canvas @rive-app/webgl2 +npm install @grandgular/rive-angular @rive-app/canvas ``` +If you use WebGL2 (`provideRiveRuntime({ renderer: 'webgl2' })`) or rely on automatic fallback from WebGL2 to Canvas, also install `@rive-app/webgl2` (see [Installation](#installation)). + ### 2. Update imports | ng-rive | @grandgular/rive-angular | @@ -131,22 +151,38 @@ onLoaded() { ## Installation +`@rive-app/canvas` and `@rive-app/webgl2` are **optional peer dependencies**: install the runtime package(s) that match how you configure the library. The bundler only pulls in the SDK that is actually imported for your `renderer` / fallback path. + +| Goal | Packages to install | +|------|------------------------| +| Canvas only (default `renderer`, or you never use WebGL2) | `@grandgular/rive-angular` + `@rive-app/canvas` | +| WebGL2 only (e.g. `renderer: 'webgl2'` and `strict: true`, so no Canvas fallback) | `@grandgular/rive-angular` + `@rive-app/webgl2` | +| WebGL2 with automatic fallback to Canvas (`strict: false`) | `@grandgular/rive-angular` + `@rive-app/canvas` + `@rive-app/webgl2` | + ```bash -npm install @grandgular/rive-angular @rive-app/canvas @rive-app/webgl2 +npm install @grandgular/rive-angular @rive-app/canvas ``` Or with yarn: ```bash -yarn add @grandgular/rive-angular @rive-app/canvas @rive-app/webgl2 +yarn add @grandgular/rive-angular @rive-app/canvas +``` + +Add WebGL2 when needed: + +```bash +npm install @rive-app/webgl2 ``` +If a required package is missing, initialization fails with an error that names the package and suggests either installing it or setting `strict: true` to avoid loading that renderer/fallback path. + ## Quick Start ### Selector notice - Preferred selector: `` -- Legacy selector: `` (deprecated, will be removed in the next major version) +- Legacy selector: `` (deprecated, will be removed in a future major version) Both selectors are supported in the current major for backward compatibility. @@ -626,8 +662,10 @@ provideRiveRuntime({ - `renderer` is optional; default is `'canvas'`. - `strict` is optional; default is `false`. -- With `strict: false`, the library automatically falls back to the other renderer if the preferred renderer fails to initialize. -- With `strict: true`, fallback is disabled and initialization fails fast. +- With `strict: false`, the library automatically falls back to the other renderer if the preferred renderer fails to initialize. **Both** `@rive-app/canvas` and `@rive-app/webgl2` must be installed for fallback to succeed if the other SDK is needed. +- With `strict: true`, fallback is disabled and initialization fails fast if the chosen renderer cannot load — useful when you intentionally ship only one runtime package. + +If fallback fails (for example WebGL2 unavailable **and** the Canvas package is not installed), the error explains what failed and suggests installing the missing package or using `strict: true` with a single renderer. ### Migration from `provideAppInitializer` @@ -882,8 +920,7 @@ See [CHANGELOG.md](./CHANGELOG.md) for complete details, migration guide, and al ## Requirements - Angular 18.0.0 or higher -- @rive-app/canvas 2.35.0 or higher -- @rive-app/webgl2 2.35.0 or higher (optional, required for vector feathering) +- At least one of: `@rive-app/canvas` or `@rive-app/webgl2` (^2.35.0), matching your `provideRiveRuntime` / fallback setup (see [Installation](#installation)) - TypeScript 5.4 or higher ## Contributing diff --git a/libs/rive-angular/package.json b/libs/rive-angular/package.json index 761687e..31e96cd 100644 --- a/libs/rive-angular/package.json +++ b/libs/rive-angular/package.json @@ -1,6 +1,6 @@ { "name": "@grandgular/rive-angular", - "version": "1.2.0-beta.0", + "version": "2.0.0-beta.0", "description": "Modern Angular wrapper for Rive animations with reactive state management, built with signals and zoneless architecture", "keywords": [ "angular", @@ -43,6 +43,9 @@ "@rive-app/webgl2": "^2.35.0" }, "peerDependenciesMeta": { + "@rive-app/canvas": { + "optional": true + }, "@rive-app/webgl2": { "optional": true } diff --git a/libs/rive-angular/src/index.ts b/libs/rive-angular/src/index.ts index e8347bb..fcd5138 100644 --- a/libs/rive-angular/src/index.ts +++ b/libs/rive-angular/src/index.ts @@ -40,7 +40,9 @@ export { export { provideRiveRuntime, + DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG, type RiveRuntimeConfig, + type RiveRuntimeResolvedConfig, } from './lib/utils/runtime-config'; // Color utilities for data binding @@ -50,14 +52,14 @@ export { riveColorToHex, } from './lib/utils/color-parser'; -// Re-export commonly used types from Rive SDK for convenience -export { +// Re-export commonly used Rive SDK-compatible types for convenience +export type { Rive, RiveFile, Layout, StateMachineInput, ViewModelInstance, - type LayoutParameters, - type RiveParameters, - type RiveFileParameters, + LayoutParameters, + RiveParameters, + RiveFileParameters, } from './lib/utils/rive-sdk'; diff --git a/libs/rive-angular/src/lib/components/rive-canvas.component.spec.ts b/libs/rive-angular/src/lib/components/rive-canvas.component.spec.ts index fb91ed4..f4289cc 100644 --- a/libs/rive-angular/src/lib/components/rive-canvas.component.spec.ts +++ b/libs/rive-angular/src/lib/components/rive-canvas.component.spec.ts @@ -9,6 +9,7 @@ import { LoopType, type RiveParameters, } from '@rive-app/canvas'; +import { resetRiveRuntimeLifecycleForTests } from '../utils/rive-runtime'; // Mock Rive jest.mock('@rive-app/canvas', () => ({ @@ -58,6 +59,13 @@ jest.mock('@rive-app/canvas', () => ({ }, })); +jest.mock('@rive-app/webgl2', () => ({ + RuntimeLoader: { + awaitInstance: jest.fn().mockResolvedValue(undefined), + setWasmUrl: jest.fn(), + }, +})); + // Mock IntersectionObserver class MockIntersectionObserver { observe = jest.fn(); @@ -76,6 +84,18 @@ class MockResizeObserver { global.ResizeObserver = MockResizeObserver as any; +/** + * After `ensureRiveRuntimeReady`, Rive is created on a microtask outside the Angular zone. + * `fixture.whenStable()` does not wait for that in zoneless tests; a macrotask flush does. + */ +async function detectChangesAndSettle( + fixture: ComponentFixture, +): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + describe('RiveCanvasComponent', () => { let component: RiveCanvasComponent; let fixture: ComponentFixture; @@ -214,6 +234,8 @@ describe('RiveCanvasComponent', () => { }; beforeEach(async () => { + resetRiveRuntimeLifecycleForTests(); + mockRive = { cleanup: jest.fn(), play: jest.fn(), @@ -273,9 +295,9 @@ describe('RiveCanvasComponent', () => { expect(component.automaticallyHandleEvents()).toBe(false); }); - it('should load animation from src after view init', () => { + it('should load animation from src after view init', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledWith( expect.objectContaining({ @@ -285,7 +307,7 @@ describe('RiveCanvasComponent', () => { ); }); - it('should emit loaded event on successful load', (done) => { + it('should emit loaded event on successful load', async () => { let onLoadCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -301,22 +323,23 @@ describe('RiveCanvasComponent', () => { component.loaded.subscribe(() => { loadedEmitted = true; expect(component.isLoaded()).toBe(true); - if (loadedEmitted && riveReadyEmitted) done(); }); component.riveReady.subscribe(() => { riveReadyEmitted = true; expect(component.riveInstance()).toBe(mockRive); - if (loadedEmitted && riveReadyEmitted) done(); }); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(loadedEmitted).toBe(true); + expect(riveReadyEmitted).toBe(true); }); - it('should emit loadError event on load failure', (done) => { + it('should emit loadError event on load failure', async () => { let onLoadErrorCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -326,47 +349,48 @@ describe('RiveCanvasComponent', () => { }, ); - component.loadError.subscribe((error) => { - expect(error).toBeDefined(); - expect(error.name).toBe('RiveLoadError'); - done(); + const errorPromise = new Promise((resolve) => { + component.loadError.subscribe((error) => resolve(error)); }); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadErrorCallback!(); + const error = await errorPromise; + expect(error).toBeDefined(); + expect((error as Error).name).toBe('RiveLoadError'); }); - it('should cleanup Rive instance on destroy', () => { + it('should cleanup Rive instance on destroy', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); fixture.destroy(); expect(mockRive.cleanup).toHaveBeenCalled(); }); - it('should reload animation when src changes', () => { + it('should reload animation when src changes', async () => { fixture.componentRef.setInput('src', 'test1.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledTimes(1); fixture.componentRef.setInput('src', 'test2.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledTimes(2); expect(mockRive.cleanup).toHaveBeenCalledTimes(1); }); - it('should prioritize riveFile over src and buffer', () => { + it('should prioritize riveFile over src and buffer', async () => { const mockRiveFile = {} as RiveFile; fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('buffer', new ArrayBuffer(100)); fixture.componentRef.setInput('riveFile', mockRiveFile); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledWith( expect.objectContaining({ @@ -382,9 +406,9 @@ describe('RiveCanvasComponent', () => { }); describe('Public API methods', () => { - beforeEach(() => { + beforeEach(async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); }); it('should play animation', () => { @@ -434,7 +458,7 @@ describe('RiveCanvasComponent', () => { }); describe('Signals', () => { - it('should update isPlaying signal on play', (done) => { + it('should update isPlaying signal on play', async () => { let onPlayCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -445,18 +469,16 @@ describe('RiveCanvasComponent', () => { ); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onPlayCallback!(); - setTimeout(() => { - expect(component.isPlaying()).toBe(true); - expect(component.isPaused()).toBe(false); - done(); - }, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(component.isPlaying()).toBe(true); + expect(component.isPaused()).toBe(false); }); - it('should update isPaused signal on pause', (done) => { + it('should update isPaused signal on pause', async () => { let onPauseCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -467,18 +489,16 @@ describe('RiveCanvasComponent', () => { ); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onPauseCallback!(); - setTimeout(() => { - expect(component.isPaused()).toBe(true); - expect(component.isPlaying()).toBe(false); - done(); - }, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(component.isPaused()).toBe(true); + expect(component.isPlaying()).toBe(false); }); - it('should expose riveInstance signal after load', (done) => { + it('should expose riveInstance signal after load', async () => { let onLoadCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -489,27 +509,21 @@ describe('RiveCanvasComponent', () => { ); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); - - // Before load, instance is set but not ready - setTimeout(() => { - expect(component.riveInstance()).toBe(mockRive); - - // After load, riveReady should emit - component.riveReady.subscribe((rive) => { - expect(rive).toBe(mockRive); - done(); - }); + await detectChangesAndSettle(fixture); - onLoadCallback!(); - }, 0); + const ready = new Promise((resolve) => { + component.riveReady.subscribe(() => resolve()); + }); + onLoadCallback!(); + await ready; + expect(component.riveInstance()).toBe(mockRive); }); }); describe('Animation lifecycle events', () => { - it('should pass onLoop and onAdvance callbacks to Rive', () => { + it('should pass onLoop and onAdvance callbacks to Rive', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledWith( expect.objectContaining({ @@ -519,7 +533,7 @@ describe('RiveCanvasComponent', () => { ); }); - it('should emit animationPlay and keep isPlaying in sync', (done) => { + it('should emit animationPlay and keep isPlaying in sync', async () => { let onPlayCallback: RiveParameters['onPlay']; (Rive as jest.MockedClass).mockImplementation( @@ -533,19 +547,17 @@ describe('RiveCanvasComponent', () => { component.animationPlay.subscribe(playSpy); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onPlayCallback?.({ type: EventType.Play }); - setTimeout(() => { - expect(playSpy).toHaveBeenCalledWith({ type: EventType.Play }); - expect(component.isPlaying()).toBe(true); - expect(component.isPaused()).toBe(false); - done(); - }, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(playSpy).toHaveBeenCalledWith({ type: EventType.Play }); + expect(component.isPlaying()).toBe(true); + expect(component.isPaused()).toBe(false); }); - it('should emit animationPause and keep isPaused in sync', (done) => { + it('should emit animationPause and keep isPaused in sync', async () => { let onPauseCallback: RiveParameters['onPause']; (Rive as jest.MockedClass).mockImplementation( @@ -559,19 +571,17 @@ describe('RiveCanvasComponent', () => { component.animationPause.subscribe(pauseSpy); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onPauseCallback?.({ type: EventType.Pause }); - setTimeout(() => { - expect(pauseSpy).toHaveBeenCalledWith({ type: EventType.Pause }); - expect(component.isPaused()).toBe(true); - expect(component.isPlaying()).toBe(false); - done(); - }, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(pauseSpy).toHaveBeenCalledWith({ type: EventType.Pause }); + expect(component.isPaused()).toBe(true); + expect(component.isPlaying()).toBe(false); }); - it('should emit animationStop and reset playing state', (done) => { + it('should emit animationStop and reset playing state', async () => { let onStopCallback: RiveParameters['onStop']; (Rive as jest.MockedClass).mockImplementation( @@ -585,19 +595,17 @@ describe('RiveCanvasComponent', () => { component.animationStop.subscribe(stopSpy); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onStopCallback?.({ type: EventType.Stop }); - setTimeout(() => { - expect(stopSpy).toHaveBeenCalledWith({ type: EventType.Stop }); - expect(component.isPlaying()).toBe(false); - expect(component.isPaused()).toBe(false); - done(); - }, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(stopSpy).toHaveBeenCalledWith({ type: EventType.Stop }); + expect(component.isPlaying()).toBe(false); + expect(component.isPaused()).toBe(false); }); - it('should emit animationLoop with loop event data', (done) => { + it('should emit animationLoop with loop event data', async () => { let onLoopCallback: RiveParameters['onLoop']; (Rive as jest.MockedClass).mockImplementation( @@ -611,7 +619,7 @@ describe('RiveCanvasComponent', () => { component.animationLoop.subscribe(loopSpy); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); const loopPayload = { type: EventType.Loop, @@ -619,13 +627,11 @@ describe('RiveCanvasComponent', () => { }; onLoopCallback?.(loopPayload); - setTimeout(() => { - expect(loopSpy).toHaveBeenCalledWith(loopPayload); - done(); - }, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(loopSpy).toHaveBeenCalledWith(loopPayload); }); - it('should emit animationAdvance when Rive advances a frame', (done) => { + it('should emit animationAdvance when Rive advances a frame', async () => { let onAdvanceCallback: RiveParameters['onAdvance']; (Rive as jest.MockedClass).mockImplementation( @@ -639,23 +645,21 @@ describe('RiveCanvasComponent', () => { component.animationAdvance.subscribe(advanceSpy); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); const advancePayload = { type: EventType.Advance }; onAdvanceCallback?.(advancePayload); - setTimeout(() => { - expect(advanceSpy).toHaveBeenCalledWith(advancePayload); - done(); - }, 0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(advanceSpy).toHaveBeenCalledWith(advancePayload); }); }); describe('Configuration', () => { - it('should pass artboard to Rive config', () => { + it('should pass artboard to Rive config', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('artboard', 'MyArtboard'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledWith( expect.objectContaining({ @@ -664,10 +668,10 @@ describe('RiveCanvasComponent', () => { ); }); - it('should pass animations to Rive config', () => { + it('should pass animations to Rive config', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('animations', ['anim1', 'anim2']); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledWith( expect.objectContaining({ @@ -676,10 +680,10 @@ describe('RiveCanvasComponent', () => { ); }); - it('should pass stateMachines to Rive config', () => { + it('should pass stateMachines to Rive config', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('stateMachines', 'StateMachine1'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledWith( expect.objectContaining({ @@ -688,11 +692,11 @@ describe('RiveCanvasComponent', () => { ); }); - it('should pass fit and alignment to Rive config', () => { + it('should pass fit and alignment to Rive config', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('fit', Fit.Cover); fixture.componentRef.setInput('alignment', Alignment.TopLeft); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); expect(Rive).toHaveBeenCalledWith( expect.objectContaining({ @@ -703,9 +707,9 @@ describe('RiveCanvasComponent', () => { }); describe('Phase 2: Debug Mode', () => { - it('should use debug level when debugMode is true', () => { + it('should use debug level when debugMode is true', async () => { fixture.componentRef.setInput('debugMode', true); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); // Logger should be initialized with debug level // We can verify this by checking console output in integration tests @@ -717,23 +721,21 @@ describe('RiveCanvasComponent', () => { expect(component.debugMode()).toBeUndefined(); }); - it('should update logger level when debugMode changes', (done) => { + it('should update logger level when debugMode changes', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('debugMode', false); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); - setTimeout(() => { - fixture.componentRef.setInput('debugMode', true); - fixture.detectChanges(); + await new Promise((r) => setTimeout(r, 0)); + fixture.componentRef.setInput('debugMode', true); + await detectChangesAndSettle(fixture); - expect(component.debugMode()).toBe(true); - done(); - }, 0); + expect(component.debugMode()).toBe(true); }); }); describe('Phase 2: Validation', () => { - it('should emit RiveValidationError for invalid artboard name', (done) => { + it('should emit RiveValidationError for invalid artboard name', async () => { let onLoadCallback: (() => void) | undefined; const mockRiveWithArtboards = { @@ -755,21 +757,18 @@ describe('RiveCanvasComponent', () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('artboard', 'InvalidArtboard'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - expect(errors.length).toBeGreaterThan(0); - const validationError = errors.find( - (e) => e.name === 'RiveValidationError', - ); - expect(validationError).toBeDefined(); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + expect(errors.length).toBeGreaterThan(0); + const validationError = errors.find( + (e) => e.name === 'RiveValidationError', + ); + expect(validationError).toBeDefined(); }); - it('should emit RiveValidationError for invalid animation name', (done) => { + it('should emit RiveValidationError for invalid animation name', async () => { let onLoadCallback: (() => void) | undefined; const mockRiveWithAnimations = { @@ -791,21 +790,18 @@ describe('RiveCanvasComponent', () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('animations', 'InvalidAnimation'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - expect(errors.length).toBeGreaterThan(0); - const validationError = errors.find( - (e) => e.name === 'RiveValidationError', - ); - expect(validationError).toBeDefined(); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + expect(errors.length).toBeGreaterThan(0); + const validationError = errors.find( + (e) => e.name === 'RiveValidationError', + ); + expect(validationError).toBeDefined(); }); - it('should emit RiveValidationError for invalid state machine name', (done) => { + it('should emit RiveValidationError for invalid state machine name', async () => { let onLoadCallback: (() => void) | undefined; const mockRiveWithStateMachines = { @@ -827,21 +823,18 @@ describe('RiveCanvasComponent', () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('stateMachines', 'InvalidSM'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - expect(errors.length).toBeGreaterThan(0); - const validationError = errors.find( - (e) => e.name === 'RiveValidationError', - ); - expect(validationError).toBeDefined(); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + expect(errors.length).toBeGreaterThan(0); + const validationError = errors.find( + (e) => e.name === 'RiveValidationError', + ); + expect(validationError).toBeDefined(); }); - it('should emit RiveValidationError with RIVE_204 for invalid input', (done) => { + it('should emit RiveValidationError with RIVE_204 for invalid input', async () => { let onLoadCallback: (() => void) | undefined; mockRive.stateMachineInputs.mockReturnValue([ @@ -861,24 +854,19 @@ describe('RiveCanvasComponent', () => { }); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - component.setInput('StateMachine', 'invalidInput', 42); - - setTimeout(() => { - const validationError = errors.find( - (e) => e.name === 'RiveValidationError', - ); - expect(validationError).toBeDefined(); - done(); - }, 0); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + component.setInput('StateMachine', 'invalidInput', 42); + await new Promise((r) => setTimeout(r, 0)); + const validationError = errors.find( + (e) => e.name === 'RiveValidationError', + ); + expect(validationError).toBeDefined(); }); - it('should emit RiveValidationError with RIVE_204 for invalid trigger', (done) => { + it('should emit RiveValidationError with RIVE_204 for invalid trigger', async () => { let onLoadCallback: (() => void) | undefined; mockRive.stateMachineInputs.mockReturnValue([ @@ -898,24 +886,19 @@ describe('RiveCanvasComponent', () => { }); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - component.fireTrigger('StateMachine', 'invalidTrigger'); - - setTimeout(() => { - const validationError = errors.find( - (e) => e.name === 'RiveValidationError', - ); - expect(validationError).toBeDefined(); - done(); - }, 0); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + component.fireTrigger('StateMachine', 'invalidTrigger'); + await new Promise((r) => setTimeout(r, 0)); + const validationError = errors.find( + (e) => e.name === 'RiveValidationError', + ); + expect(validationError).toBeDefined(); }); - it('should not crash when runtime metadata is unavailable', (done) => { + it('should not crash when runtime metadata is unavailable', async () => { let onLoadCallback: (() => void) | undefined; // Mock Rive instance without metadata properties @@ -935,15 +918,11 @@ describe('RiveCanvasComponent', () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('artboard', 'SomeArtboard'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - // Should complete without throwing - expect(component.isLoaded()).toBe(true); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + expect(component.isLoaded()).toBe(true); }); }); @@ -958,7 +937,7 @@ describe('RiveCanvasComponent', () => { }); describe('textRuns input (controlled keys)', () => { - it('should apply text runs after load', (done) => { + it('should apply text runs after load', async () => { let onLoadCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -973,24 +952,21 @@ describe('RiveCanvasComponent', () => { title: 'Hello', subtitle: 'World', }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'subtitle', - 'World', - ); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'subtitle', + 'World', + ); }); - it('should reactively update when textRuns values change', (done) => { + it('should reactively update when textRuns values change', async () => { let onLoadCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -1002,32 +978,26 @@ describe('RiveCanvasComponent', () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('textRuns', { title: 'Hello' }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); + + jest.clearAllMocks(); + fixture.componentRef.setInput('textRuns', { title: 'Updated' }); + await detectChangesAndSettle(fixture); - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - - // Clear mock and update input - jest.clearAllMocks(); - fixture.componentRef.setInput('textRuns', { title: 'Updated' }); - fixture.detectChanges(); - - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Updated', - ); - done(); - }, 0); - }, 0); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Updated', + ); }); - it('should emit error for non-existent text run', (done) => { + it('should emit error for non-existent text run', async () => { let onLoadCallback: (() => void) | undefined; mockRive.setTextRunValue = jest.fn((textRunName: string, textRunValue: string) => { @@ -1048,20 +1018,17 @@ describe('RiveCanvasComponent', () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('textRuns', { invalid: 'value' }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - const validationError = errors.find( - (e) => e.name === 'RiveValidationError', - ); - expect(validationError).toBeDefined(); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + const validationError = errors.find( + (e) => e.name === 'RiveValidationError', + ); + expect(validationError).toBeDefined(); }); - it('should make key uncontrolled when removed from input', (done) => { + it('should make key uncontrolled when removed from input', async () => { let onLoadCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -1076,45 +1043,38 @@ describe('RiveCanvasComponent', () => { title: 'Hello', subtitle: 'World', }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'subtitle', + 'World', + ); - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'subtitle', - 'World', - ); - - // Remove subtitle from input - jest.clearAllMocks(); - fixture.componentRef.setInput('textRuns', { title: 'Hello' }); - fixture.detectChanges(); - - setTimeout(() => { - // Only title should be set now - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - expect(mockRive.setTextRunValue).not.toHaveBeenCalledWith( - 'subtitle', - expect.anything(), - ); - done(); - }, 0); - }, 0); + jest.clearAllMocks(); + fixture.componentRef.setInput('textRuns', { title: 'Hello' }); + await detectChangesAndSettle(fixture); + + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); + expect(mockRive.setTextRunValue).not.toHaveBeenCalledWith( + 'subtitle', + expect.anything(), + ); }); }); describe('Imperative methods', () => { - beforeEach(() => { + beforeEach(async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); }); it('should get text run value', () => { @@ -1181,119 +1141,95 @@ describe('RiveCanvasComponent', () => { ); }); - it('scenario 1: controlled key + imperative call -> input wins', (done) => { + it('scenario 1: controlled key + imperative call -> input wins', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('textRuns', { title: 'Hello' }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - - // Imperative call on controlled key - jest.clearAllMocks(); - component.setTextRunValue('title', 'World'); - - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'World', - ); - - // Input updates with same value - should reapply - jest.clearAllMocks(); - fixture.componentRef.setInput('textRuns', { title: 'Hello' }); - fixture.detectChanges(); - - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - done(); - }, 0); - }, 0); - }, 0); + jest.clearAllMocks(); + component.setTextRunValue('title', 'World'); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'World', + ); + + jest.clearAllMocks(); + fixture.componentRef.setInput('textRuns', { title: 'Hello' }); + await detectChangesAndSettle(fixture); + + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); }); - it('scenario 2: controlled key changes value', (done) => { + it('scenario 2: controlled key changes value', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('textRuns', { title: 'Hello' }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - - // Input changes - jest.clearAllMocks(); - fixture.componentRef.setInput('textRuns', { title: 'Updated' }); - fixture.detectChanges(); - - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Updated', - ); - done(); - }, 0); - }, 0); + jest.clearAllMocks(); + fixture.componentRef.setInput('textRuns', { title: 'Updated' }); + await detectChangesAndSettle(fixture); + + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Updated', + ); }); - it('scenario 3: uncontrolled key + imperative call -> both preserved', (done) => { + it('scenario 3: uncontrolled key + imperative call -> both preserved', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('textRuns', { title: 'Hello' }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - - // Imperative call on uncontrolled key - jest.clearAllMocks(); - component.setTextRunValue('subtitle', 'World'); - - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'subtitle', - 'World', - ); - - // Input updates - should only set controlled key - jest.clearAllMocks(); - fixture.componentRef.setInput('textRuns', { title: 'Hello' }); - fixture.detectChanges(); - - setTimeout(() => { - expect(mockRive.setTextRunValue).toHaveBeenCalledWith( - 'title', - 'Hello', - ); - expect(mockRive.setTextRunValue).not.toHaveBeenCalledWith( - 'subtitle', - expect.anything(), - ); - done(); - }, 0); - }, 0); - }, 0); + jest.clearAllMocks(); + component.setTextRunValue('subtitle', 'World'); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'subtitle', + 'World', + ); + + jest.clearAllMocks(); + fixture.componentRef.setInput('textRuns', { title: 'Hello' }); + await detectChangesAndSettle(fixture); + + expect(mockRive.setTextRunValue).toHaveBeenCalledWith( + 'title', + 'Hello', + ); + expect(mockRive.setTextRunValue).not.toHaveBeenCalledWith( + 'subtitle', + expect.anything(), + ); }); }); describe('Warning logging', () => { - it('should log warning when setting controlled key imperatively', (done) => { + it('should log warning when setting controlled key imperatively', async () => { let onLoadCallback: (() => void) | undefined; (Rive as jest.MockedClass).mockImplementation( @@ -1308,22 +1244,16 @@ describe('RiveCanvasComponent', () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('textRuns', { title: 'Hello' }); fixture.componentRef.setInput('debugMode', true); // Enable debug logging to see warnings - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); + await new Promise((r) => setTimeout(r, 0)); + component.setTextRunValue('title', 'World'); - // Wait for the component to be fully loaded - setTimeout(() => { - // Call setTextRunValue - this should log a warning immediately - component.setTextRunValue('title', 'World'); - - // Verify the warning was logged - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('controlled by textRuns input'), - ); - warnSpy.mockRestore(); - done(); - }, 0); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('controlled by textRuns input'), + ); + warnSpy.mockRestore(); }); }); }); @@ -1354,7 +1284,7 @@ describe('RiveCanvasComponent', () => { ); }); - it('should apply dataBindings on load with auto-detected types', (done) => { + it('should apply dataBindings on load with auto-detected types', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('dataBindings', { backgroundColor: '#FF5733', @@ -1363,31 +1293,28 @@ describe('RiveCanvasComponent', () => { isActive: true, gameState: 'running', }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - expect(mockViewModelInstance.colorValue.rgba).toHaveBeenCalledWith( - 255, - 87, - 51, - 255, - ); - expect(mockViewModelInstance.numberValue.value).toBe(42); - expect(mockViewModelInstance.stringValue.value).toBe('Alice'); - expect(mockViewModelInstance.booleanValue.value).toBe(true); - expect(mockViewModelInstance.enumValue.value).toBe('running'); - expect(mockRive.bindViewModelInstance).toHaveBeenCalledWith( - mockViewModelInstance, - ); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + expect(mockViewModelInstance.colorValue.rgba).toHaveBeenCalledWith( + 255, + 87, + 51, + 255, + ); + expect(mockViewModelInstance.numberValue.value).toBe(42); + expect(mockViewModelInstance.stringValue.value).toBe('Alice'); + expect(mockViewModelInstance.booleanValue.value).toBe(true); + expect(mockViewModelInstance.enumValue.value).toBe('running'); + expect(mockRive.bindViewModelInstance).toHaveBeenCalledWith( + mockViewModelInstance, + ); }); - it('should emit dataBindingChange on callback from ViewModel property updates', (done) => { + it('should emit dataBindingChange on callback from ViewModel property updates', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); const emitted: Array<{ path: string; @@ -1397,91 +1324,76 @@ describe('RiveCanvasComponent', () => { component.dataBindingChange.subscribe((event) => emitted.push(event)); onLoadCallback!(); - - setTimeout(() => { - mockViewModelInstance.numberValue.value = 13; - mockViewModelInstance.numberValue.emitChange(); - - setTimeout(() => { - expect(emitted).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: 'score', - propertyType: 'number', - value: 13, - }), - ]), - ); - done(); - }, 0); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + mockViewModelInstance.numberValue.value = 13; + mockViewModelInstance.numberValue.emitChange(); + await new Promise((r) => setTimeout(r, 0)); + expect(emitted).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'score', + propertyType: 'number', + value: 13, + }), + ]), + ); }); - it('should support get/set helpers and fireTrigger', (done) => { + it('should support get/set helpers and fireTrigger', async () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('dataBindings', { isActive: true }); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - expect(component.getDataBinding('score')).toBe(0); - - component.setDataBinding('score', 5); - expect(mockViewModelInstance.numberValue.value).toBe(5); - expect(component.getDataBinding('score')).toBe(5); - component.setDataBinding('playerName', 'Bob'); - expect(mockViewModelInstance.stringValue.value).toBe('Bob'); - expect(component.getDataBinding('playerName')).toBe('Bob'); - component.fireViewModelTrigger('onComplete'); - expect(mockViewModelInstance.triggerValue.trigger).toHaveBeenCalled(); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + expect(component.getDataBinding('score')).toBe(0); + + component.setDataBinding('score', 5); + expect(mockViewModelInstance.numberValue.value).toBe(5); + expect(component.getDataBinding('score')).toBe(5); + component.setDataBinding('playerName', 'Bob'); + expect(mockViewModelInstance.stringValue.value).toBe('Bob'); + expect(component.getDataBinding('playerName')).toBe('Bob'); + component.fireViewModelTrigger('onComplete'); + expect(mockViewModelInstance.triggerValue.trigger).toHaveBeenCalled(); }); - it('should mark controlled keys warning and keep uncontrolled path mutable', (done) => { + it('should mark controlled keys warning and keep uncontrolled path mutable', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('dataBindings', { score: 1 }); fixture.componentRef.setInput('debugMode', true); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - component.setDataBinding('score', 2); - component.setDataBinding('gameState', 'paused'); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('controlled by dataBindings input'), - ); - expect(mockViewModelInstance.numberValue.value).toBe(2); - expect(mockViewModelInstance.enumValue.value).toBe('paused'); - warnSpy.mockRestore(); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + component.setDataBinding('score', 2); + component.setDataBinding('gameState', 'paused'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('controlled by dataBindings input'), + ); + expect(mockViewModelInstance.numberValue.value).toBe(2); + expect(mockViewModelInstance.enumValue.value).toBe('paused'); + warnSpy.mockRestore(); }); - it('should emit type mismatch validation error', (done) => { + it('should emit type mismatch validation error', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); const errors: Error[] = []; component.loadError.subscribe((error) => errors.push(error)); onLoadCallback!(); - - setTimeout(() => { - component.setDataBinding('score', 'invalid'); - setTimeout(() => { - expect(errors.some((error) => error.name === 'RiveValidationError')).toBe( - true, - ); - done(); - }, 0); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + component.setDataBinding('score', 'invalid'); + await new Promise((r) => setTimeout(r, 0)); + expect( + errors.some((error) => error.name === 'RiveValidationError'), + ).toBe(true); }); - it('should cleanup property subscriptions on destroy', (done) => { + it('should cleanup property subscriptions on destroy', async () => { const onDisposalSpy = jest.fn(); const unsubscribe = () => { onDisposalSpy(); @@ -1507,20 +1419,17 @@ describe('RiveCanvasComponent', () => { mockRive.defaultViewModel = jest.fn(() => localViewModel as any); fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); - - setTimeout(() => { - fixture.destroy(); - expect(onDisposalSpy).toHaveBeenCalled(); - done(); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + fixture.destroy(); + expect(onDisposalSpy).toHaveBeenCalled(); }); - it('should emit dataBindingChange when trigger fires', (done) => { + it('should emit dataBindingChange when trigger fires', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); const emitted: Array<{ path: string; @@ -1530,26 +1439,21 @@ describe('RiveCanvasComponent', () => { component.dataBindingChange.subscribe((event) => emitted.push(event)); onLoadCallback!(); - - setTimeout(() => { - mockViewModelInstance.triggerValue.emitChange(); - - setTimeout(() => { - expect(emitted).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: 'onComplete', - propertyType: 'trigger', - value: true, - }), - ]), - ); - done(); - }, 0); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + mockViewModelInstance.triggerValue.emitChange(); + await new Promise((r) => setTimeout(r, 0)); + expect(emitted).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'onComplete', + propertyType: 'trigger', + value: true, + }), + ]), + ); }); - it('should reinitialize ViewModel when viewModelName changes', (done) => { + it('should reinitialize ViewModel when viewModelName changes', async () => { const alternateVM = createMockViewModel(); alternateVM.name = 'AlternateViewModel'; mockRive.viewModelByName = jest.fn((name: string) => { @@ -1560,86 +1464,74 @@ describe('RiveCanvasComponent', () => { fixture.componentRef.setInput('src', 'test.riv'); fixture.componentRef.setInput('viewModelName', 'MainViewModel'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); onLoadCallback!(); + await new Promise((r) => setTimeout(r, 0)); + expect(mockRive.viewModelByName).toHaveBeenCalledWith('MainViewModel'); - setTimeout(() => { - expect(mockRive.viewModelByName).toHaveBeenCalledWith('MainViewModel'); - - fixture.componentRef.setInput('viewModelName', 'AlternateViewModel'); - fixture.detectChanges(); + fixture.componentRef.setInput('viewModelName', 'AlternateViewModel'); + await detectChangesAndSettle(fixture); - setTimeout(() => { - expect(mockRive.viewModelByName).toHaveBeenCalledWith('AlternateViewModel'); - done(); - }, 0); - }, 0); + expect(mockRive.viewModelByName).toHaveBeenCalledWith( + 'AlternateViewModel', + ); }); - it('should emit validation error for invalid opacity value', (done) => { + it('should emit validation error for invalid opacity value', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); const errors: Error[] = []; component.loadError.subscribe((error) => errors.push(error)); onLoadCallback!(); - - setTimeout(() => { - component.setColorOpacity('backgroundColor', 1.5); - - setTimeout(() => { - expect(errors.some((error) => - error.name === 'RiveValidationError' && - error.message.includes('opacity') - )).toBe(true); - done(); - }, 0); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + component.setColorOpacity('backgroundColor', 1.5); + await new Promise((r) => setTimeout(r, 0)); + expect( + errors.some( + (error) => + error.name === 'RiveValidationError' && + error.message.includes('opacity'), + ), + ).toBe(true); }); - it('should emit validation error for non-existent property in imperative API', (done) => { + it('should emit validation error for non-existent property in imperative API', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); const errors: Error[] = []; component.loadError.subscribe((error) => errors.push(error)); onLoadCallback!(); - - setTimeout(() => { - component.setDataBinding('nonExistentProperty', 42); - - setTimeout(() => { - expect(errors.some((error) => + await new Promise((r) => setTimeout(r, 0)); + component.setDataBinding('nonExistentProperty', 42); + await new Promise((r) => setTimeout(r, 0)); + expect( + errors.some( + (error) => error.name === 'RiveValidationError' && - error.message.includes('not found') - )).toBe(true); - done(); - }, 0); - }, 0); + error.message.includes('not found'), + ), + ).toBe(true); }); - it('should emit validation error for invalid color format', (done) => { + it('should emit validation error for invalid color format', async () => { fixture.componentRef.setInput('src', 'test.riv'); - fixture.detectChanges(); + await detectChangesAndSettle(fixture); const errors: Error[] = []; component.loadError.subscribe((error) => errors.push(error)); onLoadCallback!(); - - setTimeout(() => { - component.setColor('backgroundColor', 'invalid-color'); - - setTimeout(() => { - expect(errors.some((error) => - error.name === 'RiveValidationError' - )).toBe(true); - done(); - }, 0); - }, 0); + await new Promise((r) => setTimeout(r, 0)); + component.setColor('backgroundColor', 'invalid-color'); + await new Promise((r) => setTimeout(r, 0)); + expect( + errors.some((error) => error.name === 'RiveValidationError'), + ).toBe(true); }); }); }); diff --git a/libs/rive-angular/src/lib/components/rive-canvas.component.ts b/libs/rive-angular/src/lib/components/rive-canvas.component.ts index 3c79674..7b29578 100644 --- a/libs/rive-angular/src/lib/components/rive-canvas.component.ts +++ b/libs/rive-angular/src/lib/components/rive-canvas.component.ts @@ -19,7 +19,6 @@ import { Fit, Alignment, EventType, - CANVAS_RIVE_SDK, DEFAULT_RIVE_RENDERER, getFallbackRenderer, type Rive, @@ -30,7 +29,7 @@ import { type RiveEvent, type ViewModelInstance, type RiveSdkModule, -} from '../utils/rive-sdk'; +} from '../utils'; import { RiveLoadError } from '../models'; import type { DataBindingValue, @@ -43,6 +42,7 @@ import { RiveLogger, RIVE_DEBUG_CONFIG, RIVE_RUNTIME_CONFIG, + DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG, validateConfiguration, validateInput, RiveErrorCode, @@ -305,9 +305,13 @@ export class RiveCanvasComponent implements AfterViewInit { const fit = this.fit(); const alignment = this.alignment(); untracked(() => { - if (this.#rive && isPlatformBrowser(this.#platformId)) { + if ( + this.#rive && + this.#runtimeSdk && + isPlatformBrowser(this.#platformId) + ) { const layoutParams: LayoutParameters = { fit, alignment }; - const layoutCtor = (this.#runtimeSdk ?? CANVAS_RIVE_SDK).Layout; + const layoutCtor = this.#runtimeSdk.Layout; this.#rive.layout = new layoutCtor(layoutParams as never) as never; } }); @@ -608,12 +612,8 @@ export class RiveCanvasComponent implements AfterViewInit { }); }; - if (!this.#runtimeConfig) { - createRiveInstance(CANVAS_RIVE_SDK); - return; - } - - const runtimeConfig = this.#runtimeConfig; + const runtimeConfig = + this.#runtimeConfig ?? DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG; const preferredRenderer = runtimeConfig.renderer ?? DEFAULT_RIVE_RENDERER; const strictMode = runtimeConfig.strict; diff --git a/libs/rive-angular/src/lib/services/rive-file.service.spec.ts b/libs/rive-angular/src/lib/services/rive-file.service.spec.ts index f98a704..66f52d2 100644 --- a/libs/rive-angular/src/lib/services/rive-file.service.spec.ts +++ b/libs/rive-angular/src/lib/services/rive-file.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { RiveFileService } from './rive-file.service'; import { RiveFile, EventType, RuntimeLoader } from '@rive-app/canvas'; +import { resetRiveRuntimeLifecycleForTests } from '../utils/rive-runtime'; // Mock RiveFile jest.mock('@rive-app/canvas', () => ({ @@ -15,6 +16,18 @@ jest.mock('@rive-app/canvas', () => ({ }, })); +jest.mock('@rive-app/webgl2', () => ({ + RuntimeLoader: { + awaitInstance: jest.fn(), + setWasmUrl: jest.fn(), + }, +})); + +/** Wait until async loadRiveFile (ensureRiveRuntimeReady + RiveFile outside Angular zone) completes. */ +async function flushLoadMicrotasks(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + describe('RiveFileService', () => { let service: RiveFileService; let mockRiveFile: jest.Mocked; @@ -22,6 +35,8 @@ describe('RiveFileService', () => { let methodCallOrder: string[]; beforeEach(() => { + resetRiveRuntimeLifecycleForTests(); + eventHandlers = new Map(); methodCallOrder = []; @@ -62,8 +77,7 @@ describe('RiveFileService', () => { const params = { src: 'test.riv' }; service.loadFile(params); - // Wait for async loadRiveFile to execute - await Promise.resolve(); + await flushLoadMicrotasks(); // Check order: on(Load) -> on(LoadError) -> init const loadIndex = methodCallOrder.indexOf(`on:${EventType.Load}`); @@ -133,8 +147,7 @@ describe('RiveFileService', () => { it('should accept debug parameter but not pass it to RiveFile', async () => { const params = { src: 'debug.riv', debug: true }; service.loadFile(params); - await Promise.resolve(); - await Promise.resolve(); + await flushLoadMicrotasks(); // debug should be excluded from SDK params expect(RiveFile).toHaveBeenCalledWith({ src: 'debug.riv' }); }); diff --git a/libs/rive-angular/src/lib/services/rive-file.service.ts b/libs/rive-angular/src/lib/services/rive-file.service.ts index 3196a32..afc48fe 100644 --- a/libs/rive-angular/src/lib/services/rive-file.service.ts +++ b/libs/rive-angular/src/lib/services/rive-file.service.ts @@ -1,8 +1,12 @@ import { Injectable, signal, Signal, inject } from '@angular/core'; -import { RIVE_DEBUG_CONFIG, RIVE_RUNTIME_CONFIG } from '../utils'; +import { + RIVE_DEBUG_CONFIG, + RIVE_RUNTIME_CONFIG, + DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG, +} from '../utils'; import { RiveLogger } from '../utils'; import { ensureRiveRuntimeReady } from '../utils/rive-runtime'; -import { CANVAS_RIVE_SDK, EventType, type RiveFile } from '../utils'; +import { EventType, type RiveFile } from '../utils'; /** * Status of RiveFile loading @@ -217,9 +221,11 @@ export class RiveFileService { }; try { - const runtimeSdk = this.runtimeConfig - ? (await ensureRiveRuntimeReady(this.runtimeConfig)).sdk - : CANVAS_RIVE_SDK; + const runtimeSdk = ( + await ensureRiveRuntimeReady( + this.runtimeConfig ?? DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG, + ) + ).sdk; // Extract debug parameter - it's not part of RiveFile SDK API // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -263,8 +269,7 @@ export class RiveFileService { logger.debug(`RiveFileService: Initializing file`, { cacheKey }); - // Await init() to catch initialization errors (e.g. WASM issues) - await file.init(); + file.init(); } catch (error) { logger.error('RiveFileService: Unexpected error loading file', error); diff --git a/libs/rive-angular/src/lib/utils/rive-runtime-errors.ts b/libs/rive-angular/src/lib/utils/rive-runtime-errors.ts new file mode 100644 index 0000000..c644f02 --- /dev/null +++ b/libs/rive-angular/src/lib/utils/rive-runtime-errors.ts @@ -0,0 +1,72 @@ +import type { RiveRenderer } from './rive-sdk'; + +const RIVE_PACKAGE: Record = { + canvas: '@rive-app/canvas', + webgl2: '@rive-app/webgl2', +}; + +export function isModuleResolutionError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const err = error as Error & { code?: string }; + if ( + err.code === 'MODULE_NOT_FOUND' || + err.code === 'ERR_MODULE_NOT_FOUND' || + err.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' + ) { + return true; + } + const msg = String((error as Error).message ?? ''); + return ( + msg.includes('Cannot find module') || + msg.includes('Failed to resolve') || + msg.includes('Could not resolve') + ); +} + +export function formatMissingRivePackageError( + renderer: RiveRenderer, + cause?: unknown, +): Error { + const pkg = RIVE_PACKAGE[renderer]; + const message = [ + `[rive-angular] Rive runtime package "${pkg}" is not installed.`, + `Install it with: npm install ${pkg}`, + ].join(' '); + const err = new Error(message); + if (cause !== undefined) { + (err as Error & { cause?: unknown }).cause = cause; + } + return err; +} + +export function composeFallbackRiveRuntimeError( + preferred: RiveRenderer, + fallback: RiveRenderer, + primaryError: unknown, + fallbackError: unknown, +): Error { + const pkgPreferred = RIVE_PACKAGE[preferred]; + const pkgFallback = RIVE_PACKAGE[fallback]; + const primaryMsg = + primaryError instanceof Error ? primaryError.message : String(primaryError); + const fallbackMsg = + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError); + + const message = [ + `[rive-angular] Could not initialize Rive with renderer "${preferred}" or fallback "${fallback}".`, + `Primary error: ${primaryMsg}`, + `Fallback error: ${fallbackMsg}`, + `Install both runtimes for automatic fallback: npm install ${pkgPreferred} ${pkgFallback}`, + `Or install only the runtime you need and use provideRiveRuntime({ renderer: "${preferred}", strict: true }) (or renderer: "${fallback}" with strict: true).`, + ].join('\n'); + const err = new Error(message); + (err as Error & { cause?: unknown }).cause = { + primary: primaryError, + fallback: fallbackError, + }; + return err; +} diff --git a/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts b/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts index b7eeea3..e83e750 100644 --- a/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts +++ b/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts @@ -81,4 +81,18 @@ describe('ensureRiveRuntimeReady', () => { expect(webgl2AwaitInstance).toHaveBeenCalledTimes(1); expect(canvasAwaitInstance).not.toHaveBeenCalled(); }); + + it('combines primary and fallback errors when both runtimes fail', async () => { + webgl2AwaitInstance.mockRejectedValueOnce(new Error('primary fail')); + canvasAwaitInstance.mockRejectedValueOnce(new Error('fallback fail')); + const { ensureRiveRuntimeReady } = await import('./rive-runtime'); + + await expect( + ensureRiveRuntimeReady({ + lazy: false, + renderer: 'webgl2', + strict: false, + }), + ).rejects.toThrow(/Could not initialize Rive[\s\S]*provideRiveRuntime/); + }); }); diff --git a/libs/rive-angular/src/lib/utils/rive-runtime.ts b/libs/rive-angular/src/lib/utils/rive-runtime.ts index 8626cdc..b2a3fc5 100644 --- a/libs/rive-angular/src/lib/utils/rive-runtime.ts +++ b/libs/rive-angular/src/lib/utils/rive-runtime.ts @@ -1,4 +1,5 @@ import type { RiveRuntimeResolvedConfig } from './runtime-config'; +import { composeFallbackRiveRuntimeError } from './rive-runtime-errors'; import { DEFAULT_RIVE_RENDERER, getFallbackRenderer, @@ -15,6 +16,11 @@ interface RuntimeState { const runtimeStates = new Map(); +/** Clears WASM init state (for unit tests; each test needs a fresh RuntimeLoader path). */ +export function resetRiveRuntimeLifecycleForTests(): void { + runtimeStates.clear(); +} + function getRuntimeState(renderer: RiveRenderer): RuntimeState { const existing = runtimeStates.get(renderer); if (existing) { @@ -94,6 +100,15 @@ export async function ensureRiveRuntimeReady( } const fallbackRenderer = getFallbackRenderer(preferredRenderer); - return ensureRuntimeForRenderer(fallbackRenderer, config); + try { + return await ensureRuntimeForRenderer(fallbackRenderer, config); + } catch (fallbackError) { + throw composeFallbackRiveRuntimeError( + preferredRenderer, + fallbackRenderer, + primaryError, + fallbackError, + ); + } } } diff --git a/libs/rive-angular/src/lib/utils/rive-sdk.ts b/libs/rive-angular/src/lib/utils/rive-sdk.ts index f2d70d6..6c92600 100644 --- a/libs/rive-angular/src/lib/utils/rive-sdk.ts +++ b/libs/rive-angular/src/lib/utils/rive-sdk.ts @@ -1,42 +1,193 @@ -import * as canvasSdk from '@rive-app/canvas'; +import { + formatMissingRivePackageError, + isModuleResolutionError, +} from './rive-runtime-errors'; export type RiveRenderer = 'canvas' | 'webgl2'; +export enum Fit { + Cover = 'cover', + Contain = 'contain', + Fill = 'fill', + FitWidth = 'fitWidth', + FitHeight = 'fitHeight', + None = 'none', + ScaleDown = 'scaleDown', + Layout = 'layout', +} + +export enum Alignment { + Center = 'center', + TopLeft = 'topLeft', + TopCenter = 'topCenter', + TopRight = 'topRight', + CenterLeft = 'centerLeft', + CenterRight = 'centerRight', + BottomLeft = 'bottomLeft', + BottomCenter = 'bottomCenter', + BottomRight = 'bottomRight', +} + +export enum EventType { + Load = 'load', + LoadError = 'loaderror', + Play = 'play', + Pause = 'pause', + Stop = 'stop', + Loop = 'loop', + Draw = 'draw', + Advance = 'advance', + StateChange = 'statechange', + RiveEvent = 'riveevent', + AudioStatusChange = 'audiostatuschange', +} + +export enum LoopType { + OneShot = 'oneshot', + Loop = 'loop', + PingPong = 'pingpong', +} + +export interface LayoutParameters { + fit?: Fit; + alignment?: Alignment; + layoutScaleFactor?: number; + minX?: number; + minY?: number; + maxX?: number; + maxY?: number; +} + +export interface LoopEvent { + animation: string; + type: LoopType; +} + +export interface RiveEvent { + type: EventType; + data?: unknown; +} + +export interface StateMachineInput { + name: string; + value: number | boolean; + fire(): void; + type?: number; +} + +export interface ViewModelProperty { + value: TValue; + on?: (callback: () => void) => (() => void) | void; + trigger?: () => void; +} + +export interface ViewModelColorProperty extends ViewModelProperty { + rgba(r: number, g: number, b: number, a?: number): void; + opacity(opacity: number): void; +} + +export interface ViewModelTriggerProperty extends ViewModelProperty { + trigger(): void; +} + +export interface ViewModelInstance { + name?: string; + properties: unknown[]; + number(path: string): ViewModelProperty | undefined; + string(path: string): ViewModelProperty | undefined; + boolean(path: string): ViewModelProperty | undefined; + color(path: string): ViewModelColorProperty | undefined; + enum(path: string): ViewModelProperty | undefined; + trigger(path: string): ViewModelTriggerProperty | undefined; + cleanup(): void; +} + +export interface ViewModel { + name: string; + instance(): ViewModelInstance; +} + +export interface RiveFileParameters { + src?: string; + buffer?: ArrayBuffer; +} + +export interface RiveFile { + init(): void; + cleanup(): void; + getInstance(): unknown; + on(event: EventType, callback: () => void): void; +} + +export interface RiveParameters { + canvas: HTMLCanvasElement; + autoplay?: boolean; + layout?: unknown; + useOffscreenRenderer?: boolean; + shouldDisableRiveListeners?: boolean; + automaticallyHandleEvents?: boolean; + onLoad?: () => void; + onLoadError?: (error?: unknown) => void; + onPlay?: (event: RiveEvent) => void; + onPause?: (event: RiveEvent) => void; + onStop?: (event: RiveEvent) => void; + onLoop?: (event: RiveEvent) => void; + onAdvance?: (event: RiveEvent) => void; + onStateChange?: (event: RiveEvent) => void; + onRiveEvent?: (event: RiveEvent) => void; + src?: string; + buffer?: ArrayBuffer; + riveFile?: RiveFile; + artboard?: string; + animations?: string | string[]; + stateMachines?: string | string[]; +} + +export interface Rive { + layout?: unknown; + viewModelCount: number; + cleanup(): void; + resizeDrawingSurfaceToCanvas(): void; + startRendering(): void; + stopRendering(): void; + play(animations?: string | string[]): void; + pause(animations?: string | string[]): void; + stop(animations?: string | string[]): void; + reset(params?: unknown): void; + stateMachineInputs(stateMachine: string): StateMachineInput[]; + getTextRunValue(name: string): string | undefined; + setTextRunValue(name: string, value: string): void; + getTextRunValueAtPath(name: string, path: string): string | undefined; + setTextRunValueAtPath(name: string, value: string, path: string): void; + viewModelByIndex(index: number): ViewModel | null; + defaultViewModel(): ViewModel | null; + viewModelByName(name: string): ViewModel | null; + bindViewModelInstance(viewModelInstance: ViewModelInstance): void; + artboardNames?: string[]; + animationNames?: string[]; + stateMachineNames?: string[]; +} + +export type Layout = object; + export interface RiveSdkLoadResult { renderer: RiveRenderer; sdk: RiveSdkModule; } -export type CanvasRiveSdkModule = typeof import('@rive-app/canvas'); -export type Webgl2RiveSdkModule = typeof import('@rive-app/webgl2'); -export type RiveSdkModule = CanvasRiveSdkModule | Webgl2RiveSdkModule; +export interface RuntimeLoaderApi { + awaitInstance(): Promise; + setWasmUrl(url: string): void; +} + +export interface RiveSdkModule { + RuntimeLoader: RuntimeLoaderApi; + Layout: new (params?: LayoutParameters) => Layout; + Rive: new (params: RiveParameters) => Rive; + RiveFile: new (params: RiveFileParameters) => RiveFile; +} export const DEFAULT_RIVE_RENDERER: RiveRenderer = 'canvas'; -export const CANVAS_RIVE_SDK: CanvasRiveSdkModule = canvasSdk; - -/** - * Keep type/value exports stable for existing consumers. - * Runtime-specific module selection is handled by `loadRiveSdk`. - */ -export { - Fit, - Alignment, - EventType, - LoopType, - Rive, - RiveFile, - Layout, - StateMachineInput, - ViewModelInstance, -} from '@rive-app/canvas'; - -export type { - LayoutParameters, - RiveParameters, - RiveFileParameters, - Event as RiveEvent, - LoopEvent, -} from '@rive-app/canvas'; export function getFallbackRenderer(renderer: RiveRenderer): RiveRenderer { return renderer === 'webgl2' ? 'canvas' : 'webgl2'; @@ -45,10 +196,18 @@ export function getFallbackRenderer(renderer: RiveRenderer): RiveRenderer { export async function loadRiveSdk( renderer: RiveRenderer, ): Promise { - if (renderer === 'webgl2') { - const sdk = await import('@rive-app/webgl2'); - return { renderer, sdk }; - } + try { + if (renderer === 'webgl2') { + const sdk = (await import('@rive-app/webgl2')) as unknown as RiveSdkModule; + return { renderer, sdk }; + } - return { renderer: 'canvas', sdk: canvasSdk }; + const sdk = (await import('@rive-app/canvas')) as unknown as RiveSdkModule; + return { renderer: 'canvas', sdk }; + } catch (error) { + if (isModuleResolutionError(error)) { + throw formatMissingRivePackageError(renderer, error); + } + throw error; + } } diff --git a/libs/rive-angular/src/lib/utils/runtime-config.ts b/libs/rive-angular/src/lib/utils/runtime-config.ts index d6aeb53..a79c21d 100644 --- a/libs/rive-angular/src/lib/utils/runtime-config.ts +++ b/libs/rive-angular/src/lib/utils/runtime-config.ts @@ -33,6 +33,13 @@ export interface RiveRuntimeResolvedConfig { strict: boolean; } +/** Default runtime options when `provideRiveRuntime` is not used (canvas, non-strict). */ +export const DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG: RiveRuntimeResolvedConfig = { + lazy: false, + renderer: DEFAULT_RIVE_RENDERER, + strict: false, +}; + /** * Internal DI token used by component/service to read runtime options. */ From eeae3f04de5b0061e660a9c55144956f292f0677 Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 21:46:27 +0300 Subject: [PATCH 06/12] fix(rive-angular): await RiveFile.init() so promise rejections are handled - Use await Promise.resolve(file.init()) and keep failed state in catch - RiveFile.init typing: void | Promise - Stabilize init-failure specs with async flushLoadMicrotasks --- .../lib/services/rive-file.service.spec.ts | 30 +++++++------------ .../src/lib/services/rive-file.service.ts | 4 ++- libs/rive-angular/src/lib/utils/rive-sdk.ts | 2 +- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/libs/rive-angular/src/lib/services/rive-file.service.spec.ts b/libs/rive-angular/src/lib/services/rive-file.service.spec.ts index 66f52d2..66d9c3a 100644 --- a/libs/rive-angular/src/lib/services/rive-file.service.spec.ts +++ b/libs/rive-angular/src/lib/services/rive-file.service.spec.ts @@ -128,20 +128,17 @@ describe('RiveFileService', () => { }, 0); }); - it('should handle init() failure (catch block)', (done) => { - // Mock init to reject + it('should handle init() failure (catch block)', async () => { mockRiveFile.init.mockRejectedValue(new Error('Init failed')); const params = { src: 'fail.riv' }; const state = service.loadFile(params); - setTimeout(() => { - expect(state().status).toBe('failed'); - // Should clear pending load so retry is possible - const state2 = service.loadFile(params); - expect(state2().status).toBe('loading'); // New loading state, not cached failed state - done(); - }, 0); + await flushLoadMicrotasks(); + + expect(state().status).toBe('failed'); + const state2 = service.loadFile(params); + expect(state2().status).toBe('loading'); }); it('should accept debug parameter but not pass it to RiveFile', async () => { @@ -216,21 +213,16 @@ describe('RiveFileService', () => { }, 0); }); - it('should call finalizePendingLoadOnce exactly once on init() exception', (done) => { - // Mock init to throw + it('should call finalizePendingLoadOnce exactly once on init() exception', async () => { mockRiveFile.init.mockRejectedValue(new Error('Init failed')); const params = { src: 'fail.riv' }; service.loadFile(params); - setTimeout(() => { - // Verify pending load was cleared after exception - const state2 = service.loadFile(params); - - // Should create new loading state (retry possible) - expect(state2().status).toBe('loading'); - done(); - }, 0); + await flushLoadMicrotasks(); + + const state2 = service.loadFile(params); + expect(state2().status).toBe('loading'); }); }); diff --git a/libs/rive-angular/src/lib/services/rive-file.service.ts b/libs/rive-angular/src/lib/services/rive-file.service.ts index afc48fe..3822b67 100644 --- a/libs/rive-angular/src/lib/services/rive-file.service.ts +++ b/libs/rive-angular/src/lib/services/rive-file.service.ts @@ -269,7 +269,9 @@ export class RiveFileService { logger.debug(`RiveFileService: Initializing file`, { cacheKey }); - file.init(); + // Support both sync `init()` and Promise-returning `init()`; rejections must be awaited + // or they bypass this try/catch and leave the signal stuck in `loading`. + await Promise.resolve(file.init()); } catch (error) { logger.error('RiveFileService: Unexpected error loading file', error); diff --git a/libs/rive-angular/src/lib/utils/rive-sdk.ts b/libs/rive-angular/src/lib/utils/rive-sdk.ts index 6c92600..27cae0c 100644 --- a/libs/rive-angular/src/lib/utils/rive-sdk.ts +++ b/libs/rive-angular/src/lib/utils/rive-sdk.ts @@ -113,7 +113,7 @@ export interface RiveFileParameters { } export interface RiveFile { - init(): void; + init(): void | Promise; cleanup(): void; getInstance(): unknown; on(event: EventType, callback: () => void): void; From 6a887cc36ce3e346519e844317c6ce63ec19ea6d Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 21:49:12 +0300 Subject: [PATCH 07/12] chore(release): 2.0.0-beta.1 - Bump package version; document RiveFile.init await fix in changelog - Update README pre-release tag --- README.md | 2 +- libs/rive-angular/CHANGELOG.md | 7 +++++++ libs/rive-angular/README.md | 2 +- libs/rive-angular/package.json | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index eacd1af..15bc31d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`2.0.0-beta.0`** (pre-release). +Current release candidate in this branch: **`2.0.0-beta.1`** (pre-release). ## Migration from v1 to v2 diff --git a/libs/rive-angular/CHANGELOG.md b/libs/rive-angular/CHANGELOG.md index a72e187..c821a77 100644 --- a/libs/rive-angular/CHANGELOG.md +++ b/libs/rive-angular/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [2.0.0-beta.1] - 2026-04-20 + +### Fixed + +- `RiveFileService`: `await Promise.resolve(file.init())` so promise rejections from `RiveFile.init()` are handled and the file state moves to `failed` instead of staying `loading`. +- `RiveFileService` tests: async `flushLoadMicrotasks()` for init-failure cases (reliable in CI). + ## [2.0.0-beta.0] - 2026-04-19 ### Added diff --git a/libs/rive-angular/README.md b/libs/rive-angular/README.md index 8082c23..c539e70 100644 --- a/libs/rive-angular/README.md +++ b/libs/rive-angular/README.md @@ -11,7 +11,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`2.0.0-beta.0`** (pre-release). +Current release candidate in this branch: **`2.0.0-beta.1`** (pre-release). ## Migration from v1 to v2 diff --git a/libs/rive-angular/package.json b/libs/rive-angular/package.json index 31e96cd..fb80ce2 100644 --- a/libs/rive-angular/package.json +++ b/libs/rive-angular/package.json @@ -1,6 +1,6 @@ { "name": "@grandgular/rive-angular", - "version": "2.0.0-beta.0", + "version": "2.0.0-beta.1", "description": "Modern Angular wrapper for Rive animations with reactive state management, built with signals and zoneless architecture", "keywords": [ "angular", From 86a0a57d5699cffc9dbb1142ac2991513db3dfcc Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 22:02:25 +0300 Subject: [PATCH 08/12] fix(types): add Rive event API (on, off, removeAllRiveEventListeners) Restores consumer typing for direct Rive instance usage after v2 stub Rive interface replaced canvas re-exports. --- libs/rive-angular/src/lib/utils/rive-sdk.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/rive-angular/src/lib/utils/rive-sdk.ts b/libs/rive-angular/src/lib/utils/rive-sdk.ts index 27cae0c..2f22cbc 100644 --- a/libs/rive-angular/src/lib/utils/rive-sdk.ts +++ b/libs/rive-angular/src/lib/utils/rive-sdk.ts @@ -166,6 +166,10 @@ export interface Rive { artboardNames?: string[]; animationNames?: string[]; stateMachineNames?: string[]; + /** Subscribe to Rive-generated events (matches `@rive-app/canvas` / `@rive-app/webgl2`). */ + on(type: EventType, callback: (event: RiveEvent) => void): void; + off(type: EventType, callback: (event: RiveEvent) => void): void; + removeAllRiveEventListeners(type?: EventType): void; } export type Layout = object; From a181f2311ccd9d3ba7970013068f83b62f70db73 Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 22:03:10 +0300 Subject: [PATCH 09/12] chore(release): 2.0.0-beta.2 - Bump version; changelog entry for Rive event API typing fix - Update README pre-release tag --- README.md | 2 +- libs/rive-angular/CHANGELOG.md | 6 ++++++ libs/rive-angular/README.md | 2 +- libs/rive-angular/package.json | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 15bc31d..63f22da 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`2.0.0-beta.1`** (pre-release). +Current release candidate in this branch: **`2.0.0-beta.2`** (pre-release). ## Migration from v1 to v2 diff --git a/libs/rive-angular/CHANGELOG.md b/libs/rive-angular/CHANGELOG.md index c821a77..5ef0fc1 100644 --- a/libs/rive-angular/CHANGELOG.md +++ b/libs/rive-angular/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [2.0.0-beta.2] - 2026-04-20 + +### Fixed + +- Public `Rive` type: restored SDK-compatible event API on the stub interface (`on`, `off`, `removeAllRiveEventListeners`) so direct instance usage (e.g. `rive.on(EventType.RiveEvent, ...)`) type-checks again after v2 type-only exports. + ## [2.0.0-beta.1] - 2026-04-20 ### Fixed diff --git a/libs/rive-angular/README.md b/libs/rive-angular/README.md index c539e70..919f25e 100644 --- a/libs/rive-angular/README.md +++ b/libs/rive-angular/README.md @@ -11,7 +11,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`2.0.0-beta.1`** (pre-release). +Current release candidate in this branch: **`2.0.0-beta.2`** (pre-release). ## Migration from v1 to v2 diff --git a/libs/rive-angular/package.json b/libs/rive-angular/package.json index fb80ce2..9c517ed 100644 --- a/libs/rive-angular/package.json +++ b/libs/rive-angular/package.json @@ -1,6 +1,6 @@ { "name": "@grandgular/rive-angular", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Modern Angular wrapper for Rive animations with reactive state management, built with signals and zoneless architecture", "keywords": [ "angular", From 644f3ca8c409d93db2c4ab169f402640222cb327 Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 19 Apr 2026 22:18:01 +0300 Subject: [PATCH 10/12] fix(rive-angular): vite-ignore optional Rive SDK dynamic imports Vite must not require absent optional peers at analyze time for canvas-only or webgl2-only installs. chore(release): 2.0.0-beta.3 --- README.md | 2 +- libs/rive-angular/CHANGELOG.md | 6 ++++++ libs/rive-angular/README.md | 2 +- libs/rive-angular/package.json | 2 +- libs/rive-angular/src/lib/utils/rive-sdk.ts | 9 +++++++-- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 63f22da..0e396fe 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`2.0.0-beta.2`** (pre-release). +Current release candidate in this branch: **`2.0.0-beta.3`** (pre-release). ## Migration from v1 to v2 diff --git a/libs/rive-angular/CHANGELOG.md b/libs/rive-angular/CHANGELOG.md index 5ef0fc1..2706e3b 100644 --- a/libs/rive-angular/CHANGELOG.md +++ b/libs/rive-angular/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [2.0.0-beta.3] - 2026-04-20 + +### Fixed + +- Dynamic SDK imports: add `/* @vite-ignore */` so Vite does not fail to resolve optional peers (`@rive-app/webgl2` / `@rive-app/canvas`) when only one runtime is installed (e.g. canvas-only apps using default `renderer`). + ## [2.0.0-beta.2] - 2026-04-20 ### Fixed diff --git a/libs/rive-angular/README.md b/libs/rive-angular/README.md index 919f25e..e8876a3 100644 --- a/libs/rive-angular/README.md +++ b/libs/rive-angular/README.md @@ -11,7 +11,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`2.0.0-beta.2`** (pre-release). +Current release candidate in this branch: **`2.0.0-beta.3`** (pre-release). ## Migration from v1 to v2 diff --git a/libs/rive-angular/package.json b/libs/rive-angular/package.json index 9c517ed..5ab0036 100644 --- a/libs/rive-angular/package.json +++ b/libs/rive-angular/package.json @@ -1,6 +1,6 @@ { "name": "@grandgular/rive-angular", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "description": "Modern Angular wrapper for Rive animations with reactive state management, built with signals and zoneless architecture", "keywords": [ "angular", diff --git a/libs/rive-angular/src/lib/utils/rive-sdk.ts b/libs/rive-angular/src/lib/utils/rive-sdk.ts index 2f22cbc..247fcbd 100644 --- a/libs/rive-angular/src/lib/utils/rive-sdk.ts +++ b/libs/rive-angular/src/lib/utils/rive-sdk.ts @@ -202,11 +202,16 @@ export async function loadRiveSdk( ): Promise { try { if (renderer === 'webgl2') { - const sdk = (await import('@rive-app/webgl2')) as unknown as RiveSdkModule; + // Optional peer: suppress Vite pre-bundling so canvas-only apps need not install webgl2. + const sdk = (await import( + /* @vite-ignore */ '@rive-app/webgl2' + )) as unknown as RiveSdkModule; return { renderer, sdk }; } - const sdk = (await import('@rive-app/canvas')) as unknown as RiveSdkModule; + const sdk = (await import( + /* @vite-ignore */ '@rive-app/canvas' + )) as unknown as RiveSdkModule; return { renderer: 'canvas', sdk }; } catch (error) { if (isModuleResolutionError(error)) { From f81fce92da163efd71eb0d862024d6b0a98e5591 Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 26 Apr 2026 14:19:35 +0300 Subject: [PATCH 11/12] feat(rive-angular): make runtime fallback explicit --- libs/rive-angular/CHANGELOG.md | 13 +++++-- libs/rive-angular/README.md | 24 ++++++------ .../lib/components/rive-canvas.component.ts | 6 +-- .../src/lib/utils/rive-runtime-errors.ts | 2 +- .../src/lib/utils/rive-runtime.spec.ts | 38 +++++++++---------- .../src/lib/utils/rive-runtime.ts | 10 ++--- .../src/lib/utils/runtime-config.ts | 14 ++++--- 7 files changed, 56 insertions(+), 51 deletions(-) diff --git a/libs/rive-angular/CHANGELOG.md b/libs/rive-angular/CHANGELOG.md index 2706e3b..44547e3 100644 --- a/libs/rive-angular/CHANGELOG.md +++ b/libs/rive-angular/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [2.0.0-beta.4] - Unreleased + +### Changed + +- Runtime fallback is now explicit via `provideRiveRuntime({ fallback: true })`; selecting `renderer: 'webgl2'` no longer requires installing `@rive-app/canvas`, and the default Canvas path no longer requires `@rive-app/webgl2`. +- Removed the `strict` runtime option from the beta API; omit `fallback` to ship only the selected runtime. + ## [2.0.0-beta.3] - 2026-04-20 ### Fixed @@ -25,14 +32,14 @@ All notable changes to this project will be documented in this file. ### Added -- **Dual runtime support** for `@rive-app/canvas` and `@rive-app/webgl2` with `provideRiveRuntime()` options `renderer` and `strict`, and automatic fallback when `strict` is `false`. +- **Dual runtime support** for `@rive-app/canvas` and `@rive-app/webgl2` with `provideRiveRuntime()` options `renderer` and explicit `fallback`. - **Preferred component selector**: `` (with `` still supported as a legacy alias). -- **Runtime tests** covering `webgl2`, fallback behavior, and strict mode failures. +- **Runtime tests** covering `webgl2`, single-runtime behavior, and explicit fallback failures. ### Changed - Runtime SDK loading now uses dynamic imports for both renderers and no longer keeps a static runtime import of `@rive-app/canvas`. -- Runtime error handling now emits clearer missing-package and fallback failure messages, including install hints and `strict: true` guidance. +- Runtime error handling now emits clearer missing-package and fallback failure messages, including install hints for single-runtime and fallback setups. - Runtime initialization defaults are centralized through `DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG` and reused by both `RiveCanvasComponent` and `RiveFileService`. - README/docs were updated to describe optional runtime peers and renderer/fallback installation matrix. diff --git a/libs/rive-angular/README.md b/libs/rive-angular/README.md index e8876a3..9eaac1b 100644 --- a/libs/rive-angular/README.md +++ b/libs/rive-angular/README.md @@ -90,7 +90,7 @@ npm uninstall ng-rive npm install @grandgular/rive-angular @rive-app/canvas ``` -If you use WebGL2 (`provideRiveRuntime({ renderer: 'webgl2' })`) or rely on automatic fallback from WebGL2 to Canvas, also install `@rive-app/webgl2` (see [Installation](#installation)). +If you use WebGL2 (`provideRiveRuntime({ renderer: 'webgl2' })`) install `@rive-app/webgl2` instead of `@rive-app/canvas`. Install both runtime packages only when you explicitly configure renderer fallback (see [Installation](#installation)). ### 2. Update imports @@ -151,13 +151,13 @@ onLoaded() { ## Installation -`@rive-app/canvas` and `@rive-app/webgl2` are **optional peer dependencies**: install the runtime package(s) that match how you configure the library. The bundler only pulls in the SDK that is actually imported for your `renderer` / fallback path. +`@rive-app/canvas` and `@rive-app/webgl2` are **optional peer dependencies**: install the runtime package(s) that match how you configure the library. The bundler only pulls in the SDK that is imported for your selected `renderer`, plus the other runtime if you set `fallback: true`. | Goal | Packages to install | |------|------------------------| | Canvas only (default `renderer`, or you never use WebGL2) | `@grandgular/rive-angular` + `@rive-app/canvas` | -| WebGL2 only (e.g. `renderer: 'webgl2'` and `strict: true`, so no Canvas fallback) | `@grandgular/rive-angular` + `@rive-app/webgl2` | -| WebGL2 with automatic fallback to Canvas (`strict: false`) | `@grandgular/rive-angular` + `@rive-app/canvas` + `@rive-app/webgl2` | +| WebGL2 only (`renderer: 'webgl2'`) | `@grandgular/rive-angular` + `@rive-app/webgl2` | +| WebGL2 with explicit fallback to Canvas (`fallback: true`) | `@grandgular/rive-angular` + `@rive-app/canvas` + `@rive-app/webgl2` | ```bash npm install @grandgular/rive-angular @rive-app/canvas @@ -175,7 +175,7 @@ Add WebGL2 when needed: npm install @rive-app/webgl2 ``` -If a required package is missing, initialization fails with an error that names the package and suggests either installing it or setting `strict: true` to avoid loading that renderer/fallback path. +If a required package is missing, initialization fails with an error that names the package. A second runtime package is required only when `fallback: true`. ## Quick Start @@ -645,7 +645,6 @@ export const appConfig: ApplicationConfig = { wasmUrl: 'assets/rive/rive.v1.wasm', lazy: true, renderer: 'webgl2', - strict: false, }), ], }; @@ -656,16 +655,15 @@ export const appConfig: ApplicationConfig = { ```typescript provideRiveRuntime({ renderer: 'webgl2', - strict: false, + fallback: true, }); ``` - `renderer` is optional; default is `'canvas'`. -- `strict` is optional; default is `false`. -- With `strict: false`, the library automatically falls back to the other renderer if the preferred renderer fails to initialize. **Both** `@rive-app/canvas` and `@rive-app/webgl2` must be installed for fallback to succeed if the other SDK is needed. -- With `strict: true`, fallback is disabled and initialization fails fast if the chosen renderer cannot load — useful when you intentionally ship only one runtime package. +- `fallback` is optional; omit it or set `false` to ship only one runtime package. +- With `fallback: true`, the library falls back to the other renderer if the preferred renderer fails to initialize. **Both** `@rive-app/canvas` and `@rive-app/webgl2` must be installed for fallback to succeed. -If fallback fails (for example WebGL2 unavailable **and** the Canvas package is not installed), the error explains what failed and suggests installing the missing package or using `strict: true` with a single renderer. +If fallback fails (for example WebGL2 unavailable **and** the Canvas package is not installed), the error explains what failed and suggests installing the missing package or removing `fallback: true` to ship a single renderer. ### Migration from `provideAppInitializer` @@ -726,7 +724,7 @@ interface RiveRuntimeConfig { wasmUrl?: string; lazy?: true; renderer?: 'canvas' | 'webgl2'; - strict?: boolean; + fallback?: boolean; } ``` @@ -734,7 +732,7 @@ interface RiveRuntimeConfig { - `lazy` omitted - eager initialization on startup. - `lazy: true` - deferred initialization at first runtime usage. - `renderer` omitted - defaults to `'canvas'` for backward compatibility. -- `strict` omitted - defaults to `false` and allows automatic fallback. +- `fallback` omitted - no automatic fallback; only the selected runtime package is loaded. ### RiveCanvasComponent diff --git a/libs/rive-angular/src/lib/components/rive-canvas.component.ts b/libs/rive-angular/src/lib/components/rive-canvas.component.ts index 7b29578..dc3d8d8 100644 --- a/libs/rive-angular/src/lib/components/rive-canvas.component.ts +++ b/libs/rive-angular/src/lib/components/rive-canvas.component.ts @@ -616,7 +616,7 @@ export class RiveCanvasComponent implements AfterViewInit { this.#runtimeConfig ?? DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG; const preferredRenderer = runtimeConfig.renderer ?? DEFAULT_RIVE_RENDERER; - const strictMode = runtimeConfig.strict; + const shouldFallback = runtimeConfig.fallback; void ensureRiveRuntimeReady(runtimeConfig) .then(async (runtimeResult) => { @@ -624,7 +624,7 @@ export class RiveCanvasComponent implements AfterViewInit { createRiveInstance(runtimeResult.sdk); } catch (primaryCreateError) { if ( - strictMode || + !shouldFallback || runtimeResult.renderer !== preferredRenderer || !this.shouldFallbackOnRendererError( primaryCreateError, @@ -638,7 +638,7 @@ export class RiveCanvasComponent implements AfterViewInit { ...runtimeConfig, lazy: runtimeConfig.lazy, renderer: getFallbackRenderer(preferredRenderer), - strict: true, + fallback: false, }); createRiveInstance(fallbackRuntime.sdk); } diff --git a/libs/rive-angular/src/lib/utils/rive-runtime-errors.ts b/libs/rive-angular/src/lib/utils/rive-runtime-errors.ts index c644f02..3241a23 100644 --- a/libs/rive-angular/src/lib/utils/rive-runtime-errors.ts +++ b/libs/rive-angular/src/lib/utils/rive-runtime-errors.ts @@ -61,7 +61,7 @@ export function composeFallbackRiveRuntimeError( `Primary error: ${primaryMsg}`, `Fallback error: ${fallbackMsg}`, `Install both runtimes for automatic fallback: npm install ${pkgPreferred} ${pkgFallback}`, - `Or install only the runtime you need and use provideRiveRuntime({ renderer: "${preferred}", strict: true }) (or renderer: "${fallback}" with strict: true).`, + `Or remove fallback: true from provideRiveRuntime({ renderer: "${preferred}" }) to ship only the selected runtime.`, ].join('\n'); const err = new Error(message); (err as Error & { cause?: unknown }).cause = { diff --git a/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts b/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts index e83e750..7715270 100644 --- a/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts +++ b/libs/rive-angular/src/lib/utils/rive-runtime.spec.ts @@ -41,7 +41,6 @@ describe('ensureRiveRuntimeReady', () => { const result = await ensureRiveRuntimeReady({ lazy: false, renderer: 'webgl2', - strict: false, wasmUrl: 'assets/rive/rive.wasm', }); @@ -51,22 +50,7 @@ describe('ensureRiveRuntimeReady', () => { expect(canvasAwaitInstance).not.toHaveBeenCalled(); }); - it('falls back to canvas when webgl2 init fails and strict is false', async () => { - webgl2AwaitInstance.mockRejectedValueOnce(new Error('WebGL2 unavailable')); - const { ensureRiveRuntimeReady } = await import('./rive-runtime'); - - const result = await ensureRiveRuntimeReady({ - lazy: false, - renderer: 'webgl2', - strict: false, - }); - - expect(result.renderer).toBe('canvas'); - expect(webgl2AwaitInstance).toHaveBeenCalledTimes(1); - expect(canvasAwaitInstance).toHaveBeenCalledTimes(1); - }); - - it('throws when webgl2 init fails and strict is true', async () => { + it('does not fall back when webgl2 init fails without fallback', async () => { webgl2AwaitInstance.mockRejectedValueOnce(new Error('WebGL2 unavailable')); const { ensureRiveRuntimeReady } = await import('./rive-runtime'); @@ -74,7 +58,6 @@ describe('ensureRiveRuntimeReady', () => { ensureRiveRuntimeReady({ lazy: false, renderer: 'webgl2', - strict: true, }), ).rejects.toThrow('WebGL2 unavailable'); @@ -82,6 +65,21 @@ describe('ensureRiveRuntimeReady', () => { expect(canvasAwaitInstance).not.toHaveBeenCalled(); }); + it('falls back to canvas when fallback is enabled', async () => { + webgl2AwaitInstance.mockRejectedValueOnce(new Error('WebGL2 unavailable')); + const { ensureRiveRuntimeReady } = await import('./rive-runtime'); + + const result = await ensureRiveRuntimeReady({ + lazy: false, + renderer: 'webgl2', + fallback: true, + }); + + expect(result.renderer).toBe('canvas'); + expect(webgl2AwaitInstance).toHaveBeenCalledTimes(1); + expect(canvasAwaitInstance).toHaveBeenCalledTimes(1); + }); + it('combines primary and fallback errors when both runtimes fail', async () => { webgl2AwaitInstance.mockRejectedValueOnce(new Error('primary fail')); canvasAwaitInstance.mockRejectedValueOnce(new Error('fallback fail')); @@ -91,8 +89,8 @@ describe('ensureRiveRuntimeReady', () => { ensureRiveRuntimeReady({ lazy: false, renderer: 'webgl2', - strict: false, + fallback: true, }), - ).rejects.toThrow(/Could not initialize Rive[\s\S]*provideRiveRuntime/); + ).rejects.toThrow(/Could not initialize Rive[\s\S]*fallback: true/); }); }); diff --git a/libs/rive-angular/src/lib/utils/rive-runtime.ts b/libs/rive-angular/src/lib/utils/rive-runtime.ts index b2a3fc5..5efb177 100644 --- a/libs/rive-angular/src/lib/utils/rive-runtime.ts +++ b/libs/rive-angular/src/lib/utils/rive-runtime.ts @@ -90,22 +90,22 @@ export async function ensureRiveRuntimeReady( } const preferredRenderer = config?.renderer ?? DEFAULT_RIVE_RENDERER; - const strictMode = config?.strict === true; + const shouldFallback = config?.fallback === true; try { return await ensureRuntimeForRenderer(preferredRenderer, config); } catch (primaryError) { - if (strictMode) { + if (!shouldFallback) { throw primaryError; } - const fallbackRenderer = getFallbackRenderer(preferredRenderer); + const fallbackTargetRenderer = getFallbackRenderer(preferredRenderer); try { - return await ensureRuntimeForRenderer(fallbackRenderer, config); + return await ensureRuntimeForRenderer(fallbackTargetRenderer, config); } catch (fallbackError) { throw composeFallbackRiveRuntimeError( preferredRenderer, - fallbackRenderer, + fallbackTargetRenderer, primaryError, fallbackError, ); diff --git a/libs/rive-angular/src/lib/utils/runtime-config.ts b/libs/rive-angular/src/lib/utils/runtime-config.ts index a79c21d..98654d0 100644 --- a/libs/rive-angular/src/lib/utils/runtime-config.ts +++ b/libs/rive-angular/src/lib/utils/runtime-config.ts @@ -20,7 +20,7 @@ export interface RiveRuntimeConfig { wasmUrl?: string; lazy?: true; renderer?: RiveRenderer; - strict?: boolean; + fallback?: boolean; } /** @@ -30,14 +30,14 @@ export interface RiveRuntimeResolvedConfig { wasmUrl?: string; lazy: boolean; renderer: RiveRenderer; - strict: boolean; + fallback: boolean; } -/** Default runtime options when `provideRiveRuntime` is not used (canvas, non-strict). */ +/** Default runtime options when `provideRiveRuntime` is not used (canvas, no fallback). */ export const DEFAULT_RIVE_RUNTIME_RESOLVED_CONFIG: RiveRuntimeResolvedConfig = { lazy: false, renderer: DEFAULT_RIVE_RENDERER, - strict: false, + fallback: false, }; /** @@ -49,11 +49,13 @@ export const RIVE_RUNTIME_CONFIG = function resolveRuntimeConfig( config?: RiveRuntimeConfig, ): RiveRuntimeResolvedConfig { + const renderer = config?.renderer ?? DEFAULT_RIVE_RENDERER; + return { wasmUrl: config?.wasmUrl, lazy: config?.lazy === true, - renderer: config?.renderer ?? DEFAULT_RIVE_RENDERER, - strict: config?.strict === true, + renderer, + fallback: config?.fallback === true, }; } From e4db64311a9b47fa9e0f41db46549db0a34e398e Mon Sep 17 00:00:00 2001 From: Andrei Shpileuski Date: Sun, 26 Apr 2026 14:24:48 +0300 Subject: [PATCH 12/12] chore(release): 2.0.0-beta.4 --- README.md | 2 +- libs/rive-angular/CHANGELOG.md | 2 +- libs/rive-angular/README.md | 2 +- libs/rive-angular/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0e396fe..f018df9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`2.0.0-beta.3`** (pre-release). +Current release candidate in this branch: **`2.0.0-beta.4`** (pre-release). ## Migration from v1 to v2 diff --git a/libs/rive-angular/CHANGELOG.md b/libs/rive-angular/CHANGELOG.md index 44547e3..a36f1ab 100644 --- a/libs/rive-angular/CHANGELOG.md +++ b/libs/rive-angular/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [2.0.0-beta.4] - Unreleased +## [2.0.0-beta.4] - 2026-04-26 ### Changed diff --git a/libs/rive-angular/README.md b/libs/rive-angular/README.md index 9eaac1b..f4bdaae 100644 --- a/libs/rive-angular/README.md +++ b/libs/rive-angular/README.md @@ -11,7 +11,7 @@ Modern Angular wrapper for [Rive](https://rive.app) animations with reactive state management, built with Angular signals and zoneless architecture. **2.x** is the current major line: the public API follows [Semantic Versioning](https://semver.org/). -Current release candidate in this branch: **`2.0.0-beta.3`** (pre-release). +Current release candidate in this branch: **`2.0.0-beta.4`** (pre-release). ## Migration from v1 to v2 diff --git a/libs/rive-angular/package.json b/libs/rive-angular/package.json index 5ab0036..a588107 100644 --- a/libs/rive-angular/package.json +++ b/libs/rive-angular/package.json @@ -1,6 +1,6 @@ { "name": "@grandgular/rive-angular", - "version": "2.0.0-beta.3", + "version": "2.0.0-beta.4", "description": "Modern Angular wrapper for Rive animations with reactive state management, built with signals and zoneless architecture", "keywords": [ "angular",