fix: no WebGL took down the entire app, not just the ghost
Found by actually looking at the deployed site instead of trusting the test suite. Rendered /seance in a browser with no GPU and got a completely blank page — `#root` with ZERO children, console reading "THREE.WebGLRenderer: Error creating WebGL context". Not a missing apparition: no modes, no ask field, no transcript, nothing. Desktop and mobile alike. Cause: `new GhostScene()` constructs a THREE.WebGLRenderer, which throws when a context cannot be created. The exception escaped its effect, and with no error boundary above it React unmounted the whole root. The apparition is atmosphere and must never be able to do that. It is now constructed inside a try/catch that degrades to a quiet violet glow in the same palette, so the stage reads as occupied rather than broken, and the séance carries on completely. This is not exotic. Any device with a blocklisted GPU, hardware acceleration switched off, or a driver too old for the browser hits exactly this path — and the failure mode was the worst available: a blank page with no explanation. Proven end to end, not just in tests: same browser, same URL, before the fix `rootChildren: 0` and an uncaught error; after it `rootChildren: 4`, zero uncaught errors, and the body reading WIRE GHOST / EVP / SPIRIT RADIO / OUIJA / FIELD / LENS. The screenshots confirm the phone shell and the untouched desktop layout both render. 432 tests (3 new, which fail without the guard). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
76
frontend/src/three/GhostCanvas.test.tsx
Normal file
76
frontend/src/three/GhostCanvas.test.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<GhostCanvas visual={null} mood="idle" />
|
||||||
|
<button type="button">ask</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GhostCanvas', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
construct.mockReset()
|
||||||
|
construct.mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the canvas when the scene builds', () => {
|
||||||
|
const { container } = render(<Seance />)
|
||||||
|
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(<Seance />)
|
||||||
|
|
||||||
|
// 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(<Seance />)
|
||||||
|
expect(container.querySelector('.ghost-canvas-fallback')).toBeNull()
|
||||||
|
expect(construct).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { GhostScene } from './GhostScene'
|
import { GhostScene } from './GhostScene'
|
||||||
import type { GhostMood, GhostVisualState } from './GhostScene'
|
import type { GhostMood, GhostVisualState } from './GhostScene'
|
||||||
import { hauntBus } from '../lib/haunting'
|
import { hauntBus } from '../lib/haunting'
|
||||||
@@ -18,12 +18,36 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv
|
|||||||
const ampRef = useRef(getAmplitude)
|
const ampRef = useRef(getAmplitude)
|
||||||
ampRef.current = 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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
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
|
sceneRef.current = scene
|
||||||
scene.start()
|
|
||||||
const ampTimer = window.setInterval(() => {
|
const ampTimer = window.setInterval(() => {
|
||||||
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
|
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
|
||||||
}, 50)
|
}, 50)
|
||||||
@@ -47,7 +71,24 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
<div className={className} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||||
|
{/* 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. */}
|
||||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
|
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
|
||||||
|
{failed && (
|
||||||
|
<div
|
||||||
|
className="ghost-canvas-fallback"
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
// A slow breathing glow in the same violet the apparition uses, so
|
||||||
|
// the stage reads as occupied rather than broken.
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 50% 55%, rgba(178,107,255,0.16) 0%, rgba(178,107,255,0.05) 38%, transparent 70%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user