diff --git a/frontend/src/three/GhostCanvas.test.tsx b/frontend/src/three/GhostCanvas.test.tsx new file mode 100644 index 0000000..08fa717 --- /dev/null +++ b/frontend/src/three/GhostCanvas.test.tsx @@ -0,0 +1,76 @@ +// The apparition must never take the séance down with it. +// +// This is a regression test for a real, observed failure: on a device where a +// WebGL context cannot be created, `new THREE.WebGLRenderer()` throws, the +// exception escapes the effect, and — with no error boundary above it — React +// unmounts the ENTIRE root. The seeker got a blank page: no modes, no ask +// field, no transcript. Verified in headless Chrome, where `#root` had zero +// children and the console read "THREE.WebGLRenderer: Error creating WebGL +// context". + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const construct = vi.fn() + +vi.mock('./GhostScene', () => ({ + GhostScene: class { + constructor(canvas: HTMLCanvasElement) { + construct(canvas) + } + start() {} + dispose() {} + setVisual() {} + setMood() {} + setSpeakAmplitude() {} + flare() {} + }, +})) + +// Imported after the mock is registered. +const { GhostCanvas } = await import('./GhostCanvas') + +function Seance() { + return ( +
+ + +
+ ) +} + +describe('GhostCanvas', () => { + beforeEach(() => { + construct.mockReset() + construct.mockImplementation(() => {}) + }) + + it('renders the canvas when the scene builds', () => { + const { container } = render() + expect(container.querySelector('canvas')).toBeTruthy() + expect(container.querySelector('.ghost-canvas-fallback')).toBeNull() + }) + + it('keeps the rest of the séance alive when WebGL is unavailable', () => { + construct.mockImplementation(() => { + throw new Error('THREE.WebGLRenderer: Error creating WebGL context.') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // The assertion that matters: this render must not throw. Before the fix + // the error escaped the effect and tore down the whole tree. + const { container } = render() + + // Everything a seeker actually needs is still on screen. + expect(screen.getByRole('button', { name: 'ask' })).toBeTruthy() + expect(container.querySelector('.ghost-canvas-fallback')).toBeTruthy() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('does not show the fallback glow when the scene is fine', () => { + const { container } = render() + expect(container.querySelector('.ghost-canvas-fallback')).toBeNull() + expect(construct).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/three/GhostCanvas.tsx b/frontend/src/three/GhostCanvas.tsx index f754bbe..a67b39c 100644 --- a/frontend/src/three/GhostCanvas.tsx +++ b/frontend/src/three/GhostCanvas.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { GhostScene } from './GhostScene' import type { GhostMood, GhostVisualState } from './GhostScene' import { hauntBus } from '../lib/haunting' @@ -18,12 +18,36 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv const ampRef = useRef(getAmplitude) ampRef.current = getAmplitude + // The apparition is atmosphere. It must never be able to take the séance + // down with it — and it could: `new GhostScene()` constructs a + // THREE.WebGLRenderer, which THROWS when a WebGL context cannot be created. + // An exception raised in an effect with no error boundary above it unmounts + // the entire React root, so a seeker on a device with no WebGL got a + // completely blank page instead of a séance. Not a missing ghost — nothing + // at all: no modes, no ask field, no transcript. + // + // Observed for real: headless Chrome with no GPU renders `#root` with zero + // children and the console reading "THREE.WebGLRenderer: Error creating + // WebGL context". The same happens on any device where the GPU is + // blocklisted, hardware acceleration is switched off, or the driver is too + // old — which is a real slice of phones, not a hypothetical. + const [failed, setFailed] = useState(false) + useEffect(() => { const canvas = canvasRef.current if (!canvas) return - const scene = new GhostScene(canvas) + let scene: GhostScene + try { + scene = new GhostScene(canvas) + scene.start() + } catch (err) { + // Deliberately swallowed, not rethrown: everything else on this page + // works perfectly well without a 3D apparition. + console.warn('[ghost] no apparition on this device; the séance continues', err) + setFailed(true) + return + } sceneRef.current = scene - scene.start() const ampTimer = window.setInterval(() => { scene.setSpeakAmplitude(ampRef.current?.() ?? 0) }, 50) @@ -47,7 +71,24 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv return (
+ {/* The canvas element stays mounted even after a failure: the ref must + survive for a later remount to retry, and an empty canvas is + invisible anyway. The fallback simply sits behind it. */} + {failed && ( +
+ )}
) }