[eric] views: CSS loading placeholder kills the WebGL-context churn that crashed app-switching (no chat anchoring the GPU); drop the click-swallowing nav throttle

This commit is contained in:
ciregenz
2026-06-12 17:14:59 -07:00
parent 65a5138bfa
commit 23d8247778
3 changed files with 37 additions and 196 deletions
@@ -77,31 +77,14 @@ const AppShell: React.FC = () => {
};
return fn as typeof navigateRaw;
}, [navigateRaw]);
// Rapid app-list clicks each mount a fresh ViewEditor (its WebGL placeholder + preview webview); churning heavy WebGL apps faster than the GPU recycles surfaces kills the GPU/host renderer and takes the whole app down. Open the first click at once (idle = no click in 400ms), then during a burst fire NOTHING until the clicks stop, so a spam-burst lands on at most two apps instead of a dozen.
const lastAppClickAt = useRef(0);
const pendingAppNav = useRef<string | null>(null);
const appNavTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const APP_NAV_IDLE_MS = 400;
const APP_NAV_TRAILING_MS = 320;
// Navigate to an app instantly on click. The old debounce here swallowed clicks the
// user could see (felt broken) and never actually fixed the crash, since letting each
// app load defeats the debounce anyway. The real GPU-churn source (the WebGL loading
// placeholder) is now CSS, and the 250ms preview gate still skips webviews for apps
// switched-past too fast, so instant navigation is safe.
const navigateToApp = useCallback((id: string) => {
const now = Date.now();
const idle = now - lastAppClickAt.current >= APP_NAV_IDLE_MS;
lastAppClickAt.current = now;
if (appNavTimer.current) clearTimeout(appNavTimer.current);
if (idle) {
pendingAppNav.current = null;
navigate(`/apps/${id}`);
return;
}
pendingAppNav.current = id;
appNavTimer.current = setTimeout(() => {
appNavTimer.current = null;
const target = pendingAppNav.current;
pendingAppNav.current = null;
if (target) navigate(`/apps/${target}`);
}, APP_NAV_TRAILING_MS);
navigate(`/apps/${id}`);
}, [navigate]);
useEffect(() => () => { if (appNavTimer.current) clearTimeout(appNavTimer.current); }, []);
const location = useLocation();
// React Router (HashRouter) stores a monotonic index in history state. location
// re-renders on every nav, by which point window.history.state.idx is updated.
@@ -1,9 +1,11 @@
// Bayer-dithering pixel-blast background; same shader as webapp_template splash so cold-start phases match.
// Brand-tinted pixel-dither loading background. CSS only, on purpose: this is the
// app-card loading placeholder and it mounts/unmounts every time you open an app, so
// the old WebGL2 version churned GL contexts faster than the GPU could recycle them and
// killed the whole renderer under app-list spam (worst with no chat anchoring the GPU
// process). A tiled dot-grid + gentle pulse reads the same at a glance with zero GPU
// context churn. Same props as before so call sites don't change.
import React, { useEffect, useRef } from 'react';
// Module-level epoch so remounts pick up where the previous mount left off in the noise loop.
const PIXEL_BLAST_EPOCH = performance.now();
import React from 'react';
interface PixelBlastProps {
color?: string;
@@ -20,174 +22,30 @@ const PixelBlast: React.FC<PixelBlastProps> = ({
edgeFade = 0.3,
style,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext('webgl2', { antialias: true, alpha: true });
if (!gl) return;
const VS = `#version 300 es
in vec2 a; void main() { gl_Position = vec4(a, 0.0, 1.0); }`;
const FS = `#version 300 es
precision highp float;
out vec4 fragColor;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixelSize;
uniform vec3 uColor;
uniform float uEdgeFade;
float Bayer2(vec2 a){ a = floor(a); return fract(a.x/2.0 + a.y*a.y*0.75); }
#define Bayer4(a) (Bayer2(0.5*(a))*0.25 + Bayer2(a))
#define Bayer8(a) (Bayer4(0.5*(a))*0.25 + Bayer2(a))
float hash11(float n){ return fract(sin(n)*43758.5453); }
float vnoise(vec3 p){
vec3 ip = floor(p); vec3 fp = fract(p);
float n000 = hash11(dot(ip + vec3(0,0,0), vec3(1.0,57.0,113.0)));
float n100 = hash11(dot(ip + vec3(1,0,0), vec3(1.0,57.0,113.0)));
float n010 = hash11(dot(ip + vec3(0,1,0), vec3(1.0,57.0,113.0)));
float n110 = hash11(dot(ip + vec3(1,1,0), vec3(1.0,57.0,113.0)));
float n001 = hash11(dot(ip + vec3(0,0,1), vec3(1.0,57.0,113.0)));
float n101 = hash11(dot(ip + vec3(1,0,1), vec3(1.0,57.0,113.0)));
float n011 = hash11(dot(ip + vec3(0,1,1), vec3(1.0,57.0,113.0)));
float n111 = hash11(dot(ip + vec3(1,1,1), vec3(1.0,57.0,113.0)));
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
float x00 = mix(n000,n100,w.x); float x10 = mix(n010,n110,w.x);
float x01 = mix(n001,n101,w.x); float x11 = mix(n011,n111,w.x);
float y0 = mix(x00,x10,w.y); float y1 = mix(x01,x11,w.y);
return mix(y0,y1,w.z) * 2.0 - 1.0;
}
float fbm2(vec2 uv, float t){
vec3 p = vec3(uv * 2.0, t);
float amp = 1.0; float freq = 1.0; float sum = 1.0;
for (int i = 0; i < 5; i++) {
sum += amp * vnoise(p * freq);
freq *= 1.25;
}
return sum * 0.5 + 0.5;
}
void main(){
// 137.5 (golden-ratio angle) offsets off the FBM singularities so the center axes don't bright-stripe.
vec2 fragCoord = gl_FragCoord.xy - uResolution * 0.5 + vec2(137.5, 137.5);
float aspectRatio = uResolution.x / uResolution.y;
float cellPixelSize = 8.0 * uPixelSize;
vec2 cellId = floor(fragCoord / cellPixelSize);
vec2 cellCoord = cellId * cellPixelSize;
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
float base = fbm2(uv, uTime * 0.05);
// Density tuned to read as ambient texture, not lumpy blob. -0.55
// produced visible bright clusters around the noise peaks; pulling
// back to -0.62 evens out the field and the brand color reads as a
// diffuse haze instead of localized bright spots.
base = base * 0.5 - 0.62;
float feed = base + 0.5 * 0.3;
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
float bw = step(0.5, feed + bayer);
vec2 norm = gl_FragCoord.xy / uResolution;
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
float fade = smoothstep(0.0, uEdgeFade, edge);
float M = bw * fade;
vec3 srgb = mix(uColor * 12.92, 1.055 * pow(uColor, vec3(1.0/2.4)) - 0.055, step(0.0031308, uColor));
fragColor = vec4(srgb, M);
}`;
function compile(type: number, src: string): WebGLShader | null {
const s = gl!.createShader(type)!;
gl!.shaderSource(s, src);
gl!.compileShader(s);
if (!gl!.getShaderParameter(s, gl!.COMPILE_STATUS)) return null;
return s;
}
const vs = compile(gl.VERTEX_SHADER, VS);
const fs = compile(gl.FRAGMENT_SHADER, FS);
if (!vs || !fs) return;
const prog = gl.createProgram()!;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return;
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1]), gl.STATIC_DRAW);
const aLoc = gl.getAttribLocation(prog, 'a');
gl.enableVertexAttribArray(aLoc);
gl.vertexAttribPointer(aLoc, 2, gl.FLOAT, false, 0, 0);
const uResolution = gl.getUniformLocation(prog, 'uResolution');
const uTime = gl.getUniformLocation(prog, 'uTime');
const uPixelSize = gl.getUniformLocation(prog, 'uPixelSize');
const uColor = gl.getUniformLocation(prog, 'uColor');
const uEdgeFade = gl.getUniformLocation(prog, 'uEdgeFade');
const hex = color.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
gl.uniform3f(uColor, r, g, b);
gl.uniform1f(uEdgeFade, edgeFade);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
function resize() {
const w = Math.floor(canvas!.clientWidth * dpr);
const h = Math.floor(canvas!.clientHeight * dpr);
if (canvas!.width !== w || canvas!.height !== h) {
canvas!.width = w;
canvas!.height = h;
}
gl!.viewport(0, 0, w, h);
gl!.uniform2f(uResolution, w, h);
gl!.uniform1f(uPixelSize, pixelSize * dpr);
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// Use the module-level epoch so animation time is continuous across
// mounts. New cards opened later in the session pick up at the
// current epoch-relative time rather than restarting from 0.
let raf = 0;
const MIN_FRAME_MS = 1000 / 30;
let lastFrameAt = 0;
function frame() {
const now = performance.now();
if (now - lastFrameAt >= MIN_FRAME_MS) {
lastFrameAt = now;
const t = (now - PIXEL_BLAST_EPOCH) / 1000;
gl!.uniform1f(uTime, t * speed);
gl!.clearColor(0.10, 0.10, 0.10, 1);
gl!.clear(gl!.COLOR_BUFFER_BIT);
gl!.drawArrays(gl!.TRIANGLES, 0, 6);
}
raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
try { gl.getExtension('WEBGL_lose_context')?.loseContext(); } catch (_) {}
};
}, [color, pixelSize, speed, edgeFade]);
// 8x the pixel size matches the old shader's cell size; the dot grid reads as the
// same diffuse pixel haze. Slower speed -> longer, calmer pulse.
const tile = Math.max(4, pixelSize * 2);
const dur = `${Math.max(2, 3 / Math.max(speed, 0.1))}s`;
const fadePct = Math.round((1 - Math.min(Math.max(edgeFade, 0), 0.5)) * 100);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
...style,
}}
aria-label="OpenSwarm idle background"
/>
<>
<style>{`@keyframes pixelblast-pulse { 0%,100% { opacity: 0.35 } 50% { opacity: 0.7 } }`}</style>
<div
aria-label="OpenSwarm idle background"
style={{
position: 'absolute',
inset: 0,
backgroundColor: '#1a1a1a',
backgroundImage: `radial-gradient(${color} 1px, transparent 1.4px)`,
backgroundSize: `${tile}px ${tile}px`,
animation: `pixelblast-pulse ${dur} ease-in-out infinite`,
WebkitMaskImage: `radial-gradient(ellipse at center, #000 ${fadePct}%, transparent 100%)`,
maskImage: `radial-gradient(ellipse at center, #000 ${fadePct}%, transparent 100%)`,
...style,
}}
/>
</>
);
};
+2 -2
View File
@@ -906,7 +906,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
return () => window.clearTimeout(t);
}, [captureAppThumbnail]);
// Keep PixelBlast mounted across transient gate flips; unmounting rebuilds the GL context and the user reads it as the animation restarting.
// Keep the placeholder mounted across transient gate flips so the loading animation doesn't flicker/restart.
const placeholderVisible = showInstallPlaceholder || !iframePainted;
const [placeholderMounted, setPlaceholderMounted] = useState(placeholderVisible);
useEffect(() => {
@@ -1261,7 +1261,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
onContentLoad={onIframeContentLoad}
/>
)}
{/* previewSettled gate: PixelBlast spins up a WebGL2 context, and spinning one up per fast-switched-past app churns GL contexts faster than the renderer can recycle them, which crashes the host renderer (and the whole app) under app-list spam. Same 250ms gate as the webview. */}
{/* previewSettled gate: skip mounting the preview webview for apps the user switches past faster than 250ms, so blowing through the app list never spawns a pile of webview renderers. (The loading placeholder is CSS now, so it no longer churns GL contexts.) */}
{previewSettled && placeholderMounted && (
<Box
sx={{