From d836b1f06852212e8b0967d6db2bb9edbc23909d Mon Sep 17 00:00:00 2001 From: filemon Date: Mon, 30 Mar 2026 18:38:23 -0300 Subject: [PATCH] fix(blobbi): use RAF loop in useExternalEyeOffset to prevent stuck eyes The previous useEffect-based approach only applied eye transforms when the externalEyeOffset prop changed. This caused eyes to get stuck in the center when the companion was idle because: - The gaze RAF loop updates eyeOffset state continuously - React batches state updates, so re-renders may not happen every frame - useBlobbiEyes also runs a RAF loop for blinking that could interfere - SVG content changes (emotion recipes) could reset transforms The fix uses a RAF loop that continuously applies the transform, reading the latest offset from a ref. This ensures eyes stay positioned correctly regardless of React render timing or SVG DOM changes. Dragging previously 'fixed' the stuck eyes because isDragging changes caused guaranteed re-renders that triggered the old useEffect. --- src/blobbi/ui/lib/useExternalEyeOffset.ts | 107 ++++++++++++++++------ 1 file changed, 77 insertions(+), 30 deletions(-) diff --git a/src/blobbi/ui/lib/useExternalEyeOffset.ts b/src/blobbi/ui/lib/useExternalEyeOffset.ts index cb708d6d..8d36ec05 100644 --- a/src/blobbi/ui/lib/useExternalEyeOffset.ts +++ b/src/blobbi/ui/lib/useExternalEyeOffset.ts @@ -6,14 +6,20 @@ * instead of the default mouse tracking. * * This hook: + * - Runs a RAF loop to continuously apply eye offset * - Queries the DOM for blobbi-eye-left and blobbi-eye-right elements * - Converts -1 to 1 offset to pixel movement * - Applies asymmetric vertical movement (up stronger than down) * - * Previously this logic was duplicated in BlobbiBabyVisual and BlobbiAdultVisual. + * The RAF loop is necessary because: + * - useBlobbiEyes also runs a RAF loop for blinking + * - SVG content can change due to emotion recipes + * - A useEffect that only runs on prop change can miss DOM updates + * + * Previously this was a simple useEffect, but that caused stuck eyes when idle. */ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import type { ExternalEyeOffset, BlobbiVariant } from './types'; import { @@ -40,8 +46,9 @@ interface UseExternalEyeOffsetOptions { /** * Apply external eye offset to Blobbi eye elements. * - * This bypasses useBlobbiEyes and gives the external system (e.g., companion) - * full control over eye position. + * This bypasses useBlobbiEyes tracking and gives the external system (e.g., companion) + * full control over eye position. Uses a RAF loop to ensure transforms are continuously + * applied even when the SVG DOM changes. */ export function useExternalEyeOffset({ containerRef, @@ -49,38 +56,78 @@ export function useExternalEyeOffset({ isSleeping, variant, }: UseExternalEyeOffsetOptions): void { + // Use ref to store latest offset for RAF loop to read + const offsetRef = useRef(externalEyeOffset); + const animationRef = useRef(null); + + // Keep ref updated with latest value useEffect(() => { - if (!externalEyeOffset || !containerRef.current || isSleeping) return; - - const eyeElements = containerRef.current.querySelectorAll(`.${EYE_CLASSES.eyeLeft}, .${EYE_CLASSES.eyeRight}`); - if (eyeElements.length === 0) return; - + offsetRef.current = externalEyeOffset; + }, [externalEyeOffset]); + + // RAF loop for continuous eye offset application + useEffect(() => { + // Don't run loop if disabled + if (!externalEyeOffset || isSleeping) { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + return; + } + // Select movement constants based on variant const maxMovementX = variant === 'baby' ? BABY_EXTERNAL_EYE_MAX_X : ADULT_EXTERNAL_EYE_MAX_X; const maxMovementYUp = variant === 'baby' ? BABY_EXTERNAL_EYE_MAX_Y_UP : ADULT_EXTERNAL_EYE_MAX_Y_UP; const maxMovementYDown = variant === 'baby' ? BABY_EXTERNAL_EYE_MAX_Y_DOWN : ADULT_EXTERNAL_EYE_MAX_Y_DOWN; - - // Convert -1 to 1 offset to pixel movement - const x = externalEyeOffset.x * maxMovementX; - - // Asymmetric vertical movement: - // - Upward (negative y): stronger movement for clear "looking up" effect - // - Downward (positive y): reduced movement to avoid looking too droopy - // Y offset: -1 = looking up, +1 = looking down - const y = - externalEyeOffset.y < 0 - ? externalEyeOffset.y * maxMovementYUp // Looking up: full range - : externalEyeOffset.y * maxMovementYDown; // Looking down: reduced range - - eyeElements.forEach((el) => { - // Check if a CSS animation is controlling this element's transform - // (e.g., sleepy wake-up glance). Don't override CSS animations. - const animationName = getComputedStyle(el).animationName; - if (animationName && animationName !== 'none') { - return; // Let CSS animation control the transform + + const applyOffset = () => { + const offset = offsetRef.current; + if (!offset || !containerRef.current) { + animationRef.current = requestAnimationFrame(applyOffset); + return; } - el.setAttribute('transform', `translate(${x} ${y})`); - }); + const eyeElements = containerRef.current.querySelectorAll( + `.${EYE_CLASSES.eyeLeft}, .${EYE_CLASSES.eyeRight}` + ); + + if (eyeElements.length > 0) { + // Convert -1 to 1 offset to pixel movement + const x = offset.x * maxMovementX; + + // Asymmetric vertical movement: + // - Upward (negative y): stronger movement for clear "looking up" effect + // - Downward (positive y): reduced movement to avoid looking too droopy + // Y offset: -1 = looking up, +1 = looking down + const y = offset.y < 0 + ? offset.y * maxMovementYUp // Looking up: full range + : offset.y * maxMovementYDown; // Looking down: reduced range + + const transform = `translate(${x} ${y})`; + + eyeElements.forEach((el) => { + // Check if a CSS animation is controlling this element's transform + // (e.g., sleepy wake-up glance). Don't override CSS animations. + const animationName = getComputedStyle(el).animationName; + if (animationName && animationName !== 'none') { + return; // Let CSS animation control the transform + } + + el.setAttribute('transform', transform); + }); + } + + animationRef.current = requestAnimationFrame(applyOffset); + }; + + animationRef.current = requestAnimationFrame(applyOffset); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + }; }, [containerRef, externalEyeOffset, isSleeping, variant]); }