From b8b65e253d381da75dbc883e9837b987d48a435a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 11 Jun 2026 15:44:38 -0700 Subject: [PATCH] [eric] apps: throttle app-list nav + gate WebGL placeholder, fix rapid-app-switch GPU crash --- .../src/app/components/Layout/AppShell.tsx | 27 ++++++++++++++++++- frontend/src/app/pages/Views/ViewEditor.tsx | 4 +-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 0c9ba672..cfa7d09f 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -77,6 +77,31 @@ 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(null); + const appNavTimer = useRef | null>(null); + const APP_NAV_IDLE_MS = 400; + const APP_NAV_TRAILING_MS = 320; + 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]); + 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. @@ -990,7 +1015,7 @@ const AppShell: React.FC = () => { return ( navigate(`/apps/${app.id}`)} + onClick={() => navigateToApp(app.id)} sx={{ display: 'flex', alignItems: 'center', diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index e6718d16..eb49b235 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -1261,8 +1261,8 @@ const ViewEditor: React.FC = ({ output }) => { onContentLoad={onIframeContentLoad} /> )} - {/* Placeholder fades via opacity (never unmounts) so the PixelBlast canvas runs continuously. */} - {placeholderMounted && ( + {/* 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 && placeholderMounted && (