diff --git a/src/three-components/OrientationCubeCanvas.tsx b/src/three-components/OrientationCubeCanvas.tsx index 78eea711..b8004ef3 100644 --- a/src/three-components/OrientationCubeCanvas.tsx +++ b/src/three-components/OrientationCubeCanvas.tsx @@ -28,19 +28,14 @@ function computePointInFront( export const OrientationCubeCanvas = () => { const { mainCameraRef } = useCameraController() const containerRef = useRef(null) - const canvasRef = useRef(null) - const rendererRef = useRef(null) - const sceneRef = useRef(null) - const cameraRef = useRef(null) - const animationFrameRef = useRef(null) useEffect(() => { if (!containerRef.current) return // Create canvas + const container = containerRef.current const canvas = document.createElement("canvas") - canvasRef.current = canvas - containerRef.current.appendChild(canvas) + container.appendChild(canvas) // Create renderer const renderer = new THREE.WebGLRenderer({ @@ -50,16 +45,13 @@ export const OrientationCubeCanvas = () => { }) renderer.setSize(120, 120) renderer.setPixelRatio(window.devicePixelRatio) - rendererRef.current = renderer // Create scene const scene = new THREE.Scene() - sceneRef.current = scene // Create camera const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000) camera.up.set(0, 0, 1) - cameraRef.current = camera // Add ambient light const ambientLight = new THREE.AmbientLight(0xffffff, Math.PI / 2) @@ -146,6 +138,7 @@ export const OrientationCubeCanvas = () => { group.add(bottomText) // Animation loop + let animationFrameId: number | null = null const animate = () => { if (mainCameraRef.current) { const cameraPosition = computePointInFront( @@ -160,15 +153,19 @@ export const OrientationCubeCanvas = () => { } renderer.render(scene, camera) - animationFrameRef.current = requestAnimationFrame(animate) + animationFrameId = requestAnimationFrame(animate) } animate() // Cleanup return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) + // StrictMode clears DOM refs before replaying passive effects, so clean up + // the canvas owned by this effect instead of reading containerRef.current. + canvas.remove() + + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) } frontText.dispose() @@ -186,10 +183,6 @@ export const OrientationCubeCanvas = () => { scene.clear() renderer.dispose() renderer.forceContextLoss() - - if (canvasRef.current && containerRef.current) { - containerRef.current.removeChild(canvasRef.current) - } } }, [mainCameraRef]) diff --git a/tests/orientation-cube-strict-mode.test.tsx b/tests/orientation-cube-strict-mode.test.tsx new file mode 100644 index 00000000..ea18f25e --- /dev/null +++ b/tests/orientation-cube-strict-mode.test.tsx @@ -0,0 +1,83 @@ +import { expect, mock, test } from "bun:test" +import { JSDOM } from "jsdom" +import { StrictMode, act } from "react" +import { createRoot } from "react-dom/client" +import * as THREE from "three" + +class MockWebGLRenderer { + setSize() {} + setPixelRatio() {} + render() {} + dispose() {} + forceContextLoss() {} +} + +class MockText extends THREE.Object3D { + text = "" + color = "" + fontSize = 0 + anchorX = "" + anchorY = "" + depthOffset = 0 + font: string | null = null + + sync() {} + dispose() {} +} + +mock.module("three", () => ({ + ...THREE, + WebGLRenderer: MockWebGLRenderer, +})) +mock.module("troika-three-text", () => ({ Text: MockText })) + +const { CameraControllerProvider } = await import( + "../src/contexts/CameraControllerContext" +) +const { OrientationCubeCanvas } = await import( + "../src/three-components/OrientationCubeCanvas" +) + +test("keeps one orientation canvas after a StrictMode effect replay", async () => { + const dom = new JSDOM('
') + const previousWindow = globalThis.window + const previousDocument = globalThis.document + const previousRequestAnimationFrame = globalThis.requestAnimationFrame + const previousCancelAnimationFrame = globalThis.cancelAnimationFrame + let animationFrameId = 0 + + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + requestAnimationFrame: () => ++animationFrameId, + cancelAnimationFrame: () => {}, + IS_REACT_ACT_ENVIRONMENT: true, + }) + + const container = document.getElementById("root")! + const reactRoot = createRoot(container) + + try { + await act(async () => { + reactRoot.render( + + + + + , + ) + }) + + expect(container.querySelectorAll("canvas")).toHaveLength(1) + } finally { + await act(async () => reactRoot.unmount()) + Object.assign(globalThis, { + window: previousWindow, + document: previousDocument, + requestAnimationFrame: previousRequestAnimationFrame, + cancelAnimationFrame: previousCancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: false, + }) + dom.window.close() + } +})