fix(handtracking): absorb React StrictMode double-mount
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled

In dev, <StrictMode> 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) <noreply@anthropic.com>
This commit is contained in:
Tom Boullay
2026-06-02 16:54:28 +02:00
parent d217c3376b
commit a30a9a2d29
3 changed files with 23 additions and 2 deletions
+5
View File
@@ -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;
@@ -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]);
@@ -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]);