From 70a7ee2ea5e05d5a9ec5d868e8f6733e09f202a6 Mon Sep 17 00:00:00 2001 From: sandevistan Date: Mon, 24 Aug 2026 10:31:01 +0200 Subject: [PATCH] fix(avatar-react): keep ambient motion running --- packages/avatar-react/src/Avatar.tsx | 23 +++++++++-- .../src/__tests__/avatar-test.tsx | 40 ++++++++++++++++++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/avatar-react/src/Avatar.tsx b/packages/avatar-react/src/Avatar.tsx index 9cbe0b0..e3a21ab 100644 --- a/packages/avatar-react/src/Avatar.tsx +++ b/packages/avatar-react/src/Avatar.tsx @@ -40,6 +40,21 @@ const runtimeEnvironment = () => ({ reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, }) +const shouldRunFrameLoop = ( + definition: AvatarDefinition, + playback: Readonly, + environment: ReturnType +) => { + if (playback.status === 'playing') return true + const motion = definition.expressions[playback.activeExpression]?.motion + return ( + playback.status === 'stopped' && + !environment.reduceMotion && + motion !== undefined && + (motion.eyes !== 'none' || motion.body !== 'none') + ) +} + export const markAvatarDefinitionValidated = (definition: object) => { validatedDefinitions.add(definition) } @@ -331,7 +346,7 @@ export function Avatar({ }, [playback.activeExpression, onExpressionChange]) useEffect(() => { - if (playback.status !== 'playing') return + if (!shouldRunFrameLoop(definition, playback, runtimeEnvironment())) return let frame = 0 const tick = (now: number) => { const current = playbackRef.current @@ -351,11 +366,13 @@ export function Avatar({ } const frameScene = renderPlaybackFrame(next, now, environment) paintScene(frameScene) - if (next.status === 'playing') frame = requestAnimationFrame(tick) + if (shouldRunFrameLoop(definition, next, environment)) { + frame = requestAnimationFrame(tick) + } } frame = requestAnimationFrame(tick) return () => cancelAnimationFrame(frame) - }, [definition, playback.status, onAnimationEnd]) + }, [definition, playback.activeExpression, playback.status, onAnimationEnd]) const controlled = animation !== undefined || expression !== undefined useImperativeHandle(ref, () => ({ diff --git a/packages/avatar-react/src/__tests__/avatar-test.tsx b/packages/avatar-react/src/__tests__/avatar-test.tsx index 4e92a33..6f596e5 100644 --- a/packages/avatar-react/src/__tests__/avatar-test.tsx +++ b/packages/avatar-react/src/__tests__/avatar-test.tsx @@ -19,6 +19,11 @@ const expression = { motion: { eyes: 'none', body: 'none' }, } as const +const ambientExpression = { + ...expression, + motion: { eyes: 'none', body: 'shake' }, +} as const + const definition: AvatarDefinition = { schema: 'bible-strong/avatar-definition', schemaVersion: 1, @@ -28,8 +33,12 @@ const definition: AvatarDefinition = { nodes: [], }, colors: { body: '#5b7fe5', eyes: '#111316' }, - expressions: { neutral: expression, smile: { ...expression, head: { x: 0, y: 10, z: 0 } } }, - expressionOrder: ['neutral', 'smile'], + expressions: { + neutral: expression, + smile: { ...expression, head: { x: 0, y: 10, z: 0 } }, + restless: ambientExpression, + }, + expressionOrder: ['neutral', 'smile', 'restless'], animations: { greet: { playbackMode: 'loop', @@ -298,6 +307,33 @@ describe('@bible-strong/avatar-react', () => { cancel.mockRestore() }) + it('keeps scheduling frames for a controlled expression with ambient motion', () => { + let nextFrame = 0 + const frames = new Map() + const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frames.set(++nextFrame, callback) + return nextFrame + }) + const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + frames.delete(id) + }) + + render() + + act(() => { + for (let index = 1; index <= 3; index++) { + const callback = [...frames.values()].at(-1) + frames.clear() + callback?.(index * 1_000) + } + }) + + expect(request).toHaveBeenCalledTimes(4) + expect(frames).toHaveLength(1) + request.mockRestore() + cancel.mockRestore() + }) + it('fires once-completion exactly once under Strict Mode', () => { let nextFrame = 0 const frames = new Map()