Skip to content
Open
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
63 changes: 63 additions & 0 deletions src/components/voyage/SpaceVoyage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Main Space Voyage component for /voyage.
// A flying star-galleon explores TanStack libraries scattered across three
// altitude "dimensions" — a spacefaring cousin of the Island Explorer.

import { useState } from 'react'
import { VoyageScene } from './VoyageScene'
import { VoyageHUD } from './ui/VoyageHUD'
import type { VoyageEngine } from './engine/VoyageEngine'

const LOADING_MESSAGES = [
['Hoisting the solar sails...', 'Mind the cosmic wind'],
['Charting the star lanes...', 'X marks the nebula'],
['Waking the stardust crew...', 'They sleep in zero-g'],
['Tuning the dimension drive...', 'High, low, and in between'],
['Polishing the brass telescope...', 'For spotting distant worlds'],
['Counting the constellations...', 'We lost count at infinity'],
['Feeding the ship cat...', 'Even pirates need a navigator'],
['Calibrating the gravity anchor...', 'Down is relative out here'],
]

function LoadingOverlay() {
const [messageIndex] = useState(() =>
Math.floor(Math.random() * LOADING_MESSAGES.length),
)
const [headline, subtext] = LOADING_MESSAGES[messageIndex]

return (
<div className="absolute inset-0 bg-gradient-to-b from-[#0a0820] via-[#070a1a] to-black flex items-center justify-center z-50">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 border-4 border-white/20 border-t-cyan-300 rounded-full animate-spin" />
<p className="text-white text-lg font-medium">{headline}</p>
<p className="text-white/50 text-sm mt-2">{subtext}</p>
</div>
</div>
)
}

export default function SpaceVoyage() {
const [isLoading, setIsLoading] = useState(true)
const [engine, setEngine] = useState<VoyageEngine | null>(null)

return (
<div className="relative w-full h-[calc(100dvh-var(--navbar-height))] bg-black overflow-hidden">
{isLoading && <LoadingOverlay />}

{/* 3D scene */}
<div className="absolute inset-0">
<VoyageScene onLoadingChange={setIsLoading} onEngineReady={setEngine} />
</div>

{/* Vignette for depth */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background:
'radial-gradient(ellipse at center, transparent 45%, rgba(0,0,10,0.55) 100%)',
}}
/>

{!isLoading && <VoyageHUD engine={engine} />}
</div>
)
}
92 changes: 92 additions & 0 deletions src/components/voyage/VoyageScene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from 'react'
import { VoyageEngine } from './engine/VoyageEngine'

interface VoyageSceneProps {
onLoadingChange?: (loading: boolean) => void
onEngineReady?: (engine: VoyageEngine | null) => void
}

export function VoyageScene({
onLoadingChange,
onEngineReady,
}: VoyageSceneProps) {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<VoyageEngine | null>(null)
const [isReady, setIsReady] = useState(false)

// Wait until the container has real dimensions before booting the engine.
useEffect(() => {
const container = containerRef.current
if (!container) return

const checkSize = () => {
if (container.clientWidth > 0 && container.clientHeight > 0) {
setIsReady(true)
}
}

checkSize()
requestAnimationFrame(checkSize)

const observer = new ResizeObserver(checkSize)
observer.observe(container)
return () => observer.disconnect()
}, [])

useEffect(() => {
if (!isReady) return
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return

const width = container.clientWidth
const height = container.clientHeight
const dpr = Math.min(window.devicePixelRatio, 2)
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`

const engine = new VoyageEngine(canvas)
engineRef.current = engine

const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry && engineRef.current) {
const { width, height } = entry.contentRect
engineRef.current.resize(width, height)
}
})
resizeObserver.observe(container)

onLoadingChange?.(true)
engine
.init()
.then(() => {
engine.start()
onLoadingChange?.(false)
onEngineReady?.(engine)
})
.catch((err) => {
console.error('VoyageEngine init failed:', err)
onLoadingChange?.(false)
})

return () => {
resizeObserver.disconnect()
onEngineReady?.(null)
engine.dispose()
engineRef.current = null
}
}, [isReady, onLoadingChange, onEngineReady])
Comment on lines +64 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard the async init() continuation against unmount/dispose.

If the component unmounts (or the effect re-runs) while init() is still pending, the cleanup at Line 76 disposes the engine, but the in-flight promise still resolves and calls engine.start() on an already-disposed engine, plus onEngineReady(engine)/onLoadingChange(false) with a dead instance. dispose() sets disposed = true and stops the loop, so start() re-entering here restarts the render loop against torn-down resources.

Track cancellation and bail out in both the then and catch.

🛠️ Proposed fix
     const engine = new VoyageEngine(canvas)
     engineRef.current = engine
+    let cancelled = false

     const resizeObserver = new ResizeObserver((entries) => {
       const entry = entries[0]
       if (entry && engineRef.current) {
         const { width, height } = entry.contentRect
         engineRef.current.resize(width, height)
       }
     })
     resizeObserver.observe(container)

     onLoadingChange?.(true)
     engine
       .init()
       .then(() => {
+        if (cancelled) return
         engine.start()
         onLoadingChange?.(false)
         onEngineReady?.(engine)
       })
       .catch((err) => {
         console.error('VoyageEngine init failed:', err)
+        if (cancelled) return
         onLoadingChange?.(false)
       })

     return () => {
+      cancelled = true
       resizeObserver.disconnect()
       onEngineReady?.(null)
       engine.dispose()
       engineRef.current = null
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/voyage/VoyageScene.tsx` around lines 64 - 82, The continuation
after engine.init() must be guarded so it doesn't act on a disposed/unmounted
engine: add a cancellation flag (e.g., let cancelled = false) or use an
AbortController inside the effect and check it before calling engine.start(),
onLoadingChange(false) and onEngineReady(engine) in the .then() and .catch()
handlers; set cancelled = true (or abort) in the cleanup before calling
engine.dispose() and clearing engineRef/current and disconnecting resizeObserver
so any in-flight promise bails instead of touching the torn-down engine
instance.


return (
<div ref={containerRef} style={{ width: '100%', height: '100%' }}>
<canvas
ref={canvasRef}
style={{ display: 'block', background: '#04060e' }}
/>
</div>
)
}
Loading
Loading