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:
Indiana
2026-08-02 03:11:02 +00:00
parent 8fd60ed204
commit b2b0dc319a
2 changed files with 120 additions and 3 deletions

View 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)
})
})

View File

@@ -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>
) )
} }