Skip to content

Commit 2ebd564

Browse files
committed
Resolves #391
1 parent 9310a1d commit 2ebd564

4 files changed

Lines changed: 67 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ai-elements": patch
3+
---
4+
5+
Fix Persona crashing in Vite dev mode by deferring WebGL2 context creation to avoid exhausting browser context limits during React Strict Mode's double-mount cycle.

apps/docs/content/components/(voice)/persona.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ The Persona component responds to five distinct states, each triggering differen
118118
- **speaking**: Active when the AI is delivering a response (e.g., text-to-speech output)
119119
- **asleep**: A dormant state for when the AI is inactive or in low-power mode
120120

121+
## React Strict Mode (Vite)
122+
123+
The Persona component uses WebGL2 for rendering. Browsers limit the number of active WebGL2 contexts (~8–16), and React Strict Mode (enabled by default in Vite dev) double-mounts components, which can exhaust that limit and crash the page.
124+
125+
The component includes a built-in guard that defers WebGL2 initialization by one frame, preventing context creation during Strict Mode's throw-away mount. This means the component works in Vite dev mode out of the box — no configuration needed.
126+
127+
If you still experience crashes (for example, when rendering many Persona instances simultaneously), reduce the number of concurrent Persona components on screen.
128+
121129
## Usage Examples
122130

123131
### Basic Usage

packages/elements/__tests__/persona.test.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ vi.mock<typeof import("@rive-app/react-webgl2")>(
3939
})
4040
);
4141

42+
// Mock requestAnimationFrame to fire synchronously so that
43+
// useStrictModeSafeInit's deferred init completes within the render cycle.
44+
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
45+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
46+
cb(0); // oxlint-disable-line eslint-plugin-promise(prefer-await-to-callbacks)
47+
return 0;
48+
});
49+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
50+
4251
// Setup function for persona tests
4352
const setupPersonaTests = () => {
4453
vi.spyOn(console, "warn").mockImplementation(vi.fn());
@@ -506,8 +515,8 @@ describe("persona - Callback Execution", () => {
506515
const onLoad = vi.fn();
507516

508517
mockUseRive.mockImplementation((params) => {
509-
// Simulate calling onLoad
510-
params.onLoad?.({ artboard: "test" });
518+
// Simulate calling onLoad (params is null before deferred init)
519+
params?.onLoad?.({ artboard: "test" });
511520
return {
512521
RiveComponent: MockRiveComponent,
513522
rive: {},
@@ -525,8 +534,8 @@ describe("persona - Callback Execution", () => {
525534
const testError = new Error("Test error");
526535

527536
mockUseRive.mockImplementation((params) => {
528-
// Simulate calling onLoadError
529-
params.onLoadError?.(testError);
537+
// Simulate calling onLoadError (params is null before deferred init)
538+
params?.onLoadError?.(testError);
530539
return {
531540
RiveComponent: MockRiveComponent,
532541
rive: {},
@@ -543,8 +552,8 @@ describe("persona - Callback Execution", () => {
543552
const onReady = vi.fn();
544553

545554
mockUseRive.mockImplementation((params) => {
546-
// Simulate calling onRiveReady
547-
params.onRiveReady?.();
555+
// Simulate calling onRiveReady (params is null before deferred init)
556+
params?.onRiveReady?.();
548557
return {
549558
RiveComponent: MockRiveComponent,
550559
rive: {},
@@ -562,7 +571,7 @@ describe("persona - Callback Execution", () => {
562571
const pauseEvent = { type: "pause" };
563572

564573
mockUseRive.mockImplementation((params) => {
565-
params.onPause?.(pauseEvent);
574+
params?.onPause?.(pauseEvent);
566575
return {
567576
RiveComponent: MockRiveComponent,
568577
rive: {},
@@ -580,7 +589,7 @@ describe("persona - Callback Execution", () => {
580589
const playEvent = { type: "play" };
581590

582591
mockUseRive.mockImplementation((params) => {
583-
params.onPlay?.(playEvent);
592+
params?.onPlay?.(playEvent);
584593
return {
585594
RiveComponent: MockRiveComponent,
586595
rive: {},
@@ -598,7 +607,7 @@ describe("persona - Callback Execution", () => {
598607
const stopEvent = { type: "stop" };
599608

600609
mockUseRive.mockImplementation((params) => {
601-
params.onStop?.(stopEvent);
610+
params?.onStop?.(stopEvent);
602611
return {
603612
RiveComponent: MockRiveComponent,
604613
rive: {},

packages/elements/src/persona.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ import {
1212
import type { FC, ReactNode } from "react";
1313
import { memo, useEffect, useMemo, useRef, useState } from "react";
1414

15+
// Delays Rive initialization by one frame so that React Strict Mode's
16+
// immediate unmount cycle never creates a WebGL2 context. Only the
17+
// second (real) mount will initialise, avoiding context exhaustion.
18+
const useStrictModeSafeInit = () => {
19+
const [ready, setReady] = useState(false);
20+
21+
useEffect(() => {
22+
const id = requestAnimationFrame(() => setReady(true));
23+
return () => {
24+
cancelAnimationFrame(id);
25+
setReady(false);
26+
};
27+
}, []);
28+
29+
return ready;
30+
};
31+
1532
export type PersonaState =
1633
| "idle"
1734
| "listening"
@@ -230,17 +247,25 @@ export const Persona: FC<PersonaProps> = memo(
230247
[]
231248
);
232249

233-
const { rive, RiveComponent } = useRive({
234-
autoplay: true,
235-
onLoad: stableCallbacks.onLoad,
236-
onLoadError: stableCallbacks.onLoadError,
237-
onPause: stableCallbacks.onPause,
238-
onPlay: stableCallbacks.onPlay,
239-
onRiveReady: stableCallbacks.onReady,
240-
onStop: stableCallbacks.onStop,
241-
src: source.source,
242-
stateMachines: stateMachine,
243-
});
250+
// Delay initialisation by one frame to avoid creating (and leaking)
251+
// a WebGL2 context during React Strict Mode's first throw-away mount.
252+
const ready = useStrictModeSafeInit();
253+
254+
const { rive, RiveComponent } = useRive(
255+
ready
256+
? {
257+
autoplay: true,
258+
onLoad: stableCallbacks.onLoad,
259+
onLoadError: stableCallbacks.onLoadError,
260+
onPause: stableCallbacks.onPause,
261+
onPlay: stableCallbacks.onPlay,
262+
onRiveReady: stableCallbacks.onReady,
263+
onStop: stableCallbacks.onStop,
264+
src: source.source,
265+
stateMachines: stateMachine,
266+
}
267+
: null
268+
);
244269

245270
const listeningInput = useStateMachineInput(
246271
rive,

0 commit comments

Comments
 (0)