From a30a9a2d291ad5820d99d8b4b51b4de109b003d0 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Tue, 2 Jun 2026 16:54:28 +0200 Subject: [PATCH] fix(handtracking): absorb React StrictMode double-mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In dev, intentionally mounts → unmounts → remounts each effect to surface non-idempotent code. The hand tracking hooks were calling getUserMedia and creating MediaPipe / WebSocket runtimes on every mount, which in practice ran the full start/stop/start cycle inside a few milliseconds and pushed WebGL over its limit on top of the loaded scene → context lost. Add HAND_TRACKING_RUNTIME_START_DELAY_MS (80ms) and delay the actual start() call behind a setTimeout in both useBrowserHandTracking and useRemoteHandTracking. The cleanup clears the timer, so a fast mount/unmount never reaches start(). 80ms is invisible to the user (<5 frames at 60fps) and also absorbs rapid `nearby` toggles at trigger borders. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/data/handTrackingConfig.ts | 5 +++++ src/hooks/handTracking/useBrowserHandTracking.ts | 10 +++++++++- src/hooks/handTracking/useRemoteHandTracking.ts | 10 +++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/data/handTrackingConfig.ts b/src/data/handTrackingConfig.ts index 6027d12..2b28dda 100644 --- a/src/data/handTrackingConfig.ts +++ b/src/data/handTrackingConfig.ts @@ -9,3 +9,8 @@ export const HAND_TRACKING_BROWSER_WASM_URL = export const HAND_TRACKING_BROWSER_MODEL_URL = "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task"; export const HAND_TRACKING_BROWSER_DELEGATE: "CPU" | "GPU" = "CPU"; + +// Delay before the runtime actually starts after `enabled` flips to true. +// Absorbs React StrictMode's mount/unmount/mount cycle in dev and rapid +// `nearby` toggles at trigger borders. Invisible to the user (~5 frames). +export const HAND_TRACKING_RUNTIME_START_DELAY_MS = 80; diff --git a/src/hooks/handTracking/useBrowserHandTracking.ts b/src/hooks/handTracking/useBrowserHandTracking.ts index 7310f99..285fa2f 100644 --- a/src/hooks/handTracking/useBrowserHandTracking.ts +++ b/src/hooks/handTracking/useBrowserHandTracking.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { HAND_TRACKING_FRAME_HEIGHT, HAND_TRACKING_FRAME_WIDTH, + HAND_TRACKING_RUNTIME_START_DELAY_MS, HAND_TRACKING_TARGET_FPS, } from "@/data/handTrackingConfig"; import { @@ -169,10 +170,17 @@ export function useBrowserHandTracking({ } }; - void start(); + // Delay the actual start so that a StrictMode mount/unmount/mount + // cycle, or a rapid `enabled` toggle at a trigger border, does not + // spin up the camera + MediaPipe twice in a few milliseconds. + const startTimer = window.setTimeout(() => { + if (cancelled) return; + void start(); + }, HAND_TRACKING_RUNTIME_START_DELAY_MS); return () => { cancelled = true; + window.clearTimeout(startTimer); cleanup(); }; }, [enabled]); diff --git a/src/hooks/handTracking/useRemoteHandTracking.ts b/src/hooks/handTracking/useRemoteHandTracking.ts index e9dc1db..fafa568 100644 --- a/src/hooks/handTracking/useRemoteHandTracking.ts +++ b/src/hooks/handTracking/useRemoteHandTracking.ts @@ -4,6 +4,7 @@ import { HAND_TRACKING_FRAME_WIDTH, HAND_TRACKING_JPEG_QUALITY, HAND_TRACKING_RESPONSE_TIMEOUT_MS, + HAND_TRACKING_RUNTIME_START_DELAY_MS, HAND_TRACKING_TARGET_FPS, } from "@/data/handTrackingConfig"; import { getHandTrackingWsUrl } from "@/utils/handTracking/handTrackingEndpoint"; @@ -330,10 +331,17 @@ export function useRemoteHandTracking({ } }; - void start(); + // Delay the actual start so that a StrictMode mount/unmount/mount + // cycle, or a rapid `enabled` toggle at a trigger border, does not + // open the camera + WebSocket twice in a few milliseconds. + const startTimer = window.setTimeout(() => { + if (cancelled) return; + void start(); + }, HAND_TRACKING_RUNTIME_START_DELAY_MS); return () => { cancelled = true; + window.clearTimeout(startTimer); cleanup(); }; }, [enabled, websocketUrl]);