From 9d01f8df77cfa9217eb52224303ee5274caeaeec Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 13 Nov 2026 18:16:07 -0800 Subject: [PATCH] Show no notice for a lost context on a page that must not reload, and log each render error once An inline panel that lost its WebGL context showed the panel runtime's own notice and, over it, an alert dialog with no buttons that it could not act on. With allowReload false a lost context now clears the notice and shows none, as the docs already said. keepPanelRendering no longer logs render errors itself: the render failure notice logs each one. Name the Cesium private fields finishRaise and reportThrown rely on, and the version they were checked against. --- docs/CURRENT-STATE.md | 10 ++++---- src/app/embed.js | 23 ++----------------- src/app/embed.test.mjs | 43 ++++++++++++----------------------- src/ui/renderFailure.js | 15 ++++++++++-- src/ui/renderFailure.test.mjs | 7 ++++-- 5 files changed, 40 insertions(+), 58 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a2fa3502d..07df2ea24 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2482,10 +2482,12 @@ Pitfalls: its buttons are 44 px tall on touch screens. An inline conversation panel never reloads or navigates its host's page: it -offers Retry after a render error only, and nothing after a lost context, where -the panel runtime's own notice applies. Its timer (`keepPanelRendering()`) -draws only while the render loop is on. An error a timer frame throws is caught -there: it has already been raised as a render error. +offers Retry after a render error only, and no notice after a lost context, +where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`). +Its timer (`keepPanelRendering()`) draws only while the render loop is on and +logs nothing itself, so each render error is logged once, by the notice. An +error a timer frame throws is caught there: it has already been raised as a +render error. ## Map Source control ownership diff --git a/src/app/embed.js b/src/app/embed.js index fb608d365..32541f85a 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -94,17 +94,6 @@ export function panelAwareVisibility(doc = globalThis.document) { const PANEL_FRAME_MS = 33; const MISSED_FRAMES_MS = 250; -/** A render error's details, including the plain objects workers report. */ -function describeError(error) { - if (error instanceof Error) - return `${error.name}: ${error.message}\n${error.stack}`; - try { - return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {})); - } catch { - return String(error); - } -} - /** * Keep the globe drawing in an inline panel. Some hosts report a panel on * screen as hidden, which stops the browser's animation frames and with them @@ -123,15 +112,8 @@ export function keepPanelRendering( }; frameRequest = windowRef.requestAnimationFrame(onFrame); // Cesium stops its render loop after a render error, and the timer stops - // with it; Retry restarts both. Report the error in full: workers report - // plain objects, which Cesium prints as [object Object]. - const removeErrorListener = viewer.scene?.renderError?.addEventListener( - (_scene, error) => - console.error( - "[God's Eye View panel] render error:", - describeError(error), - ), - ); + // with it; Retry restarts both. The render failure notice logs the error + // (src/ui/renderFailure.js). const timer = windowRef.setInterval(() => { if ( viewer.useDefaultRenderLoop === false || @@ -150,7 +132,6 @@ export function keepPanelRendering( return () => { windowRef.clearInterval(timer); windowRef.cancelAnimationFrame(frameRequest); - removeErrorListener?.(); }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index cee064278..e4e8a1d22 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -457,8 +457,7 @@ test('a panel keeps drawing from a timer while animation frames stop', () => { assert.equal(frame, null); }); -test('a panel reports a render error in full and stops drawing', () => { - let listener = null; +test('a panel stops drawing while its render loop is stopped', () => { let tick = null; const windowRef = { requestAnimationFrame: () => 1, @@ -467,33 +466,19 @@ test('a panel reports a render error in full and stops drawing', () => { clearInterval() {}, }; const drawn = []; - const viewer = { - resize() {}, - render: () => drawn.push('render'), - scene: { - renderError: { - addEventListener: (fn) => ((listener = fn), () => (listener = null)), - }, - }, - }; - const logged = []; - const original = console.error; - console.error = (...args) => logged.push(args.join(' ')); - try { - let clock = 0; - const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); - // Cesium stops its render loop after a render error. - viewer.useDefaultRenderLoop = false; - listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' }); - assert.match(logged[0], /render error: .*"message":"worker failed"/); - clock = 1000; - tick(); - assert.deepEqual(drawn, []); - stop(); - assert.equal(listener, null); - } finally { - console.error = original; - } + const viewer = { resize() {}, render: () => drawn.push('render') }; + let clock = 0; + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + // Cesium stops its render loop after a render error. + viewer.useDefaultRenderLoop = false; + clock = 1000; + tick(); + assert.deepEqual(drawn, []); + // Retry restarts it, and the timer draws again. + viewer.useDefaultRenderLoop = true; + tick(); + assert.deepEqual(drawn, ['render']); + stop(); }); test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => { diff --git a/src/ui/renderFailure.js b/src/ui/renderFailure.js index 7600a8dc7..b593f57f4 100644 --- a/src/ui/renderFailure.js +++ b/src/ui/renderFailure.js @@ -7,7 +7,8 @@ import { appRootUrl } from '../share/shareLinkPath.js'; * Show a recoverable notice when the globe stops drawing, instead of a black * globe behind working controls. Retry restarts the render loop, or reloads * after a lost WebGL context; Reset opens the app's root address. With - * `allowReload: false`, Reset is dropped, and so is Retry after a context loss. + * `allowReload: false`, Reset is dropped and a lost context shows no notice: + * only a reload draws again, and the page says so itself. * Returns a function that removes the notice. */ export function installRenderFailureNotice( @@ -78,7 +79,7 @@ export function installRenderFailureNotice( notice = { reason, detail, - retry: contextLost && !allowReload ? null : retry, + retry, reset: allowReload ? reset : null, }; store.publishRenderFailure(notice); @@ -97,6 +98,12 @@ export function installRenderFailureNotice( contextLost = true; // No frame can clear this one; only a reload draws again. stopWatchingFrames(); + // A page that must not reload has nothing to offer: an inline panel + // shows its own lost-context notice (src/app/globePanelRuntime.js). + if (!allowReload) { + clear(); + return; + } present(''); }; @@ -155,6 +162,10 @@ function frameEvents(viewer) { /** * Finish a raise a throwing listener cut short. Until a raise ends, Cesium's * `Event` only queues added and removed listeners; apply that queue. + * These are Cesium's private fields (`_invokingListeners`, `_toAdd`, + * `_toRemove`, `_listeners`, and the widget's `_renderLoopRunning`), checked + * against cesium 1.138 / @cesium/engine 22.3.0; the render loop fixture tests + * drive the real classes and fail when an upgrade renames them. */ function finishRaise(event) { if (!event?._invokingListeners) return; diff --git a/src/ui/renderFailure.test.mjs b/src/ui/renderFailure.test.mjs index 43f83dff2..913348740 100644 --- a/src/ui/renderFailure.test.mjs +++ b/src/ui/renderFailure.test.mjs @@ -156,9 +156,12 @@ test('a page that must not reload offers neither reload nor reset', (t) => { f.frame(new Error('boom')); assert.equal(typeof f.store.renderFailure.retry, 'function'); assert.equal(f.store.renderFailure.reset, null); + // Only a reload draws again, which this page must not offer: the notice + // goes, and the page's own lost-context notice speaks instead. f.loseContext(); - assert.equal(f.store.renderFailure.retry, null); - assert.equal(f.store.renderFailure.reset, null); + assert.equal(f.store.renderFailure, null); + f.frame(new Error('Expected width to be greater than 0')); + assert.equal(f.store.renderFailure, null); }); test('uninstall removes the listeners and the notice', (t) => {