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
🔍 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:
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user