fix(lint): satisfy react-hooks immutability + set-state-in-effect rules
🔍 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

The new react-compiler-aware lint rules flag legitimate Three.js
external-system synchronizations (texture/uniform/AnimationAction
mutations) and a derived-state reset in PylonDownedPylon. None of
these are bugs — they're the canonical way to bridge React state
with imperative graphics objects — so they're annotated with
targeted eslint-disable comments and a small reorder.

- EbikeGPSMap: disable on uniform/texture sync effects
- EbikeSpeedmeter: disable around the canvas+texture useFrame sync
- PylonFarmerNPC: disable around playAnim (drei AnimationAction
  fadeIn/fadeOut/setLoop/clampWhenFinished) and the effects/frame
  callbacks that invoke it
- PylonDownedPylon: move showUpright/isPylonInteractive declarations
  above the useFrame that reads them (fixes access-before-declared)
  and disable set-state-in-effect on the per-step isRaised reset
This commit is contained in:
Tom Boullay
2026-06-03 00:04:14 +02:00
parent 974f340d33
commit ff4ead1d24
4 changed files with 49 additions and 18 deletions
+4
View File
@@ -181,6 +181,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Sync texture into uniform when it changes (canvas resize) // Sync texture into uniform when it changes (canvas resize)
useEffect(() => { useEffect(() => {
// External Three.js material uniform sync — intentional side effect.
// eslint-disable-next-line react-hooks/immutability
shaderMat.uniforms.map.value = texture; shaderMat.uniforms.map.value = texture;
}, [shaderMat, texture]); }, [shaderMat, texture]);
@@ -196,6 +198,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Resize the canvas whenever canvasSize changes (texture declared above) // Resize the canvas whenever canvasSize changes (texture declared above)
useEffect(() => { useEffect(() => {
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize }); Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
// External Three.js texture invalidation — intentional side effect.
// eslint-disable-next-line react-hooks/immutability
texture.needsUpdate = true; texture.needsUpdate = true;
}, [canvasSize, offscreenCanvas, texture]); }, [canvasSize, offscreenCanvas, texture]);
+3
View File
@@ -123,6 +123,8 @@ export function EbikeSpeedmeter({
); );
// ── Frame loop ────────────────────────────────────────────────────────────── // ── Frame loop ──────────────────────────────────────────────────────────────
/* External Three.js canvas+texture sync — intentional side effects in useFrame. */
/* eslint-disable react-hooks/immutability */
useFrame((_, delta) => { useFrame((_, delta) => {
// 1. Smooth speed factor // 1. Smooth speed factor
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1); const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
@@ -181,6 +183,7 @@ export function EbikeSpeedmeter({
} }
fillTexture.needsUpdate = true; fillTexture.needsUpdate = true;
/* eslint-enable react-hooks/immutability */
}); });
return ( return (
@@ -30,9 +30,26 @@ export function PylonDownedPylon(): React.JSX.Element | null {
const straightenStartRef = useRef<number | null>(null); const straightenStartRef = useRef<number | null>(null);
const hasPlayedFirstAudioRef = useRef(false); const hasPlayedFirstAudioRef = useRef(false);
const showUpright =
isRaised ||
mainState !== "pylon" ||
step === "waiting" ||
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling" ||
step === "done" ||
step === "narrator-outro";
const isPylonInteractive = step === "arrived" || step === "npc-return";
useEffect(() => { useEffect(() => {
if (step === "arrived") { if (step === "arrived") {
hasPlayedFirstAudioRef.current = false; hasPlayedFirstAudioRef.current = false;
// Reset the "raised" latch when a new run begins. This is derived
// resync from the step prop and runs once per step transition.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsRaised(false); setIsRaised(false);
} }
}, [step]); }, [step]);
@@ -62,20 +79,6 @@ export function PylonDownedPylon(): React.JSX.Element | null {
); );
}); });
const showUpright =
isRaised ||
mainState !== "pylon" ||
step === "waiting" ||
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling" ||
step === "done" ||
step === "narrator-outro";
const isPylonInteractive = step === "arrived" || step === "npc-return";
const beginStraighten = (): void => { const beginStraighten = (): void => {
setIsStraightening(true); setIsStraightening(true);
pylonStraighteningSignal.started = true; pylonStraighteningSignal.started = true;
@@ -34,7 +34,10 @@ const _target = new THREE.Vector3();
* Compute the Y rotation (radians) for a model whose default forward * Compute the Y rotation (radians) for a model whose default forward
* direction is +Z, so that it faces from `from` toward `to`. * direction is +Z, so that it faces from `from` toward `to`.
*/ */
function faceToward(from: THREE.Vector3, to: readonly [number, number, number]): number { function faceToward(
from: THREE.Vector3,
to: readonly [number, number, number],
): number {
const dx = to[0] - from.x; const dx = to[0] - from.x;
const dz = to[2] - from.z; const dz = to[2] - from.z;
return Math.atan2(dx, dz); return Math.atan2(dx, dz);
@@ -71,6 +74,10 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
// ─── playAnim ───────────────────────────────────────────────────────────── // ─── playAnim ─────────────────────────────────────────────────────────────
// NOTE: actions is intentionally in the dep array so this callback is // NOTE: actions is intentionally in the dep array so this callback is
// recreated when drei's internal state populates the actions map. // recreated when drei's internal state populates the actions map.
// External THREE.AnimationAction lifecycle methods (fadeOut/fadeIn/play +
// setLoop/clampWhenFinished mutations) are intentional side effects on
// drei-managed objects.
/* eslint-disable react-hooks/immutability */
const playAnim = useCallback( const playAnim = useCallback(
(name: NPCAnimation, fade = ANIM_FADE): void => { (name: NPCAnimation, fade = ANIM_FADE): void => {
if (currentAnimRef.current === name) return; if (currentAnimRef.current === name) return;
@@ -89,6 +96,7 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
}, },
[actions], [actions],
); );
/* eslint-enable react-hooks/immutability */
// ─── Async audio after pylon is raised ──────────────────────────────────── // ─── Async audio after pylon is raised ────────────────────────────────────
const playPostRaiseAudioAndAdvance = useCallback(async () => { const playPostRaiseAudioAndAdvance = useCallback(async () => {
@@ -112,6 +120,8 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
// ─── Step-driven animation ──────────────────────────────────────────────── // ─── Step-driven animation ────────────────────────────────────────────────
// Fires when step changes OR when playAnim changes (i.e. when actions load). // Fires when step changes OR when playAnim changes (i.e. when actions load).
// playAnim mutates drei-managed AnimationAction internals (intentional).
/* eslint-disable react-hooks/immutability */
useEffect(() => { useEffect(() => {
currentAnimRef.current = null; currentAnimRef.current = null;
if (step === "arrived") { if (step === "arrived") {
@@ -168,7 +178,10 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
currentPosRef.current.lerp(_target, t); currentPosRef.current.lerp(_target, t);
} else if (!isStraightening && currentAnimRef.current === "walk") { } else if (!isStraightening && currentAnimRef.current === "walk") {
playAnim("idle"); playAnim("idle");
savedRotationYRef.current = faceToward(currentPosRef.current, PYLON_WORLD_POSITION); savedRotationYRef.current = faceToward(
currentPosRef.current,
PYLON_WORLD_POSITION,
);
} }
group.position.copy(currentPosRef.current); group.position.copy(currentPosRef.current);
} else if (step === "inspected") { } else if (step === "inspected") {
@@ -180,8 +193,15 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
} }
// ── Rotation ────────────────────────────────────────────────────────── // ── Rotation ──────────────────────────────────────────────────────────
if (step === "npc-return" && !isCompleted && currentAnimRef.current === "walk") { if (
const walkRotY = faceToward(currentPosRef.current, PYLON_FARMER_NPC_WALK_LOOK_AT); step === "npc-return" &&
!isCompleted &&
currentAnimRef.current === "walk"
) {
const walkRotY = faceToward(
currentPosRef.current,
PYLON_FARMER_NPC_WALK_LOOK_AT,
);
group.rotation.set(0, walkRotY, 0); group.rotation.set(0, walkRotY, 0);
} else { } else {
group.rotation.set(0, savedRotationYRef.current, 0); group.rotation.set(0, savedRotationYRef.current, 0);
@@ -189,6 +209,7 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE); group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
}); });
/* eslint-enable react-hooks/immutability */
if (mainState !== "pylon") return null; if (mainState !== "pylon") return null;
if (step !== "arrived" && step !== "npc-return" && step !== "inspected") if (step !== "arrived" && step !== "npc-return" && step !== "inspected")