Files
supabase/apps/www/components/Globe.tsx
c3ccf9179f fix(www): skip the careers-page globe when WebGL is unavailable (#50350)
## Summary
- `cobe`'s `createGlobe()` resolves the canvas context internally as
`getContext('webgl2') || getContext('webgl') || 'experimental-webgl'` (a
context-name *string*, not an actual context). When both `webgl2` and
`webgl` are unavailable, it falls through to that string, but modern
browsers no longer support the `experimental-webgl` context name, so the
resulting context is `null` and cobe crashes internally reading
`.enable(...)` on it.
- `apps/www/components/Globe.tsx` (used only on `/careers`) had no guard
for this, so a browser/device with WebGL disabled or unavailable (GPU
blocklist, corporate policy, privacy setting) crashes the entire careers
page via `globalErrorBoundary`.
- Fix: detect WebGL support ourselves (`getContext('webgl2') ||
getContext('webgl')`) before mounting the globe, and skip rendering it
(no globe, rest of the page renders fine) when unsupported.

## Evidence (Sentry, past week)
- [WWW-41](https://supabase.sentry.io/issues/7731263201/) — `TypeError:
Cannot read properties of null (reading 'enable')` on `/careers`, leaf
frame inside `cobe`/`phenomenon`.

## Test plan
- [ ] Manually confirmed `Globe.tsx` is only imported by
`apps/www/pages/careers.tsx`
- [ ] No automated test added (this is a purely decorative,
non-interactive canvas element with no existing test coverage); happy to
add one if reviewers want it

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01RUrmUfMBpPqkgerh9onNTM

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RUrmUfMBpPqkgerh9onNTM)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-09-14 13:20:02 -06:00

103 lines
2.9 KiB
TypeScript

import createGlobe from 'cobe'
import { useTheme } from 'next-themes'
import { useEffect, useRef } from 'react'
// cobe crashes internally when neither webgl2 nor webgl is available instead
// of failing gracefully, so we check ourselves before mounting it.
const isWebGLAvailable = (canvas: HTMLCanvasElement) => {
try {
return !!(canvas.getContext('webgl2') || canvas.getContext('webgl'))
} catch {
return false
}
}
const Globe = () => {
const { resolvedTheme } = useTheme()
const canvasRef = useRef<any | null>(null)
useEffect(() => {
if (!canvasRef.current || !isWebGLAvailable(canvasRef.current)) {
return
}
let rotation = 0
let width = 0
let previousWidth = 0
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = Math.round(entry.contentRect.width)
}
})
if (canvasRef.current) {
resizeObserver.observe(canvasRef.current)
width = Math.round(canvasRef.current.getBoundingClientRect().width)
}
const cobe = createGlobe(canvasRef.current, {
devicePixelRatio: 2,
width: width * 2,
height: width * 2,
phi: 0,
theta: 0.3,
dark: resolvedTheme?.includes('dark') ? 1 : 0,
diffuse: 3,
scale: 1,
opacity: 0.8,
mapSamples: 20000,
mapBrightness: 4,
baseColor: [255 / 255, 255 / 255, 255 / 255],
markerColor: [62 / 255, 207 / 255, 142 / 255],
glowColor: [255 / 255, 255 / 255, 255 / 255],
markers: [
{ location: [53.4084, 2.9916], size: 0.06 },
{ location: [1.3521, 103.8198], size: 0.06 },
{ location: [-40.9006, 174.886], size: 0.06 },
{ location: [14.0583, 108.2772], size: 0.06 },
{ location: [37.7749, -122.4194], size: 0.06 },
{ location: [41.3874, 2.1686], size: 0.06 },
{ location: [49.2827, -123.1207], size: 0.06 },
{ location: [-36.9848, 143.3906], size: 0.06 },
{ location: [42.3601, -71.0589], size: 0.06 },
{ location: [52.52, 13.405], size: 0.06 },
{ location: [33.749, -84.388], size: 0.06 },
{ location: [35.6762, 139.6503], size: 0.06 },
{ location: [9.19, -75.0152], size: 0.06 },
{ location: [-25.2521, -52.0215], size: 0.06 },
],
onRender: (state) => {
state.phi = rotation
rotation += 0.0025
if (width !== previousWidth) {
state.width = width * 2
state.height = width * 2
previousWidth = width
}
},
})
const timeoutId = setTimeout(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = '0.8'
}
}, 10)
return () => {
resizeObserver.disconnect()
clearTimeout(timeoutId)
cobe.destroy()
}
}, [resolvedTheme])
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full opacity-0 transition-opacity object-contain"
/>
)
}
export default Globe