Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 10 additions & 17 deletions src/three-components/OrientationCubeCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,14 @@ function computePointInFront(
export const OrientationCubeCanvas = () => {
const { mainCameraRef } = useCameraController()
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
const sceneRef = useRef<THREE.Scene | null>(null)
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null)
const animationFrameRef = useRef<number | null>(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({
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -186,10 +183,6 @@ export const OrientationCubeCanvas = () => {
scene.clear()
renderer.dispose()
renderer.forceContextLoss()

if (canvasRef.current && containerRef.current) {
containerRef.current.removeChild(canvasRef.current)
}
}
}, [mainCameraRef])

Expand Down
83 changes: 83 additions & 0 deletions tests/orientation-cube-strict-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<div id="root"></div>')
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(
<StrictMode>
<CameraControllerProvider defaultTarget={new THREE.Vector3()}>
<OrientationCubeCanvas />
</CameraControllerProvider>
</StrictMode>,
)
})

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()
}
})
Loading